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.
187 lines
5.4 KiB
Python
187 lines
5.4 KiB
Python
"""Domain errors for the UmbraTrans web account slice.
|
|
|
|
Every error carries a stable, non-secret ``code`` plus a short message. The
|
|
messages are deliberately generic: an HTTP client must never learn whether a
|
|
username exists, which authentication mechanism an account uses, or why a
|
|
login failed. The precise (still sanitized) reason is written to the audit
|
|
trail instead, where an authorized administrator can review it.
|
|
|
|
Errors never carry a password, a hash, or a session token. Attributes that
|
|
could hold secret material are therefore absent by construction.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
class UmbraWebError(Exception):
|
|
"""Base class for all expected, handled failures."""
|
|
|
|
code: str = "umbratrans_error"
|
|
http_status: int = 500
|
|
message: str = "The request could not be completed."
|
|
|
|
def __init__(self, code: str | None = None, message: str | None = None) -> None:
|
|
self.code = code or type(self).code
|
|
self.message = message or type(self).message
|
|
super().__init__(self.message)
|
|
|
|
|
|
class ConfigurationError(UmbraWebError):
|
|
"""The process configuration is unusable or unsafe.
|
|
|
|
Raised instead of starting when production defaults would be insecure.
|
|
The message is operator-facing and is never returned to an HTTP client in
|
|
a 5xx response.
|
|
"""
|
|
|
|
code = "configuration_error"
|
|
http_status = 500
|
|
message = "The service is not configured correctly."
|
|
|
|
|
|
class InputError(UmbraWebError):
|
|
"""Caller-supplied input failed validation.
|
|
|
|
The message may describe the constraint (for example a username charset
|
|
rule) but never echoes the rejected value.
|
|
"""
|
|
|
|
code = "invalid_request"
|
|
http_status = 422
|
|
message = "The request payload is invalid."
|
|
|
|
|
|
class PasswordPolicyError(InputError):
|
|
"""A candidate password failed the local password policy.
|
|
|
|
``unmet_requirements`` names policy rules only, never the candidate value.
|
|
"""
|
|
|
|
code = "password_policy"
|
|
message = "The password does not meet the required policy."
|
|
|
|
def __init__(self, unmet_requirements: tuple[str, ...] = ()) -> None:
|
|
super().__init__()
|
|
self.unmet_requirements = unmet_requirements
|
|
|
|
|
|
class AuthenticationRequiredError(UmbraWebError):
|
|
"""No valid server-side session was presented."""
|
|
|
|
code = "authentication_required"
|
|
http_status = 401
|
|
message = "Authentication is required."
|
|
|
|
|
|
class InvalidCredentialsError(UmbraWebError):
|
|
"""Generic authentication failure.
|
|
|
|
Used for every rejected login so that responses cannot be used to
|
|
enumerate accounts, distinguish locked or suspended accounts, or detect
|
|
which authentication mechanism an account uses.
|
|
"""
|
|
|
|
code = "invalid_credentials"
|
|
http_status = 401
|
|
message = "The username or password is incorrect."
|
|
|
|
|
|
class PasswordChangeRequiredError(UmbraWebError):
|
|
"""The session exists but may only be used to change the first password."""
|
|
|
|
code = "password_change_required"
|
|
http_status = 403
|
|
message = "The temporary password must be changed before continuing."
|
|
|
|
|
|
class ForbiddenError(UmbraWebError):
|
|
"""The principal is authenticated but not allowed to perform the action."""
|
|
|
|
code = "forbidden"
|
|
http_status = 403
|
|
message = "The action is not permitted for this account."
|
|
|
|
|
|
class NotFoundError(UmbraWebError):
|
|
code = "not_found"
|
|
http_status = 404
|
|
message = "The requested record was not found."
|
|
|
|
|
|
class ConflictError(UmbraWebError):
|
|
"""The action conflicts with existing state (duplicate key, last admin)."""
|
|
|
|
code = "conflict"
|
|
http_status = 409
|
|
message = "The request conflicts with existing state."
|
|
|
|
|
|
class StateTransitionError(ConflictError):
|
|
"""The requested account status change is not allowed from the current state."""
|
|
|
|
code = "invalid_state_transition"
|
|
message = "The account is not in a state that allows this change."
|
|
|
|
|
|
class EnrollmentRefusedError(UmbraWebError):
|
|
"""An enrollment mode was requested through a path that may not use it.
|
|
|
|
Temporary local passwords must come from the operator CLI path, never from
|
|
a JSON request body.
|
|
"""
|
|
|
|
code = "enrollment_mode_refused"
|
|
http_status = 400
|
|
message = "The requested enrollment mode is not available through this path."
|
|
|
|
|
|
class CsrfError(UmbraWebError):
|
|
"""A cookie-authenticated state change arrived without a valid CSRF token."""
|
|
|
|
code = "csrf_token_invalid"
|
|
http_status = 403
|
|
message = "The request could not be verified. Reload the page and retry."
|
|
|
|
|
|
class RepositoryError(UmbraWebError):
|
|
"""The repository could not complete an operation.
|
|
|
|
The underlying cause is never included: it may quote a query parameter.
|
|
"""
|
|
|
|
code = "repository_error"
|
|
http_status = 500
|
|
message = "The data store could not complete the request."
|
|
|
|
|
|
class OperatorRefusalError(Exception):
|
|
"""The operator CLI refused to run.
|
|
|
|
Used for production-mode refusal and non-interactive terminals. Carries no
|
|
secret material.
|
|
"""
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
self.code = code
|
|
self.message = message
|
|
super().__init__(message)
|
|
|
|
|
|
__all__ = [
|
|
"AuthenticationRequiredError",
|
|
"ConfigurationError",
|
|
"ConflictError",
|
|
"CsrfError",
|
|
"EnrollmentRefusedError",
|
|
"ForbiddenError",
|
|
"InputError",
|
|
"InvalidCredentialsError",
|
|
"NotFoundError",
|
|
"OperatorRefusalError",
|
|
"PasswordChangeRequiredError",
|
|
"PasswordPolicyError",
|
|
"RepositoryError",
|
|
"StateTransitionError",
|
|
"UmbraWebError",
|
|
]
|