feat: restrict an api key to tagged servers from the create dialog
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user