Compare commits

..
24 Commits
Author SHA1 Message Date
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
45 changed files with 6757 additions and 304 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.
+34
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(serverTouchingRoutes(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,22 @@ func boolEnv(key string) bool {
}
return false
}
// serverTouchingRoutes restricts AssertServerScopeMapComplete to routes whose
// pattern names server-derived data — "server", "console" or
// "workflows/:id/run" — rather than every routeScopes entry, so unrelated
// routes are never swept in and boot never fails for no reason.
func serverTouchingRoutes(r *gin.Engine) []string {
var out []string
for _, route := range r.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
continue
}
if strings.Contains(route.Path, "server") ||
strings.Contains(route.Path, "console") ||
route.Path == "/api/workflows/:id/run" {
out = append(out, route.Method+" "+route.Path)
}
}
return out
}
+11
View File
@@ -23,17 +23,23 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // 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/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // 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
golang.org/x/time v0.15.0 // indirect
)
require (
@@ -77,3 +83,8 @@ require (
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
// TEMPORARY: vantage-shared is not yet released with license.FeatureMCP.
// Points at the local sibling checkout so this branch can compile against it.
// Must be removed and replaced with a real version pin before this branch merges.
replace gitea.hostxtra.co.uk/vantage/vantage-shared => ../../vantage-shared
+13 -2
View File
@@ -1,5 +1,3 @@
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=
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=
@@ -45,7 +43,10 @@ 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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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=
@@ -75,6 +76,8 @@ 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/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=
@@ -101,6 +104,10 @@ 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/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
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 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -132,6 +139,8 @@ 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=
@@ -183,6 +192,8 @@ 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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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=
+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"})
+44 -8
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
@@ -626,8 +656,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 +706,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 +739,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
+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",
+93
View File
@@ -0,0 +1,93 @@
package api
import "fmt"
// serverScopedRoutes declares, for every route that returns or acts on
// server-derived data, whether it honours the acting token's tag restriction.
//
// It is a maintained map for the same reason routeScopes is: a route added
// tomorrow that reads server data without filtering would leak a restricted
// token's blind spot silently, and this turns that into a failure at boot.
//
// false means the route is deliberately fleet-wide and requires a comment
// explaining the deliberate reason. It must never mean "not scoped yet" —
// an unresolved gap belongs on the fix list, not in this map, because a false
// entry here is read as a considered decision, not a placeholder.
var serverScopedRoutes = map[string]bool{
"GET /api/servers": true,
"GET /api/servers/:id": true,
"DELETE /api/servers/:id": true,
"POST /api/servers/:id/apply-updates": true,
"POST /api/servers/:id/update-agent": true,
"PUT /api/servers/:id/tags": true,
"POST /api/servers/:id/generate-key": true,
"POST /api/console/connect": true,
"GET /api/console/tunnel": true,
"POST /api/workflows/:id/run": true,
// Workload routes all resolve the server through GetServerScoped before
// touching anything.
"GET /api/servers/:id/workloads": true,
"POST /api/servers/:id/workloads/refresh": true,
"POST /api/servers/:id/workloads/:wid/action": true,
"GET /api/servers/:id/workloads/:wid/logs": true,
// listServerVulnerabilities and getServerPackages now 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": true,
"GET /api/servers/:id/packages": true,
// 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": true,
"GET /api/runs/:runId/servers/:serverId/logs/stream": true,
// revokeAssignment resolves :serverId through GetServerScoped before
// calling RevokeAssignment, so a restricted token cannot mutate an
// assignment on a server outside its scope.
"DELETE /api/keys/:id/assign/:serverId": true,
// Creating a server has no server to filter yet.
"POST /api/servers": false,
// 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": false,
"POST /api/servers/new": false,
// 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": false,
}
// POST/GET /api/mcp is deliberately absent from this map. main.go's
// serverTouchingRoutes only feeds in routes whose path contains "server" or
// "console" (or the one named workflow-run exception), and /api/mcp matches
// none of those, so it is never presented to AssertServerScopeMapComplete —
// there is nothing to declare true or false here. That is the right outcome:
// the single MCP route fronts many tools of very different shapes, several of
// which touch no server data at all, so a route-level entry could not say
// anything meaningful about tag scoping. Each tool that does read or act on
// server data applies auth.ServerScope itself, the same selector the REST
// handlers for those resources already apply, which is where this kind of
// scoping decision belongs for a many-operations-per-route endpoint.
// AssertServerScopeMapComplete refuses to boot when a route touching server
// data is missing from serverScopedRoutes.
func AssertServerScopeMapComplete(routes []string) error {
for _, r := range routes {
if _, ok := serverScopedRoutes[r]; !ok {
if _, guarded := routeScopes[r]; !guarded {
continue
}
return fmt.Errorf("route %q is not declared in serverScopedRoutes", r)
}
}
return nil
}
+17 -5
View File
@@ -74,8 +74,9 @@ 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"`
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 +109,16 @@ func createToken(c *gin.Context) {
}
}
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 +145,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})
+16 -2
View File
@@ -290,7 +290,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 +321,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
+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")
+4 -4
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
+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
+95
View File
@@ -0,0 +1,95 @@
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
// 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")
// 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)
}
services.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) {
services.LogEvent(c.InstanceID, "mcp.tool_denied", c.TokenName, "", "",
fmt.Sprintf("tool %s refused by %s", toolName, gate))
}
+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)
}
}
+133
View File
@@ -0,0 +1,133 @@
// 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
}
// 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
Handler ToolFunc
}
// 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 }
+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)
}
}
}
+127
View File
@@ -0,0 +1,127 @@
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",
// "online" 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 == "online",
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",
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",
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
},
})
}
+240
View File
@@ -0,0 +1,240 @@
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.
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",
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",
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",
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",
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
},
})
}
+60
View File
@@ -0,0 +1,60 @@
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))
}
}
+523
View File
@@ -0,0 +1,523 @@
package mcp
import (
"context"
"fmt"
"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"`
}
// ---- 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 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",
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)
}
limit := pageLimit(args)
out := make([]workflowSummary, 0, limit)
for _, w := range workflows {
if len(out) == limit {
break
}
targets := len(w.TargetServerIDs)
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",
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
}
return workflowDetail{
ID: w.WorkflowID,
Name: w.Name,
Steps: steps,
Targets: w.TargetServerIDs,
Tags: w.TargetTags,
Schedule: schedule,
}, nil
},
})
All().Register(Tool{
Name: "get_run",
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",
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)
}
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",
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",
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"),
}
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}
}
}
limit := pageLimit(args)
out := make([]vulnSummary, 0, limit)
for k, v := range meta {
if len(out) == limit {
break
}
v.AffectedNum = counts[k]
out = append(out, v)
}
return listVulnsResult{Vulnerabilities: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_server_packages",
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",
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\". Pass version_below to " +
"further narrow to versions that sort earlier than the given string.",
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")
}
versionBelow := stringArg(args, "version_below")
hits, err := services.SearchPackages(c.InstanceID, name)
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
}
if versionBelow != "" && h.Version >= versionBelow {
continue
}
// A hit's server must be resolved through the token's own
// scope: SearchPackages runs unscoped across the instance,
// so a server outside the token's tag restriction is
// dropped here rather than named to the caller.
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",
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, _, 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: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_secret_names",
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
},
})
}
+111
View File
@@ -0,0 +1,111 @@
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) {
sdk.AddTool(srv, &sdk.Tool{
Name: tool.Name,
Description: tool.Description,
}, func(ctx context.Context, req *sdk.CallToolRequest, 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 {
return nil, nil, err
}
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 }
+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"`
+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,
+54
View File
@@ -0,0 +1,54 @@
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
}
// 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
}
+113
View File
@@ -0,0 +1,113 @@
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)
}
}
@@ -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,
};
}