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.
227 lines
8.6 KiB
Python
227 lines
8.6 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
|
|
|
|
from .conftest import TEST_DSN
|
|
|
|
|
|
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", "UMBRATRANS_DATABASE_DSN": TEST_DSN})
|
|
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",
|
|
"UMBRATRANS_DATABASE_DSN": TEST_DSN,
|
|
}
|
|
)
|
|
assert csrf_error.value.code == "insecure_production_csrf"
|
|
|
|
|
|
def test_production_settings_select_the_postgres_adapter() -> None:
|
|
without_dsn = {"UMBRA_ENV": "production", "UMBRATRANS_COOKIE_SECURE": "true"}
|
|
with pytest.raises(ConfigurationError) as missing:
|
|
load_settings(without_dsn)
|
|
assert missing.value.code == "production_dsn_missing"
|
|
|
|
settings = load_settings({**without_dsn, "UMBRATRANS_DATABASE_DSN": "postgresql://app@db.internal/umbracontrol"})
|
|
assert settings.is_production is True
|
|
assert settings.repository_kind == "postgres"
|
|
assert settings.cookie_secure is True
|
|
assert settings.database_dsn is not None
|
|
|
|
|
|
def test_production_refuses_a_missing_dsn_at_the_repository_boundary() -> None:
|
|
from umbratrans_web.errors import RepositoryError
|
|
from umbratrans_web.repository import build_repository
|
|
|
|
settings = Settings(environment=Environment.TEST, repository_kind="postgres")
|
|
with pytest.raises(RepositoryError) as error:
|
|
build_repository(settings)
|
|
assert error.value.code == "database_dsn_missing"
|
|
|
|
|
|
def test_the_dsn_is_never_rendered_in_a_settings_dump() -> None:
|
|
settings = Settings(
|
|
environment=Environment.TEST,
|
|
repository_kind="json",
|
|
# Obviously fake, and never opened: nothing in the suite connects.
|
|
database_dsn="postgresql://app:not-a-real-secret@db.invalid/umbracontrol",
|
|
)
|
|
rendered = repr(settings)
|
|
assert "not-a-real-secret" not in rendered
|
|
assert "postgresql://" not in rendered
|
|
# repr=False on the field also keeps it out of equality comparisons, so two
|
|
# settings objects differing only by DSN are not distinguishable by dump.
|
|
assert "not-a-real-secret" not in str(settings)
|
|
assert "not-a-real-secret" not in f"{settings!r} {settings}"
|
|
|
|
|
|
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
|