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.
679 lines
29 KiB
Python
679 lines
29 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,
|
|
ensure_organization,
|
|
member_principal,
|
|
provision_local,
|
|
provision_oidc,
|
|
random_email,
|
|
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",
|
|
email=random_email("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",
|
|
email=random_email("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",
|
|
email=random_email("escalate-user"),
|
|
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",
|
|
email=random_email("no-password"),
|
|
operator=OperatorContext(source="operator-cli"),
|
|
)
|
|
assert error.value.code == "password_required"
|
|
|
|
|
|
def test_account_creation_requires_an_email(service: AccountService) -> None:
|
|
"""users.email is NOT NULL in the control plane, so the rule is in the domain.
|
|
|
|
Enforcing it here rather than in the store means a missing address fails the
|
|
same way against the development repositories and the authoritative store.
|
|
"""
|
|
|
|
actor = admin_principal(provision_local(service, username="Umbraadmin"))
|
|
with pytest.raises(InputError) as error:
|
|
service.create_oidc_account(
|
|
username="No.Email",
|
|
display_name="No Email",
|
|
email=None, # type: ignore[arg-type]
|
|
actor=actor,
|
|
)
|
|
assert error.value.code == "email_required"
|
|
|
|
with pytest.raises(InputError) as operator_error:
|
|
service.create_account(
|
|
enrollment_mode="temporary_password",
|
|
username="Also.No.Email",
|
|
display_name="Also No Email",
|
|
email=None, # type: ignore[arg-type]
|
|
password=random_password(28),
|
|
operator=OperatorContext(source="operator-cli"),
|
|
)
|
|
assert operator_error.value.code == "email_required"
|
|
|
|
|
|
def test_a_malformed_email_is_rejected(service: AccountService) -> None:
|
|
with pytest.raises(InputError) as error:
|
|
provision_local(service, username="Bad.Email", email="not-an-address")
|
|
assert error.value.code == "invalid_email"
|
|
|
|
|
|
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 = ensure_organization(service, name="Org A").id
|
|
org_b = ensure_organization(service, name="Org B").id
|
|
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 = ensure_organization(service, name="Scoped Org").id
|
|
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 = ensure_organization(service, name="Scoped Org").id
|
|
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",
|
|
email=random_email("escalated-user"),
|
|
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",
|
|
email=random_email("blocked-user"),
|
|
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
|