Files
UmbraTrans/umbratrans_web/errors.py
2026-10-18 19:01:05 -07:00

276 lines
8.7 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""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 PairingError(UmbraWebError):
"""A node could not be paired.
403 rather than 401: the caller presented a secret and it was wrong, which is
an authorisation failure, not a missing one. Both are "no", and the distinction
exists only because an HTTP client cares.
Every rejection carries the same wording apart from a name mismatch, so a caller
cannot learn from the response whether a code ever existed, was already spent, or
was simply mistyped. `code` names the specific reason for logs; `message` does not.
"""
code = "pairing_failed"
http_status = 403
message = "That pairing code is not valid."
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 EmailNotVerifiedError(UmbraWebError):
"""The password was correct but the email address is not yet verified.
Only ever raised after a successful password check. Reporting it earlier
would turn the login form into an account-existence oracle, which is the one
thing the uniform authentication error exists to prevent.
"""
code = "email_not_verified"
http_status = 403
message = "Confirm your email address before signing in. Check your inbox for the verification link."
class VerificationFailedError(UmbraWebError):
"""A verification link was not accepted.
One message for every reason - unknown, already used, expired - so the
endpoint cannot be used to probe which links ever existed.
"""
code = "verification_failed"
http_status = 400
message = "That verification link is not valid. It may have been used already or have expired."
class VerificationDeliveryError(UmbraWebError):
"""A verification link could not be handed to the mail transport.
Reported as a server-side failure rather than a success, because telling an
administrator that a link was sent when nothing left the machine leaves an
account permanently unverifiable with no indication why. The message names
no address and carries no token.
"""
code = "verification_delivery_failed"
http_status = 502
message = "The verification link could not be sent. The account was created and is not yet usable."
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 MailDeliveryUnavailableError(UmbraWebError):
"""The deployment cannot deliver mail, so enrollment must not proceed.
A 503 rather than a 4xx: nothing about the request is wrong, the service is
temporarily unable to do this. Retrying later is the right response, and a
client that treats it as a permanent rejection would give up on an account it
never created.
"""
code = "mail_not_deliverable"
http_status = 503
message = "Outbound mail is not available, so an account cannot be created."
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 NodeNameExhaustedError(ConflictError):
"""Every name derivable from a base name is taken.
Separate from a plain conflict because the remedy differs: a conflict says "this node
is already paired", and this says "the name you chose is too widely shared, choose a
more specific one". Collapsing them would leave an operator retyping a name that can
never work.
"""
code = "node_name_exhausted"
message = "Every name derived from that base name is already in use."
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",
"EmailNotVerifiedError",
"EnrollmentRefusedError",
"ForbiddenError",
"InputError",
"InvalidCredentialsError",
"NotFoundError",
"OperatorRefusalError",
"PasswordChangeRequiredError",
"PasswordPolicyError",
"RepositoryError",
"StateTransitionError",
"VerificationDeliveryError",
"VerificationFailedError",
"UmbraWebError",
]