Architecture¶
CRUDAuth is ports-and-adapters with feature slices and a single composition root. This page is the map: where things live, which way imports are allowed to point, and how to add a transport, OAuth provider, or backend without a cross-cutting edit.
The one rule¶
Imports point inward. Outer layers may import inner ones; inner layers never import outer ones. If a leaf imports a feature, or two features import each other, something is in the wrong layer.
The layers¶
Framework spine is the set of ports plus the composition root: crud_auth.py
(CRUDAuth, the one object you configure and mount), core.py (the Transport port and the
shared runtime types), resolution.py (the per-request principal resolver), principal.py,
repository.py, identity.py (the account-shape contract), protocols.py (AuthSurface, the
slice of CRUDAuth a route builder may use), and hooks.py. CRUDAuth is the only module
allowed to import from every layer.
Cross-cutting leaves depend on nothing internal: constants.py, exceptions.py, and
utils/ (password hashing, identifier canonicalization, request helpers, callback
introspection), re-exported as one crudauth.utils namespace. The registration gating contract (REGISTRATION_ALLOWED_FIELDS) lives in
constants.py because the spine consumes it.
Features are vertical slices, each owning its router, service, schemas, and constants:
register/, account/, email/, oauth/, mfa/. A feature may import the spine, the leaves,
and the subsystems, but never another feature.
Pluggable subsystems are a base.py port over a backends/ adapter set, so you swap the
backend without touching callers: transports/ (session, bearer), ratelimit/, and
storage/.
The identity contract¶
An account's shape is read from the model, never configured in two places. The user columns
come from models/ (the make_auth_identity factory, with AuthUserMixin its default), and the
repository.py logical-field contract reads them back, so nothing else touches the model
directly. IdentityConfig (identity.py) carries only the intent a schema can't express: the
login resolution order and the recovery factor. At construction CRUDAuth validates that config
against the model's actual columns and fails closed, which keeps the model the single source of
truth and stops the config from drifting from it.
The recovery factor is what makes verification and reset factor-agnostic. "Verified" means the
recovery factor is proven controlled (email is the special case, a phone the general one), and
recovery tokens are handed to DeliveryChannel adapters, so an account recovers over email, SMS,
or any channel you implement. The verify and reset request endpoints are shaped to the factor,
so a phone-recovery app drives them with a phone number.
A request's path¶
When a route depends on current_user(), the PrincipalResolver (resolution.py) runs the
transport loop once and caches the result on request.state, so combining gates (and a
rate limit that reads the principal) does one authentication, not several:
- Each selected transport is tried in order. A transport returns
Nonewhen its credential is absent (move on) but raises for one that's present but invalid (a tampered credential is an attack signal, not "anonymous"). - The winning transport validates its credential, enforces CSRF on unsafe methods where it
applies, resolves your user row, and returns a
Principal. - The gates you asked for (
superuser,verified,scopes,check) run on that sharedPrincipal, per call.
Middleware can resolve first with auth.resolve_principal, which skips CSRF and, by default,
the session activity update. When a route then reuses that principal, the resolver reloads the
user through the route's DB session and calls the transport's revalidate to apply the checks
that were skipped. Failed resolutions are never cached.
Adding things¶
- An OAuth provider: add
oauth/providers/<name>.pyimplementing theprovider.pyport, and register it infactory.py. - A rate-limit or storage backend: add
backends/<name>.pyimplementing the subsystem'sbase.py. Callers reach it through the port. - A transport: add a package under
transports/whose class implements theTransportport fromcore.py, and pass an instance intransports=[...]. Implementrevalidatetoo if a principal reused later in the request needs re-checking, as the session transport does for CSRF and session activity. - A delivery channel: implement the
DeliveryChannelport (email/channel.py) and pass an instance inchannels=[...], so recovery tokens route over SMS, push, or your own transport. It is a port owned by a feature rather than a top-level subsystem. - An account shape: build the model with
make_auth_identity(identifiers=, recovery=, oauth=)and a matchingIdentityConfig; the runtime reads the shape, so username-only, phone-recovery, and email accounts are configuration, not forks. - A feature: add a package with its own
router.py(plusservice.py/schemas.py/constants.py) and mount it fromcrud_auth.py, without importing sibling features. The builder takesAuthSurface(protocols.py), the typed slice ofCRUDAutha route may use, rather than importingCRUDAuthitself - which would be circular.
One caveat: route modules and deferred annotations¶
Modules that declare FastAPI routes deliberately omit from __future__ import annotations,
because FastAPI must see real types (not deferred strings) to resolve Depends(...) and
request-body models. This applies to crud_auth.py and to every module that builds routes or
route dependencies: register/route.py, account/router.py, the OAuth, email and MFA routers,
transports/session/{routes,management}.py, transports/bearer/routes.py, transports/login.py,
and ratelimit/dependency.py. Everywhere else, keep the from __future__ import.