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.
293 lines
11 KiB
Python
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",
|
|
]
|