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.
1368 lines
53 KiB
Python
1368 lines
53 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_ORGANIZATION_TRANSITIONS,
|
|
ALLOWED_STATUS_TRANSITIONS,
|
|
AccountStatus,
|
|
AccountView,
|
|
AuditEvent,
|
|
AuthenticationResult,
|
|
CredentialRecord,
|
|
CredentialSummary,
|
|
CredentialType,
|
|
EnrollmentMode,
|
|
IssuedSession,
|
|
Organization,
|
|
OrganizationStatus,
|
|
OrganizationView,
|
|
Principal,
|
|
RoleName,
|
|
SessionRecord,
|
|
User,
|
|
normalize_display_name,
|
|
normalize_email,
|
|
normalize_organization_name,
|
|
normalize_reason,
|
|
normalize_username,
|
|
require_canonical_organization_slug,
|
|
slugify_organization_name,
|
|
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_ORGANIZATION_CREATED = "organization.created"
|
|
ACTION_ORGANIZATION_UPDATED = "organization.updated"
|
|
ACTION_ORGANIZATION_STATUS_CHANGED = "organization.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,
|
|
email: str,
|
|
role: str = str(RoleName.MEMBER),
|
|
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.
|
|
|
|
``email`` is required. The control plane declares ``users.email`` as
|
|
``NOT NULL UNIQUE``, and enforcing it here means a missing address
|
|
fails identically against the development repositories and the
|
|
authoritative store instead of only at deployment.
|
|
|
|
``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,
|
|
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._validate_organization_for_assignment(organization_id)
|
|
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,
|
|
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)
|
|
self._validate_organization_for_assignment(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,
|
|
email: str,
|
|
role: str = str(RoleName.MEMBER),
|
|
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)
|
|
self._validate_organization_for_assignment(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,
|
|
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 self._organization_blocks_access(user):
|
|
# A suspended or archived tenant must not be able to mint new
|
|
# sessions. Revoking the existing ones is not sufficient on its own,
|
|
# because each member's own account status is still 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": "organization_not_active", "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()
|
|
if self._organization_blocks_access(user):
|
|
# Defense in depth. set_organization_status already revokes member
|
|
# sessions, so reaching this means the organization was suspended
|
|
# without going through that path; refuse and clean up anyway.
|
|
revoked = self._repository.revoke_sessions(user.id, reason="organization_not_active", 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={"reason": "organization_not_active", "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
|
|
|
|
# -- organizations -------------------------------------------------------
|
|
|
|
def list_organizations(
|
|
self,
|
|
actor: Principal,
|
|
*,
|
|
status: OrganizationStatus | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[OrganizationView], int]:
|
|
"""List organizations visible to the caller.
|
|
|
|
A platform administrator sees every organization. An organization
|
|
administrator is pinned to the single organization on their own
|
|
principal, so a browser-supplied filter cannot widen the result.
|
|
"""
|
|
|
|
self._require_admin(actor)
|
|
scope: tuple[UUID, ...] | None = None
|
|
if actor.role is not RoleName.PLATFORM_ADMIN and actor.organization_id is not None:
|
|
scope = (actor.organization_id,)
|
|
organizations, total = self._repository.find_organizations(
|
|
status=status, organization_ids=scope, limit=limit, offset=offset
|
|
)
|
|
return [self._organization_view(organization) for organization in organizations], total
|
|
|
|
def get_organization(self, actor: Principal, organization_id: UUID) -> OrganizationView:
|
|
self._require_admin(actor)
|
|
organization = self._require_organization(organization_id)
|
|
self._authorize_organization_target(actor, organization)
|
|
return self._organization_view(organization)
|
|
|
|
def create_organization(
|
|
self,
|
|
actor: Principal,
|
|
*,
|
|
name: str,
|
|
slug: str | None = None,
|
|
request_id: str | None = None,
|
|
) -> OrganizationView:
|
|
"""Create a tenant. Platform administrators only.
|
|
|
|
An organization administrator cannot create one, which keeps the
|
|
tenant boundary under a single authority.
|
|
"""
|
|
|
|
self._require_platform_admin(actor)
|
|
now = self._now()
|
|
canonical_name = normalize_organization_name(name)
|
|
# A missing slug is derived from the name. A supplied slug is validated
|
|
# as written so a rejected value reports the slug rule, not the name.
|
|
canonical_slug = (
|
|
require_canonical_organization_slug(slug) if slug is not None else slugify_organization_name(name)
|
|
)
|
|
organization = Organization(
|
|
id=uuid4(),
|
|
name=canonical_name,
|
|
slug=canonical_slug,
|
|
status=OrganizationStatus.ACTIVE,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
self._repository.create_organization(organization)
|
|
self._audit(
|
|
action=ACTION_ORGANIZATION_CREATED,
|
|
outcome="success",
|
|
actor=actor,
|
|
target_user_id=None,
|
|
organization_id=organization.id,
|
|
request_id=request_id,
|
|
detail={
|
|
"organization_name": organization.name,
|
|
"organization_slug": organization.slug,
|
|
"status": organization.status,
|
|
},
|
|
)
|
|
LOGGER.info("organization created organization_id=%s slug=%s", organization.id, organization.slug)
|
|
return self._organization_view(organization)
|
|
|
|
def update_organization(
|
|
self,
|
|
actor: Principal,
|
|
organization_id: UUID,
|
|
*,
|
|
name: str | None = None,
|
|
slug: str | None = None,
|
|
request_id: str | None = None,
|
|
) -> OrganizationView:
|
|
"""Rename an organization or change its slug. Platform administrators only.
|
|
|
|
The slug is a historical handle, so changing it is audited as its own
|
|
event rather than folded into a rename.
|
|
"""
|
|
|
|
self._require_platform_admin(actor)
|
|
organization = self._require_organization(organization_id)
|
|
if name is None and slug is None:
|
|
raise InputError("no_changes", "Supply a name, a slug, or both.")
|
|
updated = organization
|
|
if name is not None:
|
|
updated = replace(updated, name=normalize_organization_name(name), updated_at=self._now())
|
|
if slug is not None:
|
|
updated = replace(updated, slug=require_canonical_organization_slug(slug), updated_at=self._now())
|
|
updated = replace(updated, version=organization.version + 1)
|
|
self._repository.save_organization(updated)
|
|
self._audit(
|
|
action=ACTION_ORGANIZATION_UPDATED,
|
|
outcome="success",
|
|
actor=actor,
|
|
organization_id=organization.id,
|
|
request_id=request_id,
|
|
detail={
|
|
"organization_name": updated.name,
|
|
"organization_slug": updated.slug,
|
|
"previous_status": organization.status,
|
|
},
|
|
)
|
|
LOGGER.info("organization updated organization_id=%s slug=%s", organization.id, updated.slug)
|
|
return self._organization_view(updated)
|
|
|
|
def set_organization_status(
|
|
self,
|
|
actor: Principal,
|
|
organization_id: UUID,
|
|
status: str,
|
|
*,
|
|
reason: str | None = None,
|
|
request_id: str | None = None,
|
|
) -> tuple[OrganizationView, int]:
|
|
"""Suspend, reactivate, or archive an organization.
|
|
|
|
Leaving ``active`` revokes every live session belonging to a member of
|
|
that organization, because a suspended tenant must not keep serving
|
|
already-authenticated browsers.
|
|
"""
|
|
|
|
self._require_platform_admin(actor)
|
|
organization = self._require_organization(organization_id)
|
|
new_status = _organization_status(status)
|
|
if new_status is organization.status:
|
|
raise StateTransitionError("status_unchanged", "The organization already has that status.")
|
|
if new_status not in ALLOWED_ORGANIZATION_TRANSITIONS[organization.status]:
|
|
raise StateTransitionError()
|
|
|
|
now = self._now()
|
|
updated = replace(organization, status=new_status, updated_at=now, version=organization.version + 1)
|
|
self._repository.save_organization(updated)
|
|
|
|
revoked = 0
|
|
if new_status is not OrganizationStatus.ACTIVE:
|
|
members, _ = self._repository.find_users(organization_id=organization.id, limit=10_000)
|
|
for member in members:
|
|
if member.status is AccountStatus.ACTIVE:
|
|
revoked += self._repository.revoke_sessions(
|
|
member.id, reason=f"organization_{new_status.value}", now=now
|
|
)
|
|
|
|
self._audit(
|
|
action=ACTION_ORGANIZATION_STATUS_CHANGED,
|
|
outcome="success",
|
|
actor=actor,
|
|
organization_id=organization.id,
|
|
request_id=request_id,
|
|
detail={
|
|
"status": new_status,
|
|
"previous_status": organization.status,
|
|
"organization_name": organization.name,
|
|
"organization_slug": organization.slug,
|
|
"reason": normalize_reason(reason),
|
|
"revoked_sessions": revoked,
|
|
},
|
|
)
|
|
LOGGER.info(
|
|
"organization status changed organization_id=%s status=%s revoked_sessions=%s",
|
|
organization.id,
|
|
new_status,
|
|
revoked,
|
|
)
|
|
return self._organization_view(updated), revoked
|
|
|
|
def organization_summary(self, actor: Principal) -> dict[str, int]:
|
|
"""Counts for the administrator dashboard, within the caller's scope.
|
|
|
|
Recomputed on every call from the same scoped queries the list
|
|
endpoints use, so a summary can never disclose a number the caller
|
|
could not reach by listing.
|
|
"""
|
|
|
|
self._require_admin(actor)
|
|
scope: UUID | None = None
|
|
organization_scope: tuple[UUID, ...] | None = None
|
|
if actor.role is not RoleName.PLATFORM_ADMIN and actor.organization_id is not None:
|
|
scope = actor.organization_id
|
|
organization_scope = (actor.organization_id,)
|
|
|
|
organizations, _ = self._repository.find_organizations(organization_ids=organization_scope, limit=10_000)
|
|
accounts, _ = self._repository.find_users(organization_id=scope, limit=10_000)
|
|
pending_first_login = 0
|
|
for account in accounts:
|
|
credential = self._repository.get_credential(account.id)
|
|
if credential is not None and credential.must_change_password:
|
|
pending_first_login += 1
|
|
return {
|
|
"total_organizations": len(organizations),
|
|
"active_organizations": sum(1 for item in organizations if item.status is OrganizationStatus.ACTIVE),
|
|
"total_accounts": len(accounts),
|
|
"active_accounts": sum(1 for item in accounts if item.status is AccountStatus.ACTIVE),
|
|
"suspended_accounts": sum(1 for item in accounts if item.status is AccountStatus.SUSPENDED),
|
|
"pending_first_login": pending_first_login,
|
|
"organization_admin_count": sum(1 for item in accounts if item.role is RoleName.ORGANIZATION_ADMIN),
|
|
}
|
|
|
|
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_organization(self, organization_id: UUID) -> Organization:
|
|
organization = self._repository.get_organization(organization_id)
|
|
if organization is None:
|
|
raise NotFoundError("organization_not_found")
|
|
return organization
|
|
|
|
def _organization_blocks_access(self, user: User) -> bool:
|
|
"""Report whether the user's organization forbids access.
|
|
|
|
An account with no organization is platform-scoped and is never
|
|
blocked. A missing organization record counts as blocking: an
|
|
unresolvable tenant reference must fail closed rather than grant
|
|
access by accident.
|
|
"""
|
|
|
|
if user.organization_id is None:
|
|
return False
|
|
organization = self._repository.get_organization(user.organization_id)
|
|
if organization is None:
|
|
return True
|
|
return organization.status is not OrganizationStatus.ACTIVE
|
|
|
|
def _validate_organization_for_assignment(self, organization_id: UUID | None) -> None:
|
|
"""Reject an account assignment to an organization that cannot hold it.
|
|
|
|
Without this, any caller-supplied UUID becomes a silent tenant
|
|
reference, because nothing else in phase 1 backs
|
|
``user.organization_id``.
|
|
"""
|
|
|
|
if organization_id is None:
|
|
return
|
|
organization = self._require_organization(organization_id)
|
|
if organization.status is not OrganizationStatus.ACTIVE:
|
|
raise InputError(
|
|
"organization_not_active",
|
|
"Accounts can only be assigned to an active organization.",
|
|
)
|
|
|
|
def _organization_view(self, organization: Organization) -> OrganizationView:
|
|
return OrganizationView(
|
|
id=organization.id,
|
|
name=organization.name,
|
|
slug=organization.slug,
|
|
status=organization.status,
|
|
member_count=self._repository.count_users(organization.id),
|
|
created_at=organization.created_at,
|
|
updated_at=organization.updated_at,
|
|
)
|
|
|
|
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 _require_platform_admin(self, actor: Principal | None) -> None:
|
|
self._require_admin(actor)
|
|
if actor is None or actor.role is not RoleName.PLATFORM_ADMIN: # pragma: no cover - _require_admin raises first
|
|
raise ForbiddenError("platform_admin_only")
|
|
|
|
def _authorize_organization_target(self, actor: Principal, organization: Organization) -> None:
|
|
if actor.role is RoleName.PLATFORM_ADMIN:
|
|
return
|
|
if actor.organization_id != organization.id:
|
|
raise ForbiddenError("cross_organization_forbidden")
|
|
|
|
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=str(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 _organization_status(value: str) -> OrganizationStatus:
|
|
try:
|
|
return OrganizationStatus(value)
|
|
except ValueError as error:
|
|
raise InputError("invalid_organization_status", "Unknown organization 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_ORGANIZATION_CREATED",
|
|
"ACTION_ORGANIZATION_STATUS_CHANGED",
|
|
"ACTION_ORGANIZATION_UPDATED",
|
|
"ACTION_SESSION_EXPIRED",
|
|
"ACTION_SESSION_INVALIDATED",
|
|
"ACTION_SESSION_REVOKED",
|
|
"ADMIN_ENROLLMENT_SOURCE",
|
|
"OPERATOR_SOURCES",
|
|
"AccountService",
|
|
"IssuedTemporaryPassword",
|
|
"OperatorContext",
|
|
]
|