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.
120 lines
3.9 KiB
Python
120 lines
3.9 KiB
Python
"""Migration-file guards.
|
|
|
|
These tests do not run SQL. They assert that the reviewed migration stays
|
|
unapplied, stays inside the phase-1 identity scope, and contains no secret
|
|
material.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
MIGRATIONS = Path(__file__).resolve().parent.parent / "migrations"
|
|
FORWARD = MIGRATIONS / "0001_phase1_identity.sql"
|
|
ROLLBACK = MIGRATIONS / "0001_phase1_identity_rollback.sql"
|
|
|
|
FORBIDDEN_SQL = (
|
|
"INSERT INTO",
|
|
"UPDATE public.users SET username",
|
|
"CREATE USER",
|
|
"CREATE ROLE",
|
|
"GRANT ALL",
|
|
"DROP TABLE public.users",
|
|
"psql",
|
|
"PGPASSWORD",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_postgres(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Make any accidental database import fail loudly inside the suite."""
|
|
|
|
monkeypatch.setenv("UMBRA_ENV", "test")
|
|
monkeypatch.delenv("UMBRATRANS_REPOSITORY", raising=False)
|
|
monkeypatch.delenv("UMBRATRANS_REPOSITORY_PATH", raising=False)
|
|
|
|
|
|
def test_migration_files_exist() -> None:
|
|
assert FORWARD.is_file()
|
|
assert ROLLBACK.is_file()
|
|
assert (MIGRATIONS / "README.md").is_file()
|
|
|
|
|
|
def test_forward_migration_is_marked_unapplied() -> None:
|
|
text = FORWARD.read_text(encoding="utf-8")
|
|
assert "NOT APPLIED" in text
|
|
assert "NOT APPROVED" in text
|
|
assert "has never been executed" in text
|
|
|
|
|
|
def test_forward_migration_stays_in_phase_one_identity_scope() -> None:
|
|
text = FORWARD.read_text(encoding="utf-8")
|
|
assert "ADD COLUMN IF NOT EXISTS username" in text
|
|
assert "CREATE TABLE IF NOT EXISTS public.user_credentials" in text
|
|
for column in ("mfa_verified_at", "idle_deadline_at", "last_seen_at"):
|
|
assert column in text
|
|
# Out of scope for this migration.
|
|
for forbidden in (
|
|
"public.project_membership",
|
|
"CREATE TABLE public.organizations",
|
|
"ALTER TABLE public.organizations",
|
|
"ALTER TABLE public.projects",
|
|
"ALTER TABLE public.audit_events",
|
|
"ALTER TABLE public.user_roles",
|
|
):
|
|
assert forbidden not in text
|
|
|
|
|
|
def test_forward_migration_contains_no_secret_material() -> None:
|
|
phc_hash = re.compile(r"\$argon2(?:id|i|d)\$v=\d+\$")
|
|
assignment = re.compile(r"(?i)password\s*=\s*['\"]")
|
|
for path in (FORWARD, ROLLBACK, MIGRATIONS / "README.md"):
|
|
text = path.read_text(encoding="utf-8")
|
|
assert phc_hash.search(text) is None, path
|
|
assert assignment.search(text) is None, path
|
|
assert "PASSWORD '" not in text
|
|
assert "postgresql://" not in text
|
|
assert "BEGIN; --" not in text
|
|
|
|
|
|
def test_forward_migration_has_no_executable_dangerous_statements() -> None:
|
|
text = FORWARD.read_text(encoding="utf-8")
|
|
for statement in FORBIDDEN_SQL:
|
|
assert statement not in text, statement
|
|
|
|
|
|
def test_forward_migration_documents_safety_rolls_back_and_rls() -> None:
|
|
text = FORWARD.read_text(encoding="utf-8")
|
|
for marker in (
|
|
"lock_timeout",
|
|
"statement_timeout",
|
|
"PRE-FLIGHT VERIFICATION",
|
|
"GRANT",
|
|
"ROW LEVEL SECURITY",
|
|
"has_table_privilege",
|
|
"SET NOT NULL",
|
|
"6. ROLLBACK",
|
|
):
|
|
assert marker in text, marker
|
|
|
|
|
|
def test_rollback_documents_data_loss_and_keeps_username_values() -> None:
|
|
text = ROLLBACK.read_text(encoding="utf-8")
|
|
assert "NOT APPLIED" in text
|
|
assert "DESTRUCTIVE" in text
|
|
assert "DROP TABLE IF EXISTS public.user_credentials" in text
|
|
assert "DESTROYS" in text
|
|
# Username values are preserved unless the operator opts in.
|
|
assert "DROP COLUMN IF EXISTS username" in text
|
|
assert text.index("-- ALTER TABLE public.users DROP COLUMN IF EXISTS username") > 0
|
|
|
|
|
|
def test_readme_lists_the_approval_gates() -> None:
|
|
text = (MIGRATIONS / "README.md").read_text(encoding="utf-8")
|
|
assert "not been executed" in text
|
|
for gate in ("backup", "RLS", "rollback", "PostgreSQL"):
|
|
assert gate in text
|