Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d4307346 | ||
|
|
7e767ecb4f | ||
|
|
18495dba68 | ||
|
|
689d0e1d5b | ||
|
|
95527b3956 | ||
|
|
225b53bfa7 | ||
|
|
965419b2b8 | ||
|
|
f6988b0f1e | ||
|
|
0784ef3719 | ||
|
|
9df4a29210 | ||
|
|
4c88d6e768 | ||
|
|
a85a354e57 | ||
|
|
9b18d09d9b | ||
|
|
a398da0eac | ||
|
|
edb8406e05 | ||
|
|
bfd185adbb | ||
|
|
182752d9ab | ||
|
|
3b4c87a292 | ||
|
|
2685e9ad06 | ||
|
|
4de67e4bea | ||
|
|
524ccc6412 | ||
|
|
be4f488db3 | ||
|
|
2f60b81962 | ||
|
|
92692de94d | ||
|
|
a5f9fca59e | ||
|
|
72e5228351 | ||
|
|
33b5ec0788 | ||
|
|
1b718e7c59 | ||
|
|
6ad65a1242 | ||
|
|
a41f2b26cc | ||
|
|
71f9a9dca5 |
@@ -95,6 +95,24 @@ jobs:
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Verify the OpenAPI document is current
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
|
||||
cd server
|
||||
swag init --generalInfo cmd/main.go --dir ./,../shared \
|
||||
--output internal/api/docs --outputTypes json --v3.1
|
||||
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
|
||||
git diff --exit-code internal/api/docs/openapi.json
|
||||
|
||||
- name: Build and push server image
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
|
||||
@@ -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 `/tokens`, 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 |
|
||||
| `/tokens` | 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
|
||||
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 1.0.7
|
||||
appVersion: "1.0.7"
|
||||
version: 1.0.8
|
||||
appVersion: "1.0.8"
|
||||
|
||||
@@ -92,8 +92,8 @@ ingress:
|
||||
api:
|
||||
enabled: false
|
||||
paths:
|
||||
- /api
|
||||
- /auth
|
||||
- /api/
|
||||
- /auth/
|
||||
- /update
|
||||
- /install
|
||||
- /update.ps1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +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
|
||||
|
||||
// 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
|
||||
|
||||
// @securityDefinitions.apikey bearerAuth
|
||||
// @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.
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
|
||||
@@ -109,6 +135,10 @@ func runSchemaSetup() {
|
||||
log.Fatalf("failed to ensure auth indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAPITokenIndexes(); err != nil {
|
||||
log.Fatalf("api token indexes: %v", err)
|
||||
}
|
||||
|
||||
// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
|
||||
// index must exist before anything inserts providers, or a concurrent
|
||||
// re-run could double-insert before the index is there to refuse it.
|
||||
@@ -230,6 +260,10 @@ func serve() {
|
||||
r.Use(corsMiddleware())
|
||||
api.RegisterRoutes(r)
|
||||
|
||||
if err := api.AssertScopeMapComplete(r); err != nil {
|
||||
log.Fatalf("api scope map: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Addr: ":8080", Handler: r}
|
||||
go func() {
|
||||
log.Println("REST server listening on :8080")
|
||||
|
||||
@@ -26,10 +26,30 @@ func viewOf(c *gin.Context, p models.AuthProvider) authProviderView {
|
||||
}
|
||||
}
|
||||
|
||||
// listAuthPresets godoc
|
||||
//
|
||||
// @Summary List SSO presets
|
||||
// @Description Preset providers (Entra, Google, Okta, GitHub) that expand to a real issuer on save.
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Success 200 {array} auth.Preset
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/presets [get]
|
||||
func listAuthPresets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, auth.Presets())
|
||||
}
|
||||
|
||||
// listAuthProviders godoc
|
||||
//
|
||||
// @Summary List SSO providers
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Success 200 {array} authProviderView
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers [get]
|
||||
func listAuthProviders(c *gin.Context) {
|
||||
providers, err := services.ListAuthProviders(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -43,6 +63,18 @@ func listAuthProviders(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// createAuthProvider godoc
|
||||
//
|
||||
// @Summary Create an SSO provider
|
||||
// @Tags auth-providers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{name=string,preset=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool} true "Provider parameters"
|
||||
// @Success 201 {object} authProviderView
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers [post]
|
||||
func createAuthProvider(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
@@ -80,6 +112,22 @@ func createAuthProvider(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, viewOf(c, *p))
|
||||
}
|
||||
|
||||
// updateAuthProvider godoc
|
||||
//
|
||||
// @Summary Update an SSO provider
|
||||
// @Tags auth-providers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Param body body object{name=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool,order=int} true "Fields to update"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id} [put]
|
||||
func updateAuthProvider(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -135,9 +183,23 @@ func updateAuthProvider(c *gin.Context) {
|
||||
// document was built from the old ones.
|
||||
auth.EvictProvider(providerID)
|
||||
services.LogEvent(instanceID, "auth_provider.update", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// deleteAuthProvider godoc
|
||||
//
|
||||
// @Summary Delete an SSO provider
|
||||
// @Description Refused when the instance would be left with no way in (no local login and no other enabled provider).
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id} [delete]
|
||||
func deleteAuthProvider(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
providerID := c.Param("id")
|
||||
@@ -161,7 +223,7 @@ func deleteAuthProvider(c *gin.Context) {
|
||||
}
|
||||
auth.EvictProvider(providerID)
|
||||
services.LogEvent(instanceID, "auth_provider.delete", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// guardProviderChange asks whether the instance would still have a way in.
|
||||
@@ -178,6 +240,18 @@ func guardProviderChange(instanceID string, existing *models.AuthProvider, enabl
|
||||
return services.CheckLockout(services.IsLocalLoginEnabled(instanceID), n-1)
|
||||
}
|
||||
|
||||
// ackAuthProviderNotice godoc
|
||||
//
|
||||
// @Summary Acknowledge a provider migration notice
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} AcknowledgedResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id}/ack-notice [post]
|
||||
func ackAuthProviderNotice(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
providerID := c.Param("id")
|
||||
@@ -191,10 +265,21 @@ func ackAuthProviderNotice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "auth_provider.ack_notice", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"acknowledged": true})
|
||||
c.JSON(http.StatusOK, AcknowledgedResponse{Acknowledged: true})
|
||||
}
|
||||
|
||||
// testAuthProvider proves the configuration is reachable. It signs nobody in.
|
||||
// testAuthProvider godoc
|
||||
//
|
||||
// @Summary Test an SSO provider's reachability
|
||||
// @Description Proves the configuration is reachable. It signs nobody in.
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} TestProviderResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id}/test [post]
|
||||
func testAuthProvider(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
p, err := services.GetAuthProvider(instanceID, c.Param("id"))
|
||||
@@ -206,15 +291,15 @@ func testAuthProvider(c *gin.Context) {
|
||||
// GitHub has no discovery document. The only meaningful check without
|
||||
// a user token is that credentials are present.
|
||||
if p.ClientID == "" || p.ClientSecretEnc == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "message": "client ID and secret are required"})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: "client ID and secret are required"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "credentials are configured"})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "credentials are configured"})
|
||||
return
|
||||
}
|
||||
if _, err := oidc.NewProvider(c.Request.Context(), p.Issuer); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "message": err.Error()})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "discovery document fetched"})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "discovery document fetched"})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ func registerChannelRoutes(g *gin.RouterGroup) {
|
||||
g.POST("/channels/:id/test", testChannel)
|
||||
}
|
||||
|
||||
// listChannels godoc
|
||||
//
|
||||
// @Summary List notification channels
|
||||
// @Tags channels
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.NotificationChannel
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels [get]
|
||||
func listChannels(c *gin.Context) {
|
||||
channels, err := services.ListChannels(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -27,6 +37,20 @@ func listChannels(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, channels)
|
||||
}
|
||||
|
||||
// createChannel godoc
|
||||
//
|
||||
// @Summary Create a notification channel
|
||||
// @Tags channels
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.NotificationChannel true "Channel to create"
|
||||
// @Success 201 {object} models.NotificationChannel
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels [post]
|
||||
func createChannel(c *gin.Context) {
|
||||
var ch models.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
@@ -48,6 +72,20 @@ func createChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// updateChannel godoc
|
||||
//
|
||||
// @Summary Update a notification channel
|
||||
// @Tags channels
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Param body body object{name=string,type=string,config=map[string]string,enabled=bool} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id} [put]
|
||||
func updateChannel(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -83,6 +121,16 @@ func updateChannel(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteChannel godoc
|
||||
//
|
||||
// @Summary Delete a notification channel
|
||||
// @Tags channels
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Success 204
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id} [delete]
|
||||
func deleteChannel(c *gin.Context) {
|
||||
if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -91,10 +139,21 @@ func deleteChannel(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// testChannel godoc
|
||||
//
|
||||
// @Summary Send a test notification
|
||||
// @Tags channels
|
||||
// @Produce json
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id}/test [post]
|
||||
func testChannel(c *gin.Context) {
|
||||
if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "sent"})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,22 @@ import (
|
||||
"github.com/wwt/guac"
|
||||
)
|
||||
|
||||
// consoleConnect godoc
|
||||
//
|
||||
// @Summary Open a browser console session
|
||||
// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise.
|
||||
// @Tags console
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{server_id=string,protocol=string,key_id=string,rdp_username=string,rdp_password=string,ssh_username=string} true "Session parameters"
|
||||
// @Success 200 {object} ConsoleConnectResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /console/connect [post]
|
||||
func consoleConnect(c *gin.Context) {
|
||||
var body struct {
|
||||
ServerID string `json:"server_id" binding:"required"`
|
||||
@@ -73,10 +89,10 @@ func consoleConnect(c *gin.Context) {
|
||||
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
"console session opened ("+body.Protocol+", agent-relayed)")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sess.SessionID,
|
||||
"token": token,
|
||||
"ws_path": "/api/console/tunnel",
|
||||
c.JSON(http.StatusOK, ConsoleConnectResponse{
|
||||
SessionID: sess.SessionID,
|
||||
Token: token,
|
||||
WSPath: "/api/console/tunnel",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,6 +116,21 @@ func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
//
|
||||
// Lines are prefixed with the session ID so one attempt can be followed across
|
||||
// pods, and the pod's own hostname so it is obvious which one served it.
|
||||
// consoleTunnel godoc
|
||||
//
|
||||
// @Summary Console websocket tunnel
|
||||
// @Description Upgrades the browser's connection to a websocket and joins it to guacd, relayed through the agent. Consumes the one-time session token from /console/connect.
|
||||
// @Tags console
|
||||
// @Param token query string true "One-time session token"
|
||||
// @Success 101
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /console/tunnel [get]
|
||||
func consoleTunnel(c *gin.Context) {
|
||||
host, _ := os.Hostname()
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package docs holds the generated OpenAPI document and the vendored Scalar
|
||||
// bundle that renders it.
|
||||
//
|
||||
// openapi.json is generated by `swag init` and committed rather than built into
|
||||
// the image: server/Dockerfile produces a scratch runtime from a Go build
|
||||
// stage, and adding codegen there means putting the toolchain in the image.
|
||||
// server-deploy.yml regenerates and diffs it, so an annotation edited without
|
||||
// regenerating fails the build.
|
||||
//
|
||||
// scalar.standalone.js is vendored from
|
||||
// https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.js
|
||||
// and refreshed by hand. Fetched at build time it would break an air-gapped
|
||||
// install; fetched at page load it would break an air-gapped install more
|
||||
// visibly.
|
||||
package docs
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed openapi.json
|
||||
var OpenAPI []byte
|
||||
|
||||
//go:embed scalar.standalone.js
|
||||
var ScalarJS []byte
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+339
-39
@@ -14,10 +14,17 @@ import (
|
||||
)
|
||||
|
||||
func actorFromCtx(c *gin.Context) string {
|
||||
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
|
||||
return sess.Email
|
||||
sess := auth.GetSessionFromContext(c)
|
||||
if sess == nil || sess.Email == "" {
|
||||
return "admin"
|
||||
}
|
||||
return "admin"
|
||||
// The actor stays the human, because a token acts on their behalf and the
|
||||
// log has to name somebody. The credential is appended so a person clicking
|
||||
// and their CI job are told apart.
|
||||
if sess.TokenID != "" {
|
||||
return fmt.Sprintf("%s (via token:%s)", sess.Email, sess.TokenName)
|
||||
}
|
||||
return sess.Email
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.Engine) {
|
||||
@@ -42,6 +49,11 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
// Scope enforcement sits between authentication and the licence gate, and
|
||||
// no-ops for cookie sessions. It is mounted here rather than per route so
|
||||
// a route added later is covered by where it lives, not by memory.
|
||||
apiGroup.Use(RequireScopes())
|
||||
apiGroup.Use(RateLimitTokens())
|
||||
// Deny by default: every non-GET route under /api is gated unless it is on
|
||||
// the exemption list in licence.go. A route added later is covered because
|
||||
// of where it is mounted, not because someone remembered.
|
||||
@@ -68,6 +80,15 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
apiGroup.GET("/audit", listAuditEvents)
|
||||
|
||||
apiGroup.GET("/tokens", listTokens)
|
||||
apiGroup.GET("/tokens/scopes", listTokenScopes)
|
||||
apiGroup.POST("/tokens", createToken)
|
||||
apiGroup.DELETE("/tokens/:id", revokeToken)
|
||||
|
||||
apiGroup.GET("/openapi.json", getOpenAPI)
|
||||
apiGroup.GET("/docs", getAPIDocs)
|
||||
apiGroup.GET("/docs/scalar.js", getScalarJS)
|
||||
|
||||
settings := apiGroup.Group("/settings")
|
||||
settings.Use(auth.RequireRole("owner", "admin"))
|
||||
{
|
||||
@@ -144,6 +165,19 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
}
|
||||
}
|
||||
|
||||
// listServers godoc
|
||||
//
|
||||
// @Summary List servers
|
||||
// @Description Returns every server in the instance, optionally filtered by tag (repeatable, key:value).
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param tag query []string false "Filter by tag as key:value, repeatable"
|
||||
// @Success 200 {array} models.Server
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers [get]
|
||||
func listServers(c *gin.Context) {
|
||||
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
|
||||
if err != nil {
|
||||
@@ -158,6 +192,17 @@ func listServers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, servers)
|
||||
}
|
||||
|
||||
// listKnownTags godoc
|
||||
//
|
||||
// @Summary List known tags
|
||||
// @Description Returns every tag key currently used by any server, with the values seen for each.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string][]string
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/tags [get]
|
||||
func listKnownTags(c *gin.Context) {
|
||||
tags, err := services.KnownTags(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -167,6 +212,22 @@ func listKnownTags(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// putServerTags godoc
|
||||
//
|
||||
// @Summary Replace a server's tags
|
||||
// @Description Replaces the whole tag map for a server. Last write wins.
|
||||
// @Tags servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param body body object{tags=map[string]string} true "New tag map"
|
||||
// @Success 200 {object} TagsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/tags [put]
|
||||
func putServerTags(c *gin.Context) {
|
||||
var body struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
@@ -196,9 +257,21 @@ func putServerTags(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "server.tags_updated", actorFromCtx(c), serverID, "",
|
||||
fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags))
|
||||
c.JSON(http.StatusOK, gin.H{"tags": body.Tags})
|
||||
c.JSON(http.StatusOK, TagsResponse{Tags: body.Tags})
|
||||
}
|
||||
|
||||
// createServer godoc
|
||||
//
|
||||
// @Summary Add a server
|
||||
// @Description Creates a server record and a single-use pre-registration token (TTL 1 hour).
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 201 {object} CreateServerResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers [post]
|
||||
func createServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -208,13 +281,26 @@ func createServer(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"server": s,
|
||||
"token": token,
|
||||
"server_id": s.ServerID,
|
||||
c.JSON(http.StatusCreated, CreateServerResponse{
|
||||
Server: s,
|
||||
Token: token,
|
||||
ServerID: s.ServerID,
|
||||
})
|
||||
}
|
||||
|
||||
// newServer godoc
|
||||
//
|
||||
// @Summary Add a server (install page)
|
||||
// @Description Identical to POST /servers; also reachable by GET for the install page. Mints a new pre-registration token.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} NewServerResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/new [get]
|
||||
// @Router /servers/new [post]
|
||||
func newServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -238,14 +324,26 @@ func newServer(c *gin.Context) {
|
||||
host, s.ServerID, token,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"server_id": s.ServerID,
|
||||
"pre_reg_token": token,
|
||||
"install_command": installCmd,
|
||||
"install_command_ps": installCmdPS,
|
||||
c.JSON(http.StatusOK, NewServerResponse{
|
||||
ServerID: s.ServerID,
|
||||
PreRegToken: token,
|
||||
InstallCommand: installCmd,
|
||||
InstallCommandPS: installCmdPS,
|
||||
})
|
||||
}
|
||||
|
||||
// getServer godoc
|
||||
//
|
||||
// @Summary Get a server
|
||||
// @Description Returns a server together with its resolved key assignments.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} ServerDetailResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id} [get]
|
||||
func getServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -256,16 +354,23 @@ func getServer(c *gin.Context) {
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
|
||||
|
||||
type serverResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
}
|
||||
c.JSON(http.StatusOK, serverResponse{
|
||||
c.JSON(http.StatusOK, ServerDetailResponse{
|
||||
Server: s,
|
||||
Keys: assignments,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteServer godoc
|
||||
//
|
||||
// @Summary Delete a server
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id} [delete]
|
||||
func deleteServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, _ := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -278,9 +383,24 @@ func deleteServer(c *gin.Context) {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// generateKey godoc
|
||||
//
|
||||
// @Summary Generate a key on a server
|
||||
// @Description Dispatches an agent command that generates a keypair on the target server and reports it back.
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param body body object{label=string,key_type=string,key_size=int,passphrase=string,comment=string} false "Key generation parameters"
|
||||
// @Success 202 {object} GenerateKeyResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/generate-key [post]
|
||||
func generateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -315,13 +435,23 @@ func generateKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "key generation command sent to agent",
|
||||
"command_id": cmdID,
|
||||
"server_id": s.ServerID,
|
||||
c.JSON(http.StatusAccepted, GenerateKeyResponse{
|
||||
Message: "key generation command sent to agent",
|
||||
CommandID: cmdID,
|
||||
ServerID: s.ServerID,
|
||||
})
|
||||
}
|
||||
|
||||
// listKeys godoc
|
||||
//
|
||||
// @Summary List keys
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Key
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys [get]
|
||||
func listKeys(c *gin.Context) {
|
||||
keys, err := services.ListKeys(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -331,6 +461,19 @@ func listKeys(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// createKey godoc
|
||||
//
|
||||
// @Summary Upload a key
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{label=string,public_key=string,private_key=string,passphrase=string} true "Key material"
|
||||
// @Success 201 {object} models.Key
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys [post]
|
||||
func createKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
@@ -352,6 +495,18 @@ func createKey(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, key)
|
||||
}
|
||||
|
||||
// getPrivateKey godoc
|
||||
//
|
||||
// @Summary Get a key's private material
|
||||
// @Description Returns the decrypted private key. Reading is a keys:read action even though the material is sensitive.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} PrivateKeyResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/private-key [get]
|
||||
func getPrivateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
|
||||
@@ -359,9 +514,21 @@ func getPrivateKey(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
|
||||
c.JSON(http.StatusOK, PrivateKeyResponse{PrivateKey: plaintext})
|
||||
}
|
||||
|
||||
// getKey godoc
|
||||
//
|
||||
// @Summary Get a key
|
||||
// @Description Returns a key together with the servers it is assigned to.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} KeyDetailResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id} [get]
|
||||
func getKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key, err := services.GetKey(auth.InstanceID(c), id)
|
||||
@@ -372,16 +539,23 @@ func getKey(c *gin.Context) {
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
|
||||
|
||||
type keyResponse struct {
|
||||
*models.Key
|
||||
Assignments any `json:"assignments"`
|
||||
}
|
||||
c.JSON(http.StatusOK, keyResponse{
|
||||
c.JSON(http.StatusOK, KeyDetailResponse{
|
||||
Key: key,
|
||||
Assignments: assignments,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteKey godoc
|
||||
//
|
||||
// @Summary Delete a key
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id} [delete]
|
||||
func deleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
k, _ := services.GetKey(auth.InstanceID(c), id)
|
||||
@@ -394,9 +568,23 @@ func deleteKey(c *gin.Context) {
|
||||
label = k.Label
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// assignKey godoc
|
||||
//
|
||||
// @Summary Assign a key to a server
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Param body body object{server_id=string} true "Target server"
|
||||
// @Success 201 {object} models.Assignment
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/assign [post]
|
||||
func assignKey(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
var body struct {
|
||||
@@ -416,6 +604,19 @@ func assignKey(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, a)
|
||||
}
|
||||
|
||||
// revokeAssignment godoc
|
||||
//
|
||||
// @Summary Revoke a key assignment
|
||||
// @Description Soft revocation: sets revoked_at rather than deleting, preserving audit history.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {object} RevokedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/assign/{serverId} [delete]
|
||||
func revokeAssignment(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
serverID := c.Param("serverId")
|
||||
@@ -425,18 +626,42 @@ func revokeAssignment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
|
||||
}
|
||||
|
||||
// getLatestAgentVersion godoc
|
||||
//
|
||||
// @Summary Get the latest agent version
|
||||
// @Description Reads the latest agent/v* tag from the Gitea release API.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} AgentVersionResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /agent/latest-version [get]
|
||||
func getLatestAgentVersion(c *gin.Context) {
|
||||
version, err := services.GetLatestAgentVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"version": version})
|
||||
c.JSON(http.StatusOK, AgentVersionResponse{Version: version})
|
||||
}
|
||||
|
||||
// updateAgent godoc
|
||||
//
|
||||
// @Summary Update a server's agent
|
||||
// @Description Dispatches UpdateAgentCmd to the agent, telling it to download and replace itself.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} UpdateAgentResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/update-agent [post]
|
||||
func updateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -451,12 +676,25 @@ func updateAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "update command sent to agent",
|
||||
"version": version,
|
||||
c.JSON(http.StatusAccepted, UpdateAgentResponse{
|
||||
Message: "update command sent to agent",
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
|
||||
// applyUpdates godoc
|
||||
//
|
||||
// @Summary Apply pending OS updates on a server
|
||||
// @Description Dispatches ApplyUpdatesCmd. Exempt from the licence gate: security patching is never paywalled.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} MessageResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/apply-updates [post]
|
||||
func applyUpdates(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -470,9 +708,16 @@ func applyUpdates(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
|
||||
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -526,6 +771,21 @@ echo "vantage-agent updated to ${VERSION} and restarted."
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
||||
// listAuditEvents godoc
|
||||
//
|
||||
// @Summary List audit events
|
||||
// @Description Every mutating API path writes an audit event. Paginated with a total, since a short page is not proof of the end of the log.
|
||||
// @Tags audit
|
||||
// @Produce json
|
||||
// @Param q query string false "Free-text search"
|
||||
// @Param category query string false "Filter by category"
|
||||
// @Param limit query int false "Max events to return"
|
||||
// @Param skip query int false "Events to skip"
|
||||
// @Success 200 {object} AuditEventsResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /audit [get]
|
||||
func listAuditEvents(c *gin.Context) {
|
||||
f := services.AuditFilter{
|
||||
Search: c.Query("q"),
|
||||
@@ -549,9 +809,19 @@ func listAuditEvents(c *gin.Context) {
|
||||
}
|
||||
// An object rather than a bare array: a page is meaningless without the
|
||||
// total it came from, and a short page is not proof of the end of the log.
|
||||
c.JSON(http.StatusOK, gin.H{"events": events, "total": total})
|
||||
c.JSON(http.StatusOK, AuditEventsResponse{Events: events, Total: total})
|
||||
}
|
||||
|
||||
// getSettings godoc
|
||||
//
|
||||
// @Summary Get instance settings
|
||||
// @Tags settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Settings
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings [get]
|
||||
func getSettings(c *gin.Context) {
|
||||
s, err := services.GetSettings(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -561,17 +831,37 @@ func getSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// saveSettings godoc
|
||||
//
|
||||
// @Summary Save instance settings
|
||||
// @Description Owner and admin only. Refuses a change that would leave neither local login nor an enabled auth provider.
|
||||
// @Tags settings
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{alerts=models.AlertSettings,workflow_log_retention_days=int,local_login_enabled=bool,api_token_max_days=int} true "Settings to save"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings [put]
|
||||
func saveSettings(c *gin.Context) {
|
||||
var body struct {
|
||||
Alerts models.AlertSettings `json:"alerts"`
|
||||
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
|
||||
LocalLoginEnabled *bool `json:"local_login_enabled"`
|
||||
APITokenMaxDays *int `json:"api_token_max_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil {
|
||||
if body.APITokenMaxDays != nil && *body.APITokenMaxDays < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil {
|
||||
if errors.Is(err, services.ErrLockout) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
|
||||
return
|
||||
@@ -580,9 +870,19 @@ func saveSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
if body.APITokenMaxDays != nil {
|
||||
services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays))
|
||||
}
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -10,6 +10,16 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// listInstanceUsers godoc
|
||||
//
|
||||
// @Summary List instance members
|
||||
// @Tags instance-users
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.User
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [get]
|
||||
func listInstanceUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -23,6 +33,20 @@ func actorMayGrantOwner(c *gin.Context) bool {
|
||||
return auth.Role(c) == models.RoleOwner
|
||||
}
|
||||
|
||||
// createInstanceUser godoc
|
||||
//
|
||||
// @Summary Create an instance member
|
||||
// @Description Only an owner can create another owner.
|
||||
// @Tags instance-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{email=string,password=string,role=string} true "New member"
|
||||
// @Success 201 {object} models.User
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [post]
|
||||
func createInstanceUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
@@ -52,6 +76,24 @@ func createInstanceUser(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
|
||||
// updateInstanceUserRole godoc
|
||||
//
|
||||
// @Summary Change an instance member's role
|
||||
// @Description A caller cannot change their own role. Only an owner can change owner roles.
|
||||
// @Tags instance-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Param body body object{role=string} true "New role"
|
||||
// @Success 200 {object} OKResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users/{id}/role [put]
|
||||
func updateInstanceUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
@@ -84,9 +126,24 @@ func updateInstanceUserRole(c *gin.Context) {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||||
}
|
||||
|
||||
// deleteInstanceUser godoc
|
||||
//
|
||||
// @Summary Remove an instance member
|
||||
// @Description A caller cannot remove their own account. Only an owner can remove another owner.
|
||||
// @Tags instance-users
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users/{id} [delete]
|
||||
func deleteInstanceUser(c *gin.Context) {
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
@@ -107,7 +164,7 @@ func deleteInstanceUser(c *gin.Context) {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
func orgUserErrStatus(err error) int {
|
||||
|
||||
@@ -116,6 +116,15 @@ type licenceUsageResponse struct {
|
||||
Channels int `json:"channels"`
|
||||
}
|
||||
|
||||
// getLicence godoc
|
||||
//
|
||||
// @Summary Get this instance's licence state
|
||||
// @Tags licence
|
||||
// @Produce json
|
||||
// @Success 200 {object} licenceResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /license [get]
|
||||
func getLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
st := services.GetLicenseState(instanceID)
|
||||
@@ -169,6 +178,21 @@ func licencePostAllowed(instanceID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// postLicence godoc
|
||||
//
|
||||
// @Summary Set this instance's licence
|
||||
// @Description Self-hosted only; a cloud instance's licence is injected by admin and this endpoint answers 409 cloud_managed. Exempt from the licence gate, since pasting a valid licence is the way out of degraded mode. Rate limited to 10 attempts per instance per hour.
|
||||
// @Tags licence
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{blob=string} true "Licence key blob"
|
||||
// @Success 200 {object} LicencePostResponse
|
||||
// @Failure 400 {object} LicenceErrorResponse
|
||||
// @Failure 409 {object} LicenceErrorResponse
|
||||
// @Failure 429 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /license [post]
|
||||
func postLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
|
||||
@@ -210,7 +234,7 @@ func postLicence(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
|
||||
"licence accepted (tier "+st.Tier+")")
|
||||
c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
|
||||
c.JSON(http.StatusOK, LicencePostResponse{State: st.Status, Tier: st.Tier, ExpiresAt: st.ExpiresAt})
|
||||
}
|
||||
|
||||
// licenceRejectionMessage turns a machine reason into something a person can act
|
||||
@@ -238,11 +262,11 @@ func limitStatus(c *gin.Context, err error) bool {
|
||||
if !errors.As(err, &le) {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "limit_exceeded",
|
||||
"limit": le.Limit,
|
||||
"current": le.Current,
|
||||
"max": le.Max,
|
||||
c.JSON(http.StatusForbidden, LimitExceededResponse{
|
||||
Error: "limit_exceeded",
|
||||
Limit: le.Limit,
|
||||
Current: le.Current,
|
||||
Max: le.Max,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
}
|
||||
|
||||
// listMonitors godoc
|
||||
//
|
||||
// @Summary List monitors
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Monitor
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors [get]
|
||||
func listMonitors(c *gin.Context) {
|
||||
monitors, err := services.ListMonitors(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -30,6 +40,20 @@ func listMonitors(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, monitors)
|
||||
}
|
||||
|
||||
// createMonitor godoc
|
||||
//
|
||||
// @Summary Create a monitor
|
||||
// @Tags monitors
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.Monitor true "Monitor to create"
|
||||
// @Success 201 {object} models.Monitor
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors [post]
|
||||
func createMonitor(c *gin.Context) {
|
||||
var m models.Monitor
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
@@ -55,6 +79,18 @@ func createMonitor(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// getMonitor godoc
|
||||
//
|
||||
// @Summary Get a monitor
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {object} models.Monitor
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [get]
|
||||
func getMonitor(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -68,6 +104,20 @@ func getMonitor(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// updateMonitor godoc
|
||||
//
|
||||
// @Summary Update a monitor
|
||||
// @Tags monitors
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param body body object{name=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [put]
|
||||
func updateMonitor(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -119,6 +169,16 @@ func updateMonitor(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteMonitor godoc
|
||||
//
|
||||
// @Summary Delete a monitor
|
||||
// @Tags monitors
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 204
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [delete]
|
||||
func deleteMonitor(c *gin.Context) {
|
||||
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -127,6 +187,18 @@ func deleteMonitor(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getMonitorIncidents godoc
|
||||
//
|
||||
// @Summary List a monitor's incidents
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {array} models.Incident
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/incidents [get]
|
||||
func getMonitorIncidents(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -145,6 +217,19 @@ func getMonitorIncidents(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
// getMonitorUptime godoc
|
||||
//
|
||||
// @Summary Get a monitor's uptime rollups
|
||||
// @Description Hourly rollups for the last 30 days.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {array} models.Rollup
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/uptime [get]
|
||||
func getMonitorUptime(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api/docs"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// scalarPage renders the reference against this instance's own spec, so "Try
|
||||
// it" acts on the reader's API with the reader's session.
|
||||
const scalarPage = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Vantage API</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/api/docs/scalar.js"></script>
|
||||
<script>
|
||||
Scalar.createApiReference('#app', {
|
||||
url: '/api/openapi.json',
|
||||
theme: 'deepSpace',
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// getOpenAPI godoc
|
||||
//
|
||||
// @Summary Get the OpenAPI document
|
||||
// @Description Generated from swaggo annotations at build time and committed; served verbatim.
|
||||
// @Tags docs
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /openapi.json [get]
|
||||
func getOpenAPI(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", docs.OpenAPI)
|
||||
}
|
||||
|
||||
// getScalarJS godoc
|
||||
//
|
||||
// @Summary Get the vendored Scalar bundle
|
||||
// @Description Served locally rather than from a CDN so the reference page works on an air-gapped install.
|
||||
// @Tags docs
|
||||
// @Produce application/javascript
|
||||
// @Success 200 {string} string "javascript bundle"
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /docs/scalar.js [get]
|
||||
func getScalarJS(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/javascript; charset=utf-8", docs.ScalarJS)
|
||||
}
|
||||
|
||||
// getAPIDocs godoc
|
||||
//
|
||||
// @Summary API reference page
|
||||
// @Description Renders the Scalar reference against this instance's own OpenAPI document.
|
||||
// @Tags docs
|
||||
// @Produce html
|
||||
// @Success 200 {string} string "HTML page"
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /docs [get]
|
||||
func getAPIDocs(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarPage))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// tokenRateLimit is per token per minute. It is not the general API
|
||||
// rate-limiting project: it is only enough that a runaway script cannot take an
|
||||
// instance down, and cookie sessions are deliberately untouched.
|
||||
const tokenRateLimit = 600
|
||||
|
||||
// RateLimitTokens counts requests per token in a one-minute fixed window.
|
||||
//
|
||||
// A fixed window rather than a sliding one because the cost of a burst at a
|
||||
// boundary is a script running twice as fast for one second, and a sliding
|
||||
// window is a sorted set per token for that.
|
||||
func RateLimitTokens() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !auth.IsToken(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
rdb := auth.Redis()
|
||||
if rdb == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
window := time.Now().UTC().Unix() / 60
|
||||
key := "vantage:tokenrate:" + auth.TokenID(c) + ":" + strconv.FormatInt(window, 10)
|
||||
|
||||
count, err := rdb.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
// Redis is already required for sessions, so it being down is a
|
||||
// larger problem than this. Do not turn it into a second outage.
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
rdb.Expire(c.Request.Context(), key, 2*time.Minute)
|
||||
}
|
||||
if count > tokenRateLimit {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate limit exceeded for this API token",
|
||||
"code": "rate_limited",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// routeScopes maps a registered gin route — "<METHOD> <full path pattern>" — to
|
||||
// the scope an API token must hold to reach it.
|
||||
//
|
||||
// It is keyed on the route pattern rather than declared per route with a
|
||||
// decorator, because a route registered without a decorator would be
|
||||
// unguarded. AssertScopeMapComplete refuses to boot if any /api route is
|
||||
// missing here, so the failure lands at deploy rather than as a surprise 403
|
||||
// in production.
|
||||
//
|
||||
// GET is read, everything else is write. The exceptions are written out rather
|
||||
// than derived, because two of them are not obvious: reading a private key is
|
||||
// still reading a key, and reading a container's logs is a write-level action
|
||||
// because container output is arbitrary and cannot be masked.
|
||||
var routeScopes = map[string]string{
|
||||
"GET /api/license": "settings:read",
|
||||
"POST /api/license": "settings:write",
|
||||
|
||||
"GET /api/servers": "servers:read",
|
||||
"GET /api/servers/tags": "servers:read",
|
||||
"POST /api/servers": "servers:write",
|
||||
"GET /api/servers/new": "servers:write",
|
||||
"POST /api/servers/new": "servers:write",
|
||||
"GET /api/servers/:id": "servers:read",
|
||||
"DELETE /api/servers/:id": "servers:write",
|
||||
"POST /api/servers/:id/generate-key": "keys:write",
|
||||
"POST /api/servers/:id/update-agent": "servers:write",
|
||||
"POST /api/servers/:id/apply-updates": "servers:write",
|
||||
"PUT /api/servers/:id/tags": "servers:write",
|
||||
|
||||
"GET /api/agent/latest-version": "servers:read",
|
||||
"GET /api/audit": "settings:read",
|
||||
|
||||
"GET /api/settings": "settings:read",
|
||||
"PUT /api/settings": "settings:write",
|
||||
"POST /api/settings/secrets-token": "settings:write",
|
||||
|
||||
"GET /api/secrets": "secrets:read",
|
||||
"POST /api/secrets": "secrets:write",
|
||||
"GET /api/secrets/:group": "secrets:read",
|
||||
"PUT /api/secrets/:group": "secrets:write",
|
||||
"POST /api/secrets/:group/reveal": "secrets:read",
|
||||
"DELETE /api/secrets/:group": "secrets:write",
|
||||
"DELETE /api/secrets/:group/:key": "secrets:write",
|
||||
|
||||
"GET /api/keys": "keys:read",
|
||||
"POST /api/keys": "keys:write",
|
||||
"GET /api/keys/:id": "keys:read",
|
||||
"GET /api/keys/:id/private-key": "keys:read",
|
||||
"DELETE /api/keys/:id": "keys:write",
|
||||
"POST /api/keys/:id/assign": "keys:write",
|
||||
"DELETE /api/keys/:id/assign/:serverId": "keys:write",
|
||||
|
||||
"POST /api/console/connect": "servers:write",
|
||||
"GET /api/console/tunnel": "servers:write",
|
||||
|
||||
// Workflow, step and run routes, registered by registerWorkflowRoutes.
|
||||
"GET /api/steps": "workflows:read",
|
||||
"POST /api/steps": "workflows:write",
|
||||
"PUT /api/steps/:id": "workflows:write",
|
||||
"DELETE /api/steps/:id": "workflows:write",
|
||||
"GET /api/steps/:id/export": "workflows:read",
|
||||
"POST /api/steps/import": "workflows:write",
|
||||
"POST /api/steps/seed-defaults": "workflows:write",
|
||||
"GET /api/steps/usage": "workflows:read",
|
||||
"POST /api/steps/parse": "workflows:write",
|
||||
|
||||
"GET /api/workflows": "workflows:read",
|
||||
"POST /api/workflows": "workflows:write",
|
||||
"GET /api/workflows/:id": "workflows:read",
|
||||
"PUT /api/workflows/:id": "workflows:write",
|
||||
"DELETE /api/workflows/:id": "workflows:write",
|
||||
"POST /api/workflows/:id/run": "workflows:write",
|
||||
"GET /api/workflows/:id/runs": "workflows:read",
|
||||
"PUT /api/workflows/:id/schedule": "workflows:write",
|
||||
"GET /api/workflows/:id/schedule/preview": "workflows:read",
|
||||
|
||||
"GET /api/runs/:runId": "workflows:read",
|
||||
"POST /api/runs/:runId/cancel": "workflows:write",
|
||||
"GET /api/runs/:runId/servers/:serverId/logs": "workflows:read",
|
||||
"GET /api/runs/:runId/servers/:serverId/logs/stream": "workflows:read",
|
||||
|
||||
// Monitor and incident routes, registered by registerMonitorRoutes.
|
||||
"GET /api/monitors": "monitors:read",
|
||||
"POST /api/monitors": "monitors:write",
|
||||
"GET /api/monitors/:id": "monitors:read",
|
||||
"PUT /api/monitors/:id": "monitors:write",
|
||||
"DELETE /api/monitors/:id": "monitors:write",
|
||||
"GET /api/monitors/:id/incidents": "monitors:read",
|
||||
"GET /api/monitors/:id/uptime": "monitors:read",
|
||||
|
||||
// Channel routes, registered by registerChannelRoutes. Channels exist to
|
||||
// serve alerts, so they share the monitors scope rather than getting their
|
||||
// own resource.
|
||||
"GET /api/channels": "monitors:read",
|
||||
"POST /api/channels": "monitors:write",
|
||||
"PUT /api/channels/:id": "monitors:write",
|
||||
"DELETE /api/channels/:id": "monitors:write",
|
||||
"POST /api/channels/:id/test": "monitors:write",
|
||||
|
||||
// Instance user management and SSO configuration live on the /settings
|
||||
// page in web/ (the Access group), so both share the settings scope.
|
||||
"GET /api/instance/users": "settings:read",
|
||||
"POST /api/instance/users": "settings:write",
|
||||
"PUT /api/instance/users/:id/role": "settings:write",
|
||||
"DELETE /api/instance/users/:id": "settings:write",
|
||||
|
||||
"GET /api/auth/providers": "settings:read",
|
||||
"POST /api/auth/providers": "settings:write",
|
||||
"PUT /api/auth/providers/:id": "settings:write",
|
||||
"DELETE /api/auth/providers/:id": "settings:write",
|
||||
"POST /api/auth/providers/:id/test": "settings:write",
|
||||
"POST /api/auth/providers/:id/ack-notice": "settings:write",
|
||||
"GET /api/auth/presets": "settings:read",
|
||||
|
||||
"GET /api/vulnerabilities": "vulns:read",
|
||||
"GET /api/vulnerabilities/summary": "vulns:read",
|
||||
"POST /api/vulnerabilities/rescan": "vulns:write",
|
||||
"POST /api/vulnerabilities/:id/accept": "vulns:write",
|
||||
"DELETE /api/vulnerabilities/:id/accept": "vulns:write",
|
||||
"GET /api/servers/:id/vulnerabilities": "vulns:read",
|
||||
"GET /api/servers/:id/packages": "vulns:read",
|
||||
"GET /api/packages/search": "vulns:read",
|
||||
"GET /api/vuln-rules": "vulns:read",
|
||||
"POST /api/vuln-rules": "vulns:write",
|
||||
"PUT /api/vuln-rules/:id": "vulns:write",
|
||||
"DELETE /api/vuln-rules/:id": "vulns:write",
|
||||
|
||||
"GET /api/workloads": "workloads:read",
|
||||
"GET /api/servers/:id/workloads": "workloads:read",
|
||||
"POST /api/servers/:id/workloads/refresh": "workloads:read",
|
||||
"POST /api/servers/:id/workloads/:wid/action": "workloads:write",
|
||||
"GET /api/servers/:id/workloads/:wid/logs": "workloads:write",
|
||||
|
||||
"GET /api/tokens": "settings:read",
|
||||
"GET /api/tokens/scopes": "settings:read",
|
||||
"POST /api/tokens": "settings:write",
|
||||
"DELETE /api/tokens/:id": "settings:write",
|
||||
|
||||
// The generated OpenAPI document and its Scalar reference page. Read-only,
|
||||
// so they share the settings:read scope with the rest of the docs a token
|
||||
// can already see about its own instance.
|
||||
"GET /api/openapi.json": "settings:read",
|
||||
"GET /api/docs": "settings:read",
|
||||
"GET /api/docs/scalar.js": "settings:read",
|
||||
}
|
||||
|
||||
// RequireScopes enforces routeScopes for token-authenticated requests and does
|
||||
// nothing at all for cookie sessions, whose authority is their role.
|
||||
func RequireScopes() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !auth.IsToken(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
key := c.Request.Method + " " + c.FullPath()
|
||||
required, ok := routeScopes[key]
|
||||
if !ok {
|
||||
// Fail closed. An unmapped route reached by a token is a route
|
||||
// nobody decided the authority for.
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "this endpoint is not available to API tokens",
|
||||
"code": "scope_unmapped",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !services.ScopeSatisfied(auth.Scopes(c), required) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": fmt.Sprintf("token is missing the %q scope", required),
|
||||
"code": "scope_missing",
|
||||
"required_scope": required,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AssertScopeMapComplete fails boot when a registered /api route has no scope.
|
||||
//
|
||||
// Without it, adding a route silently makes it unreachable by every token, and
|
||||
// the report arrives as a customer asking why their script gets 403.
|
||||
func AssertScopeMapComplete(r *gin.Engine) error {
|
||||
var missing []string
|
||||
for _, route := range r.Routes() {
|
||||
if !strings.HasPrefix(route.Path, "/api/") {
|
||||
continue
|
||||
}
|
||||
// The ESO endpoint keeps its own bearer scheme and is deliberately
|
||||
// outside the token vocabulary.
|
||||
if route.Path == "/api/secrets/:group/values" {
|
||||
continue
|
||||
}
|
||||
if _, ok := routeScopes[route.Method+" "+route.Path]; !ok {
|
||||
missing = append(missing, route.Method+" "+route.Path)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return fmt.Errorf("routes missing from the API token scope map: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -37,6 +37,19 @@ func secretsReadAuth() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// esoGetGroup godoc
|
||||
//
|
||||
// @Summary Read a secret group's values (ESO)
|
||||
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security esoAuth
|
||||
// @Router /secrets/{group}/values [get]
|
||||
func esoGetGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
|
||||
@@ -58,6 +71,16 @@ func esoGetGroup(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, values)
|
||||
}
|
||||
|
||||
// listSecretGroups godoc
|
||||
//
|
||||
// @Summary List secret groups
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.GroupSummary
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets [get]
|
||||
func listSecretGroups(c *gin.Context) {
|
||||
groups, err := services.ListSecretGroups(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -67,6 +90,20 @@ func listSecretGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
// createSecretGroup godoc
|
||||
//
|
||||
// @Summary Create a secret group
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{group=string,values=map[string]string} true "Group and its initial key/value pairs"
|
||||
// @Success 201 {object} GroupResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets [post]
|
||||
func createSecretGroup(c *gin.Context) {
|
||||
var body struct {
|
||||
Group string `json:"group" binding:"required"`
|
||||
@@ -98,9 +135,22 @@ func createSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
|
||||
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
|
||||
c.JSON(http.StatusCreated, GroupResponse{Group: body.Group})
|
||||
}
|
||||
|
||||
// getSecretGroup godoc
|
||||
//
|
||||
// @Summary Get a secret group's keys
|
||||
// @Description Returns the group's key metadata, not decrypted values. See POST /secrets/{group}/reveal for a value.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} SecretGroupResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [get]
|
||||
func getSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
|
||||
@@ -112,9 +162,24 @@ func getSecretGroup(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
|
||||
c.JSON(http.StatusOK, SecretGroupResponse{Group: group, Secrets: secrets})
|
||||
}
|
||||
|
||||
// putSecretGroup godoc
|
||||
//
|
||||
// @Summary Replace a secret group's keys
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param body body map[string]string true "Key/value pairs"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [put]
|
||||
func putSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if !validName(group) {
|
||||
@@ -144,9 +209,23 @@ func putSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// revealSecret godoc
|
||||
//
|
||||
// @Summary Reveal a secret value
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param body body object{key=string} true "Key to reveal"
|
||||
// @Success 200 {object} RevealSecretResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group}/reveal [post]
|
||||
func revealSecret(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
var body struct {
|
||||
@@ -162,9 +241,21 @@ func revealSecret(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
|
||||
c.JSON(http.StatusOK, gin.H{"value": value})
|
||||
c.JSON(http.StatusOK, RevealSecretResponse{Value: value})
|
||||
}
|
||||
|
||||
// deleteSecretKey godoc
|
||||
//
|
||||
// @Summary Delete a key from a secret group
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param key path string true "Key name"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group}/{key} [delete]
|
||||
func deleteSecretKey(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
key := c.Param("key")
|
||||
@@ -173,9 +264,20 @@ func deleteSecretKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// deleteSecretGroup godoc
|
||||
//
|
||||
// @Summary Delete a secret group
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [delete]
|
||||
func deleteSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
|
||||
@@ -183,9 +285,19 @@ func deleteSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// rotateSecretsToken godoc
|
||||
//
|
||||
// @Summary Rotate the ESO read token
|
||||
// @Tags settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} SecretsTokenResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings/secrets-token [post]
|
||||
func rotateSecretsToken(c *gin.Context) {
|
||||
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -193,5 +305,5 @@ func rotateSecretsToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
c.JSON(http.StatusOK, SecretsTokenResponse{Token: token})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func elevated(c *gin.Context) bool {
|
||||
r := auth.Role(c)
|
||||
return r == models.RoleOwner || r == models.RoleAdmin
|
||||
}
|
||||
|
||||
// listTokens godoc
|
||||
//
|
||||
// @Summary List API tokens
|
||||
// @Description Returns the caller's own tokens. Owner and admin may pass all=true to see every token in the instance.
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Param all query bool false "Include every token in the instance (owner and admin only)"
|
||||
// @Success 200 {object} ListTokensResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens [get]
|
||||
func listTokens(c *gin.Context) {
|
||||
all := c.Query("all") == "true" && elevated(c)
|
||||
tokens, err := services.ListAPITokens(auth.InstanceID(c), auth.UserID(c), all)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ListTokensResponse{Tokens: tokens, All: all})
|
||||
}
|
||||
|
||||
// listTokenScopes godoc
|
||||
//
|
||||
// @Summary List available token scopes
|
||||
// @Description Advertises the scope vocabulary so the UI never hardcodes it.
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Success 200 {object} TokenScopesResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens/scopes [get]
|
||||
func listTokenScopes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, TokenScopesResponse{Scopes: services.AllScopes()})
|
||||
}
|
||||
|
||||
// createToken godoc
|
||||
//
|
||||
// @Summary Create an API token
|
||||
// @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
|
||||
// @Param body body CreateTokenRequest true "Token parameters"
|
||||
// @Success 201 {object} CreateTokenResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 422 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens [post]
|
||||
func createToken(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
Scopes []string `json:"scopes" binding:"required"`
|
||||
ExpiresInDays *int `json:"expires_in_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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(),
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, services.ErrTokenNameTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "name_taken"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenRoleTooHigh):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "role_too_high"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenExpiryPolicy):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error(), "code": "expiry_policy"})
|
||||
return
|
||||
case errors.Is(err, services.ErrInvalidScope):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "invalid_scope"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiry := "no expiry"
|
||||
if tok.ExpiresAt != nil {
|
||||
expiry = "expires " + tok.ExpiresAt.Format("2006-01-02")
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry))
|
||||
|
||||
// The plaintext is returned exactly once and is not stored anywhere.
|
||||
c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok})
|
||||
}
|
||||
|
||||
// revokeToken godoc
|
||||
//
|
||||
// @Summary Revoke an API token
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Param id path string true "Token ID"
|
||||
// @Success 200 {object} RevokedResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens/{id} [delete]
|
||||
func revokeToken(c *gin.Context) {
|
||||
requester, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := services.RevokeAPIToken(auth.InstanceID(c), c.Param("id"), requester)
|
||||
if errors.Is(err, services.ErrTokenNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "token.revoked", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token '%s' revoked", tok.Name))
|
||||
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
)
|
||||
|
||||
// ErrorResponse is the shape every failing endpoint answers with. Some also
|
||||
// carry a machine-readable code; it is omitted when absent rather than empty.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// LimitExceededResponse is what a create route answers when a licence cap
|
||||
// would be exceeded.
|
||||
type LimitExceededResponse struct {
|
||||
Error string `json:"error"`
|
||||
Limit string `json:"limit"`
|
||||
Current int `json:"current"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
// LicenceErrorResponse pairs an error with a machine-readable reason rather
|
||||
// than a code — used only on the two licence rejection paths that predate the
|
||||
// error/code convention used everywhere else.
|
||||
type LicenceErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Small, reused acknowledgement shapes. Several unrelated handlers happen to
|
||||
// answer with exactly one of these.
|
||||
type DeletedResponse struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type RevokedResponse struct {
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
|
||||
type SavedResponse struct {
|
||||
Saved bool `json:"saved"`
|
||||
}
|
||||
|
||||
type AcknowledgedResponse struct {
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
}
|
||||
|
||||
type OKResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type CancelledResponse struct {
|
||||
Cancelled bool `json:"cancelled"`
|
||||
}
|
||||
|
||||
type UpdatedResponse struct {
|
||||
Updated bool `json:"updated"`
|
||||
}
|
||||
|
||||
type MessageResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// --- servers / keys ---
|
||||
|
||||
type TagsResponse struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type CreateServerResponse struct {
|
||||
Server *models.Server `json:"server"`
|
||||
Token string `json:"token"`
|
||||
ServerID string `json:"server_id"`
|
||||
}
|
||||
|
||||
type NewServerResponse struct {
|
||||
ServerID string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
InstallCommand string `json:"install_command"`
|
||||
InstallCommandPS string `json:"install_command_ps"`
|
||||
}
|
||||
|
||||
// ServerDetailResponse is a server with its resolved key assignments.
|
||||
type ServerDetailResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
}
|
||||
|
||||
type GenerateKeyResponse struct {
|
||||
Message string `json:"message"`
|
||||
CommandID string `json:"command_id"`
|
||||
ServerID string `json:"server_id"`
|
||||
}
|
||||
|
||||
type PrivateKeyResponse struct {
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
// KeyDetailResponse is a key with its resolved server assignments.
|
||||
type KeyDetailResponse struct {
|
||||
*models.Key
|
||||
Assignments any `json:"assignments"`
|
||||
}
|
||||
|
||||
type AgentVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type UpdateAgentResponse struct {
|
||||
Message string `json:"message"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type AuditEventsResponse struct {
|
||||
Events []models.AuditEvent `json:"events"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// --- tokens ---
|
||||
|
||||
type ListTokensResponse struct {
|
||||
Tokens []models.APIToken `json:"tokens"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
|
||||
type TokenScopesResponse struct {
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
|
||||
type CreateTokenResponse struct {
|
||||
// Token is the plaintext, returned exactly once and stored nowhere.
|
||||
Token string `json:"token"`
|
||||
Record models.APIToken `json:"record"`
|
||||
}
|
||||
|
||||
// --- secrets ---
|
||||
|
||||
type GroupResponse struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
type SecretGroupResponse struct {
|
||||
Group string `json:"group"`
|
||||
Secrets []models.Secret `json:"secrets"`
|
||||
}
|
||||
|
||||
type RevealSecretResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type SecretsTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// --- auth providers ---
|
||||
|
||||
type TestProviderResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// --- licence ---
|
||||
|
||||
type LicencePostResponse struct {
|
||||
State license.State `json:"state"`
|
||||
Tier string `json:"tier"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// --- vulnerabilities ---
|
||||
|
||||
// VulnSummaryResponse's four DB-freshness fields are only present at all when
|
||||
// a vulndb_meta document exists; LastError is separately omitted from that
|
||||
// group when empty, matching the handler's original conditional gin.H.
|
||||
type VulnSummaryResponse struct {
|
||||
Counts map[string]int `json:"counts"`
|
||||
DBVersion *int `json:"db_version,omitempty"`
|
||||
PulledAt *time.Time `json:"pulled_at,omitempty"`
|
||||
LastFullScanAt *time.Time `json:"last_full_scan_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type QueuedResponse struct {
|
||||
Queued int64 `json:"queued"`
|
||||
}
|
||||
|
||||
type ReportedResponse struct {
|
||||
Reported bool `json:"reported"`
|
||||
}
|
||||
|
||||
// --- workflows ---
|
||||
|
||||
type SeedDefaultsResponse struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
}
|
||||
|
||||
type RunWorkflowResponse struct {
|
||||
RunID string `json:"run_id"`
|
||||
}
|
||||
|
||||
type ScheduleResponse struct {
|
||||
Schedule models.Schedule `json:"schedule"`
|
||||
NextRunAt *time.Time `json:"next_run_at"`
|
||||
}
|
||||
|
||||
type OccurrencesResponse struct {
|
||||
Occurrences []time.Time `json:"occurrences"`
|
||||
}
|
||||
|
||||
// --- console ---
|
||||
|
||||
type ConsoleConnectResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Token string `json:"token"`
|
||||
WSPath string `json:"ws_path"`
|
||||
}
|
||||
|
||||
// --- workloads ---
|
||||
|
||||
type WorkloadLogsResponse struct {
|
||||
Text string `json:"text"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
@@ -26,6 +26,22 @@ type vulnGroup struct {
|
||||
Findings []models.VulnFinding `json:"findings"`
|
||||
}
|
||||
|
||||
// listVulnerabilities godoc
|
||||
//
|
||||
// @Summary List vulnerabilities
|
||||
// @Description Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param severity query string false "Filter by severity"
|
||||
// @Param state query string false "Filter by state (default open)"
|
||||
// @Param server query string false "Filter by server ID"
|
||||
// @Param tag query []string false "Filter by tag as key:value, repeatable"
|
||||
// @Param has_fix query bool false "Filter by whether a vendor fix exists"
|
||||
// @Success 200 {array} vulnGroup
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities [get]
|
||||
func listVulnerabilities(c *gin.Context) {
|
||||
findings, err := services.ListInstanceFindings(auth.InstanceID(c), services.FindingFilter{
|
||||
Severity: c.Query("severity"),
|
||||
@@ -115,6 +131,17 @@ func tagsFromQuery(c *gin.Context) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// vulnerabilitySummary godoc
|
||||
//
|
||||
// @Summary Get vulnerability counts and database freshness
|
||||
// @Description Counts travel with the database version and pull time, since a fleet scanned against a stale database must say so wherever its findings are read.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {object} VulnSummaryResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/summary [get]
|
||||
func vulnerabilitySummary(c *gin.Context) {
|
||||
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -122,24 +149,32 @@ func vulnerabilitySummary(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := gin.H{"counts": counts}
|
||||
resp := VulnSummaryResponse{Counts: counts}
|
||||
|
||||
// Database freshness travels with the counts rather than living in
|
||||
// settings: a fleet scanned against a three-week-old database must say so
|
||||
// wherever its findings are read, not somewhere the reader has to go and
|
||||
// look for it.
|
||||
if meta, err := services.GetVulnDBMeta(); err == nil && meta != nil {
|
||||
resp["db_version"] = meta.DBVersion
|
||||
resp["pulled_at"] = meta.PulledAt
|
||||
resp["last_full_scan_at"] = meta.LastFullScanAt
|
||||
if meta.LastError != "" {
|
||||
resp["last_error"] = meta.LastError
|
||||
}
|
||||
resp.DBVersion = &meta.DBVersion
|
||||
resp.PulledAt = &meta.PulledAt
|
||||
resp.LastFullScanAt = &meta.LastFullScanAt
|
||||
resp.LastError = meta.LastError
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// rescanVulnerabilities godoc
|
||||
//
|
||||
// @Summary Queue the fleet for a vulnerability rescan
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {object} QueuedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/rescan [post]
|
||||
func rescanVulnerabilities(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
|
||||
@@ -151,7 +186,7 @@ func rescanVulnerabilities(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rescan", actorFromCtx(c), "", "",
|
||||
"queued "+strconv.FormatInt(n, 10)+" server(s) for rescan")
|
||||
c.JSON(http.StatusOK, gin.H{"queued": n})
|
||||
c.JSON(http.StatusOK, QueuedResponse{Queued: n})
|
||||
}
|
||||
|
||||
type acceptFindingRequest struct {
|
||||
@@ -159,6 +194,22 @@ type acceptFindingRequest struct {
|
||||
Until time.Time `json:"until"`
|
||||
}
|
||||
|
||||
// acceptFinding godoc
|
||||
//
|
||||
// @Summary Accept a finding
|
||||
// @Description Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Finding ID"
|
||||
// @Param body body acceptFindingRequest true "Reason and expiry"
|
||||
// @Success 200 {object} models.VulnFinding
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/{id}/accept [post]
|
||||
func acceptFinding(c *gin.Context) {
|
||||
var req acceptFindingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -192,6 +243,18 @@ func acceptFinding(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, f)
|
||||
}
|
||||
|
||||
// unacceptFinding godoc
|
||||
//
|
||||
// @Summary Return an accepted finding to open
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Finding ID"
|
||||
// @Success 200 {object} models.VulnFinding
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/{id}/accept [delete]
|
||||
func unacceptFinding(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
actor := actorFromCtx(c)
|
||||
@@ -215,6 +278,17 @@ func writeFindingError(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
// listServerVulnerabilities godoc
|
||||
//
|
||||
// @Summary List a server's vulnerabilities
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {array} models.VulnFinding
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/vulnerabilities [get]
|
||||
func listServerVulnerabilities(c *gin.Context) {
|
||||
findings, err := services.ListFindings(c.Request.Context(), auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -227,6 +301,18 @@ func listServerVulnerabilities(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, findings)
|
||||
}
|
||||
|
||||
// getServerPackages godoc
|
||||
//
|
||||
// @Summary Get a server's package inventory
|
||||
// @Description A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} models.ServerPackages
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/packages [get]
|
||||
func getServerPackages(c *gin.Context) {
|
||||
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -237,12 +323,24 @@ func getServerPackages(c *gin.Context) {
|
||||
// Not a 404: an agent that has not reported yet is the normal state for
|
||||
// the first hour after install, and is a different thing from a bad
|
||||
// server id.
|
||||
c.JSON(http.StatusOK, gin.H{"reported": false})
|
||||
c.JSON(http.StatusOK, ReportedResponse{Reported: false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sp)
|
||||
}
|
||||
|
||||
// searchPackages godoc
|
||||
//
|
||||
// @Summary Search packages fleet-wide
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param name query string true "Package name"
|
||||
// @Success 200 {array} services.PackageHit
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /packages/search [get]
|
||||
func searchPackages(c *gin.Context) {
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
@@ -257,6 +355,16 @@ func searchPackages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, hits)
|
||||
}
|
||||
|
||||
// listVulnRules godoc
|
||||
//
|
||||
// @Summary List vulnerability alert rules
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.VulnAlertRule
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules [get]
|
||||
func listVulnRules(c *gin.Context) {
|
||||
rules, err := services.ListVulnRules(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -266,6 +374,18 @@ func listVulnRules(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, rules)
|
||||
}
|
||||
|
||||
// createVulnRule godoc
|
||||
//
|
||||
// @Summary Create a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.VulnAlertRule true "Rule to create"
|
||||
// @Success 201 {object} models.VulnAlertRule
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules [post]
|
||||
func createVulnRule(c *gin.Context) {
|
||||
var r models.VulnAlertRule
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
@@ -284,6 +404,20 @@ func createVulnRule(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// updateVulnRule godoc
|
||||
//
|
||||
// @Summary Update a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Rule ID"
|
||||
// @Param body body models.VulnAlertRule true "Rule fields"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules/{id} [put]
|
||||
func updateVulnRule(c *gin.Context) {
|
||||
var r models.VulnAlertRule
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
@@ -302,9 +436,21 @@ func updateVulnRule(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rule_updated", actorFromCtx(c), "", "", "rule "+r.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "updated"})
|
||||
}
|
||||
|
||||
// deleteVulnRule godoc
|
||||
//
|
||||
// @Summary Delete a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Rule ID"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules/{id} [delete]
|
||||
func deleteVulnRule(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
if err := services.DeleteVulnRule(instanceID, c.Param("id")); err != nil {
|
||||
@@ -317,5 +463,5 @@ func deleteVulnRule(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rule_deleted", actorFromCtx(c), "", "", "rule "+c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "deleted"})
|
||||
}
|
||||
|
||||
@@ -47,6 +47,20 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
|
||||
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
|
||||
|
||||
// getServerRunLog godoc
|
||||
//
|
||||
// @Summary Get a run's log for one server
|
||||
// @Description Streams the stored log in pages rather than loading it whole; capped at 200k lines per server-run.
|
||||
// @Tags workflows
|
||||
// @Produce plain
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {string} string "plain-text log"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/servers/{serverId}/logs [get]
|
||||
func getServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
@@ -83,6 +97,20 @@ func getServerRunLog(c *gin.Context) {
|
||||
// is one or two queries, small enough that no single response buffers much.
|
||||
const logPageSize = 2000
|
||||
|
||||
// streamServerRunLog godoc
|
||||
//
|
||||
// @Summary Stream a run's log for one server (SSE)
|
||||
// @Description Server-sent events; sends new lines every 500ms until the server's run reaches a terminal state.
|
||||
// @Tags workflows
|
||||
// @Produce text/event-stream
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {string} string "text/event-stream"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/servers/{serverId}/logs/stream [get]
|
||||
func streamServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
@@ -166,6 +194,16 @@ func splitSSE(b []byte) []string {
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
// listSteps godoc
|
||||
//
|
||||
// @Summary List workflow steps
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.WorkflowStep
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps [get]
|
||||
func listSteps(c *gin.Context) {
|
||||
steps, err := services.ListSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -175,6 +213,16 @@ func listSteps(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, steps)
|
||||
}
|
||||
|
||||
// stepUsage godoc
|
||||
//
|
||||
// @Summary Count workflows using each step
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]int
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/usage [get]
|
||||
func stepUsage(c *gin.Context) {
|
||||
counts, err := services.StepUsageCounts(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -184,6 +232,19 @@ func stepUsage(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
|
||||
// createStep godoc
|
||||
//
|
||||
// @Summary Create a workflow step
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Step to create"
|
||||
// @Success 201 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps [post]
|
||||
func createStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
@@ -199,6 +260,22 @@ func createStep(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// updateStep godoc
|
||||
//
|
||||
// @Summary Update a workflow step
|
||||
// @Description A step with source "default" is read-only and refuses with 409, because seeding rewrites it on every boot.
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Param body body models.WorkflowStep true "Step fields"
|
||||
// @Success 200 {object} UpdatedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id} [put]
|
||||
func updateStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
@@ -214,9 +291,22 @@ func updateStep(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
c.JSON(http.StatusOK, UpdatedResponse{Updated: true})
|
||||
}
|
||||
|
||||
// deleteStep godoc
|
||||
//
|
||||
// @Summary Delete a workflow step
|
||||
// @Description A step with source "default" is read-only and refuses with 409.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id} [delete]
|
||||
func deleteStep(c *gin.Context) {
|
||||
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
if errors.Is(err, services.ErrDefaultStep) {
|
||||
@@ -227,9 +317,20 @@ func deleteStep(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// exportStep godoc
|
||||
//
|
||||
// @Summary Export a step as a downloadable JSON document
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Success 200 {object} models.WorkflowStep
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id}/export [get]
|
||||
func exportStep(c *gin.Context) {
|
||||
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -240,6 +341,16 @@ func exportStep(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
// seedDefaults godoc
|
||||
//
|
||||
// @Summary Sync the default step library
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {object} SeedDefaultsResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/seed-defaults [post]
|
||||
func seedDefaults(c *gin.Context) {
|
||||
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -247,11 +358,23 @@ func seedDefaults(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
|
||||
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
|
||||
c.JSON(http.StatusOK, SeedDefaultsResponse{Created: created, Updated: updated})
|
||||
}
|
||||
|
||||
const maxStepBodyBytes = 1 << 20
|
||||
|
||||
// importStep godoc
|
||||
//
|
||||
// @Summary Import a step from an exported JSON document
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Exported step document"
|
||||
// @Success 201 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/import [post]
|
||||
func importStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
@@ -268,6 +391,18 @@ func importStep(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// parseStep godoc
|
||||
//
|
||||
// @Summary Parse a step document without saving it
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Step document to parse"
|
||||
// @Success 200 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/parse [post]
|
||||
func parseStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
@@ -283,6 +418,16 @@ func parseStep(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// listWorkflows godoc
|
||||
//
|
||||
// @Summary List workflows
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Workflow
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows [get]
|
||||
func listWorkflows(c *gin.Context) {
|
||||
wfs, err := services.ListWorkflows(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -292,6 +437,19 @@ func listWorkflows(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, wfs)
|
||||
}
|
||||
|
||||
// createWorkflow godoc
|
||||
//
|
||||
// @Summary Create a workflow
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.Workflow true "Workflow to create"
|
||||
// @Success 201 {object} models.Workflow
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows [post]
|
||||
func createWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
@@ -307,6 +465,17 @@ func createWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// getWorkflow godoc
|
||||
//
|
||||
// @Summary Get a workflow
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 200 {object} models.Workflow
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [get]
|
||||
func getWorkflow(c *gin.Context) {
|
||||
w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -316,6 +485,20 @@ func getWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, w)
|
||||
}
|
||||
|
||||
// updateWorkflow godoc
|
||||
//
|
||||
// @Summary Update a workflow
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param body body models.Workflow true "Workflow fields"
|
||||
// @Success 200 {object} models.Workflow
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [put]
|
||||
func updateWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
@@ -335,15 +518,39 @@ func updateWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteWorkflow godoc
|
||||
//
|
||||
// @Summary Delete a workflow
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [delete]
|
||||
func deleteWorkflow(c *gin.Context) {
|
||||
if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// runWorkflow godoc
|
||||
//
|
||||
// @Summary Run a workflow
|
||||
// @Description Snapshots the resolved steps into a WorkflowRun and dispatches to every targeted server.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 202 {object} RunWorkflowResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/run [post]
|
||||
func runWorkflow(c *gin.Context) {
|
||||
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
|
||||
if err != nil {
|
||||
@@ -355,9 +562,21 @@ func runWorkflow(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
|
||||
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
|
||||
c.JSON(http.StatusAccepted, RunWorkflowResponse{RunID: runID})
|
||||
}
|
||||
|
||||
// listWorkflowRuns godoc
|
||||
//
|
||||
// @Summary List a workflow's runs
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param limit query int false "Max runs to return (default 50)"
|
||||
// @Success 200 {array} models.WorkflowRun
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/runs [get]
|
||||
func listWorkflowRuns(c *gin.Context) {
|
||||
limit := int64(50)
|
||||
if l := c.Query("limit"); l != "" {
|
||||
@@ -373,6 +592,17 @@ func listWorkflowRuns(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, runs)
|
||||
}
|
||||
|
||||
// getRun godoc
|
||||
//
|
||||
// @Summary Get a run
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Success 200 {object} models.WorkflowRun
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId} [get]
|
||||
func getRun(c *gin.Context) {
|
||||
r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
|
||||
if err != nil {
|
||||
@@ -382,15 +612,42 @@ func getRun(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
// cancelRun godoc
|
||||
//
|
||||
// @Summary Cancel a run
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Success 200 {object} CancelledResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/cancel [post]
|
||||
func cancelRun(c *gin.Context) {
|
||||
if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
||||
c.JSON(http.StatusOK, CancelledResponse{Cancelled: true})
|
||||
}
|
||||
|
||||
// putWorkflowSchedule godoc
|
||||
//
|
||||
// @Summary Set a workflow's schedule
|
||||
// @Description Standard 5-field cron and an IANA zone, both validated at save time.
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param body body models.Schedule true "Schedule"
|
||||
// @Success 200 {object} ScheduleResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/schedule [put]
|
||||
func putWorkflowSchedule(c *gin.Context) {
|
||||
var body models.Schedule
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -415,12 +672,22 @@ func putWorkflowSchedule(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
|
||||
fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
|
||||
c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next})
|
||||
c.JSON(http.StatusOK, ScheduleResponse{Schedule: body, NextRunAt: next})
|
||||
}
|
||||
|
||||
// previewWorkflowSchedule exists so the browser and the scheduler agree on
|
||||
// what a cron string means. A client-side cron parser that disagrees with the
|
||||
// server by one field is a bug found in production, at night.
|
||||
// previewWorkflowSchedule godoc
|
||||
//
|
||||
// @Summary Preview the next occurrences of a cron schedule
|
||||
// @Description Exists so the browser and the scheduler agree on what a cron string means.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param cron query string true "5-field cron expression"
|
||||
// @Param tz query string true "IANA time zone name"
|
||||
// @Success 200 {object} OccurrencesResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/schedule/preview [get]
|
||||
func previewWorkflowSchedule(c *gin.Context) {
|
||||
expr := c.Query("cron")
|
||||
tz := c.Query("tz")
|
||||
|
||||
@@ -18,6 +18,19 @@ import (
|
||||
//
|
||||
// A server that has never reported answers an empty list rather than 404: the
|
||||
// agent may simply not have got there yet, and 404 reads as "no such server".
|
||||
// getServerWorkloads godoc
|
||||
//
|
||||
// @Summary Get a server's workload snapshot
|
||||
// @Description Returns the stored snapshot. A server that has never reported answers an empty list, not 404.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} models.ServerWorkloads
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads [get]
|
||||
func getServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -47,6 +60,19 @@ func getServerWorkloads(c *gin.Context) {
|
||||
|
||||
// refreshServerWorkloads nudges the agent to report now. It returns no data:
|
||||
// the client refetches the stored document once the agent has written it.
|
||||
// refreshServerWorkloads godoc
|
||||
//
|
||||
// @Summary Request a fresh workload report
|
||||
// @Description Nudges the agent to report now. Returns no data; the client refetches once the agent has written it.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} MessageResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/refresh [post]
|
||||
func refreshServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -63,9 +89,28 @@ func refreshServerWorkloads(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
|
||||
c.JSON(http.StatusAccepted, MessageResponse{Message: "refresh requested"})
|
||||
}
|
||||
|
||||
// controlWorkload godoc
|
||||
//
|
||||
// @Summary Start, stop or restart a workload
|
||||
// @Description Owner and admin only. The protected set (vantage-agent.service and the agent's own container) is enforced agent-side and answers 409, not an error.
|
||||
// @Tags workloads
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param wid path string true "Workload ID"
|
||||
// @Param body body object{action=string,kind=string} true "Action (start/stop/restart) and kind (container/unit)"
|
||||
// @Success 200 {object} MessageResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/{wid}/action [post]
|
||||
func controlWorkload(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -117,9 +162,27 @@ func controlWorkload(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
|
||||
c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"})
|
||||
c.JSON(http.StatusOK, MessageResponse{Message: body.Action + " ok"})
|
||||
}
|
||||
|
||||
// getWorkloadLogs godoc
|
||||
//
|
||||
// @Summary Read a workload's logs
|
||||
// @Description Owner and admin only, and audited: container output is arbitrary and cannot be masked. Capped at 500 lines and 256KB, whichever binds first.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param wid path string true "Workload ID"
|
||||
// @Param kind query string false "container or unit (default container)"
|
||||
// @Param tail query int false "Lines to return, clamped to the cap"
|
||||
// @Success 200 {object} WorkloadLogsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/{wid}/logs [get]
|
||||
func getWorkloadLogs(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -163,11 +226,23 @@ func getWorkloadLogs(c *gin.Context) {
|
||||
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated})
|
||||
c.JSON(http.StatusOK, WorkloadLogsResponse{Text: text, Truncated: truncated})
|
||||
}
|
||||
|
||||
// listWorkloads answers the fleet-wide question, which is the reason the
|
||||
// snapshot is stored rather than fetched on demand and discarded.
|
||||
// listWorkloads godoc
|
||||
//
|
||||
// @Summary Search workloads fleet-wide
|
||||
// @Description Answers the fleet-wide question, which is the reason the snapshot is stored rather than fetched on demand and discarded.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param image query string false "Filter by image name"
|
||||
// @Param stack query string false "Filter by compose stack"
|
||||
// @Param state query string false "Filter by state"
|
||||
// @Success 200 {array} services.WorkloadHit
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workloads [get]
|
||||
func listWorkloads(c *gin.Context) {
|
||||
hits, err := services.SearchWorkloads(auth.InstanceID(c),
|
||||
c.Query("image"), c.Query("stack"), c.Query("state"))
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -48,17 +52,27 @@ func RequireRole(roles ...string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware authenticates a request by session cookie or by API token.
|
||||
//
|
||||
// Both paths end by putting a *Session in the context, which is why no handler,
|
||||
// role guard, licence gate or audit call needed changing: the token path is a
|
||||
// second way to arrive at the same value, not a second way through the API.
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
sess, ok := sessionFromCookie(c)
|
||||
if !ok {
|
||||
// A cookie that was presented and rejected has already had its
|
||||
// response written by sessionFromCookie (no bearer was present to
|
||||
// fall through to). Trying sessionFromToken anyway would write a
|
||||
// second body onto the same response.
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
sess, ok = sessionFromToken(c)
|
||||
}
|
||||
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
if !ok {
|
||||
// sessionFromCookie and sessionFromToken have already written the
|
||||
// response describing which credential failed and why.
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,6 +83,8 @@ func Middleware() gin.HandlerFunc {
|
||||
|
||||
c.Set(ctxSessionKey, sess)
|
||||
|
||||
// The host guard applies to both credential kinds. A token carries an
|
||||
// instance, and the tenant boundary must not have a token-shaped hole.
|
||||
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
|
||||
return
|
||||
@@ -77,3 +93,109 @@ func Middleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// sessionFromCookie returns false without writing a response when there is no
|
||||
// cookie at all, so the token path gets its turn. It writes and aborts only
|
||||
// when a cookie was presented and was not usable.
|
||||
func sessionFromCookie(c *gin.Context) (*Session, bool) {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
// A stale cookie plus a valid bearer token is a real combination —
|
||||
// a browser tab left open beside a curl. Fall through rather than
|
||||
// refusing a credential that would have worked.
|
||||
if bearerToken(c) != "" {
|
||||
return nil, false
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) string {
|
||||
const prefix = "Bearer "
|
||||
h := c.GetHeader("Authorization")
|
||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
return h[len(prefix):]
|
||||
}
|
||||
|
||||
func sessionFromToken(c *gin.Context) (*Session, bool) {
|
||||
raw := bearerToken(c)
|
||||
if raw == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
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. 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
|
||||
}
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := services.GetUserInInstance(tok.InstanceID, tok.UserID)
|
||||
if err != nil {
|
||||
// The owner is gone. DeleteUser removes tokens, so this is the
|
||||
// belt-and-braces path for a row deleted some other way.
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
services.TouchAPIToken(tok)
|
||||
|
||||
return &Session{
|
||||
UserID: tok.UserID,
|
||||
InstanceID: tok.InstanceID,
|
||||
// Recomputed per request, so demoting the person demotes the token.
|
||||
Role: services.LowerRole(user.Role, tok.Role),
|
||||
Email: user.Email,
|
||||
Name: user.Email,
|
||||
TokenID: tok.TokenID,
|
||||
TokenName: tok.Name,
|
||||
Scopes: tok.Scopes,
|
||||
}, true
|
||||
}
|
||||
|
||||
// TokenID is empty for a cookie session and the token's ID for a token
|
||||
// request. It is what lets audit detail record which credential acted.
|
||||
func TokenID(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.TokenID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TokenName(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.TokenName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Scopes(c *gin.Context) []string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.Scopes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsToken reports whether this request authenticated with an API token rather
|
||||
// than a browser session.
|
||||
func IsToken(c *gin.Context) bool { return TokenID(c) != "" }
|
||||
|
||||
@@ -22,6 +22,14 @@ type Session struct {
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
|
||||
// The three fields below are set only when the request authenticated with
|
||||
// an API token. They are never persisted to Redis — a token authenticates
|
||||
// per request and mints no session, so a revoked token stops working
|
||||
// immediately rather than at the end of a session TTL.
|
||||
TokenID string `json:"-"`
|
||||
TokenName string `json:"-"`
|
||||
Scopes []string `json:"-"`
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
@@ -51,6 +59,11 @@ func PingRedis(ctx context.Context) error {
|
||||
return rdb.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Redis exposes the session client for callers that need a counter rather than
|
||||
// a session. There is one Redis in this deployment and adding a second client
|
||||
// would double the connection pool for no reason.
|
||||
func Redis() *redis.Client { return rdb }
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// APIToken is a personal access token for the REST API.
|
||||
//
|
||||
// The plaintext is shown once at creation and never stored: only TokenHash,
|
||||
// which is sha256 hex of the value, exactly as servers.agent_token_hash and the
|
||||
// ESO read token already are. bcrypt is deliberately not used — the value is
|
||||
// full-entropy random rather than a chosen password, and a per-token salt would
|
||||
// force a collection scan where an indexed lookup is wanted.
|
||||
//
|
||||
// Role and Scopes are immutable after creation. There is no update endpoint:
|
||||
// editing what a credential already deployed in CI can do, with no record of
|
||||
// what it could do before, is worse than requiring a rotation.
|
||||
type APIToken struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
TokenID string `bson:"token_id" json:"token_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
|
||||
Name string `bson:"name" json:"name"`
|
||||
// Hint is the first 8 characters of the plaintext, stored in clear so the
|
||||
// list can identify a token without revealing it.
|
||||
Hint string `bson:"hint" json:"hint"`
|
||||
// TokenHash is never serialised to JSON.
|
||||
TokenHash string `bson:"token_hash" json:"-"`
|
||||
|
||||
Role string `bson:"role" json:"role"`
|
||||
Scopes []string `bson:"scopes" json:"scopes"`
|
||||
|
||||
// ExpiresAt nil means the token never expires. Whether that is allowed is
|
||||
// a per-instance policy, settings.api_token_max_days.
|
||||
ExpiresAt *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"`
|
||||
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
|
||||
CreatedByIP string `bson:"created_by_ip,omitempty" json:"created_by_ip,omitempty"`
|
||||
|
||||
// Email of the owning user, joined at read time for the list. Never stored.
|
||||
UserEmail string `bson:"-" json:"user_email,omitempty"`
|
||||
}
|
||||
|
||||
// Expired reports whether the token's expiry has passed. A nil ExpiresAt never
|
||||
// expires.
|
||||
func (t *APIToken) Expired(now time.Time) bool {
|
||||
return t.ExpiresAt != nil && now.After(*t.ExpiresAt)
|
||||
}
|
||||
@@ -7,3 +7,7 @@ type (
|
||||
AlertSettings = shared.AlertSettings
|
||||
SecretsSettings = shared.SecretsSettings
|
||||
)
|
||||
|
||||
// APITokenMaxDays re-exports shared.APITokenMaxDays so server/internal/services
|
||||
// can read the token lifetime cap without importing shared/models directly.
|
||||
func APITokenMaxDays(s *Settings) int { return shared.APITokenMaxDays(s) }
|
||||
|
||||
@@ -43,6 +43,7 @@ var ScopedCollections = []string{
|
||||
"server_packages",
|
||||
"vuln_findings",
|
||||
"vuln_alert_rules",
|
||||
"api_tokens",
|
||||
"server_workloads",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidScope is returned when a token is requested with a scope outside
|
||||
// the vocabulary below.
|
||||
var ErrInvalidScope = errors.New("invalid scope")
|
||||
|
||||
// ScopeResources is the whole vocabulary. Eight resources, each with :read and
|
||||
// :write, and write implies read on the same resource.
|
||||
//
|
||||
// It is deliberately coarse. A scope per endpoint is a table nobody maintains,
|
||||
// and a route added without an entry either fails closed and breaks, or
|
||||
// defaults open and is pointless.
|
||||
var ScopeResources = []string{
|
||||
"servers",
|
||||
"keys",
|
||||
"secrets",
|
||||
"workflows",
|
||||
"monitors",
|
||||
"vulns",
|
||||
"workloads",
|
||||
"settings",
|
||||
}
|
||||
|
||||
const (
|
||||
ScopeRead = "read"
|
||||
ScopeWrite = "write"
|
||||
)
|
||||
|
||||
// AllScopes returns every valid scope string, sorted, for the API to advertise
|
||||
// to the token-creation UI.
|
||||
func AllScopes() []string {
|
||||
out := make([]string, 0, len(ScopeResources)*2)
|
||||
for _, r := range ScopeResources {
|
||||
out = append(out, r+":"+ScopeRead, r+":"+ScopeWrite)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func validScope(s string) bool {
|
||||
resource, action, ok := strings.Cut(s, ":")
|
||||
if !ok || (action != ScopeRead && action != ScopeWrite) {
|
||||
return false
|
||||
}
|
||||
for _, r := range ScopeResources {
|
||||
if r == resource {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidScopes rejects an unknown scope and an empty list. A token with no
|
||||
// scopes can reach nothing, so creating one is a mistake worth naming rather
|
||||
// than a credential worth issuing.
|
||||
func ValidScopes(scopes []string) error {
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Errorf("%w: at least one scope is required", ErrInvalidScope)
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if !validScope(s) {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidScope, s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScopeSatisfied reports whether the held scopes cover the required one.
|
||||
// Holding "servers:write" satisfies a requirement of "servers:read"; the
|
||||
// converse is false.
|
||||
func ScopeSatisfied(held []string, required string) bool {
|
||||
resource, action, ok := strings.Cut(required, ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, h := range held {
|
||||
if h == required {
|
||||
return true
|
||||
}
|
||||
if action == ScopeRead && h == resource+":"+ScopeWrite {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func ResolveSecretsReadToken(token string) (string, bool) {
|
||||
return s.InstanceID, true
|
||||
}
|
||||
|
||||
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool) error {
|
||||
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error {
|
||||
if alerts.OfflineThresholdMinutes <= 0 {
|
||||
alerts.OfflineThresholdMinutes = 5
|
||||
}
|
||||
@@ -149,6 +149,9 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays
|
||||
if localLoginEnabled != nil {
|
||||
set["local_login_enabled"] = *localLoginEnabled
|
||||
}
|
||||
if apiTokenMaxDays != nil {
|
||||
set["api_token_max_days"] = *apiTokenMaxDays
|
||||
}
|
||||
_, err := db.Col("settings").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": set, "$setOnInsert": bson.M{"instance_id": instanceID}},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// EnsureAPITokenIndexes declares the indexes the token path depends on.
|
||||
//
|
||||
// The unique index on token_hash is a security property, not an optimisation:
|
||||
// it is what makes authentication a single indexed lookup rather than a scan,
|
||||
// and what makes two tokens hashing to one value impossible to store.
|
||||
//
|
||||
// Fatal on failure, like EnsureAuthIndexes and unlike the secrets and workflow
|
||||
// builders: without the unique index the auth path would still answer, which is
|
||||
// exactly the wrong kind of degradation.
|
||||
func EnsureAPITokenIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "token_hash", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenNameTaken = errors.New("a token with that name already exists")
|
||||
ErrTokenRoleTooHigh = errors.New("cannot create a token above your own role")
|
||||
ErrTokenExpiryPolicy = errors.New("expiry exceeds this instance's maximum token lifetime")
|
||||
// ErrTokenInvalid marks a caller mistake as distinct from a backend
|
||||
// failure, which is what lets the handler choose 400 or 500.
|
||||
ErrTokenInvalid = errors.New("invalid token request")
|
||||
)
|
||||
|
||||
// TokenPrefix is on every plaintext so a leaked value is recognisable in a log
|
||||
// or a paste, and so a wrong credential fails at the prefix check rather than
|
||||
// as an anonymous 401.
|
||||
const TokenPrefix = "vt_"
|
||||
|
||||
const tokenNameMax = 64
|
||||
|
||||
// roleRank orders the three roles so a token can be capped at its owner's.
|
||||
func roleRank(role string) int {
|
||||
switch role {
|
||||
case models.RoleOwner:
|
||||
return 3
|
||||
case models.RoleAdmin:
|
||||
return 2
|
||||
case models.RoleMember:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LowerRole returns whichever of the two roles grants less. It is what makes a
|
||||
// token's authority follow its owner: demote the person and the token demotes
|
||||
// with them, because this is recomputed on every request rather than frozen at
|
||||
// creation.
|
||||
func LowerRole(a, b string) string {
|
||||
if roleRank(a) <= roleRank(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// CreateAPIToken mints a token and returns the document plus the plaintext.
|
||||
// The plaintext is the only copy: it is returned once and never stored.
|
||||
func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expiresInDays *int, ip string) (*models.APIToken, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > tokenNameMax {
|
||||
return nil, "", fmt.Errorf("%w: token name must be 1 to %d characters", ErrTokenInvalid, tokenNameMax)
|
||||
}
|
||||
if !models.ValidRole(role) {
|
||||
return nil, "", fmt.Errorf("%w: invalid role %q", ErrTokenInvalid, role)
|
||||
}
|
||||
if err := ValidScopes(scopes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
owner, err := GetUserInInstance(instanceID, userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: user not found", ErrTokenInvalid)
|
||||
}
|
||||
if roleRank(role) > roleRank(owner.Role) {
|
||||
return nil, "", ErrTokenRoleTooHigh
|
||||
}
|
||||
|
||||
settings, err := GetSettings(instanceID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
maxDays := models.APITokenMaxDays(settings)
|
||||
|
||||
var expiresAt *time.Time
|
||||
switch {
|
||||
case expiresInDays != nil:
|
||||
if *expiresInDays <= 0 {
|
||||
return nil, "", fmt.Errorf("%w: expires_in_days must be positive", ErrTokenInvalid)
|
||||
}
|
||||
if maxDays > 0 && *expiresInDays > maxDays {
|
||||
return nil, "", fmt.Errorf("%w: maximum is %d day(s)", ErrTokenExpiryPolicy, maxDays)
|
||||
}
|
||||
t := time.Now().UTC().AddDate(0, 0, *expiresInDays)
|
||||
expiresAt = &t
|
||||
case maxDays > 0:
|
||||
// A policy is set, so a token with no expiry is refused rather than
|
||||
// silently capped: the caller asked for something the instance does not
|
||||
// allow, and quietly giving them something else is worse than a 422.
|
||||
return nil, "", fmt.Errorf("%w: an expiry of at most %d day(s) is required", ErrTokenExpiryPolicy, maxDays)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
existing := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID, "name": name})
|
||||
if existing.Err() == nil {
|
||||
return nil, "", ErrTokenNameTaken
|
||||
} else if !errors.Is(existing.Err(), mongo.ErrNoDocuments) {
|
||||
return nil, "", existing.Err()
|
||||
}
|
||||
|
||||
secret, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
plaintext := TokenPrefix + secret
|
||||
|
||||
tok := &models.APIToken{
|
||||
TokenID: uuid.NewString(),
|
||||
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,
|
||||
Scopes: scopes,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedByIP: ip,
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").InsertOne(ctx, tok); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tok, plaintext, nil
|
||||
}
|
||||
|
||||
// ResolveAPIToken looks a plaintext up by hash.
|
||||
//
|
||||
// It returns ErrTokenExpired distinctly from ErrTokenNotFound so the auth layer
|
||||
// can say which happened: a forgotten CI job hitting an expired token is worth
|
||||
// seeing in the audit log, and an anonymous 401 hides it.
|
||||
func ResolveAPIToken(plaintext string) (*models.APIToken, error) {
|
||||
if !strings.HasPrefix(plaintext, TokenPrefix) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tok models.APIToken
|
||||
err := db.Col("api_tokens").FindOne(ctx, bson.M{"token_hash": HashToken(plaintext)}).Decode(&tok)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tok.Expired(time.Now().UTC()) {
|
||||
return &tok, ErrTokenExpired
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// TouchAPIToken records use, but only when the stored value is more than a
|
||||
// minute stale. Without the check this is a Mongo write on every API call.
|
||||
func TouchAPIToken(tok *models.APIToken) {
|
||||
now := time.Now().UTC()
|
||||
if tok.LastUsedAt != nil && now.Sub(*tok.LastUsedAt) < time.Minute {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, _ = db.Col("api_tokens").UpdateOne(ctx,
|
||||
bson.M{"token_id": tok.TokenID, "instance_id": tok.InstanceID},
|
||||
bson.M{"$set": bson.M{"last_used_at": now}},
|
||||
)
|
||||
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) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
if !all {
|
||||
filter["user_id"] = userID
|
||||
}
|
||||
cursor, err := db.Col("api_tokens").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var tokens []models.APIToken
|
||||
if err := cursor.All(ctx, &tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokens == nil {
|
||||
tokens = []models.APIToken{}
|
||||
}
|
||||
|
||||
// Join the owning email so an admin's list names people rather than UUIDs.
|
||||
users, err := ListUsers(instanceID)
|
||||
if err == nil {
|
||||
byID := make(map[string]string, len(users))
|
||||
for _, u := range users {
|
||||
byID[u.UserID] = u.Email
|
||||
}
|
||||
for i := range tokens {
|
||||
tokens[i].UserEmail = byID[tokens[i].UserID]
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// RevokeAPIToken deletes a token. A member may revoke only their own; owner and
|
||||
// admin may revoke any token in the instance.
|
||||
func RevokeAPIToken(instanceID, tokenID string, requester *models.User) (*models.APIToken, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tok models.APIToken
|
||||
err := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}).Decode(&tok)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
elevated := requester.Role == models.RoleOwner || requester.Role == models.RoleAdmin
|
||||
if tok.UserID != requester.UserID && !elevated {
|
||||
// Not 403: confirming the token exists tells a member about somebody
|
||||
// else's credential. Same argument as admin's customer endpoints.
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").DeleteOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// DeleteTokensForUser removes every token belonging to a user. Offboarding is
|
||||
// one action, not two: a token that outlives its owner is an access path with
|
||||
// nobody attached to it.
|
||||
func DeleteTokensForUser(instanceID, userID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := db.Col("api_tokens").DeleteMany(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
return err
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -175,5 +176,14 @@ func DeleteUser(instanceID, userID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Offboarding is one action. A token outliving its owner is an access path
|
||||
// with nobody attached to it.
|
||||
if err := DeleteTokensForUser(instanceID, userID); err != nil {
|
||||
log.Printf("delete tokens for user %s: %v", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ type Settings struct {
|
||||
// WorkflowLogRetentionDays is: absent must mean the default, not zero.
|
||||
// Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept.
|
||||
VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"`
|
||||
|
||||
// APITokenMaxDays caps how long a newly created API token may live.
|
||||
//
|
||||
// A pointer for the same reason the retention fields are: absent must mean
|
||||
// the default, and the default here is no cap at all — never-expire tokens
|
||||
// are allowed until an instance decides otherwise, so an upgrade changes
|
||||
// nothing. Nil or 0 is no cap. A positive value refuses both a longer
|
||||
// expiry and a token with no expiry.
|
||||
//
|
||||
// It is a policy on issuance, not on use: raising or lowering it never
|
||||
// invalidates a token that already exists.
|
||||
APITokenMaxDays *int `bson:"api_token_max_days,omitempty" json:"api_token_max_days,omitempty"`
|
||||
}
|
||||
|
||||
// LocalLoginEnabled reads the setting with its absent-means-on default. Every
|
||||
@@ -49,3 +61,13 @@ func LocalLoginEnabled(s *Settings) bool {
|
||||
}
|
||||
return *s.LocalLoginEnabled
|
||||
}
|
||||
|
||||
// APITokenMaxDays reads the token lifetime cap with its absent-means-uncapped
|
||||
// default. 0 means no cap. Every caller must go through this rather than
|
||||
// dereferencing the field.
|
||||
func APITokenMaxDays(s *Settings) int {
|
||||
if s == nil || s.APITokenMaxDays == nil || *s.APITokenMaxDays < 0 {
|
||||
return 0
|
||||
}
|
||||
return *s.APITokenMaxDays
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ export default function SettingsPage() {
|
||||
const [thresholdMinutes, setThresholdMinutes] = useState(5);
|
||||
const [logRetentionDays, setLogRetentionDays] = useState(30);
|
||||
const [offlineChannelIds, setOfflineChannelIds] = useState<string[]>([]);
|
||||
const [apiTokenMaxDays, setApiTokenMaxDays] = useState(0);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -151,6 +152,7 @@ export default function SettingsPage() {
|
||||
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
|
||||
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
|
||||
setOfflineChannelIds(settings.alerts.offline_channel_ids ?? []);
|
||||
setApiTokenMaxDays(settings.api_token_max_days ?? 0);
|
||||
}, [settings]);
|
||||
|
||||
// The one place the in-progress form is turned into a payload. Both the
|
||||
@@ -163,6 +165,7 @@ export default function SettingsPage() {
|
||||
offline_channel_ids: offlineChannelIds,
|
||||
},
|
||||
workflow_log_retention_days: logRetentionDays,
|
||||
api_token_max_days: apiTokenMaxDays,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,6 +294,30 @@ export default function SettingsPage() {
|
||||
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
|
||||
<input type="number" min={0} value={logRetentionDays} onChange={(e) => setLogRetentionDays(Number(e.target.value))} className={numberInputClass} />
|
||||
</Field>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* The cap lives here rather than on /tokens 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="/tokens" className="text-accent hover:underline">
|
||||
API Keys
|
||||
</Link>
|
||||
, which every member can reach.
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
+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: "/tokens", 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">
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
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"];
|
||||
|
||||
const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [
|
||||
{ label: "30 days", days: 30 },
|
||||
{ label: "60 days", days: 60 },
|
||||
{ label: "90 days", days: 90 },
|
||||
{ label: "365 days", days: 365 },
|
||||
{ label: "Never", days: null },
|
||||
];
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The token a pending revoke refers to, carried so the dialog and the
|
||||
* confirmation message name a token rather than a token_id. */
|
||||
type PendingRevoke = { id: string; name: string };
|
||||
|
||||
function roleVariant(role: Role) {
|
||||
if (role === "owner") return "accent" as const;
|
||||
if (role === "admin") return "warning" as const;
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
function rolesAtOrBelow(role: Role): Role[] {
|
||||
const idx = ROLES.indexOf(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. */
|
||||
function ExpiryCell({ token, capDays }: { token: ApiToken; capDays: number }) {
|
||||
const outsidePolicy = capDays > 0 && (!token.expires_at || new Date(token.expires_at).getTime() > Date.now() + capDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (!token.expires_at) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-text-secondary">— never</span>
|
||||
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy — rotate when convenient</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(token.expires_at);
|
||||
const expired = expiresAt.getTime() <= Date.now();
|
||||
const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className={expired ? "text-danger" : soon ? "text-warning" : "text-text-secondary"}>
|
||||
{expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()}
|
||||
</span>
|
||||
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy — rotate when convenient</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [revoking, setRevoking] = useState<PendingRevoke | null>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<Role>("member");
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(30);
|
||||
const [result, setResult] = useState<{ token: string; record: ApiToken } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings, enabled: isAdmin });
|
||||
const capDays = settings?.api_token_max_days ?? 0;
|
||||
|
||||
const {
|
||||
data: tokensData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({ queryKey: ["api-tokens", showAll], queryFn: () => api.listApiTokens(showAll) });
|
||||
const tokens = tokensData?.tokens;
|
||||
|
||||
const { data: scopesData } = useQuery({ queryKey: ["token-scopes"], queryFn: api.listTokenScopes, enabled: createOpen });
|
||||
const availableScopes = scopesData?.scopes ?? [];
|
||||
const resources = Array.from(new Set(availableScopes.map((s) => s.split(":")[0])));
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
|
||||
|
||||
function resetForm() {
|
||||
setName("");
|
||||
setRole("member");
|
||||
setScopes([]);
|
||||
setExpiryDays(30);
|
||||
setResult(null);
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
// Once the policy is known, default to the shortest option the policy still
|
||||
// allows rather than a value the submit is about to be refused for.
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
const valid = EXPIRY_OPTIONS.find((o) => !(capDays > 0 && (o.days === null || o.days > capDays)));
|
||||
if (valid) setExpiryDays(valid.days);
|
||||
}, [createOpen, capDays]);
|
||||
|
||||
const {
|
||||
mutate: createToken,
|
||||
isPending: creating,
|
||||
error: createError,
|
||||
reset: resetCreateError,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.createApiToken({ name, role, scopes, expires_in_days: expiryDays ?? undefined }),
|
||||
onSuccess: (res) => {
|
||||
setResult(res);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: revokeToken,
|
||||
isPending: isRevoking,
|
||||
error: revokeError,
|
||||
reset: resetRevoke,
|
||||
} = useMutation({
|
||||
mutationFn: (t: PendingRevoke) => api.revokeApiToken(t.id),
|
||||
onSuccess: (_data, t) => {
|
||||
invalidate();
|
||||
toast.success(`Revoked ${t.name}.`);
|
||||
setRevoking(null);
|
||||
},
|
||||
});
|
||||
|
||||
function toggleScope(s: string) {
|
||||
setScopes((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
// The plaintext is gone once this closes, so only invalidate having
|
||||
// shown it — closing before a result exists is a plain cancel.
|
||||
if (result) invalidate();
|
||||
setCreateOpen(false);
|
||||
resetCreateError();
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!result) return;
|
||||
await navigator.clipboard.writeText(result.token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
|
||||
|
||||
const count = tokens?.length ?? 0;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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 key"
|
||||
confirmLabel="Revoke key"
|
||||
loading={isRevoking}
|
||||
error={revokeError ? friendlyMessage(revokeError) : null}
|
||||
onClose={() => {
|
||||
resetRevoke();
|
||||
setRevoking(null);
|
||||
}}
|
||||
onConfirm={() => revoking && revokeToken(revoking)}
|
||||
body={
|
||||
<p>
|
||||
<span className="text-text-primary">{revoking?.name}</span> stops authenticating immediately. Any script or CI job using it
|
||||
will start failing on its next call.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal open={createOpen} title={result ? "Key created" : "New API key"} onClose={closeCreate}>
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<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="ghost" onClick={closeCreate}>
|
||||
Done
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={copyToken}>
|
||||
{copied ? "Copied" : "Copy key"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createToken();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<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>
|
||||
|
||||
<Field label="Role">
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
|
||||
{assignableRoles.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<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`;
|
||||
const writeScope = `${r}:write`;
|
||||
return (
|
||||
<div key={r} className="flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3 py-2">
|
||||
<span className="text-sm capitalize text-text-primary">{r}</span>
|
||||
<div className="flex gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(readScope)}
|
||||
onChange={() => toggleScope(readScope)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
|
||||
/>
|
||||
read
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(writeScope)}
|
||||
onChange={() => toggleScope(writeScope)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
|
||||
/>
|
||||
write
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Expires"
|
||||
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)}
|
||||
onChange={(e) => setExpiryDays(e.target.value === "never" ? null : Number(e.target.value))}
|
||||
className={inputClass}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((o) => {
|
||||
const disabled = capDays > 0 && (o.days === null || o.days > capDays);
|
||||
return (
|
||||
<option key={o.label} value={o.days === null ? "never" : String(o.days)} disabled={disabled}>
|
||||
{o.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{createError && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={closeCreate}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={creating}>
|
||||
Create key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+39
-1
@@ -197,8 +197,22 @@ export interface Settings {
|
||||
secrets: SecretsSettings;
|
||||
workflow_log_retention_days?: number | null;
|
||||
local_login_enabled?: boolean;
|
||||
api_token_max_days?: number | null;
|
||||
}
|
||||
|
||||
export type ApiToken = {
|
||||
token_id: string;
|
||||
name: string;
|
||||
hint: string;
|
||||
role: Role;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
created_at: string;
|
||||
last_used_at?: string | null;
|
||||
user_id: string;
|
||||
user_email?: string;
|
||||
};
|
||||
|
||||
export interface SecretGroupSummary {
|
||||
group: string;
|
||||
key_count: number;
|
||||
@@ -683,7 +697,12 @@ export const api = {
|
||||
return request<Settings>("/settings");
|
||||
},
|
||||
|
||||
saveSettings(settings: { alerts: AlertSettings; workflow_log_retention_days?: number | null; local_login_enabled?: boolean }): Promise<{ saved: boolean }> {
|
||||
saveSettings(settings: {
|
||||
alerts: AlertSettings;
|
||||
workflow_log_retention_days?: number | null;
|
||||
local_login_enabled?: boolean;
|
||||
api_token_max_days?: number | null;
|
||||
}): Promise<{ saved: boolean }> {
|
||||
return request<{ saved: boolean }>("/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
@@ -694,6 +713,25 @@ export const api = {
|
||||
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
|
||||
},
|
||||
|
||||
listApiTokens(all = false): Promise<{ tokens: ApiToken[]; all: boolean }> {
|
||||
return request<{ tokens: ApiToken[]; all: boolean }>(`/tokens${all ? "?all=true" : ""}`);
|
||||
},
|
||||
|
||||
listTokenScopes(): Promise<{ scopes: string[] }> {
|
||||
return request<{ scopes: string[] }>("/tokens/scopes");
|
||||
},
|
||||
|
||||
createApiToken(body: { name: string; role: Role; scopes: string[]; expires_in_days?: number | null }): Promise<{ token: string; record: ApiToken }> {
|
||||
return request<{ token: string; record: ApiToken }>("/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
revokeApiToken(tokenId: string): Promise<{ revoked: boolean }> {
|
||||
return request<{ revoked: boolean }>(`/tokens/${tokenId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
listSecretGroups(): Promise<SecretGroupSummary[]> {
|
||||
return request<SecretGroupSummary[]>("/secrets");
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ export const AUDIT_CATEGORIES: { value: string; label: string }[] = [
|
||||
{ value: "updates", label: "OS updates" },
|
||||
{ value: "auth_provider", label: "Single sign-on" },
|
||||
{ value: "settings", label: "Settings" },
|
||||
{ value: "token", label: "API tokens" },
|
||||
{ value: "license", label: "Licence" },
|
||||
{ value: "instance", label: "Instance" },
|
||||
];
|
||||
@@ -98,6 +99,10 @@ const OVERRIDES: Record<string, string> = {
|
||||
"instance.reaped": "Instance deleted",
|
||||
"vuln.rescan": "Rescan requested",
|
||||
"workload.logs_read": "Workload logs read",
|
||||
"token.created": "API token created",
|
||||
"token.revoked": "API token revoked",
|
||||
"token.expired_use": "Expired API token used",
|
||||
"settings.token_policy_updated": "API token policy updated",
|
||||
};
|
||||
|
||||
export interface AuditEventDisplay {
|
||||
|
||||
@@ -8,6 +8,20 @@ const apiUrl = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http:/
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
// The API keys page briefly lived at /api-keys, which every
|
||||
// raw-prefix proxy in front of this app captures with its /api
|
||||
// rule — nginx's `location /api` matches /api-keys, so the
|
||||
// request reached the Go server and 404'd. The page is at
|
||||
// /tokens now precisely because that cannot happen to it.
|
||||
source: "/api-keys",
|
||||
destination: "/tokens",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user