276 lines
8.7 KiB
Python
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",
|
|
]
|