Files
UmbraTrans/umbratrans_web/config.py
umbra audio b474995414 Add phase-1 account management backend slice
Implements the account and user-management system behind the new web UI
against development repositories only. Nothing here connects to, reads, or
modifies the production umbracontrol database, and no account is created in
any approved environment.

Account service and API:
- Local temporary-password and OIDC enrollment, separated by type.
- Argon2id credentials, must-change enforcement, server-side sessions with
  hashed tokens, idle and absolute deadlines, rotation and revocation.
- Administrator account management: list, get, create, suspend, reactivate,
  deactivate, revoke sessions, org-scoped audit view.
- Hidden-prompt operator command for the first administrator, refusing
  production mode and refusing to run without a TTY.

Temporary passwords are never accepted from a client. The request schema has
no password field and refuses unknown fields; the administrator path generates
the one-time password in process, hashes it immediately, and returns it once in
the creation response. The account can reach no route, including
/api/auth/me, until it replaces that password, after which the one-time value
no longer authenticates.

Security posture: fail-closed configuration, Secure cookies in production,
double-submit CSRF, constant-time token comparison, generic authentication
errors, audit detail allow-listing, and logger-tree secret redaction.

Reviewed but unapplied: migrations/0001_phase1_identity.sql adds
users.username, a separate user_credentials table, and additive session
columns, with a rollback and open review questions. The PostgreSQL adapter is
deliberately absent, so the application refuses to start in production rather
than run against an unreviewed store.

Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff
format, compileall, and make web-check are clean.
2026-09-26 08:29:23 -07:00

293 lines
11 KiB
Python

"""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.1.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)
class Environment(StrEnum):
DEVELOPMENT = "development"
TEST = "test"
PRODUCTION = "production"
@property
def is_production(self) -> bool:
return self is 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,
"production": Environment.PRODUCTION,
"prod": Environment.PRODUCTION,
}
if normalized not in aliases:
raise ConfigurationError(
"invalid_environment",
f"UMBRA_ENV must be one of development, test, 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
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.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 environment.is_production:
self._validate_production()
def _validate_production(self) -> None:
if not self.cookie_secure:
raise ConfigurationError("insecure_production_cookie", "Production requires Secure session cookies.")
if not self.require_csrf:
raise ConfigurationError("insecure_production_csrf", "Production requires CSRF enforcement.")
if self.repository_kind != PRODUCTION_REPOSITORY_KIND:
raise ConfigurationError(
"production_repository_missing",
"Production requires the PostgreSQL repository adapter, which is not part of this slice.",
)
if self.repository_path is not None:
raise ConfigurationError(
"production_repository_path_forbidden",
"A file-backed repository path is not permitted in production.",
)
@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())
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,
"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()
allowed_hosts = tuple(host.strip() for host in env.get("UMBRATRANS_ALLOWED_HOSTS", "").split(",") if host.strip())
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"),
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,
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",
]