Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c08b2cc
docs(spec): add auth principal-resolver chain design (#163)
antosubash May 21, 2026
024e7c6
docs(plan): add auth principal-resolver implementation plan (#163)
antosubash May 21, 2026
2a0562c
feat(auth): add PrincipalResolver type alias for credential-chain ext…
antosubash May 21, 2026
cc76d7f
feat(auth): add AuthState registry for principal-resolvers
antosubash May 21, 2026
cdf4006
feat(auth): seed app.state.auth with AuthState in register_settings
antosubash May 21, 2026
adb06c3
feat(auth): re-export PrincipalResolver + UserContext from package root
antosubash May 21, 2026
e2c03b4
chore(auth): drop unused Callable import in resolver registry tests
antosubash May 21, 2026
dfeca2e
test(users): seed app.state.auth in middleware test helper
antosubash May 21, 2026
ba7c1df
feat(users): consult app.state.auth.principal_resolvers + 401-JSON fo…
antosubash May 21, 2026
225369e
test(users): split resolver-chain tests into sibling file to honour 3…
antosubash May 21, 2026
74aee1a
test: end-to-end integration test for principal-resolver chain
antosubash May 21, 2026
afc1f05
docs(framework): add principal-resolver chain reference
antosubash May 21, 2026
9bb1dae
docs: link framework-conventions to the principal-resolver reference
antosubash May 21, 2026
73b7fb2
style: apply ruff format to middleware + resolver integration test
antosubash May 21, 2026
d21e59e
chore(tests): use dict-literal + sort imports to satisfy ruff (C408, …
antosubash May 21, 2026
a00cd18
refactor(auth): apply xhigh code-review feedback
antosubash May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/framework-conventions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -237,6 +237,16 @@ async def create_order(...): ...

`DEFAULT_ROLE_PERMISSIONS` in `simple_module_hosting.permissions` ships only `admin: ["*"]`. Host apps configure their own role → permission map; the framework does not know about plugin permission strings.

## Authentication extension points

The `auth` module exposes a principal-resolver chain on
`app.state.auth.principal_resolvers` — a list of async callables that
`users.AuthMiddleware` consults after the session-cookie path. Use it to add
non-cookie credential sources (Personal Access Tokens, API keys, JWTs)
without forking the middleware. See
[`docs/framework/principal-resolvers.md`](framework/principal-resolvers.md)
for the contract, ordering rules, and a worked Bearer-token example.

## Events

Base class: `Event` from `simple_module_core.events`. Subclass per domain event:
Expand Down
141 changes: 141 additions & 0 deletions docs/framework/principal-resolvers.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
# Principal-resolver chain

The `auth` module exposes an extension point — a list of async resolvers on
`app.state.auth.principal_resolvers` — that lets downstream modules plug in
additional credential sources alongside the built-in session cookie. This is
the supported way to add Personal Access Tokens, API keys, JWT bearers, or
any other request-scoped authentication scheme without forking
`users.AuthMiddleware`.

## The contract

```python
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from auth import PrincipalResolver, UserContext

PrincipalResolver = Callable[[Request], Awaitable[UserContext | None]]
```

A resolver MUST:

- Be **async** (it is awaited by the middleware).
- **Bail fast** when its credential type isn't present (e.g., return `None`
immediately if there is no `Authorization` header) — resolvers run on
every request, including completely unauthenticated ones.
- **Self-check active / disabled state** before returning a `UserContext`.
The middleware does not re-validate.
- **Never raise** on bad credentials — return `None` so the chain continues
to the next resolver. The middleware swallows exceptions defensively but
resolver authors should not rely on it.
- **Not write to the session.** Resolver-authenticated requests are
per-request only; they never silently elevate into a long-lived session
cookie. (To mint a session, use the standard login flow.)

## Resolution order

`users.AuthMiddleware` consults credential sources in this order:

1. **Session cookie** — the existing fast/cached path. If the session
carries a valid `user_id` (and matching cached `user_ctx`), the user is
authenticated and the resolver chain is **not** consulted.
2. **Registered resolvers**, in registration order. The first non-`None`
return wins.
3. **Unauthenticated** — for `/api/*` paths the middleware returns
`401 {"detail": "Not authenticated"}`; for view paths it 302-redirects to
`/users/login` and stashes the original URL in `session["next"]`.

The chain runs on **every** request, including public paths (`/health`,
`/openapi.json`, the login page itself). That lets a resolver attach
`request.state.user` for telemetry even on unauthenticated routes. The
unauthenticated response is suppressed for public paths regardless of
resolver outcome — the chain populates the principal, public-path
allow-listing controls the response.

## Worked example — bearer-token resolver

A module that ships its own Personal-Access-Token table registers a
resolver from its `on_startup` hook:

```python
# modules/example/example/module.py
from __future__ import annotations

from typing import TYPE_CHECKING

from auth import PrincipalResolver, UserContext
from simple_module_core.module import ModuleBase, ModuleMeta
from starlette.requests import Request

if TYPE_CHECKING:
from fastapi import FastAPI


class ExampleModule(ModuleBase):
meta = ModuleMeta(name="Example", depends_on=["Auth", "Users"])

async def on_startup(self, app: FastAPI) -> None:
app.state.auth.principal_resolvers.append(self._build_pat_resolver(app))

@staticmethod
def _build_pat_resolver(app: FastAPI) -> PrincipalResolver:
async def resolve_pat(request: Request) -> UserContext | None:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
token = header.removeprefix("Bearer ")

# Look up the token in the module's own storage and load the user.
async with app.state.sm.db.session_factory() as session:
record = await find_active_token(session, token)
if record is None:
return None
user = await load_user_with_roles(session, record.user_id)
if user is None or not user.is_active or user.disabled_at is not None:
return None
return UserContext.from_user(user)

return resolve_pat
```

`depends_on=["Auth", "Users"]` ensures `AuthModule.register_settings` has
run (so `app.state.auth` exists) and `UsersModule.register_middleware` has
installed `AuthMiddleware` (which calls the resolvers).

### Performance — caching the token lookup

The middleware does not cache resolver results (there's nothing safe to key
on — credentials must be re-validated every request to honor revocation).
For each authenticated request, the resolver opens a fresh DB session
and queries the token store. That's one round-trip per request.

For high-traffic deployments, cache the token-to-user mapping *inside*
the resolver — typically an LRU keyed by the token (or its hash) with a
short TTL. The resolver still runs every request, but the DB lookup is
skipped on cache hits. Pick a TTL short enough that revocation latency
stays acceptable. The framework deliberately stays out of this — caching
policy is a per-module concern.

## When NOT to write a resolver

- **You want to mint a long-lived session.** Use the standard login flow
(`/users/login` or OAuth). Resolvers are explicitly forbidden from
writing the session.
- **You only need a per-endpoint API-key check.** A FastAPI dependency
(`require_api_key`) on the route signature is simpler and keeps the
authenticated-user shape clean.
- **You want to override the `users` module's behavior** (e.g., reject
active users, change role semantics). Resolvers add credential sources;
they don't change the rules of authentication. For that, swap
`UsersModule`/`AuthMiddleware` outright.

## Testing your resolver

Write resolver tests against a minimal app (see
`modules/users/tests/_middleware_support.py::_build_app` for the pattern
used by the framework's own resolver suite — it takes a
`principal_resolvers=` keyword and seeds `app.state.auth` for you).

End-to-end tests should drive the full `create_app(settings)` stack and
append your resolver to `app.state.auth.principal_resolvers` in a fixture —
see `tests/test_principal_resolver_integration.py` for a worked example.
Loading
Loading