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

1039 lines
39 KiB
Python

"""Account service: enrollment, authentication, sessions, and administration.
This module is the only place that knows how an account is created, how a
password is verified, and what a session is. Both enrollment modes are
implemented here and are separated by a code-level boundary rather than by a
convention:
``temporary_password``
Has two callers, and both keep the plaintext out of the request surface.
The operator CLI reads it from a hidden local prompt and passes it with an
:class:`OperatorContext`. The administrator API never accepts a submitted
password at all: it asks the service to generate a one-time password
in-process, which is hashed immediately and returned to the authenticated
administrator exactly once. Either way the credential is marked
``must_change_password=True`` so the first login can do nothing else.
``oidc``
Requires an authenticated administrator principal. No local password is
created or stored; the identity provider completes enrollment.
Rules enforced here:
* The plaintext password is never persisted, returned, logged, or audited.
* Every login failure returns the same generic error, whatever the real cause;
the cause is recorded in the sanitized audit trail instead.
* Sessions store only a hash of the bearer token and are revoked on
suspension, deactivation, password change, and logout-everywhere.
* Account lookups and mutations are authorized against a server-resolved
principal; no caller-supplied organization or role is trusted.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from types import MappingProxyType
from typing import Callable, Mapping
from uuid import UUID, uuid4
from .config import Settings
from .errors import (
ConflictError,
EnrollmentRefusedError,
ForbiddenError,
InputError,
InvalidCredentialsError,
NotFoundError,
OperatorRefusalError,
PasswordChangeRequiredError,
PasswordPolicyError,
StateTransitionError,
)
from .models import (
ALLOWED_STATUS_TRANSITIONS,
AccountStatus,
AccountView,
AuditEvent,
AuthenticationResult,
CredentialRecord,
CredentialSummary,
CredentialType,
EnrollmentMode,
IssuedSession,
Principal,
RoleName,
SessionRecord,
User,
normalize_display_name,
normalize_email,
normalize_reason,
normalize_username,
username_key,
)
from .repository import AccountRepository
from .security import (
PasswordPolicy,
PasswordService,
generate_token,
hash_session_token,
sanitize_audit_detail,
utc_now,
)
LOGGER = logging.getLogger("umbratrans_web.service")
#: Sources that may request the operator-only local temporary-password path.
OPERATOR_SOURCES = frozenset({"operator-cli", "operator-service"})
#: Audit/log label for administrator-driven temporary-password enrollment.
ADMIN_ENROLLMENT_SOURCE = "admin-api"
#: Audit action names. Stable strings, no free-form data.
ACTION_ACCOUNT_CREATED = "account.created"
ACTION_ACCOUNT_PROVISIONED_LOCAL = "account.provisioned_local"
ACTION_ACCOUNT_STATUS_CHANGED = "account.status_changed"
ACTION_AUTH_LOGIN = "auth.login"
ACTION_AUTH_FIRST_PASSWORD_CHANGED = "auth.first_password_changed"
ACTION_AUTH_LOGOUT = "auth.logout"
ACTION_AUTH_LOGOUT_EVERYWHERE = "auth.logout_everywhere"
ACTION_SESSION_REVOKED = "session.revoked"
ACTION_SESSION_EXPIRED = "session.expired"
ACTION_SESSION_INVALIDATED = "session.invalidated"
@dataclass(frozen=True, slots=True)
class OperatorContext:
"""Proves the caller is the operator path, not a public API request.
The HTTP layer never constructs this object, which is what keeps
temporary-password enrollment off the JSON API surface.
"""
source: str
interactive: bool = True
def __post_init__(self) -> None:
if self.source not in OPERATOR_SOURCES:
raise OperatorRefusalError("unknown_operator_source", f"Unknown operator source {self.source!r}.")
@dataclass(frozen=True, slots=True)
class IssuedTemporaryPassword:
"""An account plus the one-time password generated for it.
``temporary_password`` is excluded from ``repr`` so an accidental log line
or traceback cannot carry the value. It exists only in the response that
returns it from account creation, and the caller is expected to display it
once to the account owner.
"""
account: AccountView
temporary_password: str = field(repr=False)
class AccountService:
"""Application service for identity and account management."""
def __init__(
self,
repository: AccountRepository,
settings: Settings,
*,
clock: Callable[[], datetime] = utc_now,
password_service: PasswordService | None = None,
) -> None:
self._repository = repository
self._settings = settings
self._clock = clock
self._passwords = password_service or PasswordService(
memory_cost_kib=settings.argon2_memory_cost_kib,
time_cost=settings.argon2_time_cost,
parallelism=settings.argon2_parallelism,
hash_length=settings.argon2_hash_length,
salt_length=settings.argon2_salt_length,
policy=PasswordPolicy(min_length=settings.password_min_length, max_length=settings.password_max_length),
)
# -- introspection -------------------------------------------------------
@property
def settings(self) -> Settings:
return self._settings
@property
def repository(self) -> AccountRepository:
return self._repository
def password_policy_description(self) -> dict[str, object]:
return self._passwords.policy.describe()
def _now(self) -> datetime:
return self._clock()
# -- enrollment ----------------------------------------------------------
def create_account(
self,
*,
enrollment_mode: str,
username: str,
display_name: str,
role: str = str(RoleName.MEMBER),
email: str | None = None,
organization_id: UUID | None = None,
password: str | None = None,
actor: Principal | None = None,
operator: OperatorContext | None = None,
request_id: str | None = None,
) -> AccountView:
"""Create an account through one of the two enrollment modes.
``temporary_password`` is the operator path and requires ``operator``
plus ``password``; use :meth:`provision_admin_temporary_password` for
administrator-driven enrollment. ``oidc`` requires an administrator
``actor`` and never stores a password.
"""
mode = _enrollment_mode(enrollment_mode)
if mode is EnrollmentMode.TEMPORARY_PASSWORD:
return self._provision_local_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
password=password,
operator=operator,
request_id=request_id,
)
return self.create_oidc_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
actor=actor,
request_id=request_id,
)
def _provision_local_account(
self,
*,
username: str,
display_name: str,
role: str,
email: str | None,
organization_id: UUID | None,
password: str | None,
operator: OperatorContext | None,
request_id: str | None,
) -> AccountView:
"""Operator-only enrollment with a one-time local password."""
if operator is None:
raise EnrollmentRefusedError(
"operator_context_required",
"Temporary password enrollment requires the operator provisioning path.",
)
if not isinstance(password, str) or not password:
raise InputError("password_required", "A one-time password is required for this enrollment mode.")
self._passwords.policy.validate(password, username=username)
password_hash = self._passwords.hash_password(password)
user, credential = self._insert_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
credential_type=CredentialType.LOCAL_PASSWORD,
password_hash=password_hash,
must_change_password=True,
)
self._audit(
action=ACTION_ACCOUNT_PROVISIONED_LOCAL,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"role": user.role,
"organization_id": user.organization_id,
"enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD,
"credential_type": credential.credential_type,
"must_change_password": True,
"source": operator.source,
},
)
LOGGER.info(
"account provisioned username=%s user_id=%s mode=temporary_password source=%s",
user.username,
user.id,
operator.source,
)
return self._view(user, now=self._now())
def provision_admin_temporary_password(
self,
*,
username: str,
display_name: str,
role: str = str(RoleName.MEMBER),
email: str | None = None,
organization_id: UUID | None = None,
actor: Principal | None = None,
request_id: str | None = None,
) -> IssuedTemporaryPassword:
"""Administrator-driven enrollment with a generated one-time password.
The password is generated inside the service, validated against the
local policy, and hashed before it is returned. No password is accepted
from the caller, so no request body, header, or queue message can carry
one; the plaintext leaves the process only in the single creation
response, is excluded from ``repr``, and is never logged, audited, or
stored. The account is created with ``must_change_password=True``, so it
can do nothing beyond replacing this password on its first login.
"""
self._require_admin(actor)
if actor is None: # pragma: no cover - _require_admin raises first
raise ForbiddenError("administrator_required")
self._authorize_creation(actor, role=role, organization_id=organization_id)
temporary_password = self._passwords.generate_temporary_password(username=normalize_username(username))
user, credential = self._insert_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
credential_type=CredentialType.LOCAL_PASSWORD,
password_hash=self._passwords.hash_password(temporary_password),
must_change_password=True,
)
self._audit(
action=ACTION_ACCOUNT_PROVISIONED_LOCAL,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"role": user.role,
"organization_id": user.organization_id,
"enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD,
"credential_type": credential.credential_type,
"must_change_password": True,
"source": ADMIN_ENROLLMENT_SOURCE,
},
)
LOGGER.info(
"account provisioned username=%s user_id=%s mode=temporary_password source=%s",
user.username,
user.id,
ADMIN_ENROLLMENT_SOURCE,
)
return IssuedTemporaryPassword(account=self._view(user, now=self._now()), temporary_password=temporary_password)
def create_oidc_account(
self,
*,
username: str,
display_name: str,
role: str = str(RoleName.MEMBER),
email: str | None = None,
organization_id: UUID | None = None,
actor: Principal | None = None,
request_id: str | None = None,
) -> AccountView:
"""Administrator-driven OIDC enrollment. No local password is created."""
self._require_admin(actor)
if actor is None: # pragma: no cover - _require_admin raises first
raise ForbiddenError("administrator_required")
self._authorize_creation(actor, role=role, organization_id=organization_id)
user, credential = self._insert_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
credential_type=CredentialType.OIDC,
password_hash=None,
must_change_password=False,
)
self._audit(
action=ACTION_ACCOUNT_CREATED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"role": user.role,
"organization_id": user.organization_id,
"enrollment_mode": EnrollmentMode.OIDC,
"credential_type": credential.credential_type,
"must_change_password": False,
},
)
LOGGER.info("account created username=%s user_id=%s mode=oidc", user.username, user.id)
return self._view(user, now=self._now())
def _insert_account(
self,
*,
username: str,
display_name: str,
role: str,
email: str | None,
organization_id: UUID | None,
credential_type: CredentialType,
password_hash: str | None,
must_change_password: bool,
) -> tuple[User, CredentialRecord]:
now = self._now()
canonical_username = normalize_username(username)
user = User(
id=uuid4(),
username=canonical_username,
username_key=username_key(canonical_username),
display_name=normalize_display_name(display_name),
role=_role(role),
status=AccountStatus.ACTIVE,
organization_id=organization_id,
email=normalize_email(email),
created_at=now,
updated_at=now,
)
credential = CredentialRecord(
id=uuid4(),
user_id=user.id,
credential_type=credential_type,
password_hash=password_hash,
must_change_password=must_change_password,
created_at=now,
updated_at=now,
)
self._repository.create_user(user)
self._repository.save_credential(credential)
return user, credential
# -- authentication ------------------------------------------------------
def authenticate(self, *, username: str, password: str, request_id: str | None = None) -> AuthenticationResult:
"""Authenticate a local password and issue a server-side session.
Every rejection path raises the same :class:`InvalidCredentialsError` so
that the response cannot be used to enumerate accounts.
"""
now = self._now()
key = _safe_username_key(username)
if key is None or not isinstance(password, str) or not password:
self._passwords.verify_dummy(password if isinstance(password, str) else "")
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
request_id=request_id,
detail={"reason": "malformed_request"},
)
raise InvalidCredentialsError()
user = self._repository.get_user_by_username(key)
if user is None:
self._passwords.verify_dummy(password)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
request_id=request_id,
detail={"reason": "unknown_account"},
)
raise InvalidCredentialsError()
credential = self._repository.get_credential(user.id)
if credential is None or credential.credential_type is not CredentialType.LOCAL_PASSWORD:
# OIDC accounts never authenticate with a local password; burn a
# comparable amount of CPU before failing.
self._passwords.verify_dummy(password)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "not_local_password_credential"},
)
raise InvalidCredentialsError()
if credential.is_locked(now):
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="blocked",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "credential_locked", "lock_state": "locked"},
)
raise InvalidCredentialsError()
if user.status is not AccountStatus.ACTIVE:
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="denied",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": f"account_{user.status.value}", "status": user.status},
)
raise InvalidCredentialsError()
if not self._passwords.verify_password(credential.password_hash, password):
failed_count = credential.failed_login_count + 1
locked_until: datetime | None = None
lock_state = "none"
if failed_count >= self._settings.max_failed_login_attempts:
locked_until = now + timedelta(seconds=self._settings.lockout_seconds)
lock_state = "locked"
self._repository.save_credential(
replace(credential, failed_login_count=failed_count, locked_until=locked_until, updated_at=now)
)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "invalid_password", "lock_state": lock_state, "username": user.username},
)
raise InvalidCredentialsError()
self._repository.save_credential(
replace(credential, failed_login_count=0, locked_until=None, last_login_at=now, updated_at=now)
)
session = self._issue_session(user, now=now, must_change_password=credential.must_change_password)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"must_change_password": credential.must_change_password,
"credential_type": credential.credential_type,
},
)
LOGGER.info("login succeeded user_id=%s must_change_password=%s", user.id, credential.must_change_password)
return AuthenticationResult(user=user, session=session)
def change_first_password(
self,
principal: Principal,
*,
current_password: str,
new_password: str,
request_id: str | None = None,
) -> AuthenticationResult:
"""Complete a mandatory first-login password change.
The temporary password is re-verified so that a stolen session cookie
alone cannot take over the account. On success every session is
revoked and a new one is issued (session rotation).
"""
user = self._require_user(principal.user_id)
credential = self._repository.get_credential(user.id)
if (
credential is None
or credential.credential_type is not CredentialType.LOCAL_PASSWORD
or credential.password_hash is None
):
raise InvalidCredentialsError()
if not isinstance(current_password, str) or not self._passwords.verify_password(
credential.password_hash, current_password
):
self._audit(
action=ACTION_AUTH_FIRST_PASSWORD_CHANGED,
outcome="failure",
actor=principal,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "invalid_current_password"},
)
raise InvalidCredentialsError()
try:
self._passwords.policy.validate(new_password, username=user.username)
except PasswordPolicyError as error:
self._audit(
action=ACTION_AUTH_FIRST_PASSWORD_CHANGED,
outcome="failure",
actor=principal,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "password_policy"},
)
raise error
if self._passwords.verify_password(credential.password_hash, new_password):
raise PasswordPolicyError(("must_differ_from_current",))
now = self._now()
new_hash = self._passwords.hash_password(new_password)
self._repository.save_credential(
replace(
credential,
password_hash=new_hash,
must_change_password=False,
failed_login_count=0,
locked_until=None,
password_changed_at=now,
updated_at=now,
)
)
self._repository.save_user(replace(user, updated_at=now, version=user.version + 1))
revoked = self._repository.revoke_sessions(user.id, reason="password_rotated", now=now)
session = self._issue_session(user, now=now, must_change_password=False)
self._audit(
action=ACTION_AUTH_FIRST_PASSWORD_CHANGED,
outcome="success",
actor=principal,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"revoked_sessions": revoked, "username": user.username},
)
LOGGER.info("first password changed user_id=%s revoked_sessions=%s", user.id, revoked)
return AuthenticationResult(user=user, session=session)
# -- sessions ------------------------------------------------------------
def resolve_session(self, token: str, *, request_id: str | None = None) -> Principal:
"""Resolve a session token into a principal, or raise a 401-equivalent."""
now = self._now()
if not isinstance(token, str) or not token:
raise InvalidCredentialsError()
session = self._repository.get_session_by_token_hash(self._token_hash(token))
if session is None or session.is_revoked():
raise InvalidCredentialsError()
user = self._repository.get_user(session.user_id)
if user is None:
raise InvalidCredentialsError()
if session.is_expired(now):
self._repository.save_session(replace(session, revoked_at=now, revoked_reason="expired"))
self._audit(
action=ACTION_SESSION_EXPIRED,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "deadline_exceeded"},
)
raise InvalidCredentialsError()
if user.status is not AccountStatus.ACTIVE:
revoked = self._repository.revoke_sessions(user.id, reason=f"account_{user.status.value}", now=now)
self._audit(
action=ACTION_SESSION_INVALIDATED,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"status": user.status, "revoked_sessions": revoked},
)
raise InvalidCredentialsError()
credential = self._repository.get_credential(user.id)
must_change = bool(credential and credential.must_change_password)
self._touch_session(session, now=now)
return Principal(
user_id=user.id,
username=user.username,
role=user.role,
session_id=session.id,
organization_id=user.organization_id,
must_change_password=must_change,
)
def logout(self, principal: Principal, *, request_id: str | None = None) -> int:
"""Revoke only the caller's current session.
Other sessions of the same account survive; ``logout_everywhere`` is
the explicit way to end all of them.
"""
now = self._now()
revoked = 0
session = self._repository.get_session(principal.session_id)
if session is not None and not session.is_revoked():
self._repository.save_session(replace(session, revoked_at=now, revoked_reason="logout"))
revoked = 1
self._audit(
action=ACTION_AUTH_LOGOUT,
outcome="success",
actor=principal,
target_user_id=principal.user_id,
request_id=request_id,
detail={"revoked_sessions": revoked},
)
return revoked
def logout_everywhere(self, principal: Principal, *, request_id: str | None = None) -> int:
"""Revoke every session belonging to the caller, including the current one."""
now = self._now()
revoked = self._repository.revoke_sessions(principal.user_id, reason="logout_everywhere", now=now)
self._audit(
action=ACTION_AUTH_LOGOUT_EVERYWHERE,
outcome="success",
actor=principal,
target_user_id=principal.user_id,
request_id=request_id,
detail={"revoked_sessions": revoked},
)
LOGGER.info("logout everywhere user_id=%s revoked_sessions=%s", principal.user_id, revoked)
return revoked
# -- administration ------------------------------------------------------
def self_view(self, user_id: UUID) -> AccountView:
"""Return the sanitized view of one account for its own profile.
The caller must already be authenticated as that account; no
organization scoping is needed because the identifier comes from the
resolved principal, never from the browser.
"""
return self._view(self._require_user(user_id), now=self._now())
def list_accounts(
self,
actor: Principal,
*,
organization_id: UUID | None = None,
status: AccountStatus | None = None,
role: RoleName | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[AccountView], int]:
"""List accounts. An organization administrator never sees other organizations."""
self._require_admin(actor)
scope = self._admin_scope(actor, organization_id)
users, total = self._repository.find_users(
organization_id=scope, status=status, role=str(role) if role else None, limit=limit, offset=offset
)
now = self._now()
return [self._view(user, now=now, visible_to=actor) for user in users], total
def get_account(self, actor: Principal, user_id: UUID) -> AccountView:
self._require_admin(actor)
user = self._require_user(user_id)
self._authorize_target(actor, user)
return self._view(user, now=self._now(), visible_to=actor)
def set_account_status(
self,
actor: Principal,
user_id: UUID,
status: str,
*,
reason: str | None = None,
request_id: str | None = None,
) -> tuple[AccountView, int]:
"""Suspend, reactivate, or deactivate an account and revoke its sessions."""
self._require_admin(actor)
user = self._require_user(user_id)
self._authorize_target(actor, user)
new_status = _account_status(status)
if new_status is user.status:
raise StateTransitionError("status_unchanged", "The account already has that status.")
if new_status not in ALLOWED_STATUS_TRANSITIONS[user.status]:
raise StateTransitionError()
if new_status is not AccountStatus.ACTIVE and user.role is RoleName.PLATFORM_ADMIN:
if self._count_active_platform_admins() <= 1:
raise ConflictError(
"last_platform_admin",
"The last active platform administrator cannot be suspended or deactivated.",
)
if (
new_status is AccountStatus.ACTIVE
and user.status is AccountStatus.DEACTIVATED
and actor.role is not RoleName.PLATFORM_ADMIN
):
raise ForbiddenError(
"reactivation_requires_platform_admin",
"Only a platform administrator may reactivate a deactivated account.",
)
now = self._now()
updated = replace(user, status=new_status, updated_at=now, version=user.version + 1)
self._repository.save_user(updated)
revoked = 0
if new_status is not AccountStatus.ACTIVE:
revoked = self._repository.revoke_sessions(user.id, reason=f"account_{new_status.value}", now=now)
self._audit(
action=ACTION_ACCOUNT_STATUS_CHANGED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"status": new_status,
"previous_status": user.status,
"reason": normalize_reason(reason),
"role": user.role,
"revoked_sessions": revoked,
},
)
LOGGER.info("account status changed user_id=%s status=%s revoked_sessions=%s", user.id, new_status, revoked)
return self._view(updated, now=now, visible_to=actor), revoked
def revoke_sessions(self, actor: Principal, user_id: UUID, *, request_id: str | None = None) -> int:
"""Administrative session revocation for one account."""
self._require_admin(actor)
user = self._require_user(user_id)
self._authorize_target(actor, user)
now = self._now()
revoked = self._repository.revoke_sessions(user.id, reason="administrative_revocation", now=now)
self._audit(
action=ACTION_SESSION_REVOKED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"revoked_sessions": revoked, "reason": "administrative_revocation"},
)
return revoked
def list_audit_events(
self,
actor: Principal,
*,
target_user_id: UUID | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[AuditEvent], int]:
"""List sanitized audit events within the caller's administrative scope."""
self._require_admin(actor)
scope = self._admin_scope(actor, None)
if target_user_id is not None:
self._authorize_target(actor, self._require_user(target_user_id))
events, total = self._repository.list_audit_events(
organization_id=scope,
target_user_id=target_user_id,
limit=limit,
offset=offset,
)
return list(events), total
# -- internals -----------------------------------------------------------
def _token_hash(self, token: str) -> str:
return hash_session_token(token, pepper=self._settings.token_pepper)
def _issue_session(self, user: User, *, now: datetime, must_change_password: bool) -> IssuedSession:
token = generate_token()
absolute_deadline = now + timedelta(seconds=self._settings.session_absolute_seconds)
idle_deadline = min(now + timedelta(seconds=self._settings.session_idle_seconds), absolute_deadline)
session = SessionRecord(
id=uuid4(),
user_id=user.id,
token_hash=self._token_hash(token),
created_at=now,
last_seen_at=now,
idle_deadline_at=idle_deadline,
absolute_deadline_at=absolute_deadline,
mfa_verified_at=None,
)
self._repository.create_session(session)
return IssuedSession(
session_id=session.id,
token=token,
idle_expires_at=idle_deadline,
absolute_expires_at=absolute_deadline,
must_change_password=must_change_password,
)
def _touch_session(self, session: SessionRecord, *, now: datetime) -> None:
"""Refresh ``last_seen_at`` at most once per configured interval."""
interval = self._settings.last_seen_write_interval_seconds
if interval <= 0 or (now - session.last_seen_at).total_seconds() < interval:
return
idle_deadline = min(now + timedelta(seconds=self._settings.session_idle_seconds), session.absolute_deadline_at)
self._repository.save_session(
replace(session, last_seen_at=now, idle_deadline_at=max(idle_deadline, session.idle_deadline_at))
)
def _require_user(self, user_id: UUID) -> User:
user = self._repository.get_user(user_id)
if user is None:
raise NotFoundError("account_not_found")
return user
def _require_admin(self, actor: Principal | None) -> None:
if actor is None:
raise ForbiddenError("administrator_required")
if actor.must_change_password:
raise PasswordChangeRequiredError()
if not actor.is_admin:
raise ForbiddenError()
def _admin_scope(self, actor: Principal, requested_organization: UUID | None) -> UUID | None:
"""Return the organization filter an administrator may use.
An organization administrator is always pinned to their own
organization; a browser-supplied organization identifier is ignored.
"""
if actor.role is RoleName.PLATFORM_ADMIN:
return requested_organization
if actor.organization_id is None:
return None
return actor.organization_id
def _authorize_target(self, actor: Principal, target: User) -> None:
if actor.role is RoleName.PLATFORM_ADMIN:
return
if target.role is RoleName.PLATFORM_ADMIN:
raise ForbiddenError("platform_admin_only")
if actor.organization_id is None or target.organization_id != actor.organization_id:
raise ForbiddenError("cross_organization_forbidden")
def _authorize_creation(self, actor: Principal, *, role: str, organization_id: UUID | None) -> None:
"""Stop an organization administrator escalating its own scope.
An organization administrator may only create members inside its own
organization. A browser-supplied organization or role is never honoured
outside that scope.
"""
if actor.role is RoleName.PLATFORM_ADMIN:
return
if _role(role) is RoleName.PLATFORM_ADMIN:
raise ForbiddenError("platform_admin_only")
if organization_id != actor.organization_id:
raise ForbiddenError("cross_organization_forbidden")
def _count_active_platform_admins(self) -> int:
users, _ = self._repository.find_users(role=str(RoleName.PLATFORM_ADMIN), limit=10_000)
return sum(1 for user in users if user.status is AccountStatus.ACTIVE)
def _view(self, user: User, *, now: datetime, visible_to: Principal | None = None) -> AccountView:
credential = self._repository.get_credential(user.id)
summary = (
credential.public_summary(now=now)
if credential is not None
else CredentialSummary(
credential_type=CredentialType.OIDC,
must_change_password=False,
failed_login_count=0,
locked=False,
)
)
return AccountView(
id=user.id,
username=user.username,
display_name=user.display_name,
role=user.role,
status=user.status,
credential=summary,
organization_id=user.organization_id,
email=user.email,
created_at=user.created_at,
updated_at=user.updated_at,
active_session_count=self._repository.count_sessions(user.id),
)
def _audit(
self,
*,
action: str,
outcome: str,
actor: Principal | None = None,
target_user_id: UUID | None = None,
organization_id: UUID | None = None,
request_id: str | None = None,
detail: Mapping[str, object] | None = None,
) -> AuditEvent:
event = AuditEvent(
id=uuid4(),
occurred_at=self._now(),
action=action,
outcome=outcome,
actor_user_id=actor.user_id if actor else None,
target_user_id=target_user_id,
organization_id=organization_id,
request_id=request_id,
detail=MappingProxyType(sanitize_audit_detail(detail)),
)
self._repository.append_audit_event(event)
LOGGER.info(
"audit action=%s outcome=%s actor=%s target=%s request_id=%s",
action,
outcome,
event.actor_user_id,
event.target_user_id,
event.request_id,
)
return event
def _enrollment_mode(value: str) -> EnrollmentMode:
try:
return EnrollmentMode(value)
except ValueError as error:
raise InputError("invalid_enrollment_mode", "Unknown enrollment mode.") from error
def _account_status(value: str) -> AccountStatus:
try:
return AccountStatus(value)
except ValueError as error:
raise InputError("invalid_status", "Unknown account status.") from error
def _role(value: str) -> RoleName:
try:
return RoleName(value)
except ValueError as error:
raise InputError("invalid_role", "Unknown role.") from error
def _safe_username_key(username: str) -> str | None:
if not isinstance(username, str):
return None
try:
return username_key(username)
except InputError:
return None
__all__ = [
"ACTION_ACCOUNT_CREATED",
"ACTION_ACCOUNT_PROVISIONED_LOCAL",
"ACTION_ACCOUNT_STATUS_CHANGED",
"ACTION_AUTH_FIRST_PASSWORD_CHANGED",
"ACTION_AUTH_LOGIN",
"ACTION_AUTH_LOGOUT",
"ACTION_AUTH_LOGOUT_EVERYWHERE",
"ACTION_SESSION_EXPIRED",
"ACTION_SESSION_INVALIDATED",
"ACTION_SESSION_REVOKED",
"ADMIN_ENROLLMENT_SOURCE",
"OPERATOR_SOURCES",
"AccountService",
"IssuedTemporaryPassword",
"OperatorContext",
]