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.
196 lines
7.4 KiB
Python
196 lines
7.4 KiB
Python
"""Configuration tests: fail-closed production defaults, no wildcards, no secrets."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from umbratrans_web.config import (
|
|
DEVELOPMENT_REPOSITORY_RELATIVE_PATH,
|
|
Environment,
|
|
Settings,
|
|
load_settings,
|
|
suite_settings,
|
|
)
|
|
from umbratrans_web.errors import ConfigurationError
|
|
from umbratrans_web.security import SecretRedactionFilter
|
|
|
|
|
|
def test_defaults_are_development_and_insecure_only_where_allowed() -> None:
|
|
settings = Settings()
|
|
assert settings.environment is Environment.DEVELOPMENT
|
|
assert settings.cookie_secure is False
|
|
assert settings.require_csrf is True
|
|
assert settings.repository_kind == "memory"
|
|
|
|
|
|
def test_load_settings_reads_the_development_environment() -> None:
|
|
settings = load_settings({"UMBRA_ENV": "development"})
|
|
assert settings.environment is Environment.DEVELOPMENT
|
|
assert settings.repository_kind == "memory"
|
|
assert settings.repository_path is None
|
|
|
|
|
|
def test_load_settings_refuses_unknown_environments() -> None:
|
|
with pytest.raises(ConfigurationError) as error:
|
|
load_settings({"UMBRA_ENV": "staging"})
|
|
assert error.value.code == "invalid_environment"
|
|
|
|
|
|
def test_load_settings_refuses_insecure_production() -> None:
|
|
with pytest.raises(ConfigurationError) as cookie_error:
|
|
load_settings({"UMBRA_ENV": "production"})
|
|
assert cookie_error.value.code == "insecure_production_cookie"
|
|
|
|
with pytest.raises(ConfigurationError) as csrf_error:
|
|
load_settings(
|
|
{"UMBRA_ENV": "production", "UMBRATRANS_COOKIE_SECURE": "true", "UMBRATRANS_REQUIRE_CSRF": "false"}
|
|
)
|
|
assert csrf_error.value.code == "insecure_production_csrf"
|
|
|
|
|
|
def test_production_settings_select_the_absent_postgres_adapter() -> None:
|
|
settings = load_settings({"UMBRA_ENV": "production", "UMBRATRANS_COOKIE_SECURE": "true"})
|
|
|
|
assert settings.is_production is True
|
|
assert settings.repository_kind == "postgres"
|
|
assert settings.cookie_secure is True
|
|
# The adapter is not part of this slice, so the app cannot start.
|
|
from umbratrans_web.errors import RepositoryError
|
|
from umbratrans_web.repository import build_repository
|
|
|
|
with pytest.raises(RepositoryError) as error:
|
|
build_repository(settings)
|
|
assert error.value.code == "postgres_adapter_unavailable"
|
|
|
|
|
|
def test_load_settings_parses_json_repository_paths() -> None:
|
|
settings = load_settings(
|
|
{
|
|
"UMBRA_ENV": "development",
|
|
"UMBRATRANS_REPOSITORY": "json",
|
|
"UMBRATRANS_REPOSITORY_PATH": "/tmp/umbratrans-test/dev-accounts.json",
|
|
}
|
|
)
|
|
assert settings.repository_kind == "json"
|
|
assert settings.repository_path == Path("/tmp/umbratrans-test/dev-accounts.json")
|
|
|
|
|
|
def test_json_repository_gets_a_development_default_path() -> None:
|
|
settings = load_settings({"UMBRA_ENV": "development", "UMBRATRANS_REPOSITORY": "json"})
|
|
assert settings.repository_path is not None
|
|
assert settings.repository_path.name == DEVELOPMENT_REPOSITORY_RELATIVE_PATH.name
|
|
assert str(settings.repository_path).startswith(str(Path.cwd() / ".local/data/development"))
|
|
|
|
|
|
def test_load_settings_rejects_weak_peppers_and_wildcard_hosts() -> None:
|
|
with pytest.raises(ConfigurationError) as pepper_error:
|
|
load_settings({"UMBRATRANS_TOKEN_PEPPER": "short"})
|
|
assert pepper_error.value.code == "weak_token_pepper"
|
|
|
|
with pytest.raises(ConfigurationError) as host_error:
|
|
load_settings({"UMBRATRANS_ALLOWED_HOSTS": "umbratrans.bdmurphy.me,*"})
|
|
assert host_error.value.code == "wildcard_host_forbidden"
|
|
|
|
|
|
def test_pepper_is_absent_from_repr_and_comparison() -> None:
|
|
pepper = b"k" * 32
|
|
settings = Settings(environment=Environment.TEST, token_pepper=pepper)
|
|
# The pepper is a secret: it must not surface in repr, and it must not
|
|
# change equality either.
|
|
assert pepper.decode() not in repr(settings)
|
|
assert settings == Settings(environment=Environment.TEST)
|
|
assert settings.token_pepper == pepper
|
|
|
|
|
|
def test_session_lifetime_must_be_consistent() -> None:
|
|
with pytest.raises(ConfigurationError) as error:
|
|
Settings(environment=Environment.TEST, session_idle_seconds=7200, session_absolute_seconds=600)
|
|
assert error.value.code == "invalid_session_lifetime"
|
|
|
|
|
|
def test_samesite_must_be_lax_or_strict() -> None:
|
|
with pytest.raises(ConfigurationError) as error:
|
|
Settings(environment=Environment.TEST, cookie_samesite="none")
|
|
assert error.value.code == "invalid_samesite"
|
|
|
|
|
|
def test_public_fingerprint_has_no_secret_values() -> None:
|
|
settings = Settings(environment=Environment.TEST, token_pepper=b"k" * 32)
|
|
fingerprint = settings.public_fingerprint()
|
|
assert "k" * 32 not in str(fingerprint)
|
|
assert set(fingerprint) == {
|
|
"environment",
|
|
"repository",
|
|
"cookie_secure",
|
|
"csrf_required",
|
|
"session_idle_seconds",
|
|
"session_absolute_seconds",
|
|
"argon2",
|
|
}
|
|
|
|
|
|
def test_suite_settings_are_fast_and_isolated() -> None:
|
|
settings = suite_settings()
|
|
assert settings.environment is Environment.TEST
|
|
assert settings.argon2_memory_cost_kib == 8 * 1024
|
|
assert settings.argon2_time_cost == 1
|
|
assert settings.require_csrf is True
|
|
|
|
|
|
def test_application_logger_tree_has_the_redaction_filter() -> None:
|
|
from umbratrans_web.app import create_app
|
|
from umbratrans_web.security import install_secret_redaction_filter
|
|
|
|
create_app(settings=suite_settings())
|
|
namespace = logging.getLogger("umbratrans_web")
|
|
assert any(isinstance(item, SecretRedactionFilter) for item in namespace.filters)
|
|
# Child loggers are patched too, because a parent filter is not applied to
|
|
# records emitted by its children.
|
|
child = logging.getLogger("umbratrans_web.service")
|
|
assert any(isinstance(item, SecretRedactionFilter) for item in child.filters)
|
|
# Installation is idempotent.
|
|
assert install_secret_redaction_filter() >= 1
|
|
|
|
|
|
def test_child_logger_records_are_redacted_end_to_end() -> None:
|
|
import logging as std_logging
|
|
|
|
from umbratrans_web.app import create_app
|
|
from umbratrans_web.repository import InMemoryAccountRepository
|
|
from umbratrans_web.service import AccountService
|
|
|
|
settings = suite_settings()
|
|
repository = InMemoryAccountRepository()
|
|
service = AccountService(repository, settings)
|
|
create_app(settings=settings, repository=repository, service=service)
|
|
|
|
records: list[std_logging.LogRecord] = []
|
|
|
|
class _Capture(std_logging.Handler):
|
|
def emit(self, record: std_logging.LogRecord) -> None:
|
|
records.append(record)
|
|
|
|
logger = std_logging.getLogger("umbratrans_web.service")
|
|
handler = _Capture()
|
|
logger.addHandler(handler)
|
|
previous_level = logger.level
|
|
logger.setLevel(std_logging.DEBUG)
|
|
try:
|
|
# The filter is pattern based: it removes secret-shaped text such as
|
|
# "password=..." or "Authorization: Bearer ...". It cannot recognise a
|
|
# secret hidden behind an innocuous key, which is why the service logs
|
|
# identifiers only and never a submitted value.
|
|
password = "not-a-real-password-value"
|
|
logger.warning("operator supplied password=%s", password)
|
|
finally:
|
|
logger.removeHandler(handler)
|
|
logger.setLevel(previous_level)
|
|
|
|
assert records
|
|
rendered = "".join(record.getMessage() for record in records)
|
|
assert password not in rendered
|
|
assert "[redacted]" in rendered
|