Skip to content

feat(api_tokens): personal access tokens and service API keys (#455) - #484

Merged
antosubash merged 10 commits into
mainfrom
feat/api-tokens-455
Oct 15, 2026
Merged

antosubash merged 10 commits into
mainfrom
feat/api-tokens-455

Conversation

@antosubash

Copy link
Copy Markdown
Owner

What it adds

New api_tokens module (simple_module_api_tokens) giving users personal access tokens (PATs) and admins service API keys.

  • Token formats: PAT smpat_<43 base62> (acts as its owner), service key smsk_<43 base62> (acts as svc:<token id>, no person). Plaintext is shown once; only an 8-char display prefix is kept.
  • Hashing: only sha256(token) is stored.
  • Resolver: cheap prefix check, regex format check, then one indexed lookup by hash (no cache). Revoked, expired or unknown tokens give 401.
  • Scope cap: effective permissions are the owner's live roles and grants (PAT) or the roles chosen at creation (service key), intersected with the token's scopes. * never survives in the cap. A user can only pick scopes they hold.
  • Service keys: admin-created, restricted to non-admin, non-tenant roles (API rejects admin, tenant:* and unknown roles with 422).
  • UI: user page at /api-tokens (create, copy-once secret, list, revoke, revoke all) and admin page at /admin/api-tokens (all tokens, service keys).
  • Permissions, audit, revoke-all: api_tokens.use / admin permissions, audit events for create/revoke, and a "revoke all" action.
  • Docs: docs/modules/api_tokens.md.

Framework changes

  • UserContext gains auth_kind, scopes and required_permission.
  • App-wide token guard: tokens are accepted only on permission-gated routes or routes marked TokenAllowed, never on pages (pages answer 403 with anonymous layout props, so the token owner's auth/menus do not leak).
  • is_admin_principal helper.
  • Bearer auth_kind for users access tokens and keycloak JWTs.
  • PAT tenant pinning: a PAT is bound to the tenant it was created in.
  • Anonymous layout props for refused/demoted tokens.
  • users/keycloak provider prefix carve-out so those providers leave smpat_/smsk_ to the token resolver.
  • Scaffold template fixes for make new-module (tsconfig, Inertia import, peer version).

Behaviour changes

  • Bearer logins (users access tokens, keycloak JWTs) now report auth_kind == "bearer" instead of "session"; they can no longer mint PATs.
  • A token on a route with no permission gate now gets 403. Modules that want tokens on such a route add a permission gate or TokenAllowed.

Security review

Adversarial review found 3 medium (M1 tokens reaching sign-in-only routes, M2 PAT not tenant-bound, M3 bearer tokens minting 365-day PATs), lows (L1-L5) and follow-ups N1-N4 (page marker lost through nested include_router; refused-token pages carrying owner auth/menus; revoke-all/list outliving api_tokens.use; public-route tenant context). M1-M3, L2, L5 and N1-N4 are fixed and re-verified; L1, L3, L4 are documented decisions (below).

Known limitations and decisions

  • Tokens survive a password change and "sign out everywhere"; revoke them explicitly.
  • Re-enabling a disabled user revives their tokens.
  • No extra rate limit for token traffic beyond the existing guards.
  • The guard reads FastAPI's effective route context and fails closed if it cannot.
  • No FK on owner_user_id (module isolation).

QA

8/8 scenarios passed (PAT lifecycle, token curl matrix, revoke, service keys, admin scoping, expiry, es fallback, cookie sessions). P3 notes: API-level 422 text for empty name/scopes is not translated (unreachable from UI); only an en catalog is shipped (es shows English, no raw keys); pre-existing: fresh installs have no "user" role, so self-registered users lack api_tokens.use until an admin assigns one.

Release note for maintainer

Register a PyPI pending trusted publisher for simple_module_api_tokens before the next release, or that leg 403s and blocks the tag.

Verification (this run, rebased on origin/main 138c30b, already up to date)

  • make lint: pass
  • make test-py: 4184 passed, 11 skipped, 65 deselected, 1 xfailed
  • make test-js: 71 files, 548 tests passed
  • make migrations-roundtrip-pg: pass (upgrade, downgrade, upgrade, alembic check clean)
  • make doctor: 0 errors, 0 warnings, 1 info (SM026)
  • Local e2e skipped; CI e2e covers it.

Part of the #447-#459 batch (spec lands with #481).

Closes #455

https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4

…carve-out (#455)

UserContext gains auth_kind ("session" by default) and scopes (None =
uncapped), wired through from_user / to_session_dict / from_session_dict.
auth.contracts.tokens holds the credential kinds and the smpat_/smsk_
prefixes.

- resolve_principal_permissions and the bare-router fallback cap the
  resolved set at the principal's scopes (apply_scope_cap); an admin's
  wildcard expands to the scope list only.
- require_permission skips its admin shortcut for a scoped principal;
  the /admin index, the Doctor page and the maintenance bypass use the
  new is_admin_principal, which is False for any token.
- users and keycloak providers return None at once for an api_tokens
  bearer value instead of looking it up.
- the tenants resolver binds a service principal to its pinned tenant
  and ignores the tenant header and subdomain for it.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
New module simple_module_api_tokens. Tokens are smpat_/smsk_ + 32 random
bytes in base62; only sha256(token) and an 8-char display prefix are
stored, and the secret is returned once. A principal resolver does one
indexed lookup by hash per request (no cache), refuses revoked, expired,
unknown and inactive/disabled-owner tokens, and throttles last_used_at to
one conditional UPDATE per 5 minutes in its own transaction.

Scopes must be held by the creator at creation and cap the principal on
every request on top of the owner's live grants. Service keys act as
svc:<id> with roles chosen at creation (never admin or tenant:*) and are
pinned to their tenant. Tokens can only be created, listed and revoked
from a session (403 for any token), with RequiresCsrf on every mutation.
Self-service page at /api-tokens/ (user menu), admin screen at
/admin/api-tokens/ (Access group). Create/revoke are audited; the hash is
excluded. Migration 51b2058d8afd (branch label api_tokens), verified with
the Postgres round trip. Added to the release matrix; not to `smpy new`.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
The scaffolded tsconfig set baseUrl, which TypeScript 6 rejects (TS5102)
and fails `make lint`; views.py imported InertiaResponse from a package
that does not exist; package.json peered @inertiajs/react ^2.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
Hide role-gated menu entries from token principals, vary PAT tenant
resolution on Authorization, fix scaffold views import order, and add
tests for scaffold templates, UserContext token fields and
is_admin_principal.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
- M1: simple_module_hosting.token_guard, an app-wide dependency. API tokens
  reach only permission-gated, non-page routes (or TokenAllowed). Other
  routes get a 403; public routes serve the token as anonymous.
- M2: PATs are pinned to their creation tenant like service keys and need
  an active membership there, else 401. Token answers vary on Authorization.
- M3: provider bearer credentials are auth_kind "bearer" (unscoped, full
  rights). Token management requires a cookie session with no
  Authorization header.
- L2: /users/me fails closed on a non-UUID principal.
- L5: drop tenant:* and admin from stored service-key roles.
- Refuse a PAT for an account the resolver cannot load (Keycloak), with a 400.
- L1: "Revoke all" endpoint and button. L1/L3/L4 are documented decisions.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
Guard accepted websockets (HTTPConnection), page marker now enforced at
request time so nested include_router cannot hide it, page beats TokenAllowed.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
…s 4)

- N2: layout shared props render a token principal as anonymous (no
  auth user, permissions, menus or tenant memberships on 403/public pages).
- N3: a PAT requires its owner to currently hold api_tokens.use
  (UserContext.required_permission, checked tenant-aware after tenant
  resolution); otherwise the request is demoted to anonymous -> 401.
- N4: demoting a token to anonymous also re-resolves the tenant as
  anonymous (TenantMiddleware re-resolution, Vary kept).
- N1: token_guard reads FastAPI's effective route context so
  include_router(dependencies=[...]) gates and page markers are seen;
  falls back to the original route (fail closed).

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 76d87df
Status: ✅  Deploy successful!
Preview URL: https://1922a848.simple-module-python.pages.dev
Branch Preview URL: https://feat-api-tokens-455.simple-module-python.pages.dev

View logs

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-10-15T13:15:33.305374Z 1f58d87 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

# Conflicts:
#	CHANGELOG.md
#	framework/hosting/simple_module_hosting/_module_routes.py
@antosubash
antosubash merged commit 2b3b5fd into main Oct 15, 2026
14 checks passed
@antosubash
antosubash deleted the feat/api-tokens-455 branch October 18, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

users: personal access tokens / API keys for non-browser clients

1 participant