Files
UmbraTrans/umbratrans_web/tests/test_api_uadmin.py
umbra audio b474995414 Add phase-1 account management backend slice
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.
2026-09-26 08:29:23 -07:00

521 lines
20 KiB
Python

"""HTTP administrator tests: authorization, enrollment, lifecycle, audit."""
from __future__ import annotations
import json
from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
from umbratrans_web.service import AccountService
from .conftest import ApiHarness, activated_admin, provision_local, random_password
@pytest.fixture
def admin(api: ApiHarness, service: AccountService) -> object:
"""An activated platform administrator, signed in over HTTP."""
account = activated_admin(service, username="Umbraadmin")
api.login_ok(account)
return account
# ----------------------------------------------------------------------
# Authorization
# ----------------------------------------------------------------------
def test_admin_routes_require_a_session(client: TestClient) -> None:
for method, path in [
("get", "/api/uadmin/accounts"),
("get", "/api/uadmin/audit"),
("get", f"/api/uadmin/accounts/{uuid4()}"),
]:
response = getattr(client, method)(path)
assert response.status_code == 401, path
assert response.json()["error"]["code"] == "authentication_required"
for method, path in [
("post", "/api/uadmin/accounts"),
("post", f"/api/uadmin/accounts/{uuid4()}/revoke-sessions"),
]:
response = getattr(client, method)(path, json={})
# Rejected by CSRF first, then by authentication; either way, never 2xx.
assert response.status_code in {401, 403}, path
assert response.json()["error"]["code"] in {
"authentication_required",
"csrf_token_missing",
"csrf_token_invalid",
}
def test_members_are_forbidden(api: ApiHarness, service: AccountService) -> None:
member = provision_local(service, username="Member.User", role="member")
activated_admin(service, username="Admin.Helper")
api.login_ok(member) # the temporary password is all this session can do
# Complete the change so the session is usable, then retry.
new_password = random_password(28)
response = api.post(
"/api/auth/change-first-password",
json={"current_password": member.password, "new_password": new_password},
)
assert response.status_code == 200
for method, path, payload in [
("get", "/api/uadmin/accounts", None),
("get", "/api/uadmin/audit", None),
("get", f"/api/uadmin/accounts/{member.user_id}", None),
("post", "/api/uadmin/accounts", {"username": "New.User", "display_name": "New User"}),
("patch", f"/api/uadmin/accounts/{member.user_id}/status", {"status": "suspended"}),
("post", f"/api/uadmin/accounts/{member.user_id}/revoke-sessions", None),
]:
call = getattr(api, method)(path, **({"json": payload} if payload is not None else {}))
assert call.status_code == 403, f"{method} {path} -> {call.status_code}"
assert call.json()["error"]["code"] == "forbidden"
def test_temporary_password_accounts_cannot_reach_admin_routes(api: ApiHarness, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin", role="platform_admin")
api.login_ok(account)
response = api.get("/api/uadmin/accounts")
assert response.status_code == 403
assert response.json()["error"]["code"] == "password_change_required"
# ----------------------------------------------------------------------
# Account creation
# ----------------------------------------------------------------------
def test_create_oidc_account(api: ApiHarness, admin: object, service: AccountService) -> None:
response = api.post(
"/api/uadmin/accounts",
json={
"username": "Oidc.User",
"display_name": "OIDC User",
"enrollment_mode": "oidc",
"role": "member",
"email": "Oidc.User@Example.COM",
},
)
assert response.status_code == 201
body = response.json()
assert body["temporary_password"] is None
account = body["account"]
assert account["username"] == "Oidc.User"
assert account["email"] == "oidc.user@example.com"
assert account["status"] == "active"
assert account["credential"] == {
"credential_type": "oidc",
"must_change_password": False,
"failed_login_count": 0,
"locked": False,
"last_login_at": None,
"password_changed_at": None,
}
assert "password_hash" not in response.text
stored = service.repository.get_credential(UUID(account["id"]))
assert stored is not None
assert stored.password_hash is None
def test_create_account_refuses_a_submitted_password_in_either_mode(api: ApiHarness, admin: object) -> None:
password = random_password(28)
for mode in ("oidc", "temporary_password"):
response = api.post(
"/api/uadmin/accounts",
json={
"username": f"Sneaky.User.{mode}",
"display_name": "Sneaky User",
"enrollment_mode": mode,
"password": password,
},
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"
assert password not in response.text
def test_admin_provisions_a_temporary_password_account(
api: ApiHarness,
admin: object,
service: AccountService,
) -> None:
response = api.post(
"/api/uadmin/accounts",
json={
"username": "Temp.User",
"display_name": "Temp User",
"enrollment_mode": "temporary_password",
"role": "member",
"email": "Temp.User@Example.com",
},
)
assert response.status_code == 201
body = response.json()
account = body["account"]
temporary_password = body["temporary_password"]
# The one-time password is returned once, by this endpoint only.
assert isinstance(temporary_password, str)
assert len(temporary_password) >= 14
assert any(character.isdigit() for character in temporary_password)
assert any(not character.isalnum() for character in temporary_password)
assert account["credential"]["credential_type"] == "local_password"
assert account["credential"]["must_change_password"] is True
assert account["status"] == "active"
assert "$argon2" not in response.text
# It is never stored, logged, or audited in the clear.
stored = service.repository.get_credential(UUID(account["id"]))
assert stored is not None
assert stored.password_hash is not None
assert stored.password_hash.startswith("$argon2id$")
assert temporary_password not in stored.password_hash
assert temporary_password not in repr(stored)
assert temporary_password not in json.dumps(
[event.detail for event in service.repository.list_audit_events()[0]],
default=str,
)
# No other endpoint will ever repeat it.
assert temporary_password not in api.get(f"/api/uadmin/accounts/{account['id']}").text
assert temporary_password not in api.get("/api/uadmin/accounts").text
assert temporary_password not in api.get("/api/uadmin/audit").text
def test_generated_temporary_password_must_be_replaced_on_first_login(
api: ApiHarness,
admin: object,
) -> None:
created = api.post(
"/api/uadmin/accounts",
json={
"username": "First.Login",
"display_name": "First Login",
"enrollment_mode": "temporary_password",
},
)
assert created.status_code == 201
username = created.json()["account"]["username"]
temporary_password = created.json()["temporary_password"]
api.logout()
login = api.login(username, temporary_password)
assert login.status_code == 200
assert login.json()["must_change_password"] is True
# The temporary session can do nothing except replace the password: every
# other route, including /api/auth/me, is refused until the change happens.
for path in ("/api/uadmin/accounts", "/api/auth/me"):
blocked = api.get(path)
assert blocked.status_code == 403, path
assert blocked.json()["error"]["code"] == "password_change_required", path
new_password = random_password(30)
changed = api.post(
"/api/auth/change-first-password",
json={"current_password": temporary_password, "new_password": new_password},
)
assert changed.status_code == 200
assert changed.json()["must_change_password"] is False
# The one-time password is spent: it never works again.
api.logout()
assert api.login(username, temporary_password).status_code == 401
assert api.login(username, new_password).status_code == 200
def test_generated_temporary_passwords_are_unique_per_account(
api: ApiHarness,
admin: object,
) -> None:
issued = {
api.post(
"/api/uadmin/accounts",
json={
"username": f"Bulk.User{index}",
"display_name": f"Bulk User {index}",
"enrollment_mode": "temporary_password",
},
).json()["temporary_password"]
for index in range(5)
}
assert len(issued) == 5
def test_organization_admin_cannot_provision_outside_its_scope(api: ApiHarness, service: AccountService) -> None:
org_a = uuid4()
org_b = uuid4()
activated_admin(service, username="Umbraadmin")
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
api.login_ok(org_admin)
outside = api.post(
"/api/uadmin/accounts",
json={
"username": "Outside.Temp",
"display_name": "Outside Temp",
"enrollment_mode": "temporary_password",
"organization_id": str(org_b),
},
)
escalate = api.post(
"/api/uadmin/accounts",
json={
"username": "Escalate.Temp",
"display_name": "Escalate Temp",
"enrollment_mode": "temporary_password",
"role": "platform_admin",
},
)
assert outside.status_code == 403
assert escalate.status_code == 403
# Nothing was created by either attempt.
assert api.get("/api/uadmin/accounts").json()["total"] == 1
def test_create_account_validates_input(api: ApiHarness, admin: object) -> None:
duplicate = api.post(
"/api/uadmin/accounts",
json={"username": "Umbraadmin", "display_name": "Duplicate", "enrollment_mode": "oidc"},
)
invalid = api.post(
"/api/uadmin/accounts",
json={"username": "bad user", "display_name": "Bad", "enrollment_mode": "oidc"},
)
unknown_mode = api.post(
"/api/uadmin/accounts",
json={"username": "Mode.User", "display_name": "Mode", "enrollment_mode": "carrier_pigeon"},
)
assert duplicate.status_code == 409
assert duplicate.json()["error"]["code"] == "username_taken"
assert invalid.status_code == 422
assert unknown_mode.status_code == 422
# ----------------------------------------------------------------------
# Listing, retrieval, isolation
# ----------------------------------------------------------------------
def test_list_and_get_accounts(api: ApiHarness, admin: object, service: AccountService) -> None:
provision_local(service, username="Member.One", role="member")
provision_local(service, username="Member.Two", role="member")
listing = api.get("/api/uadmin/accounts")
assert listing.status_code == 200
body = listing.json()
assert body["total"] == 3
assert {item["username"] for item in body["items"]} == {"Umbraadmin", "Member.One", "Member.Two"}
assert "password_hash" not in listing.text
assert "$argon2" not in listing.text
member = api.get("/api/uadmin/accounts?status=active&limit=1")
assert member.status_code == 200
assert member.json()["total"] == 3
assert len(member.json()["items"]) == 1
single = api.get(f"/api/uadmin/accounts/{admin.user_id}") # type: ignore[attr-defined]
assert single.status_code == 200
assert single.json()["id"] == str(admin.user_id) # type: ignore[attr-defined]
missing = api.get(f"/api/uadmin/accounts/{uuid4()}")
assert missing.status_code == 404
assert missing.json()["error"]["code"] == "account_not_found"
def test_organization_admin_scope_is_enforced(api: ApiHarness, service: AccountService) -> None:
org_a = uuid4()
org_b = uuid4()
activated_admin(service, username="Umbraadmin")
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
inside = provision_local(service, username="Inside.User", role="member", organization_id=org_a)
outside = provision_local(service, username="Outside.User", role="member", organization_id=org_b)
api.login_ok(org_admin)
listing = api.get("/api/uadmin/accounts")
assert listing.status_code == 200
assert listing.json()["total"] == 2
assert str(outside.user_id) not in listing.text
assert str(inside.user_id) in listing.text
# A forged organization filter cannot widen the scope.
forged = api.get(f"/api/uadmin/accounts?organization_id={org_b}")
assert forged.status_code == 200
assert forged.json()["total"] == 2
assert api.get(f"/api/uadmin/accounts/{outside.user_id}").status_code == 403
assert api.patch(f"/api/uadmin/accounts/{outside.user_id}/status", json={"status": "suspended"}).status_code == 403
assert api.post(f"/api/uadmin/accounts/{outside.user_id}/revoke-sessions").status_code == 403
# Creating an account outside its own organization is refused.
escape = api.post(
"/api/uadmin/accounts",
json={
"username": "Escape.User",
"display_name": "Escape",
"enrollment_mode": "oidc",
"organization_id": str(org_b),
},
)
assert escape.status_code == 403
# Nor can it create a platform administrator.
escalate = api.post(
"/api/uadmin/accounts",
json={
"username": "Escalate.User",
"display_name": "Escalate",
"enrollment_mode": "oidc",
"role": "platform_admin",
"organization_id": str(org_a),
},
)
assert escalate.status_code == 403
# ----------------------------------------------------------------------
# Lifecycle
# ----------------------------------------------------------------------
def test_suspend_reactivate_and_deactivate(api: ApiHarness, admin: object, service: AccountService) -> None:
member = provision_local(service, username="Member.User", role="member")
# A separate browser holds the member's live session while the
# administrator acts through the first client.
with TestClient(api.client.app) as member_client:
result = member_client.post("/api/auth/login", json={"username": member.username, "password": member.password})
assert result.status_code == 200
suspended = api.patch(
f"/api/uadmin/accounts/{member.user_id}/status",
json={"status": "suspended", "reason": "policy review"},
)
assert suspended.status_code == 200
assert suspended.json()["account"]["status"] == "suspended"
assert suspended.json()["revoked_sessions"] == 1
# The suspended user's live session is dead.
assert member_client.get("/api/auth/me").status_code == 401
reactivated = api.patch(
f"/api/uadmin/accounts/{member.user_id}/status",
json={"status": "active", "reason": "cleared"},
)
assert reactivated.status_code == 200
assert reactivated.json()["account"]["status"] == "active"
deactivated = api.patch(f"/api/uadmin/accounts/{member.user_id}/status", json={"status": "deactivated"})
assert deactivated.status_code == 200
assert deactivated.json()["account"]["status"] == "deactivated"
# Logging in as the deactivated account is refused.
assert api.login(member.username, member.password).status_code == 401
def test_status_change_of_the_last_platform_admin_is_refused(api: ApiHarness, admin: object) -> None:
response = api.patch(
f"/api/uadmin/accounts/{admin.user_id}/status", # type: ignore[attr-defined]
json={"status": "suspended"},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "last_platform_admin"
def test_revoke_sessions_endpoint(api: ApiHarness, admin: object, service: AccountService) -> None:
member = provision_local(service, username="Member.User", role="member")
with TestClient(api.client.app) as member_client:
result = member_client.post("/api/auth/login", json={"username": member.username, "password": member.password})
assert result.status_code == 200
revoked = api.post(f"/api/uadmin/accounts/{member.user_id}/revoke-sessions")
assert revoked.status_code == 200
assert revoked.json()["revoked_sessions"] == 1
assert member_client.get("/api/auth/me").status_code == 401
# Idempotent: nothing is left to revoke.
assert api.post(f"/api/uadmin/accounts/{member.user_id}/revoke-sessions").json() == {"revoked_sessions": 0}
def test_admin_cannot_suspend_itself_into_a_lockout(api: ApiHarness, admin: object, service: AccountService) -> None:
second_admin = activated_admin(service, username="Second.Admin")
# A second platform administrator exists, so the first may step down.
response = api.patch(
f"/api/uadmin/accounts/{admin.user_id}/status", # type: ignore[attr-defined]
json={"status": "suspended", "reason": "handover"},
)
assert response.status_code == 200
assert api.client.get("/api/auth/me").status_code == 401
assert second_admin.username == "Second.Admin"
# ----------------------------------------------------------------------
# Audit
# ----------------------------------------------------------------------
def test_audit_endpoint_returns_sanitized_events(api: ApiHarness, admin: object, service: AccountService) -> None:
member = provision_local(service, username="Member.User", role="member")
api.patch(
f"/api/uadmin/accounts/{member.user_id}/status",
json={"status": "suspended", "reason": f"password={random_password()}"},
)
response = api.get("/api/uadmin/audit")
assert response.status_code == 200
body = response.json()
assert body["total"] >= 1
actions = {item["action"] for item in body["items"]}
assert "account.status_changed" in actions
assert "auth.login" in actions
for item in body["items"]:
assert set(item) == {
"id",
"occurred_at",
"action",
"outcome",
"actor_user_id",
"target_user_id",
"organization_id",
"request_id",
"detail",
}
assert "$argon2" not in str(item)
assert "password" not in response.text or '"password"' not in response.text
def test_audit_is_scoped_to_the_callers_organization(api: ApiHarness, service: AccountService) -> None:
org_a = uuid4()
org_b = uuid4()
activated_admin(service, username="Umbraadmin")
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
other = provision_local(service, username="Other.User", role="member", organization_id=org_b)
api.login_ok(org_admin)
response = api.get("/api/uadmin/audit")
assert response.status_code == 200
assert str(other.user_id) not in response.text
filtered = api.get(f"/api/uadmin/audit?target_user_id={other.user_id}")
assert filtered.status_code == 403
def test_audit_events_reference_the_request_id(api: ApiHarness, admin: object, service: AccountService) -> None:
response = api.post(
"/api/uadmin/accounts",
json={"username": "Traced.User", "display_name": "Traced", "enrollment_mode": "oidc"},
)
assert response.status_code == 201
request_id = response.headers["x-request-id"]
events = api.get(f"/api/uadmin/audit?target_user_id={response.json()['account']['id']}").json()
assert any(event["request_id"] == request_id for event in events["items"])
assert all(event["request_id"] for event in events["items"] if event["action"] == "account.created")