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:
@@ -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.
|
||||
Reference in New Issue
Block a user