contracts.rules_auth_meta

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

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/ …

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 / @requires decorators 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 (static RouteMeta.authis non-open, or its handler carries the_chirp_requires_authmarker) but no AuthMiddleware is registered. Without it, get_user()raises LookupError→ 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). Dynamic meta() 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. A RouteMeta.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

function _edit_distance

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…

Jump to symbol
function _near_reserved_token

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,…

Jump to symbol
function _auth_spec_is_open

True when a staticRouteMeta.authdeclares no gate.

Mirrorsnormalize_auth_spec(): Noneand the falsy/empty string case are open, as are the exact reserved…

Jump to symbol
function _handler_declares_auth

True when a route's handler carries the static auth-gate marker.

The marker (_AUTH_MARKER) is set by @login_required / @requireson the OUTERMOST…

Jump to symbol
function check_auth_middleware

Flag auth-declaring routes whenAuthMiddlewareis absent.

A route "declares auth" when EITHER:

  • its staticRouteMeta.auth(from route_metas, keyed by URL path) is…
Jump to symbol
function _permission_names

Return the declared permission name(s) for anauthvalue.

  • a bare non-openstr-> that single permission (the legacy shape);
  • anAuthSpec-> its …
Jump to symbol
function _scope_names

Return the declared machine-token scope name(s) for anauthvalue.

Only a structuredAuthSpec carries scopes — a bare string authnever produces scopes…

Jump to symbol
function _looks_like_reserved_token_confusion

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 /…
Jump to symbol
function check_auth_spec

Flag declaredRouteMeta.authpermissions/policies/scopes that fail silently.

A non-reservedauth string (or an AuthSpec.permissionsentry) is a required PERMISSION; anAuthSpec.policyis a NAME…

Jump to symbol
function _typo_issue

Build the reserved-token-confusionauth_specissue for a permission.

Jump to symbol
_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 staticRouteMeta.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-openstr-> that single permission (the legacy shape);
  • anAuthSpec -> its permissionstuple (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 referencedAuthSpec.policyNAME not in policy_registry is ALWAYS an ERROR (env-aware), INCLUDING when policy_registry is empty. AnAuthSpec(policy="x")with no register_policy("x")is unconditionally a bug — it raises LookupError-> 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); an AuthSpec.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 EXISTING auth_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