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.
328 lines
13 KiB
Python
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",
|
|
]
|