Auth-wiring contract checks — AuthMiddleware presence + auth-spec typos.
Two env-aware, deploy-escalating rules that guard the auth surface a route
declares against the runtime wiring that surface needs. Both mirror
rules_security_stack / rules_cookie_secure: middleware is detected by
class name (neverisinstance, never importing middleware into the
contracts layer), severity is read fromconfig.env so chirp check --deployescalates via the production-posture config view, and the
"mutating route" / class-name helpers are reused fromrules_security_stack
rather than re-derived.
Why this matters — a route can declare auth two ways, both of which 500/403 at request time when the wiring is wrong, with NO startup signal otherwise:
RouteMeta.auth(filesystem pages via_meta.py):None/"none"/"optional"are open;"required"is authn-required; any other non-empty string is treated as a single required PERMISSION (seeenforce_route_meta_auth()).@login_required/@requiresdecorators on@app.routehandlers, which now carry a static_chirp_requires_authmarker on the outermost wrapper so a check can prove the handler is auth-gated without executing it (the Marker phase;@wrapskeeps the marker reachable on the stored handler whileinspect.unwrapstill reaches the inner handler).
Both paths callget_user()
(get_user()), which raises LookupError→ a 500
at request time whenAuthMiddlewareis absent from the stack.
Categories:
-
auth_middleware: a route DECLARES auth (staticRouteMeta.authis non-open, or its handler carries the_chirp_requires_authmarker) but noAuthMiddlewareis registered. Without it,get_user()raisesLookupError→ 500. Env-aware: ERROR in production, WARNING in staging, silent in development (the dev 500 surfaces it locally — a standing dev WARNING would just be noise, matchingsecurity_stack). Dynamicmeta()pages (meta_provider_paths) are a static blind spot: never false-ERRORed, but a single INFO notes that auth wiring could not be statically verified for them. -
auth_spec: the silent-403 permission-typo class. ARouteMeta.auththat is a case/whitespace variant of a reserved token ("Required","REQUIRED"," required ","None","Optional") or empty-after-strip is almost certainly meant to be the reserved token but is instead treated as a required PERMISSION named that string — so it 403s forever. HIGH-SIGNAL ONLY: plausible permission names ("admin") are NOT flagged — without a permission registry (a later wave) we cannot know them, and false positives erode trust. Env-aware likeauth_middleware.
contracts.rules_auth_meta
| Name | Type | Default | Description |
|---|---|---|---|
type
|
|
— | |
qualified_name
|
|
— | |
element_type
|
|
— | |
description
|
|
— | |
source_file
|
|
— | |
line_number
|
|
— | |
is_autodoc
|
|
— | |
autodoc_element
|
|
— | |
_autodoc_template
|
|
— | |
_autodoc_url_path
|
|
— | |
_autodoc_page_type
|
|
— | |
title
|
|
— | |
doc_content_hash
|
|
— |
Symbols on this page
Levenshtein distance betweena and b(insert/delete/substitute).
Small DP — auth values are tiny strings, so the O(len(a)*len(b)) table is trivially cheap and runs…
True whenvalue(already lowercased+stripped, non-reserved) is a tight
near-miss of"required"— a likely typo (e.g. 'requied' -> 'required').
Scoped to"required"ONLY,…
True when a staticRouteMeta.authdeclares no gate.
Mirrorsnormalize_auth_spec(): Noneand the falsy/empty string case are open, as are the exact reserved…
True when a route's handler carries the static auth-gate marker.
The marker (_AUTH_MARKER) is set by @login_required / @requireson the OUTERMOST…
Flag auth-declaring routes whenAuthMiddlewareis absent.
A route "declares auth" when EITHER:
- its static
RouteMeta.auth(from route_metas, keyed by URL path) is…
Return the declared permission name(s) for anauthvalue.
- a bare non-open
str-> that single permission (the legacy shape); - an
AuthSpec-> its …
Return the declared machine-token scope name(s) for anauthvalue.
Only a structuredAuthSpec carries scopes — a bare string authnever produces scopes…
True when a permission name is almost certainly a botched reserved token.
The high-signal heuristic used when NO permission registry is declared:
- empty-after-strip /…
Flag declaredRouteMeta.authpermissions/policies/scopes that fail silently.
A non-reservedauth string (or an AuthSpec.permissionsentry) is a
required PERMISSION; anAuthSpec.policyis a NAME…
Build the reserved-token-confusionauth_specissue for a permission.
_edit_distance
function
def _edit_distance(a: str, b: str) -> int
Levenshtein distance betweena and b(insert/delete/substitute).
Small DP — auth values are tiny strings, so the O(len(a)*len(b)) table is trivially cheap and runs only on the handful of declared, non-reserved auth values an app actually ships.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
a
|
str
|
— | |
b
|
str
|
— |
_near_reserved_token
function
def _near_reserved_token(value: str) -> bool
True whenvalue(already lowercased+stripped, non-reserved) is a tight
near-miss of"required"— a likely typo (e.g. 'requied' -> 'required').
Scoped to"required"ONLY, the one reserved token long and specific
enough to attract typos. The short open tokens (none/optional) are
deliberately NOT in the edit-distance neighbourhood: their distance-2 ball
contains real words (node/note near none), which would be false
positives — case/whitespace variants of those (None/Optional) are
still caught by the variant branch. Bounded by_TYPO_EDIT_DISTANCE
so realistic permission names (admin/editor/moderatorare all
distance >= 5 fromrequired) are never flagged.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
value
|
str
|
— |
_auth_spec_is_open
function
def _auth_spec_is_open(auth: Any) -> bool
True when a staticRouteMeta.authdeclares no gate.
Mirrorsnormalize_auth_spec(): Noneand
the falsy/empty string case are open, as are the exact reserved open tokens
"none" / "optional". A bare "required", any other non-empty
string, OR a structuredAuthSpec DECLARES auth (an AuthSpecalways
gates).
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
auth
|
Any
|
— |
_handler_declares_auth
function
def _handler_declares_auth(route: Any) -> bool
True when a route's handler carries the static auth-gate marker.
The marker (_AUTH_MARKER) is set by @login_required / @requires
on the OUTERMOST wrapper the router stores — NOT the inner handler — so it
must be read offroute.handler(and, for mounted pages,
route.page_source_handler) directly, before inspect.unwrapwould
drop to the inner handler and lose it. We additionally walk the unwrap chain
so a marker that landed on an inner layer (e.g. a stacked decorator
arrangement) is still detected. This mirrors howrules_nojs_floorresolves
the user's real handler viapage_source_handler then inspect.unwrap.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
route
|
Any
|
— |
check_auth_middleware
function
def check_auth_middleware(router: Router, config: Any, middleware_list: list[Any], route_metas: dict[str, Any] | None = None, meta_provider_paths: set[str] | None = None) -> list[ContractIssue]
Flag auth-declaring routes whenAuthMiddlewareis absent.
A route "declares auth" when EITHER:
- its static
RouteMeta.auth(from route_metas, keyed by URL path) is non-open — notNone/"none"/"optional"; or - its handler carries the
_chirp_requires_authmarker set by@login_required/@requires(see_handler_declares_auth()).
If ANY auth-declaring route exists and noAuthMiddlewareis registered,
emitauth_middlewarenaming a concrete offending route + the fix.
WithoutAuthMiddleware, get_user() raises LookupError→ 500 at
request time. Severity is env-aware: ERROR in production, WARNING in staging,
silent in development (the dev 500 surfaces it locally — matching
security_stack; no standing dev WARNING).
Dynamicmeta()pages (meta_provider_paths) are a static blind spot: a
page whose_meta.py defines meta()registers a meta provider with
staticmeta left None, so its auth value is invisible here. Those
paths are excluded from the ERROR/WARNING (no false positive) and, when
AuthMiddlewareis absent and such pages exist, get a single INFO noting
auth wiring could not be statically verified for them — mirroring how
check_section_coverage handles meta_provider_paths.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
router
|
Router
|
— | |
config
|
Any
|
— | |
middleware_list
|
list[Any]
|
— | |
route_metas
|
dict[str, Any] | None
|
None
|
|
meta_provider_paths
|
set[str] | None
|
None
|
_permission_names
function
def _permission_names(auth: Any) -> tuple[str, ...]
Return the declared permission name(s) for anauthvalue.
- a bare non-open
str-> that single permission (the legacy shape); - an
AuthSpec-> itspermissionstuple (canonical, post-discovery); - anything open / authn-only ->
().
Mirrorsnormalize_auth_spec(): requiredand
the open tokens declare no permission, so they are never inspected here.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
auth
|
Any
|
— |
_scope_names
function
def _scope_names(auth: Any) -> tuple[str, ...]
Return the declared machine-token scope name(s) for anauthvalue.
Only a structuredAuthSpec carries scopes — a bare string auth
never produces scopes (it normalizes to a permission). The machine-auth
counterpart to_permission_names().
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
auth
|
Any
|
— |
_looks_like_reserved_token_confusion
function
def _looks_like_reserved_token_confusion(name: str) -> bool
True when a permission name is almost certainly a botched reserved token.
The high-signal heuristic used when NO permission registry is declared:
- empty-after-strip / whitespace-only (a permission named
""); - a case/whitespace variant of a reserved token (
"Required"/" required "/"None"/"Optional"); - a tight misspelling of
"required"(edit distance <= 2, e.g."requied").
Plausible permission names ("admin", "billing.read") are NOT flagged.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
name
|
str
|
— |
check_auth_spec
function
def check_auth_spec(config: Any, route_metas: dict[str, Any] | None = None, meta_provider_paths: set[str] | None = None, permission_registry: frozenset[str] | set[str] | None = None, policy_registry: frozenset[str] | set[str] | None = None, scope_registry: frozenset[str] | set[str] | None = None) -> list[ContractIssue]
Flag declaredRouteMeta.authpermissions/policies/scopes that fail silently.
A non-reservedauth string (or an AuthSpec.permissionsentry) is a
required PERMISSION; anAuthSpec.policyis a NAME resolved against the app
policy registry; anAuthSpec.scopesentry is a machine-token SCOPE. All
403 (permission/scope) / 500 (unresolved policy) at request time when wrong,
with no other startup signal.
PERMISSIONS, POLICIES, and SCOPES are validated by design:
- Permissions are opt-in. They are only validated against
permission_registry when that registry is non-empty (a declared
app.register_permission); with no permission registry the high-signal reserved-token-confusion heuristic runs instead ("Required"/" required "/"requied"/ whitespace-only). Plausible permission names ("admin") are never flagged without a registry — false positives erode trust. - Policies always resolve. A referenced
AuthSpec.policyNAME not in policy_registry is ALWAYS an ERROR (env-aware), INCLUDING when policy_registry is empty. AnAuthSpec(policy="x")with noregister_policy("x")is unconditionally a bug — it raisesLookupError-> 500 at request time, so there is no false-positive risk. - Scopes are opt-in (machine-auth axis). They are validated against
scope_registry only when that registry is non-empty (a declared
app.register_scope); anAuthSpec.scopesentry not in the declared set is an env-aware ERROR. With no scope registry scopes are free strings — there is no typo heuristic (a scope is an arbitrary machine token, so a plausible-name heuristic has no signal). This folds into the EXISTINGauth_speccategory — no new category, no severity change.
Env-aware via config.env (ERROR production / WARNING staging / silent
development), same asauth_middleware. Dynamic meta()pages
(meta_provider_paths) are skipped — their auth value is not in
route_metas.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
config
|
Any
|
— | |
route_metas
|
dict[str, Any] | None
|
None
|
|
meta_provider_paths
|
set[str] | None
|
None
|
|
permission_registry
|
frozenset[str] | set[str] | None
|
None
|
|
policy_registry
|
frozenset[str] | set[str] | None
|
None
|
|
scope_registry
|
frozenset[str] | set[str] | None
|
None
|
_typo_issue
function
def _typo_issue(severity: Severity, name: str, path: str) -> ContractIssue
Build the reserved-token-confusionauth_specissue for a permission.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
severity
|
Severity
|
— | |
name
|
str
|
— | |
path
|
str
|
— |
View source · /home/runner/work/chirp/chirp/site/../src/chirp/contracts/rules_auth_meta.py:1