Files
UmbraTrans/umbratrans_web/tests/test_service.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

632 lines
27 KiB
Python

"""Service-level tests: enrollment, authentication, sessions, authorization."""
from __future__ import annotations
from dataclasses import fields, is_dataclass
from uuid import UUID, uuid4
import pytest
from umbratrans_web.errors import (
ConflictError,
EnrollmentRefusedError,
ForbiddenError,
InputError,
InvalidCredentialsError,
OperatorRefusalError,
PasswordChangeRequiredError,
PasswordPolicyError,
StateTransitionError,
)
from umbratrans_web.models import AccountStatus, CredentialType, Principal, RoleName
from umbratrans_web.service import AccountService, OperatorContext
from .conftest import (
admin_principal,
member_principal,
provision_local,
provision_oidc,
random_password,
)
# ----------------------------------------------------------------------
# Enrollment
# ----------------------------------------------------------------------
def test_operator_enrollment_stores_only_an_argon2id_hash(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
stored = repository.get_credential(account.user_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.credential_type is CredentialType.LOCAL_PASSWORD
assert stored.password_hash is not None
assert stored.password_hash.startswith("$argon2id$")
assert account.password not in stored.password_hash
assert stored.must_change_password is True
assert account.view.credential.must_change_password is True
def test_repository_state_contains_no_plaintext_password(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
repository_object = repository
blobs: list[str] = []
for attribute in vars(repository_object).values():
if isinstance(attribute, dict):
for value in attribute.values():
blobs.append(repr(value))
elif isinstance(attribute, list):
blobs.extend(repr(value) for value in attribute)
joined = "\n".join(blobs)
# Nothing the repository holds - including every repr - contains the
# plaintext, and the hash is hidden from repr as well.
assert account.password not in joined
assert "$argon2id$" not in joined
stored = repository.get_credential(account.user_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.password_hash is not None
assert stored.password_hash.startswith("$argon2id$")
def test_credential_records_hide_hashes_in_repr(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
stored = service.repository.get_credential(account.user_id)
assert stored is not None
assert stored.password_hash not in repr(stored)
assert account.password not in repr(stored)
def test_username_is_unique_case_insensitively(service: AccountService) -> None:
provision_local(service, username="Umbraadmin")
with pytest.raises(ConflictError) as error:
provision_local(service, username="umbraadmin")
assert error.value.code == "username_taken"
def test_generic_create_account_path_requires_the_operator_context(service: AccountService) -> None:
"""The generic entry point stays operator-only for local passwords.
Administrator-driven enrollment has its own entry point,
:meth:`provision_admin_temporary_password`, which generates the secret
instead of accepting one.
"""
actor = admin_principal(provision_local(service, username="Umbraadmin"))
password = random_password()
with pytest.raises(EnrollmentRefusedError) as error:
service.create_account(
enrollment_mode="temporary_password",
username="No.Path",
display_name="No Path",
password=password,
actor=actor,
)
assert error.value.code == "operator_context_required"
assert service.repository.get_user_by_username("no.path") is None
def test_admin_enrollment_generates_a_one_time_password(service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
issued = service.provision_admin_temporary_password(
username="Temp.User",
display_name="Temp User",
actor=actor,
)
# Handed back once, and hidden from any accidental rendering or log line.
assert issued.temporary_password not in repr(issued)
assert issued.account.credential.must_change_password is True
assert issued.account.credential.credential_type is CredentialType.LOCAL_PASSWORD
stored = service.repository.get_credential(issued.account.id)
assert stored is not None
assert stored.password_hash is not None
assert stored.password_hash.startswith("$argon2id$")
assert issued.temporary_password not in stored.password_hash
# It authenticates once, and only far enough to be replaced.
first = service.authenticate(username="Temp.User", password=issued.temporary_password)
assert first.session.must_change_password is True
replacement = random_password(30)
service.change_first_password(
service.resolve_session(first.session.token),
current_password=issued.temporary_password,
new_password=replacement,
)
with pytest.raises(InvalidCredentialsError):
service.authenticate(username="Temp.User", password=issued.temporary_password)
assert service.authenticate(username="Temp.User", password=replacement).session.must_change_password is False
def test_admin_enrollment_requires_an_administrator(service: AccountService) -> None:
member = provision_local(service, username="Member.User", role="member")
with pytest.raises(ForbiddenError):
service.provision_admin_temporary_password(
username="Escalate.User",
display_name="Escalate",
actor=member_principal(member),
)
assert service.repository.get_user_by_username("escalate.user") is None
def test_temporary_password_enrollment_requires_a_password(service: AccountService) -> None:
with pytest.raises(InputError) as error:
service.create_account(
enrollment_mode="temporary_password",
username="No.Password",
display_name="No Password",
operator=OperatorContext(source="operator-cli"),
)
assert error.value.code == "password_required"
def test_oidc_enrollment_creates_no_password_material(repository: object, service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
account = provision_oidc(service, actor, username="Oidc.User")
stored = repository.get_credential(account.user_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.credential_type is CredentialType.OIDC
assert stored.password_hash is None
assert stored.must_change_password is False
assert account.view.credential.credential_type is CredentialType.OIDC
def test_oidc_account_cannot_authenticate_with_a_local_password(service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
account = provision_oidc(service, actor, username="Oidc.User")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
def test_only_declared_operator_sources_are_accepted() -> None:
with pytest.raises(OperatorRefusalError) as error:
OperatorContext(source="http-post")
assert error.value.code == "unknown_operator_source"
# The HTTP layer never builds this object at all; see test_api_uadmin.
assert OperatorContext(source="operator-cli").interactive is True
# ----------------------------------------------------------------------
# Authentication
# ----------------------------------------------------------------------
def test_authentication_issues_a_session_without_exposing_the_token(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
assert result.session.token
assert result.session.must_change_password is True
principal = service.resolve_session(result.session.token)
assert principal.user_id == account.user_id
assert principal.must_change_password is True
def test_stored_session_token_is_a_hash_not_the_token(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
stored = repository.get_session(result.session.session_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.token_hash != result.session.token
assert result.session.token not in stored.token_hash
assert result.session.token not in repr(stored)
def test_unknown_user_and_wrong_password_are_indistinguishable(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError) as unknown:
service.authenticate(username="Nobody.Here", password=random_password())
with pytest.raises(InvalidCredentialsError) as wrong:
service.authenticate(username=account.username, password=random_password())
assert unknown.value.code == wrong.value.code == "invalid_credentials"
assert unknown.value.message == wrong.value.message
assert unknown.value.message == InvalidCredentialsError().message
def test_login_failures_are_audited_without_the_username_value(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
events, _ = repository.list_audit_events(target_user_id=account.user_id) # type: ignore[attr-defined]
failures = [event for event in events if event.action == "auth.login" and event.outcome == "failure"]
assert failures
assert failures[0].detail.get("reason") == "invalid_password"
assert "password" not in failures[0].detail
def test_repeated_failures_lock_the_credential(service: AccountService, settings: object) -> None:
account = provision_local(service, username="Umbraadmin")
for _ in range(settings.max_failed_login_attempts): # type: ignore[attr-defined]
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
stored = service.repository.get_credential(account.user_id)
assert stored is not None
assert stored.failed_login_count >= settings.max_failed_login_attempts # type: ignore[attr-defined]
assert stored.is_locked(service._now()) # type: ignore[attr-defined]
# Even the correct password is refused while locked, with the same error.
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=account.password)
events, _ = service.repository.list_audit_events(target_user_id=account.user_id)
assert any(event.detail.get("lock_state") == "locked" for event in events)
def test_successful_login_clears_the_failure_counter(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
service.authenticate(username=account.username, password=account.password)
stored = service.repository.get_credential(account.user_id)
assert stored is not None
assert stored.failed_login_count == 0
assert stored.last_login_at is not None
# ----------------------------------------------------------------------
# First-login password change
# ----------------------------------------------------------------------
def test_first_login_change_rotates_the_password_and_revokes_old_sessions(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
first = service.authenticate(username=account.username, password=account.password)
second = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(first.session.token)
new_password = random_password(28)
result = service.change_first_password(principal, current_password=account.password, new_password=new_password)
assert result.session.token != first.session.token
assert result.session.must_change_password is False
# The previous sessions, including the one used for the change, are gone.
with pytest.raises(InvalidCredentialsError):
service.resolve_session(first.session.token)
with pytest.raises(InvalidCredentialsError):
service.resolve_session(second.session.token)
# The temporary password no longer works.
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=account.password)
# The new password does, and no longer forces a change.
reopened = service.authenticate(username=account.username, password=new_password)
assert reopened.session.must_change_password is False
assert service.resolve_session(result.session.token).must_change_password is False
def test_first_login_change_requires_the_temporary_password(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(result.session.token)
with pytest.raises(InvalidCredentialsError):
service.change_first_password(principal, current_password=random_password(), new_password=random_password(28))
def test_first_login_change_rejects_password_reuse_and_weak_values(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(result.session.token)
with pytest.raises(PasswordPolicyError) as reuse:
service.change_first_password(principal, current_password=account.password, new_password=account.password)
assert reuse.value.unmet_requirements == ("must_differ_from_current",)
with pytest.raises(PasswordPolicyError):
service.change_first_password(principal, current_password=account.password, new_password="short")
# ----------------------------------------------------------------------
# Session lifecycle
# ----------------------------------------------------------------------
def test_logout_revokes_only_the_current_session(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
first = service.authenticate(username=account.username, password=account.password)
second = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(first.session.token)
service.logout(principal)
with pytest.raises(InvalidCredentialsError):
service.resolve_session(first.session.token)
assert service.resolve_session(second.session.token).user_id == account.user_id
def test_logout_everywhere_revokes_all_sessions(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
tokens = [
service.authenticate(username=account.username, password=account.password).session.token for _ in range(3)
]
principal = service.resolve_session(tokens[0])
revoked = service.logout_everywhere(principal)
assert revoked == 3
for token in tokens:
with pytest.raises(InvalidCredentialsError):
service.resolve_session(token)
def test_expired_sessions_are_rejected_and_revoked(service: AccountService, settings: object) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
stored = service.repository.get_session(result.session.session_id)
assert stored is not None
from dataclasses import replace
expired = replace(stored, idle_deadline_at=service._now()) # type: ignore[attr-defined]
service.repository.save_session(expired)
with pytest.raises(InvalidCredentialsError):
service.resolve_session(result.session.token)
assert service.repository.get_session(result.session.session_id).revoked_reason == "expired" # type: ignore[union-attr]
def test_unknown_and_forged_tokens_are_rejected(service: AccountService) -> None:
for token in ("", "not-a-token", random_password(40)):
with pytest.raises(InvalidCredentialsError):
service.resolve_session(token)
# ----------------------------------------------------------------------
# Account lifecycle and authorization
# ----------------------------------------------------------------------
def test_suspension_revokes_sessions_and_blocks_new_logins(repository: object, service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
member_result = service.authenticate(username=member.username, password=member.password)
assert service.resolve_session(member_result.session.token).user_id == member.user_id
view, revoked = service.set_account_status(actor, member.user_id, "suspended", reason="policy review")
assert view.status is AccountStatus.SUSPENDED
assert revoked == 1
with pytest.raises(InvalidCredentialsError):
service.resolve_session(member_result.session.token)
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=member.username, password=member.password)
def test_reactivation_restores_access(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
service.set_account_status(actor, member.user_id, "suspended", reason="pause")
view, _ = service.set_account_status(actor, member.user_id, "active")
assert view.status is AccountStatus.ACTIVE
result = service.authenticate(username=member.username, password=member.password)
assert service.resolve_session(result.session.token).user_id == member.user_id
def test_administrative_session_revocation(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
result = service.authenticate(username=member.username, password=member.password)
assert service.revoke_sessions(actor, member.user_id) == 1
with pytest.raises(InvalidCredentialsError):
service.resolve_session(result.session.token)
def test_last_platform_admin_cannot_be_suspended(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
with pytest.raises(ConflictError) as error:
service.set_account_status(actor, admin.user_id, "suspended")
assert error.value.code == "last_platform_admin"
def test_status_transitions_are_enforced(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
with pytest.raises(StateTransitionError):
service.set_account_status(actor, member.user_id, "active")
service.set_account_status(actor, member.user_id, "deactivated")
with pytest.raises(StateTransitionError):
service.set_account_status(actor, member.user_id, "deactivated")
def test_members_cannot_use_administrator_operations(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
actor = member_principal(member)
with pytest.raises(ForbiddenError):
service.list_accounts(actor)
with pytest.raises(ForbiddenError):
service.get_account(actor, admin.user_id)
with pytest.raises(ForbiddenError):
service.set_account_status(actor, admin.user_id, "suspended")
with pytest.raises(ForbiddenError):
service.revoke_sessions(actor, admin.user_id)
with pytest.raises(ForbiddenError):
service.list_audit_events(actor)
def test_organization_admin_cannot_cross_organizations(service: AccountService) -> None:
org_a = uuid4()
org_b = uuid4()
org_admin_account = provision_local(
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org_a
)
other = provision_local(service, username="Other.User", role=str(RoleName.MEMBER), organization_id=org_b)
same_org = provision_local(service, username="Same.User", role=str(RoleName.MEMBER), organization_id=org_a)
actor = admin_principal(org_admin_account, organization_id=org_a)
visible, total = service.list_accounts(actor)
assert total == 2
assert {view.id for view in visible} == {org_admin_account.user_id, same_org.user_id}
with pytest.raises(ForbiddenError):
service.get_account(actor, other.user_id)
with pytest.raises(ForbiddenError):
service.set_account_status(actor, other.user_id, "suspended")
with pytest.raises(ForbiddenError):
service.revoke_sessions(actor, other.user_id)
def test_organization_admin_cannot_manage_a_platform_admin(service: AccountService) -> None:
org = uuid4()
admin = provision_local(service, username="Umbraadmin")
org_admin_account = provision_local(
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org
)
actor = admin_principal(org_admin_account, organization_id=org)
with pytest.raises(ForbiddenError) as error:
service.get_account(actor, admin.user_id)
assert error.value.code == "platform_admin_only"
def test_organization_admin_cannot_promote_to_platform_admin(service: AccountService) -> None:
org = uuid4()
org_admin_account = provision_local(
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org
)
actor = admin_principal(org_admin_account, organization_id=org)
with pytest.raises(ForbiddenError):
service.create_oidc_account(
username="Escalated.User",
display_name="Escalated",
role=str(RoleName.PLATFORM_ADMIN),
organization_id=org,
actor=actor,
)
def test_password_change_principal_cannot_use_admin_operations(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=admin.username, password=admin.password)
principal = service.resolve_session(result.session.token)
assert principal.must_change_password is True
with pytest.raises(PasswordChangeRequiredError):
service.list_accounts(principal)
with pytest.raises(PasswordChangeRequiredError):
service.create_oidc_account(username="Blocked.User", display_name="Blocked", actor=principal)
def test_audit_events_are_sanitized_and_scoped(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
service.set_account_status(actor, member.user_id, "suspended", reason=f"password={random_password()}")
events, total = service.list_audit_events(actor)
assert total >= 1
statuses = [event for event in events if event.action == "account.status_changed"]
assert statuses
for event in events:
for key in event.detail:
assert key in {
"credential_type",
"enrollment_mode",
"lock_state",
"must_change_password",
"organization_id",
"previous_status",
"reason",
"revoked_sessions",
"role",
"source",
"status",
"username",
}
assert "password" not in event.detail
assert "token" not in event.detail
# The audit view is scoped: a non-admin sees nothing.
with pytest.raises(ForbiddenError):
service.list_audit_events(member_principal(member))
def test_audit_detail_never_contains_a_password_value(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
events, _ = service.repository.list_audit_events(target_user_id=account.user_id)
for event in events:
for value in event.detail.values():
assert account.password not in value
assert "$argon2" not in value
# ----------------------------------------------------------------------
# Model invariants
# ----------------------------------------------------------------------
def test_oidc_credential_cannot_store_a_hash() -> None:
from umbratrans_web.models import CredentialRecord
with pytest.raises(InputError):
CredentialRecord(
id=uuid4(),
user_id=uuid4(),
credential_type=CredentialType.OIDC,
password_hash="$argon2id$fake",
)
with pytest.raises(InputError):
CredentialRecord(
id=uuid4(),
user_id=uuid4(),
credential_type=CredentialType.LOCAL_PASSWORD,
password_hash=None,
)
def test_account_view_is_a_plain_dataclass_without_hash_fields() -> None:
from umbratrans_web.models import CredentialSummary
names = {item.name for item in fields(CredentialSummary)}
assert is_dataclass(CredentialSummary)
assert "password_hash" not in names
assert names == {
"credential_type",
"must_change_password",
"failed_login_count",
"locked",
"last_login_at",
"password_changed_at",
}
def test_username_and_display_name_validation() -> None:
from umbratrans_web.errors import InputError
from umbratrans_web.models import normalize_display_name, normalize_email, normalize_username, username_key
assert normalize_username(" Umbra.admin-1 ") == "Umbra.admin-1"
assert username_key("UmbraAdmin") == "umbraadmin"
for bad in ("", "ab", "with space", "with/slash", "x" * 65):
with pytest.raises(InputError):
normalize_username(bad)
assert normalize_email(" User@Example.COM ") == "user@example.com"
with pytest.raises(InputError):
normalize_email("not-an-email")
assert normalize_display_name(" Umbra Admin ") == "Umbra Admin"
def test_principal_organization_is_never_taken_from_the_browser() -> None:
"""The principal type has no field a browser could populate directly."""
principal = Principal(
user_id=UUID(int=1),
username="Umbraadmin",
role=RoleName.PLATFORM_ADMIN,
session_id=UUID(int=2),
organization_id=None,
)
assert not hasattr(principal, "request")
assert principal.is_admin is True