# Copyright 2026 Umbra Tools (umbratools.com) # SPDX-License-Identifier: Apache-2.0 """Process configuration for the UmbraTrans web slice. Configuration is fail-closed: * Production requires ``Secure`` cookies, CSRF enforcement, and a repository adapter other than the local development stores. * The local JSON development repository is refused outright in production. * Wildcard host allow-lists and short token peppers are rejected. * The production PostgreSQL adapter is not part of this slice, so requesting production mode raises instead of silently starting an insecure process. The token pepper is excluded from ``repr`` and is never logged. """ from __future__ import annotations import os from collections.abc import Mapping from dataclasses import dataclass, field, replace from enum import StrEnum from pathlib import Path from .errors import ConfigurationError from .security import ( ARGON2_HASH_LENGTH, ARGON2_MEMORY_COST_KIB, ARGON2_PARALLELISM, ARGON2_SALT_LENGTH, ARGON2_TIME_COST, MIN_PEPPER_BYTES, ) SERVICE_NAME = "umbratrans-web" SERVICE_VERSION = "0.2.0" DEFAULT_SESSION_COOKIE_NAME = "umbratrans_session" DEFAULT_CSRF_COOKIE_NAME = "umbratrans_csrf" DEFAULT_CSRF_HEADER_NAME = "x-csrf-token" DEFAULT_SESSION_IDLE_SECONDS = 30 * 60 DEFAULT_SESSION_ABSOLUTE_SECONDS = 12 * 60 * 60 DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS = 5 DEFAULT_LOCKOUT_SECONDS = 15 * 60 DEVELOPMENT_DATA_ROOT = Path(".local/data/development") DEVELOPMENT_REPOSITORY_RELATIVE_PATH = Path("umbratrans-web/dev-accounts.json") PRODUCTION_REPOSITORY_KIND = "postgres" REPOSITORY_KINDS = ("memory", "json", PRODUCTION_REPOSITORY_KIND) MAILER_TRANSPORTS = ("log", "smtp") class Environment(StrEnum): DEVELOPMENT = "development" TEST = "test" #: A real deployment against the authoritative store, with the same cookie, #: CSRF, and repository rules as production, but able to start before mail is #: configured. It exists because the alternative is a deployment that cannot #: be brought up at all: production requires SMTP, and SMTP needs credentials #: that only the operator has, so a strict-only deployment blocks first-run #: setup behind a chicken-and-egg. #: #: The waiver that allows an unconfigured mailer is explicit, is reported in #: ``/api/health``, and is shown as a standing banner in the admin console, #: which also refuses to create accounts while it is in force. It is never #: silent: an account created here could not be activated by email, so the #: refusal is the point rather than an inconvenience. STAGING = "staging" PRODUCTION = "production" @property def is_production(self) -> bool: return self is Environment.PRODUCTION @property def is_public(self) -> bool: """Whether this environment is reachable by clients and must be hardened. Staging and production both hold real data behind a real hostname, so the rules that matter are the same; they differ only in whether mail must already be configured. """ return self in (Environment.STAGING, Environment.PRODUCTION) def _environment_from(value: str) -> Environment: normalized = value.strip().lower() aliases = { "development": Environment.DEVELOPMENT, "dev": Environment.DEVELOPMENT, "local": Environment.DEVELOPMENT, "test": Environment.TEST, "testing": Environment.TEST, "staging": Environment.STAGING, "stage": Environment.STAGING, "production": Environment.PRODUCTION, "prod": Environment.PRODUCTION, } if normalized not in aliases: raise ConfigurationError( "invalid_environment", f"UMBRA_ENV must be one of development, test, staging, production (got {value!r}).", ) return aliases[normalized] def _bool_from(value: str, *, name: str) -> bool: normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False raise ConfigurationError("invalid_boolean", f"{name} must be a boolean value.") def _int_from(value: str, *, name: str, minimum: int = 1) -> int: try: parsed = int(value.strip()) except ValueError as error: raise ConfigurationError("invalid_integer", f"{name} must be an integer.") from error if parsed < minimum: raise ConfigurationError("invalid_integer", f"{name} must be >= {minimum}.") return parsed @dataclass(frozen=True, slots=True) class Settings: """Immutable settings for one application process.""" environment: Environment = Environment.DEVELOPMENT service_name: str = SERVICE_NAME version: str = SERVICE_VERSION session_cookie_name: str = DEFAULT_SESSION_COOKIE_NAME csrf_cookie_name: str = DEFAULT_CSRF_COOKIE_NAME csrf_header_name: str = DEFAULT_CSRF_HEADER_NAME cookie_secure: bool = False cookie_samesite: str = "lax" cookie_path: str = "/" require_csrf: bool = True session_idle_seconds: int = DEFAULT_SESSION_IDLE_SECONDS session_absolute_seconds: int = DEFAULT_SESSION_ABSOLUTE_SECONDS max_failed_login_attempts: int = DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS lockout_seconds: int = DEFAULT_LOCKOUT_SECONDS argon2_memory_cost_kib: int = ARGON2_MEMORY_COST_KIB argon2_time_cost: int = ARGON2_TIME_COST argon2_parallelism: int = ARGON2_PARALLELISM argon2_hash_length: int = ARGON2_HASH_LENGTH argon2_salt_length: int = ARGON2_SALT_LENGTH password_min_length: int = 14 password_max_length: int = 1024 repository_kind: str = "memory" repository_path: Path | None = None database_dsn: str | None = field(default=None, repr=False, compare=False) database_pool_min: int = 1 database_pool_max: int = 10 static_root: Path | None = None #: Where production directories live (``productions/-/``). Read #: by the project-status adapter so the workspace can report real translation #: and output state before the master store holds those rows. Unset means #: "no production data is reachable", which the adapter reports honestly #: rather than inventing progress. productions_root: Path | None = None mailer_transport: str = "log" smtp_host: str = "" #: Set only to bring a staging deployment up before SMTP exists. It is #: refused in production, reported by the health endpoint, and shown as a #: standing banner in the admin console, which then refuses to create #: accounts. It exists so first-run setup is not blocked behind credentials #: the operator is the only one who can supply. mail_delivery_waived: bool = False smtp_port: int = 587 smtp_username: str | None = None smtp_password: str | None = field(default=None, repr=False, compare=False) smtp_sender: str = "umbratrans@localhost" smtp_starttls: bool = True verification_base_url: str = "" allowed_hosts: tuple[str, ...] = () last_seen_write_interval_seconds: int = 30 token_pepper: bytes | None = field(default=None, repr=False, compare=False) def __post_init__(self) -> None: environment = Environment(self.environment) object.__setattr__(self, "environment", environment) if self.repository_kind not in REPOSITORY_KINDS: raise ConfigurationError( "invalid_repository_kind", f"Repository kind must be one of {', '.join(REPOSITORY_KINDS)}.", ) if self.session_idle_seconds > self.session_absolute_seconds: raise ConfigurationError( "invalid_session_lifetime", "Idle timeout must not exceed the absolute session lifetime." ) if self.cookie_samesite not in {"lax", "strict"}: raise ConfigurationError("invalid_samesite", "Cookie SameSite must be lax or strict.") if self.repository_path is not None and not isinstance(self.repository_path, Path): object.__setattr__(self, "repository_path", Path(self.repository_path)) if self.static_root is not None and not isinstance(self.static_root, Path): object.__setattr__(self, "static_root", Path(self.static_root)) if self.token_pepper is not None and len(self.token_pepper) < MIN_PEPPER_BYTES: raise ConfigurationError("weak_token_pepper", f"Token pepper must be at least {MIN_PEPPER_BYTES} bytes.") if any(host.strip() in {"*", ""} for host in self.allowed_hosts): raise ConfigurationError("wildcard_host_forbidden", "A wildcard or empty allowed host is not permitted.") if self.mailer_transport not in MAILER_TRANSPORTS: raise ConfigurationError( "invalid_mailer_transport", f"Mail transport must be one of {', '.join(MAILER_TRANSPORTS)}.", ) if self.verification_base_url and not self.verification_base_url.startswith(("http://", "https://")): raise ConfigurationError("invalid_verification_base_url", "The verification base URL must be absolute.") if self.database_pool_min < 1 or self.database_pool_max < self.database_pool_min: raise ConfigurationError("invalid_pool_bounds", "Pool sizes must satisfy 1 <= min <= max.") if environment.is_public: self._validate_public() def _mail_is_deliverable(self) -> bool: """Whether a real transport is configured and would actually send.""" return self.mailer_transport == "smtp" and bool(self.smtp_host) def _validate_public(self) -> None: if not self.cookie_secure: raise ConfigurationError("insecure_production_cookie", "This environment requires Secure session cookies.") if not self.require_csrf: raise ConfigurationError("insecure_production_csrf", "This environment requires CSRF enforcement.") # The PostgreSQL repository has no usable default. Refusing here means a # public process cannot start without real credentials rather than # appearing healthy while holding a development store. The same # requirement is re-checked at the repository boundary for any # environment, so a non-public process that selects postgres is stopped # there instead. if not self.database_dsn: raise ConfigurationError( "production_dsn_missing", "This environment requires a PostgreSQL connection string.", ) # Email validation is a required part of account creation, so a process # that cannot deliver a link would leave every new account permanently # unverified. Production insists on it outright. Staging allows it only # under an explicit waiver, which is reported and displayed rather than # assumed. if not self._mail_is_deliverable(): if self.environment.is_production: raise ConfigurationError( "production_mailer_missing", "Production requires a real SMTP transport; the logging transport would " "write verification links to the log and never deliver them.", ) if not self.mail_delivery_waived: raise ConfigurationError( "staging_mailer_unconfigured", "Staging has no SMTP transport configured. Set UMBRATRANS_MAIL_DELIVERY_WAIVED=1 to " "start anyway, understanding that accounts cannot be activated by email until " "SMTP is configured.", ) if not self.verification_base_url: raise ConfigurationError( "production_verification_url_missing", "This environment requires an absolute verification base URL for mailed links.", ) if self.repository_kind != PRODUCTION_REPOSITORY_KIND: raise ConfigurationError( "production_repository_missing", "This environment requires the PostgreSQL repository adapter.", ) if self.repository_path is not None: raise ConfigurationError( "production_repository_path_forbidden", "A file-backed repository path is not permitted in this environment.", ) @property def is_production(self) -> bool: return self.environment.is_production @property def is_development(self) -> bool: return self.environment is Environment.DEVELOPMENT def with_development_path_defaults(self, *, root: Path | None = None) -> Settings: """Return settings with a development repository path filled in.""" if self.repository_path is not None: return self base = root or DEVELOPMENT_DATA_ROOT return replace(self, repository_path=(base / DEVELOPMENT_REPOSITORY_RELATIVE_PATH).resolve()) @property def mail_is_deliverable(self) -> bool: """Whether verification mail can actually leave this process.""" return self._mail_is_deliverable() def public_fingerprint(self) -> dict[str, object]: """Return non-secret settings suitable for a health response.""" return { "environment": str(self.environment), "repository": self.repository_kind, "cookie_secure": self.cookie_secure, "csrf_required": self.require_csrf, "mail_deliverable": self.mail_is_deliverable, "mail_delivery_waived": self.mail_delivery_waived, "session_idle_seconds": self.session_idle_seconds, "session_absolute_seconds": self.session_absolute_seconds, "argon2": "argon2id", } def default_settings() -> Settings: """Return the development defaults used by tests and local tooling.""" return Settings(environment=Environment.TEST, cookie_secure=False) def suite_settings(*, secure_cookies: bool = False, require_csrf: bool = True) -> Settings: """Return fast, isolated settings for the test suite. The Argon2 cost is reduced so the suite stays quick. Production parameters are asserted separately by ``test_security.py``. """ return replace( default_settings(), cookie_secure=secure_cookies, require_csrf=require_csrf, argon2_memory_cost_kib=8 * 1024, argon2_time_cost=1, argon2_parallelism=1, argon2_hash_length=32, argon2_salt_length=16, password_min_length=14, ) def load_settings(environ: Mapping[str, str] | None = None) -> Settings: """Build settings from environment variables, failing closed.""" env = os.environ if environ is None else environ environment = _environment_from(env.get("UMBRA_ENV", str(Environment.DEVELOPMENT))) repository_kind = env.get( "UMBRATRANS_REPOSITORY", "memory" if not environment.is_production else PRODUCTION_REPOSITORY_KIND ) repository_path: Path | None = None raw_path = env.get("UMBRATRANS_REPOSITORY_PATH") if raw_path: repository_path = Path(raw_path).expanduser() static_root: Path | None = None raw_static = env.get("UMBRATRANS_STATIC_ROOT") if raw_static: static_root = Path(raw_static).expanduser() productions_root: Path | None = None raw_productions = env.get("UMBRATRANS_PRODUCTIONS_ROOT") if raw_productions: productions_root = Path(raw_productions).expanduser() # The DSN is read from the environment and is never logged, echoed by an # endpoint, or written to a development data file. repr/compare are off on # the field so it cannot leak through a settings dump. database_dsn = env.get("UMBRATRANS_DATABASE_DSN") or None pool_min = _int_from(env.get("UMBRATRANS_DATABASE_POOL_MIN", "1"), name="UMBRATRANS_DATABASE_POOL_MIN", minimum=1) pool_max = _int_from(env.get("UMBRATRANS_DATABASE_POOL_MAX", "10"), name="UMBRATRANS_DATABASE_POOL_MAX", minimum=1) allowed_hosts = tuple(host.strip() for host in env.get("UMBRATRANS_ALLOWED_HOSTS", "").split(",") if host.strip()) # Verification links must be absolute, or the mailed link is unusable. verification_base_url = env.get("UMBRATRANS_VERIFICATION_BASE_URL", "").strip().rstrip("/") raw_pepper = env.get("UMBRATRANS_TOKEN_PEPPER") pepper = raw_pepper.encode("utf-8") if raw_pepper else None if pepper is not None and len(pepper) < MIN_PEPPER_BYTES: raise ConfigurationError("weak_token_pepper", f"Token pepper must be at least {MIN_PEPPER_BYTES} bytes.") settings = Settings( environment=environment, session_cookie_name=env.get("UMBRATRANS_SESSION_COOKIE", DEFAULT_SESSION_COOKIE_NAME), csrf_cookie_name=env.get("UMBRATRANS_CSRF_COOKIE", DEFAULT_CSRF_COOKIE_NAME), csrf_header_name=env.get("UMBRATRANS_CSRF_HEADER", DEFAULT_CSRF_HEADER_NAME).lower(), cookie_secure=_bool_from(env.get("UMBRATRANS_COOKIE_SECURE", "false"), name="UMBRATRANS_COOKIE_SECURE"), require_csrf=_bool_from(env.get("UMBRATRANS_REQUIRE_CSRF", "true"), name="UMBRATRANS_REQUIRE_CSRF"), mail_delivery_waived=_bool_from( env.get("UMBRATRANS_MAIL_DELIVERY_WAIVED", "false"), name="UMBRATRANS_MAIL_DELIVERY_WAIVED", ), session_idle_seconds=_int_from( env.get("UMBRATRANS_SESSION_IDLE_SECONDS", str(DEFAULT_SESSION_IDLE_SECONDS)), name="UMBRATRANS_SESSION_IDLE_SECONDS", minimum=60, ), session_absolute_seconds=_int_from( env.get("UMBRATRANS_SESSION_ABSOLUTE_SECONDS", str(DEFAULT_SESSION_ABSOLUTE_SECONDS)), name="UMBRATRANS_SESSION_ABSOLUTE_SECONDS", minimum=60, ), max_failed_login_attempts=_int_from( env.get("UMBRATRANS_MAX_FAILED_LOGINS", str(DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS)), name="UMBRATRANS_MAX_FAILED_LOGINS", minimum=1, ), lockout_seconds=_int_from( env.get("UMBRATRANS_LOCKOUT_SECONDS", str(DEFAULT_LOCKOUT_SECONDS)), name="UMBRATRANS_LOCKOUT_SECONDS", minimum=1, ), repository_kind=repository_kind, repository_path=repository_path, database_dsn=database_dsn, database_pool_min=pool_min, database_pool_max=pool_max, static_root=static_root, productions_root=productions_root, mailer_transport=env.get("UMBRATRANS_MAILER", "log").strip().lower(), smtp_host=env.get("UMBRATRANS_SMTP_HOST", "").strip(), smtp_port=_int_from(env.get("UMBRATRANS_SMTP_PORT", "587"), name="UMBRATRANS_SMTP_PORT", minimum=1), smtp_username=env.get("UMBRATRANS_SMTP_USERNAME") or None, smtp_password=env.get("UMBRATRANS_SMTP_PASSWORD") or None, smtp_sender=env.get("UMBRATRANS_SMTP_SENDER", "umbratrans@localhost").strip(), smtp_starttls=_bool_from(env.get("UMBRATRANS_SMTP_STARTTLS", "true"), name="UMBRATRANS_SMTP_STARTTLS"), verification_base_url=verification_base_url, allowed_hosts=allowed_hosts, token_pepper=pepper, ) if settings.repository_kind == "json" and settings.repository_path is None: return settings.with_development_path_defaults() return settings __all__ = [ "DEVELOPMENT_DATA_ROOT", "PRODUCTION_REPOSITORY_KIND", "REPOSITORY_KINDS", "SERVICE_NAME", "SERVICE_VERSION", "Environment", "Settings", "default_settings", "load_settings", "suite_settings", ]