docs: Specify scoped API tokens and an OpenAPI reference

Adds the approved design for personal access tokens on the control plane
REST API, and for the generated OpenAPI 3.1 document served as a Scalar
reference page.

Tokens fall back into the existing session middleware rather than getting
their own route group, so every handler, role guard and audit call works
unchanged. Scope enforcement derives from the registered route pattern and
fails closed, with a boot-time check for unmapped routes.

A Terraform provider is deliberately left to a follow-on spec.
This commit is contained in:
2026-08-12 13:59:03 +00:00
parent 449684ceaa
commit 71f9a9dca5
@@ -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:<name>"` 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.