Compare commits

...
48 Commits
Author SHA1 Message Date
mrhid6 bfe58c4cd2 fix: Fixed serverscope for secret eso endpoint
Chart Release / chart (push) Successful in 23s
Server Deploy / deploy (push) Successful in 2m13s
2026-09-09 08:57:21 +00:00
mrhid6 9d7c4b72aa fix: Fixed openapi doc
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Canceled after 2m8s
2026-09-09 08:46:25 +00:00
mrhid6 a2351c75dd feat:runscope test
Chart Release / chart (push) Successful in 23s
Server Deploy / deploy (push) Failing after 58s
2026-09-09 08:43:55 +00:00
mrhid6 3695bc9e1a feat(mcp): declare an input schema and a server-data flag for every tool 2026-09-09 08:41:16 +00:00
mrhid6 9bcec168b9 refactor(api): require every /api route to declare its server-scope status 2026-09-09 08:38:53 +00:00
mrhid6 d95f299562 feat(tokens): refuse minting mcp scopes without the MCP licence feature 2026-09-09 08:36:26 +00:00
mrhid6 ac8e957859 fix(security): scope package search, run documents, workloads and vulnerability reads to the token's tags 2026-09-09 08:36:04 +00:00
mrhid6 6a48dd5d73 fix(security): scope workflow target and monitor runner validation to the caller's tags 2026-09-09 08:33:38 +00:00
mrhid6 5fcfb40084 fix(security): scope workflow run dispatch and MCP run logs to the token's tags 2026-09-09 08:32:32 +00:00
mrhid6 705085d3c7 feat: Fixed spacing 2026-09-09 08:16:06 +00:00
mrhid6 67520c677b feat: show mcp feature row on the licence page 2026-09-09 08:09:46 +00:00
mrhid6 bb698eba8a feat: let an agent create steps, workflows and monitors, inert until a human arms them 2026-09-09 08:07:26 +00:00
mrhid6 3bdbf33f90 fix: scope monitor runner and workflow targets to the caller's fleet
GET /api/monitors and GET /api/monitors/:id returned Monitor.Runner
unfiltered; for an agent-pushed monitor that field is literally a server
ID, so a restricted token learned which out-of-scope server a monitor
runs on directly, not merely that one exists. services.RedactMonitorRunner
replaces Runner with models.RunnerRestricted when it names a server
outside the caller's scope, resolved once via the new
services.VisibleServerIDs rather than per monitor. The monitor itself is
still returned — a restricted operator may legitimately need to see that
it exists and is up or down — only the runner field goes neutral; omitting
the monitor entirely was considered and rejected as more surprising than
one field changing. Runner "server" (control-plane-run) is never
touched. The MCP list_monitors/get_monitor_status projections never had a
Runner field to begin with, so REST and the tool surface already agreed;
a comment now records why.

GET /api/workflows and GET /api/workflows/:id returned
Workflow.TargetServerIDs unfiltered — directly naming out-of-scope
servers, worse than a count. services.FilterVisibleServerIDs narrows the
list to what VisibleServerIDs admits and reports hidden (no count) when
at least one target was dropped; WorkflowResponse wraps *models.Workflow
with a scoped TargetServerIDs and a TargetsRestricted flag. TargetTags is
left untouched — the tag vocabulary is already ruled acceptable to
expose. The MCP list_workflows/get_workflow tools get the identical
treatment: list_workflows' target count is now based on the filtered ID
list, and get_workflow's workflowDetail carries the same
TargetsRestricted flag, so a model that sees a filtered target list and
then has run_workflow refuse the same workflow for out-of-scope targets
is not left concluding the refusal invented a problem the list never
mentioned.

All four routes recorded in serverScopedRoutes as true; none is
boot-enforced, for the same substring-filter reason as the key routes
added in the previous round.
2026-09-09 08:03:26 +00:00
mrhid6 e06f9d5670 fix: scope the assignment count leaked by the key list
GET /api/keys returned each key's AssignedCount as a raw
CountDocuments over every non-revoked assignment, with no scope filter —
a tag-restricted token reading the list saw a nonzero count for a key it
can see nothing assigned to in its own scope, which is enough to tell it
an assignment exists on a host it must not know about. Same class of leak
getKey's assignment-list filter closed on the detail route, surviving on
the list route through a count instead of a server object.

services.ListKeys now takes the caller's tokenScope. An unrestricted
caller (empty scope) takes the original unfiltered per-key
CountDocuments with no extra work, so the common case is not slower. A
restricted caller resolves the visible fleet once via ListServers before
the per-key loop, then counts each key's assignments with an added
server_id $in filter — one extra query total, not one per key.
ListKeys had exactly one caller (listKeys), so the parameter went there
rather than adding a second entry point.

Recorded GET /api/keys in serverScopedRoutes as true; its path, like GET
/api/keys/:id, matches none of serverTouchingRoutes' substrings, so the
entry is not boot-enforced. Deliberately did not widen the filter to
catch "keys" — that would sweep in create/delete/private-key routes with
no server data at all. The real fix for this shape of gap is the
declare-by-default inversion already recorded as a follow-up.
2026-09-09 07:54:42 +00:00
mrhid6 dc6e1b3c29 fix: widen server-scope boot check, filter out-of-scope key assignments
serverTouchingRoutes in cmd/main.go filtered on "server"/"console"/an exact
workflows-run match, which is how POST /api/keys/:id/assign reached
production with no scope check and no boot-time signal at all: its path
names neither. Widen the filter to also match ":serverId" and "assign",
and document at the filter why a substring match is the weak part of this
design — a route that acts on a server without saying so in its path stays
invisible to it — noting that inverting the model (every /api route
declares itself, with an exemption list) would be the stronger fix and is
left as a follow-up. Re-running the mechanical check against the widened
filter swept in no route beyond what serverScopedRoutes already declared.

GET /api/keys/:id also leaked out-of-scope hostnames: it returned every
assignment for a key, server attached, unfiltered by the caller's tag
restriction. getKey now drops any assignment whose server fails
services.ServerInTokenScope before returning the list — silently, so the
response carries no count of what was removed — while still returning the
key itself, since a restricted token may legitimately hold a key also
assigned outside its scope. GetAssignmentsWithServers has exactly one
caller (getKey), so the filtering is done in the handler rather than
threaded into the service. Recorded in serverScopedRoutes; its path
matches none of the filter's substrings either, so it is not boot-enforced
and is kept as a considered decision, same as the assign/revoke entries.
2026-09-09 07:51:04 +00:00
mrhid6 cbf929fe2d fix: audit refused and failed mcp write calls, scope key assignment to token
Every early return from a write-tool handler skipped both the tool's own
LogCall and transport.go's gated LogCall (which only fires for reads), so a
blocked mutation attempt left no audit trail. registerSDKTool now routes
every write-tool error through LogDenied (fan-out and tag-scope refusals,
by gate name) or LogFailure (everything else), keeping the successful-write
path logging its own resolved server count exactly as before.

Also close a live scope gap surfaced while reviewing this: POST
/api/keys/:id/assign called services.AssignKey with an unscoped GetServer
lookup, so a tag-restricted token could assign a key to a server outside
its restriction. The handler now resolves the target through
GetServerScoped first, matching its sibling revoke route, and the route is
recorded in serverScopedRoutes.
2026-09-09 07:46:45 +00:00
mrhid6 b3651ab58c feat: add mcp write tools with a fan-out guard 2026-09-09 07:28:48 +00:00
mrhid6 191a8e9074 fix(mcp): correct fleet online status, unsafe version filter, audit totals
summariseServer compared Status against "online", a value never assigned
anywhere (the real vocabulary is pending/active/offline), so every server
misreported as offline. search_fleet's version_below used a lexicographic
comparison across dpkg/rpm/apk version schemes with no common ordering, so
it refuses that filter now and returns all matches instead of a wrong
answer. listAuditResult's Total carried the "shown" JSON tag and the
capped count; it now reports the real total alongside shown.
2026-09-09 07:24:09 +00:00
mrhid6 ed79df4270 fix: restore the vantage-shared go.sum entries dropped by go mod tidy 2026-09-09 07:17:53 +00:00
mrhid6 7ec97ae8c2 feat: Removed go.mod replace
Chart Release / chart (push) Successful in 17s
Server Deploy / deploy (push) Successful in 3m9s
2026-09-08 15:10:05 +00:00
mrhid6 b96cd85e43 feat: go mod tidy
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Failing after 1m16s
2026-09-08 15:00:54 +00:00
mrhid6 971bcece44 feat: Updated openapi doc 2026-09-08 14:51:43 +00:00
mrhid6 333d729026 feat: Updated go deps
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Failing after 1m20s
2026-09-08 14:44:45 +00:00
mrhid6 8dd68e34c1 docs: mark the api keys redesign plan implemented
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m11s
2026-09-08 14:14:57 +00:00
mrhid6 e5b9894384 feat: surface the mcp endpoint and its scopes on the api keys page 2026-09-08 14:14:44 +00:00
mrhid6 14b947f791 feat: add mcp read tools for fleet, health and workflow data 2026-09-08 14:12:44 +00:00
mrhid6 98233b620c docs: fold the mcp task 12 token-form steps into the api keys redesign 2026-09-08 14:12:26 +00:00
mrhid6 2d6b5bd8a3 feat: restrict an api key to tagged servers from the create dialog 2026-09-08 14:12:10 +00:00
mrhid6 ac9cc57e7e feat: rebuild the create key dialog around a scope matrix and a preview 2026-09-08 14:10:37 +00:00
mrhid6 a0641e8ecb feat: summarise key posture above the ledger 2026-09-08 14:09:00 +00:00
mrhid6 67ac029354 feat: redesign the api key list as a ledger with lifetime bars 2026-09-08 14:07:56 +00:00
mrhid6 a0b5565a63 docs: explain why GET /api/mcp deliberately answers 405 in stateless mode 2026-09-08 14:07:54 +00:00
mrhid6 aedc388535 refactor: split the api keys panel into ledger, chips, lifetime and dialog 2026-09-08 14:06:02 +00:00
mrhid6 5e4c8afdd1 feat: model an api key's remaining lifetime as a single value 2026-09-08 14:03:49 +00:00
mrhid6 0166b17299 feat: serve the mcp endpoint behind the licence feature 2026-09-08 13:57:21 +00:00
mrhid6 674236bb76 feat: audit mcp tool calls and guard against fleet-wide fan-out 2026-09-08 13:50:28 +00:00
mrhid6 5943d98681 feat: add the mcp tool registry and its scope gates 2026-09-08 13:50:01 +00:00
mrhid6 7ea8e2fff0 fix: close out-of-scope server access in vulns, packages, run logs, and key revoke 2026-09-08 13:48:11 +00:00
mrhid6 f87986b4f7 feat: enforce token tag restrictions at the server resolution chokepoints 2026-09-08 13:43:38 +00:00
mrhid6 2481974b3a feat: carry the token tag restriction on the session 2026-09-08 13:37:05 +00:00
mrhid6 f9df426e6c feat: allow an API token to be restricted to servers by tag 2026-09-08 13:34:58 +00:00
mrhid6 e8e41f197a build: temporarily replace vantage-shared with local checkout 2026-09-08 13:31:56 +00:00
mrhid6 1f2b56ea29 Revert "chore: pick up the mcp licence feature from vantage-shared"
This reverts commit a2fe478c82.
2026-09-08 13:31:20 +00:00
mrhid6 8234bdf9f3 feat: add the mcp scope resource 2026-09-08 13:29:41 +00:00
mrhid6 a2fe478c82 chore: pick up the mcp licence feature from vantage-shared 2026-09-08 13:28:25 +00:00
mrhid6 a2eee958f0 docs: implementation plan for the mcp server, and creation tools in the spec 2026-09-08 13:24:03 +00:00
mrhid6 998e1c419d docs: design spec for the MCP server feature 2026-09-08 13:12:57 +00:00
mrhid6 d92ca7591f Rephrased secrets page
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m3s
2026-09-08 10:25:01 +00:00
70 changed files with 9417 additions and 508 deletions
@@ -0,0 +1,439 @@
<title>Vantage Key Ledger</title>
<style>
:root{
color-scheme: dark;
--ground:#071628;
--panel:#0d2138;
--panel-2:#102842;
--well:#04101f;
--ink:#e4ecf6;
--ink-2:#9fb3ca;
--ink-3:#71879f;
--rule:#1e3855;
--rule-soft:#172c44;
--accent:#5b9be8;
--accent-hover:#7fb2f0;
--accent-ink:#04101f;
--up:#4fb484;
--pend:#d6a63f;
--down:#e2705a;
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--mono:ui-monospace,"Cascadia Mono","SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--r:4px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--ground);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;font-size:14px;line-height:1.5}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 96px}
/* ---------- masthead ---------- */
.masthead{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid var(--rule)}
h1,h2,h3{font-weight:800;letter-spacing:-.03em;text-wrap:balance}
h1{font-size:26px}
.btn{font:inherit;font-weight:500;border-radius:var(--r);border:1px solid transparent;padding:9px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:8px}
.btn-primary{background:var(--accent);color:var(--accent-ink);font-weight:600}
.btn-primary:hover{background:var(--accent-hover)}
.btn-ghost{background:transparent;color:var(--ink-2);border-color:var(--rule)}
.btn-ghost:hover{color:var(--ink);border-color:var(--ink-3)}
.btn-danger{background:transparent;color:var(--down);border-color:transparent;padding:5px 8px;font-size:13px}
.btn-danger:hover{background:rgba(226,112,90,.12)}
/* ---------- posture strip: the summary before the detail ---------- */
.posture{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--rule-soft);border:1px solid var(--rule);border-radius:var(--r);margin:20px 0 24px;overflow:hidden}
.posture div{background:var(--panel);padding:12px 14px;display:flex;flex-direction:column;gap:2px}
.posture .n{font-family:var(--mono);font-size:20px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
.posture .l{font-size:12px;color:var(--ink-3)}
.n.warn{color:var(--pend)} .n.bad{color:var(--down)} .n.dim{color:var(--ink-2)}
/* ---------- filter ---------- */
.filters{display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.seg{display:inline-flex;border:1px solid var(--rule);border-radius:var(--r);overflow:hidden}
.seg button{font:inherit;font-size:13px;background:transparent;color:var(--ink-2);border:0;padding:6px 12px;cursor:pointer;white-space:nowrap}
.seg button+button{border-left:1px solid var(--rule)}
.seg button[aria-pressed="true"]{background:var(--panel-2);color:var(--ink);box-shadow:inset 0 -2px 0 var(--accent)}
.filters .spacer{flex:1}
.hint{font-size:12px;color:var(--ink-3)}
/* ---------- ledger ---------- */
.ledger{border:1px solid var(--rule);border-radius:var(--r);background:var(--panel);overflow:hidden}
.lrow{display:grid;grid-template-columns:minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto;gap:20px;align-items:center;padding:14px 18px}
.lhead{padding:9px 18px;background:var(--panel-2);border-bottom:1px solid var(--rule);font-size:11px;color:var(--ink-3);letter-spacing:.06em;font-family:var(--mono)}
.lrow+.lrow{border-top:1px solid var(--rule-soft)}
.lrow:hover{background:var(--panel-2)}
.name{display:flex;flex-direction:column;gap:3px;min-width:0}
.name b{font-weight:600}
.fingerprint{font-family:var(--mono);font-size:12px;color:var(--ink-3)}
.who{font-size:12px;color:var(--ink-3)}
.pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-family:var(--mono);border:1px solid var(--rule);border-radius:999px;padding:1px 8px;color:var(--ink-2)}
.pill.owner{border-color:rgba(91,155,232,.5);color:var(--accent)}
.pill.admin{border-color:rgba(214,166,63,.5);color:var(--pend)}
.dot{width:5px;height:5px;border-radius:50%;background:currentColor}
/* scope matrix: resource + r/w encoded as filled halves */
.scopes{display:flex;flex-wrap:wrap;gap:5px}
.scope{display:inline-flex;align-items:stretch;border:1px solid var(--rule);border-radius:3px;overflow:hidden;font-family:var(--mono);font-size:11px}
.scope span{padding:1px 6px;color:var(--ink-2)}
.scope i{font-style:normal;padding:1px 5px;border-left:1px solid var(--rule);color:var(--ink-3)}
.scope.rw i{background:rgba(91,155,232,.18);color:var(--accent)}
.scope.r i{background:rgba(159,179,202,.1)}
.scope.none{color:var(--ink-3);border-style:dashed}
/* lifetime bar: the redesign's one visual idea */
.life{display:flex;flex-direction:column;gap:5px}
.track{height:4px;border-radius:2px;background:var(--rule-soft);overflow:hidden}
.track b{display:block;height:100%;background:var(--up)}
.track b.warn{background:var(--pend)} .track b.bad{background:var(--down)} .track b.flat{background:var(--ink-3)}
.life small{font-family:var(--mono);font-size:11.5px;color:var(--ink-2);font-variant-numeric:tabular-nums}
.life small.warn{color:var(--pend)} .life small.bad{color:var(--down)}
.used{font-family:var(--mono);font-size:12px;color:var(--ink-2);font-variant-numeric:tabular-nums}
.used.never{color:var(--ink-3)}
.right{text-align:right}
/* ---------- modal ---------- */
.stage{margin-top:44px;padding-top:28px;border-top:1px dashed var(--rule)}
.stage h2{font-size:16px}
.stage p.note{color:var(--ink-3);font-size:13px;margin-top:4px;max-width:60ch}
.scrim{margin-top:18px;background:rgba(4,16,31,.72);border:1px solid var(--rule-soft);border-radius:var(--r);padding:28px 16px;display:flex;justify-content:center}
.modal{width:100%;max-width:640px;background:var(--panel);border:1px solid var(--rule);border-radius:var(--r);box-shadow:0 1px 0 rgba(0,0,0,.35),0 20px 44px -26px rgba(0,0,0,.85);overflow:hidden}
.mhead{padding:16px 20px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center}
.mhead h3{font-size:15px}
.mhead .hint{margin-top:2px}
.mbody{padding:20px;display:flex;flex-direction:column;gap:20px}
.mfoot{padding:14px 20px;border-top:1px solid var(--rule);display:flex;justify-content:flex-end;gap:8px;background:var(--panel-2)}
label.f{display:flex;flex-direction:column;gap:6px}
label.f>span{font-size:13px;font-weight:500}
label.f em{font-style:normal;font-size:12px;color:var(--ink-3);font-weight:400}
input[type=text],select{font:inherit;background:var(--well);border:1px solid var(--rule);color:var(--ink);border-radius:var(--r);padding:9px 11px;width:100%}
input[type=text]::placeholder{color:var(--ink-3)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
/* scope matrix in the modal — one grid, not 9 cards */
.matrix{border:1px solid var(--rule);border-radius:var(--r);overflow:hidden}
.mx{display:grid;grid-template-columns:1fr 64px 64px;align-items:center}
.mx.head{background:var(--panel-2);border-bottom:1px solid var(--rule);font-family:var(--mono);font-size:11px;color:var(--ink-3)}
.mx>*{padding:7px 12px}
.mx.head>*:not(:first-child),.mx>label{text-align:center}
.mx+.mx{border-top:1px solid var(--rule-soft)}
.mx>b{font-weight:500;font-size:13px}
.mx>b small{display:block;color:var(--ink-3);font-size:11.5px;font-weight:400}
.mx label{display:flex;justify-content:center;cursor:pointer}
input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);background:var(--well);cursor:pointer}
.mxfoot{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;background:var(--panel-2);border-top:1px solid var(--rule);font-size:12px;color:var(--ink-3)}
.linky{background:none;border:0;font:inherit;color:var(--accent);cursor:pointer;padding:0}
.linky:hover{color:var(--accent-hover);text-decoration:underline}
/* live preview line — what this key will be able to do, in one sentence */
.preview{background:var(--well);border:1px solid var(--rule-soft);border-radius:var(--r);padding:11px 13px;font-family:var(--mono);font-size:12px;color:var(--ink-2);line-height:1.7}
.preview b{color:var(--ink);font-weight:500}
.preview .cap{color:var(--pend)}
/* reveal panel */
.reveal{display:flex;flex-direction:column;gap:14px}
.warnbar{border:1px solid rgba(214,166,63,.35);background:rgba(214,166,63,.1);color:var(--pend);border-radius:var(--r);padding:9px 12px;font-size:13px}
.secret{display:flex;gap:0;align-items:stretch;border:1px solid var(--rule);border-radius:var(--r);overflow:hidden;background:var(--well)}
.secret code{flex:1;font-family:var(--mono);font-size:13px;padding:11px 12px;overflow-x:auto;white-space:nowrap;color:var(--ink)}
.secret button{border:0;border-left:1px solid var(--rule);background:var(--panel-2);color:var(--ink);font:inherit;font-size:13px;padding:0 16px;cursor:pointer}
.secret button:hover{background:var(--rule)}
dl.meta{display:grid;grid-template-columns:88px 1fr;gap:8px 14px;font-size:13px;align-items:baseline}
dl.meta dt{color:var(--ink-3)}
/* ---------- narrow: the ledger stops being a table ---------- */
@media (max-width:900px){
.wrap{padding:24px 16px 72px}
.masthead{align-items:flex-start;gap:16px}
.masthead>.btn{width:100%;justify-content:center}
.posture{grid-template-columns:1fr 1fr}
.two{grid-template-columns:1fr}
/* Each key becomes a stacked record. The header row is gone, so every
cell carries its own label — an unlabelled date under an unlabelled
scope list is unreadable once the columns are gone. */
.lhead{display:none}
.lrow{grid-template-columns:1fr;gap:12px;align-items:stretch;padding:16px 16px 12px;position:relative}
.lrow>[data-label]::before{content:attr(data-label);display:block;font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);margin-bottom:6px}
.name{padding-right:88px}
.scopes{overflow-x:auto;flex-wrap:nowrap;padding-bottom:2px;-webkit-overflow-scrolling:touch}
.scope{flex:0 0 auto}
.right{position:absolute;top:12px;right:12px;text-align:right}
.btn-danger{border-color:var(--rule);padding:7px 12px}
}
@media (max-width:520px){
h1{font-size:22px}
.posture{grid-template-columns:1fr}
.posture div{flex-direction:row;align-items:baseline;gap:10px}
.posture .n{font-size:16px;min-width:2ch}
/* The spacer and the hint were competing with the segment for one row,
squeezing the buttons below their own labels. Stack instead. */
.filters{flex-direction:column;align-items:stretch}
.filters .spacer{display:none}
.filters .seg{width:100%}
.filters .seg button{flex:1;white-space:nowrap;padding:9px 8px}
.scrim{padding:16px 10px}
.mbody{padding:16px;gap:16px}
.mhead,.mfoot{padding:14px 16px}
.mfoot{flex-direction:column-reverse}
.mfoot .btn{width:100%;justify-content:center}
.mx{grid-template-columns:1fr 54px 54px}
.mx>*{padding:9px 10px}
.mxfoot{flex-direction:column;align-items:flex-start;gap:6px}
/* Copy has to stay reachable without scrolling the secret sideways first. */
.secret{flex-direction:column}
.secret button{border-left:0;border-top:1px solid var(--rule);padding:11px 16px}
dl.meta{grid-template-columns:1fr;gap:3px}
dl.meta dt{margin-top:8px}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
<div class="wrap">
<header class="masthead">
<div>
<h1>API keys</h1>
</div>
<button class="btn btn-primary" onclick="document.getElementById('create').scrollIntoView({behavior:'smooth'})">Create key</button>
</header>
<section class="posture" aria-label="Key posture">
<div><span class="n">6</span><span class="l">keys in this instance</span></div>
<div><span class="n warn">2</span><span class="l">expire within 7 days</span></div>
<div><span class="n bad">1</span><span class="l">never expires</span></div>
<div><span class="n dim">2</span><span class="l">unused since issue</span></div>
</section>
<div class="filters">
<div class="seg" role="group" aria-label="Whose keys">
<button type="button" aria-pressed="false">My keys</button>
<button type="button" aria-pressed="true">All keys</button>
</div>
<div class="spacer"></div>
<span class="hint">Instance policy caps new keys at 90 days.</span>
</div>
<section class="ledger" aria-label="API keys">
<div class="lrow lhead">
<span>key / holder</span><span>scopes</span><span>lifetime</span><span>last call</span><span></span>
</div>
<div class="lrow">
<div class="name">
<b>gitea-ci-deploy</b>
<span class="fingerprint">vt_9f2c…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill admin"><span class="dot"></span>admin</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope rw"><span>workflows</span><i>rw</i></span>
<span class="scope r"><span>secrets</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b style="width:71%"></b></div>
<small>64 days left · 12 Nov</small>
</div>
<span class="used" data-label="last call">4 minutes ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>eso-cluster-prod</b>
<span class="fingerprint">vt_41ab…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>secrets</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="warn" style="width:6%"></b></div>
<small class="warn">5 days left · 13 Sep</small>
</div>
<span class="used" data-label="last call">22 minutes ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>status-page-embed</b>
<span class="fingerprint">vt_c70e…</span>
<span class="who">priya@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>status</span><i>r</i></span>
<span class="scope r"><span>monitors</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b style="width:88%"></b></div>
<small>318 days left · 23 Jul 2027</small>
</div>
<span class="used" data-label="last call">3 days ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>patch-tuesday-runner</b>
<span class="fingerprint">vt_2d55…</span>
<span class="who">ops@hostxtra.co.uk · <span class="pill owner"><span class="dot"></span>owner</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope rw"><span>vulns</span><i>rw</i></span>
<span class="scope rw"><span>workloads</span><i>rw</i></span>
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope r"><span>keys</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="flat" style="width:100%"></b></div>
<small>No expiry · issued before the 90-day cap</small>
</div>
<span class="used" data-label="last call">Yesterday</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>laptop-scratch</b>
<span class="fingerprint">vt_86f1…</span>
<span class="who">priya@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes"><span class="scope none"><span>no scopes granted</span></span></div>
<div class="life" data-label="lifetime">
<div class="track"><b class="bad" style="width:0%"></b></div>
<small class="bad">Expired 2 Sep</small>
</div>
<span class="used never" data-label="last call">Never used</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>terraform-plan-readonly</b>
<span class="fingerprint">vt_0b3d…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>servers</span><i>r</i></span>
<span class="scope r"><span>keys</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="warn" style="width:2%"></b></div>
<small class="warn">2 days left · 10 Sep</small>
</div>
<span class="used never" data-label="last call">Never used</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
</section>
<!-- ============ create modal, state 1 ============ -->
<section class="stage" id="create">
<h2>Create key</h2>
<p class="note">One dialog, three decisions in the order that matters: who the key is, what it may call, how long it lives. The preview line is the key read back as a sentence before it exists.</p>
<div class="scrim">
<div class="modal" role="dialog" aria-label="Create key">
<div class="mhead">
<div>
<h3>Create key</h3>
<p class="hint">Shown once. Copy it before you close.</p>
</div>
<span class="pill"><span class="dot"></span>your role: admin</span>
</div>
<div class="mbody">
<div class="two">
<label class="f">
<span>Name <em>what will use it</em></span>
<input type="text" value="gitea-ci-deploy">
</label>
<label class="f">
<span>Role <em>capped at yours</em></span>
<select><option>admin</option><option>member</option></select>
</label>
</div>
<div>
<label class="f" style="margin-bottom:8px"><span>Scopes <em>write already covers read</em></span></label>
<div class="matrix">
<div class="mx head"><span>resource</span><span>read</span><span>write</span></div>
<div class="mx"><b>servers<small>fleet list, inventory, agent updates</small></b><label><input type="checkbox" checked></label><label><input type="checkbox" checked></label></div>
<div class="mx"><b>workflows<small>steps, runs, logs</small></b><label><input type="checkbox" checked></label><label><input type="checkbox" checked></label></div>
<div class="mx"><b>secrets<small>vault groups and values</small></b><label><input type="checkbox" checked></label><label><input type="checkbox"></label></div>
<div class="mx"><b>keys<small>SSH keys and assignments</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mx"><b>monitors<small>checks, incidents, uptime</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mx"><b>vulns<small>findings and rescans</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mxfoot">
<span>3 of 9 resources · 5 scopes</span>
<span><button class="linky" type="button">Read-only everywhere</button> · <button class="linky" type="button">Clear all</button></span>
</div>
</div>
</div>
<label class="f">
<span>Expires <em>this instance caps new keys at 90 days</em></span>
<select><option>90 days — 7 December 2026</option><option>60 days</option><option>30 days</option><option disabled>365 days (over the cap)</option><option disabled>Never (over the cap)</option></select>
</label>
<p class="preview">
<b>gitea-ci-deploy</b> acts as <b>admin</b>, may <b>read and write</b> servers and workflows,
<b>read</b> secrets, and stops working on <b class="cap">7 December 2026</b>.
</p>
</div>
<div class="mfoot">
<button class="btn btn-ghost">Cancel</button>
<button class="btn btn-primary">Create key</button>
</div>
</div>
</div>
</section>
<!-- ============ create modal, state 2 ============ -->
<section class="stage">
<h2>After it is created</h2>
<p class="note">The secret is unrecoverable once this closes, so Copy is the primary action and the summary confirms what was granted without a second trip to the ledger.</p>
<div class="scrim">
<div class="modal" role="dialog" aria-label="Key created">
<div class="mhead">
<div>
<h3>gitea-ci-deploy is ready</h3>
<p class="hint">Vantage stores only a hash of this value.</p>
</div>
</div>
<div class="mbody reveal">
<div class="warnbar">This is the only time the key is shown. Copy it into your CI secret store now.</div>
<div class="secret">
<code>vt_9f2c4b71ae03d85f6c19bb27e4a0d3f58c62719ad4be05f3c8a1d7602b94ef11</code>
<button type="button">Copy</button>
</div>
<dl class="meta">
<dt>Role</dt><dd>admin</dd>
<dt>Scopes</dt>
<dd class="scopes">
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope rw"><span>workflows</span><i>rw</i></span>
<span class="scope r"><span>secrets</span><i>r</i></span>
</dd>
<dt>Expires</dt><dd>7 December 2026 · 90 days</dd>
<dt>Use it</dt><dd><code style="font-family:var(--mono);font-size:12px;color:var(--ink-2)">curl -H "Authorization: Bearer vt_…" https://acme.vantage.example/api/servers</code></dd>
</dl>
</div>
<div class="mfoot">
<button class="btn btn-ghost">Done</button>
<button class="btn btn-primary">Copy key</button>
</div>
</div>
</div>
</section>
<!-- ============ empty state ============ -->
<section class="stage">
<h2>When there are no keys</h2>
<div class="scrim" style="padding:40px 16px">
<div style="max-width:420px;text-align:center;display:flex;flex-direction:column;gap:10px;align-items:center">
<span style="font-family:var(--mono);font-size:13px;color:var(--ink-3);border:1px dashed var(--rule);border-radius:var(--r);padding:6px 12px">vt_ · nothing issued yet</span>
<b style="font-size:15px">You have no API keys.</b>
<p style="color:var(--ink-2);font-size:13px">Create one to call the REST API from a script or a CI job. It is scoped to what you grant it and never outranks your own role.</p>
<button class="btn btn-primary">Create your first key</button>
</div>
</div>
</section>
</div>
@@ -0,0 +1,386 @@
# API Keys Page Redesign 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:** Rebuild `/tokens` — the API keys page and its create dialog — around what an operator actually needs to decide: which credentials are about to expire, what each one can reach, and whether a new one is over-granted. The current page is a seven-column table where every column is a bare string.
**Reference mockup:** `docs/superpowers/plans/2026-09-08-api-keys-redesign-mockup.html`. Open it in a browser. It is the visual contract for this plan: the posture strip, the record layout, the lifetime bar, the scope matrix and both dialog states are all drawn there, in the app's own tokens and font stacks. Where this plan and the mockup disagree, the plan wins — the mockup carries example data and static markup, not logic.
**Tech Stack:** Next.js 16 App Router, React 18, Tailwind 3 (tokens only, no hex), TanStack Query. No new dependencies.
**Existing code:** the whole page is one 487-line file, `web/components/apikeys/ApiKeysPanel.tsx`, rendered by `web/app/(app)/tokens/page.tsx`.
## Global Constraints
- **No component may carry a hex value.** Every colour comes from the Tailwind token map (`accent`, `danger`, `warning`, `success`, `text-primary/secondary/tertiary`, `surface`, `surface-2`, `well`, `border`). This is a repository-wide rule, not a preference for this page — see the Frontend section of `CLAUDE.md`.
- **`web/` is dark only.** Do not add a light variant or a theme toggle.
- **State never reads by colour alone.** Every coloured element in the mockup also carries a text label — an amber lifetime bar always sits above the words "5 days left".
- **There is no test runner in `web/`.** Verification for each task is `npm run lint` and `npm run build` from `vantage-app/web`, plus a stated browser check. Pure logic goes in `web/lib/` so it is at least readable in isolation.
- **The API is the boundary; the UI is the courtesy.** Nothing here may be the only thing enforcing a rule. Disabled expiry options, hidden MCP scopes and the role cap are all mirrors of server behaviour that already exists.
- Conventional commits (`feat:`, `refactor:`, `fix:`), one per task.
- Branch: this work is UI-only and independent of the MCP server tasks, but it **collides with them in one file** — read the next section before starting.
## Relationship to the MCP server plan
`docs/superpowers/plans/2026-09-08-mcp-server.md` is in progress on branch `feat/mcp-server`. Tasks 15 are committed: the `mcp` scope resource exists, and `api_tokens.tag_selector` is modelled, accepted at creation (`POST /api/tokens`) and enforced at the server-resolution chokepoints. Tasks 615 are not started.
**That plan's Task 12 rewrites the same file this plan rewrites**, and its file list is stale — it names `web/app/(app)/settings/`, but the page moved to `web/app/(app)/tokens/` with its body in `web/components/apikeys/ApiKeysPanel.tsx`. Two plans editing one 500-line component from opposite ends is a guaranteed conflict.
Resolution, and it is a decision this plan makes deliberately: **this plan absorbs MCP Task 12 steps 2, 3, 4 and 5** — the tag selector field, the MCP scope gating, the tag chip in the list, and the agent access panel. They are built here, on the redesigned surfaces, because a tag selector is a field in the create dialog and a tag chip is a column in the ledger, and both are cheaper to design once than to design and then redesign.
What stays with MCP Task 12: **step 1 only**, the `Agent Access (MCP)` row on `settings/license/page.tsx`, which is a different file and a different page.
Sequencing:
- Run this plan **on `feat/mcp-server`, after MCP Task 11**, so `license.features.mcp` and the `mcp:*` scopes exist when Tasks 6 and 7 below need them; or
- run it on its own branch **stopping after Task 5**, and land Tasks 6 and 7 later once MCP merges.
Tasks 6 and 7 are written to be skippable and are marked so. Nothing in Tasks 15 depends on MCP.
> **Implemented 8 September 2026, all eight tasks, on `feat/mcp-server`.** MCP
> Tasks 69 landed on the same branch while this ran, which unblocked Task 7, so
> it was built rather than deferred. MCP Task 12 has been amended in place: its
> steps 2 and 4 are struck as done here, steps 3 and 5 point at Task 7, and only
> its step 1 (the licence-page row) remains its own work. The six browser checks
> in the final checklist are the only items left unticked — they need a running
> instance with fixture keys.
Whichever route is taken, **strike steps 25 from MCP Task 12 and leave a pointer to this plan**, so the next worker through does not build the tag picker twice.
---
### Task 1: The lifetime model
The redesign's one visual idea is that a key's expiry is a bar, not a date — how much of its issued life is left, coloured by urgency. That calculation is the only real logic on the page, so it goes in a module of its own rather than inline in a cell.
**Files:**
- Create: `web/lib/keyLifetime.ts`
- Modify: none yet
**Interfaces:**
- Produces: `keyLifetime(token, now)` returning `{ state, remainingPct, label, outsidePolicy }`, consumed by Tasks 2 and 3.
- [x] **Step 1: Write the module**
Create `web/lib/keyLifetime.ts`. It takes an `ApiToken` and returns everything a lifetime cell needs, with no JSX and no date formatting spread across components:
```ts
import type { ApiToken } from "@/lib/api";
export type LifetimeState = "healthy" | "soon" | "expired" | "eternal";
export type Lifetime = {
state: LifetimeState;
/** 0100, the share of the token's issued life still to run. `eternal` is 100. */
remainingPct: number;
/** e.g. "64 days left · 12 Nov", "Expired 2 Sep", "No expiry". */
label: string;
/** True when the instance cap has tightened since this token was issued. */
outsidePolicy: boolean;
};
```
Rules the implementation must honour:
- `soon` is seven days or fewer remaining — the same `SEVEN_DAYS_MS` threshold the current file already uses. Keep the constant here and delete it there.
- `remainingPct` is measured against the token's **own** issued span (`created_at``expires_at`), not against the instance cap. A 30-day key at day 15 is half gone; a 365-day key at day 15 is barely started. Clamp to 0100, and guard the zero-length span (`created_at === expires_at`) so it cannot divide by zero.
- A token with no `expires_at` is `eternal`, drawn full-width and grey. It is not `healthy` — "runs forever" is the state the posture strip counts as a risk.
- `outsidePolicy` keeps the existing rule verbatim: with a cap set, a token that never expires, or that expires further out than the cap allows, is outside it. **The cap is not applied retroactively** — this is a prompt to rotate, never an error, and the copy must not imply the key has stopped working.
- Accept `now` as an argument with a `Date.now()` default. A function that reads the clock itself cannot be reasoned about.
- [x] **Step 2: Verify it compiles**
From `vantage-app/web`: `npx tsc --noEmit`
Expected: no output.
- [x] **Step 3: Commit**
```bash
git add web/lib/keyLifetime.ts
git commit -m "feat: model an api key's remaining lifetime as a single value"
```
---
### Task 2: Split the panel into components
`ApiKeysPanel.tsx` is 487 lines holding a page, a table, two dialogs and a form. Every later task in this plan edits it. Split first, or each of them edits the same file and the diffs stop being reviewable.
**Files:**
- Create: `web/components/apikeys/ScopeChips.tsx`, `web/components/apikeys/LifetimeBar.tsx`, `web/components/apikeys/KeyLedger.tsx`, `web/components/apikeys/CreateKeyDialog.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`
**Interfaces:**
- Produces: the four components above. `ApiKeysPanel` keeps the queries, the mutations and the dialog open/closed state; the children stay presentational, taking props and calling handlers.
- [x] **Step 1: Move the existing pieces out, unchanged**
This step is a pure refactor — **no visual change, no behaviour change.** Move `summariseScopes` and `ScopeChips` into `ScopeChips.tsx` verbatim, exporting both. Move `ExpiryCell` into `LifetimeBar.tsx` as-is for now (Task 3 rewrites its body). Move the `<Table>` block into `KeyLedger.tsx`, the `<Modal>` block into `CreateKeyDialog.tsx`.
Keep every explanatory comment with the code it explains. Those comments are the record of why `write` implies `rw` in a chip and why Copy outranks Done, and they are worth more than the lines they sit above.
`ApiKeysPanel` keeps: both `useQuery` calls, both `useMutation` calls, `showAll`, `createOpen`, `revoking`, the form state, `closeCreate`, `copyToken`, `resetForm` and the expiry-default effect.
- [x] **Step 2: Verify nothing moved on screen**
```bash
npm run lint && npm run build
```
Then run the app and compare `/tokens` against the page before the split — key list, create dialog, revoke dialog, empty state. It must be pixel-identical. Any difference is a mistake made during the move, and it is far cheaper to find now than under the redesign.
- [x] **Step 3: Commit**
```bash
git add web/components/apikeys/
git commit -m "refactor: split the api keys panel into ledger, chips, lifetime and dialog"
```
---
### Task 3: The ledger
Replace the seven-column table with the record layout from the mockup: identity (name, `vt_` hint, holder, role) in one column, then scopes, lifetime and last call.
**Files:**
- Modify: `web/components/apikeys/KeyLedger.tsx`, `web/components/apikeys/LifetimeBar.tsx`, `web/components/apikeys/ScopeChips.tsx`
**Interfaces:**
- Consumes: `keyLifetime` (Task 1).
- [x] **Step 1: Rewrite `LifetimeBar`**
It renders a 4px track with a filled portion at `remainingPct`, the label beneath it in mono with `tabular-nums`, and the policy note when `outsidePolicy` is set. Colour by state: `bg-success`, `bg-warning`, `bg-danger`, and `bg-text-tertiary` for `eternal`. The label takes the matching text colour.
Give the track `role="img"` with an `aria-label` carrying the same text as the visible label. A bar with no accessible name is decoration to a screen reader, and this one is the primary signal in the row.
- [x] **Step 2: Rewrite the row as a grid, not a `<Table>`**
The mockup's row is a CSS grid, because the identity column stacks four things and the existing `Table`/`Td` primitives assume one value per cell. Columns: `minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto`, `gap-5`, rows separated by `border-border/60` — reach for the `rule-soft` token if a softer divider is wanted; do not invent a colour.
Keep the header row as a mono, tracked-out strip on `surface-2`. Keep the hover fill. Keep the owner column conditional on `showAll` — but fold it **into** the identity column as a third line rather than adding a fifth grid column, exactly as the mockup does. `showAll` then changes what a record says, not how the page is laid out.
Keep the role `Badge` inline in that identity column, and keep `roleVariant` as-is: `owner` accent, `admin` warning, `member` neutral.
- [x] **Step 3: Make the scope chips two-part**
Each chip becomes resource plus a tinted access half — `rw` on `accent/18`, `r` on a neutral wash — as in the mockup. `summariseScopes` already produces exactly this shape and does not change. A token with no scopes keeps its dashed "no scopes granted" chip rather than an em dash; an em dash reads as "unknown", and "this key can call nothing" is a fact worth stating.
- [x] **Step 4: Rewrite the mobile layout**
Below `900px` the grid collapses to a stacked record. Hide the header row and give each cell its own label via `data-label` and a `::before` rule, as the mockup does — an unlabelled date sitting under an unlabelled chip list is unreadable once the columns are gone. The scope list scrolls horizontally in its own track instead of wrapping to four lines. Revoke pins to the top-right of the record and gains a border so it is a real tap target.
Below `520px`: the posture strip goes single-column, the filter segment goes full width with its hint on its own line, and the dialog footer stacks with the primary button on top.
Copy these breakpoints from the mockup rather than re-deriving them; they were tuned against a real narrow viewport.
- [x] **Step 5: Keep `AsyncBoundary`, the skeleton and the empty state working**
`TableSkeleton` assumes a table. Either keep it for the loading state and accept a one-frame shape change, or add a small ledger-shaped skeleton beside it. Do not leave the loading state as an empty box.
The empty state keeps both existing copy variants — instance-wide versus personal — and the "Create your first key" action.
- [x] **Step 6: Verify**
```bash
npm run lint && npm run build
```
In a browser at `/tokens`: a healthy key, a key expiring inside seven days, an expired key, a never-expiring key and a key with no scopes all render distinctly. Resize to 375px wide and confirm every cell is labelled and nothing clips. Tab through the page and confirm Revoke is reachable and its focus ring is visible.
- [x] **Step 7: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: redesign the api key list as a ledger with lifetime bars"
```
---
### Task 4: The posture strip
Four counts above the list, answering "is anything wrong here" before the operator reads a single row.
**Files:**
- Create: `web/components/apikeys/KeyPosture.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`
- [x] **Step 1: Build it**
Four cells in a bordered grid: total keys, expiring within seven days (warning), never expiring (danger), and never used (muted). Derive all four from the `tokens` array already in hand with `keyLifetime`**no new request, and no new endpoint.**
"Never used" is `last_used_at == null`. It is muted rather than coloured: an unused key is a cleanup candidate, not an incident.
The counts describe the list as filtered, so the strip sits below the `My keys` / `All keys` toggle in the DOM order the mockup shows, and re-renders with it.
- [x] **Step 2: Delete the old subtitle**
The `{count} key{s} · {scope}` line under the heading goes; the strip says it better. The masthead is left as the heading and the Create key button, vertically centred.
The descriptive paragraph about what API keys are for is **not** to be added — it was in an earlier draft of the mockup and was cut deliberately. The `sha256` and role-cap facts appear in the create dialog and the reveal panel, where they are actionable.
- [x] **Step 3: Verify**
Browser check: with the fixtures from Task 3 present, the four counts are correct, and switching `My keys` / `All keys` changes them.
- [x] **Step 4: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: summarise key posture above the ledger"
```
---
### Task 5: The create dialog
Name and role side by side, scopes as one matrix instead of nine mini-cards, an expiry select that names the resolved date, and a preview line that reads the key back as a sentence before it exists.
**Files:**
- Modify: `web/components/apikeys/CreateKeyDialog.tsx`
- Create: `web/components/apikeys/ScopeMatrix.tsx`
- [x] **Step 1: Build the scope matrix**
One bordered grid: a resource per row, `read` and `write` checkbox columns, a mono header row. Resources come from `GET /api/tokens/scopes` exactly as now — **do not hardcode the nine resources**, the endpoint is the source of truth and MCP is about to add a tenth.
Each row carries a one-line description under the resource name ("fleet list, inventory, agent updates"). Those strings are UI copy with no server counterpart, so keep them in one exported record in this file, keyed by resource, and fall back to no description for an unknown key rather than rendering `undefined`.
The footer carries the running count ("3 of 9 resources · 5 scopes") and two bulk actions: **Read-only everywhere** and **Clear all**.
Checking `write` must also check `read` in the UI. The server treats write as satisfying read on the same resource, so a `:write`-only token works — but a matrix that lets you tick write while read sits empty invites the reader to conclude the key cannot read.
- [x] **Step 2: Name the date in the expiry options**
Each option renders as "90 days — 7 December 2026", computed from `Date.now()`. Options beyond the cap, and Never, stay `disabled` with the existing hint, and the existing effect that defaults to the shortest allowed option stays as it is.
- [x] **Step 3: Add the preview line**
One mono line in a `well` box, assembled from the current form state: the name, the role, the resources it may read and write, and the date it stops working. It is the over-granting check — reading "may read and write servers, workflows, secrets and keys" out loud is what makes someone go back and untick two boxes.
Handle the empty states honestly: no name yet, no scopes granted, no expiry.
- [x] **Step 4: Rework the reveal panel**
Keep the warning bar, keep the `sha256` sentence, keep Copy as the primary action with Done as the ghost — all three are existing decisions and all three were right. Add the `curl` example line from the mockup so nobody leaves the dialog to find out how to use what they just made. Put the plaintext key beside its Copy button, stacking below `520px` so Copy is reachable without scrolling 64 characters of hex sideways.
- [x] **Step 5: Verify**
```bash
npm run lint && npm run build
```
In a browser: create a key with two resources ticked; the preview names them and the resolved date; the created key's summary matches what the preview said. Confirm the cap still disables the long options, and that closing the dialog after a reveal still invalidates the list.
- [x] **Step 6: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: rebuild the create key dialog around a scope matrix and a preview"
```
---
### Task 6: Tag restriction — absorbs MCP Task 12 steps 2 and 4
**Requires MCP Tasks 35, which are already committed on `feat/mcp-server`.** Skip this task entirely on a branch that does not have them; `tag_selector` will be rejected by a server without them.
**Files:**
- Modify: `web/lib/api.ts` (the `ApiToken` type and `createApiToken`)
- Modify: `web/components/apikeys/CreateKeyDialog.tsx`, `web/components/apikeys/KeyLedger.tsx`
- [x] **Step 1: Carry the field in the API client**
Add `tag_selector?: Record<string, string> | null` to the `ApiToken` type, and `tag_selector?: Record<string, string>` to `createApiToken`'s body. The server already models, accepts and enforces it — `models/api_token.go` and `api/tokens.go` — so this is the client catching up, not a new contract.
- [x] **Step 2: Add the field to the dialog**
Below the scope matrix, a "Restrict to servers tagged" control offering the key/value vocabulary from `GET /api/servers/tags` (`api.listKnownTags`, already in the client). Reuse the workflow target tag rows from `EditWorkflowModal` if that component can be lifted without dragging workflow state with it; build the smallest possible thing if it cannot.
Send `tag_selector` omitted or `{}` when unrestricted. **This field is not licence-gated** — tag scoping ships useful on its own and is shown to everyone.
Two lines of copy earn their place here, because the asymmetry is genuinely surprising: an **empty** selector means unrestricted, and a selector matches a server only when **every** pair matches. Say both.
- [x] **Step 3: Show the restriction in the ledger**
Render a token's `tag_selector` as a chip beside its scopes — `env=prod` in mono. An unrestricted token renders nothing at all, not an empty chip and not "unrestricted": most tokens are unrestricted, and a chip on every row for the common case is noise. Include the selector in the preview line's sentence.
- [x] **Step 4: Verify**
Create a restricted key, confirm the chip appears, and confirm the audit detail on the server records the restriction (`api/tokens.go` already appends "restricted to …").
- [x] **Step 5: Commit**
```bash
git add web/lib/api.ts web/components/apikeys/
git commit -m "feat: restrict an api key to tagged servers from the create dialog"
```
---
### Task 7: Agent access — absorbs MCP Task 12 steps 3 and 5
**Requires MCP Tasks 611 (the endpoint itself) and the `mcp` licence feature.** Skip on a branch without them.
**Files:**
- Create: `web/components/apikeys/AgentAccessPanel.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`, `web/components/apikeys/ScopeMatrix.tsx`
- [x] **Step 1: Gate the MCP scopes in the matrix**
`mcp:read` and `mcp:write` arrive from `GET /api/tokens/scopes` with no client change. Hide that row when `license.features.mcp` is false, following whatever the console-gated UI already does — check `web/lib/useLicense.ts` for the existing pattern rather than inventing a second one.
- [x] **Step 2: Build the panel**
Below the ledger, visible only when `license.features.mcp` is true: the endpoint URL (`${window.location.origin}/api/mcp`) with a copy button, the copyable client configuration JSON from MCP Task 12 step 5, and one line saying the token needs `mcp:read`, plus `mcp:write` for tools that change anything, linking to the docs page from MCP Task 15.
Style it as a `well` block, not a card — it is machine output being handed to the operator, the same treatment the install one-liner gets on `/servers/new`.
- [x] **Step 3: Verify**
With the feature off: no panel, no MCP row in the matrix. With it on: both appear, and the copied JSON pastes into a client and connects.
- [x] **Step 4: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: surface the mcp endpoint and its scopes on the api keys page"
```
- [x] **Step 5: Amend the MCP plan**
In `docs/superpowers/plans/2026-09-08-mcp-server.md`, strike steps 25 of Task 12, correct its stale file list to `web/app/(app)/tokens/` plus `web/components/apikeys/`, and point the remaining step 1 at this plan for the rest. Commit as `docs:`.
---
### Task 8: Documentation
**Files:**
- Modify: `vantage-docs/docs/reference/api-tokens.md`
- [x] **Step 1: Update the screenshots and the walkthrough**
The reference page describes the old form field by field. Rewrite the creation walkthrough around the matrix and the preview line, and document the lifetime bar's four states so the colours mean the same thing to a reader as to an operator. If Task 6 landed, document the tag restriction and both halves of its asymmetry.
- [x] **Step 2: Commit**
```bash
cd vantage-docs
git add docs/reference/api-tokens.md
git commit -m "docs: describe the redesigned api keys page"
```
---
## Verification checklist
Run before calling the work done:
- [x] `npm run lint` and `npm run build` clean from `vantage-app/web`.
- [x] `grep -rn "#[0-9a-fA-F]\{6\}" web/components/apikeys/` returns nothing.
- [ ] `/tokens` renders correctly at 1440px, 900px and 375px, with nothing clipped and every mobile cell labelled.
- [ ] Every state has a fixture that was actually looked at: healthy, expiring, expired, never-expiring, unscoped, never-used, outside-policy.
- [ ] Keyboard: every control reachable, focus rings visible on the dark ground, the dialog still traps focus and restores it on close.
- [ ] A member (not owner or admin) sees the page, sees only their own keys, and sees no `All keys` toggle.
- [ ] Revoke still works, still names the key in its confirmation and its toast, and still shows the server's error inline on failure.
- [ ] The plaintext key is still shown exactly once, and closing the dialog after a reveal still invalidates the list.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
# MCP server
Date: 2026-09-08
## Goal
Expose Vantage to LLM agents as a first-class tool surface, so that an agent
acting for a user can answer questions about the fleet and — when explicitly
permitted — act on it, under the same identity, scopes, licence and audit trail
as every other API caller.
Concretely: a user mints a Vantage API token, points Claude (or any MCP client)
at `https://<instance>/api/mcp`, and asks "which hosts are still on OpenSSL
1.1?" or "run the patch workflow on staging". Nothing an agent can do is
something the token's owner could not already do through the UI.
Out of scope, deliberately:
- **OAuth 2.1 authorization server.** A first-class remote connector on
claude.ai would need one. Bearer tokens work today in every client that
matters, and the token model already exists. Its own sub-project.
- **MCP resources and prompts.** Client support is uneven, and tools alone are
the whole value. Both can be added later without a protocol break.
- **Secret reveal.** Secret names and metadata are exposed; plaintext never is,
at any scope. An LLM context window is the wrong place for a credential, and
the human path through the UI still exists.
- **Console and exec.** Interactive terminal access is a streaming, stateful
problem that does not fit a tool call, and an agent with a shell is a
different security conversation.
- **An approval queue.** Writes are gated by an explicit scope, not by a
human-in-the-loop workflow. A pending-action subsystem is a real feature and
would roughly double this one.
- **Editing or deleting existing workflows, steps and monitors.** Creation is
in scope; changing or removing something a human already made is not. An agent
that can only add leaves every existing definition intact, and an unwanted new
one is deleted in a click.
- **Secret-referencing steps.** A created step may not declare `secret_refs`.
Composing a script around a secret reference is how a credential ends up
echoed into a log, and the human step editor already does this safely.
## Current state
Most of the hard parts already exist, which is the reason this is worth doing
now rather than as a large project later.
| Capability | Where |
| --- | --- |
| Bearer token auth, role recomputation, expiry auditing | `auth.sessionFromToken` |
| Token model, hash-only storage, immutable role and scopes | `models.APIToken` |
| Coarse scope vocabulary, write implies read | `services.ScopeResources` |
| Route-to-scope map, boot-time completeness assertion | `api.routeScopes`, `AssertScopeMapComplete` |
| Licence feature gate as middleware | `api.RequireFeature`, `license.HasFeature` |
| Feature catalogue and Paddle pricing | `models.CatalogueRow`, `catalogue.LineItems` |
| Tag selectors over servers | `services.MatchesTags`, `ListServersFiltered`, `ResolveTargets` |
| Audit event write | `services.LogEvent` |
Three things do not exist: any MCP protocol handling, any notion of a
credential restricted to part of the fleet, and any licence feature for either.
## Approach
A new package `server/internal/mcp` registers a tool set against the existing
service layer and serves it over Streamable HTTP at `/api/mcp`, mounted inside
the existing `/api` group so that every middleware already on that group applies
unchanged.
The design principle throughout: **MCP is a presentation layer over the service
layer, and introduces no new authority.** It calls the same service functions
the REST handlers call, and every decision about who may do what is made by
machinery that already exists. Where MCP needs something new — tag-scoped
tokens — that thing is built as a general capability of the API, not as an MCP
feature.
Three independent gates gate every tool call, and all three must pass:
1. The licence grants `license.FeatureMCP`.
2. The token holds `mcp:read` (any tool) or `mcp:write` (write tools).
3. The token holds the per-tool resource scope, e.g. `workflows:write`.
## Scope vocabulary
`services.ScopeResources` gains one entry, `"mcp"`. That is the whole change:
`AllScopes()` derives `mcp:read` and `mcp:write` from it, `validScope` accepts
them, `ScopeSatisfied` already implements write-implies-read, and the token
creation UI advertises them without modification.
A bespoke `mcp:use` scope was rejected. The vocabulary is deliberately uniform —
every resource has exactly `:read` and `:write` — and one special-cased action
verb would be the first exception in a table whose value is having none.
The meanings:
- **`mcp:read`** — the token may reach `/api/mcp` at all. A token without it is
not an agent token, whatever else it holds. Read tools are listed and callable
subject to their own resource scopes.
- **`mcp:write`** — write tools are listed and callable, again subject to their
own resource scopes. Implied by the existing rule when a token holds
`mcp:write`, so `mcp:read` need not be requested separately.
Write tools are **omitted from `tools/list`** for a token without `mcp:write`,
not merely refused on call. An agent cannot be talked into using a tool it has
never been told exists, and a read-only agent that cannot see destructive tools
produces better behaviour than one that keeps trying them and reading errors.
## Tag-scoped API tokens
This is a general API token capability, not an MCP one, and it ships ungated by
licence. Restricting what a credential can touch is a security control, and
putting a security control behind a paywall is the wrong instinct.
`models.APIToken` gains:
```go
// TagSelector restricts this token to servers carrying every tag in the map.
// Empty or nil means the whole fleet. Immutable after creation, like Role and
// Scopes: narrowing or widening what a deployed credential reaches, with no
// record of what it reached before, is worse than requiring a rotation.
TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,omitempty"`
```
Validated on creation by the existing `services.ValidateTags`, so a token
selector cannot express a tag a server could never carry. A caller may only
create a token whose selector is at least as narrow as their own — the same
rule `ScopeSatisfied` already enforces for scopes, applied to tags.
`auth.Session` carries `TagSelector`, populated in `sessionFromToken` and always
empty for a cookie session. `auth.ServerScope(c)` returns it.
### Enforcement
The selector is intersected at the points where servers are resolved, not at
each handler:
| Path | Change |
| --- | --- |
| `services.ListServers` | Handlers call `ListServersFiltered` with the session selector merged into any request selector |
| `services.GetServer` | Returns not-found when `!MatchesTags(srv, sel)` |
| `services.ResolveTargets` | Intersects the caller's selector with the requested one; a request naming an out-of-scope ID resolves to nothing |
`ResolveTargets` is the chokepoint that matters most: workflow runs, console
connections and update application all pass through it, so a correct
intersection there covers the mutating surface.
**Out-of-scope hosts read as 404, never 403.** A scoped token must not be able
to enumerate the fleet it cannot see by observing which IDs answer differently.
### Completeness assertion
Mirroring `AssertScopeMapComplete`, a boot-time assertion in `api` lists every
route that returns or acts on server-derived data and asserts each is declared
either tag-filtered or explicitly fleet-wide. A route added tomorrow that reads
server data without honouring the selector fails at deploy rather than leaking
silently. The precedent is deliberate: this codebase already prefers a
maintained map that fails boot over a decorator someone can forget.
## Licence feature
`vantage-shared/license` gains:
```go
FeatureMCP = "mcp" // agent access over the Model Context Protocol
```
No plan bundles it. Every tier's `Features` stays `[]string{}`, consistent with
console and OIDC being opt-in per customer.
Enforced in three places:
1. **Route**`RequireFeature(license.FeatureMCP)` on the `/api/mcp` group,
answering the standard `feature_unavailable` 403.
2. **Token minting** — creating a token with `mcp:read` or `mcp:write` is
refused without the feature. A licence downgrade should not leave live agent
credentials that fail confusingly mid-conversation, and the same
guard-at-source thinking is already in `services/packages.go`.
3. **UI** — the token form's MCP scopes and the MCP connection panel are hidden
when the licence does not grant it, as console is today.
Existing tokens are unaffected: absent the new scopes, no token can reach the
endpoint, so enabling the feature grants nothing by itself.
## Transport and protocol
Streamable HTTP, stateless. `POST /api/mcp` carries the JSON-RPC request and
returns either a JSON response or an SSE stream. The transport is stateless
rather than session-resuming precisely so each request can stand alone and
sit behind ordinary request middleware with no special-casing, and that
stateless mode leaves no session for a server-to-client stream to resume
against — so `GET /api/mcp` is registered but answers the protocol's 405
rather than opening a stream. A client probing the endpoint therefore learns
"POST-only here" rather than seeing a bare 404, which is what the MCP spec
expects from a server that does not offer the GET/SSE leg.
Protocol framing comes from `github.com/modelcontextprotocol/go-sdk`. Everything
below the framing is the existing service layer, called directly in-process.
The MCP layer never issues HTTP requests to Vantage's own API: doing so would
duplicate auth and double every request's cost for no benefit.
`routeScopes` gains `POST /api/mcp` and `GET /api/mcp`, both mapped to
`mcp:read`, satisfying `AssertScopeMapComplete`. Per-tool scope enforcement
happens inside the handler, because one route serves many operations — this is
the first route where the route-level scope is a floor rather than the whole
answer, and the map entry's comment says so.
Server metadata advertises the instance name and Vantage version, so a user with
several instances connected can tell them apart in a client.
## Tool set
Roughly twenty tools, written to how an agent asks questions rather than to how
the REST API is shaped. Each declares its resource scope and whether it is a
write.
| Tool | Scope | Write |
| --- | --- | --- |
| `list_servers` | `servers:read` | |
| `get_server` | `servers:read` | |
| `search_fleet` | `vulns:read` | |
| `list_monitors` | `monitors:read` | |
| `get_monitor_status` | `monitors:read` | |
| `list_incidents` | `monitors:read` | |
| `get_monitor_samples` | `monitors:read` | |
| `list_pending_updates` | `servers:read` | |
| `list_vulnerabilities` | `vulns:read` | |
| `get_server_packages` | `vulns:read` | |
| `list_workflows` | `workflows:read` | |
| `get_workflow` | `workflows:read` | |
| `get_run` | `workflows:read` | |
| `get_run_logs` | `workflows:read` | |
| `list_audit_events` | `settings:read` | |
| `list_secret_names` | `secrets:read` | |
| `run_workflow` | `workflows:write` | yes |
| `cancel_run` | `workflows:write` | yes |
| `apply_updates` | `servers:write` | yes |
| `update_agent` | `servers:write` | yes |
| `assign_key` | `keys:write` | yes |
| `create_step` | `workflows:write` | yes |
| `create_workflow` | `workflows:write` | yes |
| `create_monitor` | `monitors:write` | yes |
Rules every tool follows:
- **Trimmed projections, not API JSON.** `list_servers` over thirty hosts must
cost a few hundred tokens, not several thousand. Each tool defines its own
response struct containing what an agent needs to decide what to do next, and
a `get_*` tool exists for the detail.
- **Pagination with a hard cap.** Every list takes `limit` and `cursor`, caps
`limit`, and states the total so an agent knows it is seeing a page.
- **Descriptions state blast radius in plain words.** A tool description is
prompt text; `run_workflow` says that it executes commands on real servers.
- **No blocking.** `run_workflow` returns a run ID immediately. The agent polls
`get_run`. A tool call must never hold a connection open for a long job.
- **Fan-out guard.** Any write tool resolving more than a configurable number of
servers (default 25) refuses unless called with `confirm: true`, and says how
many it would have touched. Cheap insurance against a mis-parsed selector
reaching the whole fleet.
### Creation tools
`create_step`, `create_workflow` and `create_monitor` let an agent build the
thing it is about to propose, rather than describing a script in prose that a
human then retypes. They are the tools that make the surface generative instead
of merely observational, and they are also the ones most able to surprise
someone, so they carry extra rules on top of the ordinary write gates:
- **Creation only.** No update and no delete tool exists. An agent may add a
definition; it may never alter or remove one a human wrote.
- **Nothing is armed on creation.** `create_workflow` refuses a `schedule`, and
`create_monitor` sets `enabled` false. A created definition sits inert until a
human enables it, so creating and acting stay two decisions. An agent that
wants to run what it just made calls `run_workflow`, which is separately
gated, separately audited, and subject to the fan-out guard.
- **No secret references.** `create_step` rejects a non-empty `secret_refs`.
- **Marked as agent-authored.** `models.WorkflowStep` already carries a `Source`
field; created steps set it to `mcp`, so the UI can badge them and a human can
tell at a glance what a model wrote. Workflows and monitors get the same
treatment through their audit event rather than a new field.
- **Script validation.** `create_step` runs the same parse and scan the existing
step-create route runs (`services.CreateStep` already does this) — an agent
gets no laxer a path than the UI.
## Audit
Every tool call writes an audit event through `services.LogEvent`, reads
included. The point of an agent-facing surface is being able to reconstruct
afterwards what the agent looked at, not only what it changed.
Creation tools log a distinct event type, `mcp.created`, naming what was made
and its ID. A generic tool-call row buried among reads is not enough for the
question a human will actually ask, which is "what has this agent added to my
instance".
Event type `mcp.tool_call`; actor is the token name, as REST token actions
already record; detail is the tool name, a compact argument summary, and the
number of servers affected. Failures record `mcp.tool_denied` with the gate that
refused — licence, MCP scope, resource scope, or tag selector — which is what
turns "the agent said it couldn't" into a diagnosable event.
Arguments are summarised, never dumped verbatim: an argument could carry
arbitrary text from a model, and the audit log is read by humans in a UI.
A chatty agent can produce many events. If that becomes a problem the throttle
pattern already used for `token.expired_use` applies, but v1 records everything —
under-recording a new and sensitive surface is the worse failure.
## Errors
Scope, licence and selector failures return **MCP tool errors**, not transport
errors, carrying a plain-language remedy: "this token does not hold
workflows:write". The agent must be able to read the refusal and adapt or tell
its user, and a transport-level failure is invisible to the model.
Out-of-scope hosts are not-found, matching the REST rule. Upstream service
errors are summarised — a raw Mongo error is neither useful to a model nor safe
to expose.
## HQ, catalogue and Paddle
### Catalogue
`models.seedRows()` gains `license.FeatureMCP` to its shared feature list, one
more `KindFeature` row at `ScopeShared`, sold by every paid plan at one price.
`SeedCatalogue` is `$setOnInsert` only, so the row appears empty on deploy and
staff-entered price IDs are never blanked. The comment naming the row count
("nine rows") is updated — the file explicitly asks the next person to keep that
number deliberate.
`catalogue.LineItems` needs no change: a `KindFeature` row the customer selected
becomes a line item, and one with no price ID in the running environment is
granted free. That is what makes the pre-pricing window safe.
### HQ UI
`vantage-admin/web/lib/features.ts` gains the label "Agent access (MCP)" and the
description "Let AI agents query and act on your fleet through the Model Context
Protocol, under a scoped token you control." It then appears automatically in
the purchase form, the staff pricing page and the account detail view, all of
which render from that map.
### App licence page
`vantage-app/web/app/(app)/settings/license/page.tsx` gains
`<Feature label="Agent Access (MCP)" included={Boolean(license.features.mcp)} />`
alongside the existing four. `licenceResponse.Features` is already a
`map[string]bool` built from the licence, so no server change is needed.
### Paddle
One product, two prices, created in the sandbox environment first:
| Field | Value |
| --- | --- |
| Product name | Vantage — Agent Access (MCP) |
| Description | AI agent access to a Vantage instance over the Model Context Protocol |
| Tax category | `standard` |
| Currency | GBP |
| Monthly price | £9.00, billing interval `month` × 1 |
| Annual price | £90.00, billing interval `year` × 1 |
Annual is ten months' money for twelve, matching the convention the other add-on
rows use.
Creation runs through the connected `paddle-sandbox` MCP server during
implementation, with the exact payload confirmed before each call. The resulting
price IDs are recorded in the catalogue row's `price_ids.sandbox` map through
the existing staff pricing page — not by a migration, because that page is the
only place price IDs are meant to be entered and a migration writing them would
be a second source of truth.
Production prices are created by hand in the Paddle dashboard when the feature
ships, and pasted into `price_ids.production` the same way. Nothing in this spec
writes to a production billing account.
## Frontend
A new **Agent access** panel on the API tokens settings page, visible only when
the licence grants the feature:
- The endpoint URL for this instance, with a copy button.
- A short client configuration snippet, again copyable.
- A link to the docs page.
The token creation form gains the two MCP scopes in its scope list — no special
UI, they are ordinary scopes — and a **tag restriction** field, which is shown
for every token regardless of licence because tag scoping is not gated. The
field offers the tag keys and values already in use on servers, as the workflow
target selector does.
The token list shows a token's tag restriction as a chip beside its scopes, so
that "what can this credential reach" is answerable at a glance.
## Documentation
`vantage-docs` gains `docs/vantage/mcp.md`: what MCP is in two sentences, how to
mint a suitable token, how to connect Claude and other clients, the full tool
list with what each one does, and an explicit section on what an agent cannot do
(reveal secrets, open a console, exceed its tags, act without `mcp:write`).
`docs/reference/api-tokens.md` gains the tag restriction field.
## Testing
Table-driven, over the tool registry rather than per tool, because the registry
is the thing that must stay correct as tools are added:
- **Gate matrix.** Each tool × token shape (no MCP scope, `mcp:read`,
`mcp:write`, missing resource scope, missing licence feature): assert listed
or not listed, and allowed or refused. This is the security test of the
feature.
- **Registry completeness.** Every registered tool declares a resource scope
from `ScopeResources` and a write flag. Same spirit as
`AssertScopeMapComplete`; a tool added without a scope fails the build.
- **Tag scoping at the chokepoints.** `GetServer` on an out-of-scope host is
not-found; `ResolveTargets` intersects rather than unions; a run naming
out-of-scope IDs targets nothing. Service-level tests, since the property is a
service-level one.
- **Token creation.** A caller cannot mint a token with scopes or a tag
selector broader than their own; MCP scopes are refused without the licence.
- **Audit.** A successful call and a refused call each write exactly one event
of the expected type.
- **Response size.** `list_servers` over a seeded fleet stays under a stated
byte budget — a regression here degrades every agent interaction and is
otherwise invisible.
`services/statuspages_test.go` is the style model.
## Migration and rollout
No data migration. `TagSelector` absent on existing tokens means fleet-wide,
which is what those tokens do today. `SeedCatalogue` adds the row on the next
admin deploy. No licence gains the feature until staff grant it.
Order of work:
1. `vantage-shared`: `FeatureMCP` constant.
2. `vantage-app` server: `mcp` scope resource, tag selector on tokens plus
enforcement and the completeness assertion, then the MCP package and tools.
3. `vantage-app` web: token form fields, agent access panel, licence page row.
4. `vantage-admin`: catalogue seed row, feature label.
5. Paddle sandbox product and prices; price IDs entered through the staff page.
6. `vantage-docs`: the MCP page.
Steps 13 are independently useful: tag-scoped tokens are a security improvement
whether or not MCP ever ships, which is the argument for building them as a
general capability rather than folding them into the MCP package.
+37
View File
@@ -22,6 +22,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched"
@@ -196,7 +197,17 @@ func runSchemaSetup() {
}
// apiVersion mirrors the @version annotation on the swagger block above,
// which is the only version string this server already establishes — there is
// no separate runtime build-version constant to reuse instead. Nothing ties
// the two together mechanically, so change them in the same commit: this is
// the value mcp.SetVersion reports to MCP clients, and it must keep agreeing
// with "// @version" above or the two will read as two different servers.
const apiVersion = "1.0"
func serve() {
mcp.SetVersion(apiVersion)
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
redisUser := os.Getenv("REDIS_USERNAME")
redisPass := os.Getenv("REDIS_PASSWORD")
@@ -282,6 +293,10 @@ func serve() {
log.Fatalf("api scope map: %v", err)
}
if err := api.AssertServerScopeMapComplete(apiRoutes(r)); err != nil {
log.Fatalf("api server scope map: %v", err)
}
srv := &http.Server{Addr: ":8080", Handler: r}
go func() {
log.Println("REST server listening on :8080")
@@ -343,3 +358,25 @@ func boolEnv(key string) bool {
}
return false
}
// apiRoutes lists every registered /api route as "METHOD /path", which is the
// whole input AssertServerScopeMapComplete now takes.
//
// It replaces a substring filter that fed in only routes whose path contained
// "server", ":serverId", "console" or "assign". That filter could only ever
// catch a route whose *path* named a server, and a route can act on one named
// in its body, in a query parameter, or derived by the handler — it caught one
// of the leaks found in the final review of the MCP feature, and none of the
// eleven found during implementation. Declaring every route is more typing
// once and no maintenance after: a new route fails boot until somebody answers
// "does this touch server data?" for it.
func apiRoutes(r *gin.Engine) []string {
var out []string
for _, route := range r.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
continue
}
out = append(out, route.Method+" "+route.Path)
}
return out
}
+53 -47
View File
@@ -1,79 +1,85 @@
module gitea.hostxtra.co.uk/mrhid6/vantage/server
go 1.26
go 1.26.0
require (
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gin-gonic/gin v1.10.0
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57
github.com/coreos/go-oidc/v3 v3.21.0
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.20.1
github.com/redis/go-redis/v9 v9.22.0
github.com/robfig/cron/v3 v3.0.1
github.com/wwt/guac v1.3.2
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
go.mongodb.org/mongo-driver/v2 v2.9.0
golang.org/x/crypto v0.56.0
golang.org/x/oauth2 v0.37.0
google.golang.org/grpc v1.83.2
oras.land/oras-go/v2 v2.6.2
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/samber/lo v1.50.0 // indirect
github.com/samber/oops v1.18.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.62.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/samber/oops v1.23.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/time v0.16.0 // indirect
)
require (
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
gitea.hostxtra.co.uk/vantage/vantage-shared v0.2.1
github.com/bytedance/sonic v1.15.3 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/websocket v1.4.1 // indirect
github.com/go-playground/validator/v10 v10.30.4 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
golang.org/x/arch v0.31.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+131 -103
View File
@@ -1,129 +1,144 @@
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.2.1 h1:rPzXSRwU+4+F2pdkmDrIxKsIzqz3S6feJEWalGmKqfU=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.2.1/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f h1:GvCU5GXhHq+7LeOzx/haG7HSIZokl3/0GkoUFzsRJjg=
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8=
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 h1:dWzdsqjh1p2gNtRKqNwuBvKqMNwnLOPLzVZT1n6DK7s=
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23/go.mod h1:lUaIXCWzf7BRKTY5iEcrYy1TfgbYLYVIS/B2vPkJzOc=
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f h1:xt29M2T6STgldg+WEP51gGePQCsQvklmP2eIhPIBK3g=
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f/go.mod h1:i4sF0l1fFnY1aiw08QQSwVAFxHEm311Me3WsU/X7nL0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790 h1:2R0QOkcV/csVHDigcH1sMNP3fQLw4Wi1ZxG0oC+29Ts=
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790/go.mod h1:i4sF0l1fFnY1aiw08QQSwVAFxHEm311Me3WsU/X7nL0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/oops v1.23.1 h1:QKkdrmSTr46B2u+FoQnUJ8dtp0an/yHKvytWHCTEba8=
github.com/samber/oops v1.23.1/go.mod h1:LO+VjrupgloQZ3CrXnhWqOWYARz8lY4EM+RHGzb3UJI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
@@ -132,75 +147,88 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+2 -2
View File
@@ -46,7 +46,7 @@ func consoleConnect(c *gin.Context) {
return
}
srv, err := services.GetServer(auth.InstanceID(c), body.ServerID)
srv, err := services.GetServerScoped(auth.InstanceID(c), body.ServerID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -168,7 +168,7 @@ func consoleTunnel(c *gin.Context) {
return
}
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
srv, err := services.GetServerScoped(auth.InstanceID(c), sess.ServerID, auth.ServerScope(c))
if err != nil {
tlog("reject: server %s not found: %v", sess.ServerID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
+54 -1
View File
@@ -315,6 +315,52 @@
},
"type": "object"
},
"api.RunResponse": {
"properties": {
"finished_at": {
"type": "string"
},
"instance_id": {
"type": "string"
},
"name": {
"type": "string"
},
"run_id": {
"type": "string"
},
"server_runs": {
"items": {
"$ref": "#/components/schemas/models.ServerRun"
},
"type": "array",
"uniqueItems": false
},
"servers_restricted": {
"type": "boolean"
},
"started_at": {
"type": "string"
},
"status": {
"type": "string"
},
"steps_snapshot": {
"items": {
"$ref": "#/components/schemas/models.ResolvedStep"
},
"type": "array",
"uniqueItems": false
},
"triggered_by": {
"type": "string"
},
"workflow_id": {
"type": "string"
}
},
"type": "object"
},
"api.RunWorkflowResponse": {
"properties": {
"run_id": {
@@ -811,6 +857,13 @@
"type": "array",
"uniqueItems": false
},
"tag_selector": {
"additionalProperties": {
"type": "string"
},
"description": "TagSelector restricts this token to servers carrying every tag in the\nmap. Empty or nil means the whole fleet.\n\nImmutable after creation for the same reason as Role and Scopes: changing\nwhat a credential already deployed in CI can reach, with no record of what\nit could reach before, is worse than requiring a rotation.",
"type": "object"
},
"token_id": {
"type": "string"
},
@@ -4966,7 +5019,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/models.WorkflowRun"
"$ref": "#/components/schemas/api.RunResponse"
}
}
},
+68 -11
View File
@@ -8,8 +8,10 @@ import (
"strconv"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
"github.com/gin-gonic/gin"
)
@@ -120,6 +122,23 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
// MCP is mounted inside /api so that bearer auth, rate limiting, licence
// activity and RequireScopes all apply from where it lives rather than
// because someone remembered. The route-level scope is a floor: one route
// serves many tools, so per-tool scopes are enforced inside the handler.
//
// GET is registered deliberately even though the transport runs stateless
// and therefore never serves it usefully: mcp.Handler's underlying SDK
// handler answers every GET with a hardcoded 405, because a stateless
// server has no session to open the server-to-client SSE stream against.
// That 405 is the protocol-correct response for an MCP server that offers
// no SSE leg — an unregistered GET would 404 instead, which a client reads
// as "no MCP endpoint here at all" rather than "this one is POST-only".
// This route is not a working GET; it exists solely to produce that 405.
mcpGroup := apiGroup.Group("/mcp", RequireFeature(license.FeatureMCP))
mcpGroup.POST("", mcp.Handler())
mcpGroup.GET("", mcp.Handler())
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
@@ -185,11 +204,18 @@ func RegisterRoutes(r *gin.Engine) {
// @Security bearerAuth
// @Router /servers [get]
func listServers(c *gin.Context) {
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
requested, err := services.ParseTagFilters(c.QueryArray("tag"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sel, ok := services.IntersectSelectors(auth.ServerScope(c), requested)
if !ok {
// The token's own restriction and the requested filter can never both
// hold, so this resolves to nothing rather than an error.
c.JSON(http.StatusOK, []models.Server{})
return
}
servers, err := services.ListServersFiltered(auth.InstanceID(c), sel)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -246,7 +272,7 @@ func putServerTags(c *gin.Context) {
instanceID := auth.InstanceID(c)
serverID := c.Param("id")
before, err := services.GetServer(instanceID, serverID)
before, err := services.GetServerScoped(instanceID, serverID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -352,7 +378,7 @@ func newServer(c *gin.Context) {
// @Router /servers/{id} [get]
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -379,7 +405,11 @@ func getServer(c *gin.Context) {
// @Router /servers/{id} [delete]
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -422,7 +452,7 @@ func generateKey(c *gin.Context) {
body.Label = "generated"
}
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -459,7 +489,7 @@ func generateKey(c *gin.Context) {
// @Security bearerAuth
// @Router /keys [get]
func listKeys(c *gin.Context) {
keys, err := services.ListKeys(auth.InstanceID(c))
keys, err := services.ListKeys(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -543,7 +573,22 @@ func getKey(c *gin.Context) {
return
}
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
all, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
// A tag-restricted token may legitimately hold a key that is also
// assigned to a server outside its restriction — the key itself is
// still returned above. Only the assignment list is filtered, and
// silently: an assignment whose Server is nil or out of scope is
// dropped rather than kept with the hostname redacted, so the response
// gives no signal — not even a count — of what was removed.
scope := auth.ServerScope(c)
assignments := make([]services.AssignmentWithServer, 0, len(all))
for _, a := range all {
if a.Server != nil && !services.ServerInTokenScope(*a.Server, scope) {
continue
}
assignments = append(assignments, a)
}
c.JSON(http.StatusOK, KeyDetailResponse{
Key: key,
@@ -593,6 +638,7 @@ func deleteKey(c *gin.Context) {
// @Router /keys/{id}/assign [post]
func assignKey(c *gin.Context) {
keyID := c.Param("id")
instanceID := auth.InstanceID(c)
var body struct {
ServerID string `json:"server_id" binding:"required"`
}
@@ -601,7 +647,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
if _, err := services.GetServerScoped(instanceID, body.ServerID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
a, err := services.AssignKey(instanceID, keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -626,8 +677,14 @@ func assignKey(c *gin.Context) {
func revokeAssignment(c *gin.Context) {
keyID := c.Param("id")
serverID := c.Param("serverId")
instanceID := auth.InstanceID(c)
if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil {
if _, err := services.GetServerScoped(instanceID, serverID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.RevokeAssignment(instanceID, keyID, serverID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -670,7 +727,7 @@ func getLatestAgentVersion(c *gin.Context) {
// @Router /servers/{id}/update-agent [post]
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -703,7 +760,7 @@ func updateAgent(c *gin.Context) {
// @Router /servers/{id}/apply-updates [post]
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
+21 -3
View File
@@ -39,6 +39,16 @@ func listMonitors(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range monitors {
monitors[i] = services.RedactMonitorRunner(monitors[i], visible, restricted)
}
c.JSON(http.StatusOK, monitors)
}
@@ -73,7 +83,7 @@ func createMonitor(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
created, err := services.CreateMonitor(auth.InstanceID(c), &m, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -103,7 +113,15 @@ func getMonitor(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
c.JSON(http.StatusOK, m)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
redacted := services.RedactMonitorRunner(*m, visible, restricted)
c.JSON(http.StatusOK, &redacted)
}
// updateMonitor godoc
@@ -168,7 +186,7 @@ func updateMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil {
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func runFixture() *models.WorkflowRun {
return &models.WorkflowRun{
RunID: "r1",
ServerRuns: []models.ServerRun{
{ServerID: "stg-1", Hostname: "staging-web"},
{ServerID: "prod-1", Hostname: "prod-db"},
},
}
}
// A run document names every host it touched, hostname included. A restricted
// caller must see only its own, and must be told some entries are missing
// without being told how many — the targets_restricted precedent.
func TestScopeRunHidesOutOfScopeServerRuns(t *testing.T) {
got := scopeRun(runFixture(), map[string]bool{"stg-1": true}, true)
if len(got.ServerRuns) != 1 || got.ServerRuns[0].ServerID != "stg-1" {
t.Fatalf("server_runs = %v, want only stg-1", got.ServerRuns)
}
for _, sr := range got.ServerRuns {
if sr.Hostname == "prod-db" {
t.Error("out-of-scope hostname survived filtering")
}
}
if !got.ServersRestricted {
t.Error("servers_restricted = false with an entry dropped")
}
}
func TestScopeRunLeavesUnrestrictedCallerWhole(t *testing.T) {
got := scopeRun(runFixture(), nil, false)
if len(got.ServerRuns) != 2 {
t.Fatalf("server_runs = %v, want both", got.ServerRuns)
}
if got.ServersRestricted {
t.Error("unrestricted caller told entries were restricted")
}
}
// A restricted caller whose scope happens to cover the whole run must not be
// told anything was hidden — the flag is about disclosure, not about being
// restricted in general.
func TestScopeRunNoFlagWhenNothingDropped(t *testing.T) {
got := scopeRun(runFixture(), map[string]bool{"stg-1": true, "prod-1": true}, true)
if got.ServersRestricted {
t.Error("servers_restricted set with nothing dropped")
}
}
+6
View File
@@ -43,6 +43,12 @@ var routeScopes = map[string]string{
"GET /api/agent/latest-version": "servers:read",
"GET /api/audit": "settings:read",
// Both MCP routes require mcp:read as a floor. Individual tools require
// their own resource scope, and write tools additionally require mcp:write,
// enforced inside the handler because one route serves many operations.
"POST /api/mcp": "mcp:read",
"GET /api/mcp": "mcp:read",
"GET /api/settings": "settings:read",
"PUT /api/settings": "settings:write",
"POST /api/settings/secrets-token": "settings:write",
+394
View File
@@ -0,0 +1,394 @@
package api
import "fmt"
// scopeDecl is one route's declaration about server-derived data.
type scopeDecl int
const (
// scoped: the handler honours the acting token's tag restriction.
scoped scopeDecl = iota
// fleetWide: the route deliberately reaches the whole fleet. Every
// fleetWide entry carries a comment giving the reason. It must never mean
// "not scoped yet" — an unresolved gap belongs on a fix list, not here,
// because this value is read as a considered decision.
fleetWide
// exempt: the route touches no server-derived data at all. Every exempt
// entry carries a comment saying why, because "this reads no server data"
// is exactly the claim that turns out to be wrong when a handler later
// grows a server lookup.
exempt
)
// serverScopedRoutes declares, for EVERY registered /api route, whether it
// honours the acting token's tag restriction.
//
// The declaration is inverted from what it used to be. It was once a partial
// map checked only against routes whose path contained "server", "console" or
// "assign"; that filter caught one of the routes found leaking in the final
// review of this feature, and none of the eleven found during implementation,
// because a route can act on a server named in its body, in a query parameter
// or derived by the handler, with a path saying nothing about it. Every route
// must now appear here with an explicit value and boot fails on an undeclared
// one, so the question "does this touch server data?" is asked once per route
// by construction rather than when someone thinks to widen a pattern.
//
// Two things this assertion cannot do, and one of them has already bitten:
//
// 1. It can only ever check that a DECLARATION EXISTS, never that the handler
// honours it. "POST /api/workflows/:id/run" was declared scoped here while
// services.TriggerWorkflow resolved its targets through the unscoped
// ResolveTargets — a true entry that lied, boot-enforced, for the whole
// life of the feature. A declaration is a claim a reviewer must verify,
// not a property this file establishes.
//
// 2. /api/mcp is exempt at route level, and that is the honest answer rather
// than an omission. One route serves roughly twenty tools of very
// different shapes — some read no server data at all, some resolve one
// host, some enumerate the fleet — so no single route-level value could
// be true of all of them. The decision genuinely lives per tool, where
// each tool that touches server data applies auth.ServerScope's selector
// itself, and the registry's own tests are where that is enforced.
var serverScopedRoutes = map[string]scopeDecl{
// ---- servers ----
"GET /api/servers": scoped,
"GET /api/servers/:id": scoped,
"DELETE /api/servers/:id": scoped,
"POST /api/servers/:id/apply-updates": scoped,
"POST /api/servers/:id/update-agent": scoped,
"PUT /api/servers/:id/tags": scoped,
"POST /api/servers/:id/generate-key": scoped,
// Creating a server has no server to filter yet.
"POST /api/servers": fleetWide,
// The agent's own enrolment routes authenticate as the agent, not as a
// user token, so no session selector exists to apply.
"GET /api/servers/new": fleetWide,
"POST /api/servers/new": fleetWide,
// KnownTags aggregates the tag *vocabulary* in use across the fleet — keys
// and the values seen for them — never a server identifier or any other
// server attribute, so it does not let a restricted token enumerate which
// hosts exist. Filtering it would mean plumbing a selector through an
// aggregation query for a leak that carries no server identity; ruled
// acceptable to leave fleet-wide rather than take that on for this.
"GET /api/servers/tags": fleetWide,
// ---- console ----
"POST /api/console/connect": scoped,
"GET /api/console/tunnel": scoped,
// ---- workloads ----
// Workload routes all resolve the server through GetServerScoped before
// touching anything.
"GET /api/servers/:id/workloads": scoped,
"POST /api/servers/:id/workloads/refresh": scoped,
"POST /api/servers/:id/workloads/:wid/action": scoped,
"GET /api/servers/:id/workloads/:wid/logs": scoped,
// listWorkloads passes the caller's selector into services.SearchWorkloads,
// which drops hits on servers outside it. A WorkloadHit names a server ID,
// so the fleet-wide form enumerated hosts directly.
"GET /api/workloads": scoped,
// ---- vulnerabilities and packages ----
// listServerVulnerabilities and getServerPackages resolve the server
// through GetServerScoped before calling ListFindings/ListPackages, so an
// out-of-scope server ID reads as not-found before either function runs.
"GET /api/servers/:id/vulnerabilities": scoped,
"GET /api/servers/:id/packages": scoped,
// listVulnerabilities passes the selector as FindingFilter.TokenScope,
// narrowing server_id in the same query the Tags selector already narrows,
// and vulnerabilitySummary passes it to CountOpenFindingsBySeverity so the
// summary tiles count only visible servers.
"GET /api/vulnerabilities": scoped,
"GET /api/vulnerabilities/summary": scoped,
// searchPackages passes the caller's selector into services.SearchPackages,
// which drops hits on servers outside it using one VisibleServerIDs
// membership set. The MCP search_fleet tool answers the same question and
// was already scoped; this makes the REST twin agree.
"GET /api/packages/search": scoped,
// Rescan flags the whole fleet and returns a count of servers flagged, not
// their identities. Scanning is a control-plane background job with no
// caller-visible per-server effect, and a partial rescan would leave the
// findings a restricted token *can* see computed against a stale database.
// Owner|admin only in any case.
"POST /api/vulnerabilities/rescan": fleetWide,
// Accepting or reopening a finding names the finding, not a server, but a
// finding does belong to one — so a restricted token can accept a finding
// on a host outside its scope if it learns the finding ID. It cannot learn
// one through this API any more (every listing is now scoped), so this is
// left fleet-wide rather than given a lookup of its own. Owner|admin only.
"POST /api/vulnerabilities/:id/accept": fleetWide,
"DELETE /api/vulnerabilities/:id/accept": fleetWide,
// Vuln alert rules carry severities and tag selectors, never server IDs.
"GET /api/vuln-rules": exempt,
"POST /api/vuln-rules": exempt,
"PUT /api/vuln-rules/:id": exempt,
"DELETE /api/vuln-rules/:id": exempt,
// ---- keys ----
// getKey filters services.GetAssignmentsWithServers' result down to
// assignments whose server passes services.ServerInTokenScope before
// returning it, so a restricted token cannot learn the hostname of an
// out-of-scope server through a key it happens to also hold there. The
// key document itself is still returned unfiltered — a token restricted
// to staging may legitimately hold a key that is also assigned in prod,
// and only the assignment list, not the key's existence, is the leak
// this closes.
"GET /api/keys/:id": scoped,
// listKeys' services.ListKeys narrows each key's AssignedCount to
// assignments on servers ServerInTokenScope admits, for the same reason
// as getKey above: a nonzero count on a key a restricted token sees
// nothing assigned to in its own scope is itself the leak — it tells the
// token an assignment exists on a host it must not know about, without
// naming the host.
"GET /api/keys": scoped,
// assignKey resolves body.ServerID through GetServerScoped before calling
// services.AssignKey, and revokeAssignment resolves :serverId the same way
// before calling RevokeAssignment.
"POST /api/keys/:id/assign": scoped,
"DELETE /api/keys/:id/assign/:serverId": scoped,
// Uploading a key and reading its stored private half touch no server:
// a key exists in the library before it is assigned anywhere.
"POST /api/keys": exempt,
"GET /api/keys/:id/private-key": exempt,
// Deleting a key removes it everywhere it is assigned, including on hosts
// outside a restricted token's scope — the delete is of the key, not of a
// server, and there is no partial delete that leaves a key half-revoked.
// Nothing about which hosts held it is disclosed by the call.
"DELETE /api/keys/:id": fleetWide,
// ---- workflows, steps and runs ----
// listWorkflows/getWorkflow narrow Workflow.TargetServerIDs to what the
// caller's scope admits via services.VisibleServerIDs +
// FilterVisibleServerIDs, wrapped in WorkflowResponse so the JSON field
// name is unchanged. TargetTags is left untouched — the tag vocabulary
// itself is ruled acceptable to expose, unlike a resolved server ID.
// TargetsRestricted is set (with no count) whenever at least one target
// was dropped.
"GET /api/workflows": scoped,
"GET /api/workflows/:id": scoped,
// createWorkflow/updateWorkflow validate target_server_ids through
// services.validateTargetServers, which resolves each named ID with
// GetServerScoped — so a restricted token can neither save a workflow
// targeting a host outside its scope (which the scheduler, firing as the
// system, would otherwise run there) nor learn which IDs exist by the
// difference between "target server not found" and a successful save.
"POST /api/workflows": scoped,
"PUT /api/workflows/:id": scoped,
// runWorkflow passes auth.ServerScope into services.TriggerWorkflow, which
// resolves through ResolveTargetsScoped. Note the history: this entry read
// scoped for the whole life of the feature while TriggerWorkflow called
// the UNSCOPED ResolveTargets — see this file's header on what this
// assertion can and cannot prove.
"POST /api/workflows/:id/run": scoped,
// getRun and listWorkflowRuns narrow WorkflowRun.ServerRuns — each entry
// of which carries a ServerID and a Hostname — to what the caller's scope
// admits, setting servers_restricted (a boolean, never a count) when any
// entry was dropped.
"GET /api/runs/:runId": scoped,
"GET /api/workflows/:id/runs": scoped,
// getServerRunLog/streamServerRunLog resolve :serverId through
// GetServerScoped before reading anything from the log store, so a
// restricted token holding a valid runId still cannot read output from a
// server outside its scope.
"GET /api/runs/:runId/servers/:serverId/logs": scoped,
"GET /api/runs/:runId/servers/:serverId/logs/stream": scoped,
// Deleting a workflow, cancelling a run and arming a schedule all act on a
// definition rather than on a server, and none of them returns server
// data. Each can nevertheless reach a definition whose targets a
// restricted token cannot see — a cancel stops work on out-of-scope hosts,
// a schedule arms it there. That reach is real but bounded: the caller
// learns nothing about which hosts are involved (both /workflows listings
// are scoped), and a scope-narrowed variant of "cancel this run" would
// have to either half-cancel a run or refuse one whose targets are mixed,
// neither of which is a better answer than the current one. Recorded as a
// deliberate choice, not an oversight.
"DELETE /api/workflows/:id": fleetWide,
"POST /api/runs/:runId/cancel": fleetWide,
"PUT /api/workflows/:id/schedule": fleetWide,
"GET /api/workflows/:id/schedule/preview": exempt,
// A step is a script with declared inputs and outputs. It names no server
// and is not bound to one; targeting happens at the workflow level.
"GET /api/steps": exempt,
"POST /api/steps": exempt,
"PUT /api/steps/:id": exempt,
"DELETE /api/steps/:id": exempt,
"GET /api/steps/:id/export": exempt,
"POST /api/steps/import": exempt,
"POST /api/steps/parse": exempt,
"POST /api/steps/seed-defaults": exempt,
// StepUsageCounts counts workflows per step, never servers.
"GET /api/steps/usage": exempt,
// ---- monitors ----
// listMonitors/getMonitor redact models.Monitor.Runner to
// models.RunnerRestricted via services.RedactMonitorRunner when it names
// a server outside the caller's scope — Runner is literally a server ID
// for an agent-pushed monitor, so left unfiltered it discloses one
// directly. The monitor itself is still returned: a restricted operator
// may legitimately need to see that it exists and is up or down, so only
// the runner field goes neutral. Runner "server" (control-plane-run) is
// never touched — it names no server.
"GET /api/monitors": scoped,
"GET /api/monitors/:id": scoped,
// createMonitor/updateMonitor validate the runner — which is a server ID
// for an agent-pushed monitor — through services.validateRunner, resolving
// with GetServerScoped so a restricted token can neither point a check at
// an out-of-scope agent nor use the not-found answer as an oracle.
"POST /api/monitors": scoped,
"PUT /api/monitors/:id": scoped,
// Deleting a monitor removes the check, not a server, and returns nothing
// about where it ran. The runner field it might have named is already
// redacted on every read path, so a restricted token cannot learn one to
// then act on.
"DELETE /api/monitors/:id": fleetWide,
// A monitor's incidents, uptime rollups and recent samples are all about
// the monitored endpoint — status, latency, timestamps — and carry no
// server identifier at all; the runner is a field of the monitor
// document, which these do not return.
"GET /api/monitors/:id/incidents": exempt,
"GET /api/monitors/:id/uptime": exempt,
"GET /api/monitors/:id/samples": exempt,
// ---- notification channels ----
// A channel is an outbound destination — a webhook URL, an SMTP account.
// Nothing about a server reaches these routes.
"GET /api/channels": exempt,
"POST /api/channels": exempt,
"PUT /api/channels/:id": exempt,
"DELETE /api/channels/:id": exempt,
"POST /api/channels/:id/test": exempt,
// ---- secrets ----
// Vault secrets are key/value pairs grouped by name, consumed by workflow
// steps at execution time. No secret is bound to a server, and no server
// attribute is returned by any of these.
"GET /api/secrets": exempt,
"POST /api/secrets": exempt,
"GET /api/secrets/:group": exempt,
"PUT /api/secrets/:group": exempt,
"DELETE /api/secrets/:group": exempt,
"DELETE /api/secrets/:group/:key": exempt,
"POST /api/secrets/:group/reveal": exempt,
"GET /api/secrets/:group/values": exempt,
// ---- status pages ----
// A status page pairs monitor IDs with per-page display names, and every
// public read goes through services.assembleSnapshot, which is the
// redaction boundary — its PublicComponent vocabulary has no field for a
// host, URL or runner. These authoring routes handle the page document
// itself and never a server.
"GET /api/status-pages": exempt,
"POST /api/status-pages": exempt,
"GET /api/status-pages/:pageId": exempt,
"PUT /api/status-pages/:pageId": exempt,
"DELETE /api/status-pages/:pageId": exempt,
"GET /api/status-pages/:pageId/incidents": exempt,
"POST /api/status-pages/:pageId/incidents": exempt,
"PUT /api/status-pages/:pageId/incidents/:incidentId": exempt,
"DELETE /api/status-pages/:pageId/incidents/:incidentId": exempt,
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": exempt,
// ---- audit ----
// Audit rows are a record of what people and tokens did, and a row's free
// text detail can name a host in passing ("run <id> triggered", "key
// assigned to web-01"). Filtering the log by tag would mean parsing those
// strings, or dropping every row whose target this token cannot resolve —
// which would hide a restricted token's own actions from itself the
// moment a server is renamed or deleted. The log is left whole and
// deliberately so: an audit trail with holes in it is worth less than the
// disclosure is worth avoiding, and the route is settings:read.
"GET /api/audit": fleetWide,
// ---- instance administration ----
// Members, roles, single sign-on, settings and the licence are all
// instance-level configuration. None reads the servers collection.
"GET /api/instance/users": exempt,
"POST /api/instance/users": exempt,
"PUT /api/instance/users/:id/role": exempt,
"DELETE /api/instance/users/:id": exempt,
"GET /api/auth/providers": exempt,
"POST /api/auth/providers": exempt,
"PUT /api/auth/providers/:id": exempt,
"DELETE /api/auth/providers/:id": exempt,
"POST /api/auth/providers/:id/test": exempt,
"POST /api/auth/providers/:id/ack-notice": exempt,
"GET /api/auth/presets": exempt,
"GET /api/settings": exempt,
"PUT /api/settings": exempt,
"POST /api/settings/secrets-token": exempt,
"GET /api/license": exempt,
"POST /api/license": exempt,
// A token document carries a tag selector but no server: minting one
// checks the selector is no wider than the caller's own
// (services.SelectorNarrowerOrEqual), which reads the caller's session,
// not the fleet.
"GET /api/tokens": exempt,
"GET /api/tokens/scopes": exempt,
"POST /api/tokens": exempt,
"DELETE /api/tokens/:id": exempt,
// Reference documentation and the agent version lookup are static or read
// from a release feed.
"GET /api/openapi.json": exempt,
"GET /api/docs": exempt,
"GET /api/docs/scalar.js": exempt,
"GET /api/agent/latest-version": exempt,
// ---- MCP ----
// Exempt at route level, for the reason set out in this file's header:
// one route serves many tools, so the answer genuinely lives per tool.
// Each tool touching server data applies the caller's selector itself.
"POST /api/mcp": exempt,
"GET /api/mcp": exempt,
}
// AssertServerScopeMapComplete refuses to boot when any registered /api route
// is missing from serverScopedRoutes. routes is every /api route the engine
// registered — not a filtered subset — which is the whole point of the
// inversion: a new route is checked by default rather than only when its path
// happens to match a pattern somebody remembered to add.
func AssertServerScopeMapComplete(routes []string) error {
for _, r := range routes {
if _, ok := serverScopedRoutes[r]; !ok {
return fmt.Errorf("route %q is not declared in serverScopedRoutes "+
"(declare it scoped, fleetWide with a reason, or exempt with a reason)", r)
}
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package api
import "testing"
// The two maps must name exactly the same routes. AssertScopeMapComplete
// already fails boot on an /api route missing from routeScopes, so making
// serverScopedRoutes agree with routeScopes is what makes the inverted
// server-scope assertion total without needing a running engine to check it.
func TestServerScopeMapCoversEveryScopedRoute(t *testing.T) {
for r := range routeScopes {
if _, ok := serverScopedRoutes[r]; !ok {
t.Errorf("route %q is in routeScopes but not declared in serverScopedRoutes", r)
}
}
for r := range serverScopedRoutes {
if _, ok := routeScopes[r]; !ok {
t.Errorf("route %q is declared in serverScopedRoutes but is not a registered route", r)
}
}
}
// A route that vanished from the engine but stayed here would make the
// assertion pass while declaring nothing real, so the assertion itself is
// tested for the one thing it does promise: an undeclared route fails.
func TestAssertServerScopeMapCompleteRejectsUndeclaredRoute(t *testing.T) {
if err := AssertServerScopeMapComplete([]string{"GET /api/servers"}); err != nil {
t.Fatalf("declared route rejected: %v", err)
}
if err := AssertServerScopeMapComplete([]string{"GET /api/brand-new"}); err == nil {
t.Fatal("undeclared route accepted; boot would not fail on it")
}
}
+45 -7
View File
@@ -4,10 +4,12 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"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"
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
"github.com/gin-gonic/gin"
)
@@ -72,10 +74,11 @@ func listTokenScopes(c *gin.Context) {
// @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"`
Name string `json:"name" binding:"required"`
Role string `json:"role" binding:"required"`
Scopes []string `json:"scopes" binding:"required"`
ExpiresInDays *int `json:"expires_in_days"`
TagSelector map[string]string `json:"tag_selector"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -108,9 +111,40 @@ func createToken(c *gin.Context) {
}
}
// The MCP scopes are refused without the licence feature, matching the
// guard-at-source thinking in services/packages.go rather than relying on
// RequireFeature at the /api/mcp group alone. That gate is a runtime one:
// without this, a licence downgrade leaves live agent credentials that
// authenticate, list as MCP tokens in the UI, and then fail mid-
// conversation with a 403 the model cannot explain. Refusing at minting
// means a token carrying mcp:* only ever existed while the feature did.
//
// Existing tokens are deliberately untouched by a downgrade: the route
// gate already stops them reaching the endpoint, and silently revoking
// credentials on a billing change is worse than refusing new ones.
if !services.GetLicenseState(auth.InstanceID(c)).Feature(license.FeatureMCP) {
for _, s := range body.Scopes {
if strings.HasPrefix(s, "mcp:") {
c.JSON(http.StatusForbidden, gin.H{
"error": "feature_unavailable",
"feature": license.FeatureMCP,
"code": "feature_unavailable",
})
return
}
}
}
if !services.SelectorNarrowerOrEqual(body.TagSelector, auth.ServerScope(c)) {
c.JSON(http.StatusForbidden, gin.H{
"error": "a token cannot reach servers its creator cannot reach",
})
return
}
tok, plaintext, err := services.CreateAPIToken(
auth.InstanceID(c), auth.UserID(c),
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
body.Name, body.Role, body.Scopes, body.TagSelector, body.ExpiresInDays, c.ClientIP(),
)
switch {
case errors.Is(err, services.ErrTokenNameTaken):
@@ -137,8 +171,12 @@ func createToken(c *gin.Context) {
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))
detail := fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s",
tok.Name, tok.Role, tok.Scopes, expiry)
if len(tok.TagSelector) > 0 {
detail += fmt.Sprintf(", restricted to %v", tok.TagSelector)
}
services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "", detail)
// The plaintext is returned exactly once and is not stored anywhere.
c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok})
+55 -4
View File
@@ -114,6 +114,57 @@ type AgentVersionResponse struct {
Version string `json:"version"`
}
// WorkflowResponse is a workflow with its TargetServerIDs narrowed to what
// the acting token's scope admits — the explicit field shadows the embedded
// one for JSON marshalling, matching the pattern KeyDetailResponse already
// uses. TargetTags is not filtered: the tag vocabulary itself is ruled
// acceptable to expose, and only the resolved ID list can name a specific
// out-of-scope server.
//
// TargetsRestricted is set, with no count, when at least one target was
// dropped, so a caller reading this alongside run_workflow's all-or-nothing
// out-of-scope refusal sees why: the refusal is not inventing a problem the
// list never mentioned.
type WorkflowResponse struct {
*models.Workflow
TargetServerIDs []string `json:"target_server_ids"`
TargetsRestricted bool `json:"targets_restricted,omitempty"`
}
// RunResponse is a workflow run with its ServerRuns narrowed to the servers
// the acting token's scope admits. Each models.ServerRun carries both a
// ServerID and a Hostname, so an unfiltered run document names every host it
// touched — the same disclosure WorkflowResponse.TargetServerIDs closes one
// level up, and the parent of the per-server log routes that were already
// scoped.
//
// ServersRestricted follows the targets_restricted precedent exactly: a
// boolean and no count, because how many entries were dropped is itself
// information about a fleet the caller must not be able to size.
type RunResponse struct {
*models.WorkflowRun
ServerRuns []models.ServerRun `json:"server_runs"`
ServersRestricted bool `json:"servers_restricted,omitempty"`
}
// scopeRun narrows one run's ServerRuns using the (visible, restricted) pair
// services.VisibleServerIDs returns.
func scopeRun(r *models.WorkflowRun, visible map[string]bool, restricted bool) RunResponse {
if !restricted {
return RunResponse{WorkflowRun: r, ServerRuns: r.ServerRuns}
}
out := make([]models.ServerRun, 0, len(r.ServerRuns))
hidden := false
for _, sr := range r.ServerRuns {
if visible[sr.ServerID] {
out = append(out, sr)
} else {
hidden = true
}
}
return RunResponse{WorkflowRun: r, ServerRuns: out, ServersRestricted: hidden}
}
type UpdateAgentResponse struct {
Message string `json:"message"`
Version string `json:"version"`
@@ -121,14 +172,14 @@ type UpdateAgentResponse struct {
type AuditEventsResponse struct {
Events []models.AuditEvent `json:"events"`
Total int64 `json:"total"`
Total int64 `json:"total"`
}
// --- tokens ---
type ListTokensResponse struct {
Tokens []models.APIToken `json:"tokens"`
All bool `json:"all"`
All bool `json:"all"`
}
type TokenScopesResponse struct {
@@ -215,8 +266,8 @@ type RunWorkflowResponse struct {
}
type ScheduleResponse struct {
Schedule models.Schedule `json:"schedule"`
NextRunAt *time.Time `json:"next_run_at"`
Schedule models.Schedule `json:"schedule"`
NextRunAt *time.Time `json:"next_run_at"`
}
type OccurrencesResponse struct {
+20 -4
View File
@@ -49,6 +49,8 @@ func listVulnerabilities(c *gin.Context) {
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
HasFix: hasFixFromQuery(c),
TokenScope: auth.ServerScope(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -143,7 +145,7 @@ func tagsFromQuery(c *gin.Context) map[string]string {
// @Security bearerAuth
// @Router /vulnerabilities/summary [get]
func vulnerabilitySummary(c *gin.Context) {
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -290,7 +292,14 @@ func writeFindingError(c *gin.Context, err error) {
// @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"))
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
findings, err := services.ListFindings(c.Request.Context(), instanceID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -314,7 +323,14 @@ func listServerVulnerabilities(c *gin.Context) {
// @Security bearerAuth
// @Router /servers/{id}/packages [get]
func getServerPackages(c *gin.Context) {
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sp, err := services.ListPackages(instanceID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -347,7 +363,7 @@ func searchPackages(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
hits, err := services.SearchPackages(auth.InstanceID(c), name)
hits, err := services.SearchPackages(auth.InstanceID(c), name, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+52 -8
View File
@@ -67,6 +67,10 @@ func getServerRunLog(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if _, err := services.GetServerScoped(auth.InstanceID(c), serverID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if !services.HasServerRunLog(runID, serverID) {
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
return
@@ -117,6 +121,10 @@ func streamServerRunLog(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if _, err := services.GetServerScoped(auth.InstanceID(c), serverID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
@@ -434,7 +442,20 @@ func listWorkflows(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, wfs)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]WorkflowResponse, 0, len(wfs))
for i := range wfs {
w := wfs[i]
ids, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
out = append(out, WorkflowResponse{Workflow: &w, TargetServerIDs: ids, TargetsRestricted: hidden})
}
c.JSON(http.StatusOK, out)
}
// createWorkflow godoc
@@ -456,7 +477,7 @@ func createWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateWorkflow(auth.InstanceID(c), w)
out, err := services.CreateWorkflow(auth.InstanceID(c), w, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -482,7 +503,15 @@ func getWorkflow(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, w)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ids, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
c.JSON(http.StatusOK, WorkflowResponse{Workflow: w, TargetServerIDs: ids, TargetsRestricted: hidden})
}
// updateWorkflow godoc
@@ -505,7 +534,7 @@ func updateWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil {
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -552,7 +581,7 @@ func deleteWorkflow(c *gin.Context) {
// @Security bearerAuth
// @Router /workflows/{id}/run [post]
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c), auth.ServerScope(c))
if err != nil {
if errors.Is(err, services.ErrNoTargets) {
c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
@@ -589,7 +618,17 @@ func listWorkflowRuns(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, runs)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]RunResponse, 0, len(runs))
for i := range runs {
out = append(out, scopeRun(&runs[i], visible, restricted))
}
c.JSON(http.StatusOK, out)
}
// getRun godoc
@@ -598,7 +637,7 @@ func listWorkflowRuns(c *gin.Context) {
// @Tags workflows
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.WorkflowRun
// @Success 200 {object} RunResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
@@ -609,7 +648,12 @@ func getRun(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, r)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, scopeRun(r, visible, restricted))
}
// cancelRun godoc
+5 -5
View File
@@ -35,7 +35,7 @@ func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServer(instanceID, id); err != nil {
if _, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
@@ -77,7 +77,7 @@ func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -139,7 +139,7 @@ func controlWorkload(c *gin.Context) {
return
}
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -205,7 +205,7 @@ func getWorkloadLogs(c *gin.Context) {
tail = services.MaxWorkloadLogLines
}
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -245,7 +245,7 @@ func getWorkloadLogs(c *gin.Context) {
// @Router /workloads [get]
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))
c.Query("image"), c.Query("stack"), c.Query("state"), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+15 -3
View File
@@ -167,9 +167,10 @@ func sessionFromToken(c *gin.Context) (*Session, bool) {
Role: services.LowerRole(user.Role, tok.Role),
Email: user.Email,
Name: user.Email,
TokenID: tok.TokenID,
TokenName: tok.Name,
Scopes: tok.Scopes,
TokenID: tok.TokenID,
TokenName: tok.Name,
Scopes: tok.Scopes,
TokenScope: tok.TagSelector,
}, true
}
@@ -199,3 +200,14 @@ func Scopes(c *gin.Context) []string {
// IsToken reports whether this request authenticated with an API token rather
// than a browser session.
func IsToken(c *gin.Context) bool { return TokenID(c) != "" }
// ServerScope is the tag restriction the acting credential carries, or nil for
// an unrestricted token and for every cookie session. Callers pass it to
// services.ServerInTokenScope or services.IntersectSelectors — nil means the
// whole fleet, never nothing.
func ServerScope(c *gin.Context) map[string]string {
if s := GetSessionFromContext(c); s != nil {
return s.TokenScope
}
return nil
}
+5 -4
View File
@@ -23,13 +23,14 @@ type Session struct {
Email string `json:"email"`
Name string `json:"name"`
// The three fields below are set only when the request authenticated with
// The four 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:"-"`
TokenID string `json:"-"`
TokenName string `json:"-"`
Scopes []string `json:"-"`
TokenScope map[string]string `json:"-"`
}
var rdb *redis.Client
+138
View File
@@ -0,0 +1,138 @@
package mcp
import (
"errors"
"fmt"
"sort"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// FanOutLimit is how many servers a write tool may touch before it demands
// explicit confirmation. Cheap insurance against a mis-parsed selector reaching
// the whole fleet on one badly phrased instruction.
const FanOutLimit = 25
// Gate names for a write tool's own policy refusals, on top of GateMCPScope
// and GateResourceScope in registry.go. These name a decision this package
// made deliberately, so a human reading audit_logs can tell "the agent was
// stopped by policy" from "the agent tried and the machine failed".
const (
GateFanOut = "fan_out"
GateTagScope = "tag_selector"
)
// ErrConfirmRequired is returned to the model as a tool error it can act on:
// it says what would have happened and how to proceed deliberately.
var ErrConfirmRequired = errors.New("confirmation required")
// ErrOutOfScope wraps a write tool's refusal to act because the resolved (or,
// for run_workflow, the workflow's configured) targets are not entirely
// within the calling token's tag restriction. Handlers wrap this rather than
// returning a bare error so registerSDKTool can tell a scope refusal apart
// from an ordinary service failure and audit it as GateTagScope.
var ErrOutOfScope = errors.New("targets outside token scope")
// logEvent is services.LogEvent behind a package variable so tests can
// observe what would have been audited without a live database connection —
// services.LogEvent talks straight to Mongo via db.Col, which panics on a nil
// client outside a real boot.
var logEvent = services.LogEvent
// CheckFanOut refuses a write that would touch more servers than FanOutLimit
// unless the call passed confirm:true.
func CheckFanOut(count int, args map[string]any) error {
if count <= FanOutLimit {
return nil
}
if confirm, ok := args["confirm"].(bool); ok && confirm {
return nil
}
return fmt.Errorf("%w: this would affect %d servers, above the limit of %d; "+
"call again with confirm:true if that is intended",
ErrConfirmRequired, count, FanOutLimit)
}
// SummariseArgs renders an argument object as a short, deterministic,
// bounded string for the audit log. Values are described rather than
// reproduced: an argument may carry arbitrary text a model generated.
func SummariseArgs(args map[string]any) string {
if len(args) == 0 {
return "no arguments"
}
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+summariseValue(args[k]))
}
out := strings.Join(parts, " ")
if len(out) > 200 {
out = out[:197] + "..."
}
return out
}
func summariseValue(v any) string {
switch t := v.(type) {
case string:
if len(t) > 40 {
return fmt.Sprintf("<%d chars>", len(t))
}
return t
case bool, float64, int:
return fmt.Sprint(t)
case []any:
return fmt.Sprintf("<%d items>", len(t))
case map[string]any:
return fmt.Sprintf("<%d fields>", len(t))
default:
return "<value>"
}
}
// LogCall records a successful tool call. Reads are recorded as well as writes:
// the point of an agent-facing surface is being able to reconstruct afterwards
// what the agent looked at, not only what it changed.
func LogCall(c Caller, t Tool, args map[string]any, servers int) {
detail := fmt.Sprintf("tool %s (%s)", t.Name, SummariseArgs(args))
if servers > 0 {
detail += fmt.Sprintf(", %d server(s) affected", servers)
}
logEvent(c.InstanceID, "mcp.tool_call", c.TokenName, "", "", detail)
}
// LogDenied records a refusal and which gate refused, which is what turns "the
// agent said it could not" into a diagnosable event.
func LogDenied(c Caller, toolName, gate string) {
logEvent(c.InstanceID, "mcp.tool_denied", c.TokenName, "", "",
fmt.Sprintf("tool %s refused by %s", toolName, gate))
}
// LogFailure records a write tool call that reached a service and that
// service returned an error — as opposed to LogDenied, which records a
// policy refusal that never reached one. Distinguishing the two in
// audit_logs is what lets a human reading it tell "the agent was stopped"
// from "the agent tried and the machine failed".
func LogFailure(c Caller, t Tool, args map[string]any, err error) {
logEvent(c.InstanceID, "mcp.tool_failed", c.TokenName, "", "",
fmt.Sprintf("tool %s (%s) failed: %v", t.Name, SummariseArgs(args), err))
}
// LogCreated records a definition an agent added.
//
// It is a distinct event type rather than another mcp.tool_call row because of
// the question a human will actually ask, which is "what has this agent added
// to my instance" — an answer buried among hundreds of read rows is not an
// answer.
func LogCreated(c Caller, kind, id, name string) {
logEvent(c.InstanceID, "mcp.created", c.TokenName, "", "",
fmt.Sprintf("created %s %q (%s)", kind, name, id))
}
+45
View File
@@ -0,0 +1,45 @@
package mcp
import (
"strings"
"testing"
)
// Arguments can carry arbitrary model output and the audit log is read by
// humans in a UI, so they are summarised rather than dumped.
func TestSummariseArgsIsBoundedAndOrdered(t *testing.T) {
got := SummariseArgs(map[string]any{
"workflow_id": "wf-1",
"note": strings.Repeat("x", 500),
})
if len(got) > 200 {
t.Errorf("summary is %d chars, want at most 200", len(got))
}
if !strings.Contains(got, "workflow_id") {
t.Errorf("summary %q omits an argument name", got)
}
// Deterministic ordering, or two identical calls produce different audit
// rows and nothing can be compared.
if SummariseArgs(map[string]any{"b": 1, "a": 2}) != SummariseArgs(map[string]any{"a": 2, "b": 1}) {
t.Error("SummariseArgs is not deterministic")
}
}
func TestCheckFanOutRequiresConfirmation(t *testing.T) {
if err := CheckFanOut(5, nil); err != nil {
t.Errorf("CheckFanOut(5) = %v, want nil", err)
}
err := CheckFanOut(200, nil)
if err == nil {
t.Fatal("CheckFanOut(200) = nil, want a refusal")
}
if !strings.Contains(err.Error(), "200") {
t.Errorf("refusal %q does not say how many servers", err)
}
if err := CheckFanOut(200, map[string]any{"confirm": true}); err != nil {
t.Errorf("CheckFanOut(200, confirm) = %v, want nil", err)
}
}
+224
View File
@@ -0,0 +1,224 @@
// Package mcp exposes Vantage to LLM agents over the Model Context Protocol.
//
// It is a presentation layer over the service layer and introduces no authority
// of its own: every tool calls the same service functions the REST handlers
// call, and every decision about who may do what is made by machinery that
// already exists. Three gates apply to every call — the licence feature, the
// mcp:* scope, and the tool's own resource scope — and all three must pass.
package mcp
import (
"context"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// Gate names, returned by Allowed so a refusal can be audited and explained to
// the model in words it can act on.
const (
GateMCPScope = "mcp_scope"
GateResourceScope = "resource_scope"
)
// ToolFunc is one tool's implementation. args is the decoded argument object;
// the returned value is marshalled as the tool result.
type ToolFunc func(ctx context.Context, c Caller, args map[string]any) (any, error)
// Caller is the acting credential, built from the gin session by the transport
// layer. The mcp package never reads a request or a cookie itself.
type Caller struct {
InstanceID string
Scopes []string
TokenScope map[string]string
TokenName string
}
// ArgType is the JSON type of one declared tool argument. The set is closed
// deliberately: these are the only shapes the argument helpers in this package
// (stringArg, stringSliceArg, tagArg, pageLimit) can actually decode, so a
// schema promising anything else would advertise an argument no handler could
// read.
type ArgType string
const (
ArgString ArgType = "string"
ArgInteger ArgType = "integer"
ArgBoolean ArgType = "boolean"
ArgStringArray ArgType = "string_array"
// ArgTagMap is a flat object of string tag keys to string values, which is
// what tagArg decodes.
ArgTagMap ArgType = "tag_map"
// ArgObject is a free-form object whose inner shape the tool documents in
// the argument description — create_monitor's target, whose fields differ
// per monitor type.
ArgObject ArgType = "object"
)
// ToolArg declares one argument a tool actually reads.
//
// Without this, a tool's arguments existed only in prose inside its
// Description and in the handler's args[...] lookups: no client could discover
// limit, tags, cursor, confirm, server_ids or any of the rest, so a model had
// to guess them from the description or not use them at all. Declaring them
// here also gets the SDK to validate and reject a malformed call before the
// handler runs, which is where a required-argument check belongs.
type ToolArg struct {
Name string
Type ArgType
Description string
Required bool
}
// Tool is one registered capability.
type Tool struct {
Name string
// Description is prompt text the model reads to choose a tool, so it states
// blast radius in plain words rather than describing an endpoint.
Description string
// Scope is the resource scope required, e.g. "servers:read".
Scope string
// Write marks a tool that changes something. A write tool is omitted from
// the listing for a caller without mcp:write.
Write bool
// Args declares every argument the handler reads, in the order a client
// should see them. A tool taking none declares an empty slice, which is
// distinct from "nobody has written the schema yet" — see the registry
// tests, which require the declaration to be deliberate.
Args []ToolArg
// TouchesServers marks a tool that returns or acts on server-derived data:
// a hostname, a server ID, a package list, a run's per-server output. Such
// a tool must apply Caller.TokenScope, through GetServerScoped,
// ResolveTargetsScoped, ListServersFiltered or VisibleServerIDs.
//
// Like serverScopedRoutes in the api package, this can only ever assert
// that a declaration exists, never that the handler honours it — get_run_logs
// proved the run's instance and the server's membership in the run and then
// read production stdout for a staging token. What it does buy is that
// adding a tool forces an answer to "does this touch server data?", and the
// registry test names every tool that says yes, so the set cannot grow
// without a reviewer seeing it.
TouchesServers bool
Handler ToolFunc
}
// InputSchema renders the tool's declared arguments as a JSON Schema object,
// which is what a client reads from tools/list to know what to send.
//
// It returns a map rather than a typed schema so this file stays free of the
// MCP SDK; the transport hands it straight to the SDK, which remarshals it.
// additionalProperties is left open: several handlers accept confirm on top of
// their own arguments through CheckFanOut, and a strict object would refuse a
// call the fan-out guard is there to handle.
func (t Tool) InputSchema() map[string]any {
props := map[string]any{}
var required []string
for _, a := range t.Args {
p := map[string]any{"description": a.Description}
switch a.Type {
case ArgStringArray:
p["type"] = "array"
p["items"] = map[string]any{"type": "string"}
case ArgTagMap:
p["type"] = "object"
p["additionalProperties"] = map[string]any{"type": "string"}
case ArgObject:
p["type"] = "object"
default:
p["type"] = string(a.Type)
}
props[a.Name] = p
if a.Required {
required = append(required, a.Name)
}
}
schema := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
// Registry holds the tool set in registration order, which is the order a
// client sees.
type Registry struct {
order []string
tools map[string]Tool
}
func NewRegistry() *Registry {
return &Registry{tools: map[string]Tool{}}
}
func (r *Registry) Register(t Tool) {
if _, exists := r.tools[t.Name]; exists {
panic("mcp: duplicate tool " + t.Name)
}
r.order = append(r.order, t.Name)
r.tools[t.Name] = t
}
func (r *Registry) Lookup(name string) (Tool, bool) {
t, ok := r.tools[name]
return t, ok
}
// Tools returns every registered tool regardless of caller, for tests and
// documentation generation.
func (r *Registry) Tools() []Tool {
out := make([]Tool, 0, len(r.order))
for _, n := range r.order {
out = append(out, r.tools[n])
}
return out
}
// Visible is what this caller's tools/list returns.
func (r *Registry) Visible(c Caller) []Tool {
out := []Tool{}
for _, t := range r.Tools() {
if ok, _ := Allowed(t, c); ok {
out = append(out, t)
}
}
return out
}
// Allowed reports whether this caller may invoke this tool, and names the gate
// that refused when they may not.
//
// The licence gate is not checked here: it is route middleware, so a caller
// reaching this code has already passed it.
func Allowed(t Tool, c Caller) (bool, string) {
required := "mcp:read"
if t.Write {
required = "mcp:write"
}
if !services.ScopeSatisfied(c.Scopes, required) {
return false, GateMCPScope
}
if !services.ScopeSatisfied(c.Scopes, t.Scope) {
return false, GateResourceScope
}
return true, ""
}
func knownScope(s string) bool {
resource, action, ok := strings.Cut(s, ":")
if !ok || (action != services.ScopeRead && action != services.ScopeWrite) {
return false
}
for _, r := range services.ScopeResources {
if r == resource {
return true
}
}
return false
}
// all is the process-wide registry the tool files populate from their init
// functions, and the transport serves.
var all = NewRegistry()
// All returns the process-wide registry.
func All() *Registry { return all }
+148
View File
@@ -0,0 +1,148 @@
package mcp
import (
"encoding/json"
"strings"
"testing"
)
// Every tool must declare its arguments. A nil Args is "nobody wrote the
// schema", which is what the whole tool set looked like before: descriptions
// promised limit, tags, confirm, server_ids and the rest, and tools/list
// advertised none of them, so no client could discover an argument and a model
// had to guess. An empty (but non-nil) slice is the deliberate "takes none".
func TestEveryToolDeclaresArgs(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Args == nil {
t.Errorf("tool %q declares no Args; use []ToolArg{} if it truly takes none", tool.Name)
}
}
}
func TestToolArgsAreWellFormed(t *testing.T) {
valid := map[ArgType]bool{
ArgString: true, ArgInteger: true, ArgBoolean: true,
ArgStringArray: true, ArgTagMap: true, ArgObject: true,
}
for _, tool := range All().Tools() {
seen := map[string]bool{}
for _, a := range tool.Args {
if a.Name == "" {
t.Errorf("tool %q has an argument with no name", tool.Name)
}
if seen[a.Name] {
t.Errorf("tool %q declares argument %q twice", tool.Name, a.Name)
}
seen[a.Name] = true
if !valid[a.Type] {
t.Errorf("tool %q argument %q has unknown type %q", tool.Name, a.Name, a.Type)
}
if strings.TrimSpace(a.Description) == "" {
t.Errorf("tool %q argument %q has no description; the description is what a model reads", tool.Name, a.Name)
}
}
}
}
// The schema has to survive JSON marshalling, because that is the only form a
// client ever sees it in.
func TestInputSchemaMarshals(t *testing.T) {
for _, tool := range All().Tools() {
schema := tool.InputSchema()
if schema["type"] != "object" {
t.Errorf("tool %q schema is not an object", tool.Name)
}
b, err := json.Marshal(schema)
if err != nil {
t.Errorf("tool %q schema does not marshal: %v", tool.Name, err)
continue
}
props, _ := schema["properties"].(map[string]any)
for _, a := range tool.Args {
if _, ok := props[a.Name]; !ok {
t.Errorf("tool %q declares argument %q but the schema omits it", tool.Name, a.Name)
}
}
if len(tool.Args) > 0 && !strings.Contains(string(b), tool.Args[0].Name) {
t.Errorf("tool %q schema lost argument %q in marshalling", tool.Name, tool.Args[0].Name)
}
}
}
// serverTouchingTools names every tool that returns or acts on server-derived
// data. The test below pins the registry against it, so a tool added that
// reads a hostname, a server ID, a package list or a run's per-server output
// fails until somebody declares TouchesServers and — the point of the exercise
// — decides how it applies Caller.TokenScope.
//
// This is the assertion that would have caught get_run_logs, which proved the
// run's instance and the named server's membership in the run and then read
// production stdout for a token restricted to staging. Declaring the flag does
// not prove the handler is scoped; it puts the question in front of a reviewer
// at the moment the tool is written, which is the same bargain
// api.serverScopedRoutes makes.
var serverTouchingTools = map[string]bool{
"list_servers": true,
"get_server": true,
"list_monitors": true, // Runner is a server ID; redacted out of scope.
"get_monitor_status": true, // same.
"list_workflows": true, // target server IDs.
"get_workflow": true, // same.
"get_run": true, // per-server run status.
"get_run_logs": true, // a named server's stdout.
"list_pending_updates": true,
"list_vulnerabilities": true, // affected-host counts.
"get_server_packages": true,
"search_fleet": true,
"run_workflow": true,
"apply_updates": true,
"update_agent": true,
"assign_key": true,
"create_workflow": true, // saves a target server list.
}
func TestServerTouchingToolsAreDeclared(t *testing.T) {
for _, tool := range All().Tools() {
want := serverTouchingTools[tool.Name]
if tool.TouchesServers != want {
if want {
t.Errorf("tool %q is listed as touching server data but does not declare TouchesServers", tool.Name)
} else {
t.Errorf("tool %q declares TouchesServers but is not in serverTouchingTools; "+
"add it there, having first checked it applies Caller.TokenScope", tool.Name)
}
}
}
registered := map[string]bool{}
for _, tool := range All().Tools() {
registered[tool.Name] = true
}
for name := range serverTouchingTools {
if !registered[name] {
t.Errorf("serverTouchingTools names %q, which is not a registered tool", name)
}
}
}
// A tool that touches server data and takes a server_ids or tags selector must
// also offer confirm, or the fan-out guard has no way to be satisfied and a
// legitimate fleet-wide call is unrefusable rather than merely confirmed.
func TestFanOutToolsOfferConfirm(t *testing.T) {
for _, tool := range All().Tools() {
if !tool.Write {
continue
}
selector, confirm := false, false
for _, a := range tool.Args {
switch a.Name {
case "server_ids", "tags":
selector = true
case "confirm":
confirm = true
}
}
if selector && !confirm {
t.Errorf("write tool %q takes a server selector but declares no confirm argument", tool.Name)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package mcp
import "testing"
func testRegistry() *Registry {
r := NewRegistry()
r.Register(Tool{Name: "list_servers", Scope: "servers:read", Write: false})
r.Register(Tool{Name: "run_workflow", Scope: "workflows:write", Write: true})
return r
}
// A token without mcp:read is not an agent token, whatever else it holds.
func TestNoMCPScopeSeesNothing(t *testing.T) {
c := Caller{Scopes: []string{"servers:read", "workflows:write"}}
if got := testRegistry().Visible(c); len(got) != 0 {
t.Errorf("Visible = %d tools, want 0", len(got))
}
}
// Write tools are OMITTED from the listing, not merely refused on call: an
// agent cannot be talked into using a tool it has never been told exists.
func TestReadOnlyCallerCannotSeeWriteTools(t *testing.T) {
c := Caller{Scopes: []string{"mcp:read", "servers:read", "workflows:write"}}
names := map[string]bool{}
for _, tool := range testRegistry().Visible(c) {
names[tool.Name] = true
}
if !names["list_servers"] {
t.Error("list_servers hidden from a read-capable caller")
}
if names["run_workflow"] {
t.Error("run_workflow listed without mcp:write")
}
}
func TestWriteCallerSeesBoth(t *testing.T) {
c := Caller{Scopes: []string{"mcp:write", "servers:read", "workflows:write"}}
if got := testRegistry().Visible(c); len(got) != 2 {
t.Errorf("Visible = %d tools, want 2", len(got))
}
}
// The resource scope is enforced independently of the MCP scope.
func TestResourceScopeStillRequired(t *testing.T) {
c := Caller{Scopes: []string{"mcp:write", "servers:read"}}
for _, tool := range testRegistry().Visible(c) {
if tool.Name == "run_workflow" {
t.Error("run_workflow listed without workflows:write")
}
}
run, _ := testRegistry().Lookup("run_workflow")
ok, gate := Allowed(run, c)
if ok {
t.Error("run_workflow allowed without workflows:write")
}
if gate != GateResourceScope {
t.Errorf("gate = %q, want %q", gate, GateResourceScope)
}
}
func TestAllowedNamesTheMCPGate(t *testing.T) {
c := Caller{Scopes: []string{"servers:read"}}
list, _ := testRegistry().Lookup("list_servers")
ok, gate := Allowed(list, c)
if ok {
t.Error("call allowed without mcp:read")
}
if gate != GateMCPScope {
t.Errorf("gate = %q, want %q", gate, GateMCPScope)
}
}
// Every tool must declare a scope from the real vocabulary, or a tool added
// tomorrow could be reachable with no resource scope at all.
func TestEveryRegisteredToolDeclaresAKnownScope(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Scope == "" {
t.Errorf("tool %q declares no scope", tool.Name)
continue
}
if !knownScope(tool.Scope) {
t.Errorf("tool %q declares unknown scope %q", tool.Name, tool.Scope)
}
if tool.Description == "" {
t.Errorf("tool %q has no description; descriptions are prompt text", tool.Name)
}
}
}
+288
View File
@@ -0,0 +1,288 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// SourceMCP marks a definition an agent wrote. models.WorkflowStep already
// carries a Source field for exactly this kind of provenance, so the UI can
// badge agent-authored steps without a schema change.
const SourceMCP = "mcp"
// buildStep validates the arguments and returns the step to create.
//
// It is pure so that every refusal below is testable without a database, and
// separate from the handler so the handler is only plumbing.
func buildStep(args map[string]any) (models.WorkflowStep, error) {
name := stringArg(args, "name")
interpreter := stringArg(args, "interpreter")
script := stringArg(args, "script")
if name == "" {
return models.WorkflowStep{}, fmt.Errorf("name is required")
}
if interpreter == "" {
return models.WorkflowStep{}, fmt.Errorf("interpreter is required, for example bash or powershell")
}
if script == "" {
return models.WorkflowStep{}, fmt.Errorf("script is required")
}
if len(stringSliceArg(args, "secret_refs")) > 0 {
return models.WorkflowStep{}, fmt.Errorf(
"a step created through MCP cannot reference secrets; " +
"add the secret reference in the Vantage UI after reviewing the script")
}
return models.WorkflowStep{
Name: name,
Description: stringArg(args, "description"),
Interpreter: interpreter,
Script: script,
Source: SourceMCP,
}, nil
}
// buildWorkflow validates the arguments and returns the workflow to create.
func buildWorkflow(args map[string]any) (models.Workflow, error) {
name := stringArg(args, "name")
if name == "" {
return models.Workflow{}, fmt.Errorf("name is required")
}
if _, scheduled := args["schedule"]; scheduled {
return models.Workflow{}, fmt.Errorf(
"a workflow created through MCP cannot be scheduled; " +
"create it, review it, then set a schedule in the Vantage UI")
}
stepIDs := stringSliceArg(args, "step_ids")
if len(stepIDs) == 0 {
return models.Workflow{}, fmt.Errorf("step_ids must name at least one existing step; create steps first with create_step")
}
// Order comes from the array order rather than from a field, because step
// order is the whole meaning of a workflow and is not worth asking a model
// to restate correctly. OnFailure defaults to "stop", the same default the
// workflow runner falls back to when a saved ref leaves it blank (see
// resolveInlineStep/resolveLibStep in workflow_runner.go).
steps := make([]models.WorkflowStepRef, 0, len(stepIDs))
for i, id := range stepIDs {
steps = append(steps, models.WorkflowStepRef{
StepID: id,
Order: i,
OnFailure: "stop",
})
}
return models.Workflow{
Name: name,
TargetServerIDs: stringSliceArg(args, "server_ids"),
TargetTags: tagArg(args),
Steps: steps,
}, nil
}
// buildMonitor validates the arguments and returns the monitor to create.
func buildMonitor(args map[string]any) (models.Monitor, error) {
name := stringArg(args, "name")
monitorType := stringArg(args, "type")
if name == "" {
return models.Monitor{}, fmt.Errorf("name is required")
}
if monitorType == "" {
return models.Monitor{}, fmt.Errorf("type is required")
}
rawTarget, ok := args["target"].(map[string]any)
if !ok || len(rawTarget) == 0 {
return models.Monitor{}, fmt.Errorf("target is required")
}
target := models.MonitorTarget{
URL: stringArg(rawTarget, "url"),
Host: stringArg(rawTarget, "host"),
Method: stringArg(rawTarget, "method"),
Keyword: stringArg(rawTarget, "keyword"),
}
if n, ok := rawTarget["port"].(float64); ok {
target.Port = int(n)
}
if n, ok := rawTarget["expected_status"].(float64); ok {
target.ExpectedStatus = int(n)
}
if n, ok := rawTarget["tls_warn_days"].(float64); ok {
target.TLSWarnDays = int(n)
}
if b, ok := rawTarget["insecure"].(bool); ok {
target.Insecure = b
}
switch monitorType {
case models.MonitorHTTP, models.MonitorTLS:
if target.URL == "" {
return models.Monitor{}, fmt.Errorf("target.url is required for a %s monitor", monitorType)
}
case models.MonitorTCP, models.MonitorICMP:
if target.Host == "" {
return models.Monitor{}, fmt.Errorf("target.host is required for a %s monitor", monitorType)
}
if monitorType == models.MonitorTCP && target.Port == 0 {
return models.Monitor{}, fmt.Errorf("target.port is required for a tcp monitor")
}
default:
return models.Monitor{}, fmt.Errorf("unknown monitor type %q", monitorType)
}
// Runner is deliberately not settable from a tool call, and this refusal
// makes that explicit rather than leaving it safe by omission. A runner
// is a server ID: accepting one would let an agent push a check onto a
// named agent, and a silently ignored argument would leave a model
// believing it had. services.CreateMonitor now validates a runner
// through GetServerScoped as well, so this is a second line rather than
// the only one — but the clearer answer belongs here.
if _, present := args["runner"]; present {
return models.Monitor{}, fmt.Errorf("runner cannot be set from here; monitors created this way always run on the control plane")
}
interval := 60
if n, ok := args["interval_sec"].(float64); ok && int(n) > 0 {
interval = int(n)
}
return models.Monitor{
Name: name,
Group: stringArg(args, "group"),
Type: monitorType,
Target: target,
IntervalSec: interval,
// Never armed on creation. A monitor that started enabled would begin
// alerting real people the moment a model invented it, and creating
// must stay a separate decision from acting.
Enabled: false,
}, nil
}
func init() {
All().Register(Tool{
Name: "create_step",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the step.", Required: true},
{Name: "interpreter", Type: ArgString, Description: "Interpreter to run the script with, e.g. bash or powershell.", Required: true},
{Name: "script", Type: ArgString, Description: "The script body. It is parsed and scanned exactly as the UI does; secret_refs are refused.", Required: true},
{Name: "description", Type: ArgString, Description: "What the step does, for a human reading the library later."},
},
Write: true,
Scope: "workflows:write",
Description: "Create a reusable workflow step: a named script with an interpreter. " +
"The step is SAVED to this Vantage instance but is not run by creating it — " +
"add it to a workflow with create_workflow, then run that with run_workflow. " +
"Steps created this way cannot reference secrets.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
step, err := buildStep(args)
if err != nil {
return nil, err
}
created, err := services.CreateStep(c.InstanceID, step)
if err != nil {
return nil, fmt.Errorf("could not create the step: %w", err)
}
LogCreated(c, "step", created.StepID, created.Name)
return map[string]any{
"step_id": created.StepID,
"name": created.Name,
"note": "Saved but not run. Reference this step_id from create_workflow.",
}, nil
},
})
All().Register(Tool{
Name: "create_workflow",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the workflow.", Required: true},
{Name: "step_ids", Type: ArgStringArray, Description: "IDs of existing steps, in the order they should run.", Required: true},
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "workflows:write",
Description: "Create a workflow from existing step IDs, in the order given, targeting " +
"servers by ID or by tags. The workflow is SAVED but not run and cannot be " +
"created with a schedule; run it explicitly with run_workflow.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
wf, err := buildWorkflow(args)
if err != nil {
return nil, err
}
// Targets are stored, not resolved at run time, so a workflow
// cannot be saved pointing at servers this token cannot reach.
// run_workflow later refuses to run a saved workflow unless the
// scoped view of its targets covers every server the unscoped
// resolution would touch; this mirrors that same all-or-nothing
// check at creation time so a workflow this token could not run
// is never created in the first place.
if len(wf.TargetServerIDs) > 0 || len(wf.TargetTags) > 0 {
allTargets, err := services.ResolveTargets(c.InstanceID, wf.TargetServerIDs, wf.TargetTags)
if err != nil {
return nil, fmt.Errorf("this workflow matches no servers")
}
scopedTargets, err := services.ResolveTargetsScoped(c.InstanceID, wf.TargetServerIDs, wf.TargetTags, c.TokenScope)
if err != nil || len(scopedTargets) != len(allTargets) {
return nil, fmt.Errorf("%w: no servers visible to this token matched the requested targets", ErrOutOfScope)
}
if err := CheckFanOut(len(scopedTargets), args); err != nil {
return nil, err
}
}
created, err := services.CreateWorkflow(c.InstanceID, wf, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not create the workflow: %w", err)
}
LogCreated(c, "workflow", created.WorkflowID, created.Name)
return map[string]any{
"workflow_id": created.WorkflowID,
"name": created.Name,
"steps": len(created.Steps),
"note": "Saved but not run and not scheduled. Call run_workflow to run it.",
}, nil
},
})
All().Register(Tool{
Name: "create_monitor",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the monitor.", Required: true},
{Name: "type", Type: ArgString, Description: "Check type: http, tcp, icmp or tls.", Required: true},
{Name: "target", Type: ArgObject, Description: "What to check. http/tls take url; tcp/icmp take host, and tcp also port. Optional: method, keyword, expected_status, tls_warn_days, insecure.", Required: true},
{Name: "group", Type: ArgString, Description: "Optional group name to file the monitor under."},
{Name: "interval_sec", Type: ArgInteger, Description: "Seconds between checks; defaults to 60."},
},
Write: true,
Scope: "monitors:write",
Description: "Create a monitor. It is SAVED DISABLED and will not check anything or " +
"send any alert until a human enables it in the Vantage UI, so proposing a " +
"monitor is safe.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
m, err := buildMonitor(args)
if err != nil {
return nil, err
}
created, err := services.CreateMonitor(c.InstanceID, &m, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not create the monitor: %w", err)
}
LogCreated(c, "monitor", created.MonitorID, created.Name)
return map[string]any{
"monitor_id": created.MonitorID,
"name": created.Name,
"enabled": false,
"note": "Created disabled. Enable it in Vantage to start checking.",
}, nil
},
})
}
+228
View File
@@ -0,0 +1,228 @@
package mcp
import (
"strings"
"testing"
)
func TestCreationToolsAreRegisteredAsWrites(t *testing.T) {
for _, name := range []string{"create_step", "create_workflow", "create_monitor"} {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if !tool.Write {
t.Errorf("tool %q is not marked as a write", name)
}
}
}
// An agent may add a definition. It may never alter or remove one a human
// wrote, and the cheapest guard against that is the tool simply not existing.
func TestNoUpdateOrDeleteTools(t *testing.T) {
for _, tool := range All().Tools() {
n := tool.Name
if strings.HasPrefix(n, "update_") && n != "update_agent" {
t.Errorf("tool %q edits an existing definition", n)
}
if strings.HasPrefix(n, "delete_") || strings.HasPrefix(n, "remove_") {
t.Errorf("tool %q deletes a definition", n)
}
}
}
// Composing a script around a secret reference is how a credential ends up
// echoed into a log.
func TestCreateStepRejectsSecretRefs(t *testing.T) {
step, err := buildStep(map[string]any{
"name": "leaky",
"interpreter": "bash",
"script": "echo hello",
"secret_refs": []any{"prod/db"},
})
if err == nil {
t.Fatalf("buildStep accepted secret_refs, got %+v", step)
}
if !strings.Contains(err.Error(), "secret") {
t.Errorf("error %q does not explain the refusal", err)
}
}
func TestCreateStepRequiresScriptAndInterpreter(t *testing.T) {
if _, err := buildStep(map[string]any{"name": "x", "script": "echo hi"}); err == nil {
t.Error("buildStep accepted a step with no interpreter")
}
if _, err := buildStep(map[string]any{"name": "x", "interpreter": "bash"}); err == nil {
t.Error("buildStep accepted a step with no script")
}
}
// Steps a model wrote are badged in the UI, so a human can tell at a glance
// what came from an agent.
func TestCreatedStepIsMarkedAgentAuthored(t *testing.T) {
step, err := buildStep(map[string]any{
"name": "patch", "interpreter": "bash", "script": "apt-get update",
})
if err != nil {
t.Fatal(err)
}
if step.Source != "mcp" {
t.Errorf("Source = %q, want %q", step.Source, "mcp")
}
}
// Creating and acting stay two decisions: a created workflow cannot arrive
// already scheduled.
func TestCreateWorkflowRefusesASchedule(t *testing.T) {
_, err := buildWorkflow(map[string]any{
"name": "nightly",
"step_ids": []any{"step-1"},
"schedule": map[string]any{"cron": "0 3 * * *"},
})
if err == nil {
t.Fatal("buildWorkflow accepted a schedule")
}
if !strings.Contains(err.Error(), "schedule") {
t.Errorf("error %q does not explain the refusal", err)
}
}
func TestCreateWorkflowRequiresSteps(t *testing.T) {
if _, err := buildWorkflow(map[string]any{"name": "empty"}); err == nil {
t.Error("buildWorkflow accepted a workflow with no steps")
}
}
// Step order is the whole meaning of a workflow, so it comes from the array
// order rather than from a field a model has to get right.
func TestBuildWorkflowNumbersStepsInOrder(t *testing.T) {
wf, err := buildWorkflow(map[string]any{
"name": "three",
"step_ids": []any{"a", "b", "c"},
})
if err != nil {
t.Fatal(err)
}
if len(wf.Steps) != 3 {
t.Fatalf("got %d steps, want 3", len(wf.Steps))
}
for i, ref := range wf.Steps {
if ref.Order != i {
t.Errorf("step %d has Order %d", i, ref.Order)
}
}
if wf.Steps[0].StepID != "a" || wf.Steps[2].StepID != "c" {
t.Errorf("step order does not follow the argument order: %+v", wf.Steps)
}
}
// A monitor that starts enabled would begin alerting real people the moment a
// model invented it.
func TestCreatedMonitorIsDisabled(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health", "type": "http", "target": map[string]any{"url": "https://example.com"},
})
if err != nil {
t.Fatal(err)
}
if m.Enabled {
t.Error("created monitor is enabled; it must wait for a human")
}
}
func TestCreateMonitorRequiresNameAndType(t *testing.T) {
if _, err := buildMonitor(map[string]any{"type": "http"}); err == nil {
t.Error("buildMonitor accepted a monitor with no name")
}
if _, err := buildMonitor(map[string]any{"name": "x"}); err == nil {
t.Error("buildMonitor accepted a monitor with no type")
}
}
// A monitor with no target argument at all cannot be checked, so it is
// refused the same way a missing name or type is.
func TestCreateMonitorRequiresTarget(t *testing.T) {
if _, err := buildMonitor(map[string]any{"name": "x", "type": "http"}); err == nil {
t.Error("buildMonitor accepted a monitor with no target")
}
}
// The target argument decodes into the real models.MonitorTarget shape, not
// a passthrough map, so an http monitor without a URL is rejected here rather
// than surfacing a confusing failure the first time it is checked.
func TestCreateMonitorHTTPRequiresURL(t *testing.T) {
if _, err := buildMonitor(map[string]any{
"name": "x", "type": "http", "target": map[string]any{"method": "GET"},
}); err == nil {
t.Error("buildMonitor accepted an http monitor with no target url")
}
}
func TestCreateMonitorTCPRequiresHostAndPort(t *testing.T) {
if _, err := buildMonitor(map[string]any{
"name": "x", "type": "tcp", "target": map[string]any{"host": "example.com"},
}); err == nil {
t.Error("buildMonitor accepted a tcp monitor with no port")
}
}
func TestCreateMonitorDecodesTargetFields(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health", "type": "http",
"target": map[string]any{
"url": "https://example.com/health",
"method": "GET",
"expected_status": float64(200),
"keyword": "ok",
},
})
if err != nil {
t.Fatal(err)
}
if m.Target.URL != "https://example.com/health" {
t.Errorf("Target.URL = %q", m.Target.URL)
}
if m.Target.Method != "GET" {
t.Errorf("Target.Method = %q", m.Target.Method)
}
if m.Target.ExpectedStatus != 200 {
t.Errorf("Target.ExpectedStatus = %d", m.Target.ExpectedStatus)
}
if m.Target.Keyword != "ok" {
t.Errorf("Target.Keyword = %q", m.Target.Keyword)
}
}
// A runner is a server ID. buildMonitor must refuse one outright rather than
// dropping it silently, or a model would believe it had pinned a check to an
// agent it never reached.
func TestBuildMonitorRefusesRunner(t *testing.T) {
args := map[string]any{
"name": "api health",
"type": "http",
"target": map[string]any{"url": "https://example.com"},
"runner": "some-server-id",
}
if _, err := buildMonitor(args); err == nil {
t.Fatal("buildMonitor accepted a runner argument")
}
}
// Without a runner it still builds, and never arms itself.
func TestBuildMonitorWithoutRunnerIsDisabled(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health",
"type": "http",
"target": map[string]any{"url": "https://example.com"},
})
if err != nil {
t.Fatalf("buildMonitor: %v", err)
}
if m.Runner != "" {
t.Errorf("Runner = %q, want empty so CreateMonitor defaults it to the control plane", m.Runner)
}
if m.Enabled {
t.Error("monitor created enabled")
}
}
+136
View File
@@ -0,0 +1,136 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// serverSummary is what a list returns: enough for a model to decide which
// server to ask about next, and nothing else. The full document is an order of
// magnitude larger and listing thirty of them would dominate a context window.
type serverSummary struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Online bool `json:"online"`
Tags map[string]string `json:"tags,omitempty"`
}
// models.Server has no Online bool: it stores Status as one of "pending",
// "active" or "offline" (see internal/services/servers.go). Online here
// mirrors that string the same way the REST layer treats it.
func summariseServer(s models.Server) serverSummary {
return serverSummary{
ID: s.ServerID,
Hostname: s.Hostname,
OS: s.OSInfo,
Online: s.Status == "active",
Tags: s.Tags,
}
}
// defaultLimit and maxLimit bound every listing. A model asking for everything
// gets a page and is told the total, which is more useful than a truncated blob
// it cannot tell is truncated.
const (
defaultLimit = 50
maxLimit = 200
)
func pageLimit(args map[string]any) int {
n, ok := args["limit"].(float64)
if !ok || int(n) <= 0 {
return defaultLimit
}
if int(n) > maxLimit {
return maxLimit
}
return int(n)
}
func stringArg(args map[string]any, key string) string {
s, _ := args[key].(string)
return s
}
func tagArg(args map[string]any) map[string]string {
raw, ok := args["tags"].(map[string]any)
if !ok {
return nil
}
out := map[string]string{}
for k, v := range raw {
if s, ok := v.(string); ok {
out[k] = s
}
}
return out
}
type listServersResult struct {
Servers []serverSummary `json:"servers"`
Total int `json:"total"`
Shown int `json:"shown"`
}
func init() {
All().Register(Tool{
Name: "list_servers",
Args: []ToolArg{
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
},
TouchesServers: true,
Scope: "servers:read",
Description: "List the servers in this Vantage fleet, optionally filtered by tags. " +
"Returns a compact summary per server; use get_server for full detail on one.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
if !ok {
// The requested tags and the token's restriction can never both
// hold, so the honest answer is an empty fleet.
return listServersResult{Servers: []serverSummary{}}, nil
}
servers, err := services.ListServersFiltered(c.InstanceID, sel)
if err != nil {
return nil, fmt.Errorf("could not list servers: %w", err)
}
limit := pageLimit(args)
out := make([]serverSummary, 0, limit)
for _, s := range servers {
if len(out) == limit {
break
}
out = append(out, summariseServer(s))
}
return listServersResult{Servers: out, Total: len(servers), Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_server",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "The server's ID.", Required: true},
},
TouchesServers: true,
Scope: "servers:read",
Description: "Get detail for one server by ID: OS, online state and tags. " +
"Use list_pending_updates for that server's outstanding package updates.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "server_id")
if id == "" {
return nil, fmt.Errorf("server_id is required")
}
srv, err := services.GetServerScoped(c.InstanceID, id, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", id)
}
return summariseServer(*srv), nil
},
})
}
+265
View File
@@ -0,0 +1,265 @@
package mcp
import (
"context"
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// monitorSummary carries state and identity. A model asking "what is broken"
// needs the state and the name; the target URL, expected status, keyword,
// runner and channel list are configuration it did not ask for.
//
// Runner in particular is not merely omitted as noise: for an agent-pushed
// monitor it is literally a server ID, and REST's listMonitors/getMonitor
// redact it to models.RunnerRestricted when that server is outside the
// caller's scope. This projection never had a runner field to redact — the
// same outcome, reached by never including it rather than by filtering it
// out, so this tool and get_monitor_status cannot disagree with the REST
// surface about what a restricted token learns.
type monitorSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Interval int `json:"interval_sec"`
}
// models.Monitor.State is a MonitorState struct whose status field is
// Status (a plain string: models.StatusUp/StatusDown/StatusPending), not the
// ".Status" field-of-a-field the brief guessed at.
func summariseMonitor(m models.Monitor) monitorSummary {
return monitorSummary{
ID: m.MonitorID,
Name: m.Name,
Group: m.Group,
Type: m.Type,
Enabled: m.Enabled,
State: m.State.Status,
Interval: m.IntervalSec,
}
}
type listMonitorsResult struct {
Monitors []monitorSummary `json:"monitors"`
Total int `json:"total"`
Shown int `json:"shown"`
Down int `json:"down"`
}
// monitorStatusDetail is get_monitor_status's projection: enough to tell a
// model what a monitor is currently doing, without its target configuration.
type monitorStatusDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
State string `json:"state"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type incidentSummary struct {
ID string `json:"id"`
MonitorName string `json:"monitor_name"`
StartedAt time.Time `json:"started_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
Cause string `json:"cause,omitempty"`
}
type listIncidentsResult struct {
Incidents []incidentSummary `json:"incidents"`
Shown int `json:"shown"`
}
type monitorSample struct {
At time.Time `json:"at"`
Ok bool `json:"ok"`
LatencyMs int `json:"latency_ms"`
}
type listSamplesResult struct {
Samples []monitorSample `json:"samples"`
Shown int `json:"shown"`
}
const defaultSampleLimit = 100
const maxSampleLimit = 500
func init() {
All().Register(Tool{
Name: "list_monitors",
Args: []ToolArg{
{Name: "state", Type: ArgString, Description: "Only monitors in this state: up, down or pending."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "monitors:read",
Description: "List the monitors on this instance with their current state. " +
"Pass state:\"down\" to see only what is currently failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
monitors, err := services.ListMonitors(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list monitors: %w", err)
}
wantState := stringArg(args, "state")
limit := pageLimit(args)
out := make([]monitorSummary, 0, limit)
down, total := 0, 0
for _, m := range monitors {
summary := summariseMonitor(m)
if summary.State == "down" {
down++
}
if wantState != "" && summary.State != wantState {
continue
}
total++
if len(out) < limit {
out = append(out, summary)
}
}
return listMonitorsResult{Monitors: out, Total: total, Shown: len(out), Down: down}, nil
},
})
All().Register(Tool{
Name: "get_monitor_status",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "The monitor's ID.", Required: true},
},
TouchesServers: true,
Scope: "monitors:read",
Description: "Get one monitor's current state: up, down or pending, the last check " +
"time, and the last error message if it is failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "monitor_id")
if id == "" {
return nil, fmt.Errorf("monitor_id is required")
}
m, err := services.GetMonitor(c.InstanceID, id)
if err != nil || m == nil {
return nil, fmt.Errorf("no monitor %q found", id)
}
return monitorStatusDetail{
ID: m.MonitorID,
Name: m.Name,
Type: m.Type,
State: m.State.Status,
LastCheckAt: m.State.LastCheckAt,
LastError: m.State.Message,
}, nil
},
})
All().Register(Tool{
Name: "list_incidents",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "Only incidents for this monitor; omit for every monitor."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "monitors:read",
Description: "List monitor incidents (outages), most recent first. Pass monitor_id to " +
"scope to one monitor, or omit it to see incidents across every monitor.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := int64(pageLimit(args))
monitorID := stringArg(args, "monitor_id")
var monitorNames map[string]string
var monitorIDs []string
if monitorID != "" {
monitorIDs = []string{monitorID}
} else {
monitors, err := services.ListMonitors(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list monitors: %w", err)
}
monitorNames = make(map[string]string, len(monitors))
for _, m := range monitors {
monitorNames[m.MonitorID] = m.Name
monitorIDs = append(monitorIDs, m.MonitorID)
}
}
out := []incidentSummary{}
for _, mid := range monitorIDs {
if len(out) >= int(limit) {
break
}
incidents, err := services.ListIncidents(c.InstanceID, mid, limit)
if err != nil {
return nil, fmt.Errorf("could not list incidents: %w", err)
}
name := mid
if monitorNames != nil {
if n, ok := monitorNames[mid]; ok {
name = n
}
} else {
if m, err := services.GetMonitor(c.InstanceID, mid); err == nil && m != nil {
name = m.Name
}
}
for _, inc := range incidents {
if len(out) >= int(limit) {
break
}
out = append(out, incidentSummary{
ID: inc.IncidentID,
MonitorName: name,
StartedAt: inc.StartedAt,
ResolvedAt: inc.ResolvedAt,
Cause: inc.Cause,
})
}
}
return listIncidentsResult{Incidents: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_monitor_samples",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "The monitor's ID.", Required: true},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "monitors:read",
Description: "Get one monitor's recent raw check results (timestamp, ok/fail, latency). " +
"Samples are numerous and expire after 48 hours; use list_incidents for a longer view.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "monitor_id")
if id == "" {
return nil, fmt.Errorf("monitor_id is required")
}
limit := int(pageLimit(args))
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
limit = int(raw)
} else {
limit = defaultSampleLimit
}
if limit > maxSampleLimit {
limit = maxSampleLimit
}
samples, err := services.MonitorSamples(c.InstanceID, id, time.Now().Add(-services.MonitorSampleTTL))
if err != nil {
return nil, fmt.Errorf("could not get samples: %w", err)
}
out := make([]monitorSample, 0, limit)
for _, s := range samples {
if len(out) == limit {
break
}
out = append(out, monitorSample{At: s.At, Ok: s.Up, LatencyMs: s.LatencyMs})
}
return listSamplesResult{Samples: out, Shown: len(out)}, nil
},
})
}
+78
View File
@@ -0,0 +1,78 @@
package mcp
import (
"encoding/json"
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func TestReadToolsAreRegistered(t *testing.T) {
want := []string{
"list_servers", "get_server", "search_fleet",
"list_monitors", "get_monitor_status", "list_incidents", "get_monitor_samples",
"list_pending_updates", "list_vulnerabilities", "get_server_packages",
"list_workflows", "get_workflow", "get_run", "get_run_logs",
"list_audit_events", "list_secret_names",
}
for _, name := range want {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if tool.Write {
t.Errorf("tool %q is marked as a write", name)
}
}
}
// Secret plaintext must never be reachable, at any scope. This is the one
// deliberate refusal in the read set and it is worth a test of its own.
func TestNoSecretRevealTool(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Name == "reveal_secret" || tool.Name == "get_secret" {
t.Errorf("tool %q exposes secret plaintext to a model", tool.Name)
}
}
}
// A fleet listing that costs thousands of tokens degrades every interaction
// and is otherwise invisible until someone reads a bill.
func TestServerSummaryStaysSmall(t *testing.T) {
fleet := make([]serverSummary, 30)
for i := range fleet {
fleet[i] = summariseServer(models.Server{
ServerID: "srv-000000000000000000000000",
Hostname: "web-server-with-a-longish-name",
OSInfo: "Ubuntu 24.04.1 LTS",
Tags: map[string]string{"env": "prod", "team": "core"},
})
}
out, err := json.Marshal(fleet)
if err != nil {
t.Fatal(err)
}
if len(out) > 8000 {
t.Errorf("30 servers serialise to %d bytes, want at most 8000", len(out))
}
}
// The real status vocabulary is "pending" / "active" / "offline" (see
// internal/services/servers.go) — "online" is never assigned anywhere. A
// server carrying the live status ("active") must project as Online: true,
// or list_servers/get_server misreport the entire fleet as down.
func TestSummariseServerReportsActiveAsOnline(t *testing.T) {
active := summariseServer(models.Server{ServerID: "srv-active", Status: "active"})
if !active.Online {
t.Errorf("server with status %q should be online, got Online=false", "active")
}
for _, status := range []string{"pending", "offline"} {
s := summariseServer(models.Server{ServerID: "srv-" + status, Status: status})
if s.Online {
t.Errorf("server with status %q should not be online, got Online=true", status)
}
}
}
+644
View File
@@ -0,0 +1,644 @@
package mcp
import (
"context"
"fmt"
"sort"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// ---- workflows ----
type workflowSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Steps int `json:"steps"`
Targets int `json:"targets"`
Scheduled bool `json:"scheduled"`
}
type listWorkflowsResult struct {
Workflows []workflowSummary `json:"workflows"`
Total int `json:"total"`
Shown int `json:"shown"`
}
type workflowStepRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
type workflowDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []workflowStepRef `json:"steps"`
Targets []string `json:"target_server_ids,omitempty"`
Tags map[string]string `json:"target_tags,omitempty"`
Schedule string `json:"schedule,omitempty"`
// TargetsRestricted is set, with no count, when Targets omits at least
// one server ID outside this token's scope — mirroring
// WorkflowResponse's REST field, so a model reading this alongside a
// run_workflow refusal for the same workflow is not left to conclude the
// refusal invented a problem this tool never mentioned.
TargetsRestricted bool `json:"targets_restricted,omitempty"`
}
// ---- runs ----
type runStatusCounts struct {
Pending int `json:"pending,omitempty"`
Running int `json:"running,omitempty"`
Success int `json:"success,omitempty"`
Failed int `json:"failed,omitempty"`
Skipped int `json:"skipped,omitempty"`
}
type runDetail struct {
ID string `json:"id"`
WorkflowName string `json:"workflow_name"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
ServerCounts runStatusCounts `json:"server_status_counts"`
}
type runLogsResult struct {
Lines []string `json:"lines"`
Shown int `json:"shown"`
}
const defaultLogLimit = 200
// ---- pending updates ----
type pendingUpdate struct {
ServerID string `json:"server_id"`
Hostname string `json:"hostname"`
Package string `json:"package"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
}
type listPendingUpdatesResult struct {
Updates []pendingUpdate `json:"updates"`
Shown int `json:"shown"`
}
// ---- vulnerabilities ----
type vulnSummary struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Package string `json:"package"`
AffectedNum int `json:"affected_servers"`
FixedIn string `json:"fixed_in,omitempty"`
}
type listVulnsResult struct {
Vulnerabilities []vulnSummary `json:"vulnerabilities"`
Shown int `json:"shown"`
}
// ---- packages ----
type packageEntry struct {
Name string `json:"name"`
Version string `json:"version"`
}
type serverPackagesResult struct {
ServerID string `json:"server_id"`
Packages []packageEntry `json:"packages"`
Total int `json:"total"`
Shown int `json:"shown"`
}
// ---- search_fleet ----
type packageMatch struct {
Hostname string `json:"hostname"`
Package string `json:"package"`
Version string `json:"version"`
}
type searchFleetResult struct {
Matches []packageMatch `json:"matches"`
Shown int `json:"shown"`
}
// ---- audit ----
type auditEventSummary struct {
At time.Time `json:"at"`
Type string `json:"type"`
Actor string `json:"actor"`
Detail string `json:"detail,omitempty"`
}
type listAuditResult struct {
Events []auditEventSummary `json:"events"`
Total int64 `json:"total"`
Shown int `json:"shown"`
}
// ---- secrets ----
type secretGroupNames struct {
Group string `json:"group"`
Keys []string `json:"keys"`
}
type listSecretNamesResult struct {
Groups []secretGroupNames `json:"groups"`
}
func init() {
All().Register(Tool{
Name: "list_workflows",
Args: []ToolArg{
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "List the workflows defined on this instance: step count, target count, " +
"and whether each is on a schedule. Use get_workflow for the ordered step list.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
workflows, err := services.ListWorkflows(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list workflows: %w", err)
}
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
}
limit := pageLimit(args)
out := make([]workflowSummary, 0, limit)
for _, w := range workflows {
if len(out) == limit {
break
}
// The tag count is exposed as-is (the tag vocabulary is not
// restricted); the ID count is narrowed to what this token
// can see so it cannot itself disclose that out-of-scope
// targets exist, the same leak the REST list closes.
ids, _ := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
targets := len(ids)
if len(w.TargetTags) > 0 {
targets = len(w.TargetTags)
}
out = append(out, workflowSummary{
ID: w.WorkflowID,
Name: w.Name,
Steps: len(w.Steps),
Targets: targets,
Scheduled: w.Schedule != nil && w.Schedule.Enabled,
})
}
return listWorkflowsResult{Workflows: out, Total: len(workflows), Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_workflow",
Args: []ToolArg{
{Name: "workflow_id", Type: ArgString, Description: "The workflow's ID.", Required: true},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get one workflow's full definition: ordered steps, targets and schedule. " +
"Use get_run for what happened the last time it ran.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "workflow_id")
if id == "" {
return nil, fmt.Errorf("workflow_id is required")
}
w, err := services.GetWorkflow(c.InstanceID, id)
if err != nil || w == nil {
return nil, fmt.Errorf("no workflow %q found", id)
}
steps := make([]workflowStepRef, 0, len(w.Steps))
for _, s := range w.Steps {
name := s.StepID
if s.Inline != nil {
name = s.Inline.Name
}
steps = append(steps, workflowStepRef{ID: s.StepID, Name: name})
}
schedule := ""
if w.Schedule != nil && w.Schedule.Enabled {
schedule = w.Schedule.Cron
}
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
}
targets, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
return workflowDetail{
ID: w.WorkflowID,
Name: w.Name,
Steps: steps,
Targets: targets,
Tags: w.TargetTags,
Schedule: schedule,
TargetsRestricted: hidden,
}, nil
},
})
All().Register(Tool{
Name: "get_run",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run's ID.", Required: true},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get one workflow run's status: overall state, start/finish time, and a " +
"count of servers by their per-server status. Use get_run_logs for the output of one server.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "run_id")
if id == "" {
return nil, fmt.Errorf("run_id is required")
}
r, err := services.GetRun(c.InstanceID, id)
if err != nil || r == nil {
return nil, fmt.Errorf("no run %q found", id)
}
var counts runStatusCounts
for _, sr := range r.ServerRuns {
switch sr.Status {
case "pending":
counts.Pending++
case "running":
counts.Running++
case "success":
counts.Success++
case "failed":
counts.Failed++
case "skipped":
counts.Skipped++
}
}
return runDetail{
ID: r.RunID,
WorkflowName: r.Name,
Status: r.Status,
StartedAt: r.StartedAt,
FinishedAt: r.FinishedAt,
ServerCounts: counts,
}, nil
},
})
All().Register(Tool{
Name: "get_run_logs",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run's ID.", Required: true},
{Name: "server_id", Type: ArgString, Description: "Which server within the run to read output for.", Required: true},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get the ordered log lines for one server within one workflow run. " +
"Capped at 200 lines by default; ask for a higher limit if you need more.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
runID := stringArg(args, "run_id")
serverID := stringArg(args, "server_id")
if runID == "" || serverID == "" {
return nil, fmt.Errorf("run_id and server_id are required")
}
// The run's own instance must be checked before any line is
// returned: ReadServerRunLog takes no instance ID and will read
// any run on the process, so GetRun is what proves this run
// belongs to the caller.
r, err := services.GetRun(c.InstanceID, runID)
if err != nil || r == nil {
return nil, fmt.Errorf("no run %q found", runID)
}
found := false
for _, sr := range r.ServerRuns {
if sr.ServerID == serverID {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
}
// Membership in the run is not scope: a run started before this
// token was restricted, or by an unrestricted credential, names
// servers this token must not read. The stdout of an
// out-of-scope host is exactly the data the tag restriction
// exists to withhold.
//
// The refusal reuses the membership message verbatim so that
// "in the run but out of your scope" and "not in the run at all"
// are indistinguishable — otherwise the difference between the
// two answers enumerates hosts the token cannot see.
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
}
limit := defaultLogLimit
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
limit = int(raw)
}
if limit > maxLimit {
limit = maxLimit
}
lines, _, err := services.ReadServerRunLog(runID, serverID, 0, limit)
if err != nil {
return nil, fmt.Errorf("could not read run log: %w", err)
}
return runLogsResult{Lines: lines, Shown: len(lines)}, nil
},
})
All().Register(Tool{
Name: "list_pending_updates",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "One server to report on; omit to report across the fleet."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "servers:read",
Description: "List outstanding package updates across the fleet, or for one server. " +
"Pass server_id for one server, or tags to filter by, respecting the token's own scope.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := pageLimit(args)
out := []pendingUpdate{}
serverID := stringArg(args, "server_id")
if serverID != "" {
srv, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
}
for _, u := range srv.AvailableUpdates {
if len(out) == limit {
break
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
})
}
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
}
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
if !ok {
return listPendingUpdatesResult{Updates: out}, nil
}
servers, err := services.ListServersFiltered(c.InstanceID, sel)
if err != nil {
return nil, fmt.Errorf("could not list servers: %w", err)
}
for _, srv := range servers {
for _, u := range srv.AvailableUpdates {
if len(out) == limit {
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
})
}
}
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_vulnerabilities",
Args: []ToolArg{
{Name: "severity", Type: ArgString, Description: "Only this severity: critical, high, medium or low."},
{Name: "status", Type: ArgString, Description: "Only findings in this state: open (default) or accepted."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "List known CVEs affecting this fleet, one row per CVE/package pair with " +
"how many servers are affected. Filter by severity or status (open/accepted).",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
f := services.FindingFilter{
Severity: stringArg(args, "severity"),
State: stringArg(args, "status"),
// The service layer drops findings on servers outside this
// token's scope, so the affected-host count below is over
// visible servers only. An unfiltered count is the same
// aggregate leak ListKeys.AssignedCount was fixed for: it
// says something exists on a machine the caller must not
// know about. A CVE affecting only out-of-scope hosts
// disappears from the list rather than showing a zero.
TokenScope: c.TokenScope,
}
findings, err := services.ListInstanceFindings(c.InstanceID, f)
if err != nil {
return nil, fmt.Errorf("could not list vulnerabilities: %w", err)
}
type key struct{ cve, pkg string }
counts := map[key]int{}
meta := map[key]vulnSummary{}
for _, fnd := range findings {
k := key{fnd.CVEID, fnd.PackageName}
counts[k]++
if _, seen := meta[k]; !seen {
meta[k] = vulnSummary{CVEID: fnd.CVEID, Severity: fnd.Severity, Package: fnd.PackageName, FixedIn: fnd.FixedIn}
}
}
// Go randomises map iteration order, so truncating a ranged map
// to a page made two identical calls return different CVEs — a
// model comparing its own two answers would see the fleet change
// under it. Sorting by CVE ID (then package, since the key is a
// pair) makes the page deterministic.
keys := make([]key, 0, len(meta))
for k := range meta {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].cve != keys[j].cve {
return keys[i].cve < keys[j].cve
}
return keys[i].pkg < keys[j].pkg
})
limit := pageLimit(args)
out := make([]vulnSummary, 0, limit)
for _, k := range keys {
if len(out) == limit {
break
}
v := meta[k]
v.AffectedNum = counts[k]
out = append(out, v)
}
return listVulnsResult{Vulnerabilities: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_server_packages",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "The server's ID.", Required: true},
{Name: "name", Type: ArgString, Description: "Substring match on the package name. A host can carry ~2000 packages, so pass this."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "List installed packages on one server, optionally filtered by name. " +
"A server can carry ~2000 packages, so pass name to search rather than listing them all.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
serverID := stringArg(args, "server_id")
if serverID == "" {
return nil, fmt.Errorf("server_id is required")
}
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
}
pkgs, err := services.ListPackages(c.InstanceID, serverID)
if err != nil || pkgs == nil {
return nil, fmt.Errorf("no package data for server %q", serverID)
}
nameFilter := strings.ToLower(stringArg(args, "name"))
limit := pageLimit(args)
out := make([]packageEntry, 0, limit)
total := 0
for _, p := range pkgs.Packages {
if nameFilter != "" && !strings.Contains(strings.ToLower(p.Name), nameFilter) {
continue
}
total++
if len(out) < limit {
out = append(out, packageEntry{Name: p.Name, Version: p.Version})
}
}
return serverPackagesResult{ServerID: serverID, Packages: out, Total: total, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "search_fleet",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Exact package name to search for across the fleet.", Required: true},
{Name: "version_below", Type: ArgString, Description: "Not supported and refused if supplied: version ordering is per-distribution and cannot be resolved here."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "Search every server's installed packages by name across the whole fleet — " +
"answers questions like \"which hosts still run OpenSSL 1.1\". version_below is not " +
"currently supported: filtering package versions correctly requires knowing each " +
"distribution's own version-ordering scheme (dpkg/rpm/apk), which this tool cannot " +
"determine, so it refuses rather than guess with a lexicographic comparison. Every " +
"matching install is returned; compare versions yourself.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
name := stringArg(args, "name")
if name == "" {
return nil, fmt.Errorf("name is required")
}
if stringArg(args, "version_below") != "" {
return nil, fmt.Errorf("version_below is not supported: correct version ordering " +
"depends on each host's distribution (dpkg/rpm/apk each order differently), " +
"which this tool cannot resolve here — omit version_below and every matching " +
"install is returned instead")
}
hits, err := services.SearchPackages(c.InstanceID, name, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not search packages: %w", err)
}
limit := pageLimit(args)
out := make([]packageMatch, 0, limit)
for _, h := range hits {
if len(out) == limit {
break
}
// SearchPackages now filters by the token's scope itself,
// so this resolve is how the hostname is obtained rather
// than the only scope check. It stays scoped anyway: this
// loop is what turns a server ID into a name the model
// sees, and a second check costs nothing.
srv, err := services.GetServerScoped(c.InstanceID, h.ServerID, c.TokenScope)
if err != nil {
continue
}
out = append(out, packageMatch{Hostname: srv.Hostname, Package: h.Name, Version: h.Version})
}
return searchFleetResult{Matches: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_audit_events",
Args: []ToolArg{
{Name: "event_type", Type: ArgString, Description: "Event type prefix to filter by, e.g. \"workflow\", \"key\", \"server\"."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "settings:read",
Description: "List recent audit log events on this instance: who did what, and when. " +
"Filter by event_type prefix (e.g. \"workflow\", \"key\", \"server\").",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := int64(pageLimit(args))
events, total, err := services.ListAuditEvents(c.InstanceID, services.AuditFilter{
Category: stringArg(args, "event_type"),
Limit: limit,
})
if err != nil {
return nil, fmt.Errorf("could not list audit events: %w", err)
}
out := make([]auditEventSummary, 0, len(events))
for _, e := range events {
out = append(out, auditEventSummary{At: e.CreatedAt, Type: e.EventType, Actor: e.Actor, Detail: e.Details})
}
return listAuditResult{Events: out, Total: total, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_secret_names",
// This tool reads no arguments at all.
Args: []ToolArg{},
Scope: "secrets:read",
Description: "List secret group and key names on this instance. Metadata only — no " +
"tool ever returns a secret's plaintext value to a model.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
groups, err := services.ListSecretGroups(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list secret groups: %w", err)
}
out := make([]secretGroupNames, 0, len(groups))
for _, g := range groups {
// GetSecretGroup returns key metadata only (models.Secret's
// EncryptedValue is json:"-"); the plaintext reveal path
// (services.RevealSecret) is never called from this tool.
secrets, err := services.GetSecretGroup(c.InstanceID, g.Group)
if err != nil {
return nil, fmt.Errorf("could not read secret group %q: %w", g.Group, err)
}
keys := make([]string, 0, len(secrets))
for _, s := range secrets {
keys = append(keys, s.Key)
}
out = append(out, secretGroupNames{Group: g.Group, Keys: keys})
}
return listSecretNamesResult{Groups: out}, nil
},
})
}
+334
View File
@@ -0,0 +1,334 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// stringSliceArg reads a JSON array-of-strings argument, ignoring any element
// that is not a string. Missing or wrongly-typed input decodes to nil, which
// every caller here treats as "no targets named this way".
func stringSliceArg(args map[string]any, key string) []string {
raw, ok := args[key].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func mustLookup(name string) Tool {
t, ok := All().Lookup(name)
if !ok {
panic("mcp: unknown tool " + name)
}
return t
}
type runStartedResult struct {
RunID string `json:"run_id"`
Note string `json:"note"`
}
// run_workflow. The real REST run route (internal/api/workflows.go's
// runWorkflow) does not take an ad-hoc target list at all: it calls
// services.TriggerWorkflow(instanceID, workflowID, actor), which resolves the
// workflow's own configured target_server_ids/target_tags via
// services.ResolveTargets (unscoped) and runs against exactly that set. There
// is no per-call server_ids/tags override to plumb through, so this tool takes
// only workflow_id. To keep the token's scope meaningful — TriggerWorkflow
// itself does not consult it — this handler first loads the workflow and
// resolves its configured targets through ResolveTargetsScoped with the
// caller's TokenScope, and refuses the run outright if that scoped view does
// not cover every server the unscoped resolution would touch. That is the
// fan-out and scope check; the actual dispatch is the same single call the
// REST route makes, so there is exactly one path that starts a run.
func init() {
All().Register(Tool{
Name: "run_workflow",
Args: []ToolArg{
{Name: "workflow_id", Type: ArgString, Description: "The workflow to run. Its saved targets are used; this call cannot pick different ones.", Required: true},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "workflows:write",
Description: "Run a workflow against the servers it is already configured to target " +
"(its saved server list and tags — this call does not let you pick different " +
"targets). This EXECUTES COMMANDS on real machines and cannot be undone from " +
"here. Returns a run ID immediately; poll get_run for progress and get_run_logs " +
"for output. Refused if the workflow's targets reach outside this token's own " +
"server scope, or if it would affect more than the fan-out limit without confirm:true.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
workflowID := stringArg(args, "workflow_id")
if workflowID == "" {
return nil, fmt.Errorf("workflow_id is required")
}
wf, err := services.GetWorkflow(c.InstanceID, workflowID)
if err != nil {
return nil, fmt.Errorf("workflow not found")
}
allTargets, err := services.ResolveTargets(c.InstanceID, wf.TargetServerIDs, wf.TargetTags)
if err != nil {
return nil, fmt.Errorf("this workflow matches no servers")
}
// This all-or-nothing pre-check is no longer the only guard:
// services.TriggerWorkflow now resolves through
// ResolveTargetsScoped itself, so a run started with this token
// can never touch a server outside its scope regardless of what
// happens here. It is kept because its refusal is the clearer
// answer for a model: the service layer would silently run
// against the in-scope subset, while a partially out-of-scope
// workflow is documented here as refused outright, which is
// behaviour a caller relies on.
scopedTargets, err := services.ResolveTargetsScoped(c.InstanceID, wf.TargetServerIDs, wf.TargetTags, c.TokenScope)
if err != nil || len(scopedTargets) != len(allTargets) {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(scopedTargets), args); err != nil {
return nil, err
}
runID, err := services.TriggerWorkflow(c.InstanceID, workflowID, c.TokenName, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not start the run: %w", err)
}
LogCall(c, mustLookup("run_workflow"), args, len(scopedTargets))
return runStartedResult{
RunID: runID,
Note: "The run is in progress. Poll get_run with this run_id; do not assume it succeeded.",
}, nil
},
})
}
type cancelledResult struct {
Cancelled bool `json:"cancelled"`
}
// cancel_run. The REST cancel route (workflows.go's cancelRun) calls
// services.CancelRun(instanceID, runID) directly; that call is already scoped
// to the caller's instance by instanceID, which is what "verifies the run
// belongs to the caller's instance" reduces to here — there is no separate
// per-server scope to check, since cancelling touches the run record, not a
// server.
func init() {
All().Register(Tool{
Name: "cancel_run",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run to cancel.", Required: true},
},
Write: true,
Scope: "workflows:write",
Description: "Cancel an in-progress workflow run. This stops further steps from " +
"being dispatched to real machines but cannot undo steps that already ran, and " +
"cannot be undone from here.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
runID := stringArg(args, "run_id")
if runID == "" {
return nil, fmt.Errorf("run_id is required")
}
if err := services.CancelRun(c.InstanceID, runID); err != nil {
return nil, fmt.Errorf("could not cancel the run: %w", err)
}
LogCall(c, mustLookup("cancel_run"), args, 0)
return cancelledResult{Cancelled: true}, nil
},
})
}
type updateBatchResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
}
// apply_updates. The REST route (internal/api/handlers.go's applyUpdates) is
// per-server: POST /servers/:id/apply-updates resolves one server with
// services.GetServerScoped and calls services.DispatchApplyUpdates(serverID).
// There is no fleet-wide variant of that service call to invoke once, so this
// tool resolves the requested targets through ResolveTargetsScoped exactly as
// the brief describes, then calls the same DispatchApplyUpdates the REST route
// calls, once per resolved server — the identical dispatch, just looped
// instead of hardcoded to one server_id from the URL.
func init() {
All().Register(Tool{
Name: "apply_updates",
Args: []ToolArg{
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "servers:write",
Description: "Apply pending OS package updates on real servers, selected by " +
"server_ids and/or tags. This installs packages on real machines right now and " +
"cannot be undone from here. A server may need a reboot afterward, which this " +
"tool does not do.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := updateBatchResult{Servers: len(targets), Failed: map[string]string{}}
for _, srv := range targets {
if err := services.DispatchApplyUpdates(srv.ServerID); err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded = append(result.Succeeded, srv.ServerID)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("apply_updates"), args, len(targets))
return result, nil
},
})
}
type agentUpdateResult struct {
Servers int `json:"servers"`
Succeeded map[string]string `json:"succeeded,omitempty"`
Failed map[string]string `json:"failed,omitempty"`
}
// update_agent. Same shape as apply_updates: the REST route
// (handlers.go's updateAgent) resolves one server and calls
// services.DispatchUpdateAgent(serverID), so this tool loops the same call
// over the resolved, scoped target set.
func init() {
All().Register(Tool{
Name: "update_agent",
Args: []ToolArg{
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "servers:write",
Description: "Trigger the Vantage agent on real servers, selected by server_ids " +
"and/or tags, to download and replace itself with the latest version. This " +
"restarts the agent process on real machines and cannot be undone from here.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := agentUpdateResult{Servers: len(targets), Succeeded: map[string]string{}, Failed: map[string]string{}}
for _, srv := range targets {
version, err := services.DispatchUpdateAgent(srv.ServerID)
if err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded[srv.ServerID] = version
}
if len(result.Succeeded) == 0 {
result.Succeeded = nil
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("update_agent"), args, len(targets))
return result, nil
},
})
}
type assignKeyResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
}
// assign_key. The REST route (handlers.go's assignKey) takes one server_id in
// the body and calls services.AssignKey(instanceID, keyID, serverID) directly
// — AssignKey itself resolves the server with the unscoped services.GetServer,
// not GetServerScoped, so the REST route carries no token-scope check of its
// own (session auth has no server-scope restriction; only API tokens do). For
// the MCP surface, this tool resolves every named target through
// ResolveTargetsScoped first — the same chokepoint every other target-
// resolving write tool goes through — so a token whose scope excludes a server
// cannot reach it here even though the REST handler's own server lookup would
// not have stopped it. Then it calls the identical AssignKey once per resolved
// server.
func init() {
All().Register(Tool{
Name: "assign_key",
Args: []ToolArg{
{Name: "key_id", Type: ArgString, Description: "The SSH key to assign.", Required: true},
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "keys:write",
Description: "Assign an SSH key to real servers, selected by server_ids and/or " +
"tags. The agent rewrites /root/.ssh/authorized_keys on each targeted machine " +
"and this cannot be undone from here — use revoke to remove it afterward.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
keyID := stringArg(args, "key_id")
if keyID == "" {
return nil, fmt.Errorf("key_id is required")
}
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := assignKeyResult{Servers: len(targets), Failed: map[string]string{}}
for _, srv := range targets {
if _, err := services.AssignKey(c.InstanceID, keyID, srv.ServerID); err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded = append(result.Succeeded, srv.ServerID)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("assign_key"), args, len(targets))
return result, nil
},
})
}
+44
View File
@@ -0,0 +1,44 @@
package mcp
import "testing"
func TestWriteToolsAreMarkedAsWrites(t *testing.T) {
want := []string{"run_workflow", "cancel_run", "apply_updates", "update_agent", "assign_key"}
for _, name := range want {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if !tool.Write {
t.Errorf("tool %q is not marked as a write, so it would be listed to a read-only agent", name)
}
}
}
// A tool description is prompt text. A model choosing between tools must be
// told which ones touch real machines.
func TestWriteToolDescriptionsStateBlastRadius(t *testing.T) {
for _, tool := range All().Tools() {
if !tool.Write {
continue
}
if len(tool.Description) < 40 {
t.Errorf("tool %q has a %d-char description; write tools must state what they affect",
tool.Name, len(tool.Description))
}
}
}
// A caller holding every resource scope but not mcp:write must still see no
// write tools at all.
func TestWriteToolsHiddenWithoutMCPWrite(t *testing.T) {
c := Caller{Scopes: []string{
"mcp:read", "servers:write", "workflows:write", "keys:write",
}}
for _, tool := range All().Visible(c) {
if tool.Write {
t.Errorf("write tool %q visible without mcp:write", tool.Name)
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package mcp
import (
"context"
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"github.com/gin-gonic/gin"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// callerFromContext builds the acting credential from the session the auth
// middleware already resolved. The mcp package reads no cookie and no header of
// its own: identity is settled before a request reaches here.
func callerFromContext(c *gin.Context) Caller {
return Caller{
InstanceID: auth.InstanceID(c),
Scopes: auth.Scopes(c),
TokenScope: auth.ServerScope(c),
TokenName: auth.TokenName(c),
}
}
// Handler serves the MCP endpoint. It is stateless: no session resumption, each
// request self-contained, which is what lets it sit behind ordinary request
// middleware with no special casing.
//
// Stateless mode also means this handler is POST-only in practice: the SDK's
// StreamableHTTPHandler hardcodes a 405 for GET whenever Stateless is true,
// because a stateless server has no session to open the server-to-client SSE
// stream against. The GET route is still registered deliberately (see
// handlers.go) so a client probing for the endpoint sees a protocol-correct
// 405 rather than gin's 404 — the MCP spec expects exactly that response from
// a server that does not offer the GET/SSE leg. Nothing here should route GET
// requests differently or try to make them do anything else.
func Handler() gin.HandlerFunc {
return func(c *gin.Context) {
caller := callerFromContext(c)
// A cookie session is not an agent. MCP is a credential-shaped surface
// and browsing to it in a logged-in tab must not act as one.
if !auth.IsToken(c) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "the mcp endpoint requires an API token",
})
return
}
srv := sdk.NewServer(&sdk.Implementation{
Name: "vantage",
Version: buildVersion,
}, nil)
for _, tool := range All().Visible(caller) {
registerSDKTool(srv, tool, caller)
}
sdk.NewStreamableHTTPHandler(func(*http.Request) *sdk.Server {
return srv
}, &sdk.StreamableHTTPOptions{Stateless: true}).ServeHTTP(c.Writer, c.Request)
}
}
// registerSDKTool adapts one registered Tool onto the SDK, wrapping it in the
// gate check and the audit write. The gate is re-checked here rather than
// trusted from Visible, because listing and calling are separate requests and a
// token's scopes are re-read on each.
func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) {
// InputSchema is set explicitly rather than inferred from the handler's
// argument type. The SDK can infer one from a typed In parameter, which is
// cleaner where it fits — but every ToolFunc here takes map[string]any, and
// inference over that yields a bare open object saying nothing. Giving each
// tool its own Go argument struct would mean twenty-odd structs and a
// generic registry that could no longer hold them in one map, losing the
// gate logic and the audit wrapper this function exists to apply. The
// declared Args are the same information without that cost, and the SDK
// validates against the schema either way.
sdk.AddTool(srv, &sdk.Tool{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.InputSchema(),
}, func(ctx context.Context, req *sdk.CallToolRequest, args map[string]any) (*sdk.CallToolResult, any, error) {
return callTool(ctx, tool, caller, args)
})
}
// callTool is the gate check, dispatch and audit write registerSDKTool wraps
// onto the SDK's call signature. It is a separate function — rather than the
// closure body inline — so it can be exercised directly in tests without
// standing up an sdk.Server and driving a real MCP request through it.
func callTool(ctx context.Context, tool Tool, caller Caller, args map[string]any) (*sdk.CallToolResult, any, error) {
if ok, gate := Allowed(tool, caller); !ok {
LogDenied(caller, tool.Name, gate)
return nil, nil, toolError(gate, tool)
}
out, err := tool.Handler(ctx, caller, args)
if err != nil {
// A write tool's own handler never gets a chance to audit its own
// refusal or failure — it returns before reaching its LogCall, and
// unlike a successful write, this layer does not know a resolved
// server count to pass along anyway. So every write failure is
// audited here instead: a policy refusal (fan-out or tag scope) as
// mcp.tool_denied naming the gate, everything else as
// mcp.tool_failed, so a human reading audit_logs can tell "the agent
// was stopped" from "the agent tried and the machine failed". Read
// tools are unaffected — a failed read was never going to change
// anything and carries no gate to name.
if tool.Write {
switch {
case errors.Is(err, ErrConfirmRequired):
LogDenied(caller, tool.Name, GateFanOut)
case errors.Is(err, ErrOutOfScope):
LogDenied(caller, tool.Name, GateTagScope)
default:
LogFailure(caller, tool, args, err)
}
}
return nil, nil, err
}
if !tool.Write {
// Write tools log their own call with a resolved server count, which
// this layer cannot know.
LogCall(caller, tool, args, 0)
}
return nil, out, nil
}
// toolError explains a refusal in words the model can act on. A transport-level
// failure would be invisible to it; a tool error is something it can read and
// relay to its user.
func toolError(gate string, tool Tool) error {
switch gate {
case GateMCPScope:
if tool.Write {
return errors.New("this token does not hold mcp:write, so it cannot use tools that change anything")
}
return errors.New("this token does not hold mcp:read")
case GateResourceScope:
return errors.New("this token does not hold " + tool.Scope)
default:
return errors.New("refused")
}
}
// buildVersion is stamped so a user with several instances connected can tell
// them apart in a client. Wire it to whatever the server already uses for its
// version string.
var buildVersion = "dev"
// SetVersion is called once at boot from main.
func SetVersion(v string) { buildVersion = v }
@@ -0,0 +1,25 @@
package mcp
import (
"testing"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// sdk.AddTool panics on a schema it cannot resolve, and the only place that
// would otherwise happen is inside a live request. Registering every tool onto
// a real server here moves that failure to the test run.
func TestEveryToolRegistersWithTheSDK(t *testing.T) {
srv := sdk.NewServer(&sdk.Implementation{Name: "vantage", Version: "test"}, nil)
caller := Caller{InstanceID: "i", Scopes: []string{"mcp:write"}}
for _, tool := range All().Tools() {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("tool %q: SDK rejected its input schema: %v", tool.Name, r)
}
}()
registerSDKTool(srv, tool, caller)
}()
}
}
+115
View File
@@ -0,0 +1,115 @@
package mcp
import (
"context"
"testing"
)
// TestRefusedWriteIsAudited exercises the real dispatch path (callTool, which
// registerSDKTool wraps) for a write tool whose handler refuses the call
// before it ever reaches its own LogCall — a fan-out refusal, in this case,
// which run_workflow, apply_updates, update_agent and assign_key all reach
// the same way via CheckFanOut. The refusal must still produce an audit row:
// a blocked mutation attempt is the single most audit-worthy event a write
// tool produces, and until this test the only thing recording it was the
// tool's own success path.
func TestRefusedWriteIsAudited(t *testing.T) {
var got []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
got = append(got, eventType+": "+details)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_write_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, CheckFanOut(200, args)
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
_, _, err := callTool(context.Background(), tool, caller, nil)
if err == nil {
t.Fatal("callTool() = nil error, want the fan-out refusal")
}
if len(got) != 1 {
t.Fatalf("logEvent called %d times, want exactly 1 audit row for the refusal; got %v", len(got), got)
}
if want := "mcp.tool_denied: "; len(got[0]) < len(want) || got[0][:len(want)] != want {
t.Errorf("audit row %q does not record a denial", got[0])
}
}
// TestOutOfScopeWriteIsAudited covers the other write refusal shape: a
// service-layer ErrOutOfScope wrap, as apply_updates/update_agent/assign_key
// return when ResolveTargetsScoped finds nothing this token may touch.
func TestOutOfScopeWriteIsAudited(t *testing.T) {
var got []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
got = append(got, eventType)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_scoped_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, ErrOutOfScope
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
if _, _, err := callTool(context.Background(), tool, caller, nil); err == nil {
t.Fatal("callTool() = nil error, want the scope refusal")
}
if len(got) != 1 || got[0] != "mcp.tool_denied" {
t.Errorf("audit events = %v, want exactly one mcp.tool_denied row", got)
}
}
// TestServiceFailureIsAuditedDistinctly makes sure a write tool failing for a
// reason that is not a policy refusal — the underlying service call itself
// erroring — is still audited, but as mcp.tool_failed rather than
// mcp.tool_denied, so a human reading audit_logs can tell the two apart.
func TestServiceFailureIsAuditedDistinctly(t *testing.T) {
var events []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
events = append(events, eventType)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_failing_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, errFakeServiceFailure
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
if _, _, err := callTool(context.Background(), tool, caller, nil); err == nil {
t.Fatal("callTool() = nil error, want the service failure")
}
if len(events) != 1 || events[0] != "mcp.tool_failed" {
t.Errorf("audit events = %v, want exactly one mcp.tool_failed row", events)
}
}
var errFakeServiceFailure = &fakeError{"the dispatcher refused the command"}
type fakeError struct{ msg string }
func (e *fakeError) Error() string { return e.msg }
+12 -3
View File
@@ -14,9 +14,10 @@ import (
// 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.
// Role, Scopes and TagSelector 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"`
@@ -33,6 +34,14 @@ type APIToken struct {
Role string `bson:"role" json:"role"`
Scopes []string `bson:"scopes" json:"scopes"`
// TagSelector restricts this token to servers carrying every tag in the
// map. Empty or nil means the whole fleet.
//
// Immutable after creation for the same reason as Role and Scopes: changing
// what a credential already deployed in CI can reach, with no record of what
// it could reach before, is worse than requiring a rotation.
TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,omitempty"`
// 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"`
+8
View File
@@ -21,6 +21,14 @@ const (
const RunnerServer = "server"
// RunnerRestricted replaces a monitor's runner in an API response when the
// real value is a server ID the acting token's scope does not admit. The
// monitor itself is still returned — a restricted operator may legitimately
// need to see its name and state — only where it runs is hidden, the same
// way a workflow's target list can omit an ID without the whole workflow
// disappearing from a list.
const RunnerRestricted = "restricted"
type MonitorTarget struct {
URL string `bson:"url,omitempty" json:"url,omitempty"`
Host string `bson:"host,omitempty" json:"host,omitempty"`
+57 -3
View File
@@ -119,6 +119,11 @@ type FindingFilter struct {
// patchable"; false is the unfixable set — remove the package, disable the
// service, or accept it, but do not wait for an update.
HasFix *bool
// TokenScope is the acting credential's tag restriction, nil meaning
// unrestricted. A finding on a server outside it is dropped: a CVE row
// names a server ID and a hostname, and a count that includes invisible
// hosts is itself a statement about a fleet the caller must not see.
TokenScope map[string]string
}
// ListInstanceFindings returns findings across the whole fleet.
@@ -166,6 +171,36 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
filter["server_id"] = bson.M{"$in": ids}
}
// The token restriction is applied the same way the Tags selector above
// is — by narrowing server_id — rather than by a post-pass, so the two
// cannot disagree and the query keeps one shape. IntersectSelectors is
// not used here because Tags has already been resolved to IDs by this
// point; intersecting the ID sets is the same operation one level down.
if len(f.TokenScope) > 0 {
visible, restricted, err := VisibleServerIDs(instanceID, f.TokenScope)
if err != nil {
return nil, err
}
if restricted {
ids := make([]string, 0, len(visible))
for id := range visible {
ids = append(ids, id)
}
if len(ids) == 0 {
return []models.VulnFinding{}, nil
}
if existing, ok := filter["server_id"]; ok {
filter["$and"] = bson.A{
bson.M{"server_id": existing},
bson.M{"server_id": bson.M{"$in": ids}},
}
delete(filter, "server_id")
} else {
filter["server_id"] = bson.M{"$in": ids}
}
}
}
cur, err := db.Col("vuln_findings").Find(ctx, filter)
if err != nil {
return nil, err
@@ -182,12 +217,31 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
// CountOpenFindingsBySeverity powers the summary tiles. Accepted findings are
// excluded: they are suppressed from counts until their expiry, which is the
// whole point of accepting one.
func CountOpenFindingsBySeverity(instanceID string) (map[string]int, error) {
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted: a summary tile counting findings on hosts the caller cannot
// see is the same aggregate leak as an unfiltered affected-host count.
func CountOpenFindingsBySeverity(instanceID string, tokenScope map[string]string) (map[string]int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
match := bson.M{"instance_id": instanceID, "state": models.FindingOpen}
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
if err != nil {
return nil, err
}
if restricted {
ids := make([]string, 0, len(visible))
for id := range visible {
ids = append(ids, id)
}
if len(ids) == 0 {
return map[string]int{}, nil
}
match["server_id"] = bson.M{"$in": ids}
}
cur, err := db.Col("vuln_findings").Aggregate(ctx, []bson.M{
{"$match": bson.M{"instance_id": instanceID, "state": models.FindingOpen}},
{"$match": match},
{"$group": bson.M{"_id": "$severity", "n": bson.M{"$sum": 1}}},
})
if err != nil {
@@ -464,4 +518,4 @@ func sweepFixedFindings(ctx context.Context) {
log.Printf("vuln sweeper: removed %d fixed findings for %s", res.DeletedCount, instanceID)
}
}
}
}
+39 -3
View File
@@ -118,7 +118,20 @@ type KeyWithCount struct {
AssignedCount int `bson:"-" json:"assigned_count"`
}
func ListKeys(instanceID string) ([]KeyWithCount, error) {
// ListKeys returns every key for instanceID together with its assignment
// count, narrowed by tokenScope: AssignedCount only counts assignments on
// servers ServerInTokenScope admits. Without this, a restricted token reading
// the key list would see a nonzero count for a key it cannot see a single
// assignment of in its own scope — the same hostname-existence leak
// getKey's scope filter closes on the detail route, reachable here through a
// count instead of a server object.
//
// An empty tokenScope (an unrestricted token, or a cookie session) means "see
// everything", matching ServerInTokenScope elsewhere, and takes the original,
// unfiltered per-key CountDocuments query with no extra work: the visible-
// fleet resolution below is skipped entirely, so the common unrestricted case
// costs exactly what it cost before this scope filter existed.
func ListKeys(instanceID string, tokenScope map[string]string) ([]KeyWithCount, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -133,14 +146,37 @@ func ListKeys(instanceID string) ([]KeyWithCount, error) {
return nil, err
}
// Resolve the visible fleet once, outside the per-key loop, so a
// restricted token's count costs one extra query total rather than one
// per key — the same reasoning ResolveTargetsScoped already applies to
// target resolution.
scoped := len(tokenScope) > 0
var visibleIDs []string
if scoped {
servers, err := ListServers(instanceID)
if err != nil {
return nil, err
}
visibleIDs = make([]string, 0, len(servers))
for _, s := range servers {
if ServerInTokenScope(s, tokenScope) {
visibleIDs = append(visibleIDs, s.ServerID)
}
}
}
result := make([]KeyWithCount, 0, len(keys))
for _, k := range keys {
setKeyMeta(&k)
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
filter := bson.M{
"instance_id": instanceID,
"key_id": k.KeyID,
"revoked_at": nil,
})
}
if scoped {
filter["server_id"] = bson.M{"$in": visibleIDs}
}
count, _ := db.Col("assignments").CountDocuments(ctx, filter)
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
}
return result, nil
+34 -7
View File
@@ -53,6 +53,28 @@ func SpecFor(m *models.Monitor) checker.Spec {
}
}
// RedactMonitorRunner replaces m.Runner with models.RunnerRestricted when it
// names a server outside the caller's scope, so GET /monitors and GET
// /monitors/:id can keep listing the monitor itself — name, type, state,
// whether it exists at all — as the first-class object it is, without
// disclosing which out-of-scope server it happens to run on. Omitting the
// monitor entirely was considered and rejected: a restricted operator has a
// legitimate reason to see that a monitor exists and is up or down even when
// they cannot manage the host it checks from, and hiding it wholesale is a
// bigger surprise than one field going neutral.
//
// Runner == models.RunnerServer (control-plane-run) is never touched: it
// names no server at all, so there is nothing to redact.
func RedactMonitorRunner(m models.Monitor, visible map[string]bool, restricted bool) models.Monitor {
if !restricted || m.Runner == models.RunnerServer {
return m
}
if !visible[m.Runner] {
m.Runner = models.RunnerRestricted
}
return m
}
func ListMonitors(instanceID string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
@@ -124,23 +146,28 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) {
return &m, nil
}
func validateRunner(instanceID, runner string) error {
// validateRunner refuses a monitor whose runner names a server the acting
// credential cannot see. A runner is literally a server ID, so an unscoped
// check here both lets a restricted token push work onto an out-of-scope agent
// and answers a fleet-enumeration question by the difference between "not
// found" and success. GetServerScoped collapses both into not-found.
func validateRunner(instanceID, runner string, tokenScope map[string]string) error {
if runner == "" || runner == models.RunnerServer {
return nil
}
if _, err := GetServer(instanceID, runner); err != nil {
if _, err := GetServerScoped(instanceID, runner, tokenScope); err != nil {
return fmt.Errorf("runner server %s not found", runner)
}
return nil
}
func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error) {
func CreateMonitor(instanceID string, m *models.Monitor, tokenScope map[string]string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
if err := validateChannelIDs(instanceID, m.ChannelIDs); err != nil {
return nil, err
}
if err := validateRunner(instanceID, m.Runner); err != nil {
if err := validateRunner(instanceID, m.Runner, tokenScope); err != nil {
return nil, err
}
group, err := normaliseGroup(m.Group)
@@ -167,7 +194,7 @@ func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error
return m, nil
}
func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
func UpdateMonitor(instanceID, monitorID string, upd bson.M, tokenScope map[string]string) error {
ctx, cancel := monCtx()
defer cancel()
@@ -196,7 +223,7 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
if !ok {
return fmt.Errorf("runner must be a string")
}
if err := validateRunner(instanceID, runner); err != nil {
if err := validateRunner(instanceID, runner, tokenScope); err != nil {
return err
}
@@ -424,5 +451,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
}
}(ch)
}
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()}, nil)
}
+18 -1
View File
@@ -93,7 +93,16 @@ type PackageHit struct {
// The Mongo filter narrows to documents containing the name; the second pass is
// needed because a multikey match returns the whole document, not the matching
// array element.
func SearchPackages(instanceID, name string) ([]PackageHit, error) {
//
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted; a hit on a server outside it is dropped before it is returned.
// The filtering is done with VisibleServerIDs — one membership set resolved
// once — rather than by resolving each hit's server individually the way
// search_fleet does, because a package search can return one hit per host in
// the fleet and the query shape must not depend on how many matched. The Mongo
// query itself is unchanged: server_packages carries no tags to filter on, so
// the narrowing is necessarily a second pass either way.
func SearchPackages(instanceID, name string, tokenScope map[string]string) ([]PackageHit, error) {
ctx := context.Background()
cur, err := db.Col("server_packages").Find(ctx, bson.M{
"instance_id": instanceID,
@@ -109,8 +118,16 @@ func SearchPackages(instanceID, name string) ([]PackageHit, error) {
return nil, err
}
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
if err != nil {
return nil, err
}
hits := []PackageHit{}
for _, d := range docs {
if restricted && !visible[d.ServerID] {
continue
}
for _, p := range d.Packages {
if p.Name == name {
hits = append(hits, PackageHit{ServerID: d.ServerID, Name: p.Name, Version: p.Version})
+5 -1
View File
@@ -11,7 +11,7 @@ import (
// the vocabulary below.
var ErrInvalidScope = errors.New("invalid scope")
// ScopeResources is the whole vocabulary. Eight resources, each with :read and
// ScopeResources is the whole vocabulary. Ten 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,
@@ -27,6 +27,10 @@ var ScopeResources = []string{
"workloads",
"settings",
"status",
// mcp:read is permission to reach the MCP endpoint at all; mcp:write is
// permission for its write tools, which are not merely refused without it
// but omitted from tools/list entirely.
"mcp",
}
const (
+43
View File
@@ -0,0 +1,43 @@
package services
import "testing"
// The MCP endpoint is reached with an ordinary scope from the ordinary
// vocabulary. A bespoke action verb here would be the first exception in a
// table whose whole value is having none.
func TestMCPScopesExist(t *testing.T) {
if err := ValidScopes([]string{"mcp:read"}); err != nil {
t.Errorf("ValidScopes(mcp:read) = %v, want nil", err)
}
if err := ValidScopes([]string{"mcp:write"}); err != nil {
t.Errorf("ValidScopes(mcp:write) = %v, want nil", err)
}
if err := ValidScopes([]string{"mcp:use"}); err == nil {
t.Error("ValidScopes(mcp:use) = nil, want an error")
}
}
// Write implies read on the same resource, so a token minted with mcp:write
// alone still reaches the endpoint.
func TestMCPWriteImpliesRead(t *testing.T) {
if !ScopeSatisfied([]string{"mcp:write"}, "mcp:read") {
t.Error("mcp:write does not satisfy mcp:read")
}
if ScopeSatisfied([]string{"mcp:read"}, "mcp:write") {
t.Error("mcp:read satisfies mcp:write, want false")
}
}
func TestAllScopesAdvertisesMCP(t *testing.T) {
want := map[string]bool{"mcp:read": false, "mcp:write": false}
for _, s := range AllScopes() {
if _, ok := want[s]; ok {
want[s] = true
}
}
for s, found := range want {
if !found {
t.Errorf("AllScopes() is missing %q", s)
}
}
}
+19
View File
@@ -71,6 +71,25 @@ func GetServer(instanceID, serverID string) (*models.Server, error) {
return &s, nil
}
// GetServerScoped is GetServer narrowed by the acting credential's tag
// restriction. An out-of-scope server reads as not-found, never as forbidden:
// a restricted token must not be able to enumerate the fleet it cannot see by
// noticing which IDs answer differently.
//
// mongo.ErrNoDocuments is GetServer's own not-found identifier — reused here
// rather than introducing a second one, so a caller checking for one keeps
// working against a server that exists but is out of the token's scope.
func GetServerScoped(instanceID, serverID string, tokenScope map[string]string) (*models.Server, error) {
srv, err := GetServer(instanceID, serverID)
if err != nil {
return nil, err
}
if !ServerInTokenScope(*srv, tokenScope) {
return nil, mongo.ErrNoDocuments
}
return srv, nil
}
func getServerByID(serverID string) (*models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+30
View File
@@ -66,3 +66,33 @@ func ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]m
}
return matched, nil
}
// ResolveTargetsScoped is ResolveTargets narrowed by the acting credential's
// tag restriction.
//
// This is the chokepoint that matters: workflow runs, console connections and
// update application all resolve targets through here, so filtering once here
// covers the mutating surface rather than each handler remembering.
//
// A request naming an out-of-scope server by ID resolves to nothing rather than
// to an error, which is what makes an out-of-scope host indistinguishable from
// one that does not exist.
func ResolveTargetsScoped(instanceID string, ids []string, sel, tokenScope map[string]string) ([]models.Server, error) {
all, err := ListServers(instanceID)
if err != nil {
return nil, err
}
visible := make([]models.Server, 0, len(all))
for _, s := range all {
if ServerInTokenScope(s, tokenScope) {
visible = append(visible, s)
}
}
matched := UnionTargets(visible, ids, sel)
if len(matched) == 0 {
return nil, ErrNoTargets
}
return matched, nil
}
+7 -1
View File
@@ -59,7 +59,7 @@ func LowerRole(a, b string) string {
// 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) {
func CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]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)
@@ -70,6 +70,11 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expi
if err := ValidScopes(scopes); err != nil {
return nil, "", err
}
if len(tagSelector) > 0 {
if err := ValidateTags(tagSelector); err != nil {
return nil, "", err
}
}
owner, err := GetUserInInstance(instanceID, userID)
if err != nil {
@@ -131,6 +136,7 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expi
TokenHash: HashToken(plaintext),
Role: role,
Scopes: scopes,
TagSelector: tagSelector,
ExpiresAt: expiresAt,
CreatedAt: time.Now().UTC(),
CreatedByIP: ip,
+107
View File
@@ -0,0 +1,107 @@
package services
import "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
// ServerInTokenScope reports whether a credential restricted to sel may see
// this server.
//
// This is deliberately NOT MatchesTags. That function serves workflow
// targeting, where an empty selector selects nothing because the caller named
// servers by ID instead. Here an empty selector means the token is
// unrestricted, so it must select everything. The two rules are opposite and
// sharing one function would silently lock every unrestricted token out of the
// whole fleet.
func ServerInTokenScope(srv models.Server, sel map[string]string) bool {
if len(sel) == 0 {
return true
}
for k, v := range sel {
if srv.Tags[k] != v {
return false
}
}
return true
}
// VisibleServerIDs resolves the servers tokenScope admits into a membership
// set, for a caller that needs to test many IDs against the caller's scope in
// one pass — redacting a monitor's runner, filtering a workflow's target list
// — rather than resolving one server at a time the way GetServerScoped does.
//
// restricted is false for an empty tokenScope, matching ServerInTokenScope's
// own rule that an empty selector is unrestricted rather than "sees nothing".
// ids is then nil, and callers must treat (nil, false) as "everything
// visible", never as "nothing visible" — the zero value of a map read is
// false, which would silently invert the rule for every unrestricted caller
// if this contract were not honoured.
func VisibleServerIDs(instanceID string, tokenScope map[string]string) (ids map[string]bool, restricted bool, err error) {
if len(tokenScope) == 0 {
return nil, false, nil
}
servers, err := ListServers(instanceID)
if err != nil {
return nil, true, err
}
ids = make(map[string]bool, len(servers))
for _, s := range servers {
if ServerInTokenScope(s, tokenScope) {
ids[s.ServerID] = true
}
}
return ids, true, nil
}
// FilterVisibleServerIDs narrows ids to the ones visible admits, using the
// (ids, restricted) pair VisibleServerIDs returns. An unrestricted caller
// (restricted false) gets ids back unchanged and hidden is always false.
//
// hidden reports only whether at least one id was dropped — never how many —
// because the point of surfacing it at all is to let a caller say "some
// targets are not visible to you" without the count itself becoming the leak
// this exists to close. A workflow that targets both an in-scope and an
// out-of-scope host should not read as "0 hidden" or "3 hidden"; either
// number is information about a fleet outside the caller's scope.
func FilterVisibleServerIDs(ids []string, visible map[string]bool, restricted bool) (filtered []string, hidden bool) {
if !restricted {
return ids, false
}
filtered = make([]string, 0, len(ids))
for _, id := range ids {
if visible[id] {
filtered = append(filtered, id)
} else {
hidden = true
}
}
return filtered, hidden
}
// IntersectSelectors merges the caller's token restriction with a selector the
// request asked for. ok is false when the two can never both hold, which means
// the request resolves to no servers rather than to an error.
func IntersectSelectors(caller, requested map[string]string) (map[string]string, bool) {
out := make(map[string]string, len(caller)+len(requested))
for k, v := range caller {
out[k] = v
}
for k, v := range requested {
if existing, ok := out[k]; ok && existing != v {
return nil, false
}
out[k] = v
}
return out, true
}
// SelectorNarrowerOrEqual reports whether child reaches no further than parent.
//
// It is the tag equivalent of the rule ScopeSatisfied already enforces for
// scopes: a credential may only mint one no more powerful than itself.
func SelectorNarrowerOrEqual(child, parent map[string]string) bool {
for k, v := range parent {
if child[k] != v {
return false
}
}
return true
}
+187
View File
@@ -0,0 +1,187 @@
package services
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func srv(tags map[string]string) models.Server {
return models.Server{ServerID: "s1", Tags: tags}
}
// The critical asymmetry with MatchesTags: an EMPTY token selector means the
// whole fleet, where an empty workflow selector matches nothing. Reusing
// MatchesTags here would lock every unrestricted token out of everything.
func TestServerInTokenScopeEmptySelectorAllowsAll(t *testing.T) {
if !ServerInTokenScope(srv(nil), nil) {
t.Error("nil selector rejected a server, want whole-fleet access")
}
if !ServerInTokenScope(srv(map[string]string{"env": "prod"}), map[string]string{}) {
t.Error("empty selector rejected a server, want whole-fleet access")
}
}
func TestServerInTokenScopeRequiresEveryTag(t *testing.T) {
s := srv(map[string]string{"env": "staging", "team": "core"})
if !ServerInTokenScope(s, map[string]string{"env": "staging"}) {
t.Error("matching selector rejected")
}
if !ServerInTokenScope(s, map[string]string{"env": "staging", "team": "core"}) {
t.Error("fully matching selector rejected")
}
if ServerInTokenScope(s, map[string]string{"env": "prod"}) {
t.Error("non-matching selector accepted")
}
if ServerInTokenScope(s, map[string]string{"env": "staging", "team": "web"}) {
t.Error("partially matching selector accepted, every tag must match")
}
}
func TestIntersectSelectors(t *testing.T) {
// No token restriction: the request's own selector stands.
got, ok := IntersectSelectors(nil, map[string]string{"env": "prod"})
if !ok || got["env"] != "prod" || len(got) != 1 {
t.Errorf("IntersectSelectors(nil, env=prod) = %v, %v", got, ok)
}
// Disjoint values for the same key can never both hold.
if _, ok := IntersectSelectors(
map[string]string{"env": "staging"},
map[string]string{"env": "prod"},
); ok {
t.Error("conflicting selectors intersected to something, want impossible")
}
// Different keys combine.
got, ok = IntersectSelectors(
map[string]string{"env": "staging"},
map[string]string{"team": "core"},
)
if !ok || got["env"] != "staging" || got["team"] != "core" {
t.Errorf("IntersectSelectors = %v, %v, want both keys", got, ok)
}
}
func TestSelectorNarrowerOrEqual(t *testing.T) {
parent := map[string]string{"env": "staging"}
// Same selector, and a stricter one, are both allowed.
if !SelectorNarrowerOrEqual(parent, parent) {
t.Error("identical selector rejected")
}
if !SelectorNarrowerOrEqual(map[string]string{"env": "staging", "team": "core"}, parent) {
t.Error("stricter selector rejected")
}
// A token may not mint one that reaches further than itself.
if SelectorNarrowerOrEqual(nil, parent) {
t.Error("unrestricted child of a restricted parent allowed")
}
if SelectorNarrowerOrEqual(map[string]string{"env": "prod"}, parent) {
t.Error("child escaping the parent's tag allowed")
}
// An unrestricted parent permits anything.
if !SelectorNarrowerOrEqual(map[string]string{"env": "prod"}, nil) {
t.Error("restricted child of an unrestricted parent rejected")
}
}
// UnionTargets is the pure core of target resolution, so scoping can be proved
// without a database by filtering its input the way ResolveTargetsScoped does.
func TestScopedTargetsExcludeOutOfScopeServers(t *testing.T) {
all := []models.Server{
{ServerID: "a", Tags: map[string]string{"env": "staging"}},
{ServerID: "b", Tags: map[string]string{"env": "prod"}},
}
scope := map[string]string{"env": "staging"}
visible := []models.Server{}
for _, s := range all {
if ServerInTokenScope(s, scope) {
visible = append(visible, s)
}
}
// Naming an out-of-scope server by ID must not reach it.
got := UnionTargets(visible, []string{"a", "b"}, nil)
if len(got) != 1 || got[0].ServerID != "a" {
t.Errorf("scoped targets = %v, want only a", got)
}
}
// A workflow run is dispatched to whatever ResolveTargetsScoped returns, so
// the property Critical 1 restores is that a run of a production-targeting
// workflow, started by a staging token, reaches nothing. Proved over
// UnionTargets for the same reason as the test above: no database.
func TestScopedRunOfOutOfScopeWorkflowReachesNothing(t *testing.T) {
all := []models.Server{
{ServerID: "prod-1", Tags: map[string]string{"env": "prod"}},
{ServerID: "prod-2", Tags: map[string]string{"env": "prod"}},
}
scope := map[string]string{"env": "staging"}
visible := []models.Server{}
for _, s := range all {
if ServerInTokenScope(s, scope) {
visible = append(visible, s)
}
}
// The workflow's own saved targets, both by ID and by tag.
got := UnionTargets(visible, []string{"prod-1", "prod-2"}, map[string]string{"env": "prod"})
if len(got) != 0 {
t.Errorf("staging-scoped run resolved %v, want nothing", got)
}
// ResolveTargetsScoped turns that empty set into ErrNoTargets, which is
// the same answer a workflow targeting no servers at all gives — so the
// refusal does not tell the caller that production hosts exist.
}
// The scheduler passes a nil scope because it acts as the system. That must
// keep meaning "the whole fleet", never "nothing", or every scheduled workflow
// would silently stop firing.
func TestNilScopeIsUnrestrictedNotEmpty(t *testing.T) {
all := []models.Server{
{ServerID: "prod-1", Tags: map[string]string{"env": "prod"}},
{ServerID: "stg-1", Tags: map[string]string{"env": "staging"}},
}
visible := []models.Server{}
for _, s := range all {
if ServerInTokenScope(s, nil) {
visible = append(visible, s)
}
}
if len(visible) != 2 {
t.Errorf("nil scope admitted %d servers, want all %d", len(visible), len(all))
}
}
// FilterVisibleServerIDs is what narrows a run document's ServerRuns and a
// workflow's target list. The property that matters is that the caller is told
// something was hidden without being told how much.
func TestFilterVisibleServerIDsReportsHiddenWithoutCount(t *testing.T) {
visible := map[string]bool{"a": true}
got, hidden := FilterVisibleServerIDs([]string{"a", "b", "c"}, visible, true)
if len(got) != 1 || got[0] != "a" {
t.Errorf("filtered = %v, want [a]", got)
}
if !hidden {
t.Error("hidden = false with two ids dropped")
}
// Dropping one and dropping ten are indistinguishable: hidden is a bool.
_, hiddenOne := FilterVisibleServerIDs([]string{"a", "b"}, visible, true)
if hiddenOne != hidden {
t.Error("hidden distinguishes how many were dropped")
}
// An unrestricted caller is never told anything was hidden.
got, hidden = FilterVisibleServerIDs([]string{"a", "b"}, nil, false)
if len(got) != 2 || hidden {
t.Errorf("unrestricted filter = %v, %v; want everything and no hidden flag", got, hidden)
}
}
+14 -2
View File
@@ -17,12 +17,24 @@ import (
const stepDispatchGrace = 15 * time.Second
func TriggerWorkflow(instanceID, workflowID, actor string) (string, error) {
// TriggerWorkflow starts a run of workflow workflowID.
//
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted. It is threaded down to ResolveTargetsScoped rather than being
// applied by the caller, because this is the one place a run's target set is
// decided: a handler that resolved targets itself and then called an unscoped
// trigger would leave the dispatch reaching further than the readout.
//
// A run whose configured targets fall entirely outside the caller's scope
// resolves to nothing and returns ErrNoTargets — the same answer a workflow
// targeting no servers at all gives, so an out-of-scope host stays
// indistinguishable from one that does not exist.
func TriggerWorkflow(instanceID, workflowID, actor string, tokenScope map[string]string) (string, error) {
wf, err := GetWorkflow(instanceID, workflowID)
if err != nil {
return "", err
}
targets, err := ResolveTargets(instanceID, wf.TargetServerIDs, wf.TargetTags)
targets, err := ResolveTargetsScoped(instanceID, wf.TargetServerIDs, wf.TargetTags, tokenScope)
if err != nil {
return "", err
}
+19 -6
View File
@@ -239,7 +239,7 @@ func GetWorkflow(instanceID, id string) (*models.Workflow, error) {
return &w, err
}
func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, error) {
func CreateWorkflow(instanceID string, w models.Workflow, tokenScope map[string]string) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
w.InstanceID = instanceID
@@ -258,7 +258,7 @@ func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, err
if err := ValidateTags(w.TargetTags); err != nil {
return nil, err
}
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, w.TargetServerIDs, tokenScope); err != nil {
return nil, err
}
normalizeInlineSteps(&w)
@@ -268,7 +268,7 @@ func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, err
return &w, nil
}
func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
func UpdateWorkflow(instanceID, id string, w models.Workflow, tokenScope map[string]string) error {
ctx, cancel := wfCtx()
defer cancel()
if err := ValidateWorkflow(w); err != nil {
@@ -277,7 +277,7 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
if err := ValidateTags(w.TargetTags); err != nil {
return err
}
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, w.TargetServerIDs, tokenScope); err != nil {
return err
}
normalizeInlineSteps(&w)
@@ -291,9 +291,22 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
return err
}
func validateTargetServers(instanceID string, serverIDs []string) error {
// validateTargetServers refuses a workflow naming a server the acting
// credential cannot see.
//
// It resolves through GetServerScoped rather than GetServer for two reasons.
// The first is escalation: without it a token restricted to staging could save
// a workflow targeting production and then reach those hosts through the
// scheduler, which fires as the system with no restriction of its own. The
// second is enumeration — "target server X not found" versus a successful save
// is a yes/no oracle over the whole fleet, and the design forbids a restricted
// token learning which IDs exist outside its scope.
//
// Both cases collapse into the same message an ID that genuinely does not
// exist produces, which is what keeps the two indistinguishable.
func validateTargetServers(instanceID string, serverIDs []string, tokenScope map[string]string) error {
for _, sid := range serverIDs {
if _, err := GetServer(instanceID, sid); err != nil {
if _, err := GetServerScoped(instanceID, sid, tokenScope); err != nil {
return fmt.Errorf("target server %s not found", sid)
}
}
+15 -1
View File
@@ -93,7 +93,13 @@ type WorkloadHit struct {
// SearchWorkloads answers "which servers run image X" — the reason the snapshot
// is stored rather than fetched on demand and discarded.
func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, error) {
//
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted. A WorkloadHit names a server ID, so an unfiltered fleet-wide
// search enumerates hosts a restricted token must not see. server_workloads
// carries no tags of its own, so the narrowing is a membership test against
// VisibleServerIDs resolved once — the same shape SearchPackages uses.
func SearchWorkloads(instanceID, image, stack, state string, tokenScope map[string]string) ([]WorkloadHit, error) {
ctx := context.Background()
filter := bson.M{"instance_id": instanceID}
@@ -112,8 +118,16 @@ func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, err
return nil, err
}
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
if err != nil {
return nil, err
}
hits := []WorkloadHit{}
for _, d := range docs {
if restricted && !visible[d.ServerID] {
continue
}
for _, w := range d.Workloads {
if image != "" && w.Image != image {
continue
+8 -2
View File
@@ -17,7 +17,7 @@ const tickInterval = 30 * time.Second
// imported because services already imports this package for NextOccurrence,
// and a package cannot import its own importer.
type Deps struct {
TriggerWorkflow func(instanceID, workflowID, actor string) (string, error)
TriggerWorkflow func(instanceID, workflowID, actor string, tokenScope map[string]string) (string, error)
LogEvent func(instanceID, eventType, actor, serverID, keyID, details string)
}
@@ -105,7 +105,13 @@ func process(ctx context.Context, deps Deps, wf models.Workflow, now time.Time)
case SkipRunning:
recordSkip(ctx, deps, wf, string(SkipRunning), dueAt, now)
case Fire:
if _, err := deps.TriggerWorkflow(wf.InstanceID, wf.WorkflowID, "schedule"); err != nil {
// nil tokenScope: the scheduler acts as the system, not as any user.
// A schedule fires the workflow's own saved targets, and there is no
// acting credential whose tag restriction could narrow them — the
// person who armed the schedule is not present at fire time, and
// inheriting a restriction from whoever last saved the workflow would
// make a run's reach depend on an editor's credential.
if _, err := deps.TriggerWorkflow(wf.InstanceID, wf.WorkflowID, "schedule", nil); err != nil {
log.Printf("workflowsched: trigger %s: %v", wf.WorkflowID, err)
recordSkip(ctx, deps, wf, "error: "+err.Error(), dueAt, now)
return
+4 -4
View File
@@ -216,7 +216,7 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
<span className="font-mono text-text-primary">{secret.key}</span> will be removed from the{" "}
<span className="font-mono text-text-primary">{group}</span> group.
</p>
<p>Anything reading this key a workflow step, an External Secrets sync starts failing at its next run.</p>
<p>Anything reading this key a workflow step, an API consumer, an external sync starts failing at its next run.</p>
</>
}
/>
@@ -310,7 +310,7 @@ export default function SecretGroupPage() {
<div className="min-w-0">
<h1 className="break-all font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 break-words text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /api/secrets/{group}/values</span>
Read this group at <span className="font-mono">GET /api/secrets/{group}/values</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
@@ -344,7 +344,7 @@ export default function SecretGroupPage() {
{data ? `${data.secrets.length} of its keys` : "of its keys"}. The values cannot be recovered.
</p>
<p>
Every workflow step referencing this group, and any External Secrets sync reading{" "}
Every workflow step referencing this group, and anything reading{" "}
<span className="font-mono">/api/secrets/{group}/values</span>, fails at its next run.
</p>
</>
@@ -361,7 +361,7 @@ export default function SecretGroupPage() {
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!data || data.secrets.length === 0}
empty={<EmptyState title="This group has no keys yet." description="Add one above and it becomes available to workflow steps and External Secrets straight away." />}
empty={<EmptyState title="This group has no keys yet." description="Add one above and it becomes available to workflow steps and any API consumer straight away." />}
>
<Table>
<Thead>
+2 -2
View File
@@ -109,7 +109,7 @@ export default function SecretsPage() {
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, exposed to Kubernetes via ESO
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, read by workflows, the API, and external consumers
</p>
</div>
<Button variant="primary" onClick={() => setShowNew(true)}>
@@ -130,7 +130,7 @@ export default function SecretsPage() {
empty={
<EmptyState
title="No secret groups yet."
description="A group holds related key/value pairs, encrypted at rest, and is read by workflow steps and External Secrets."
description="A group holds related key/value pairs, encrypted at rest, and readable by workflow steps, the API, and anything you point at it."
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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
+1
View File
@@ -262,6 +262,7 @@ export default function LicensePage() {
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
<Feature label="Vulnerability Scanning" included={Boolean(license.features.vuln_scanning)} />
<Feature label="Status Pages" included={Boolean(license.features.status_pages)} />
<Feature label="Agent Access (MCP)" included={Boolean(license.features.mcp)} />
</div>
</Card>
</Group>
@@ -0,0 +1,68 @@
import { useState } from "react";
/*
* Everything needed to point an LLM client at this instance, on the page where
* the credential it needs is minted. The endpoint is licence-gated
* (RequireFeature(license.FeatureMCP)), so the panel only exists where the
* connection would actually work.
*
* Styled as a well rather than a card: this is machine output being handed to
* the operator, the same treatment the install one-liner gets on /servers/new.
*/
function CopyLine({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex flex-col overflow-hidden rounded border border-border bg-well sm:flex-row">
<pre className="flex-1 overflow-x-auto p-3 font-mono text-xs text-text-primary">{value}</pre>
<button
type="button"
onClick={copy}
className="border-t border-border bg-surface-2 px-4 py-2.5 text-sm text-text-primary hover:bg-border sm:border-l sm:border-t-0"
>
{copied ? "Copied" : "Copy"}
<span className="sr-only"> the {label}</span>
</button>
</div>
);
}
export function AgentAccessPanel() {
const origin = typeof window === "undefined" ? "https://YOUR-INSTANCE" : window.location.origin;
const endpoint = `${origin}/api/mcp`;
const config = JSON.stringify(
{
mcpServers: {
vantage: {
type: "http",
url: endpoint,
headers: { Authorization: "Bearer vt_your_key_here" },
},
},
},
null,
2,
);
return (
<section className="mt-8 rounded-lg border border-border bg-surface p-4 sm:p-5">
<h2 className="text-base font-semibold text-text-primary">Agent access</h2>
<p className="mt-1 max-w-[65ch] text-sm text-text-secondary">
An LLM client can call this instance over MCP with an API key. The key needs <code className="font-mono text-xs">mcp:read</code>,
plus <code className="font-mono text-xs">mcp:write</code> for tools that change anything, and its other scopes and tag restriction
still decide what those tools can reach.
</p>
<div className="mt-4 flex flex-col gap-3">
<CopyLine label="endpoint" value={endpoint} />
<CopyLine label="client configuration" value={config} />
</div>
</section>
);
}
+57 -268
View File
@@ -4,119 +4,32 @@ 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 { useLicense } from "@/lib/useLicense";
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";
import { KeyLedger, LedgerSkeleton } from "./KeyLedger";
import { KeyPosture } from "./KeyPosture";
import { AgentAccessPanel } from "./AgentAccessPanel";
import { CreateKeyDialog, EXPIRY_OPTIONS } from "./CreateKeyDialog";
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.
*
@@ -129,6 +42,11 @@ export function ApiKeysPanel() {
const queryClient = useQueryClient();
const { user, isAdmin } = useAuth();
const toast = useToast();
// Strict rather than useLicense's optimistic hasFeature: both consumers
// below hide rather than disable, and a panel that appears and then
// vanishes once the licence loads reads as a glitch.
const { license } = useLicense();
const hasMCP = Boolean(license?.features?.mcp);
const [showAll, setShowAll] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
@@ -137,6 +55,7 @@ export function ApiKeysPanel() {
const [name, setName] = useState("");
const [role, setRole] = useState<Role>("member");
const [scopes, setScopes] = useState<string[]>([]);
const [tagSelector, setTagSelector] = useState<Record<string, string>>({});
const [expiryDays, setExpiryDays] = useState<number | null>(30);
const [result, setResult] = useState<{ token: string; record: ApiToken } | null>(null);
const [copied, setCopied] = useState(false);
@@ -153,7 +72,10 @@ export function ApiKeysPanel() {
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])));
// The scope vocabulary is the server's, but mcp:* is unreachable without
// the licence feature, and offering a grant that cannot be used is a
// support ticket waiting to happen.
const resources = Array.from(new Set(availableScopes.map((s) => s.split(":")[0]))).filter((r) => r !== "mcp" || hasMCP);
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
@@ -161,6 +83,7 @@ export function ApiKeysPanel() {
setName("");
setRole("member");
setScopes([]);
setTagSelector({});
setExpiryDays(30);
setResult(null);
setCopied(false);
@@ -180,7 +103,16 @@ export function ApiKeysPanel() {
error: createError,
reset: resetCreateError,
} = useMutation({
mutationFn: () => api.createApiToken({ name, role, scopes, expires_in_days: expiryDays ?? undefined }),
mutationFn: () =>
api.createApiToken({
name,
role,
scopes,
// Omitted rather than {} when unrestricted: the server reads an
// absent selector as the whole fleet, and so does the reader.
tag_selector: Object.keys(tagSelector).length ? tagSelector : undefined,
expires_in_days: expiryDays ?? undefined,
}),
onSuccess: (res) => {
setResult(res);
},
@@ -227,12 +159,7 @@ export function ApiKeysPanel() {
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>
<h1 className="text-2xl font-bold text-text-primary">API Keys</h1>
<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" />
@@ -266,11 +193,13 @@ export function ApiKeysPanel() {
</div>
)}
{count > 0 && <KeyPosture tokens={tokens ?? []} capDays={capDays} />}
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
skeleton={<TableSkeleton columns={showAll ? 7 : 6} />}
skeleton={<LedgerSkeleton />}
isEmpty={count === 0}
empty={
<EmptyState
@@ -293,55 +222,12 @@ export function ApiKeysPanel() {
/>
}
>
<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>
<KeyLedger tokens={tokens ?? []} showAll={showAll} capDays={capDays} onRevoke={setRevoking} />
</AsyncBoundary>
</Card>
{hasMCP && <AgentAccessPanel />}
<ConfirmDialog
open={revoking !== null}
title="Revoke key"
@@ -361,127 +247,30 @@ export function ApiKeysPanel() {
}
/>
<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>
<CreateKeyDialog
open={createOpen}
onClose={closeCreate}
result={result}
copied={copied}
onCopy={copyToken}
name={name}
setName={setName}
role={role}
setRole={setRole}
assignableRoles={assignableRoles}
resources={resources}
scopes={scopes}
toggleScope={toggleScope}
setScopes={setScopes}
tagSelector={tagSelector}
setTagSelector={setTagSelector}
expiryDays={expiryDays}
setExpiryDays={setExpiryDays}
capDays={capDays}
creating={creating}
createError={createError}
onSubmit={createToken}
/>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
import type { ApiToken, Role } from "@/lib/api";
import { Button, Modal, friendlyMessage } from "@/components/ui";
import { Field, inputClass } from "@/components/settings/Field";
import { ScopeChips, summariseScopes } from "./ScopeChips";
import { ScopeMatrix } from "./ScopeMatrix";
import { TagChips, TagRestriction } from "./TagRestriction";
export 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 },
];
function expiryDate(days: number, now = Date.now()) {
return new Date(now + days * 24 * 60 * 60 * 1000).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
}
/**
* The key read back as a sentence before it exists.
*
* Ticking eleven boxes and reading eleven boxes back are the same act, so the
* form cannot catch an over-grant on its own. A sentence can: reading "may read
* and write servers, workflows, secrets and keys" out loud is what sends
* somebody back to untick two of them.
*/
function PreviewLine({
name,
role,
scopes,
tagSelector,
expiryDays,
}: {
name: string;
role: Role;
scopes: string[];
tagSelector: Record<string, string>;
expiryDays: number | null;
}) {
const summary = summariseScopes(scopes);
const rw = summary.filter((s) => s.access === "rw").map((s) => s.resource);
const ro = summary.filter((s) => s.access === "r").map((s) => s.resource);
const list = (xs: string[]) => (xs.length > 1 ? `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}` : xs[0]);
const tags = Object.entries(tagSelector).map(([k, v]) => `${k}=${v}`);
const grants: string[] = [];
if (rw.length) grants.push(`read and write ${list(rw)}`);
if (ro.length) grants.push(`read ${list(ro)}`);
return (
<p className="rounded border border-border-soft bg-well px-3 py-2.5 font-mono text-xs leading-relaxed text-text-secondary">
<span className="text-text-primary">{name.trim() || "This key"}</span> acts as{" "}
<span className="text-text-primary">{role}</span>,{" "}
{grants.length ? (
<>
may <span className="text-text-primary">{grants.join(", and ")}</span>
</>
) : (
<span className="text-warning">can call nothing until a scope is granted</span>
)}
{tags.length > 0 && (
<>
{" "}
on servers tagged <span className="text-text-primary">{list(tags)}</span>
</>
)}
, and{" "}
{expiryDays === null ? (
<span className="text-warning">never expires</span>
) : (
<>
stops working on <span className="text-warning">{expiryDate(expiryDays)}</span>
</>
)}
.
</p>
);
}
export function CreateKeyDialog({
open,
onClose,
result,
copied,
onCopy,
name,
setName,
role,
setRole,
assignableRoles,
resources,
scopes,
toggleScope,
setScopes,
tagSelector,
setTagSelector,
expiryDays,
setExpiryDays,
capDays,
creating,
createError,
onSubmit,
}: {
open: boolean;
onClose: () => void;
result: { token: string; record: ApiToken } | null;
copied: boolean;
onCopy: () => void;
name: string;
setName: (v: string) => void;
role: Role;
setRole: (v: Role) => void;
assignableRoles: Role[];
resources: string[];
scopes: string[];
toggleScope: (s: string) => void;
setScopes: (s: string[]) => void;
tagSelector: Record<string, string>;
setTagSelector: (t: Record<string, string>) => void;
expiryDays: number | null;
setExpiryDays: (v: number | null) => void;
capDays: number;
creating: boolean;
createError: unknown;
onSubmit: () => void;
}) {
return (
<Modal open={open} title={result ? `${result.record.name} is ready` : "Create key"} onClose={onClose}>
{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 the key is shown. Copy it now Vantage stores only a hash and cannot show it again.
</div>
{/* Below sm the button drops beneath the value: Copy has to
be reachable without scrolling 64 characters of hex. */}
<div className="flex flex-col overflow-hidden rounded border border-border bg-well sm:flex-row">
<code className="flex-1 overflow-x-auto whitespace-nowrap p-3 font-mono text-sm text-text-primary">{result.token}</code>
<button
type="button"
onClick={onCopy}
className="border-t border-border bg-surface-2 px-4 py-2.5 text-sm text-text-primary hover:bg-border sm:border-l sm:border-t-0"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
<dl className="grid grid-cols-[88px_1fr] items-baseline 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>
{result.record.tag_selector && Object.keys(result.record.tag_selector).length > 0 && (
<>
<dt className="text-text-secondary">Servers</dt>
<dd className="flex flex-wrap gap-1.5">
<TagChips selector={result.record.tag_selector} />
</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>
{/* So nobody leaves the dialog to find out how to use
what they just made, while the value is on screen. */}
<dt className="text-text-secondary">Use it</dt>
<dd className="overflow-x-auto">
<code className="whitespace-nowrap font-mono text-xs text-text-secondary">
curl -H &quot;Authorization: Bearer {result.record.hint}&quot; {typeof window !== "undefined" ? window.location.origin : ""}
/api/servers
</code>
</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 flex-col-reverse justify-end gap-2 sm:flex-row">
<Button type="button" variant="ghost" onClick={onClose}>
Done
</Button>
<Button type="button" variant="primary" onClick={onCopy}>
{copied ? "Copied" : "Copy key"}
</Button>
</div>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-5"
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name" hint="What will use this key — the CI pipeline, the script, the cluster.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
<Field label="Role" hint="A key never outranks the person who made it.">
<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>
</div>
<Field label="Scopes" hint="Grant only what the caller actually needs. Write already covers read.">
<ScopeMatrix resources={resources} scopes={scopes} onToggle={toggleScope} onSet={setScopes} />
</Field>
<Field label="Restrict to servers tagged" hint="Optional. Narrows which servers this key can act on, whatever its scopes say.">
<TagRestriction selector={tagSelector} onChange={setTagSelector} />
</Field>
<Field
label="Expires"
hint={
capDays > 0
? `This instance caps new keys at ${capDays} days. Longer options, 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.days === null ? o.label : `${o.label}${expiryDate(o.days)}`}
</option>
);
})}
</select>
</Field>
<PreviewLine name={name} role={role} scopes={scopes} tagSelector={tagSelector} expiryDays={expiryDays} />
{createError ? (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>
) : null}
<div className="flex flex-col-reverse justify-end gap-2 sm:flex-row">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Create key
</Button>
</div>
</form>
)}
</Modal>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { ApiToken, Role } from "@/lib/api";
import { Badge, Button } from "@/components/ui";
import { ScopeChips } from "./ScopeChips";
import { LifetimeBar } from "./LifetimeBar";
import { TagChips } from "./TagRestriction";
export function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
return "neutral" as const;
}
/*
* A grid rather than the shared <Table>: the identity column stacks four
* things name, hint, holder, role and Td assumes one value per cell.
*
* Below lg the grid collapses to a stacked record and each cell grows its own
* label from data-label. A date sitting under a chip list with no headings is
* unreadable once the columns are gone, and the header row cannot follow the
* cells down.
*/
const COLUMNS = "lg:grid-cols-[minmax(220px,1.5fr)_minmax(180px,1.3fr)_minmax(150px,1fr)_150px_auto]";
const LABEL =
"before:mb-1.5 before:block before:font-mono before:text-[0.65rem] before:uppercase before:tracking-[0.08em] before:text-text-tertiary before:content-[attr(data-label)] lg:before:hidden";
export function KeyLedger({
tokens,
showAll,
capDays,
onRevoke,
}: {
tokens: ApiToken[];
showAll: boolean;
capDays: number;
onRevoke: (t: { id: string; name: string }) => void;
}) {
return (
<div>
<div
className={`hidden bg-surface-2 px-4 py-2 font-mono text-[0.65rem] uppercase tracking-[0.08em] text-text-tertiary lg:grid lg:gap-5 ${COLUMNS}`}
>
<span>Key {showAll && "/ holder"}</span>
<span>Scopes</span>
<span>Lifetime</span>
<span>Last call</span>
<span className="sr-only">Actions</span>
</div>
{tokens.map((t) => (
<div
key={t.token_id}
className={`relative grid gap-3 border-t border-border-soft px-4 py-4 transition-colors hover:bg-surface-2 lg:items-center lg:gap-5 ${COLUMNS}`}
>
<div className="flex min-w-0 flex-col gap-1 pr-24 lg:pr-0">
<span className="font-medium text-text-primary">{t.name}</span>
<span className="font-mono text-xs text-text-tertiary">{t.hint}</span>
{/* The holder joins the identity rather than claiming a
fifth column, so All keys changes what a record says
instead of how the page is laid out. */}
<span className="flex flex-wrap items-center gap-1.5 text-xs text-text-tertiary">
{showAll && <span>{t.user_email ?? t.user_id}</span>}
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
</span>
</div>
<div data-label="scopes" className={LABEL}>
<div className="flex flex-wrap gap-1.5">
<ScopeChips scopes={t.scopes} wrap={false} />
<TagChips selector={t.tag_selector} />
</div>
</div>
<div data-label="lifetime" className={LABEL}>
<LifetimeBar token={t} capDays={capDays} />
</div>
<div data-label="last call" className={LABEL}>
<span className={`font-mono text-xs tabular-nums ${t.last_used_at ? "text-text-secondary" : "text-text-tertiary"}`}>
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never used"}
</span>
</div>
<div className="absolute right-3 top-3 lg:static lg:text-right">
<Button
variant="ghost"
size="sm"
className="border border-border text-danger hover:text-danger lg:border-transparent"
onClick={() => onRevoke({ id: t.token_id, name: t.name })}
>
Revoke<span className="sr-only"> {t.name}</span>
</Button>
</div>
</div>
))}
</div>
);
}
/** A ledger-shaped loading state. TableSkeleton draws a table, and the shape
* flipping under the reader on the first paint reads as a layout bug. */
export function LedgerSkeleton() {
return (
<div>
{[0, 1, 2].map((i) => (
<div key={i} className="grid gap-3 border-t border-border-soft px-4 py-5 lg:gap-5 lg:grid-cols-4">
<div className="h-4 w-40 animate-pulse rounded bg-surface-2" />
<div className="h-4 w-32 animate-pulse rounded bg-surface-2" />
<div className="h-4 w-36 animate-pulse rounded bg-surface-2" />
<div className="h-4 w-28 animate-pulse rounded bg-surface-2" />
</div>
))}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ApiToken } from "@/lib/api";
import { keyLifetime } from "@/lib/keyLifetime";
/*
* Four counts above the list, answering "is anything wrong here" before a
* single row is read. All four are derived from the tokens already in hand
* no second request, and no endpoint that could disagree with the list.
*
* The counts describe the list as filtered, so this sits below the My keys /
* All keys toggle and moves with it.
*/
export function KeyPosture({ tokens, capDays }: { tokens: ApiToken[]; capDays: number }) {
const now = Date.now();
const lifetimes = tokens.map((t) => keyLifetime(t, capDays, now));
const cells: { n: number; label: string; tone: string }[] = [
{ n: tokens.length, label: `key${tokens.length === 1 ? "" : "s"} listed`, tone: "text-text-primary" },
{ n: lifetimes.filter((l) => l.state === "soon").length, label: "expire within 7 days", tone: "text-warning" },
{ n: lifetimes.filter((l) => l.state === "eternal").length, label: "never expire", tone: "text-danger" },
// Muted, not coloured: an unused key is a cleanup candidate, not an
// incident.
{ n: tokens.filter((t) => !t.last_used_at).length, label: "unused since issue", tone: "text-text-secondary" },
];
return (
<div className="mb-6 grid gap-px overflow-hidden rounded-lg border border-border bg-border-soft sm:grid-cols-2 lg:grid-cols-4">
{cells.map((c) => (
<div key={c.label} className="flex items-baseline gap-2.5 bg-surface px-3.5 py-3 lg:flex-col lg:gap-0.5">
<span className={`font-mono text-xl font-semibold tabular-nums tracking-tight ${c.tone}`}>{c.n}</span>
<span className="text-xs text-text-tertiary">{c.label}</span>
</div>
))}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { ApiToken } from "@/lib/api";
import { keyLifetime, type LifetimeState } from "@/lib/keyLifetime";
/*
* A key's expiry drawn as the share of its issued life still to run.
*
* A column of dates answers "when" but not "which of these needs me first",
* which is the only question the list is scanned for. The bar answers it at a
* glance and the label underneath still says the date, because state never
* reads by colour alone here.
*/
const FILL: Record<LifetimeState, string> = {
healthy: "bg-success",
soon: "bg-warning",
expired: "bg-danger",
eternal: "bg-text-tertiary",
};
const TEXT: Record<LifetimeState, string> = {
healthy: "text-text-secondary",
soon: "text-warning",
expired: "text-danger",
eternal: "text-text-tertiary",
};
export function LifetimeBar({ token, capDays }: { token: ApiToken; capDays: number }) {
const { state, remainingPct, label, outsidePolicy } = keyLifetime(token, capDays);
return (
<div className="flex flex-col gap-1.5">
{/* The bar is the primary signal in the row, so it carries the same
text as the label rather than reading as decoration. */}
<div className="h-1 overflow-hidden rounded-full bg-border-soft" role="img" aria-label={label}>
<div className={`h-full ${FILL[state]}`} style={{ width: `${remainingPct}%` }} />
</div>
<span className={`font-mono text-xs tabular-nums ${TEXT[state]}`}>{label}</span>
{outsidePolicy && <p className="text-xs text-warning">Outside the current policy rotate when convenient.</p>}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* 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.
*/
export 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" : "",
}));
}
/**
* The chip splits in two resource, then a tinted access half so the read
* and write halves of a grant are told apart without reading either word.
*
* `wrap` is false in the ledger, where the list scrolls in its own track on a
* narrow screen rather than growing the record to four lines, and true in the
* dialog, where there is room and nothing below to push away.
*/
export function ScopeChips({ scopes, wrap = true }: { scopes: string[]; wrap?: boolean }) {
if (scopes.length === 0) {
// Not an em dash: "unknown" and "this key can call nothing" are
// different facts, and only one of them is true here.
return (
<span className="inline-flex rounded border border-dashed border-border px-1.5 py-0.5 font-mono text-xs text-text-tertiary">
no scopes granted
</span>
);
}
return (
<div className={`flex gap-1.5 ${wrap ? "flex-wrap" : "flex-wrap lg:flex-nowrap lg:overflow-x-auto"}`}>
{summariseScopes(scopes).map(({ resource, access }) => (
<span
key={resource}
className="inline-flex shrink-0 items-stretch overflow-hidden rounded border border-border font-mono text-xs"
>
<span className="px-1.5 py-0.5 text-text-secondary">{resource}</span>
<span
className={`border-l border-border px-1.5 py-0.5 ${
access === "rw" ? "bg-accent/20 text-accent" : "bg-text-secondary/10 text-text-tertiary"
}`}
>
{access}
</span>
</span>
))}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
/*
* One grid: a resource per row, read and write per column.
*
* Nine bordered cards each holding two checkboxes made the grant look like nine
* decisions. It is one decision with a shape, and a matrix is the shape.
*
* Resources come from GET /api/tokens/scopes and are never hardcoded here
* the endpoint is the source of truth and the vocabulary grows.
*/
/** UI copy with no server counterpart: what a resource covers, in the words a
* person granting it would use. An unknown resource simply gets no line. */
const DESCRIPTIONS: Record<string, string> = {
servers: "fleet list, inventory, agent updates",
keys: "SSH keys and their assignments",
secrets: "vault groups and values",
workflows: "steps, runs and logs",
monitors: "checks, incidents, uptime",
vulns: "findings, rescans, acceptances",
workloads: "containers and services",
status: "status pages and incidents",
settings: "instance settings and API keys",
mcp: "agent access over MCP",
};
export function ScopeMatrix({
resources,
scopes,
onToggle,
onSet,
}: {
resources: string[];
scopes: string[];
/** Toggles one scope string, e.g. "servers:write". */
onToggle: (scope: string) => void;
/** Replaces the whole selection, for the bulk actions. */
onSet: (scopes: string[]) => void;
}) {
const granted = new Set(scopes);
const resourceCount = resources.filter((r) => granted.has(`${r}:read`) || granted.has(`${r}:write`)).length;
function toggleWrite(resource: string) {
const read = `${resource}:read`;
const write = `${resource}:write`;
if (granted.has(write)) {
onToggle(write);
return;
}
// Write satisfies read on the server, so a :write-only token works. A
// matrix that let write sit ticked above an empty read box would still
// read as "this key cannot read", which is the wrong conclusion.
onSet(Array.from(new Set([...scopes, write, read])));
}
return (
<div className="overflow-hidden rounded border border-border">
<div className="grid grid-cols-[1fr_56px_56px] items-center border-b border-border bg-surface-2 px-3 py-2 font-mono text-[0.65rem] uppercase tracking-[0.08em] text-text-tertiary">
<span>Resource</span>
<span className="text-center">Read</span>
<span className="text-center">Write</span>
</div>
{resources.map((r) => {
const read = `${r}:read`;
const write = `${r}:write`;
return (
<div
key={r}
className="grid grid-cols-[1fr_56px_56px] items-center border-b border-border-soft px-3 py-2 last:border-b-0"
>
<span className="text-sm text-text-primary">
{r}
{DESCRIPTIONS[r] && <span className="block text-xs text-text-tertiary">{DESCRIPTIONS[r]}</span>}
</span>
<label className="flex justify-center">
<span className="sr-only">read {r}</span>
<input
type="checkbox"
checked={granted.has(read)}
onChange={() => onToggle(read)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
</label>
<label className="flex justify-center">
<span className="sr-only">write {r}</span>
<input
type="checkbox"
checked={granted.has(write)}
onChange={() => toggleWrite(r)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
</label>
</div>
);
})}
<div className="flex flex-col gap-1.5 border-t border-border bg-surface-2 px-3 py-2 text-xs text-text-tertiary sm:flex-row sm:items-center sm:justify-between">
<span>
{resourceCount} of {resources.length} resources · {scopes.length} scope{scopes.length === 1 ? "" : "s"}
</span>
<span className="flex gap-3">
<button type="button" className="text-accent hover:underline" onClick={() => onSet(resources.map((r) => `${r}:read`))}>
Read-only everywhere
</button>
<button type="button" className="text-accent hover:underline" onClick={() => onSet([])}>
Clear all
</button>
</span>
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { Button } from "@/components/ui";
import { inputClass } from "@/components/settings/Field";
/*
* Restricts a key to servers carrying every pair listed.
*
* Not licence-gated: tag scoping narrows what any credential can reach and is
* useful on its own, whatever else the instance is licensed for.
*
* The vocabulary comes from the fleet itself (GET /api/servers/tags), the same
* endpoint the workflow target selector reads, so a key can only be restricted
* to tags that exist.
*/
export function TagRestriction({
selector,
onChange,
}: {
selector: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}) {
const { data: known } = useQuery({ queryKey: ["known-tags"], queryFn: api.listKnownTags });
const vocabulary = known ?? {};
const keys = Object.keys(vocabulary);
const rows = Object.entries(selector);
function setPair(oldKey: string, key: string, value: string) {
const next = { ...selector };
delete next[oldKey];
if (key) next[key] = value;
onChange(next);
}
function addRow() {
const free = keys.find((k) => !(k in selector));
if (!free) return;
onChange({ ...selector, [free]: vocabulary[free]?.[0] ?? "" });
}
if (keys.length === 0) {
return <p className="text-xs text-text-tertiary">No server tags exist yet, so there is nothing to restrict this key to.</p>;
}
return (
<div className="flex flex-col gap-2">
{rows.map(([k, v]) => (
<div key={k} className="flex flex-wrap items-center gap-2">
<select value={k} onChange={(e) => setPair(k, e.target.value, vocabulary[e.target.value]?.[0] ?? "")} className={`${inputClass} w-auto flex-1`}>
{keys.map((option) => (
<option key={option} value={option} disabled={option !== k && option in selector}>
{option}
</option>
))}
</select>
<select value={v} onChange={(e) => setPair(k, k, e.target.value)} className={`${inputClass} w-auto flex-1`}>
{(vocabulary[k] ?? [v]).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setPair(k, "", "")}
>
Remove<span className="sr-only"> the {k} restriction</span>
</Button>
</div>
))}
<div>
<Button type="button" variant="ghost" size="sm" onClick={addRow} disabled={rows.length >= keys.length}>
Add a tag
</Button>
</div>
{/* Both halves of the asymmetry, because both are surprising: no
rows is the whole fleet, and two rows is an AND rather than an
OR. Getting either backwards mints a key with the wrong reach. */}
<p className="text-xs text-text-tertiary">
{rows.length === 0
? "No restriction: this key reaches every server in the fleet."
: "A server must carry every tag listed here for this key to reach it."}
</p>
</div>
);
}
/** The same restriction rendered for a key that already exists. Unrestricted
* renders nothing at all most keys are, and a chip on every row for the
* common case is noise rather than information. */
export function TagChips({ selector }: { selector?: Record<string, string> | null }) {
const pairs = Object.entries(selector ?? {});
if (pairs.length === 0) return null;
return (
<>
{pairs.map(([k, v]) => (
<span
key={k}
className="inline-flex shrink-0 rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 font-mono text-xs text-accent"
>
{k}={v}
</span>
))}
</>
);
}
+10 -1
View File
@@ -303,6 +303,9 @@ export type ApiToken = {
last_used_at?: string | null;
user_id: string;
user_email?: string;
/** Restricts the token to servers carrying every pair. Absent or empty is
* the whole fleet the asymmetry is deliberate, see services.MatchesSelector. */
tag_selector?: Record<string, string> | null;
};
export interface SecretGroupSummary {
@@ -880,7 +883,13 @@ export const api = {
return request<{ scopes: string[] }>("/tokens/scopes");
},
createApiToken(body: { name: string; role: Role; scopes: string[]; expires_in_days?: number | null }): Promise<{ token: string; record: ApiToken }> {
createApiToken(body: {
name: string;
role: Role;
scopes: string[];
tag_selector?: Record<string, string>;
expires_in_days?: number | null;
}): Promise<{ token: string; record: ApiToken }> {
return request<{ token: string; record: ApiToken }>("/tokens", {
method: "POST",
body: JSON.stringify(body),
+72
View File
@@ -0,0 +1,72 @@
import type { ApiToken } from "@/lib/api";
/**
* How much of an API key's issued life is left, as one value.
*
* The list draws expiry as a bar rather than a date, so the calculation behind
* it stopped being a formatting detail of one cell and became the only real
* logic on the page. It lives here so it can be read in one sitting, and so the
* ledger and the posture strip cannot disagree about what "expiring soon" means.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * DAY_MS;
export type LifetimeState = "healthy" | "soon" | "expired" | "eternal";
export type Lifetime = {
state: LifetimeState;
/** 0100, the share of the token's issued life still to run. `eternal` is 100. */
remainingPct: number;
/** e.g. "64 days left · 12 Nov", "Expired 2 Sep", "No expiry". */
label: string;
/** True when the instance cap has tightened since this token was issued. */
outsidePolicy: boolean;
};
function shortDate(d: Date) {
return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: d.getFullYear() === new Date().getFullYear() ? undefined : "numeric" });
}
/**
* `capDays` is the instance's current `api_token_max_days`, 0 when unset. It is
* never applied retroactively a token issued before the cap tightened keeps
* working, and `outsidePolicy` is a prompt to rotate rather than a failure of
* any kind. Copy built on this flag must not imply the key has stopped working.
*/
export function keyLifetime(token: ApiToken, capDays = 0, now = Date.now()): Lifetime {
const outsidePolicy =
capDays > 0 && (!token.expires_at || new Date(token.expires_at).getTime() > now + capDays * DAY_MS);
if (!token.expires_at) {
// Not "healthy": a key that runs forever is the state the posture strip
// counts as a risk, so it gets its own name rather than the good one.
return { state: "eternal", remainingPct: 100, label: "No expiry", outsidePolicy };
}
const expiresAt = new Date(token.expires_at).getTime();
const issuedAt = new Date(token.created_at).getTime();
const remainingMs = expiresAt - now;
if (remainingMs <= 0) {
return { state: "expired", remainingPct: 0, label: `Expired ${shortDate(new Date(expiresAt))}`, outsidePolicy };
}
// Measured against the token's own issued span, not against the instance
// cap: a 30-day key at day 15 is half gone, a 365-day key at day 15 is
// barely started, and one bar has to say which. A zero-length span is not
// reachable through the UI but is cheap to survive.
const span = expiresAt - issuedAt;
const rawPct = span > 0 ? (remainingMs / span) * 100 : 100;
const remainingPct = Math.min(100, Math.max(0, rawPct));
const days = Math.ceil(remainingMs / DAY_MS);
const label = `${days} day${days === 1 ? "" : "s"} left · ${shortDate(new Date(expiresAt))}`;
return {
state: remainingMs <= SEVEN_DAYS_MS ? "soon" : "healthy",
remainingPct,
label,
outsidePolicy,
};
}
File diff suppressed because one or more lines are too long