Compare commits
45
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 | ||
|
|
449684ceaa | ||
|
|
cdc50b7aaf | ||
|
|
f534b74066 | ||
|
|
3c15ee15ef | ||
|
|
45a4f968d6 | ||
|
|
df09e42cf2 | ||
|
|
ee427ed6e1 | ||
|
|
5856deede3 | ||
|
|
e02b263054 | ||
|
|
1d6c89c368 | ||
|
|
0edfddb710 | ||
|
|
71a4f53bed | ||
|
|
de7350fce9 | ||
|
|
21786fa1a8 |
@@ -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.
|
||||
@@ -551,6 +605,20 @@ The control plane refuses to change an `hq`-sourced user's role or delete it
|
||||
the portal, but the API is the boundary; the UI is a courtesy. There is no local
|
||||
password-change endpoint at all, so there is no competing writer for the hash.
|
||||
|
||||
**A rename moves the host, and the licence does not care.** `PUT
|
||||
/api/instances/:id/name` re-derives the slug from the new name through
|
||||
`provision.RenameSlug` — the same rules that named the instance at creation —
|
||||
and writes the control plane first, because `instances.slug`'s unique index is
|
||||
what settles a race between two accounts reaching for one name. A taken slug is
|
||||
a refusal, not an `acme-2`: creation appends a counter because any free slug
|
||||
will do, and a rename is a request for one specific host. A licence binds the
|
||||
instance UUID, so nothing is reissued and Paddle is not called. The old host
|
||||
keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot
|
||||
reach into), and `km_session` is host-only, so the customer signs in again on
|
||||
the new address — the portal says so rather than redirecting them into a login
|
||||
screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at`
|
||||
because it is admin's policy; staff bypass it and must not write the field.
|
||||
|
||||
---
|
||||
|
||||
## Auth and Orgs
|
||||
@@ -661,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.
|
||||
@@ -692,6 +762,7 @@ GET /account # account, instances, max_relinks
|
||||
POST /instances # create a cloud instance (Free tier, one Free per account per deployment)
|
||||
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
|
||||
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
|
||||
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/entitlement
|
||||
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
|
||||
@@ -717,6 +788,7 @@ Staff-session (`/api/staff`):
|
||||
GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID
|
||||
GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state
|
||||
POST /instances/:id/issue · /instances/:id/relink
|
||||
PUT /instances/:id/name # rename any instance, no cooldown
|
||||
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
|
||||
GET,PUT /catalogue
|
||||
GET,PUT /instances/:id/entitlement
|
||||
@@ -733,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/`.
|
||||
|
||||
@@ -752,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.
|
||||
|
||||
@@ -949,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// ownedInstance resolves an instance and confirms the session's account owns it.
|
||||
@@ -538,6 +540,185 @@ func claimFree(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, lic)
|
||||
}
|
||||
|
||||
// renameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// The control plane is written FIRST, because instances.slug carries the unique
|
||||
// index and that index is what actually settles a race between two accounts
|
||||
// reaching for the same name. Admin's own row follows; if that write fails the
|
||||
// control plane is put back, because HQ printing a host that is not the host is
|
||||
// worse than a failed rename.
|
||||
//
|
||||
// No licence is issued and Paddle is not called: a licence binds the instance
|
||||
// UUID, and a rename does not change it.
|
||||
func renameInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
if inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// The unwind and the audit write run on a context detached from the request.
|
||||
// The commonest reason the admin-side write fails at all is the caller
|
||||
// walking away, and an unwind sharing that context fails with it — leaving
|
||||
// the control plane renamed and admin's row not, which is the exact
|
||||
// divergence this handler is arranged to prevent.
|
||||
//
|
||||
// Only the cancellation is detached here; each deadline is derived at its use
|
||||
// site below. A deadline started before the forward work is a deadline the
|
||||
// unwind may never get to use — a control plane slow enough to make the admin
|
||||
// write fail is exactly the one that would have spent it already.
|
||||
detached := context.WithoutCancel(ctx)
|
||||
|
||||
// Claim the cooldown atomically BEFORE the control-plane call. Checking it
|
||||
// and then acting lets two parallel PUTs both pass the check and then
|
||||
// interleave their two-database writes, which ends with the two databases
|
||||
// disagreeing about the host — a worse outcome than either rename losing.
|
||||
// The conditional update IS the cooldown; there is no second reading of it.
|
||||
now := time.Now().UTC()
|
||||
var claimed models.Instance
|
||||
err := db.Admin("admin_instances").FindOneAndUpdate(ctx,
|
||||
bson.M{
|
||||
"instance_id": inst.InstanceID,
|
||||
"account_id": inst.AccountID,
|
||||
"$or": []bson.M{
|
||||
{"renamed_at": bson.M{"$exists": false}},
|
||||
{"renamed_at": bson.M{"$lte": now.Add(-models.RenameCooldown)}},
|
||||
},
|
||||
},
|
||||
bson.M{"$set": bson.M{"renamed_at": now}}).Decode(&claimed)
|
||||
if err != nil {
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
log.Printf("renameInstance: claiming the cooldown on %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
// No match means the cooldown is live or the row has gone; only a
|
||||
// re-read tells those apart, and they are different answers.
|
||||
var cur models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "account_id": inst.AccountID}).Decode(&cur); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if cur.RenamedAt != nil {
|
||||
until := cur.RenamedAt.Add(models.RenameCooldown)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
|
||||
"retry_after": until,
|
||||
})
|
||||
return
|
||||
}
|
||||
// The row is here and its cooldown is spent, yet the claim matched
|
||||
// nothing: it changed under us. Nothing has been written, so refuse
|
||||
// rather than guess which way.
|
||||
log.Printf("renameInstance: cooldown claim on %s matched nothing against an eligible row", inst.InstanceID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
// releaseClaim puts renamed_at back to whatever the claim overwrote — the
|
||||
// previous instant, or absent when there was none. Every failure past the
|
||||
// claim owes the customer their rename back.
|
||||
releaseClaim := func(after string) {
|
||||
undo := bson.M{"$unset": bson.M{"renamed_at": ""}}
|
||||
if claimed.RenamedAt != nil {
|
||||
undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
|
||||
}
|
||||
rcCtx, cancel := context.WithTimeout(detached, 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(rcCtx,
|
||||
bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
|
||||
log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
|
||||
}
|
||||
}
|
||||
|
||||
renamed, prevName, prevSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
releaseClaim("a taken slug")
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
releaseClaim("a rejected name")
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
releaseClaim("a failed control-plane rename")
|
||||
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
// A matched count of zero is a silent version of the same failure: the
|
||||
// control plane moved and admin's row did not.
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{"name": renamed.Name, "slug": renamed.Slug}})
|
||||
if err == nil && res.MatchedCount == 0 {
|
||||
err = errors.New("admin_instances row matched nothing")
|
||||
}
|
||||
if err != nil {
|
||||
// The control plane's own previous values, not admin's copy: admin's may
|
||||
// be stale, and its slug is omitempty.
|
||||
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
|
||||
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
rbCancel()
|
||||
releaseClaim("a failed record write")
|
||||
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
if renamed.Slug == prevSlug {
|
||||
// The cooldown exists because a rename moves the DNS host; a cosmetic
|
||||
// edit that derives to the same slug moves nothing, so it should not
|
||||
// spend one. The claim is already written by this point — releasing it
|
||||
// is how that is expressed now the check is atomic.
|
||||
releaseClaim("a rename that did not move the host")
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
audit.Write(auCtx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
|
||||
auCancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance_id": inst.InstanceID,
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
// The same builder the licence emails use, rather than a second opinion
|
||||
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
|
||||
"login_url": loginURLFor(renamed.Slug),
|
||||
})
|
||||
}
|
||||
|
||||
// deliver sends a freshly issued licence where it needs to go. Cloud instances
|
||||
// are injected; self-hosted customers are emailed and can download.
|
||||
//
|
||||
|
||||
@@ -75,6 +75,11 @@ func Routes(cfg config.Config) http.Handler {
|
||||
cust.POST("/instances/:id/claim-free",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimFree)
|
||||
// Renaming moves the instance's DNS host, so it is owner-or-admin like
|
||||
// every other instance mutation. Cloud only; the handler refuses the rest.
|
||||
cust.PUT("/instances/:id/name",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
renameInstance)
|
||||
cust.GET("/instances/:id/entitlement", getEntitlement)
|
||||
cust.GET("/checkout/options", checkoutOptions)
|
||||
// Paid self-hosted: links (or reuses) the customer's real install UUID so
|
||||
@@ -118,6 +123,7 @@ func Routes(cfg config.Config) http.Handler {
|
||||
staff.GET("/subscriptions", staffListSubscriptions)
|
||||
staff.POST("/instances/:id/issue", staffIssue)
|
||||
staff.POST("/instances/:id/relink", staffRelink)
|
||||
staff.PUT("/instances/:id/name", staffRenameInstance)
|
||||
staff.GET("/licenses", staffListLicenses)
|
||||
staff.GET("/plans", staffListPlans)
|
||||
// Plans are keyed on the pair now, so the path is too. A single :tier
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -618,3 +623,106 @@ func staffCreateAccountUser(c *gin.Context) {
|
||||
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
|
||||
// staffRenameInstance renames any instance, with no cooldown.
|
||||
//
|
||||
// It does NOT write renamed_at: a staff rename must not start the customer's
|
||||
// 24h clock, or fixing a name for someone locks them out of fixing it further.
|
||||
//
|
||||
// On self-hosted it changes admin's label only. There is no control-plane row to
|
||||
// write — the install is the customer's — and no slug, because self-hosted has
|
||||
// no tenant subdomain.
|
||||
//
|
||||
// A cloud placeholder is refused outright rather than relabelled: it has no
|
||||
// control-plane row yet, so a label-only rename here would be a name that the
|
||||
// instance never gets when provisioning finally derives its slug from the
|
||||
// checkout's name. The customer endpoint refuses it for the same reason.
|
||||
func staffRenameInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if inst.Deployment == license.DeploymentCloud && inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
// The unwind and the audit write must survive the request being cancelled:
|
||||
// an unwind on a dead context leaves the two databases disagreeing, which is
|
||||
// the failure the unwind exists for.
|
||||
//
|
||||
// Only the cancellation is detached here; each deadline is derived at its use
|
||||
// site below. A deadline started before the forward work is a deadline the
|
||||
// unwind may never get to use — a control plane slow enough to make the admin
|
||||
// write fail is exactly the one that would have spent it already.
|
||||
detached := context.WithoutCancel(ctx)
|
||||
|
||||
set := bson.M{"name": name}
|
||||
slug := inst.Slug
|
||||
cloud := inst.Deployment == license.DeploymentCloud
|
||||
// The control plane's own previous values, not admin's copy: admin's may be
|
||||
// stale, and its slug is omitempty, so unwinding from it can write an empty
|
||||
// slug into instances.
|
||||
prevName, prevSlug := inst.Name, inst.Slug
|
||||
|
||||
if cloud {
|
||||
renamed, pName, pSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
prevName, prevSlug = pName, pSlug
|
||||
slug = renamed.Slug
|
||||
set["slug"] = renamed.Slug
|
||||
}
|
||||
|
||||
// A matched count of zero is the same failure quietly: the control plane
|
||||
// moved and admin's row did not.
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set})
|
||||
if err == nil && res.MatchedCount == 0 {
|
||||
err = errors.New("admin_instances row matched nothing")
|
||||
}
|
||||
if err != nil {
|
||||
if cloud {
|
||||
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
|
||||
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
rbCancel()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
audit.Write(auCtx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
|
||||
auCancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
|
||||
}
|
||||
|
||||
@@ -202,3 +202,23 @@ func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User,
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// RenameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// It writes `instances` and nothing else, so admin's control-plane write
|
||||
// boundary is unchanged. It issues no licence: a licence binds the instance
|
||||
// UUID, which a rename never touches.
|
||||
//
|
||||
// The previous name and slug come back with the result because they are what an
|
||||
// unwind must restore — admin's own copy can be stale, or slugless.
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (inst *sharedmodels.Instance, prevName, prevSlug string, err error) {
|
||||
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
|
||||
// caller unwinding a rename whose admin-side write failed. Leaving the two
|
||||
// databases disagreeing would have HQ print a host that is not the host.
|
||||
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
|
||||
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,15 @@ const GracePeriod = 3 * 24 * time.Hour
|
||||
// second mechanism.
|
||||
const RenewWindow = 7 * 24 * time.Hour
|
||||
|
||||
// RenameCooldown is how long a customer must wait between renames of one
|
||||
// instance.
|
||||
//
|
||||
// A rename moves the instance's DNS host and invalidates every saved link to it,
|
||||
// so this exists to make that a considered act rather than a slider. Staff are
|
||||
// not subject to it: a support conversation about a name is already a human
|
||||
// deciding.
|
||||
const RenameCooldown = 24 * time.Hour
|
||||
|
||||
type Account struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
@@ -137,7 +146,12 @@ type Instance struct {
|
||||
Status string `bson:"status" json:"status"`
|
||||
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
|
||||
RelinkCount int `bson:"relink_count" json:"relink_count"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
// RenamedAt is when this instance last changed name, and backs the customer
|
||||
// rename cooldown. It is a pointer because absent means "never renamed"; a
|
||||
// zero time.Time would read as year 1 — an inert cooldown, but only by
|
||||
// accident. Staff renames deliberately leave it alone.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
// NoticesSent holds the lifecycle notice keys already emailed for the
|
||||
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
|
||||
// clears it, so the next term starts the sequence again. It is what stops a
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { LicenceDelivery } from "@/components/LicenceDelivery";
|
||||
import { MembersPanel } from "@/components/MembersPanel";
|
||||
import { RelinkPanel } from "@/components/RelinkPanel";
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { TermBar } from "@/components/TermBar";
|
||||
import { EmptyState, Note, Panel } from "@/components/Panel";
|
||||
@@ -17,6 +18,7 @@ import { PageHeader } from "@/components/PageHeader";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** One key/value row. The key is the same keyed idiom as everywhere else. */
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
@@ -65,6 +67,10 @@ export default function InstancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [relinkError, setRelinkError] = useState<string | undefined>();
|
||||
|
||||
// useSession is the app's one way to ask who the caller is — it shares the
|
||||
// ["me"] query, so this adds no request.
|
||||
const { session } = useSession();
|
||||
|
||||
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
const licence = useQuery({
|
||||
queryKey: ["license", id],
|
||||
@@ -95,6 +101,7 @@ export default function InstancePage() {
|
||||
const lic = licence.data;
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
|
||||
const maxRelinks = account.data?.max_relinks ?? 3;
|
||||
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
|
||||
|
||||
@@ -194,6 +201,38 @@ export default function InstancePage() {
|
||||
|
||||
{cloud && <MembersPanel instanceId={instance.instance_id} />}
|
||||
|
||||
{/*
|
||||
* Address rather than "Rename": the panel is about where this
|
||||
* instance lives, and the rename is how you change it. Cloud
|
||||
* only — a self-hosted install has no tenant subdomain for us to
|
||||
* move.
|
||||
*/}
|
||||
{cloud && mayRename && (
|
||||
<Panel title="Address" meta={host ?? undefined}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
|
||||
one, so saved links and bookmarks to it stop working.
|
||||
</p>
|
||||
{/*
|
||||
* Keyed on the instance: this element stays mounted
|
||||
* across a navigation between two instance pages, so
|
||||
* without a key the success note and the typed name
|
||||
* from one instance surface on the next.
|
||||
*/}
|
||||
<RenamePanel
|
||||
key={instance.instance_id}
|
||||
movesHost
|
||||
currentName={instance.name}
|
||||
currentSlug={instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.renameInstance(instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* "Moves" rather than "Relinks": the count is rationed, so the
|
||||
* headline is how many are left, and the panel explains what
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Panel } from "@/components/Panel";
|
||||
import { licenceState } from "@/lib/format";
|
||||
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
current: { label: "Control plane holds the current licence", tone: "text-valid" },
|
||||
@@ -26,6 +27,7 @@ const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
|
||||
export default function StaffInstancePage() {
|
||||
const id = String(useParams().id);
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["staff-instance", id],
|
||||
queryFn: () => api.staff.instance(id),
|
||||
@@ -36,6 +38,12 @@ export default function StaffInstancePage() {
|
||||
|
||||
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
|
||||
const current = data.licenses.find((l) => !l.superseded_by);
|
||||
// A cloud placeholder has no control-plane row yet, so there is no host to
|
||||
// move and nothing to rename — the panel's wording and its control are both
|
||||
// read from this one answer rather than from the deployment alone, which is
|
||||
// how they came to contradict each other.
|
||||
const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder;
|
||||
const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
@@ -79,6 +87,38 @@ export default function StaffInstancePage() {
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</Panel>
|
||||
|
||||
{/*
|
||||
* Staff rename has no cooldown and does not start the customer's:
|
||||
* fixing a name on someone's behalf must not spend their next 24
|
||||
* hours.
|
||||
*/}
|
||||
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
|
||||
{cloudPlaceholder ? (
|
||||
// The API refuses this with a 409, so offering the control
|
||||
// would only be a form that cannot succeed.
|
||||
<p className="text-[0.85rem] text-ink-3">
|
||||
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
|
||||
</p>
|
||||
) : (
|
||||
/*
|
||||
* Keyed on the instance so a success note cannot follow staff
|
||||
* from one instance page to the next — the element stays
|
||||
* mounted across that navigation.
|
||||
*/
|
||||
<RenamePanel
|
||||
key={data.instance.instance_id}
|
||||
movesHost={movesHost}
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Field } from "./Field";
|
||||
import { Note } from "./Panel";
|
||||
import { ApiError, type RenameResult } from "@/lib/api";
|
||||
import { baseSlug, hostFor, slugError } from "@/lib/slug";
|
||||
|
||||
/*
|
||||
* The rename control, and only the control — the same shape as RelinkPanel: an
|
||||
* input that expands in place rather than a modal, because this app has no modal
|
||||
* and one action with one field does not need one.
|
||||
*
|
||||
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
|
||||
* disagree with the server; the 409 that comes back is the answer that counts.
|
||||
*
|
||||
* movesHost is what separates a rename that moves a DNS host from one that only
|
||||
* changes a label. Self-hosted instances and unprovisioned cloud placeholders
|
||||
* have no address, so every word about old links breaking and signing in again
|
||||
* is false for them — and a preview host they will never live at is worse than
|
||||
* no preview at all.
|
||||
*/
|
||||
export function RenamePanel({
|
||||
currentName,
|
||||
currentSlug,
|
||||
movesHost,
|
||||
onRename,
|
||||
}: {
|
||||
currentName: string;
|
||||
currentSlug: string;
|
||||
movesHost: boolean;
|
||||
onRename: (name: string) => Promise<RenameResult>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(currentName);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<RenameResult | undefined>();
|
||||
|
||||
const name = value.trim();
|
||||
const derived = baseSlug(name);
|
||||
const invalid = slugError(name);
|
||||
// A cosmetic edit that lands on the same slug is still a rename worth doing —
|
||||
// the name is what the customer reads. Only an empty or unchanged name is
|
||||
// nothing to submit.
|
||||
const unchanged = name === currentName.trim();
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await onRename(name);
|
||||
setDone(res);
|
||||
setOpen(false);
|
||||
// The input is prefilled with the current name, and the current name
|
||||
// is now this one. Leaving the old text in would make the next open
|
||||
// look like an edit already in progress.
|
||||
setValue(res.name);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The note sits ABOVE the control rather than replacing it. A rename is not
|
||||
// a one-shot action — a customer who mistypes the new name needs the panel
|
||||
// back, and returning early here left them with a success message and no way
|
||||
// to correct it short of a reload.
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{done &&
|
||||
(movesHost ? (
|
||||
<Note tone="warn">
|
||||
<span className="grid gap-2">
|
||||
<span>
|
||||
This instance is now <strong>{done.name}</strong>, at{" "}
|
||||
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and
|
||||
your sign-in does not follow it — you will need to sign in again there.
|
||||
</span>
|
||||
<a
|
||||
href={done.login_url || `https://${hostFor(done.slug)}`}
|
||||
className="justify-self-start font-mono text-[0.78rem] text-accent underline"
|
||||
>
|
||||
Open {hostFor(done.slug)} →
|
||||
</a>
|
||||
</span>
|
||||
</Note>
|
||||
) : (
|
||||
<Note tone="warn">
|
||||
This instance is now <strong>{done.name}</strong>.
|
||||
</Note>
|
||||
))}
|
||||
{open && (
|
||||
<Field
|
||||
label="Instance name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error ?? (name ? invalid : undefined)}
|
||||
hint={
|
||||
movesHost && name && !invalid ? (
|
||||
<>
|
||||
Moves to <span className="font-mono">{hostFor(derived)}</span>
|
||||
{derived === currentSlug && " — the address does not change"}
|
||||
</>
|
||||
) : (
|
||||
"Letters and digits; everything else becomes a hyphen."
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
|
||||
onClick={() => (open ? submit() : setOpen(true))}
|
||||
>
|
||||
{busy ? "Renaming…" : "Rename instance"}
|
||||
</Button>
|
||||
{open && movesHost && (
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,9 @@ export interface Instance {
|
||||
status: InstanceStatus;
|
||||
current_license?: string;
|
||||
relink_count: number;
|
||||
/** Cloud only, and only until the paid checkout provisions the real row. */
|
||||
placeholder?: boolean;
|
||||
renamed_at?: string;
|
||||
inject_failed_at?: string | null;
|
||||
notices_sent?: string[];
|
||||
created_at: string;
|
||||
@@ -288,6 +291,14 @@ export interface StaffInstanceResponse {
|
||||
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
|
||||
}
|
||||
|
||||
export interface RenameResult {
|
||||
instance_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
/** Empty when APP_LOGIN_URL is unset on the server. */
|
||||
login_url?: string;
|
||||
}
|
||||
|
||||
// --- calls ---------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
@@ -306,6 +317,8 @@ export const api = {
|
||||
post<Instance>("/api/instances/link", { instance_id, name }),
|
||||
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
|
||||
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/instances/${id}/name`, { name }),
|
||||
// Self-hosted Free: issue the licence on an already-linked instance.
|
||||
claimFree: (id: string) => post<License>(`/api/instances/${id}/claim-free`, {}),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
@@ -367,6 +380,8 @@ export const api = {
|
||||
post<License>(`/api/staff/instances/${id}/issue`, payload),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
|
||||
licenses: (params?: Record<string, string>) =>
|
||||
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
||||
plans: () => req<Plan[]>("/api/staff/plans"),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the
|
||||
* host a rename would move an instance to while the customer types.
|
||||
*
|
||||
* It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it
|
||||
* must change in the same commit as the Go one — the same hazard as
|
||||
* web/lib/targets.ts. The preview is a courtesy; the server's 409 is the
|
||||
* boundary, and the two are allowed to disagree without anything breaking.
|
||||
*/
|
||||
|
||||
/** Mirrors provision.MinSlugLength / MaxSlugLength. */
|
||||
export const MIN_SLUG_LENGTH = 3;
|
||||
export const MAX_SLUG_LENGTH = 40;
|
||||
|
||||
/** Mirrors provision.ReservedSlugs. */
|
||||
const RESERVED = new Set([
|
||||
"www", "api", "app", "admin", "auth",
|
||||
"install", "static", "_next", "default",
|
||||
]);
|
||||
|
||||
/*
|
||||
* The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the
|
||||
* customer instance page; those predate this file and are left alone rather than
|
||||
* refactored under a rename change.
|
||||
*/
|
||||
export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk";
|
||||
|
||||
/** Mirrors provision.Slugify. */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Mirrors provision.BaseSlug's truncation. */
|
||||
export function baseSlug(name: string): string {
|
||||
return slugify(name).slice(0, MAX_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
/** The reason a name cannot become a slug, or undefined when it can. */
|
||||
export function slugError(name: string): string | undefined {
|
||||
const base = slugify(name);
|
||||
if (base.length < MIN_SLUG_LENGTH) {
|
||||
return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`;
|
||||
}
|
||||
if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) {
|
||||
return "That name is reserved.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The host an instance on this slug is reached at. */
|
||||
export function hostFor(slug: string): string {
|
||||
return `${slug}.${INSTANCE_DOMAIN}`;
|
||||
}
|
||||
@@ -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,938 @@
|
||||
# Instance Rename in Vantage HQ — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let an HQ customer (owner or admin) rename a cloud instance, which re-derives its slug and moves it to a new DNS host, with staff able to do the same without the cooldown.
|
||||
|
||||
**Architecture:** Slug derivation stays in `shared/provision`, beside the create path that already owns it. Admin reaches the control plane only through `cloudprov`, writing `instances` — a collection it already writes. Admin's own row (`admin_instances`) is updated second and carries the 24h cooldown timestamp, because the cooldown is admin's policy and the control plane has no opinion about it. The portal shows the new host and asks the customer to click through; it does not redirect.
|
||||
|
||||
**Note:** This repo has no automated test suite and the user has ruled out adding test files. Every task verifies by build, vet and (Task 8) manual exercise.
|
||||
|
||||
**Tech Stack:** Go 1.x (gin, mongo-driver v2), Next.js 16 App Router + TanStack Query + Tailwind 3 (`adminsite`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-12-instance-rename-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- A licence binds an instance **UUID**, not a slug. A rename must not issue a licence, call Paddle, or touch `licenses`, `subscriptions` or `entitlements`.
|
||||
- Admin's control-plane write boundary is unchanged: `cloudprov` writes `instances` and `users` only. Do not add a write to any other control-plane collection.
|
||||
- Customer rename is **cloud only**. Self-hosted is refused with the existing `selfHostedRefusal` constant and HTTP **400**, matching `members.go`.
|
||||
- Cooldown for customers is **24 hours**, tracked by `admin_instances.renamed_at`. Staff bypass it and must **not** write `renamed_at`.
|
||||
- No `-2` suffix loop on rename. A taken slug is a refusal (`ErrSlugTaken` → HTTP 409).
|
||||
- No component in `adminsite` may carry a hex colour; use the existing token classes (`text-ink-2`, `text-ink-3`, `border-rule`, `text-accent`, `text-expired`, `bg-panel-2`).
|
||||
- The host domain used for display is `vantage.hostxtra.co.uk`, already hardcoded in `adminsite/components/InstanceRecord.tsx` and the customer instance page.
|
||||
- Commit messages follow the repo's existing style: `feat: Sentence case summary` / `fix: …` / `docs: …`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Slug derivation and the control-plane rename
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/provision/instance.go`
|
||||
**Interfaces:**
|
||||
- Consumes: `BaseSlug(name string) (string, error)`, `ErrNameRejected` — both already in `shared/provision`.
|
||||
- Produces:
|
||||
- `provision.ErrSlugTaken` (`error`)
|
||||
- `provision.RenameSlug(name, currentSlug string) (string, error)`
|
||||
- `provision.RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)`
|
||||
- `provision.RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error`
|
||||
|
||||
Behaviour `RenameSlug` must have, verified by reading rather than by test (this
|
||||
repo has no Go test suite and the user has ruled out adding one):
|
||||
|
||||
| Input name | Current slug | Result |
|
||||
|---|---|---|
|
||||
| `Acme Ltd` | `acme` | `acme-ltd` |
|
||||
| `ACME!` | `acme` | `acme` — still derives to the current slug, so not a move |
|
||||
| `Acme` | `acme-2` | `acme` — a creation-time collision suffix derives from no name, so moving off it is a real move |
|
||||
| `ab` | any | `ErrNameRejected` |
|
||||
| `Admin` | any | `ErrNameRejected` (reserved) |
|
||||
| `!!!` | any | `ErrNameRejected` |
|
||||
| 50 `a`s | any | truncated to `MaxSlugLength`, exactly as `BaseSlug` truncates on create |
|
||||
|
||||
- [ ] **Step 1: Write the implementation**
|
||||
|
||||
Append to `shared/provision/instance.go`:
|
||||
|
||||
```go
|
||||
// ErrSlugTaken means the slug a new name derives to already belongs to another
|
||||
// instance.
|
||||
//
|
||||
// Rename refuses rather than appending a counter the way creation does. Creation
|
||||
// appends because the customer is waiting on an instance and any free slug will
|
||||
// do; a rename is a request for one specific host, and silently landing them on
|
||||
// "acme-2" answers a question they did not ask.
|
||||
var ErrSlugTaken = errors.New("slug taken")
|
||||
|
||||
// RenameSlug derives the slug a rename to name would move an instance to, given
|
||||
// the slug it holds now.
|
||||
//
|
||||
// It returns the current slug unchanged when the name still derives to it, so a
|
||||
// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move
|
||||
// and cannot collide with the instance's own slug.
|
||||
func RenameSlug(name, currentSlug string) (string, error) {
|
||||
base, err := BaseSlug(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
if base == currentSlug {
|
||||
return currentSlug, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// RenameInstance changes an instance's name and re-derives its slug from it.
|
||||
//
|
||||
// The count-then-update is racy on its own, and is safe for the same reason
|
||||
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
|
||||
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
|
||||
// to retry with — the caller asked for one specific name — so it becomes
|
||||
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
|
||||
// index.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) {
|
||||
var inst models.Instance
|
||||
if err := db.Collection("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slug, err := RenameSlug(name, inst.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if slug != inst.Slug {
|
||||
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{
|
||||
"slug": slug,
|
||||
"instance_id": bson.M{"$ne": instanceID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inst.Name = name
|
||||
inst.Slug = slug
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename
|
||||
// whose caller-side bookkeeping then failed.
|
||||
//
|
||||
// It derives nothing. The values being restored may include a creation-time
|
||||
// collision suffix that no name derives to, so re-running RenameInstance with the
|
||||
// old name would not reproduce them.
|
||||
func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error {
|
||||
_, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and vet**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./shared/... && go vet ./shared/provision/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add shared/provision/instance.go
|
||||
git commit -m "feat: Add instance rename to shared provisioning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Admin's row and the cloudprov wrappers
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/models/models.go` (the `Instance` struct, ~line 129; constants block near `RenewWindow`, ~line 105)
|
||||
- Modify: `admin/internal/cloudprov/cloudprov.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `provision.RenameInstance`, `provision.RestoreInstanceIdentity` (Task 1).
|
||||
- Produces:
|
||||
- `models.RenameCooldown` (`time.Duration`)
|
||||
- `models.Instance.RenamedAt *time.Time` (bson `renamed_at`, json `renamed_at`)
|
||||
- `cloudprov.RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)`
|
||||
- `cloudprov.RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error`
|
||||
|
||||
- [ ] **Step 1: Add the cooldown constant**
|
||||
|
||||
In `admin/internal/models/models.go`, directly beneath the `RenewWindow` block:
|
||||
|
||||
```go
|
||||
// RenameCooldown is how long a customer must wait between renames of one
|
||||
// instance.
|
||||
//
|
||||
// A rename moves the instance's DNS host and invalidates every saved link to it,
|
||||
// so this exists to make that a considered act rather than a slider. Staff are
|
||||
// not subject to it: a support conversation about a name is already a human
|
||||
// deciding.
|
||||
const RenameCooldown = 24 * time.Hour
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the field to `Instance`**
|
||||
|
||||
In the same file, inside the `Instance` struct, after `RelinkCount`:
|
||||
|
||||
```go
|
||||
// RenamedAt is when this instance last changed name, and backs the customer
|
||||
// rename cooldown. It is a pointer because absent means "never renamed"; a
|
||||
// zero time.Time would read as year 1 — an inert cooldown, but only by
|
||||
// accident. Staff renames deliberately leave it alone.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the cloudprov wrappers**
|
||||
|
||||
Append to `admin/internal/cloudprov/cloudprov.go`:
|
||||
|
||||
```go
|
||||
// RenameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// It writes `instances` and nothing else, so admin's control-plane write
|
||||
// boundary is unchanged. It issues no licence: a licence binds the instance
|
||||
// UUID, which a rename never touches.
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error) {
|
||||
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
|
||||
// caller unwinding a rename whose admin-side write failed. Leaving the two
|
||||
// databases disagreeing would have HQ print a host that is not the host.
|
||||
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
|
||||
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... ./shared/...`
|
||||
Expected: clean build, no output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/models/models.go admin/internal/cloudprov/cloudprov.go
|
||||
git commit -m "feat: Add rename cooldown field and cloudprov rename"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Customer rename endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/api/customer.go` (add handler; `loginURLFor` at ~line 442 is already in this file)
|
||||
- Modify: `admin/internal/api/routes.go` (~line 77, beside the other `/instances/:id/*` customer routes)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ownedInstance(c, id) (*models.Instance, bool)`, `selfHostedRefusal` (`members.go`), `loginURLFor(slug) string`, `cloudprov.RenameInstance`, `cloudprov.RestoreInstanceIdentity`, `models.RenameCooldown`, `provision.ErrSlugTaken`, `provision.ErrNameRejected`.
|
||||
- Produces: `PUT /api/instances/:id/name` returning `{instance_id, name, slug, login_url}`.
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Append to `admin/internal/api/customer.go`:
|
||||
|
||||
```go
|
||||
// renameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// The control plane is written FIRST, because instances.slug carries the unique
|
||||
// index and that index is what actually settles a race between two accounts
|
||||
// reaching for the same name. Admin's own row follows; if that write fails the
|
||||
// control plane is put back, because HQ printing a host that is not the host is
|
||||
// worse than a failed rename.
|
||||
//
|
||||
// No licence is issued and Paddle is not called: a licence binds the instance
|
||||
// UUID, and a rename does not change it.
|
||||
func renameInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
if inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if inst.RenamedAt != nil {
|
||||
if until := inst.RenamedAt.Add(models.RenameCooldown); time.Now().UTC().Before(until) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
|
||||
"retry_after": until,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
"renamed_at": time.Now().UTC(),
|
||||
}}); err != nil {
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
|
||||
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: inst.Slug + " -> " + renamed.Slug, IP: c.ClientIP()})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance_id": inst.InstanceID,
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
// The same builder the licence emails use, rather than a second opinion
|
||||
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
|
||||
"login_url": loginURLFor(renamed.Slug),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Check the imports**
|
||||
|
||||
`customer.go` must import `errors`, `fmt`, `log`, `net/http`, `strings`, `time`, `audit`, `auth`, `cloudprov`, `db`, `models`, `license`, `provision`, `gin`, `bson`. Most are already there — add only what the compiler asks for. `provision` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision`; `license` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`.
|
||||
|
||||
- [ ] **Step 3: Mount the route**
|
||||
|
||||
In `admin/internal/api/routes.go`, in the `cust` group beside the other instance routes (after `cust.POST("/instances/:id/claim-free", …)`):
|
||||
|
||||
```go
|
||||
// Renaming moves the instance's DNS host, so it is owner-or-admin like
|
||||
// every other instance mutation. Cloud only; the handler refuses the rest.
|
||||
cust.PUT("/instances/:id/name",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
renameInstance)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/api/customer.go admin/internal/api/routes.go
|
||||
git commit -m "feat: Add customer instance rename endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Staff rename endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/api/staff.go`
|
||||
- Modify: `admin/internal/api/routes.go` (the `staff` group, beside `staff.POST("/instances/:id/relink", …)`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything Task 3 consumes, plus `db.Admin`.
|
||||
- Produces: `PUT /api/staff/instances/:id/name` returning `{instance_id, name, slug}`.
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Append to `admin/internal/api/staff.go`:
|
||||
|
||||
```go
|
||||
// staffRenameInstance renames any instance, with no cooldown.
|
||||
//
|
||||
// It does NOT write renamed_at: a staff rename must not start the customer's
|
||||
// 24h clock, or fixing a name for someone locks them out of fixing it further.
|
||||
//
|
||||
// On self-hosted it changes admin's label only. There is no control-plane row to
|
||||
// write — the install is the customer's — and no slug, because self-hosted has
|
||||
// no tenant subdomain.
|
||||
func staffRenameInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{"name": name}
|
||||
slug := inst.Slug
|
||||
|
||||
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
|
||||
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
slug = renamed.Slug
|
||||
set["slug"] = renamed.Slug
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
|
||||
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
|
||||
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID, Detail: inst.Slug + " -> " + slug, IP: c.ClientIP()})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
|
||||
}
|
||||
```
|
||||
|
||||
`staff.go` will need `errors`, `log`, `cloudprov` and `provision` added to its imports; `fmt`, `net/http`, `strings`, `time`, `audit`, `auth`, `db`, `models`, `license`, `bson` are already there.
|
||||
|
||||
- [ ] **Step 2: Mount the route**
|
||||
|
||||
In `routes.go`, in the `staff` group after `staff.POST("/instances/:id/relink", staffRelink)`:
|
||||
|
||||
```go
|
||||
staff.PUT("/instances/:id/name", staffRenameInstance)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/api/staff.go admin/internal/api/routes.go
|
||||
git commit -m "feat: Add staff instance rename endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `adminsite` API client and slug preview
|
||||
|
||||
**Files:**
|
||||
- Create: `adminsite/lib/slug.ts`
|
||||
- Modify: `adminsite/lib/api.ts` (the `Instance` interface ~line 123; the `api` object's instance calls ~line 305; `api.staff` ~line 360)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PUT /api/instances/:id/name`, `PUT /api/staff/instances/:id/name` (Tasks 3 and 4).
|
||||
- Produces:
|
||||
- `INSTANCE_DOMAIN`, `slugify(name: string): string`, `slugError(name: string): string | undefined` from `@/lib/slug`
|
||||
- `RenameResult` interface, `api.renameInstance(id, name): Promise<RenameResult>`, `api.staff.renameInstance(id, name): Promise<RenameResult>`
|
||||
- `Instance.renamed_at?: string`
|
||||
|
||||
- [ ] **Step 1: Create the slug mirror**
|
||||
|
||||
Create `adminsite/lib/slug.ts`:
|
||||
|
||||
```ts
|
||||
/*
|
||||
* A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the
|
||||
* host a rename would move an instance to while the customer types.
|
||||
*
|
||||
* It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it
|
||||
* must change in the same commit as the Go one — the same hazard as
|
||||
* web/lib/targets.ts. The preview is a courtesy; the server's 409 is the
|
||||
* boundary, and the two are allowed to disagree without anything breaking.
|
||||
*/
|
||||
|
||||
/** Mirrors provision.MinSlugLength / MaxSlugLength. */
|
||||
export const MIN_SLUG_LENGTH = 3;
|
||||
export const MAX_SLUG_LENGTH = 40;
|
||||
|
||||
/** Mirrors provision.ReservedSlugs. */
|
||||
const RESERVED = new Set([
|
||||
"www", "api", "app", "admin", "auth",
|
||||
"install", "static", "_next", "default",
|
||||
]);
|
||||
|
||||
/*
|
||||
* The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the
|
||||
* customer instance page; those predate this file and are left alone rather than
|
||||
* refactored under a rename change.
|
||||
*/
|
||||
export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk";
|
||||
|
||||
/** Mirrors provision.Slugify. */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Mirrors provision.BaseSlug's truncation. */
|
||||
export function baseSlug(name: string): string {
|
||||
return slugify(name).slice(0, MAX_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
/** The reason a name cannot become a slug, or undefined when it can. */
|
||||
export function slugError(name: string): string | undefined {
|
||||
const base = slugify(name);
|
||||
if (base.length < MIN_SLUG_LENGTH) {
|
||||
return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`;
|
||||
}
|
||||
if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) {
|
||||
return "That name is reserved.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The host an instance on this slug is reached at. */
|
||||
export function hostFor(slug: string): string {
|
||||
return `${slug}.${INSTANCE_DOMAIN}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the API client**
|
||||
|
||||
In `adminsite/lib/api.ts`, add `renamed_at` to `Instance` (after `relink_count`):
|
||||
|
||||
```ts
|
||||
renamed_at?: string;
|
||||
```
|
||||
|
||||
Add the response type beside the other interfaces:
|
||||
|
||||
```ts
|
||||
export interface RenameResult {
|
||||
instance_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
/** Empty when APP_LOGIN_URL is unset on the server. */
|
||||
login_url?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add the call to the `api` object, after `renewInstance`:
|
||||
|
||||
```ts
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/instances/${id}/name`, { name }),
|
||||
```
|
||||
|
||||
And to `api.staff`, after `relink`:
|
||||
|
||||
```ts
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npx tsc --noEmit`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add adminsite/lib/slug.ts adminsite/lib/api.ts
|
||||
git commit -m "feat: Add rename calls and slug preview to the HQ client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: The rename panel and the customer instance page
|
||||
|
||||
**Files:**
|
||||
- Create: `adminsite/components/RenamePanel.tsx`
|
||||
- Modify: `adminsite/app/(customer)/instances/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.renameInstance` / `api.staff.renameInstance`, `RenameResult` (Task 5); `slugError`, `baseSlug`, `hostFor` (Task 5); `Panel`, `Note` (`@/components/Panel`), `Button` (`@/components/Button`), `Field` (`@/components/Field`), `ApiError` (`@/lib/api`).
|
||||
- Produces: `RenamePanel({ currentName, currentSlug, onRename })` — a default-collapsed control; `onRename` is `(name: string) => Promise<RenameResult>`.
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `adminsite/components/RenamePanel.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Field } from "./Field";
|
||||
import { Note } from "./Panel";
|
||||
import { ApiError, type RenameResult } from "@/lib/api";
|
||||
import { baseSlug, hostFor, slugError } from "@/lib/slug";
|
||||
|
||||
/*
|
||||
* The rename control, and only the control — the same shape as RelinkPanel: an
|
||||
* input that expands in place rather than a modal, because this app has no modal
|
||||
* and one action with one field does not need one.
|
||||
*
|
||||
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
|
||||
* disagree with the server; the 409 that comes back is the answer that counts.
|
||||
*/
|
||||
export function RenamePanel({
|
||||
currentName,
|
||||
currentSlug,
|
||||
onRename,
|
||||
}: {
|
||||
currentName: string;
|
||||
currentSlug: string;
|
||||
onRename: (name: string) => Promise<RenameResult>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(currentName);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<RenameResult | undefined>();
|
||||
|
||||
const name = value.trim();
|
||||
const derived = baseSlug(name);
|
||||
const invalid = slugError(name);
|
||||
// A cosmetic edit that lands on the same slug is still a rename worth doing —
|
||||
// the name is what the customer reads. Only an empty or unchanged name is
|
||||
// nothing to submit.
|
||||
const unchanged = name === currentName.trim();
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await onRename(name);
|
||||
setDone(res);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
const host = done.login_url || `https://${hostFor(done.slug)}`;
|
||||
return (
|
||||
<Note tone="warn">
|
||||
<span className="grid gap-2">
|
||||
<span>
|
||||
This instance is now <strong>{done.name}</strong>, at{" "}
|
||||
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and your
|
||||
sign-in does not follow it — you will need to sign in again there.
|
||||
</span>
|
||||
<a href={host} className="justify-self-start font-mono text-[0.78rem] text-accent underline">
|
||||
Open {hostFor(done.slug)} →
|
||||
</a>
|
||||
</span>
|
||||
</Note>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{open && (
|
||||
<Field
|
||||
label="Instance name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error ?? (name ? invalid : undefined)}
|
||||
hint={
|
||||
name && !invalid ? (
|
||||
<>
|
||||
Moves to <span className="font-mono">{hostFor(derived)}</span>
|
||||
{derived === currentSlug && " — the address does not change"}
|
||||
</>
|
||||
) : (
|
||||
"Letters and digits; everything else becomes a hyphen."
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
|
||||
onClick={() => (open ? submit() : setOpen(true))}
|
||||
>
|
||||
{busy ? "Renaming…" : "Rename instance"}
|
||||
</Button>
|
||||
{open && (
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`Note` is `({ tone = "accent" | "warn" | "expired", children })` and renders a `<p>`, which is why the success state wraps its two lines in a `<span className="grid gap-2">` rather than block elements.
|
||||
|
||||
- [ ] **Step 2: Mount it on the customer instance page**
|
||||
|
||||
In `adminsite/app/(customer)/instances/[id]/page.tsx`:
|
||||
|
||||
Add the imports:
|
||||
|
||||
```tsx
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```tsx
|
||||
import { useSession } from "@/lib/session";
|
||||
```
|
||||
|
||||
Inside `InstancePage`, with the other hooks (hooks must precede the early returns already in this component):
|
||||
|
||||
```tsx
|
||||
// useSession is the app's one way to ask who the caller is — it shares the
|
||||
// ["me"] query, so this adds no request.
|
||||
const { session } = useSession();
|
||||
```
|
||||
|
||||
and after the `cloud` const:
|
||||
|
||||
```tsx
|
||||
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
|
||||
```
|
||||
|
||||
Then add the panel to `PageFrame`'s children, directly after the `MembersPanel` line:
|
||||
|
||||
```tsx
|
||||
{/*
|
||||
* Address rather than "Rename": the panel is about where this
|
||||
* instance lives, and the rename is how you change it. Cloud
|
||||
* only — a self-hosted install has no tenant subdomain for us to
|
||||
* move.
|
||||
*/}
|
||||
{cloud && mayRename && (
|
||||
<Panel title="Address" meta={host ?? undefined}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
|
||||
one, so saved links and bookmarks to it stop working.
|
||||
</p>
|
||||
<RenamePanel
|
||||
currentName={instance.name}
|
||||
currentSlug={instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.renameInstance(instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add adminsite/components/RenamePanel.tsx "adminsite/app/(customer)/instances/[id]/page.tsx"
|
||||
git commit -m "feat: Let a customer rename a cloud instance from HQ"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Staff instance page rename
|
||||
|
||||
**Files:**
|
||||
- Modify: `adminsite/app/(staff)/staff/instances/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RenamePanel` (Task 6), `api.staff.renameInstance` (Task 5).
|
||||
- Produces: nothing later tasks depend on.
|
||||
|
||||
- [ ] **Step 1: Add the panel**
|
||||
|
||||
In `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, add the imports:
|
||||
|
||||
```tsx
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
```
|
||||
|
||||
and, inside `StaffInstancePage`, add `const qc = useQueryClient();` at the top of the component if it is not already there (`useQueryClient` is already imported for `EntitlementSection`).
|
||||
|
||||
Add this panel after the "Licence history" panel:
|
||||
|
||||
```tsx
|
||||
{/*
|
||||
* Staff rename has no cooldown and does not start the customer's:
|
||||
* fixing a name on someone's behalf must not spend their next 24
|
||||
* hours.
|
||||
*/}
|
||||
<Panel title="Name" meta={data.instance.deployment === "cloud" ? "Moves the address" : "Label only"}>
|
||||
<RenamePanel
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add "adminsite/app/(staff)/staff/instances/[id]/page.tsx"
|
||||
git commit -m "feat: Let staff rename an instance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Documentation and end-to-end verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (the Admin REST API route list, and the `admin_instances` note under MongoDB Collections)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything above.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Update the Admin REST API route list**
|
||||
|
||||
In `CLAUDE.md`, in the customer-session block, after the `POST /instances/:id/claim-free` line:
|
||||
|
||||
```
|
||||
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
|
||||
```
|
||||
|
||||
and in the staff-session block, after `POST /instances/:id/issue · /instances/:id/relink`:
|
||||
|
||||
```
|
||||
PUT /instances/:id/name # rename any instance, no cooldown
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the design note**
|
||||
|
||||
In `CLAUDE.md`, under "Grants project, they do not federate" (admin's control-plane write boundary is described nearby), add a short paragraph:
|
||||
|
||||
```markdown
|
||||
**A rename moves the host, and the licence does not care.** `PUT
|
||||
/api/instances/:id/name` re-derives the slug from the new name through
|
||||
`provision.RenameSlug` — the same rules that named the instance at creation —
|
||||
and writes the control plane first, because `instances.slug`'s unique index is
|
||||
what settles a race between two accounts reaching for one name. A taken slug is
|
||||
a refusal, not an `acme-2`: creation appends a counter because any free slug
|
||||
will do, and a rename is a request for one specific host. A licence binds the
|
||||
instance UUID, so nothing is reissued and Paddle is not called. The old host
|
||||
keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot
|
||||
reach into), and `km_session` is host-only, so the customer signs in again on
|
||||
the new address — the portal says so rather than redirecting them into a login
|
||||
screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at`
|
||||
because it is admin's policy; staff bypass it and must not write the field.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Full build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /go-projects/vantage && go build ./... && go vet ./admin/... ./shared/... && (cd adminsite && npm run build)
|
||||
```
|
||||
Expected: all clean.
|
||||
|
||||
- [ ] **Step 4: Manual verification against a running stack**
|
||||
|
||||
Work through each and record the result:
|
||||
|
||||
1. Rename a cloud instance from `/instances/<id>` as an owner. Panel reports the new host.
|
||||
2. In Mongo: `db.instances.findOne({instance_id})` and `db.admin_instances.findOne({instance_id})` agree on `name` and `slug`; `admin_instances.renamed_at` is set.
|
||||
3. The new host serves a login page. The old host stops resolving to the instance within ~60 seconds.
|
||||
4. A second rename inside 24 hours answers `429` with the unlock time.
|
||||
5. Renaming onto a slug another instance holds answers `409` and changes neither database.
|
||||
6. `PUT /api/instances/:id/name` on a self-hosted instance answers `400` with the `selfHostedRefusal` message.
|
||||
7. `GET /api/staff/audit` shows `instance.renamed` with `old-slug -> new-slug`.
|
||||
8. Staff rename of the same instance succeeds immediately and leaves `renamed_at` unchanged.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: Document instance rename in HQ"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Refresh the knowledge graph**
|
||||
|
||||
```bash
|
||||
graphify update .
|
||||
```
|
||||
@@ -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,236 @@
|
||||
# Instance rename in Vantage HQ
|
||||
|
||||
**Date:** 2026-08-12
|
||||
**Status:** approved, not yet implemented
|
||||
|
||||
## Problem
|
||||
|
||||
A cloud instance is named once, at creation, and never again. The name is
|
||||
chosen in the first thirty seconds of a customer's relationship with the
|
||||
product — before they have decided whether this is "Acme" or "Acme
|
||||
Production" — and it is the name that becomes their DNS host, appears in every
|
||||
sign-in link and heads every page of their control plane. Today the only way to
|
||||
change it is to create a second instance and move, or to open a support ticket
|
||||
that has no tooling behind it.
|
||||
|
||||
## What a rename is
|
||||
|
||||
One customer-initiated action on a **cloud** instance: a new name, from which a
|
||||
new slug is derived, which moves the instance to a new DNS host.
|
||||
|
||||
Name and slug move together. The slug is re-derived through
|
||||
`provision.BaseSlug`, so the rules that named the instance at creation are the
|
||||
rules that rename it — the same reserved-label list, the same 3–40 character
|
||||
bound, the same `Slugify` collapse of non-alphanumeric runs. There is no
|
||||
separate slug field for the customer to edit, because two fields invite the
|
||||
state where the name says one thing and the host says another, and that
|
||||
divergence is exactly what a rename exists to fix.
|
||||
|
||||
A licence binds an instance **UUID**, not a slug. A rename therefore issues no
|
||||
licence, calls Paddle not at all, and consumes no relink. This is the property
|
||||
that makes the whole feature cheap, and it should be stated in any future change
|
||||
that tempts someone to touch the licence from this path.
|
||||
|
||||
### What breaks, deliberately
|
||||
|
||||
- **The old host stops working.** The old slug is released the moment the rename
|
||||
commits; another account may take it. Bookmarks, saved sign-in links and any
|
||||
agent install one-liner that named the web host are stale. Agents themselves
|
||||
are unaffected — they dial `GRPC_HOST`, which is not per-tenant.
|
||||
- **The old host keeps working for up to 60 seconds.** `server/internal/auth/instancehost.go`
|
||||
caches slug-to-instance lookups for 60s, and admin has no path to invalidate
|
||||
another process's memory. The released slug can be claimed by another account
|
||||
inside that window, so for up to a minute a replica still maps that host to the
|
||||
previous tenant. No data is exposed — the host/session guard rejects a session
|
||||
belonging to a different instance — but the new owner's users can briefly reach
|
||||
the old tenant's instance on their own host, and see its login page rather than
|
||||
theirs. Adding a cross-service invalidation channel for a 60-second window is
|
||||
not worth the coupling.
|
||||
- **The customer must sign in again.** `km_session` is set with no `Domain`
|
||||
attribute, so it is host-only and does not follow the instance to its new
|
||||
subdomain. The UI says so rather than letting the customer discover it.
|
||||
|
||||
## Scope
|
||||
|
||||
| | Customer (owner or admin) | Staff |
|
||||
|---|---|---|
|
||||
| Cloud instance | rename, 24h cooldown | rename, no cooldown |
|
||||
| Self-hosted instance | refused, 400 | name only; there is no slug |
|
||||
| Cloud placeholder | refused, 409 | refused, 409 |
|
||||
|
||||
Self-hosted is refused on the customer side for the same reason the member
|
||||
endpoints refuse it: there is no control-plane row to write. The install is the
|
||||
customer's, on their own database, and admin cannot reach it. Staff may still
|
||||
correct the label on admin's own row, because that label is what staff search
|
||||
by.
|
||||
|
||||
## Data flow
|
||||
|
||||
Two writes, in this order:
|
||||
|
||||
1. **Control plane `instances`** — `{name, slug}`.
|
||||
2. **Admin `admin_instances`** — `{name, slug, renamed_at}`.
|
||||
|
||||
The control plane goes first because `instances.slug` carries the unique index,
|
||||
and that index is what actually decides a race between two accounts reaching for
|
||||
the same name. Deciding it anywhere else would be guessing.
|
||||
|
||||
If the second write fails, the first is rolled back best-effort — restoring the
|
||||
previous name and slug — and the request answers 500. Leaving them divergent
|
||||
would have HQ print a host that is not the host, which is worse than a failed
|
||||
rename.
|
||||
|
||||
## Backend
|
||||
|
||||
### `shared/provision/instance.go`
|
||||
|
||||
```go
|
||||
// ErrSlugTaken means the derived slug belongs to another instance.
|
||||
var ErrSlugTaken = errors.New("slug taken")
|
||||
|
||||
// RenameInstance changes an instance's name and re-derives its slug.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)
|
||||
```
|
||||
|
||||
It lives beside `CreateInstanceWithID` so slug derivation keeps one home, and it
|
||||
behaves as that function's rules imply:
|
||||
|
||||
- `BaseSlug(name)` failures wrap `ErrNameRejected` — too short, too long,
|
||||
reserved.
|
||||
- The derived slug is compared against the instance's current one. If they are
|
||||
equal, only the name is written; a cosmetic capitalisation change is not a
|
||||
move, and must not fail on its own slug.
|
||||
- **No `-2` suffix loop.** Creation appends a counter because the customer is
|
||||
waiting on an instance and any free slug will do. A rename is a request for a
|
||||
specific host, and silently landing the customer on `acme-2` is a worse answer
|
||||
than refusing.
|
||||
- A duplicate-key error on the update surfaces as `ErrSlugTaken`, exactly as the
|
||||
create path treats it as "that slug is taken". The pre-check is a courtesy;
|
||||
the index is the boundary.
|
||||
|
||||
### `admin/internal/cloudprov`
|
||||
|
||||
```go
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)
|
||||
```
|
||||
|
||||
A thin wrapper over `provision.RenameInstance` on `db.ControlDB()`. It writes
|
||||
`instances` and nothing else, so admin's documented control-plane write boundary
|
||||
— `instances` and `users`, from `cloudprov` and `inject` only — is unchanged.
|
||||
|
||||
### `admin/internal/models`
|
||||
|
||||
`Instance` gains:
|
||||
|
||||
```go
|
||||
// RenamedAt is when this instance last changed name, and backs the 24h
|
||||
// customer cooldown. The cooldown is admin's policy, so it lives on admin's
|
||||
// row rather than in the control plane, which has no opinion about how often
|
||||
// a customer may move.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
```
|
||||
|
||||
A pointer because absent means "never renamed", and a zero `time.Time` would
|
||||
read as 1 January year 1 — far enough in the past that the cooldown is inert,
|
||||
but only by accident.
|
||||
|
||||
### `PUT /api/instances/:id/name` (customer)
|
||||
|
||||
Mounted in the `cust` group behind `auth.RequireAccountRole(owner, admin)`, and
|
||||
resolving the instance through `ownedInstance` like every other instance route,
|
||||
so another account's instance answers 404 rather than 403.
|
||||
|
||||
Body: `{"name": "..."}`, trimmed before use.
|
||||
|
||||
Refusals, in the order checked:
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| `deployment != cloud` | 400 | `selfHostedRefusal`, the same constant and status the member endpoints already answer with |
|
||||
| `placeholder` | 409 | instance is not provisioned yet |
|
||||
| within 24h of `renamed_at` | 429 | includes the UTC time it unlocks |
|
||||
| `provision.ErrNameRejected` | 422 | the wrapped reason, verbatim |
|
||||
| `provision.ErrSlugTaken` | 409 | that name is already in use |
|
||||
|
||||
Success returns `{"instance_id", "name", "slug", "login_url"}` and writes an
|
||||
audit entry `instance.renamed` with detail `<old-slug> -> <new-slug>`, so the
|
||||
history of a host is answerable from the audit log alone.
|
||||
|
||||
`login_url` comes from the existing `loginURLFor(slug)`, which fills `{slug}`
|
||||
into `APP_LOGIN_URL` — the same builder the licence emails already use, rather
|
||||
than a second opinion about how a tenant host is spelled. It is empty when
|
||||
`APP_LOGIN_URL` is unset, and the portal then falls back to the host string it
|
||||
already composes from the slug in `InstanceRecord` and the instance page.
|
||||
|
||||
### `PUT /api/staff/instances/:id/name`
|
||||
|
||||
The same core, without the cooldown, actor recorded as the staff user. On a
|
||||
self-hosted instance it updates `admin_instances.name` only and does not call
|
||||
`cloudprov`.
|
||||
|
||||
## Frontend (`adminsite`)
|
||||
|
||||
### `lib/slug.ts`
|
||||
|
||||
A TypeScript mirror of `provision.Slugify` and the length/reserved checks, used
|
||||
only to preview the resulting host while the customer types. It carries the same
|
||||
warning as `web/lib/targets.ts`: it is a second implementation and must change in
|
||||
the same commit as the Go one. The preview can disagree with the server — the
|
||||
409 is the answer that counts.
|
||||
|
||||
### `components/RenamePanel.tsx`
|
||||
|
||||
An inline panel, not a modal — `adminsite` has no modal component, and the
|
||||
codebase's idiom for a destructive-ish action with one input is `RelinkPanel`:
|
||||
a control that expands in place inside a `Panel`.
|
||||
|
||||
Prefilled with the current name. Below the input, a live line reading
|
||||
`acme-ltd.vantage.hostxtra.co.uk` as the customer types, and a note that they
|
||||
will need to sign in again on the new host. Submit is disabled while the derived
|
||||
slug is unchanged or invalid.
|
||||
|
||||
It lives in an "Address" panel on `app/(customer)/instances/[id]/page.tsx`,
|
||||
rendered only when the instance is cloud and `account_role` is `owner` or
|
||||
`admin`. The staff instance page mounts the same component against the staff
|
||||
route.
|
||||
|
||||
`InstanceRecord` on the Overview page is not touched: it stays a summary, and
|
||||
the rename is a decision that deserves the detail page.
|
||||
|
||||
### After a successful rename
|
||||
|
||||
Invalidate `["account"]`, collapse the panel, and let the page redraw with the new
|
||||
name and host. The Console rail card shows the new host, with a note:
|
||||
|
||||
> This instance now lives at `acme-ltd.vantage.hostxtra.co.uk`. You will need to
|
||||
> sign in again there.
|
||||
|
||||
**No automatic redirect.** Sending the browser to the new host lands the customer
|
||||
on a login screen with no explanation, having just lost the HQ page they were
|
||||
standing on. The link is right there; they click it when they are ready.
|
||||
|
||||
## Verification
|
||||
|
||||
The repository has no Go test suite, so verification is build plus manual
|
||||
exercise, matching existing practice:
|
||||
|
||||
- `go build ./...` in `shared` and `admin`; `npm run build` in `adminsite`.
|
||||
- Rename a cloud instance; confirm `instances` and `admin_instances` agree on
|
||||
name and slug.
|
||||
- The new host serves a login page; the old host stops resolving to the instance
|
||||
within ~60 seconds.
|
||||
- A second rename within 24 hours answers 429.
|
||||
- A rename onto an occupied slug answers 409 and changes nothing.
|
||||
- A rename attempt on a self-hosted instance from the customer portal answers
|
||||
400, the same status and constant the member endpoints already answer with.
|
||||
- The audit log carries `instance.renamed` with both slugs.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Slug aliases or redirects from the old host. The control plane resolves one
|
||||
slug per instance, and an alias table is a second identity to keep correct for
|
||||
the sake of stale bookmarks.
|
||||
- Renaming from inside the control plane's own `/settings`. HQ owns instance
|
||||
identity, the same way it owns licences and `hq`-sourced users; a second
|
||||
writer would need the same collision handling and the same cooldown.
|
||||
- Any change to the licence, subscription or Paddle line items.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -79,6 +79,99 @@ func CreateInstanceWithID(ctx context.Context, db *mongo.Database, instanceID, n
|
||||
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
|
||||
}
|
||||
|
||||
// ErrSlugTaken means the slug a new name derives to already belongs to another
|
||||
// instance.
|
||||
//
|
||||
// Rename refuses rather than appending a counter the way creation does. Creation
|
||||
// appends because the customer is waiting on an instance and any free slug will
|
||||
// do; a rename is a request for one specific host, and silently landing them on
|
||||
// "acme-2" answers a question they did not ask.
|
||||
var ErrSlugTaken = errors.New("slug taken")
|
||||
|
||||
// RenameSlug derives the slug a rename to name would move an instance to, given
|
||||
// the slug it holds now.
|
||||
//
|
||||
// It returns the current slug unchanged when the name still derives to it, so a
|
||||
// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move
|
||||
// and cannot collide with the instance's own slug.
|
||||
func RenameSlug(name, currentSlug string) (string, error) {
|
||||
base, err := BaseSlug(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
if base == currentSlug {
|
||||
return currentSlug, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// RenameInstance changes an instance's name and re-derives its slug from it.
|
||||
//
|
||||
// It returns the name and slug the control plane held BEFORE the write, and
|
||||
// those are the only correct values to unwind with. The caller's own copy of the
|
||||
// instance may be stale, and admin's copy stores slug with `omitempty`, so an
|
||||
// unwind driven from there can write an empty slug — which either mis-restores
|
||||
// the tenant host or trips the unique index against every other slugless row.
|
||||
//
|
||||
// The count-then-update is racy on its own, and is safe for the same reason
|
||||
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
|
||||
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
|
||||
// to retry with — the caller asked for one specific name — so it becomes
|
||||
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
|
||||
// index.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (inst *models.Instance, prevName, prevSlug string, err error) {
|
||||
var cur models.Instance
|
||||
if err := db.Collection("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&cur); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
prevName, prevSlug = cur.Name, cur.Slug
|
||||
|
||||
slug, err := RenameSlug(name, cur.Slug)
|
||||
if err != nil {
|
||||
return nil, prevName, prevSlug, err
|
||||
}
|
||||
|
||||
if slug != cur.Slug {
|
||||
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{
|
||||
"slug": slug,
|
||||
"instance_id": bson.M{"$ne": instanceID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, prevName, prevSlug, err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
return nil, prevName, prevSlug, err
|
||||
}
|
||||
|
||||
cur.Name = name
|
||||
cur.Slug = slug
|
||||
return &cur, prevName, prevSlug, nil
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename
|
||||
// whose caller-side bookkeeping then failed.
|
||||
//
|
||||
// It derives nothing. The values being restored may include a creation-time
|
||||
// collision suffix that no name derives to, so re-running RenameInstance with the
|
||||
// old name would not reproduce them.
|
||||
func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error {
|
||||
_, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}})
|
||||
return err
|
||||
}
|
||||
|
||||
// RollbackInstance deletes an instance that has no users.
|
||||
//
|
||||
// It refuses an instance that has users. Rollback exists to clean up a
|
||||
|
||||
@@ -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