feat: restrict an api key to tagged servers from the create dialog

This commit is contained in:
2026-09-08 14:12:10 +00:00
parent ac9cc57e7e
commit 2d6b5bd8a3
5 changed files with 179 additions and 5 deletions
+14 -1
View File
@@ -48,6 +48,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);
@@ -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}
+39 -2
View File
@@ -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<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)}`);
@@ -45,6 +60,12 @@ function PreviewLine({ name, role, scopes, expiryDays }: { name: string; role: R
) : (
<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>
@@ -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<string, string>;
setTagSelector: (t: Record<string, string>) => void;
expiryDays: number | null;
setExpiryDays: (v: number | null) => void;
capDays: number;
@@ -129,6 +154,14 @@ export function CreateKeyDialog({
<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"}
@@ -184,6 +217,10 @@ export function CreateKeyDialog({
<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={
@@ -208,7 +245,7 @@ export function CreateKeyDialog({
</select>
</Field>
<PreviewLine name={name} role={role} scopes={scopes} expiryDays={expiryDays} />
<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>
+5 -1
View File
@@ -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({
</div>
<div data-label="scopes" className={LABEL}>
<ScopeChips scopes={t.scopes} wrap={false} />
<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}>
+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),