Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: make secret key configurable and reorganize settings #668

Merged
merged 5 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- New `SECRET_KEY_PATH` and `SECRET_KEY_LOAD_AND_STORE` environment variables ([#668](https://github.com/Substra/substra-backend/pull/668))

## [0.38.0](https://github.com/Substra/substra-backend/releases/tag/0.38.0) 2023-06-12

### Added
Expand Down
108 changes: 9 additions & 99 deletions backend/backend/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,78 +12,25 @@

import json
import os
import pathlib
import sys
from datetime import timedelta

import structlog
from django.core.files.storage import FileSystemStorage

from libs.gen_secret_key import write_secret_key
from substrapp.compute_tasks.errors import CeleryRetryError

from .deps.celery import *
from .deps.jwt import *
SdgJlbl marked this conversation as resolved.
Show resolved Hide resolved
from .deps.org import *

TRUE_VALUES = {"t", "T", "y", "Y", "yes", "YES", "true", "True", "TRUE", "on", "On", "ON", "1", 1, True}


def to_bool(value):
return value in TRUE_VALUES


def build_broker_url(user: str, password: str, host: str, port: str) -> str:
"""Builds a redis connection string

Args:
user (str): redis user
password (str): redis password
host (str): redis hostname
port (str): redis port

Returns:
str: a connection string of the form "redis://user:password@hostname:port//"
"""
conn_info = ""
conn_port = ""
if user and password:
conn_info = f"{user}:{password}@"
if port:
conn_port = f":{port}"
return f"redis://{conn_info}{host}{conn_port}//"


# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_ROOT = os.path.dirname(BASE_DIR)

sys.path.append(PROJECT_ROOT)
sys.path.append(os.path.normpath(os.path.join(PROJECT_ROOT, "libs")))

# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_FILE = os.path.normpath(os.path.join(PROJECT_ROOT, "SECRET"))

# KEY CONFIGURATION
# Try to load the SECRET_KEY from our SECRET_FILE. If that fails, then generate
# a random SECRET_KEY and save it into our SECRET_FILE for future loading. If
# everything fails, then just raise an exception.
try:
SECRET_KEY = pathlib.Path(SECRET_FILE).read_text().strip()
except IOError:
try:
SECRET_KEY = write_secret_key(SECRET_FILE)
except IOError:
raise Exception(f"Cannot open file `{SECRET_FILE}` for writing.")
# END KEY CONFIGURATION
from .deps.path import *
from .deps.secret_key import *
from .deps.utils import to_bool

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

SUBPATH = ""
if os.environ.get("SUBPATH"):
SUBPATH = os.environ.get("SUBPATH").strip("/") + "/"

SUBPATH = os.environ.get("SUBPATH", "") # prefix for backend endpoints
if SUBPATH:
SUBPATH = SUBPATH.strip("/") + "/"

ALLOWED_HOSTS = ["127.0.0.1", "::1", "localhost"] + json.loads(os.environ.get("ALLOWED_HOSTS", "[]"))
if os.environ.get("HOST_IP"):
Expand Down Expand Up @@ -247,40 +194,6 @@ def build_broker_url(user: str, password: str, host: str, port: str) -> str:
"COMPUTE_POD_STARTUP_TIMEOUT_SECONDS": int(os.environ.get("COMPUTE_POD_STARTUP_TIMEOUT_SECONDS", 300)),
}

CELERY_BROKER_USER = os.environ.get("CELERY_BROKER_USER")
CELERY_BROKER_PASSWORD = os.environ.get("CELERY_BROKER_PASSWORD")
CELERY_BROKER_HOST = os.environ.get("CELERY_BROKER_HOST", "localhost")
CELERY_BROKER_PORT = os.environ.get("CELERY_BROKER_PORT", "5672")
CELERY_BROKER_URL = build_broker_url(CELERY_BROKER_USER, CELERY_BROKER_PASSWORD, CELERY_BROKER_HOST, CELERY_BROKER_PORT)

CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_RESULT_SERIALIZER = "json"
CELERY_TASK_SERIALIZER = "json"
CELERY_TASK_TRACK_STARTED = True # since 4.0

# With these settings, tasks will be retried for up to a maximum of 127 minutes.
#
# max_wait = CELERY_TASK_RETRY_BACKOFF * sum(2 ** n for n in range(CELERY_TASK_MAX_RETRIES))
# = 60 * (1 + 2 + 4 + 8 + 16 + 32 + 64)
# = 127 minutes
#
# Since jitter is enabled, the actual cumulative wait can be much less than max_wait. From the doc
# (https://docs.celeryproject.org/en/stable/userguide/tasks.html#Task.retry_jitter):
#
# > If this option is set to True, the delay value calculated by retry_backoff is treated as a maximum, and the actual
# > delay value will be a random number between zero and that maximum.
CELERY_TASK_AUTORETRY_FOR = (CeleryRetryError,)
CELERY_TASK_MAX_RETRIES = int(os.environ.get("CELERY_TASK_MAX_RETRIES", 7))
CELERY_TASK_RETRY_BACKOFF = int(os.environ.get("CELERY_TASK_RETRY_BACKOFF", 60)) # time in seconds
CELERY_TASK_RETRY_BACKOFF_MAX = int(os.environ.get("CELERY_TASK_RETRY_BACKOFF_MAX", 64 * 60))
CELERY_TASK_RETRY_JITTER = True

CELERY_WORKER_CONCURRENCY = int(os.environ.get("CELERY_WORKER_CONCURRENCY", 1))
CELERY_BROADCAST = f"{ORG_NAME}.broadcast"

CELERYBEAT_MAXIMUM_IMAGES_TTL = os.environ.get("CELERYBEAT_MAXIMUM_IMAGES_TTL", 7 * 24 * 3600)
CELERYBEAT_FLUSH_EXPIRED_TOKENS_TASK_PERIOD = os.environ.get("CELERYBEAT_FLUSH_EXPIRED_TOKENS_TASK_PERIOD", 24 * 3600)

WORKER_PVC_IS_HOSTPATH = to_bool(os.environ.get("WORKER_PVC_IS_HOSTPATH"))
WORKER_PVC_DOCKER_CACHE = os.environ.get("WORKER_PVC_DOCKER_CACHE")
WORKER_PVC_SUBTUPLE = os.environ.get("WORKER_PVC_SUBTUPLE")
Expand Down Expand Up @@ -422,9 +335,6 @@ def build_broker_url(user: str, password: str, host: str, port: str) -> str:

CONTENT_DISPOSITION_HEADER = {}

# To encode unique jwt token generated with reset password request
RESET_JWT_SIGNATURE_ALGORITHM = "HS256"

# Username of additional Django user representing user external to organization
EXTERNAL_USERNAME = "external"

Expand Down
3 changes: 3 additions & 0 deletions backend/backend/settings/deps/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
self-contained categorized settings
"""
65 changes: 65 additions & 0 deletions backend/backend/settings/deps/celery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Task broker settings
"""

import os

from substrapp.compute_tasks.errors import CeleryRetryError

from .org import ORG_NAME


def build_broker_url(user: str, password: str, host: str, port: str) -> str:
"""Builds a redis connection string

Args:
user (str): redis user
password (str): redis password
host (str): redis hostname
port (str): redis port

Returns:
str: a connection string of the form "redis://user:password@hostname:port//"
"""
conn_info = ""
conn_port = ""
if user and password:
conn_info = f"{user}:{password}@"
if port:
conn_port = f":{port}"
return f"redis://{conn_info}{host}{conn_port}//"


CELERY_BROKER_USER = os.environ.get("CELERY_BROKER_USER")
CELERY_BROKER_PASSWORD = os.environ.get("CELERY_BROKER_PASSWORD")
CELERY_BROKER_HOST = os.environ.get("CELERY_BROKER_HOST", "localhost")
CELERY_BROKER_PORT = os.environ.get("CELERY_BROKER_PORT", "5672")
CELERY_BROKER_URL = build_broker_url(CELERY_BROKER_USER, CELERY_BROKER_PASSWORD, CELERY_BROKER_HOST, CELERY_BROKER_PORT)

CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_RESULT_SERIALIZER = "json"
CELERY_TASK_SERIALIZER = "json"
CELERY_TASK_TRACK_STARTED = True # since 4.0

# With these settings, tasks will be retried for up to a maximum of 127 minutes.
#
# max_wait = CELERY_TASK_RETRY_BACKOFF * sum(2 ** n for n in range(CELERY_TASK_MAX_RETRIES))
# = 60 * (1 + 2 + 4 + 8 + 16 + 32 + 64)
# = 127 minutes
#
# Since jitter is enabled, the actual cumulative wait can be much less than max_wait. From the doc
# (https://docs.celeryproject.org/en/stable/userguide/tasks.html#Task.retry_jitter):
#
# > If this option is set to True, the delay value calculated by retry_backoff is treated as a maximum, and the actual
# > delay value will be a random number between zero and that maximum.
CELERY_TASK_AUTORETRY_FOR = (CeleryRetryError,)
CELERY_TASK_MAX_RETRIES = int(os.environ.get("CELERY_TASK_MAX_RETRIES", 7))
CELERY_TASK_RETRY_BACKOFF = int(os.environ.get("CELERY_TASK_RETRY_BACKOFF", 60)) # time in seconds
CELERY_TASK_RETRY_BACKOFF_MAX = int(os.environ.get("CELERY_TASK_RETRY_BACKOFF_MAX", 64 * 60))
CELERY_TASK_RETRY_JITTER = True

CELERY_WORKER_CONCURRENCY = int(os.environ.get("CELERY_WORKER_CONCURRENCY", 1))
CELERY_BROADCAST = f"{ORG_NAME}.broadcast"

CELERYBEAT_MAXIMUM_IMAGES_TTL = os.environ.get("CELERYBEAT_MAXIMUM_IMAGES_TTL", 7 * 24 * 3600)
CELERYBEAT_FLUSH_EXPIRED_TOKENS_TASK_PERIOD = os.environ.get("CELERYBEAT_FLUSH_EXPIRED_TOKENS_TASK_PERIOD", 24 * 3600)
17 changes: 17 additions & 0 deletions backend/backend/settings/deps/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
JSON web tokens
"""

import os
from datetime import timedelta

SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=int(os.environ.get("ACCESS_TOKEN_LIFETIME", 24 * 60))),
"REFRESH_TOKEN_LIFETIME": timedelta(minutes=int(os.environ.get("REFRESH_TOKEN_LIFETIME", 24 * 60 * 7))),
"ROTATE_REFRESH_TOKENS": True,
"AUTH_HEADER_TYPES": ("JWT",),
"BLACKLIST_AFTER_ROTATION": True,
}

# To encode unique jwt token generated with reset password request
RESET_JWT_SIGNATURE_ALGORITHM = "HS256"
6 changes: 3 additions & 3 deletions backend/backend/settings/deps/orchestrator.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import os

from .. import common
from .utils import to_bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


ORCHESTRATOR_HOST = os.environ.get("ORCHESTRATOR_HOST")
ORCHESTRATOR_PORT = os.environ.get("ORCHESTRATOR_PORT")
ORCHESTRATOR_TLS_ENABLED = common.to_bool(os.environ.get("ORCHESTRATOR_TLS_ENABLED"))
ORCHESTRATOR_MTLS_ENABLED = common.to_bool(os.environ.get("ORCHESTRATOR_MTLS_ENABLED"))
ORCHESTRATOR_TLS_ENABLED = to_bool(os.environ.get("ORCHESTRATOR_TLS_ENABLED"))
ORCHESTRATOR_MTLS_ENABLED = to_bool(os.environ.get("ORCHESTRATOR_MTLS_ENABLED"))
ORCHESTRATOR_TLS_SERVER_CACERT_PATH = os.environ.get("ORCHESTRATOR_TLS_SERVER_CACERT_PATH")
ORCHESTRATOR_TLS_CLIENT_CERT_PATH = os.environ.get("ORCHESTRATOR_TLS_CLIENT_CERT_PATH")
ORCHESTRATOR_TLS_CLIENT_KEY_PATH = os.environ.get("ORCHESTRATOR_TLS_CLIENT_KEY_PATH")
Expand Down
9 changes: 9 additions & 0 deletions backend/backend/settings/deps/path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import os
import sys

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_ROOT = os.path.dirname(BASE_DIR)

sys.path.append(PROJECT_ROOT)
sys.path.append(os.path.normpath(os.path.join(PROJECT_ROOT, "libs")))
9 changes: 0 additions & 9 deletions backend/backend/settings/deps/restframework.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import os
from datetime import timedelta

REST_FRAMEWORK = {
"TEST_REQUEST_DEFAULT_FORMAT": "json",
Expand Down Expand Up @@ -36,14 +35,6 @@
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
}

SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=int(os.environ.get("ACCESS_TOKEN_LIFETIME", 24 * 60))),
"REFRESH_TOKEN_LIFETIME": timedelta(minutes=int(os.environ.get("REFRESH_TOKEN_LIFETIME", 24 * 60 * 7))),
"ROTATE_REFRESH_TOKENS": True,
"AUTH_HEADER_TYPES": ("JWT",),
"BLACKLIST_AFTER_ROTATION": True,
}

SPECTACULAR_SETTINGS = {
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAuthenticated"],
}
34 changes: 34 additions & 0 deletions backend/backend/settings/deps/secret_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
SECRET_KEY is built in Django, but also used for signing JWTs
"""

import os
import pathlib
import secrets

from . import path
from .utils import to_bool

SECRET_KEY_PATH = os.environ.get("SECRET_KEY_PATH", os.path.normpath(os.path.join(path.PROJECT_ROOT, "SECRET")))


def _generate_secret_key():
return secrets.token_urlsafe() # uses a "reasonable default" length


_SECRET_KEY_LOAD_AND_STORE = to_bool(
os.environ.get("SECRET_KEY_LOAD_AND_STORE", "False")
) # Whether to load the secret key from file (and write it there if it doesn't exist)

if _SECRET_KEY_LOAD_AND_STORE:
try:
SECRET_KEY = pathlib.Path(SECRET_KEY_PATH).read_text().strip()
except IOError:
try:
SECRET_KEY = _generate_secret_key()
with open(SECRET_KEY_PATH, "w") as fp:
fp.write(SECRET_KEY)
except IOError:
raise Exception(f"Cannot open file `{SECRET_KEY_PATH}` for writing.")
else:
SECRET_KEY = _generate_secret_key()
5 changes: 5 additions & 0 deletions backend/backend/settings/deps/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
TRUE_VALUES = {"t", "T", "y", "Y", "yes", "YES", "true", "True", "TRUE", "on", "On", "ON", "1", 1, True}


def to_bool(value):
return value in TRUE_VALUES
4 changes: 2 additions & 2 deletions backend/backend/settings/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from substrapp.storages.minio import MinioStorage

from .common import *
from .deps.cors import *
from .deps.ledger import *
from .deps.oidc import *
from .deps.org import *
from .deps.restframework import *
from .mods.cors import *
from .mods.oidc import *

DEBUG = True

Expand Down
2 changes: 1 addition & 1 deletion backend/backend/settings/localdev.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .deps.oidc import *
from .mods.oidc import *
from .test import *

# Enable Browsable API
Expand Down
3 changes: 3 additions & 0 deletions backend/backend/settings/mods/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
settings that modify common settings
"""
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""
Add Cross-origin site requests (CORS) restrictions
"""

import json
import os

from .. import common
from ..deps.utils import to_bool

common.INSTALLED_APPS += ("corsheaders",)

Expand All @@ -11,7 +16,7 @@
CORS_ALLOWED_ORIGINS = json.loads(os.environ.get("CORS_ORIGIN_WHITELIST", "[]"))
CORS_ALLOW_ALL_ORIGINS = False
# @CORS_ALLOW_CREDENTIALS: If True cookies can be included in cross site requests. Set this to `True` for frontend auth.
CORS_ALLOW_CREDENTIALS = common.to_bool(os.environ.get("CORS_ALLOW_CREDENTIALS", False))
CORS_ALLOW_CREDENTIALS = to_bool(os.environ.get("CORS_ALLOW_CREDENTIALS", False))
CORS_ALLOW_HEADERS = (
"accept",
"accept-encoding",
Expand Down
Loading