Skip to content

Security Error Responses (Phase 5)

Status: COMPLETE (directive §20)

Source: src/jdlib/security/errors.py, src/jdlib/security/responses.py (new), src/jdlib/security/authz/pep.py Tests: tests/unit/security/test_error_responses.py (new), tests/unit/security/test_security_errors.py, test_authz_pep.py

Evidence Result
Unit (error model + responses + PEP) 272 passed in tests/unit/security
Whole suite (-W error) 876 passed, 6 skipped
ruff check . / mypy src/jdlib clean (86 source files)

1. Stable codes and the HTTP mapping

Every failure the security layer can raise carries a stable code, an HTTP status, a retryability flag and a classification. The table below is generated from the descriptor table itself, so it cannot drift from what the runtime produces; test_security_errors.py separately asserts that every descriptor agrees with the runtime http_status_for() mapping.

Error Code Status Retryable Classification
AuthenticationError AUTHENTICATION_REQUIRED 401 no authentication
ExpiredToken EXPIRED_TOKEN 401 no authentication
InvalidToken INVALID_TOKEN 401 no authentication
UnknownPrincipal UNKNOWN_PRINCIPAL 401 no authentication
AuthenticationUnavailable AUTHENTICATION_UNAVAILABLE 503 yes authentication
AuthorizationError PERMISSION_DENIED 403 no authorization
PermissionDenied PERMISSION_DENIED 403 no authorization
RoleEscalationBlocked ROLE_ESCALATION_BLOCKED 403 no authorization
TenantAccessDenied TENANT_ACCESS_DENIED 404 no tenancy
TenantNotFound TENANT_NOT_FOUND 404 no tenancy
CrossTenantReferenceError CROSS_TENANT_ACCESS 409 no data boundary
InvalidReference INVALID_REFERENCE 409 no data boundary
TenantNotWritable TENANT_NOT_SERVABLE 423 no tenancy
TenantSuspended TENANT_SUSPENDED 423 no tenancy
InvalidResourcePermission SECURITY_CONFIGURATION_ERROR 500 no configuration
MigrationError MIGRATION_ERROR 500 no internal
MissingTenantContext MISSING_TENANT_CONTEXT 500 no tenancy
PoolCapacityError CAPACITY_EXCEEDED 500 yes availability
ProvisioningError PROVISIONING_ERROR 500 no internal
RelocationPhaseError RELOCATION_IN_PROGRESS 500 yes tenancy
RoleScopeMismatch SECURITY_CONFIGURATION_ERROR 500 no configuration
SchemaLintError SCHEMA_LINT_FAILED 500 no configuration
SecurityConfigurationError SECURITY_CONFIGURATION_ERROR 500 no configuration
StrategyCapabilityError INTERNAL_ERROR 500 no internal
TenantOperationInProgress TENANT_OPERATION_IN_PROGRESS 500 yes tenancy
UnknownPermission SECURITY_CONFIGURATION_ERROR 500 no configuration
UnknownResourceType SECURITY_CONFIGURATION_ERROR 500 no configuration
UnscopedBulkOperation UNSCOPED_DATA_OPERATION 500 no data boundary
UnscopedRawSql UNSCOPED_DATA_OPERATION 500 no data boundary
AuthorizationUnavailable POLICY_UNAVAILABLE 503 yes authorization

All seven statuses the directive lists (401, 403, 404, 409, 423, 500, 503) are exercised by the test suite, and a matrix test walks the whole descriptor table asserting that each entry yields a status from that set and a complete envelope.

1a. The two codes the HTTP edges supply (added 2026-09-25, with the G10 fix)

The table above is generated from the core descriptor table, and the core cannot name every failure a client can see: jdlib.data imports jdlib.security (context, audit, tracing), so a descriptor for a connector error would close a dependency cycle. The HTTP edges therefore map those failures with the same descriptor shape and through the same builder (jdlib.security.responses.error_response(..., descriptor=...)), so there is still exactly one envelope and one vocabulary:

Failure Code Status Retryable Classification Described by
A malformed request body VALIDATION_FAILED 422 no validation jdlib.integrations.fastapi (FastAPI's RequestValidationError)
An application's own rejection — fastapi.HTTPException and its subclasses, the shape Starlette answers {"detail": ...} well-known statuses map to BAD_REQUEST 400, AUTHENTICATION_REQUIRED 401, PERMISSION_DENIED 403, NOT_FOUND 404, CONFLICT 409, VALIDATION_FAILED 422, TOO_MANY_REQUESTS 429, DEPENDENCY_UNAVAILABLE 503; an unmapped status keeps its own code point the application's own by status by status jdlib.integrations.fastapi, which registers it so that "one envelope" is true for an application that raises one
A rate limit TOO_MANY_REQUESTS 429 yes availability the same table; a Retry-After the application set is passed through (the only two response headers that are: it and WWW-Authenticate)
A dependency that is not answering: ConnectorError, a spent RetryBudgetExhaustedError, a ShutdownInProgressError, and the connection-level sqlalchemy errors (OperationalError, InterfaceError, TimeoutError) DEPENDENCY_UNAVAILABLE 503 yes availability jdlib.integrations.fastapi

Two rules the edges must keep, and the tests assert:

  • The same shape. Every one of these answers with exactly code, message, request_id, correlation_id - and a 401 with its RFC 6750 challenge. The tenant middleware and the FastAPI integration both used to answer {"error": <class name>, "detail": <str(exc)>}, which is what this section's contract was written against; the class name leaked the implementation and the detail leaked every message, including 5xx ones.
  • A 5xx never echoes its own text. A server-side message can legitimately name a DSN, an internal endpoint or key material, so the client gets the generic description for its classification. A 4xx keeps the error's own text - printable, redacted, capped - because that text was written for a caller.
  • Selectivity. A connection the database refused is an outage (503, retry me); a statement it rejected is a defect in the caller's application (500). A handler that mapped every DBAPIError would pass a one-sided test and hide real defects, so the tests assert both directions.

2. The safe response envelope

error_response(exc, request_ids=...) returns

{"code": "PERMISSION_DENIED", "message": "...",
 "request_id": "req-1", "correlation_id": "corr-1"}

with exactly those four keys — plus a WWW-Authenticate header for 401s and nothing else. correlation_id falls back to the request id when no correlation id was supplied, so a client always has something to quote in a support request.

Rule Why it matters Test
4xx messages are the error's own text, redacted and made printable and capped at 300 chars control characters cannot be used to forge log lines or UI output; unbounded text cannot be used to bloat responses; and an error that quotes a header, a connection string or a query while explaining itself cannot become a credential-disclosure channel test_client_messages_are_sanitized_and_capped, test_no_response_leaks_a_sensitive_value
5xx messages are a fixed, classification-derived description an internal message can legitimately contain key material, an internal host or a query fragment; it belongs in the logs, not in a response body test_server_side_failures_never_echo_their_own_message
unclassified exceptions become a generic 500 exception type names, module paths and stack shapes are implementation detail test_unclassified_exceptions_become_a_generic_500
no Traceback, File ", /src/, jdlib., site-packages and no known secret value in any response the whole point of the phase test_responses_never_leak_internals

Leak corpus (directive §3)

A 50-case matrix — ten categories × five failure shapes (4xx authentication, 4xx authorization, 4xx tenancy, 5xx, unclassified) — asserts that none of these survives into a response body, in whole or as the fragments that identify them:

Category Example shape
JWT a three-segment eyJ… token
API key apikey=sk-live-…
password password=…
client secret client_secret=…
private key a -----BEGIN PRIVATE KEY----- block
SQL SELECT … FROM tenant_policy WHERE …
stack trace File "/srv/jdlib/src/…/pep.py", line 42
internal policy details resource.document.vdefault rule 3 …
database credentials postgresql+asyncpg://user:pass@host:5432/db
authorization header Bearer …

Because client-visible messages are echoed by design, this is enforced by a redaction pass (_SECRET_PATTERNS in responses.py): PEM blocks, JWTs, bearer credentials, URL-embedded credentials, key=value secret assignments, SQL statements, stack frames and fully-qualified policy names are replaced with [redacted] before the message leaves the process. Two tests keep it honest — redaction must not empty a message, and it must not mangle an ordinary one (principal user-1 may not read document doc-42 passes through unchanged). Disabling the redaction pass makes 28 of the 75 tests fail, so the guard is load-bearing rather than decorative.

3. WWW-Authenticate (RFC 6750)

A 401 carries the challenge for its code, and no other status carries one:

Code Challenge
AUTHENTICATION_REQUIRED Bearer realm="jdlib"
INVALID_TOKEN, UNKNOWN_PRINCIPAL Bearer realm="jdlib", error="invalid_token"
EXPIRED_TOKEN Bearer realm="jdlib", error="invalid_token", error_description="The access token expired"

4. Behaviour change: a decision that could not be taken is a 503

Directive §90 requires behaviour changes to be documented; this is one, and it was made deliberately.

Before: a policy decision point that could not reach a verdict produced PermissionDenied — 403, marked non-retryable and indistinguishable from a real policy denial.

Now: the enforcement point raises the new AuthorizationUnavailable (POLICY_UNAVAILABLE, 503, retryable) in that case. It is still a denial — nothing is ever allowed because an engine was down, which remains the §16 property the Phase 3 tests assert — but:

  • a client can distinguish "you may not" (stop) from "we could not decide" (retry with backoff),
  • operators get an alertable signal instead of a stream of 403s that look like policy denials,
  • the message never names the engine or its endpoint, so the 503 does not leak architecture.

AuthorizationUnavailable lives in jdlib.errors with the other error classes and is re-exported by jdlib.security.errors; the 503 mapping uses the POLICY_UNAVAILABLE code that already existed in the code enum, which is what it was reserved for.

5. Public surface

jdlib.security.__all__ gains exactly three names in this phase — AuthorizationUnavailable, ErrorResponse, error_response — and test_security_public_surface.py pins the surface as an exact set, so any further addition is a deliberate, reviewable act. jdlib.__all__ is unchanged (the core's 13 exports are asserted to be exactly unchanged, including the negative assertion that SecurityConfig is not among them).

6. Deferred

  • Closed (G10). The envelope is wired: jdlib.integrations.fastapi.install installs install_error_envelope — and it can be called alone by an application that builds its own edge — the middleware derives the request ids before anything can fail, a 401 emits its WWW-Authenticate challenge, and tests/integration/test_error_envelope_contract.py pins the four-key shape, the 5xx rule and the edges (including the framework's own HTTPException). This section originally recorded the deferral: "produced by the library but not yet wired into a FastAPI exception handler".
  • Response sizes are not compressed or padded; no timing side channel hardening beyond what is described here is claimed.