feat: add Django project scaffold, configuration and container

Environment-variable configuration that fails fast, database-only health
check, JSON request logs with a request id, security headers with a strict
CSP, and a multi-stage non-root Docker image with compose file. Ticket 1.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
Ebbe Baß
2026-10-15 13:44:07 +02:00
co-authored by Claude Sonnet 5.5
parent 24de5878ed
commit f8b0584687
31 changed files with 1134 additions and 2 deletions
View File
+118
View File
@@ -0,0 +1,118 @@
"""Typed environment configuration that reports every problem at once (AD-13)."""
from __future__ import annotations
import ipaddress
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
MIN_SECRET_KEY_LENGTH = 32
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
class ConfigError(Exception):
"""Raised with one message that lists every invalid or missing variable.
Messages name variables and the rule they break, never their values.
"""
def __init__(self, problems: list[str]) -> None:
self.problems = problems
super().__init__(self.render(problems))
@staticmethod
def render(problems: list[str]) -> str:
lines = ["Invalid configuration. Fix these environment variables:"]
lines.extend(f" - {p}" for p in problems)
return "\n".join(lines)
@dataclass(frozen=True)
class Config:
secret_key: str
allowed_hosts: list[str]
data_dir: Path
debug: bool
trusted_proxies: list[str]
log_level: str
default_timezone: str
def load_config(environ: Mapping[str, str]) -> Config:
problems: list[str] = []
def raw(name: str) -> str | None:
value = environ.get(name)
if value is None or value.strip() == "":
return None
return value.strip()
secret_key = raw("SECRET_KEY")
if secret_key is None:
problems.append("SECRET_KEY: required")
elif len(secret_key) < MIN_SECRET_KEY_LENGTH:
problems.append(f"SECRET_KEY: must be at least {MIN_SECRET_KEY_LENGTH} characters")
hosts_raw = raw("ALLOWED_HOSTS")
allowed_hosts: list[str] = []
if hosts_raw is None:
problems.append("ALLOWED_HOSTS: required, comma-separated host names")
else:
allowed_hosts = [h.strip() for h in hosts_raw.split(",") if h.strip()]
if not allowed_hosts:
problems.append("ALLOWED_HOSTS: must contain at least one host name")
data_dir = Path(raw("DATA_DIR") or "/data")
if not data_dir.is_dir():
problems.append("DATA_DIR: must be an existing directory")
else:
data_dir = data_dir.resolve()
debug = False
debug_raw = raw("DEBUG")
if debug_raw is not None:
if debug_raw.lower() in _TRUE:
debug = True
elif debug_raw.lower() not in _FALSE:
problems.append("DEBUG: must be true or false")
proxies: list[str] = []
proxies_raw = raw("TRUSTED_PROXIES")
if proxies_raw is not None:
for item in proxies_raw.split(","):
item = item.strip()
if not item:
continue
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
problems.append("TRUSTED_PROXIES: must be comma-separated IP addresses or networks")
break
proxies.append(item)
log_level = (raw("LOG_LEVEL") or "INFO").upper()
if log_level not in LOG_LEVELS:
problems.append(f"LOG_LEVEL: must be one of {', '.join(LOG_LEVELS)}")
timezone = raw("DEFAULT_TIMEZONE") or "Europe/Berlin"
try:
ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ValueError, OSError):
problems.append("DEFAULT_TIMEZONE: must be a valid IANA time zone name")
if problems:
raise ConfigError(problems)
return Config(
secret_key=secret_key or "",
allowed_hosts=allowed_hosts,
data_dir=data_dir,
debug=debug,
trusted_proxies=proxies,
log_level=log_level,
default_timezone=timezone,
)
+23
View File
@@ -0,0 +1,23 @@
"""Liveness check. Database only; never AD, SMTP or the share (AD-18)."""
import logging
from django.db import connection
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
logger = logging.getLogger(__name__)
@require_GET
@never_cache
def healthz(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except Exception as exc: # noqa: BLE001 - any failure means unavailable
logger.error("health check failed", extra={"error_type": type(exc).__name__})
return JsonResponse({"status": "unavailable"}, status=503)
return JsonResponse({"status": "ok"})
+49
View File
@@ -0,0 +1,49 @@
"""JSON logs on stdout with a request id (AD-18)."""
from __future__ import annotations
import json
import logging
from contextvars import ContextVar
from datetime import UTC, datetime
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
# Attributes set by logging itself; everything else passed via `extra` is emitted.
_STANDARD = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
"ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"request_id": getattr(record, "request_id", None) or request_id_var.get(),
}
for key, value in record.__dict__.items():
if key in _STANDARD or key in ("request_id", "request"):
continue
if value is None or isinstance(value, str | int | float | bool):
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, default=str)
def build_logging(level: str) -> dict:
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": "portal.config.logging.JsonFormatter"}},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
}
},
"loggers": {"django.request": {"level": "ERROR"}},
"root": {"handlers": ["stdout"], "level": level},
}
+70
View File
@@ -0,0 +1,70 @@
"""Request id, request logging and security headers (AD-16, AD-18)."""
from __future__ import annotations
import logging
import re
import time
import uuid
from .logging import request_id_var
logger = logging.getLogger("portal.request")
_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
CSP = (
"default-src 'self'; "
"script-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()"
class RequestIdMiddleware:
"""Assign a request id, set the response header and write one log line."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
incoming = request.META.get("HTTP_X_REQUEST_ID", "")
request_id = incoming if _VALID_REQUEST_ID.match(incoming) else uuid.uuid4().hex
request.request_id = request_id
token = request_id_var.set(request_id)
started = time.monotonic()
status = 500
try:
response = self.get_response(request)
status = response.status_code
response["X-Request-ID"] = request_id
return response
finally:
# Path only: query strings and bodies are never logged.
logger.info(
"request",
extra={
"request_id": request_id,
"method": request.method,
"path": request.path,
"status": status,
"duration_ms": round((time.monotonic() - started) * 1000, 1),
},
)
request_id_var.reset(token)
class SecurityHeadersMiddleware:
"""Add the Content-Security-Policy and related headers to every response."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
return response
+109
View File
@@ -0,0 +1,109 @@
"""Django settings. All deployment values come from environment variables (AD-13)."""
import os
import sys
from pathlib import Path
from .env import ConfigError, load_config
from .logging import build_logging
try:
_config = load_config(os.environ)
except ConfigError as exc:
# One clear message, no traceback, no values.
print(str(exc), file=sys.stderr)
sys.exit(1)
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = _config.secret_key
DEBUG = _config.debug
ALLOWED_HOSTS = _config.allowed_hosts
# Reserved for the client-IP rule in a later entry; currently has no effect.
TRUSTED_PROXIES = _config.trusted_proxies
DATA_DIR = _config.data_dir
DISPLAY_TIME_ZONE = _config.default_timezone
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.sessions",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"portal.config.middleware.RequestIdMiddleware",
"portal.config.middleware.SecurityHeadersMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "portal.config.urls"
WSGI_APPLICATION = "portal.config.wsgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "portal" / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.i18n",
],
},
}
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "db.sqlite3",
"OPTIONS": {
"transaction_mode": "IMMEDIATE",
"timeout": 20,
"init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
},
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Time and language (AD-15): store UTC, translate every string.
USE_TZ = True
TIME_ZONE = "UTC"
USE_I18N = True
LANGUAGE_CODE = "en"
LANGUAGES = [("en", "English"), ("de", "Deutsch")]
LOCALE_PATHS = [BASE_DIR / "portal" / "locale"]
# Static files served by whitenoise; assets are built into the image.
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "portal" / "static"]
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
# Sessions in the database; cookies secure, HttpOnly, SameSite=Lax (AD-6).
SESSION_ENGINE = "django.contrib.sessions.backends.db"
SESSION_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = "Lax"
# TLS ends at the reverse proxy.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "same-origin"
SECURE_HSTS_SECONDS = 0 if DEBUG else 31536000
X_FRAME_OPTIONS = "DENY"
LOGGING = build_logging(_config.log_level)
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .health import healthz
urlpatterns = [
path("healthz", healthz, name="healthz"),
]
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portal.config.settings")
application = get_wsgi_application()