Files
UmbraTrans/umbratrans_web/config.py
umbra audio 31fd8720ec Add the PostgreSQL adapter for the authoritative store
Implements AccountRepository against the control-plane schema plus the
phase-1 identity migration, and wires it in so production can select it.

The adapter is not a thin SQL wrapper, because the control plane does not
store the two things the application needs on the users row.

Role and organization come from user_roles. There is no role column and no
organization column on users; a role is assigned through user_roles with a
scope, and app.validate_user_role_scope() enforces that the role's scope
matches the assignment. Resolving one role per user is therefore a join, and
a user holding more than one active assignment is refused rather than resolved
to an arbitrary row. The organization on that assignment becomes the scope of
every administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership stays an open
review question, recorded as such.

Email became a required field on account creation. users.email is NOT NULL
UNIQUE, so accepting a missing address would let account creation succeed
against the development repositories and then fail on the authoritative store.
It is now required in the service, the API schema, and the bootstrap command,
so it fails identically everywhere.

Audit identifiers now belong to the store. audit_events.id is a bigint
identity, so the adapter reports that value; the application correlation UUID
is preserved in the detail and never displaces the row key. AuditEvent.id is a
string for that reason, which removes a type the two stores disagreed about.

Three further fail-closed behaviours: a users.status of invited or locked is
refused rather than coerced onto active; a row with mfa_required is refused,
because this phase cannot satisfy a second factor and authenticating it anyway
would grant access to someone who was supposed to present one; and a missing
organization blocks access rather than granting it. save_user deliberately does
not write username or email, since both are identity keys with no reviewed
change path and no audit event behind them.

Every operation runs in one transaction on one pooled connection and publishes
the caller with SET LOCAL app.current_user_id, so a returned connection cannot
carry one request's identity into the next. That is inert until the reviewed RLS
policies exist; the explicit WHERE clauses enforce scope today. A statement
timeout bounds how long a pooled connection can be held.

Production fails closed at two independent gates: the settings layer refuses
without a DSN, and the repository boundary refuses to build a PostgreSQL store
without one. Verified by running both, including that an unreachable host
refuses to start rather than degrading.

The migration gains an index on audit_events (target_type, target_id), which
the administrator audit filter needs and nothing covered, and the review
questions now include the three things that must be checked before
provisioning: the roles rows and their scopes, any row with mfa_required set,
and any user holding more than one active assignment.

Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row
mapping, role resolution, conflict mapping, and configuration are unit tested
and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed:
this environment has no database credentials, because sudo to the postgres user
needs a password, there is no umbra database role, and there is no .pgpass. The
integration suite is written and selected by UMBRATRANS_TEST_DSN, and its
fixture refuses any DSN whose database name contains umbracontrol. A green run
here says nothing about whether the SQL is correct.
2026-09-26 20:28:07 -07:00

328 lines
13 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
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
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.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_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.")
# The PostgreSQL repository has no usable default. Refusing here means a
# production 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-production process that selects postgres is
# stopped there instead.
if not self.database_dsn:
raise ConfigurationError(
"production_dsn_missing",
"Production requires a PostgreSQL connection string.",
)
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()
static_root: Path | None = None
raw_static = env.get("UMBRATRANS_STATIC_ROOT")
if raw_static:
static_root = Path(raw_static).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())
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,
database_dsn=database_dsn,
database_pool_min=pool_min,
database_pool_max=pool_max,
static_root=static_root,
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",
]