Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ jobs:
- simple_module_hosting
- simple_module_inertia
- simple_module_test
- simple_module_api_tokens
- simple_module_audit_log
- simple_module_auth
- simple_module_background_tasks
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,35 @@ All notable changes to this project are documented in this file. The format is b
previously served, and anonymous, in production.

### Added
- **New `api_tokens` module** (#455): personal access tokens (`smpat_…`) and
service API keys (`smsk_…`). Only `sha256(token)` is stored and the secret
is shown once; a token is resolved by one indexed lookup per request (no
cache), so a revoke applies on the next request. Every token carries scopes
that cap the principal's permissions on top of the owner's live grants (an
admin's token is worth its scopes only). Service keys get roles chosen at
creation (never `admin`); every token is pinned to the tenant it was created
in (a PAT only while its owner is still a member). Self-service page at
`/api-tokens/` (user menu), admin screen at `/admin/api-tokens/`; tokens can
only be managed from a cookie session, with `RequiresCsrf`. New PyPI package
`simple_module_api_tokens`; not added to the `smpy new` scaffold. Needs a
migration (`branch_labels = ("api_tokens",)`).
- `UserContext` gains `auth_kind` (`"session"` by default; `"pat"` /
`"service"` for tokens) and `scopes` (`None` = uncapped), carried through
`from_user`, `to_session_dict` and `from_session_dict`. `auth.contracts.tokens`
holds the kinds and the `smpat_` / `smsk_` prefixes.
- `simple_module_hosting.permissions.apply_scope_cap` and
`simple_module_core.permissions.is_admin_principal`. A role-only admin gate
should use `is_admin_principal(user)`, which is `False` for any token.
- `simple_module_hosting.token_guard` (#455): an app-wide dependency that
refuses an API token (403) on every route without a permission gate
(`RequiresPermission` / `require_permission`) and on every page; a public
route serves it as anonymous. `TokenAllowed()` opts a route in explicitly.
Sessions and provider bearer tokens are unaffected. **Behaviour change** for
any module that relied on a token reaching an ungated route: gate it with a
permission, or mark it `TokenAllowed`.
- `auth.contracts.tokens.AUTH_KIND_BEARER`: a `users` access token or Keycloak
JWT now reports `auth_kind == "bearer"` (was `"session"`). Same rights; it
only stops such credentials from managing API tokens.
- Resumable multipart uploads for `file_storage` (#454): `POST /api/file-storage/uploads`
sessions with presigned S3 part URLs (or relayed parts on the filesystem backend),
4-way parallel parts with retry and `localStorage` resume in the Files page,
Expand Down Expand Up @@ -147,6 +176,18 @@ All notable changes to this project are documented in this file. The format is b
production-mode containers pass `UsersSettings` boot validation.

### Changed
- The `users` and `keycloak` auth providers return `None` at once for a bearer
value with an `api_tokens` prefix instead of looking it up (#455), leaving it
to the principal resolver chain.
- `require_permission` (auth) skips its admin-role shortcut, and
`resolve_principal_permissions` / `resolved_permissions_for` cap the set,
when the principal carries `scopes`. The `/admin` index, the Doctor page and
the maintenance bypass no longer admit an admin's token on the role alone.
- The `tenants` resolver binds a service principal to its pinned tenant and
ignores the tenant header and subdomain for it.
- `make new-module`: the scaffolded `tsconfig.json` no longer sets `baseUrl`
(removed in TypeScript 6), `views.py` imports `InertiaResponse` from
`simple_module_inertia`, and `package.json` peers `@inertiajs/react` `^3.7.0`.
- `StoredFile.checksum_sha256` / `StoredFileOut.checksum_sha256` are now nullable (#454):
files uploaded through the multipart flow have no checksum until
`file_storage.compute_checksum` runs, and `ETag`s fall back to id + size.
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export default defineConfig({
{ text: "auth", link: "/modules/auth" },
{ text: "users", link: "/modules/users" },
{ text: "keycloak", link: "/modules/keycloak" },
{ text: "api_tokens", link: "/modules/api_tokens" },
{ text: "permissions", link: "/modules/permissions" },
{ text: "settings", link: "/modules/settings" },
{ text: "feature_flags", link: "/modules/feature_flags" },
Expand Down
29 changes: 29 additions & 0 deletions docs/framework/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,35 @@ class UserContext:

The active auth provider (the `users` or `keycloak` module) owns extraction from the session cookie / bearer token. `InertiaLayoutDataMiddleware` then expands roles into a permission set (cached on `request.state.resolved_permissions`) and serializes `auth.user`, `auth.isAuthenticated`, `auth.permissions` into the Inertia shared props. Permissions are derived mostly from roles via the `PermissionRegistry.role_map`. `UserContext.permissions` adds keys the principal carries itself — a token scope or IdP claim a provider or principal resolver maps in. `resolve_principal_permissions` (and so `RequiresPermission`, `require_permission` and `auth.permissions`) merges them into the role-derived set, **dropping `*`**: like a grant source, a claim can never confer the wildcard; admin comes from roles only. They are written into the session cookie with `roles` and frozen there until the next sign-in, so anything an admin must be able to revoke belongs in a grant source, not here. `UserContext.has_permission(key)` is an exact-match check against that list alone.

## Scoped principals (API tokens) and ungated routes

A principal with `UserContext.scopes` set — an [`api_tokens`](/modules/api_tokens)
personal access token or service key — has its permission set capped at those
scopes. Because the cap can only constrain a route that asks for a permission,
`simple_module_hosting.token_guard` (installed by `create_app` as an app-wide
dependency) applies a fail-closed rule to every route:

- a route whose dependency tree contains `RequiresPermission(...)` or
`auth.deps.require_permission(...)` admits tokens — the gate decides;
- a page (a module view router, or any route depending on `InertiaDep`)
refuses tokens with 403, gated or not;
- `Depends(TokenAllowed())` from `simple_module_hosting.token_guard` admits
tokens explicitly, without a permission;
- every other route refuses tokens with 403 — except a public route, where the
token's principal is dropped and the request runs anonymously (no user, no
permissions, and the tenant an anonymous request would resolve to).

So **gate a route with a permission dependency if API tokens should reach it**;
a permission checked inline in the handler does not count. A gate passed as
`include_router(..., dependencies=[...])` counts: the guard reads the route as
FastAPI runs it (`scope["fastapi"]["effective_route_context"]`), include-level
dependencies included. Should a future FastAPI stop recording that context,
such a route falls back to failing closed (tokens refused), and a page still
refuses tokens through `page_route` itself. Sessions and a
provider's own bearer tokens (`auth_kind == "bearer"`) are unscoped and
unaffected. A custom gate dependency can mark itself with
`mark_token_gate(fn, GATE_PERMISSION)`.

## Role shorthand for menus

`MenuItem.roles` hides an item from users who hold none of the listed roles (an empty list = visible to all authenticated users); `requires_auth` (default `True`) hides it from anonymous visitors. Filtering happens in `MenuRegistry.get_for_user`, called by `InertiaLayoutDataMiddleware` *before* the menu reaches the client — so the client never sees menu items it can't use.
Expand Down
16 changes: 16 additions & 0 deletions docs/framework/principal-resolvers.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ unauthenticated response is suppressed for public paths regardless of
resolver outcome — the chain populates the principal, public-path
allow-listing controls the response.

A bearer-token resolver only ever sees the request if the provider steps
aside. The `users` and `keycloak` providers treat any `Authorization: Bearer`
header as theirs and return a final `None` for a value they do not know —
except values with an `api_tokens` prefix (`auth.contracts.tokens.is_api_token`:
`smpat_`, `smsk_`), which they skip without a lookup. A resolver for a new
bearer format needs the same carve-out.

The shipped [`api_tokens`](/modules/api_tokens) module is the reference
implementation: it sets `UserContext.auth_kind` and `scopes`, and the framework
caps the principal's permissions at those scopes (`apply_scope_cap`), skips
admin-role shortcuts for it (`is_admin_principal`), and refuses it on every
route without a permission gate and on every page (`token_guard`, see
[Permissions](/framework/permissions#scoped-principals-api-tokens-and-ungated-routes)).
A provider's own bearer credential (a `users` access token, a Keycloak JWT)
reports `auth_kind = "bearer"`: unscoped, full rights, but not a cookie session.

## Worked example — bearer-token resolver

A module that ships its own Personal-Access-Token table registers a
Expand Down
Loading
Loading