Authorization¶
Two questions, two callbacks, two status codes:
- Who are you? —
is_authorized(node B8). A failure is a401, optionally with aWWW-Authenticatechallenge. This is authentication. - May you do this? —
forbidden(node B7). A failure is a403. This is authorization, of a known principal.
They run early — B8 then B7, both before resource_exists — so a request that
can't identify or isn't allowed is turned away before any work.
Protecting the login itself
Authentication answers who you are; it doesn't cap how often you may try. To throttle credential-stuffing against a login route, add a limiter at an even earlier node — see Rate-limit an auth endpoint.
from dataclasses import dataclass
from asgimachine.auth import bearer_token
from asgimachine.resource import Ctx, Resource
@dataclass(slots=True)
class DocCtx(Ctx):
user: str | None = None
role: str | None = None
class Doc(Resource[DocCtx]):
context_class = DocCtx
def __init__(self, users: dict[str, str], doc: dict) -> None:
self._users, self._doc = users, doc
async def is_authorized(self, ctx: DocCtx) -> bool | str:
token = bearer_token(ctx.request.headers.get("authorization"))
if token is None or token not in self._users:
return "Bearer" # -> 401 + WWW-Authenticate: Bearer
ctx.user, ctx.role = token, self._users[token]
return True # authenticated
async def forbidden(self, ctx: DocCtx) -> bool:
return ctx.user != self._doc["owner"] and ctx.role != "admin" # -> 403
async def represent(self, ctx: DocCtx) -> object:
return {"text": self._doc["text"]}
is_authorizedreturnsTrue(known),False(a bare401), or astr— theWWW-Authenticatechallenge value, so a browser or client knows how to authenticate.forbiddenreturnsTrueto deny (403),Falseto allow. It runs only afteris_authorizedpassed, soctx.useris set.
Parsing the Authorization header
bearer_token extracts and validates the token from Authorization: Bearer …
(scheme case-insensitive, token68 per RFC 6750) — don't hand-roll
removeprefix("Bearer "). Its siblings in asgimachine.auth are
basic_credentials (Basic → (user, password)) and parse_authorization (the
generic (scheme, credentials) split, for any scheme). They parse; verifying
the credential is still your job.
$ curl -is localhost:8000/doc
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
$ curl -is localhost:8000/doc -H 'authorization: Bearer bob' # not the owner
HTTP/1.1 403 Forbidden
$ curl -is localhost:8000/doc -H 'authorization: Bearer alice' # the owner
HTTP/1.1 200 OK
A policy engine for richer rules¶
Hand-rolled boolean logic in forbidden gets unwieldy. asgimachine ships an
ordered Allow/Deny rule engine you delegate to — each rule fires ALLOW/DENY
or abstains (None); first match wins; the default is deny. The decision itself
is the graph's (B7 → 403); how you decide is this collaborator, wired at the
composition root.
from asgimachine.policy import Effect, NamedRule, RuleEngine
async def admin(ctx: DocCtx) -> Effect | None:
return Effect.ALLOW if ctx.role == "admin" else None # admins pass
async def owner(ctx: DocCtx) -> Effect | None:
return Effect.ALLOW if ctx.user == doc["owner"] else None # so does the owner
policy = RuleEngine(
[NamedRule("admin", admin), NamedRule("owner", owner)],
default=Effect.DENY, # everyone else: 403
)
class Doc(Resource[DocCtx]):
def __init__(self, policy: RuleEngine[DocCtx]) -> None:
self._policy = policy
async def forbidden(self, ctx: DocCtx) -> bool:
return not (await self._policy.evaluate(ctx)).allowed
The deciding rule lands in the decision trace, so "which rule denied me" and "which node returned 403" are one story:
Load ownership state early¶
Authorization often depends on the target — "is this note's owner you?". But B7/B8
run before resource_exists (G7), so load the entity you authorize against in
is_authorized, not resource_exists:
async def is_authorized(self, ctx: DocCtx) -> bool | str:
... # authenticate, set ctx.user
ctx.note = await self._store.load(ctx.request.path_params["id"]) # for the policy
return True
Then forbidden (and the policy) can see ctx.note, and resource_exists just
reports whether it was found.
Should you cache parsed credentials?¶
Rarely — and it's worth knowing why, because the instinct is common.
- Within a request,
ctxis the cache. Parse once inis_authorized, stash the principal onctx, and every later callback reads it (the examples above do exactly this). There's nothing to re-parse. - Across requests, cache verification, not parsing. The cost of auth is
checking the credential — a session/DB lookup, a JWT signature verify — which
dwarfs the header parse. An app that cares caches
token → principal, and that cache already amortizes the parse to once per unique token: a hit skips both, a miss does both once.
The helpers are pure functions, so if a profile ever shows the parse itself is hot (extreme RPS, cheap verification, long tokens), caching is one line:
from functools import lru_cache
from asgimachine.auth import bearer_token
cached_bearer = lru_cache(maxsize=4096)(bearer_token)
asgimachine deliberately doesn't ship that cache. It would be a process-global store holding tokens — secrets — with an unbounded-growth policy (a max size, an eviction rule): decisions that belong to your app, not a framework default. And the sharper lever, if the parse ever profiles hot, is a cheaper validation path — not a cache that retains credentials.
401 vs 403 vs 405
401— not authenticated; the client should retry with credentials (hence the challenge).403— authenticated but not permitted; retrying won't help.405— the method isn't allowed on this resource at all — a property of the resource (ALLOWED_METHODS), not the principal. That's a different callback; see Return a specific outcome.