From 0784ef37197dd4cefa2f0f20db36c35b116dc34b Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 13 Aug 2026 08:04:09 +0000 Subject: [PATCH] docs: Document API tokens and the OpenAPI reference --- CLAUDE.md | 38 +++++++++- docsite/docs/reference/api-tokens.md | 108 +++++++++++++++++++++++++++ docsite/sidebars.ts | 2 +- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 docsite/docs/reference/api-tokens.md diff --git a/CLAUDE.md b/CLAUDE.md index 0e6fe72..173366a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -434,6 +434,39 @@ same commit. `UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself. +### API tokens and OpenAPI + +A token is `vt_` plus 32 random bytes hex, shown once at creation and stored +only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read +token, and for the same reason: nothing downstream ever needs the plaintext +back. It belongs to the user who created it, and its role can never exceed +theirs; see the `api_tokens` note under MongoDB Collections for how that stays +true across a demotion rather than only at issuance. Scopes are eight +resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`, +`workloads`, `settings` — each split into `:read` and `:write`, with `:write` +satisfying a `:read` requirement on the same resource so a caller does not have +to hold both. `settings:write` covers token management itself, which is why +creating and revoking tokens is owner|admin territory the same as the rest of +Settings. Expiry is optional per token; `settings.api_token_max_days` caps how +far out a new one may be set, and when that cap is set a token requested with +no expiry is refused rather than silently capped — the policy governs +issuance only and never reaches back to invalidate a token already issued. +`RateLimitTokens` holds every token to 600 requests/minute in a Redis fixed +window, answering 429 with `Retry-After`; cookie sessions are untouched; it +exists so a runaway script cannot take an instance down, not as the general +API rate-limiting project some future ticket might build. + +`server/internal/api/docs/openapi.json` is a **generated, committed** OpenAPI +3.1 document — `swag v2` reading `@…` annotations off the handlers — served at +`GET /api/openapi.json` and rendered as a reference page by a vendored Scalar +bundle at `GET /api/docs`. `server-deploy.yml` regenerates it on every server +build and runs `git diff --exit-code` against the committed copy: a handler +whose annotation drifted from its code fails CI rather than shipping a +reference that lies. Scalar is vendored (`scalar.standalone.js`, served from +`GET /api/docs/scalar.js`) rather than pulled from a CDN, because the +reference page has to work on an air-gapped install with no outbound access at +all — the same requirement licence verification already meets. + ### Marketing site and sitesvc `site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal. @@ -675,6 +708,8 @@ licence GET /license · POST /license (POST: self-hosted onl org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin) +tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id + GET /openapi.json · GET /docs ``` `GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy. @@ -749,7 +784,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no ## MongoDB Collections -`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `migrations` +`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `migrations` Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`. @@ -768,6 +803,7 @@ Notes that are not obvious from the structs: - `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable". - `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`. - **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance. +- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token. Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. diff --git a/docsite/docs/reference/api-tokens.md b/docsite/docs/reference/api-tokens.md new file mode 100644 index 0000000..d587def --- /dev/null +++ b/docsite/docs/reference/api-tokens.md @@ -0,0 +1,108 @@ +--- +id: api-tokens +title: API tokens +sidebar_label: API tokens +--- + +A session cookie is fine for a browser. A script, a CI job or a cron task +needs something it can hold onto instead — an API token. + +## Creating one + +Settings → **Access** → API tokens. Give it a name, a role (owner, admin or +member) and one or more scopes, and optionally an expiry. The value is shown +once, in full, immediately after creation: + +``` +vt_8f2c1a9e4b6d0735a1c8e29f4b0d6e17... +``` + +That is the only time you will see it. Vantage stores a hash of the token, +never the value itself, so if you lose it there is no support ticket that gets +it back — create a new token and revoke the old one. + +## Scopes + +A token can reach only what its scopes name. There are eight resources, each +with a `:read` and a `:write` scope, and holding `:write` on a resource also +satisfies a `:read` requirement for it — you do not need to tick both. + +| Resource | Covers | +| ----------- | --------------------------------------------------- | +| `servers` | Fleet list, server detail, agent commands, tags | +| `keys` | SSH key library and assignment | +| `secrets` | The vault | +| `workflows` | Steps, workflows, runs and their logs | +| `monitors` | Monitors, incidents, uptime and notification channels | +| `vulns` | Vulnerability findings, packages and scan rules | +| `workloads` | Containers and systemd units, including control actions and logs | +| `settings` | Instance settings, members, single sign-on, licence, and token management itself | + +A token created with only `servers:read` can list and inspect servers but +cannot run a workflow against them, touch a key, or read a secret — each of +those needs its own scope. + +## A token never outranks its owner + +A token's role can be at most the role of the person who created it, and its +effective role is **recomputed on every request** as the lower of the two — +not fixed at creation. Demote the person from owner to member and every token +they hold drops to member from that request onward. Remove the person and +every token they hold stops working immediately: a token has no existence +independent of its owner. + +## Expiry + +An expiry is optional on a token you create. An instance can set a +**maximum token lifetime** (Settings → Access) that caps how far out a new +token's expiry may be set; when that cap is in place, a token with no expiry +at all is refused, so there is no way to route around the policy by leaving +the field blank. + +Changing the maximum lifetime only affects tokens created afterwards. It does +not shorten, extend or invalidate a token that already exists. + +## Using a token + +Send it as a bearer token: + +```bash +curl -H "Authorization: Bearer vt_…" https://acme.vantage.example.com/api/servers +``` + +Everything else about the [REST API](./rest-api.md) applies the same way it +does to a session — JSON errors, audit logging, licence gating on writes — +except that authority comes from the token's role and scopes rather than a +signed-in person's role. + +## Rate limit + +A token is limited to **600 requests per minute**. Going over it gets a `429` +with a `Retry-After` header naming how many seconds to wait. Cookie sessions +are not subject to this limit; it exists so a runaway script cannot take an +instance down, not as a general throttle. + +## Rotating a token + +1. Create the replacement token first, with the scopes and role you need. +2. Deploy it wherever the old one was used, and confirm it works. +3. Revoke the old one. + +Doing it in that order means there is no gap where the credential in use has +already been deleted. + +## The full reference + +This page covers the token model. Every route, request and response shape is +in the generated OpenAPI reference, served by **your own instance** at +`/api/docs` — not this documentation site, since the routes and their shapes +are specific to your install. The raw document is at `/api/openapi.json`. + +:::danger Not the External Secrets token +The bearer token read by `GET /api/secrets/:group/values` for the Kubernetes +External Secrets Operator is a **separate credential** — a single instance-wide +value, rotated from Settings, that reaches only that one endpoint. It is not an +API token and an API token cannot be used in its place: the two are checked by +different code, and neither substitutes for the other. See +[Secrets](../vantage/secrets.md#kubernetes-external-secrets-operator). +::: diff --git a/docsite/sidebars.ts b/docsite/sidebars.ts index 6ecaaff..e005805 100644 --- a/docsite/sidebars.ts +++ b/docsite/sidebars.ts @@ -43,7 +43,7 @@ const sidebars: SidebarsConfig = { { type: "category", label: "Reference", - items: ["reference/environment-variables", "reference/rest-api", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"], + items: ["reference/environment-variables", "reference/rest-api", "reference/api-tokens", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"], }, { type: "category",