Compare commits
8
Commits
4c88d6e768
...
18495dba68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18495dba68 | ||
|
|
689d0e1d5b | ||
|
|
95527b3956 | ||
|
|
225b53bfa7 | ||
|
|
965419b2b8 | ||
|
|
f6988b0f1e | ||
|
|
0784ef3719 | ||
|
|
9df4a29210 |
@@ -434,6 +434,60 @@ 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. Any signed-in member may mint and revoke their **own** tokens —
|
||||
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
|
||||
`roleRank` already bounds what a token can do to no more than its creator's
|
||||
own role, so a member cannot use a token to reach past themselves. Owner and
|
||||
admin additionally see and revoke every token in the instance — `all=true` on
|
||||
`GET /tokens` is gated by `elevated()` in `api/tokens.go`, and
|
||||
`RevokeAPIToken` in `services/tokens.go` checks the same owner/admin condition
|
||||
before letting a revoke target somebody else's token — neither is a
|
||||
`RequireRole` middleware.
|
||||
The `settings:read`/`settings:write` entries in `routeScopes` govern a
|
||||
**token-authenticated** caller reaching the token endpoints — `RequireScopes`
|
||||
no-ops entirely for a cookie session — so they say nothing about which human
|
||||
role may call these routes with a session; that is `roleRank` and `elevated()`,
|
||||
not the scope map. 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.
|
||||
|
||||
**The UI calls them API keys and lives at `/api-keys`, not on `/settings`.**
|
||||
The page is reachable at **every** role, which is the whole reason it is a page:
|
||||
`/settings` is owner|admin throughout, so a card there hid a capability every
|
||||
member has. `settings.api_token_max_days` stays on `/settings` because it is
|
||||
instance policy rather than one person's credentials, and that split is exactly
|
||||
what lets the page be ungated. The label differs from the identifiers on
|
||||
purpose — the collection is `api_tokens`, the prefix is `vt_`, the routes are
|
||||
`/api/tokens`, and renaming a published endpoint to match a nav label would
|
||||
break every script already written against it.
|
||||
|
||||
`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 +729,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 +805,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 +824,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.
|
||||
|
||||
@@ -965,9 +1022,20 @@ Customer nav is three destinations — Overview, People, Billing. Settings is in
|
||||
| `/steps` | Reusable step library |
|
||||
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
|
||||
| `/secrets`, `/secrets/[group]` | Vault |
|
||||
| `/api-keys` | Personal API keys — reachable at **every** role, unlike `/settings` |
|
||||
| `/audit` | Audit log |
|
||||
| `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence |
|
||||
|
||||
**The sidebar is grouped, and the groups are the nav's structure rather than
|
||||
decoration.** `web/components/Sidebar.tsx` holds `navGroups` — Fleet, Access,
|
||||
Automation, Instance — each rendered with a mono small-caps heading and a
|
||||
hairline rule above it, the first group excepted. Grouping is by what the
|
||||
operator is doing, not by which service answers: SSH keys, vault secrets and
|
||||
API keys sit together under Access because all three are credentials. A group
|
||||
whose every item is `adminOnly` disappears **whole**, heading and rule
|
||||
included, for a member — a labelled section with nothing under it reads as
|
||||
something that failed to load rather than something withheld.
|
||||
|
||||
**`/settings` is one page, not a section.** Members and single sign-on used to
|
||||
live at `/settings/instance` with their own sidebar entry; they are now the
|
||||
**Access** group at the top of `/settings`, above **Monitoring** and
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
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
|
||||
|
||||
**API Keys**, in the Access group of the sidebar. The page is reachable at
|
||||
every role: any member may create and revoke their own keys, and owner and
|
||||
admin additionally see every key in the instance. 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 key lifetime** (Settings → Integrations) 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).
|
||||
:::
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+23
-15
@@ -29,24 +29,32 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @title Vantage API
|
||||
// @version 1.0
|
||||
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
|
||||
// @BasePath /api
|
||||
//
|
||||
// @title Vantage API
|
||||
// @version 1.0
|
||||
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
|
||||
// @BasePath /api
|
||||
|
||||
// Each @securityDefinitions.apikey block below is deliberately its own
|
||||
// comment group, separated by a real blank line rather than a bare "//": Go's
|
||||
// parser only splits ast.CommentGroups on an actual blank line, and
|
||||
// swag v2.0.0-rc5's parseSecAttributesV3 resolves a scheme's map key by
|
||||
// scanning from the start of whatever comment group it was handed — so three
|
||||
// stacked blocks sharing one group all collapse onto the first block's name.
|
||||
// Three groups means three independent scans, each finding its own name.
|
||||
|
||||
// @securityDefinitions.apikey cookieAuth
|
||||
// @in cookie
|
||||
// @name km_session
|
||||
//
|
||||
// @in cookie
|
||||
// @name km_session
|
||||
|
||||
// @securityDefinitions.apikey bearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
|
||||
//
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
|
||||
|
||||
// @securityDefinitions.apikey esoAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
|
||||
|
||||
@@ -2002,7 +2002,18 @@
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"description": "An API token, sent as \"Bearer vt_…\". Scoped and optionally expiring.",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
"type": "apiKey"
|
||||
},
|
||||
"cookieAuth": {
|
||||
"in": "cookie",
|
||||
"name": "km_session",
|
||||
"type": "apiKey"
|
||||
},
|
||||
"esoAuth": {
|
||||
"description": "The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
@@ -3118,47 +3129,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/install": {
|
||||
"get": {
|
||||
"description": "Dynamically generated shell script that downloads, verifies and installs the agent, seeded with a pre-registration token.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Server ID",
|
||||
"in": "query",
|
||||
"name": "server_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Pre-registration token",
|
||||
"in": "query",
|
||||
"name": "token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "shell script"
|
||||
}
|
||||
},
|
||||
"summary": "Agent install script (Linux)",
|
||||
"tags": [
|
||||
"install"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/instance/users": {
|
||||
"get": {
|
||||
"responses": {
|
||||
@@ -7129,7 +7099,7 @@
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "The plaintext token is returned exactly once and stored nowhere. A token's role and scopes cannot exceed the creator's own.",
|
||||
"description": "The plaintext token is returned exactly once and stored nowhere. A token's role cannot exceed the creator's own; when the request is itself token-authenticated, its scopes cannot exceed the calling token's scopes either.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -7324,27 +7294,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/update": {
|
||||
"get": {
|
||||
"description": "Dynamically generated shell script that downloads and installs the latest agent.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "shell script"
|
||||
}
|
||||
},
|
||||
"summary": "Agent update script (Linux)",
|
||||
"tags": [
|
||||
"install"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/vuln-rules": {
|
||||
"get": {
|
||||
"responses": {
|
||||
|
||||
@@ -711,14 +711,13 @@ func applyUpdates(c *gin.Context) {
|
||||
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
|
||||
}
|
||||
|
||||
// handleUpdateScript godoc
|
||||
//
|
||||
// @Summary Agent update script (Linux)
|
||||
// @Description Dynamically generated shell script that downloads and installs the latest agent.
|
||||
// @Tags install
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "shell script"
|
||||
// @Router /update [get]
|
||||
// handleUpdateScript serves a dynamically generated shell script that
|
||||
// downloads and installs the latest agent. Deliberately not in the generated
|
||||
// OpenAPI document: it is registered on the bare engine, not under the /api
|
||||
// group the document's BasePath assumes, so a @Router annotation here would
|
||||
// publish /api/update — a path that 404s — rather than the real top-level
|
||||
// /update. It serves a shell script, not JSON, so there is nothing lost by
|
||||
// leaving it out of a JSON API reference.
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
giteaHost := "gitea.hostxtra.co.uk"
|
||||
|
||||
@@ -878,16 +877,12 @@ func saveSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// handleInstallScript godoc
|
||||
//
|
||||
// @Summary Agent install script (Linux)
|
||||
// @Description Dynamically generated shell script that downloads, verifies and installs the agent, seeded with a pre-registration token.
|
||||
// @Tags install
|
||||
// @Produce plain
|
||||
// @Param server_id query string true "Server ID"
|
||||
// @Param token query string true "Pre-registration token"
|
||||
// @Success 200 {string} string "shell script"
|
||||
// @Router /install [get]
|
||||
// handleInstallScript serves a dynamically generated shell script that
|
||||
// downloads, verifies and installs the agent, seeded with a pre-registration
|
||||
// token. Deliberately not in the generated OpenAPI document, for the same
|
||||
// reason as handleUpdateScript: it is registered on the bare engine, outside
|
||||
// the /api group the document's BasePath assumes, so a @Router annotation
|
||||
// would publish a /api/install path that 404s.
|
||||
func handleInstallScript(c *gin.Context) {
|
||||
serverID := c.Query("server_id")
|
||||
token := c.Query("token")
|
||||
|
||||
@@ -56,7 +56,7 @@ func listTokenScopes(c *gin.Context) {
|
||||
// createToken godoc
|
||||
//
|
||||
// @Summary Create an API token
|
||||
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role and scopes cannot exceed the creator's own.
|
||||
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role cannot exceed the creator's own; when the request is itself token-authenticated, its scopes cannot exceed the calling token's scopes either.
|
||||
// @Tags tokens
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -82,6 +82,32 @@ func createToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// A token-authenticated request may only mint a token whose scopes are a
|
||||
// subset of its own. Role is capped against the creating *user* below (in
|
||||
// services.CreateAPIToken), but a role cap alone does not confine scopes —
|
||||
// without this, a CI token holding only settings:write could mint a token
|
||||
// holding keys:write and secrets:write, since minting only ever required
|
||||
// settings:write and never checked what the caller itself could reach. A
|
||||
// cookie session skips this: its authority is the user's role, not a
|
||||
// scope list.
|
||||
if auth.IsToken(c) {
|
||||
callerScopes := auth.Scopes(c)
|
||||
var excess []string
|
||||
for _, s := range body.Scopes {
|
||||
if !services.ScopeSatisfied(callerScopes, s) {
|
||||
excess = append(excess, s)
|
||||
}
|
||||
}
|
||||
if len(excess) > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": fmt.Sprintf("requested scopes exceed the calling token's own scopes: %v", excess),
|
||||
"code": "scope_confinement",
|
||||
"excess_scopes": excess,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tok, plaintext, err := services.CreateAPIToken(
|
||||
auth.InstanceID(c), auth.UserID(c),
|
||||
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
|
||||
|
||||
@@ -135,9 +135,13 @@ func sessionFromToken(c *gin.Context) (*Session, bool) {
|
||||
tok, err := services.ResolveAPIToken(raw)
|
||||
if errors.Is(err, services.ErrTokenExpired) {
|
||||
// Recorded rather than only refused: an expired token still being
|
||||
// presented is how a forgotten CI job becomes visible.
|
||||
services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "",
|
||||
fmt.Sprintf("expired token '%s' was used", tok.Name))
|
||||
// presented is how a forgotten CI job becomes visible. Throttled to
|
||||
// once per token per minute, or a looping job writes an unbounded
|
||||
// stream of audit rows instead of one.
|
||||
if services.ShouldLogExpiredTokenUse(tok.TokenID) {
|
||||
services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "",
|
||||
fmt.Sprintf("expired token '%s' was used", tok.Name))
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token expired", "code": "token_expired"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
@@ -123,6 +124,9 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expi
|
||||
InstanceID: instanceID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
// Hint is "vt_" plus 5 hex characters of the secret (20 bits) — enough
|
||||
// for a user to recognise their own token in a list, not enough to be
|
||||
// useful to anyone who only has the hint. Considered and accepted.
|
||||
Hint: plaintext[:8],
|
||||
TokenHash: HashToken(plaintext),
|
||||
Role: role,
|
||||
@@ -181,6 +185,40 @@ func TouchAPIToken(tok *models.APIToken) {
|
||||
tok.LastUsedAt = &now
|
||||
}
|
||||
|
||||
var (
|
||||
expiredTokenAuditMu sync.Mutex
|
||||
expiredTokenAuditSeen = map[string]time.Time{}
|
||||
)
|
||||
|
||||
// ShouldLogExpiredTokenUse reports whether an expired token's use is worth a
|
||||
// fresh audit row, throttled to once per token per minute — the same window
|
||||
// TouchAPIToken uses for last-used, kept here rather than in the auth package
|
||||
// because the storage concern (what counts as "recent") belongs beside the
|
||||
// token's other storage-backed state, not scattered into the request layer.
|
||||
//
|
||||
// Without this, a looping CI job presenting one expired token writes an
|
||||
// unbounded stream of token.expired_use audit rows (and a Mongo FindOne per
|
||||
// request), drowning the real audit trail. The first use per window is still
|
||||
// recorded: that is what turns a forgotten job into something visible, rather
|
||||
// than silencing it entirely.
|
||||
//
|
||||
// This is in-memory and per-process, which is a deliberate choice matching
|
||||
// TouchAPIToken: it degrades to "up to once per minute per replica" rather
|
||||
// than needing a shared store, and undercounting a security-relevant audit
|
||||
// signal is the safe direction to err in.
|
||||
func ShouldLogExpiredTokenUse(tokenID string) bool {
|
||||
now := time.Now().UTC()
|
||||
|
||||
expiredTokenAuditMu.Lock()
|
||||
defer expiredTokenAuditMu.Unlock()
|
||||
|
||||
if last, ok := expiredTokenAuditSeen[tokenID]; ok && now.Sub(last) < time.Minute {
|
||||
return false
|
||||
}
|
||||
expiredTokenAuditSeen[tokenID] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// ListAPITokens returns a user's own tokens, or every token in the instance
|
||||
// when all is true. The caller decides whether all is permitted.
|
||||
func ListAPITokens(instanceID string, userID string, all bool) ([]models.APIToken, error) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ApiKeysPanel } from "@/components/apikeys/ApiKeysPanel";
|
||||
|
||||
/**
|
||||
* Reachable at every role, unlike /settings. Any member may mint and revoke
|
||||
* their own API keys — the API has never required owner or admin for that —
|
||||
* and owner and admin additionally see every key in the instance.
|
||||
*/
|
||||
export default function ApiKeysPage() {
|
||||
return <ApiKeysPanel />;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { Field } from "@/components/settings/Field";
|
||||
import { Group } from "@/components/settings/Group";
|
||||
import { SectionCard } from "@/components/settings/SectionCard";
|
||||
import { MembersCard } from "@/components/settings/MembersCard";
|
||||
import { ApiTokensCard } from "@/components/settings/ApiTokensCard";
|
||||
import { AuthProvidersCard } from "@/components/settings/AuthProvidersCard";
|
||||
|
||||
const numberInputClass =
|
||||
@@ -224,7 +223,6 @@ export default function SettingsPage() {
|
||||
<div className="space-y-10">
|
||||
<Group label="Access">
|
||||
<MembersCard />
|
||||
<ApiTokensCard />
|
||||
<AuthProvidersCard
|
||||
localLoginEnabled={settings?.local_login_enabled ?? true}
|
||||
onLocalLoginChange={(v) => {
|
||||
@@ -297,14 +295,29 @@ export default function SettingsPage() {
|
||||
<input type="number" min={0} value={logRetentionDays} onChange={(e) => setLogRetentionDays(Number(e.target.value))} className={numberInputClass} />
|
||||
</Field>
|
||||
|
||||
<div className="mt-6">
|
||||
<Field
|
||||
label="Maximum API token lifetime (days)"
|
||||
hint="0 means no cap, and tokens may be created with no expiry. Changing this affects new tokens only."
|
||||
>
|
||||
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
|
||||
</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* The cap lives here rather than on /api-keys because it is
|
||||
instance policy, not one person's credentials — which is
|
||||
also what lets that page be reachable at every role. */}
|
||||
<SectionCard
|
||||
title="API keys"
|
||||
description="Issuance policy for the keys people create to call the REST API."
|
||||
icon={<KeyIcon />}
|
||||
>
|
||||
<Field
|
||||
label="Maximum API key lifetime (days)"
|
||||
hint="0 means no cap, and keys may be created with no expiry. Changing this affects new keys only — existing keys keep working and are flagged for rotation."
|
||||
>
|
||||
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
|
||||
</Field>
|
||||
<p className="mt-4 text-sm text-text-secondary">
|
||||
Keys themselves are managed on{" "}
|
||||
<Link href="/api-keys" className="text-accent hover:underline">
|
||||
API Keys
|
||||
</Link>
|
||||
, which every member can reach.
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
|
||||
+101
-38
@@ -16,6 +16,16 @@ interface NavItem {
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled run of nav items. Grouping is by what the operator is doing, not
|
||||
* by which API serves the page: credentials sit together under Access whether
|
||||
* they are SSH keys, vault secrets or API keys.
|
||||
*/
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
function ServerIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -145,18 +155,54 @@ function WorkloadIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
||||
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
||||
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
|
||||
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
|
||||
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
|
||||
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
|
||||
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
||||
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
|
||||
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
|
||||
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
|
||||
function TokenIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Fleet",
|
||||
items: [
|
||||
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
||||
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
|
||||
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
||||
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Access",
|
||||
items: [
|
||||
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
|
||||
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
|
||||
// Not adminOnly: the API lets any member mint and revoke their own
|
||||
// keys, capped at their own role, so gating the page would hide a
|
||||
// capability they have.
|
||||
{ href: "/api-keys", label: "API Keys", icon: <TokenIcon /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Automation",
|
||||
items: [
|
||||
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
||||
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Instance",
|
||||
items: [
|
||||
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
|
||||
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Shared by the permanent aside and the offcanvas drawer one copy of the nav. */
|
||||
@@ -164,7 +210,14 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const { user, instance, isAdmin } = useAuth();
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
|
||||
// A group whose every item is admin-only disappears entirely for a member,
|
||||
// heading and rule included — an empty labelled section reads as something
|
||||
// that failed to load.
|
||||
const visibleGroups = navGroups
|
||||
.map((group) => ({ ...group, items: group.items.filter((item) => !item.adminOnly || isAdmin) }))
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
const visibleItems = visibleGroups.flatMap((group) => group.items);
|
||||
|
||||
const activeHref = visibleItems.reduce<string | null>((best, item) => {
|
||||
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
@@ -190,31 +243,41 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul className="space-y-1">
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = activeHref === item.href;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
// The active marker is an accent bar, the same device
|
||||
// site/ uses to mark the chosen plan. A filled pill
|
||||
// reads as a button you can press again.
|
||||
className={clsx(
|
||||
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
|
||||
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{visibleGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.label}
|
||||
// The heading terminates the group above it, so the rule
|
||||
// goes on top and the first group needs none.
|
||||
className={clsx(groupIndex > 0 && "mt-4 border-t border-border pt-4")}
|
||||
>
|
||||
<p className="px-3 pb-1.5 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{group.label}</p>
|
||||
<ul className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const isActive = activeHref === item.href;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
// The active marker is an accent bar, the same device
|
||||
// site/ uses to mark the chosen plan. A filled pill
|
||||
// reads as a button you can press again.
|
||||
className={clsx(
|
||||
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
|
||||
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t border-border px-4 py-3">
|
||||
|
||||
+211
-113
@@ -4,9 +4,25 @@ import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type ApiToken, type Role } from "@/lib/api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { Field, inputClass } from "./Field";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import {
|
||||
AsyncBoundary,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
Modal,
|
||||
Table,
|
||||
TableSkeleton,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
friendlyMessage,
|
||||
useToast,
|
||||
} from "@/components/ui";
|
||||
import { Field, inputClass } from "@/components/settings/Field";
|
||||
|
||||
const ROLES: Role[] = ["owner", "admin", "member"];
|
||||
|
||||
@@ -24,18 +40,6 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
* confirmation message name a token rather than a token_id. */
|
||||
type PendingRevoke = { id: string; name: string };
|
||||
|
||||
function TokenIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function roleVariant(role: Role) {
|
||||
if (role === "owner") return "accent" as const;
|
||||
if (role === "admin") return "warning" as const;
|
||||
@@ -47,6 +51,43 @@ function rolesAtOrBelow(role: Role): Role[] {
|
||||
return idx === -1 ? ROLES : ROLES.slice(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses ["servers:read","servers:write","keys:read"] into one chip per
|
||||
* resource carrying its access. Sixteen scopes rendered as sixteen badges make
|
||||
* the row taller than everything around it and still have to be read one at a
|
||||
* time; the resource is what a person scans for, and r/w is the qualifier.
|
||||
*/
|
||||
function summariseScopes(scopes: string[]): { resource: string; access: string }[] {
|
||||
const byResource = new Map<string, { read: boolean; write: boolean }>();
|
||||
for (const scope of scopes) {
|
||||
const [resource, action] = scope.split(":");
|
||||
const entry = byResource.get(resource) ?? { read: false, write: false };
|
||||
if (action === "read") entry.read = true;
|
||||
if (action === "write") entry.write = true;
|
||||
byResource.set(resource, entry);
|
||||
}
|
||||
return Array.from(byResource, ([resource, { read, write }]) => ({
|
||||
resource,
|
||||
// write implies read on the server, so a token holding only :write is
|
||||
// still shown as rw rather than pretending it cannot read.
|
||||
access: write ? "rw" : read ? "r" : "",
|
||||
}));
|
||||
}
|
||||
|
||||
function ScopeChips({ scopes }: { scopes: string[] }) {
|
||||
if (scopes.length === 0) return <span className="text-text-secondary">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{summariseScopes(scopes).map(({ resource, access }) => (
|
||||
<Badge key={resource} variant="neutral">
|
||||
{resource}
|
||||
<span className="ml-1 font-mono text-[0.65rem] uppercase tracking-[0.08em] opacity-70">{access}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a token's expiry, plus a policy note when the cap has tightened
|
||||
* since the token was issued. The policy is not applied retroactively, so an
|
||||
* outside-policy token is a prompt to rotate, not a failure of any kind. */
|
||||
@@ -76,7 +117,15 @@ function ExpiryCell({ token, capDays }: { token: ApiToken; capDays: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiTokensCard() {
|
||||
/**
|
||||
* The whole API Keys page body, header included.
|
||||
*
|
||||
* It is a page rather than a card on /settings because any member may mint and
|
||||
* revoke their own keys — the API has never required owner or admin for that —
|
||||
* while /settings is owner|admin throughout. The instance-wide lifetime cap
|
||||
* stays on /settings, being policy rather than one person's credentials.
|
||||
*/
|
||||
export function ApiKeysPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, isAdmin } = useAuth();
|
||||
const toast = useToast();
|
||||
@@ -173,97 +222,130 @@ export function ApiTokensCard() {
|
||||
|
||||
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
|
||||
|
||||
const count = tokens?.length ?? 0;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="API tokens"
|
||||
description="Scoped, personal tokens for scripts and CI to call the REST API without a browser session."
|
||||
icon={<TokenIcon />}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAll}
|
||||
onChange={(e) => setShowAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
|
||||
/>
|
||||
All tokens
|
||||
</label>
|
||||
)}
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Create token
|
||||
</Button>
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">API Keys</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{count} key{count !== 1 ? "s" : ""} · {showAll ? "instance-wide" : "yours"}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Owner and admin can see everyone's keys, so the scope of the list
|
||||
is a filter rather than a preference — the same pill treatment
|
||||
the vulnerabilities page uses for its state filter, so a person
|
||||
who has learned one has learned both. */}
|
||||
{isAdmin && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ label: "My keys", all: false },
|
||||
{ label: "All keys", all: true },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => setShowAll(option.all)}
|
||||
aria-pressed={showAll === option.all}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||||
showAll === option.all ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="py-6 text-sm text-danger">{friendlyMessage(error)}</p>
|
||||
) : !tokens || tokens.length === 0 ? (
|
||||
<p className="py-6 text-sm text-text-secondary">No API tokens yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
{showAll && <Th>Owner</Th>}
|
||||
<Th>Role</Th>
|
||||
<Th>Scopes</Th>
|
||||
<Th>Last used</Th>
|
||||
<Th>Expires</Th>
|
||||
<Th className="text-right">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{tokens.map((t) => (
|
||||
<Tr key={t.token_id}>
|
||||
<Td label="Name">
|
||||
<span className="font-medium text-text-primary">{t.name}</span>
|
||||
<div className="font-mono text-xs text-text-secondary">{t.hint}…</div>
|
||||
</Td>
|
||||
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
|
||||
<Td label="Role">
|
||||
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
|
||||
</Td>
|
||||
<Td label="Scopes">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.scopes.map((s) => (
|
||||
<Badge key={s} variant="neutral">
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Td>
|
||||
<Td label="Last used" className="text-text-secondary">
|
||||
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"}
|
||||
</Td>
|
||||
<Td label="Expires">
|
||||
<ExpiryCell token={t} capDays={capDays} />
|
||||
</Td>
|
||||
<Td label="Actions" className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
|
||||
>
|
||||
Revoke<span className="sr-only"> {t.name}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Card padding={false}>
|
||||
<AsyncBoundary
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
skeleton={<TableSkeleton columns={showAll ? 7 : 6} />}
|
||||
isEmpty={count === 0}
|
||||
empty={
|
||||
<EmptyState
|
||||
title={showAll ? "No API keys in this instance." : "You have no API keys."}
|
||||
description={
|
||||
showAll
|
||||
? "Nobody has created a key yet. Keys let scripts and CI call the REST API without a browser session."
|
||||
: "Create one to call the REST API from a script or a CI job. It is scoped to what you grant it and never exceeds your own role."
|
||||
}
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
action={{ label: "Create your first key", onClick: () => setCreateOpen(true) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
{showAll && <Th>Owner</Th>}
|
||||
<Th>Role</Th>
|
||||
<Th>Scopes</Th>
|
||||
<Th>Last used</Th>
|
||||
<Th>Expires</Th>
|
||||
<Th className="text-right">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{tokens?.map((t) => (
|
||||
<Tr key={t.token_id}>
|
||||
<Td label="Name">
|
||||
<span className="font-medium text-text-primary">{t.name}</span>
|
||||
<div className="font-mono text-xs text-text-secondary">{t.hint}…</div>
|
||||
</Td>
|
||||
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
|
||||
<Td label="Role">
|
||||
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
|
||||
</Td>
|
||||
<Td label="Scopes">
|
||||
<ScopeChips scopes={t.scopes} />
|
||||
</Td>
|
||||
<Td label="Last used" className="text-text-secondary">
|
||||
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : <span className="text-text-secondary/70">Never used</span>}
|
||||
</Td>
|
||||
<Td label="Expires">
|
||||
<ExpiryCell token={t} capDays={capDays} />
|
||||
</Td>
|
||||
<Td label="Actions" className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
|
||||
>
|
||||
Revoke<span className="sr-only"> {t.name}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</AsyncBoundary>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={revoking !== null}
|
||||
title="Revoke token"
|
||||
confirmLabel="Revoke token"
|
||||
title="Revoke key"
|
||||
confirmLabel="Revoke key"
|
||||
loading={isRevoking}
|
||||
error={revokeError ? friendlyMessage(revokeError) : null}
|
||||
onClose={() => {
|
||||
@@ -279,20 +361,36 @@ export function ApiTokensCard() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal open={createOpen} title={result ? "Token created" : "Create API token"} onClose={closeCreate}>
|
||||
<Modal open={createOpen} title={result ? "Key created" : "New API key"} onClose={closeCreate}>
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">This is the only time this token will be shown. Store it now.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
|
||||
<div className="rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
This is the only time <span className="font-semibold">{result.record.name}</span> is shown. Copy it now — Vantage stores only a
|
||||
hash and cannot show it again.
|
||||
</div>
|
||||
<code className="block overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-text-secondary">Role</dt>
|
||||
<dd className="text-text-primary">{result.record.role}</dd>
|
||||
<dt className="text-text-secondary">Scopes</dt>
|
||||
<dd>
|
||||
<ScopeChips scopes={result.record.scopes} />
|
||||
</dd>
|
||||
<dt className="text-text-secondary">Expires</dt>
|
||||
<dd className="text-text-primary">
|
||||
{result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"}
|
||||
</dd>
|
||||
</dl>
|
||||
{/* Copy is the primary action, not Done: the value is
|
||||
unrecoverable once this closes, so the button that
|
||||
saves it should be the one under the pointer. */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={copyToken}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={closeCreate}>
|
||||
<Button type="button" variant="ghost" onClick={closeCreate}>
|
||||
Done
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={copyToken}>
|
||||
{copied ? "Copied" : "Copy key"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -303,7 +401,7 @@ export function ApiTokensCard() {
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field label="Name" hint="A short label identifying what will use this token, e.g. the CI pipeline or the script.">
|
||||
<Field label="Name" hint="A short label identifying what will use this key, e.g. the CI pipeline or the script.">
|
||||
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
|
||||
</Field>
|
||||
|
||||
@@ -317,7 +415,7 @@ export function ApiTokensCard() {
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Scopes" hint="What this token may call. Grant only what the caller actually needs.">
|
||||
<Field label="Scopes" hint="What this key may call. Grant only what the caller actually needs.">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{resources.map((r) => {
|
||||
const readScope = `${r}:read`;
|
||||
@@ -353,7 +451,7 @@ export function ApiTokensCard() {
|
||||
|
||||
<Field
|
||||
label="Expires"
|
||||
hint={capDays > 0 ? `This instance caps new tokens at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the token has no expiry."}
|
||||
hint={capDays > 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."}
|
||||
>
|
||||
<select
|
||||
value={expiryDays === null ? "never" : String(expiryDays)}
|
||||
@@ -378,12 +476,12 @@ export function ApiTokensCard() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={creating}>
|
||||
Create token
|
||||
Create key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user