Files
ebbe.bassandClaude Sonnet 5.5 700295cb72 refactor: sweep the platform-and-sign-in epic
Split load_config into per-section readers, use contextlib.suppress and a single condition where ruff asked, remove stale noqa comments, add the SIM and RUF100 rules, share the sign_in test helper, align AD-6 with the code and record the remaining deferred findings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
2026-10-18 09:29:59 +02:00

263 lines
9.2 KiB
Python

"""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, field
from email.utils import parseaddr
from pathlib import Path
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
SMTP_SECURITY_MODES = ("starttls", "ssl")
SMTP_DEFAULT_PORTS = {"starttls": 587, "ssl": 465}
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 DirectoryConfig:
server: str # host name, optionally host:port; always reached over LDAPS
user_ou_dn: str
bind_dn: str # distinguished name or user principal name of the service account
bind_password: str = field(repr=False)
ca_file: Path
publisher_group_dn: str
admin_group_dn: str
@dataclass(frozen=True)
class OidcConfig:
issuer: str # exactly as the provider reports it, including any trailing slash
client_id: str
client_secret: str = field(repr=False)
public_base_url: str # origin the users reach the portal at; no trailing slash
@property
def redirect_uri(self) -> str:
return f"{self.public_base_url}/auth/callback"
@dataclass(frozen=True)
class SmtpConfig:
host: str
port: int
security: str # "starttls" or "ssl"; mail is never sent unencrypted
username: str
password: str = field(repr=False)
sender: str # the From address of every e-mail
@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
directory: DirectoryConfig
oidc: OidcConfig
smtp: SmtpConfig
class _Reader:
"""Reads variables and collects every problem instead of stopping at the first."""
def __init__(self, environ: Mapping[str, str]) -> None:
self._environ = environ
self.problems: list[str] = []
def raw(self, name: str) -> str | None:
value = self._environ.get(name)
if value is None or value.strip() == "":
return None
return value.strip()
def required(self, name: str, rule: str) -> str:
value = self.raw(name)
if value is None:
self.problems.append(f"{name}: required, {rule}")
return ""
return value
def problem(self, message: str) -> None:
self.problems.append(message)
def _read_debug(r: _Reader) -> bool:
value = r.raw("DEBUG")
if value is None:
return False
if value.lower() in _TRUE:
return True
if value.lower() not in _FALSE:
r.problem("DEBUG: must be true or false")
return False
def _read_trusted_proxies(r: _Reader) -> list[str]:
proxies: list[str] = []
value = r.raw("TRUSTED_PROXIES")
for item in (value or "").split(","):
item = item.strip()
if not item:
continue
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
r.problem("TRUSTED_PROXIES: must be comma-separated IP addresses or networks")
break
proxies.append(item)
return proxies
def _read_core(r: _Reader) -> dict:
secret_key = r.raw("SECRET_KEY")
if secret_key is None:
r.problem("SECRET_KEY: required")
elif len(secret_key) < MIN_SECRET_KEY_LENGTH:
r.problem(f"SECRET_KEY: must be at least {MIN_SECRET_KEY_LENGTH} characters")
hosts_raw = r.raw("ALLOWED_HOSTS")
allowed_hosts: list[str] = []
if hosts_raw is None:
r.problem("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:
r.problem("ALLOWED_HOSTS: must contain at least one host name")
data_dir = Path(r.raw("DATA_DIR") or "/data")
if not data_dir.is_dir():
r.problem("DATA_DIR: must be an existing directory")
else:
data_dir = data_dir.resolve()
log_level = (r.raw("LOG_LEVEL") or "INFO").upper()
if log_level not in LOG_LEVELS:
r.problem(f"LOG_LEVEL: must be one of {', '.join(LOG_LEVELS)}")
timezone = r.raw("DEFAULT_TIMEZONE") or "Europe/Berlin"
try:
ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ValueError, OSError):
r.problem("DEFAULT_TIMEZONE: must be a valid IANA time zone name")
return {
"secret_key": secret_key or "",
"allowed_hosts": allowed_hosts,
"data_dir": data_dir,
"debug": _read_debug(r),
"trusted_proxies": _read_trusted_proxies(r),
"log_level": log_level,
"default_timezone": timezone,
}
def _read_directory(r: _Reader) -> DirectoryConfig:
server = r.required("LDAP_SERVER", "host name of the directory server, with optional :port")
if "://" in server or "/" in server:
r.problem("LDAP_SERVER: must be a host name, not a URL (LDAPS is always used)")
user_ou_dn = r.required("LDAP_USER_OU_DN", "distinguished name of the OU that holds the users")
bind_dn = r.required("LDAP_BIND_DN", "service account distinguished name or principal name")
bind_password = r.required("LDAP_BIND_PASSWORD", "password of the service account")
publisher_group_dn = r.required("LDAP_PUBLISHER_GROUP_DN", "distinguished name of the group")
admin_group_dn = r.required("LDAP_ADMIN_GROUP_DN", "distinguished name of the group")
ca_file = Path(r.required("LDAP_CA_FILE", "path of the PEM file with the CA chain"))
if r.raw("LDAP_CA_FILE") is not None and not ca_file.is_file():
r.problem("LDAP_CA_FILE: must be an existing file")
return DirectoryConfig(
server=server,
user_ou_dn=user_ou_dn,
bind_dn=bind_dn,
bind_password=bind_password,
ca_file=ca_file,
publisher_group_dn=publisher_group_dn,
admin_group_dn=admin_group_dn,
)
def _read_oidc(r: _Reader, *, debug: bool) -> OidcConfig:
issuer = r.required("OIDC_ISSUER", "issuer address from the provider's OpenID configuration")
client_id = r.required("OIDC_CLIENT_ID", "client ID of the provider's application")
client_secret = r.required("OIDC_CLIENT_SECRET", "client secret of the provider's application")
base_url = r.required("PUBLIC_BASE_URL", "address users reach the portal at, e.g. https://host")
base_url = base_url.rstrip("/")
if issuer and urlsplit(issuer).scheme != "https":
r.problem("OIDC_ISSUER: must be an https address")
if base_url:
parts = urlsplit(base_url)
allowed = ("https", "http") if debug else ("https",)
if parts.scheme not in allowed or not parts.netloc or parts.path or parts.query:
r.problem("PUBLIC_BASE_URL: must be an https origin such as https://host")
return OidcConfig(
issuer=issuer,
client_id=client_id,
client_secret=client_secret,
public_base_url=base_url,
)
def _read_smtp(r: _Reader) -> SmtpConfig:
host = r.required("SMTP_HOST", "host name of the mail server")
if "://" in host or "/" in host or ":" in host:
r.problem("SMTP_HOST: must be a host name only; set the port in SMTP_PORT")
security = (r.raw("SMTP_SECURITY") or "starttls").lower()
if security not in SMTP_SECURITY_MODES:
r.problem(f"SMTP_SECURITY: must be one of {', '.join(SMTP_SECURITY_MODES)}")
security = "starttls"
port = SMTP_DEFAULT_PORTS[security]
port_raw = r.raw("SMTP_PORT")
if port_raw is not None:
if port_raw.isdigit() and 0 < int(port_raw) < 65536:
port = int(port_raw)
else:
r.problem("SMTP_PORT: must be a port number from 1 to 65535")
username = r.required("SMTP_USERNAME", "user name of the mailbox")
password = r.required("SMTP_PASSWORD", "password of the mailbox")
sender = r.required("MAIL_FROM", "sender address of the portal's e-mails")
address = parseaddr(sender)[1]
if sender and (
address.count("@") != 1
or any(c in sender for c in chr(13) + chr(10))
or address.startswith("@")
or address.endswith("@")
):
r.problem("MAIL_FROM: must be an e-mail address, optionally with a display name")
return SmtpConfig(
host=host, port=port, security=security, username=username, password=password, sender=sender
)
def load_config(environ: Mapping[str, str]) -> Config:
reader = _Reader(environ)
core = _read_core(reader)
directory = _read_directory(reader)
oidc = _read_oidc(reader, debug=core["debug"])
smtp = _read_smtp(reader)
if reader.problems:
raise ConfigError(reader.problems)
return Config(**core, directory=directory, oidc=oidc, smtp=smtp)