Source code for litestar_security.accounts._records

"""Local account records, status enumerations, and lifecycle outcomes."""

from collections.abc import Mapping  # noqa: TC003 - Litestar resolves public annotations at runtime
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from logging import getLogger
from types import MappingProxyType
from typing import TYPE_CHECKING, Generic, Protocol, TypeVar, runtime_checkable
from unicodedata import normalize

if TYPE_CHECKING:
    from collections.abc import Callable


from litestar_security.accounts._internal import aware_utc_time, strict_text, valid_security_epoch
from litestar_security.schema import WireStruct

__all__ = (
    "InvalidInvitation",
    "LifecycleAccepted",
    "LifecycleRejected",
    "LocalAccountState",
    "LocalAuthMode",
    "LoginMethod",
    "NoOpSecurityEventSink",
    "PasswordChangeOutcome",
    "PasswordChangeStatus",
    "PasswordCredentialState",
    "PasswordPolicyViolation",
    "PasswordReauthenticationProof",
    "PasswordResetOutcome",
    "PasswordResetStatus",
    "PasswordVerificationStatus",
    "RegistrationMode",
    "RegistrationOutcome",
    "RegistrationStatus",
    "RevokeLoginMethodOutcome",
    "RevokeLoginMethodStatus",
    "SecurityEvent",
    "SecurityEventSink",
    "TokenPurpose",
    "VerificationOutcome",
    "VerificationStatus",
    "normalize_identifier",
)

UserT = TypeVar("UserT")
_EMPTY_CORRELATION: "Mapping[str, str]" = MappingProxyType({})
_DEFAULT_REAUTHENTICATION_TTL = timedelta(minutes=5)
_LOGGER = getLogger(__name__)


[docs] class LocalAuthMode(str, Enum): """Visible local-authentication transport selection.""" SESSION = "session" TOKENS = "tokens" HYBRID = "hybrid"
[docs] class RegistrationMode(str, Enum): """Supported self-service registration policies.""" DISABLED = "disabled" PUBLIC = "public" INVITE_ONLY = "invite_only"
[docs] class TokenPurpose(str, Enum): """Closed namespaces for one-time local-account tokens.""" INVITATION = "invitation" VERIFICATION = "verification" RECOVERY = "recovery"
[docs] class PasswordChangeStatus(str, Enum): """Atomic password-change outcomes.""" CHANGED = "changed" CONFLICT = "conflict" NOT_FOUND = "not_found" EPOCH_EXHAUSTED = "epoch_exhausted"
[docs] class PasswordPolicyViolation(str, Enum): """Secret-free reasons a candidate password does not satisfy policy.""" TOO_SHORT = "too_short" TOO_LONG = "too_long" TOO_MANY_BYTES = "too_many_bytes" INVALID_TEXT = "invalid_text" MATCHES_IDENTIFIER = "matches_identifier" COMPROMISED = "compromised"
[docs] class PasswordVerificationStatus(str, Enum): """Sanitized outcomes from one constant-work password verification.""" VERIFIED = "verified" INVALID = "invalid" MALFORMED = "malformed" TOO_LONG = "too_long"
[docs] class RevokeLoginMethodStatus(str, Enum): """Atomic login-method revocation outcomes.""" REVOKED = "revoked" NOT_FOUND = "not_found" FINAL_METHOD = "final_method"
[docs] class RegistrationStatus(str, Enum): """Atomic registration outcomes.""" CREATED = "created" DUPLICATE = "duplicate" INVALID_INVITATION = "invalid_invitation"
[docs] class VerificationStatus(str, Enum): """Atomic purpose-token consumption outcomes.""" CONSUMED = "consumed" INVALID = "invalid" EXPIRED = "expired" USED = "used"
[docs] class PasswordResetStatus(str, Enum): """Atomic recovery-token/password-reset outcomes.""" RESET = "reset" INVALID = "invalid" EXPIRED = "expired" USED = "used" CONFLICT = "conflict" EPOCH_EXHAUSTED = "epoch_exhausted"
[docs] @dataclass(frozen=True, slots=True) class LocalAccountState(Generic[UserT]): """Application-owned account projection needed by local authentication.""" account_id: str normalized_identifier: str display_name: str | None active: bool verified: bool security_epoch: int user: UserT | None = field(default=None, repr=False) def __post_init__(self) -> None: """Validate the stable account and strict epoch projection.""" if ( not strict_text(self.account_id) or not strict_text(self.normalized_identifier) or not valid_security_epoch(self.security_epoch) or self.active.__class__ is not bool or self.verified.__class__ is not bool ): msg = "Local account requires stable identifiers and a valid security epoch" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class LoginMethod: """One application-owned viable login method.""" method_id: str kind: str created_at: "datetime" display_name: str | None = None
[docs] @dataclass(frozen=True, slots=True) class PasswordCredentialState: """Atomic password hash, account-state, and epoch snapshot for reauthentication.""" password_hash: str = field(repr=False) security_epoch: int active: bool verified: bool def __post_init__(self) -> None: """Require one encoded hash bound to an eligible account and strict current epoch.""" if ( not strict_text(self.password_hash) or not valid_security_epoch(self.security_epoch) or self.active.__class__ is not bool or self.verified.__class__ is not bool ): msg = "Password credential state requires a hash, valid security epoch, and boolean account state" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class PasswordReauthenticationProof: """Account- and epoch-bound recent password proof for sensitive mutation.""" account_id: str security_epoch: int authenticated_at: "datetime" expires_at: "datetime" def __post_init__(self) -> None: """Require a strict identity, epoch, and forward-moving proof window.""" try: authenticated_at = aware_utc_time(self.authenticated_at) expires_at = aware_utc_time(self.expires_at) except (AttributeError, ValueError): msg = "Password reauthentication proof timestamps must be timezone-aware" raise ValueError(msg) from None if ( not strict_text(self.account_id) or not valid_security_epoch(self.security_epoch) or expires_at <= authenticated_at or expires_at - authenticated_at > _DEFAULT_REAUTHENTICATION_TTL ): msg = "Password reauthentication proof requires an account, epoch, and valid lifetime" raise ValueError(msg) object.__setattr__(self, "authenticated_at", authenticated_at) object.__setattr__(self, "expires_at", expires_at)
[docs] @dataclass(frozen=True, slots=True) class SecurityEvent: """Secret-free event committed with a security decision or mutation.""" event_id: str occurred_at: "datetime" operation: str outcome: str account_id: str | None = None principal_id: str | None = None mechanism: str | None = None session_id: str | None = None family_id: str | None = None correlation: "Mapping[str, str]" = field(default_factory=lambda: _EMPTY_CORRELATION) def __post_init__(self) -> None: """Freeze caller-supplied correlation fields.""" object.__setattr__(self, "correlation", MappingProxyType(dict(self.correlation)))
[docs] @runtime_checkable class SecurityEventSink(Protocol): """Application-owned sink for secret-free, non-transactional decisions."""
[docs] async def emit(self, event: SecurityEvent) -> None: """Record one sanitized security decision. Events are secret-free by construction, so a sink may forward them anywhere. Observational events cannot change the decision they describe; raising from one is logged and dropped. Args: event: The event to record. """ ... # pragma: no cover
[docs] @dataclass(frozen=True, slots=True) class NoOpSecurityEventSink: """Accept security events when an application has not configured a sink."""
[docs] async def emit(self, event: SecurityEvent) -> None: """Discard one already-sanitized observational event. Args: event: The event to discard. """ del event
[docs] class LifecycleAccepted(WireStruct, frozen=True): """Shared enumeration-resistant response body for lifecycle requests.""" # One fixed wording: an eligible and an ineligible request answer identically, # so a caller cannot probe for account existence. Handlers never override it. detail: str = "If eligible, the request will be processed."
[docs] @dataclass(frozen=True, slots=True) class InvalidInvitation: """Generic invalid-invitation response without expiry or replay details.""" detail: str = "Invitation is invalid or unavailable."
[docs] @dataclass(frozen=True, slots=True) class LifecycleRejected: """Generic malformed lifecycle request response.""" detail: str = "The request is invalid."
[docs] @dataclass(frozen=True, slots=True) class PasswordChangeOutcome: """Atomic password replacement and security-epoch outcome.""" status: PasswordChangeStatus security_epoch: int | None = None def __post_init__(self) -> None: """Require an epoch only for a successful atomic replacement.""" changed = self.status is PasswordChangeStatus.CHANGED if ( self.status.__class__ is not PasswordChangeStatus or changed != (self.security_epoch is not None) or (changed and not valid_security_epoch(self.security_epoch)) ): msg = "Changed password outcomes require exactly one security epoch" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class RevokeLoginMethodOutcome: """Atomic login-method revocation outcome.""" status: RevokeLoginMethodStatus
[docs] @dataclass(frozen=True, slots=True) class RegistrationOutcome(Generic[UserT]): """Atomic registration outcome.""" status: RegistrationStatus account: LocalAccountState[UserT] | None = None def __post_init__(self) -> None: """Require an account projection only for a created registration.""" if (self.status is RegistrationStatus.CREATED) != (self.account is not None): msg = "Created registration outcomes require exactly one account" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class VerificationOutcome: """Atomic verification-token consumption outcome.""" status: VerificationStatus account_id: str | None = None security_epoch: int | None = None def __post_init__(self) -> None: """Require account and epoch payload only for successful consumption.""" consumed = self.status is VerificationStatus.CONSUMED has_payload = self.account_id is not None and self.security_epoch is not None if consumed != has_payload or ( not consumed and (self.account_id is not None or self.security_epoch is not None) ): msg = "Consumed verification outcomes require exactly one account and security epoch" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class PasswordResetOutcome: """Atomic recovery-token consumption and password-reset outcome.""" status: PasswordResetStatus account_id: str | None = None security_epoch: int | None = None def __post_init__(self) -> None: """Require account and epoch payload only for a completed reset.""" reset = self.status is PasswordResetStatus.RESET has_payload = self.account_id is not None and self.security_epoch is not None if ( self.status.__class__ is not PasswordResetStatus or reset != has_payload or (not reset and (self.account_id is not None or self.security_epoch is not None)) or (reset and (not strict_text(self.account_id) or not valid_security_epoch(self.security_epoch))) ): msg = "Reset password outcomes require exactly one account and security epoch" raise ValueError(msg)
[docs] def normalize_identifier(value: str) -> str: """Apply the default compatibility, whitespace, and case normalization. Args: value: The submitted identifier. Returns: The NFKC-normalized, stripped, case-folded identifier. Raises: ValueError: If the value is not text. """ if value.__class__ is not str: msg = "Identifier normalization requires text" raise ValueError(msg) return normalize("NFKC", value).strip().casefold()
async def emit_security_event(sink: SecurityEventSink, event: SecurityEvent) -> None: """Offer one observational event to an application sink without letting it fail. Observational events report a decision that has already been made. A sink that raises must not change that decision or surface its exception to the caller, so the failure is logged without the untrusted detail and dropped. Durable mutation events do not come through here: those are passed into the atomic store operation itself, so a rejected event fails the mutation. """ try: await sink.emit(event) except Exception: # noqa: BLE001 - a failed observational sink cannot change a settled decision _LOGGER.error("Security event sink failed for %s", event.operation) # noqa: TRY400 - omit untrusted details def lifecycle_event( event_ids: "Callable[[], str]", occurred_at: datetime, *, operation: str, outcome: str, account_id: str | None = None, ) -> SecurityEvent: event_id = event_ids() if not strict_text(event_id): raise ValueError return SecurityEvent( event_id=event_id.strip(), occurred_at=occurred_at, operation=operation, outcome=outcome, account_id=account_id, mechanism="password", )