From c07da3912b1cc4b5d112200f7af16c5ad3061d02 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 15:33:02 +0200 Subject: [PATCH] docs(16.3): capture phase context --- .../16.3-CONTEXT.md | 145 ++++++++++++++++++ .../16.3-DISCUSSION-LOG.md | 118 ++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-DISCUSSION-LOG.md diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md new file mode 100644 index 0000000..8bf1ac7 --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md @@ -0,0 +1,145 @@ +# Phase 16.3: Backend admin personal API tokens with scopes - Context + +**Gathered:** 2026-10-10 +**Status:** Ready for planning + + +## Phase Boundary + +Backend users can create personal API tokens (`g15_…`) with one or more scopes picked at creation time. The framework `backend` guard accepts such a token as well as the admin JWT, and the request runs as the token's owner. Access is the intersection of the token's scopes and the owner's own permissions. Tokens are managed in the admin SPA (`/account`, own tokens) and through `summer admin:token:*` CLI commands (any user). The Journal plugin registers its scopes and accepts tokens on its write routes, so an agent can manage blog posts as a backend author. + +Repos: `summercms.go` (cabana/bouncer guard, token model and migration, scope registry, account API, admin SPA panel, CLI, READMEs and docs); `sm-journal-plugin` (scope registration, switch to the framework principal helper); host applications only for the version bump. + +Out of scope: a backend-user management screen, a superuser view of all tokens, migrating Apparatus tokens, Payment and Fonoteka scopes (they register through the same API when their phases need them). + + + + +## Implementation Decisions + +### Scope model +- **D-01:** Scopes are named and registered by plugins through a new pact capability (working name `pact.HasTokenScopes`). Each scope has a name, a label and description (phrasebook keys), a list of permission patterns (Winter `.*` wildcard semantics, Phase 9 D-03) and a list of allowed HTTP methods. The naming convention is `` for full access and `:read` for read-only, e.g. `journal` → `golem15.journal.*` with all methods. A duplicate scope name fails boot loudly. — **Reversibility:** costly — plugins implement the capability and stored tokens reference scope names. +- **D-02:** Read-only is enforced by the scope's method list. A `:read` scope allows only `GET`/`HEAD`, and the framework refuses any other method with 403 before the handler runs. Plugins do not change their handlers for this. A POST that only reads (search) is not reachable with a read scope; that is accepted. +- **D-03:** A token holds several scopes. A request passes if any one scope allows it, meaning its method list includes the request method and its patterns match every required permission. Scopes are immutable after creation. +- **D-04:** Framework scopes: + - `*` means full access: all methods, and it behaves exactly like the owner. + - `admin:read` means system-wide read-only: `GET`/`HEAD` on everything the owner can see. + + Journal registers `journal` and `journal:read`. Payment (`payment:read`) and Fonoteka scopes come with their own phases through the same API. +- **D-05:** Deny by default. A token reaches a route only when a scope covers it. When `cabana.Allows` is called with an empty permission list under a token, only `*` (and `admin:read` for GET/HEAD) passes; other scoped tokens are refused. `GET /auth/me` is always allowed to any valid token, so an agent can see who it is. +- **D-06:** An explicit "interactive only" marker covers account changes (`PUT /account`, password, email, avatar), token management (create, list, revoke) and logout/refresh. These routes refuse every token, including `*`, and need an admin JWT session. A leaked token cannot take over the account or mint new tokens. — **Reversibility:** reversible. + +### Token lifecycle +- **D-07:** The format keeps the Apparatus shape: `g15_` + 40 lowercase hex chars (20 random bytes from `crypto/rand`, 160 bits). Only the sha256 hex is stored, in a unique indexed column, along with the last 4 characters as a display hint (`g15_…a1b2`). There is no checksum suffix. — **Reversibility:** one-way — issued tokens live in agent and integration configs. +- **D-08:** Expiry is optional. The SPA picker offers 30 / 90 / 365 days / never, defaulting to 90 days. The CLI accepts `--expires=30d|90d|365d|never` or a date, also defaulting to 90 days. Expired tokens return 401. +- **D-09:** Revoking is a soft revoke. It sets `revoked_at` and `revoked_by` and keeps the row for audit. Revoked tokens return 401 and are hidden from the default list, with an option to show them. Name, scopes and expiry cannot be edited: to change one, create a new token and revoke the old one. +- **D-10:** The owner is loaded fresh on every request through the existing `BackendUsers.FindByID` path. Deactivating (`is_activated=false`) or soft-deleting the owner makes all their tokens fail with 401. A password reset and the JWT `tokens_valid_after` cutoff do **not** invalidate tokens, so routine password changes don't break agents. +- **D-11:** `last_used_at` is stamped on use, at most once per minute per token, so every request isn't a write. Lists show name, scopes, hint, `created_at`, `last_used_at`, `expires_at`, and revoked state. +- **D-12:** The table is framework-owned. It is appended to `BackendAdminMigrations` (history id `summercms.cabana`) as a raw-SQL migration with a dated ID. Columns are roughly: id, backend_user_id (FK), name, token_hash (unique), token_hint, scopes (JSON array of names), expires_at, last_used_at, revoked_at, revoked_by, timestamps. Apparatus `golem15_apparatus_personal_api_tokens` rows are **not** migrated. + +### Guard and Journal wiring +- **D-13:** The cabana `backend` guard becomes composite. `Authorization: Bearer g15_…` goes to the token lookup; everything else (Bearer JWT or the admin cookie) goes to the existing `NewBackendJWTGuard` path unchanged. Tokens are accepted only in the Bearer header, never from a cookie or query string. The token row is returned as the bouncer credential (`CredentialGuard`). — **Reversibility:** costly — every backend-authenticated route goes through it. +- **D-14:** The permission side of the scope check lives in the principal. A token-authenticated `Principal` carries its scopes' permission patterns and methods, and `cabana.Allows` requires **both** an owner grant and a matching scope pattern. Every existing check (admin CRUD `RequiredPermissions`, actions, Journal's direct `Allows` calls) is restricted with no per-route annotation. A superuser holding a scoped token is still limited to the scope. The method check runs once, in the guard or middleware. — **Reversibility:** costly — `Allows` semantics change for every caller. +- **D-15:** Cabana exports a principal resolver, working name `cabana.BackendPrincipal(r)` or similar, that runs the composite guard plus the scope and method checks for routes that don't sit under the `backend` middleware. Journal's `controllers/api/auth.go` uses it instead of building its own `NewBackendJWTGuard`. Journal's error bodies and status codes stay byte-identical (`{"error":"Authentication required"}` 401, its existing 403). The public read routes stay optional-auth, so a valid token with `journal` or `journal:read` also unlocks drafts where the owner may see them. This reverses Phase 15 D-15. — **Reversibility:** reversible. + +### Admin SPA and CLI +- **D-16:** The SPA panel is an "API tokens" section on `/account` (`AccountView.vue`). It lists, creates and revokes the current user's own tokens only. There is no superuser view of other users' tokens; superusers use the CLI. The plaintext is shown once after creation with a copy button and a clear "you won't see this again" notice, and is never retrievable afterwards. +- **D-17:** The scope picker lists registered scopes with label and description. It hides scopes that grant the current user nothing (no overlap between the scope's patterns and their grants). `*` is always shown, labelled as full access ("everything you can do"). The server re-validates scope names and the overlap rule on create (422 on an unknown or empty scope). +- **D-18:** Account token endpoints sit under the admin API (Phase 9 D-09/D-10 envelope), for example `GET/POST /_admin/api/v1/account/tokens`, `DELETE /account/tokens/{id}` (revoke) and `GET /account/tokens/scopes`. They carry swag annotations so the OpenAPI document and the SPA TS types cover them, and they are interactive-only (D-06). +- **D-19:** CLI commands in cabana, beside `admin:create`: + - `admin:token:create --name=… --scope=journal [--scope=payment:read] [--expires=90d|never]` prints only the plaintext token to stdout so it can be piped. + - `admin:token:list [] [--revoked]` + - `admin:token:revoke ` + - `admin:token:scopes` lists registered scopes. + + The CLI may create tokens for any backend user and applies the same overlap validation as D-17. +- **D-20:** Module READMEs (cabana, bouncer if its API changes, pact for the new capability) and the `docs/` pages cover the token API, the scope registration capability, the framework scopes and the CLI commands, per the project documentation rules. The Journal README documents its scopes and token auth. + +### Claude's Discretion +- Exact names: the pact capability, the scope struct, the principal resolver, the table (`backend_api_tokens` or similar), and the interactive-only marker. +- How scopes are stored (JSON array vs `text[]`), and what an unknown scope name does at request time after a plugin drops it. Recommended: it grants nothing and is shown as unknown in lists. +- How the 403 for a scope refusal is reported in the framework envelope (e.g. `details.reason = "token_scope"`). +- Whether token requests get their own throttle bucket; reuse an existing throttle shape if cheap. +- How `last_used_at` is debounced (in-memory per process is fine). + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase origin and prior decisions +- `.planning/ROADMAP.md` § Phase 16.3 — goal, repos, explore constraints, success criteria +- `.planning/todos/pending/backend-admin-api-tokens.md` — the todo this phase was promoted from +- `.planning/notes/apparatus-dissolved-into-framework.md` — why tokens are a framework feature, not an Apparatus port +- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md` — D-01..D-04 (backend users, `backend` guard, permission semantics, `admin:create`), D-09/D-10 (admin API paths and envelope) +- `.planning/phases/15-journal-plugin/15-CONTEXT.md` — D-14/D-15 (Journal write API and the backend-JWT-only rule this phase reverses) + +### Reference implementation (behavioral, not ported 1:1) +- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/models/PersonalApiToken.php` — `g15_` + 40 hex, sha256 storage, expiry, last_used_at +- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/middleware/TokenAuthenticate.php` — Bearer lookup, expiry, owner check +- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/Plugin.php` (~lines 335-400) — My Account "API Tokens" tab and create/revoke handlers + +### Pattern to copy (Go) +- `../sm-summercmsio-app/plugins/golem15/user/middleware/api_token.go` — `APITokenGuard`: prefix check, sha256 lookup, last_used stamp, `Scope()` middleware +- `../sm-summercmsio-app/plugins/golem15/user/classes/api_tokens.go` — `MintAPIToken`, `HashAPIToken` +- `../sm-summercmsio-app/plugins/golem15/user/models/api_token.go` — `HasScope` +- `../sm-summercmsio-app/plugins/golem15/user/updates/202610040001_create_api_tokens.go` — token table migration + + + + +## Existing Code Insights + +### Reusable Assets +- `modules/bouncer/guard.go`: `Guard`, `CredentialGuard`. `modules/bouncer/context.go`: `Principal` (`Backend`, `IsSuperuser`, `PermissionGrants`), `WithUser`, `WithCredential`. +- `modules/bouncer/jwt.go`: `NewBackendJWTGuard` (:94), `Authenticate` (:106), `bearerToken` (:314). The JWT path stays as-is behind the composite guard. +- `modules/cabana/auth.go`: `BackendUsers.FindByID` (:40) and `principalFrom` (:103). These are the owner-loading path, already merging role and user grants. +- `modules/cabana/contracts.go`: `Allows` (:186) and `requiredOf` (:237). This is where scope intersection lands (D-14). +- `modules/wristband/crypto.go:26-31`: `sha256Hex` and a constant-time compare. +- `modules/cabana/commands.go`: `admin:create` (:18) and `admin:reset-password` (:107). The new `admin:token:*` commands go beside them. +- `modules/cabana/account.go` plus `admin/src/views/AccountView.vue`: the existing account API and page that host the tokens panel. + +### Established Patterns +- Plugin capabilities are optional pact interfaces compiled by cabana at boot (`modules/pact/capabilities.go:494` `HasPermissions`, compiled in `modules/cabana/registry.go:182-200`). `HasTokenScopes` follows the same path, including fail-loud validation. +- Framework-owned migrations are raw-SQL entries appended to `BackendAdminMigrations` (`modules/lagoon/backend_admin_migrations.go:21`). +- Admin API handlers carry swag annotations; SPA types are generated from OpenAPI. +- Permission checks happen in handlers, not in route middleware, which is why the scope check belongs in `Allows` and not in per-route annotations. + +### Integration Points +- `modules/cabana/http.go:145-159`: where the `backend` guard is built and registered. The composite guard wraps it here. +- `modules/bouncer/registry.go:73-113`: `Registry.Middleware` already prefers `CredentialGuard`, so the token row arrives as the credential. +- `modules/cabana/http.go:245-249`: `/auth/me` and the `/account` routes. The token endpoints and the interactive-only marker go here. +- `../sm-journal-plugin/controllers/api/auth.go`: `newBackendGuard` (:58), `requireBackendPrincipal` (:99) and `requireAccessPosts` (:113) switch to the cabana resolver (D-15). `posts.go` Index/Show `optionalBackendPrincipal` (:32, :121) do the same. +- `../sm-journal-plugin/admin_permissions.go:9`: the permission catalog the `journal` scopes map onto. + +### Noted during scouting (verify, don't assume) +- Journal `UploadFeaturedImage` / `DeleteFeaturedImage` (`posts.go:325, :393`) authenticate, but a permission check was not confirmed. Check it while wiring tokens, because a `journal:read` token must not reach them (the method rule covers this, but the permission gap would be a separate bug). +- Journal's per-user throttle key (`plugin.go:88`) reads `bouncer.User` before any guard runs and likely always falls back to the client IP. + + + + +## Specific Ideas + +- Main use case: an agent manages Journal blog posts as a backend author using a `journal`-scoped token. Later, a reporting agent reads Payment Gateway data with `payment:read`. +- The token panel follows the Apparatus/Winter "My Account → API Tokens" placement. +- CLI `admin:token:create` output is pipe-friendly (only the token on stdout). + + + + +## Deferred Ideas + +- Backend-user management screen, including a superuser view and revoke of other users' tokens: its own phase. +- `payment:read` scope: registered by the Payment Gateway phase (18.x) through D-01. +- Fonoteka scopes: when a Fonoteka integration needs token access. +- Secret-scanner-friendly checksum token format: only if a scanner partnership is pursued. + + + +--- + +*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes* +*Context gathered: 2026-10-10* diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-DISCUSSION-LOG.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-DISCUSSION-LOG.md new file mode 100644 index 0000000..b57ceff --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-DISCUSSION-LOG.md @@ -0,0 +1,118 @@ +# Phase 16.3: Backend admin personal API tokens with scopes - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-10-10 +**Phase:** 16.3-backend-admin-personal-api-tokens-with-scopes +**Areas discussed:** Scope model, Token lifecycle, Guard & Journal wiring, SPA panel & CLI + +--- + +## Scope model + +| Option | Description | Selected | +|--------|-------------|----------| +| Named scopes, plugin-registered | Plugins register scopes mapping to permission patterns; framework `*` | ✓ | +| Scope = permission patterns | Token stores raw permission patterns | | +| Coarse fixed set | read/write/* like user plugin tokens | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Scope declares methods + patterns | Read scopes allow GET/HEAD only, enforced before handler | ✓ | +| Read-only permission subset | Read scope maps to view-type permissions | | +| Both | Methods and permission subset | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Several | Token stores a list; any scope allowing passes | ✓ | +| Exactly one | One scope per token | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Deny by default, except whoami | Only `/auth/me` open; account/token routes need interactive JWT even with `*` | ✓ | +| `*` reaches everything | System-wide token can change account and mint tokens | | +| Allow unchecked routes | Unguarded routes open to any token | | + +## Token lifecycle + +| Option | Description | Selected | +|--------|-------------|----------| +| Optional, presets | 30/90/365/never, default 90 days | ✓ | +| Required, capped | Must expire, max 1 year | | +| Optional, no default | Apparatus as-is | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Owner deactivated or deleted | Password reset / tokens_valid_after do not kill tokens | ✓ | +| Also on password reset / session cutoff | Tokens respect tokens_valid_after | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Soft revoke, immutable | revoked_at kept for audit; no edits | ✓ | +| Hard delete, immutable | Apparatus behavior | | +| Soft revoke, editable name/expiry | Rename and extend allowed | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Keep Apparatus shape + hint | g15_ + 40 hex, sha256, last-4 hint | ✓ | +| Add checksum | GitHub-style base62 + CRC32 | | + +## Guard & Journal wiring + +| Option | Description | Selected | +|--------|-------------|----------| +| Bearer header only | Composite guard; g15_ to token lookup, rest to JWT | ✓ | +| Bearer + query param | Also ?token= | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Framework helper, keep Journal's envelope | cabana resolver replaces Journal's hand-built guard | ✓ | +| Mount Journal writes under backend middleware | Changes Journal error shape | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Inside the principal / cabana.Allows | Allows intersects owner grants and scope patterns | ✓ | +| Route middleware per scope | Routes declare RequireScope | | + +| Option | Description | Selected | +|--------|-------------|----------| +| `*` passes, scoped tokens denied | Empty permission list denied to scoped tokens; interactive-only marker | ✓ | +| Scoped tokens pass too | Current Allows semantics | | + +## SPA panel & CLI + +| Option | Description | Selected | +|--------|-------------|----------| +| /account only, own tokens | Superusers use CLI for others | ✓ | +| /account + superuser view | New admin screen | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Only scopes their permissions intersect | Hide useless scopes; `*` always shown; server re-checks | ✓ | +| All registered scopes | Simpler | | + +| Option | Description | Selected | +|--------|-------------|----------| +| admin:token:create/list/revoke (+scopes) | Plaintext to stdout once | ✓ | +| Fewer commands | create and revoke only | | + +| Option | Description | Selected | +|--------|-------------|----------| +| `*`, journal, journal:read | payment:read later | | +| Also a framework `admin:read` | System-wide read-only scope | ✓ | + +## Claude's Discretion + +- Names of the capability, scope struct, resolver, table and interactive-only marker +- Scope storage type and unknown-scope behavior +- Scope-refusal 403 details +- Token throttle bucket +- last_used_at debounce mechanism + +## Deferred Ideas + +- Backend-user management screen with superuser token view +- payment:read scope (Payment Gateway phase) +- Fonoteka scopes +- Checksum token format