diff --git a/docs/superpowers/specs/2026-08-12-api-tokens-openapi-design.md b/docs/superpowers/specs/2026-08-12-api-tokens-openapi-design.md new file mode 100644 index 0000000..849c09e --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-api-tokens-openapi-design.md @@ -0,0 +1,297 @@ +# API tokens and OpenAPI reference + +Date: 2026-08-12 +Status: approved, ready for implementation planning + +## Problem + +The only programmatic credential the control plane issues is the ESO secrets-read +bearer token, which reaches exactly one endpoint. Everything else requires a +browser session cookie. There is therefore no supported way to drive Vantage from +CI, a script, or infrastructure-as-code, and no machine-readable description of +the REST API for anyone who wants to try. + +This spec covers two deliverables that ship together: scoped API tokens, and an +OpenAPI 3.1 document rendered as a live reference page. A Terraform provider is +the intended follow-on and is explicitly out of scope here — it depends on both +of these being settled, and it is a separate Go module with its own release +cycle. + +## Goals + +- A person can mint a scoped, optionally expiring token and use it against the + existing REST API with no new endpoints to learn. +- A leaked token is bounded by role, by scope, and by expiry policy. +- Offboarding a person removes their tokens as a side effect of removing them. +- The API has a machine-readable description that cannot silently drift from the + handlers it describes. +- The reference page works on an air-gapped self-hosted install. + +## Non-goals + +- Token editing. Role and scopes are immutable; rotation replaces amendment. +- OAuth device flow or any browser-based authorisation grant. +- Per-server or per-tag restrictions on a token. +- Instance-owned service tokens that outlive their creator. +- The Terraform provider. +- General API rate limiting beyond the per-token limit described below. + +## Part 1 — API tokens + +### Token format and storage + +A token is `vt_` followed by 32 random bytes, hex encoded. It is displayed once, +at creation, and never again. + +Only the SHA-256 hash is stored, in a unique index. This follows the precedent +already set by `servers.agent_token_hash` and the ESO read token. bcrypt is +deliberately not used: the value is full-entropy random rather than a +user-chosen password, so a fast hash is sufficient, and a per-token salt would +force a collection scan where an indexed lookup is wanted. + +The first eight characters are stored in clear as `hint`, so the list can +identify a token without revealing it. + +### Authentication path + +`auth.Middleware()` gains a fallback. When there is no `km_session` cookie it +looks for `Authorization: Bearer vt_…`. Both paths end by placing a `*Session` in +the gin context, so every handler, `auth.RequireRole`, `RequireActiveLicense`, +`RequireFeature` and `actorFromCtx` continue to work unmodified. + +``` +Session{ + UserID: token.UserID + InstanceID: token.InstanceID + Role: min(user.Role, token.Role) // owner > admin > member + Email: user.Email + TokenID: token.TokenID // "" for cookie sessions + Scopes: token.Scopes // nil for cookie sessions +} +``` + +The effective role is recomputed on every request rather than frozen at +creation. Demoting the user demotes the token with them. No caching is required +because the user document is already read to confirm the user still exists. + +The existing host guard applies identically. A token carries an `instance_id`, +and a request arriving at a different instance's host is rejected exactly as a +mismatched cookie session is. The tenant boundary must not have a token-shaped +hole in it. + +`last_used_at` is written best-effort and only when the stored value is more +than 60 seconds old, so it does not become a Mongo write per request. + +Rejections: + +| Condition | Status | Body | +| -------------------- | ------ | -------------------------------------- | +| No credential at all | 401 | `not authenticated` | +| Unknown token | 401 | `invalid token` | +| Expired token | 401 | `code: token_expired` | +| Owning user deleted | 401 | `invalid token` | +| Missing scope | 403 | names the required scope | +| Wrong instance host | 403 | `instance host mismatch` | + +### Data model + +New collection `api_tokens`, added to `services.ScopedCollections` so instance +purge reaches it. + +``` +instance_id string +token_id string +user_id string +name string 1-64 chars, unique per user +hint string first 8 chars of the plaintext +token_hash string sha256 +role string owner|admin|member +scopes []string +expires_at *time.Time nil means never +created_at time.Time +last_used_at *time.Time +created_by_ip string +``` + +Indexes: unique on `token_hash`; compound on `(instance_id, user_id)`. + +Deleting a user deletes their tokens as part of the same service call as +`DeleteInstanceUser`, so offboarding is one action rather than two. + +### Expiry policy + +Expiry is optional by default: a token may be created with no expiry at all. +Instance settings gain `api_token_max_days *int`, editable by owner and admin: + +- `nil` — no cap; never-expire is allowed. This is the default, so an upgrade + changes nothing. +- `n > 0` — a new token must expire within `n` days, and a never-expire token is + refused. + +Changing the setting does not retroactively invalidate existing tokens; it is a +policy on issuance. Tokens already outside the new cap are flagged in the UI so +that someone can rotate them deliberately, rather than discovering the change +when a pipeline breaks. + +### Scopes + +Eight resources, each with `:read` and `:write`. Write implies read on the same +resource. + +``` +servers keys secrets workflows +monitors vulns workloads settings +``` + +Scope enforcement is a single middleware, `RequireScopes()`, mounted once in the +`/api` stack. It derives the required resource from the matched gin route +pattern using a map, rather than from a per-route decorator: a route registered +without a decorator would otherwise be unguarded, and this repo already prefers +guards that come from where a route is mounted rather than from someone +remembering. + +- Cookie sessions skip the check entirely. +- A token-authenticated request whose route pattern is absent from the map is + denied with 403. Fail closed. +- A startup check fails boot if any registered `/api` route pattern is missing + from the map, so the failure surfaces at deploy rather than at the first call. + +Deliberate placements: + +- `keys:read` covers `GET /keys/:id/private-key`. Reading a private key is + reading a key. +- `secrets:read` does not cover `GET /api/secrets/:group/values`. That endpoint + keeps its separate ESO bearer path and is unaffected by this work. +- `workloads:write` covers both container control actions and log reads, which + are already restricted to owner and admin. +- The token endpoints themselves map to the `settings` resource: `GET + /api/tokens` requires `settings:read`, and `POST` and `DELETE` require + `settings:write`. A token can therefore mint or revoke tokens only when + explicitly granted that scope, and never above its own role. + +### Endpoints + +``` +GET /api/tokens list; a member sees their own, owner|admin see all +POST /api/tokens create; returns the plaintext once +DELETE /api/tokens/:id revoke; own always, owner|admin any +``` + +There is no `PUT`. Editing a token's role or scopes changes what a credential +already deployed in a CI system can do, with no record of what it could do +before. Rotation replaces amendment. + +`POST` body: `name`, `role`, `scopes[]`, `expires_in_days` (omitted means never, +and is refused when `api_token_max_days` is set). + +Refusals: 400 for an unknown scope, 409 for a duplicate name for that user, 403 +for a role above the creator's own, 422 for an expiry beyond policy. + +### Web UI + +A new "API tokens" card in the Access group of `/settings`, alongside Members +and single sign-on. Not a new nav entry — `/settings/instance` was folded back +into `/settings` for precisely this reason, and the card lives in +`web/components/settings/` with the others, reusing the shared `Field` and +`inputClass`. + +The card lists name, hint, role, scope chips, last used, and expiry with a +distinct state for expired and for over-policy. Revoke is per row and confirms. + +Create opens a modal. The plaintext is shown once in a `--well` block with +copy-to-clipboard and an explicit line saying it will not be shown again. + +Members see only their own rows. Owner and admin get an "All tokens" toggle. + +`api_token_max_days` is a field on the same card, visible to owner and admin +only. + +### Audit + +New events: + +- `token.created` +- `token.revoked` +- `token.expired_use` — a rejected expired token, which is how a forgotten CI + job becomes visible +- `settings.token_policy_updated` + +The actor is the human's email throughout, so `actorFromCtx` needs no change. +Every existing audit event written during a token-authenticated request gains +`via: "token:"` in its detail, so the log distinguishes a person clicking +from their credential acting. + +### Rate limiting + +Token-authenticated requests are limited per token in Redis at 600 per minute, +answering 429 with `Retry-After`. Cookie sessions are untouched. This is narrow +on purpose: it is not the general API rate-limiting project, only enough that a +runaway script cannot take an instance down. + +## Part 2 — OpenAPI and the reference page + +### Generation + +`swaggo/swag` v2, pinned, emitting OpenAPI 3.1. v1 emits Swagger 2.0, which +Scalar renders poorly. + +Handlers in `server/internal/api/*.go` gain annotation comments. Request and +response bodies that are currently anonymous inline structs become named +structs. This is real churn across roughly fifteen files and is the honest cost +of choosing generation over a hand-written document. + +The generated `server/internal/api/docs/openapi.json` is committed and embedded +with `go:embed`, not generated during the image build: `server/Dockerfile` +produces a `scratch` runtime from a Go build stage, and adding codegen there +means putting the toolchain in the build image. + +`server-deploy.yml` gains a check that regenerates the spec and runs +`git diff --exit-code`. An annotation edited without regenerating fails the +build. Without this check the annotations are worth less than a hand-written +document, because they would drift while appearing authoritative. + +### Serving + +``` +GET /api/openapi.json the spec, session or token authenticated +GET /api/docs HTML page loading a vendored Scalar bundle +``` + +The Scalar standalone bundle is vendored under `server/internal/api/docs/`, with +its version recorded in a comment beside it and refreshed by hand. No CDN: +air-gapped self-hosted installs are supported, and a reference page that fails +closed on an offline site is a support ticket. + +Because the page is served by the instance itself, "Try it" acts against the +reader's own API with their own session. + +### Documented auth schemes + +Three, kept distinct: + +- `cookieAuth` — the `km_session` cookie. +- `bearerAuth` — a `vt_…` API token. +- The ESO secrets endpoint is marked as its own separate scheme, so nobody wires + a personal access token into External Secrets Operator. + +## Documentation + +- `docsite/docs/reference/api-tokens.md`: creating a token, the scope table, + curl examples, rotation, and the maximum-lifetime policy. +- `CLAUDE.md`: the three token routes under REST API, the `api_tokens` + collection, and a note that `openapi.json` is generated and CI-verified. + +## Risks + +- The anonymous-struct-to-named-struct conversion is the bulk of the work and + touches handler code this feature otherwise has no business in. +- The vendored Scalar bundle is a manual refresh that nobody will remember. The + version comment is the only mitigation. +- A scope map keyed on gin route patterns breaks if a route path is renamed. The + boot-time completeness check is what turns that into a startup failure rather + than a silent 403 in production. + +## Follow-on work + +A Terraform provider, as its own spec and plan, consuming the tokens and the +OpenAPI document produced here.