From 2d6b5bd8a34f468e16d31089117cfa08fd81cfec Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 8 Sep 2026 14:12:10 +0000 Subject: [PATCH] feat: restrict an api key to tagged servers from the create dialog --- web/components/apikeys/ApiKeysPanel.tsx | 15 ++- web/components/apikeys/CreateKeyDialog.tsx | 41 +++++++- web/components/apikeys/KeyLedger.tsx | 6 +- web/components/apikeys/TagRestriction.tsx | 111 +++++++++++++++++++++ web/lib/api.ts | 11 +- 5 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 web/components/apikeys/TagRestriction.tsx diff --git a/web/components/apikeys/ApiKeysPanel.tsx b/web/components/apikeys/ApiKeysPanel.tsx index bfab782..6e64647 100644 --- a/web/components/apikeys/ApiKeysPanel.tsx +++ b/web/components/apikeys/ApiKeysPanel.tsx @@ -48,6 +48,7 @@ export function ApiKeysPanel() { const [name, setName] = useState(""); const [role, setRole] = useState("member"); const [scopes, setScopes] = useState([]); + const [tagSelector, setTagSelector] = useState>({}); const [expiryDays, setExpiryDays] = useState(30); const [result, setResult] = useState<{ token: string; record: ApiToken } | null>(null); const [copied, setCopied] = useState(false); @@ -72,6 +73,7 @@ export function ApiKeysPanel() { setName(""); setRole("member"); setScopes([]); + setTagSelector({}); setExpiryDays(30); setResult(null); setCopied(false); @@ -91,7 +93,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); }, @@ -239,6 +250,8 @@ export function ApiKeysPanel() { scopes={scopes} toggleScope={toggleScope} setScopes={setScopes} + tagSelector={tagSelector} + setTagSelector={setTagSelector} expiryDays={expiryDays} setExpiryDays={setExpiryDays} capDays={capDays} diff --git a/web/components/apikeys/CreateKeyDialog.tsx b/web/components/apikeys/CreateKeyDialog.tsx index ce706aa..cf82426 100644 --- a/web/components/apikeys/CreateKeyDialog.tsx +++ b/web/components/apikeys/CreateKeyDialog.tsx @@ -3,6 +3,7 @@ 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 }, @@ -24,12 +25,26 @@ function expiryDate(days: number, now = Date.now()) { * and write servers, workflows, secrets and keys" out loud is what sends * somebody back to untick two of them. */ -function PreviewLine({ name, role, scopes, expiryDays }: { name: string; role: Role; scopes: string[]; expiryDays: number | null }) { +function PreviewLine({ + name, + role, + scopes, + tagSelector, + expiryDays, +}: { + name: string; + role: Role; + scopes: string[]; + tagSelector: Record; + 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)}`); @@ -45,6 +60,12 @@ function PreviewLine({ name, role, scopes, expiryDays }: { name: string; role: R ) : ( can call nothing until a scope is granted )} + {tags.length > 0 && ( + <> + {" "} + on servers tagged {list(tags)} + + )} , and{" "} {expiryDays === null ? ( never expires @@ -73,6 +94,8 @@ export function CreateKeyDialog({ scopes, toggleScope, setScopes, + tagSelector, + setTagSelector, expiryDays, setExpiryDays, capDays, @@ -94,6 +117,8 @@ export function CreateKeyDialog({ scopes: string[]; toggleScope: (s: string) => void; setScopes: (s: string[]) => void; + tagSelector: Record; + setTagSelector: (t: Record) => void; expiryDays: number | null; setExpiryDays: (v: number | null) => void; capDays: number; @@ -129,6 +154,14 @@ export function CreateKeyDialog({
+ {result.record.tag_selector && Object.keys(result.record.tag_selector).length > 0 && ( + <> +
Servers
+
+ +
+ + )}
Expires
{result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"} @@ -184,6 +217,10 @@ export function CreateKeyDialog({ + + + + - + {createError ? (
{friendlyMessage(createError)}
diff --git a/web/components/apikeys/KeyLedger.tsx b/web/components/apikeys/KeyLedger.tsx index 902ea46..752a4b2 100644 --- a/web/components/apikeys/KeyLedger.tsx +++ b/web/components/apikeys/KeyLedger.tsx @@ -2,6 +2,7 @@ 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; @@ -64,7 +65,10 @@ export function KeyLedger({
- +
+ + +
diff --git a/web/components/apikeys/TagRestriction.tsx b/web/components/apikeys/TagRestriction.tsx new file mode 100644 index 0000000..753c956 --- /dev/null +++ b/web/components/apikeys/TagRestriction.tsx @@ -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; + onChange: (next: Record) => 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

No server tags exist yet, so there is nothing to restrict this key to.

; + } + + return ( +
+ {rows.map(([k, v]) => ( +
+ + + +
+ ))} + +
+ +
+ + {/* 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. */} +

+ {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."} +

+
+ ); +} + +/** 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 | null }) { + const pairs = Object.entries(selector ?? {}); + if (pairs.length === 0) return null; + return ( + <> + {pairs.map(([k, v]) => ( + + {k}={v} + + ))} + + ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 7fae72c..4017ba9 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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 | 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; + expires_in_days?: number | null; + }): Promise<{ token: string; record: ApiToken }> { return request<{ token: string; record: ApiToken }>("/tokens", { method: "POST", body: JSON.stringify(body),