diff --git a/packages/gateway/public/admin.html b/packages/gateway/public/admin.html index a25bcb1..9dd47ba 100644 --- a/packages/gateway/public/admin.html +++ b/packages/gateway/public/admin.html @@ -89,6 +89,7 @@

Admin sign in

+ @@ -97,6 +98,7 @@

Admin sign in

+ @@ -626,6 +628,234 @@

Tool catalog ${enabledCount} of ${tools.length} enabled }; } +// ── Tool sets (#27) + self-service ceiling (#35) ── +/** Which set's rules are expanded. */ +const setsUi = { open: null }; + +const modePill = (mode) => mode === "granted" + ? 'granted' + : 'self-service'; + +/** A rule's selector in the words the admin used, not database columns. */ +function selectorText(rule) { + const parts = [`${esc(rule.upstreamId)}`]; + if (rule.toolName !== null) parts.push(`tool ${esc(rule.toolName)}`); + if (rule.groupLabel !== null) parts.push(rule.groupLabel === "" ? "(ungrouped)" : esc(rule.groupLabel)); + if (rule.tier !== null) parts.push(`${esc(rule.tier)} tier`); + if (parts.length === 1) parts.push("(whole server)"); + return parts.join(" · "); +} + +async function renderToolSets() { + const el = $("#tab-toolsets"); + const [sets, { roles }, upstreams, catalog] = await Promise.all([ + api("/tool-sets"), api("/roles"), api("/upstreams"), api("/catalog")]); + + // Categories actually present per upstream — a picker beats a free-text box + // that silently matches nothing. + const groupsOf = (upstreamId) => [...new Set(catalog + .filter(t => t.upstreamId === upstreamId) + .map(t => t.groupLabel ?? t.derivedGroup ?? ""))].sort(); + + const rulesFor = setsUi.open ? await api(`/tool-sets/${setsUi.open}/rules`) : []; + + el.innerHTML = ` +

Tool sets

+

A set is a reusable list of rules — server · category · tier → ceiling. Assign it to a role as + granted (live now) or self-service (the user may switch it on from their own page). + A role with no granted set keeps using the grants matrix on the Roles tab; the first granted set makes that role closed-world — + anything no rule covers is denied.

+ ${sets.length ? ` + + ${sets.map(s => ` + + + + + + ${setsUi.open === s.id ? `` : ""}`).join("")} +
SetRulesAssigned to
${esc(s.name)}${s.scope === "role" ? ' role-private' : ""} + ${s.description ? `
${esc(s.description)}
` : ""}
${s.ruleCount}${s.assignedTo.length + ? s.assignedTo.map(a => `${esc(a.roleName)} ${modePill(a.mode)}`).join("
") + : 'nobody'}
+ + +
+ ${rulesFor.length ? ` + + ${rulesFor.map(r => ` + + + + + `).join("")}
SelectorCeilingCovers now
${selectorText(r)}${r.maxTier === "none" ? 'excluded' : tierPill(r.maxTier)}${r.matchCount === 0 + ? '0 tools — check the category' + : `${r.matchCount} tool${r.matchCount === 1 ? "" : "s"} ${esc(r.sampleMatches.slice(0,3).join(", "))}${r.matchCount > 3 ? " …" : ""}`}
` : '

No rules yet — this set grants nothing.

'} +
+ + + + + +
+

Ceiling none excludes. Rules are inherited: a tool added to a covered category later needs no edit here. + The most specific rule wins — tool beats category beats tier beats whole-server.

+
` : '

No sets yet.

'} +
+ + + +
+
+ +

Assign sets to roles

+

Assigning previews itself first: you see how many tools the role gains and loses, with examples, before anything is written.

+ + + ${roles.map(r => { + const mine = sets.filter(s => s.assignedTo.some(a => a.roleId === r.id)); + const granted = mine.filter(s => s.assignedTo.find(a => a.roleId === r.id).mode === "granted"); + return ` + + + + `; + }).join("")} +
RoleWorldAssign
${esc(r.name)} ${r.isAdmin ? 'admin' : ""} +
${mine.length + ? mine.map(s => { + const a = s.assignedTo.find(x => x.roleId === r.id); + return `${esc(s.name)} ${modePill(a.mode)} remove`; + }).join("
") + : 'no sets — legacy grants apply'}
${granted.length ? "closed" : "open (grants matrix)"} + + + + +
+
`; + + // the category picker follows the chosen server + const syncGroups = () => { + const sel = $("#rule-group"); + if (!sel) return; + sel.innerHTML = `` + + groupsOf($("#rule-upstream").value) + .map(g => ``).join(""); + }; + if ($("#rule-upstream")) { $("#rule-upstream").onchange = syncGroups; syncGroups(); } + + el.onclick = async (ev) => { + const t = ev.target; + const open = t.closest("[data-open-set]"); + if (open) { + const id = Number(open.dataset.openSet); + setsUi.open = setsUi.open === id ? null : id; + return renderToolSets(); + } + + const delSet = t.closest("[data-del-set]"); + if (delSet) { + if (!confirm(`Delete set "${delSet.dataset.setName}"?\n\nIts rules and assignments go with it. Roles that relied on it fall back to the grants matrix.`)) return; + try { await api(`/tool-sets/${delSet.dataset.delSet}`, { method: "DELETE" }); toast("Deleted"); renderToolSets(); } + catch (e) { toast(e.message, true); } + return; + } + + const delRule = t.closest("[data-del-rule]"); + if (delRule) { + try { + await api(`/tool-sets/${setsUi.open}/rules/${delRule.dataset.delRule}`, { method: "DELETE" }); + toast("Rule deleted"); renderToolSets(); + } catch (e) { toast(e.message, true); } + return; + } + + if (t.id === "add-rule") { + const group = $("#rule-group").value; + const tier = $("#rule-tier").value; + const body = { + upstreamId: $("#rule-upstream").value, + maxTier: $("#rule-max").value, + ...(group === "__any" ? {} : { groupLabel: group }), + ...(tier ? { tier } : {}), + }; + try { + const r = await api(`/tool-sets/${setsUi.open}/rules`, { method: "PUT", body: JSON.stringify(body) }); + toast(r.matchCount === 0 + ? "Saved, but it covers 0 tools right now — check the category" + : `Saved — covers ${r.matchCount} tool(s)`); + renderToolSets(); + } catch (e) { toast(e.message, true); } + return; + } + + if (t.id === "add-set") { + const name = $("#set-name").value.trim(); + if (!name) { toast("Name is required", true); return; } + try { + const set = await api("/tool-sets", { method: "POST", + body: JSON.stringify({ name, description: $("#set-desc").value.trim() || undefined }) }); + setsUi.open = set.id; + toast(`Created "${name}" — add rules to it`); + renderToolSets(); + } catch (e) { toast(e.message, true); } + return; + } + + const unassign = t.closest("[data-unassign]"); + if (unassign) { + ev.preventDefault(); + try { + const r = await api(`/tool-sets/${unassign.dataset.unassign}/roles`, { method: "PUT", + body: JSON.stringify({ roleId: Number(unassign.dataset.role), assigned: false }) }); + toast(`Removed — role now sees ${r.after} tool(s)`); + renderToolSets(); + } catch (e) { toast(e.message, true); } + return; + } + + const assign = t.closest("[data-assign]"); + if (assign) { + const roleId = Number(assign.dataset.assign); + const setId = $(`[data-assign-set="${roleId}"]`)?.value; + const mode = $(`[data-assign-mode="${roleId}"]`).value; + if (!setId) { toast("Create a set first", true); return; } + try { + const dry = await api(`/tool-sets/${setId}/roles`, { method: "PUT", + body: JSON.stringify({ roleId, assigned: true, mode, dryRun: true }) }); + const lines = [ + `${dry.before} tool(s) now, ${dry.after} after.`, + dry.gained ? `Gains ${dry.gained}: ${dry.sampleGained.join(", ")}` : "Gains nothing.", + dry.lost ? `LOSES ${dry.lost}: ${dry.sampleLost.join(", ")}` : "Loses nothing.", + mode === "self-service" ? "\nSelf-service: nothing goes live until each user switches it on themselves." : "", + ]; + if (!confirm(lines.filter(Boolean).join("\n"))) return; + await api(`/tool-sets/${setId}/roles`, { method: "PUT", + body: JSON.stringify({ roleId, assigned: true, mode }) }); + toast("Assigned"); + renderToolSets(); + } catch (e) { toast(e.message, true); } + return; + } + + const convert = t.closest("[data-convert]"); + if (convert) { + const roleId = Number(convert.dataset.convert); + try { + const dry = await api(`/roles/${roleId}/convert-grants`, { method: "POST", body: JSON.stringify({ dryRun: true }) }); + const preview = dry.rules.map(r => ` ${r.upstreamId} → ${r.maxTier}`).join("\n"); + if (!confirm(`Write today's access for this role as set "${dry.setName}":\n\n${preview}\n\nThe role becomes closed-world: a NEW server added later is denied until you add a rule for it.`)) return; + await api(`/roles/${roleId}/convert-grants`, { method: "POST", body: JSON.stringify({}) }); + toast(`Converted — set "${dry.setName}" created and assigned`); + renderToolSets(); + } catch (e) { toast(e.message, true); } + } + }; +} + // ── Users ── async function renderUsers() { const el = $("#tab-users"); @@ -809,7 +1039,7 @@

Tool catalog ${enabledCount} of ${tools.length} enabled } const RENDER = { status: renderStatus, upstreams: renderUpstreams, tools: renderTools, - roles: renderRoles, users: renderUsers, secrets: renderSecrets }; + roles: renderRoles, toolsets: renderToolSets, users: renderUsers, secrets: renderSecrets }; async function boot() { // Show the Microsoft button only when interactive login actually exists — diff --git a/packages/gateway/src/db/index.ts b/packages/gateway/src/db/index.ts index 7de51de..96b77a7 100644 --- a/packages/gateway/src/db/index.ts +++ b/packages/gateway/src/db/index.ts @@ -207,4 +207,84 @@ export function migrate(db: DatabaseSync): void { db.exec("PRAGMA user_version = 5"); } + + if (version < 6) { + // Named tool sets (#27) + the self-service ceiling (#35). + // + // A set is a reusable list of rules; a role is assigned sets, each in one of + // two MODES: `granted` (live now) or `self-service` (the user may switch it + // on themselves). A role with no sets keeps today's behaviour exactly — + // legacy grant ?? role default — so this migration changes nothing until + // someone assigns the first set, which is what flips that role to a closed + // world. + // + // Selector semantics in tool_set_rules: NULL = "any", '' = the UNGROUPED + // bucket. They are different things, so the unique index COALESCEs to + // sentinels SQLite can actually compare (a plain UNIQUE treats NULLs as + // distinct and would allow duplicate selectors). + db.exec(` + CREATE TABLE IF NOT EXISTS tool_sets ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + scope TEXT NOT NULL DEFAULT 'shared' CHECK (scope IN ('shared','role')), + owner_role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + source TEXT NOT NULL DEFAULT 'api' CHECK (source IN ('api','preset')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK ((scope = 'role') = (owner_role_id IS NOT NULL)) + ); + + CREATE TABLE IF NOT EXISTS tool_set_rules ( + id INTEGER PRIMARY KEY, + set_id INTEGER NOT NULL REFERENCES tool_sets(id) ON DELETE CASCADE, + upstream_id TEXT NOT NULL, + group_label TEXT, + tier TEXT CHECK (tier IS NULL OR tier IN ('read','write','destructive')), + tool_name TEXT, + max_tier TEXT NOT NULL CHECK (max_tier IN ('none','read','write','destructive')) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_set_rules_selector ON tool_set_rules ( + set_id, upstream_id, + COALESCE(group_label, char(1)), + COALESCE(tier, char(1)), + COALESCE(tool_name, char(1)) + ); + + CREATE TABLE IF NOT EXISTS role_tool_sets ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + set_id INTEGER NOT NULL REFERENCES tool_sets(id) ON DELETE CASCADE, + mode TEXT NOT NULL DEFAULT 'granted' CHECK (mode IN ('granted','self-service')), + assigned_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (role_id, set_id) + ); + + -- Inert until access requests ship (#29); here so there is one migration. + CREATE TABLE IF NOT EXISTS access_requests ( + id INTEGER PRIMARY KEY, + principal TEXT NOT NULL, + requester_label TEXT, + role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL, + upstream_id TEXT NOT NULL, + group_label TEXT, + tool_name TEXT, + requested_tier TEXT NOT NULL CHECK (requested_tier IN ('read','write','destructive')), + reason TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','approved','denied','withdrawn','stale')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT, + decided_by TEXT, + decision_note TEXT, + applied_json TEXT + ); + -- A re-click merges into the open request instead of duplicating it. + CREATE UNIQUE INDEX IF NOT EXISTS idx_access_requests_open ON access_requests ( + principal, upstream_id, + COALESCE(group_label, char(1)), + COALESCE(tool_name, char(1)) + ) WHERE status = 'pending'; + `); + + db.exec("PRAGMA user_version = 6"); + } } diff --git a/packages/gateway/src/db/migrate-v5.test.ts b/packages/gateway/src/db/migrate-v5.test.ts index a7b49fd..d107cd4 100644 --- a/packages/gateway/src/db/migrate-v5.test.ts +++ b/packages/gateway/src/db/migrate-v5.test.ts @@ -91,9 +91,11 @@ describe("migration v4 → v5", () => { expect(roleSource(db, "no-role")).toBeNull(); }); - it("reaches v5 on a fresh database too", () => { + it("carries a fresh database to the current schema version", () => { const db = new DatabaseSync(":memory:"); migrate(db); - expect((db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(5); + // Bump with every new migration block — the assertion exists so adding one + // without thinking about the upgrade path fails here first. + expect((db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(6); }); }); diff --git a/packages/gateway/src/db/repo.ts b/packages/gateway/src/db/repo.ts index df987ae..77caf44 100644 --- a/packages/gateway/src/db/repo.ts +++ b/packages/gateway/src/db/repo.ts @@ -7,6 +7,7 @@ import type { DatabaseSync } from "node:sqlite"; import { parseUpstreamSpec, type UpstreamSpec } from "../config.js"; import type { MaxTier, Tier } from "../domain/policy.js"; +import type { SetMode, SetScope, ToolSetRule } from "../domain/toolsets.js"; export interface RoleRow { id: number; @@ -106,6 +107,15 @@ export interface UserCredentialRow { const TIER_RANK_SQL = `CASE r.default_max_tier WHEN 'destructive' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END`; +export interface ToolSetRow { + id: number; + name: string; + description: string | null; + scope: SetScope; + ownerRoleId: number | null; + source: "api" | "preset"; +} + export class Repo { constructor(private readonly db: DatabaseSync) {} @@ -508,6 +518,160 @@ export class Repo { ).map(mapRole); } + /** + * Run `fn` and undo every write it made. Used for assignment previews: the + * honest way to answer "what would this change?" is to make the change, read + * the answer through the normal code path, and roll back — no second + * implementation of the resolver to drift from the real one. + */ + dryRun(fn: () => T): T { + this.db.exec("BEGIN"); + try { + return fn(); + } finally { + this.db.exec("ROLLBACK"); + } + } + + // ── tool sets (#27) + self-service ceiling (#35) ── + + listToolSets(): ToolSetRow[] { + return ( + this.db + .prepare("SELECT id, name, description, scope, owner_role_id, source FROM tool_sets ORDER BY name") + .all() as Array> + ).map(mapToolSet); + } + + toolSetByName(name: string): ToolSetRow | null { + const row = this.db + .prepare("SELECT id, name, description, scope, owner_role_id, source FROM tool_sets WHERE name = ?") + .get(name) as Record | undefined; + return row ? mapToolSet(row) : null; + } + + createToolSet(input: { + name: string; + description?: string; + scope?: SetScope; + ownerRoleId?: number | null; + source?: "api" | "preset"; + }): ToolSetRow { + const scope = input.scope ?? "shared"; + this.db + .prepare( + "INSERT INTO tool_sets (name, description, scope, owner_role_id, source) VALUES (?, ?, ?, ?, ?)" + ) + .run( + input.name, + input.description ?? null, + scope, + scope === "role" ? (input.ownerRoleId ?? null) : null, + input.source ?? "api" + ); + return this.toolSetByName(input.name)!; + } + + deleteToolSet(setId: number): boolean { + return this.db.prepare("DELETE FROM tool_sets WHERE id = ?").run(setId).changes > 0; + } + + /** Upsert BY SELECTOR: re-saving the same selector changes its ceiling. */ + setToolSetRule(rule: { + setId: number; + upstreamId: string; + groupLabel?: string | null; + tier?: Tier | null; + toolName?: string | null; + maxTier: MaxTier; + }): void { + this.db + .prepare( + `INSERT INTO tool_set_rules (set_id, upstream_id, group_label, tier, tool_name, max_tier) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (set_id, upstream_id, COALESCE(group_label, char(1)), COALESCE(tier, char(1)), COALESCE(tool_name, char(1))) + DO UPDATE SET max_tier = excluded.max_tier` + ) + .run( + rule.setId, + rule.upstreamId, + rule.groupLabel ?? null, + rule.tier ?? null, + rule.toolName ?? null, + rule.maxTier + ); + } + + deleteToolSetRule(ruleId: number): boolean { + return this.db.prepare("DELETE FROM tool_set_rules WHERE id = ?").run(ruleId).changes > 0; + } + + rulesOfSet(setId: number): ToolSetRule[] { + return ( + this.db + .prepare( + `SELECT r.id, r.set_id, s.scope, r.upstream_id, r.group_label, r.tier, r.tool_name, r.max_tier + FROM tool_set_rules r JOIN tool_sets s ON s.id = r.set_id + WHERE r.set_id = ? ORDER BY r.id` + ) + .all(setId) as Array> + ).map(mapToolSetRule); + } + + assignToolSet(roleId: number, setId: number, mode: SetMode): void { + this.db + .prepare( + `INSERT INTO role_tool_sets (role_id, set_id, mode) VALUES (?, ?, ?) + ON CONFLICT (role_id, set_id) DO UPDATE SET mode = excluded.mode` + ) + .run(roleId, setId, mode); + } + + unassignToolSet(roleId: number, setId: number): boolean { + return ( + this.db.prepare("DELETE FROM role_tool_sets WHERE role_id = ? AND set_id = ?").run(roleId, setId) + .changes > 0 + ); + } + + setsOfRole(roleId: number): Array { + return ( + this.db + .prepare( + `SELECT s.id, s.name, s.description, s.scope, s.owner_role_id, s.source, rts.mode + FROM role_tool_sets rts JOIN tool_sets s ON s.id = rts.set_id + WHERE rts.role_id = ? ORDER BY s.name` + ) + .all(roleId) as Array> + ).map((row) => ({ ...mapToolSet(row), mode: row.mode as SetMode })); + } + + /** + * Every rule reaching a role in one mode. Empty for a role with no sets — + * which is what keeps the legacy path alive (see `roleHasSets`). + */ + rulesForRole(roleId: number, mode: SetMode): ToolSetRule[] { + return ( + this.db + .prepare( + `SELECT r.id, r.set_id, s.scope, r.upstream_id, r.group_label, r.tier, r.tool_name, r.max_tier + FROM role_tool_sets rts + JOIN tool_sets s ON s.id = rts.set_id + JOIN tool_set_rules r ON r.set_id = s.id + WHERE rts.role_id = ? AND rts.mode = ?` + ) + .all(roleId, mode) as Array> + ).map(mapToolSetRule); + } + + /** Any assigned set at all flips the role to a closed world. */ + roleHasSets(roleId: number): boolean { + const row = this.db + .prepare("SELECT 1 AS present FROM role_tool_sets WHERE role_id = ? AND mode = 'granted' LIMIT 1") + .get(roleId) as { present: number } | undefined; + return row !== undefined; + } + // ── group mappings ── listGroupMappings(): GroupMappingRow[] { @@ -798,6 +962,26 @@ const mapRole = (row: Record): RoleRow => ({ protected: row.protected === 1, }); +const mapToolSet = (row: Record): ToolSetRow => ({ + id: row.id as number, + name: row.name as string, + description: (row.description as string | null) ?? null, + scope: row.scope as SetScope, + ownerRoleId: (row.owner_role_id as number | null) ?? null, + source: row.source as "api" | "preset", +}); + +const mapToolSetRule = (row: Record): ToolSetRule => ({ + id: row.id as number, + setId: row.set_id as number, + scope: row.scope as SetScope, + upstreamId: row.upstream_id as string, + groupLabel: (row.group_label as string | null) ?? null, + tier: (row.tier as Tier | null) ?? null, + toolName: (row.tool_name as string | null) ?? null, + maxTier: row.max_tier as MaxTier, +}); + const mapToolSetting = (row: Record): ToolSettingRow => ({ upstreamId: row.upstream_id as string, toolName: row.tool_name as string, diff --git a/packages/gateway/src/domain/policy.ts b/packages/gateway/src/domain/policy.ts index cde03ff..b32afc7 100644 --- a/packages/gateway/src/domain/policy.ts +++ b/packages/gateway/src/domain/policy.ts @@ -12,6 +12,8 @@ */ import type { CatalogEntry, Tier } from "./catalog.js"; +import { derivedGroupOf } from "./catalog.js"; +import { resolveCeiling, type CeilingReason, type SetMode } from "./toolsets.js"; import type { Repo, RoleRow } from "../db/repo.js"; import type { Principal } from "../auth/principal.js"; import { prefsIdentity } from "../auth/principal.js"; @@ -41,6 +43,16 @@ export function toolAllowed(input: { return tierAllowed(input.maxTier, input.effectiveTier); } +/** Everything behind one allow/deny — see PolicyService.explain. */ +export interface Decision { + allowed: boolean; + maxTier: MaxTier; + effectiveTier: Tier; + toolEnabled: boolean; + override: "allow" | "deny" | null; + reason: CeilingReason; +} + export class PolicyService { constructor(private readonly repo: Repo) {} @@ -50,15 +62,62 @@ export class PolicyService { /** The single authorization decision, used by list filtering AND call-time checks. */ allows(roleId: number, entry: CatalogEntry): boolean { + return this.explain(roleId, entry).allowed; + } + + /** + * The decision plus every input that produced it — the same code path + * `allows` uses, so an explanation can never describe a different outcome + * than the boundary enforces. Powers the admin "why?" view and the /me hints. + */ + explain(roleId: number, entry: CatalogEntry, mode: SetMode = "granted"): Decision { const role = this.repo.roleById(roleId); - if (!role) return false; + if (!role) { + return { allowed: false, maxTier: "none", effectiveTier: entry.tier, toolEnabled: true, override: null, reason: { kind: "closed-world" } }; + } const setting = this.repo.toolSetting(entry.upstreamId, entry.upstreamToolName); - return toolAllowed({ - toolEnabled: setting?.enabled ?? true, - effectiveTier: setting?.tierOverride ?? entry.tier, - maxTier: this.repo.grantFor(roleId, entry.upstreamId) ?? role.defaultMaxTier, - override: this.repo.overrideFor(roleId, entry.upstreamId, entry.upstreamToolName), + const effectiveTier = setting?.tierOverride ?? entry.tier; + const override = this.repo.overrideFor(roleId, entry.upstreamId, entry.upstreamToolName); + + // Sets decide the ceiling when the role has any; otherwise the legacy + // grant/default path runs untouched, which is what keeps a set-less + // deployment byte-for-byte identical (#27). + const { maxTier, reason } = resolveCeiling({ + facts: { + upstreamId: entry.upstreamId, + toolName: entry.upstreamToolName, + tier: effectiveTier, + group: setting?.groupLabel ?? derivedGroupOf(entry.tool) ?? "", + }, + rules: this.repo.rulesForRole(roleId, mode), + // Self-service is additive on top of the granted world, so it is never + // "closed" on its own: with no self-service rules it simply offers nothing. + hasAnySet: mode === "granted" ? this.repo.roleHasSets(roleId) : true, + legacyGrant: this.repo.grantFor(roleId, entry.upstreamId), + roleDefault: role.defaultMaxTier, }); + + return { + allowed: toolAllowed({ + toolEnabled: setting?.enabled ?? true, + effectiveTier, + maxTier, + override, + }), + maxTier, + effectiveTier, + toolEnabled: setting?.enabled ?? true, + override, + reason, + }; + } + + /** + * Would this tool be self-serviceable by the role — i.e. offered by a set + * assigned in `self-service` mode? Not live until the user opts in (#35). + */ + selfServiceable(roleId: number, entry: CatalogEntry): boolean { + return this.explain(roleId, entry, "self-service").allowed; } visibleEntries(roleId: number, entries: Iterable): CatalogEntry[] { @@ -88,20 +147,29 @@ export class PolicyService { * Same function gates tools/list and tools/call, like the envelope itself. */ allowsFor(principal: Principal, entry: CatalogEntry): boolean { - if (!this.allowsAny(principal.roles.map((r) => r.id), entry)) return false; + const roleIds = principal.roles.map((r) => r.id); const who = prefsIdentity(principal); const serverPref = this.repo.userPrefFor(who, entry.upstreamId, ""); const toolPref = this.repo.userPrefFor(who, entry.upstreamId, entry.upstreamToolName); - // Off-by-default upstreams invert the personal layer: nothing is live until - // the user opts in (server-wide or per tool). The envelope check above is - // still the ceiling, so an opt-in can never widen beyond the role. - if (this.repo.getUpstream(entry.upstreamId)?.spec.userDefault === "off") { - if (serverPref === false || toolPref === false) return false; - return serverPref === true || toolPref === true; + const optedIn = serverPref === true || toolPref === true; + const denied = serverPref === false || toolPref === false; + + if (this.allowsAny(roleIds, entry)) { + // Off-by-default upstreams invert the personal layer: nothing is live + // until the user opts in (server-wide or per tool). The envelope check + // above is still the ceiling, so an opt-in can never widen beyond it. + if (this.repo.getUpstream(entry.upstreamId)?.spec.userDefault === "off") { + return denied ? false : optedIn; + } + return !denied; } - if (serverPref === false) return false; - if (toolPref === false) return false; - return true; + + // Self-service zone (#35): outside the granted envelope but offered by a + // set assigned in self-service mode. Never live by itself — an explicit + // opt-in row is what turns it on, and it is still capped by that set's own + // ceiling, so this cannot widen past what an admin wrote. + if (!optedIn || denied) return false; + return roleIds.some((roleId) => this.selfServiceable(roleId, entry)); } visibleEntriesFor(principal: Principal, entries: Iterable): CatalogEntry[] { diff --git a/packages/gateway/src/domain/toolsets-policy.test.ts b/packages/gateway/src/domain/toolsets-policy.test.ts new file mode 100644 index 0000000..06f159e --- /dev/null +++ b/packages/gateway/src/domain/toolsets-policy.test.ts @@ -0,0 +1,160 @@ +/** + * Tool sets through the real Repo + PolicyService: the closed-world flip, the + * migration guarantee (a role with no sets is untouched), and the self-service + * ceiling (#35) — offered by an admin, live only once the user opts in. + */ + +import { describe, expect, it } from "vitest"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { openDatabase } from "../db/index.js"; +import { Repo } from "../db/repo.js"; +import type { Principal } from "../auth/principal.js"; +import { prefsIdentity, withRoles } from "../auth/principal.js"; +import type { CatalogEntry } from "./catalog.js"; +import { PolicyService } from "./policy.js"; + +const entry = ( + toolName: string, + tier: "read" | "write" | "destructive", + upstreamId: string, + description = "" +): CatalogEntry => ({ + upstreamId, + namespace: upstreamId, + upstreamToolName: toolName, + exposedName: `${upstreamId}_${toolName}`, + tier, + tool: { name: toolName, inputSchema: { type: "object" }, ...(description ? { description } : {}) } as Tool, +}); + +const tickets = entry("update_ticket", "write", "cwpsa", "[tickets] update it"); +const invoice = entry("get_invoice", "read", "cwpsa", "[finance] read it"); +const doc = entry("get_doc", "read", "itglue"); + +function setup() { + const repo = new Repo(openDatabase(":memory:")); + const policy = new PolicyService(repo); + const role = repo.createRole("techs", "write"); + const principal: Principal = withRoles({ kind: "oidc", subject: "https://idp|u1", label: "alice" }, [ + { id: role.id, name: "techs", isAdmin: false }, + ]); + return { repo, policy, role, principal, who: prefsIdentity(principal) }; +} + +describe("assigning the first set flips the role to a closed world", () => { + it("before: the role default applies everywhere (unchanged behaviour)", () => { + const { policy, role } = setup(); + expect(policy.allows(role.id, tickets)).toBe(true); + expect(policy.allows(role.id, invoice)).toBe(true); + expect(policy.allows(role.id, doc)).toBe(true); + }); + + it("after: only what the rules cover, and the legacy grant stops mattering", () => { + const { repo, policy, role } = setup(); + repo.setGrant(role.id, "itglue", "destructive"); // legacy grant, about to be ignored + const set = repo.createToolSet({ name: "helpdesk" }); + repo.setToolSetRule({ setId: set.id, upstreamId: "cwpsa", groupLabel: "tickets", maxTier: "write" }); + repo.assignToolSet(role.id, set.id, "granted"); + + expect(policy.allows(role.id, tickets)).toBe(true); + expect(policy.allows(role.id, invoice)).toBe(false); // different category + expect(policy.allows(role.id, doc)).toBe(false); // grant ignored under closed world + + const why = policy.explain(role.id, doc); + expect(why.reason.kind).toBe("closed-world"); + expect(why.maxTier).toBe("none"); + }); + + it("an exclusion rule is explicit, and a tool rule beats the category", () => { + const { repo, policy, role } = setup(); + const set = repo.createToolSet({ name: "helpdesk" }); + repo.setToolSetRule({ setId: set.id, upstreamId: "cwpsa", maxTier: "write" }); + repo.setToolSetRule({ setId: set.id, upstreamId: "cwpsa", groupLabel: "finance", maxTier: "none" }); + repo.assignToolSet(role.id, set.id, "granted"); + + expect(policy.allows(role.id, tickets)).toBe(true); + expect(policy.allows(role.id, invoice)).toBe(false); + + // …and a per-tool rule can carve one tool back out of the exclusion + repo.setToolSetRule({ setId: set.id, upstreamId: "cwpsa", toolName: "get_invoice", maxTier: "read" }); + expect(policy.allows(role.id, invoice)).toBe(true); + }); + + it("re-saving the same selector updates it instead of stacking rules", () => { + const { repo, role } = setup(); + const set = repo.createToolSet({ name: "helpdesk" }); + repo.setToolSetRule({ setId: set.id, upstreamId: "cwpsa", groupLabel: "finance", maxTier: "read" }); + repo.setToolSetRule({ setId: set.id, upstreamId: "cwpsa", groupLabel: "finance", maxTier: "none" }); + const rules = repo.rulesOfSet(set.id); + expect(rules).toHaveLength(1); + expect(rules[0]!.maxTier).toBe("none"); + }); +}); + +describe("self-service ceiling", () => { + function withSelfService() { + const ctx = setup(); + const granted = ctx.repo.createToolSet({ name: "helpdesk" }); + ctx.repo.setToolSetRule({ setId: granted.id, upstreamId: "cwpsa", groupLabel: "tickets", maxTier: "write" }); + ctx.repo.assignToolSet(ctx.role.id, granted.id, "granted"); + + const offered = ctx.repo.createToolSet({ name: "docs-and-plans" }); + ctx.repo.setToolSetRule({ setId: offered.id, upstreamId: "itglue", maxTier: "read" }); + ctx.repo.assignToolSet(ctx.role.id, offered.id, "self-service"); + return ctx; + } + + it("is offered but not live until the user switches it on", () => { + const { repo, policy, role, principal, who } = withSelfService(); + + // not in the granted envelope… + expect(policy.allows(role.id, doc)).toBe(false); + // …but offered, and inert until an opt-in row exists + expect(policy.selfServiceable(role.id, doc)).toBe(true); + expect(policy.allowsFor(principal, doc)).toBe(false); + + repo.bulkSetUserPrefs(who, "itglue", ["get_doc"], true, true); + expect(policy.allowsFor(principal, doc)).toBe(true); + + // the granted zone is unaffected by any of this + expect(policy.allowsFor(principal, tickets)).toBe(true); + }); + + it("cannot widen past the ceiling the admin wrote in that set", () => { + const { repo, policy, principal, who } = withSelfService(); + const write = entry("update_doc", "write", "itglue"); + repo.bulkSetUserPrefs(who, "itglue", ["update_doc", ""], true, true); + // the self-service rule caps itglue at read — opting in cannot exceed it + expect(policy.allowsFor(principal, write)).toBe(false); + expect(policy.allowsFor(principal, doc)).toBe(true); + }); + + it("a personal deny still wins over an opt-in", () => { + const { repo, policy, principal, who } = withSelfService(); + repo.bulkSetUserPrefs(who, "itglue", [""], true, true); + expect(policy.allowsFor(principal, doc)).toBe(true); + repo.setUserPref(who, "itglue", "get_doc", false); + expect(policy.allowsFor(principal, doc)).toBe(false); + }); + + it("un-assigning the set takes the tool away, opt-in row or not", () => { + const { repo, policy, role, principal, who } = withSelfService(); + repo.bulkSetUserPrefs(who, "itglue", ["get_doc"], true, true); + expect(policy.allowsFor(principal, doc)).toBe(true); + + const offered = repo.toolSetByName("docs-and-plans")!; + repo.unassignToolSet(role.id, offered.id); + expect(policy.allowsFor(principal, doc)).toBe(false); + }); + + it("self-service alone does not make a role closed-world", () => { + const { repo, policy, role } = setup(); + const offered = repo.createToolSet({ name: "extras" }); + repo.setToolSetRule({ setId: offered.id, upstreamId: "itglue", maxTier: "read" }); + repo.assignToolSet(role.id, offered.id, "self-service"); + + // no GRANTED set → the legacy path still applies to everything else + expect(policy.allows(role.id, tickets)).toBe(true); + expect(policy.explain(role.id, tickets).reason.kind).toBe("legacy"); + }); +}); diff --git a/packages/gateway/src/domain/toolsets.test.ts b/packages/gateway/src/domain/toolsets.test.ts new file mode 100644 index 0000000..2212722 --- /dev/null +++ b/packages/gateway/src/domain/toolsets.test.ts @@ -0,0 +1,172 @@ +/** + * The tool-set resolution truth table (#27). These cases are the contract: + * which rule wins, what a role with no sets does, and that "" is a category + * rather than a wildcard. + */ + +import { describe, expect, it } from "vitest"; +import { + describeReason, + resolveCeiling, + ruleMatches, + ruleSpecificity, + type ToolFacts, + type ToolSetRule, +} from "./toolsets.js"; + +const facts: ToolFacts = { + upstreamId: "cipp", + toolName: "delete_user", + tier: "destructive", + group: "Identity", +}; + +let nextId = 0; +const rule = (partial: Partial): ToolSetRule => ({ + id: ++nextId, + setId: 1, + scope: "shared", + upstreamId: "cipp", + groupLabel: null, + tier: null, + toolName: null, + maxTier: "read", + ...partial, +}); + +const ceiling = (rules: ToolSetRule[], opts: { hasAnySet?: boolean; legacyGrant?: "none" | "read" | "write" | "destructive" | null } = {}) => + resolveCeiling({ + facts, + rules, + hasAnySet: opts.hasAnySet ?? rules.length > 0, + legacyGrant: opts.legacyGrant ?? null, + roleDefault: "read", + }); + +describe("selector matching", () => { + it("treats an absent field as any and '' as the ungrouped bucket", () => { + expect(ruleMatches(rule({}), facts)).toBe(true); + expect(ruleMatches(rule({ groupLabel: "Identity" }), facts)).toBe(true); + // "" is a real category — the tool is in "Identity", so it must NOT match + expect(ruleMatches(rule({ groupLabel: "" }), facts)).toBe(false); + expect(ruleMatches(rule({ groupLabel: "" }), { ...facts, group: "" })).toBe(true); + expect(ruleMatches(rule({ tier: "read" }), facts)).toBe(false); + expect(ruleMatches(rule({ toolName: "other" }), facts)).toBe(false); + expect(ruleMatches(rule({ upstreamId: "cwpsa" }), facts)).toBe(false); + }); + + it("gives every selector shape a distinct weight, so shapes cannot tie", () => { + const shapes = [ + rule({}), + rule({ tier: "destructive" }), + rule({ groupLabel: "Identity" }), + rule({ groupLabel: "Identity", tier: "destructive" }), + rule({ toolName: "delete_user" }), + rule({ toolName: "delete_user", tier: "destructive" }), + rule({ toolName: "delete_user", groupLabel: "Identity" }), + rule({ toolName: "delete_user", groupLabel: "Identity", tier: "destructive" }), + ].map(ruleSpecificity); + expect(new Set(shapes).size).toBe(shapes.length); + }); +}); + +describe("which rule wins", () => { + it("a group rule beats a server-wide one", () => { + const c = ceiling([ + rule({ maxTier: "destructive" }), + rule({ groupLabel: "Identity", maxTier: "read" }), + ]); + expect(c.maxTier).toBe("read"); + }); + + it("a tier-scoped rule beats a plain group rule", () => { + const c = ceiling([ + rule({ groupLabel: "Identity", maxTier: "read" }), + rule({ groupLabel: "Identity", tier: "destructive", maxTier: "destructive" }), + ]); + expect(c.maxTier).toBe("destructive"); + }); + + it("a tool rule beats everything else, including an exclusion", () => { + const c = ceiling([ + rule({ groupLabel: "Identity", maxTier: "destructive" }), + rule({ toolName: "delete_user", maxTier: "none" }), + ]); + expect(c.maxTier).toBe("none"); + }); + + it("same selector in two shared sets: the more permissive wins (sets are additive)", () => { + const c = ceiling([ + rule({ setId: 1, groupLabel: "Identity", maxTier: "read" }), + rule({ setId: 2, groupLabel: "Identity", maxTier: "write" }), + ]); + expect(c.maxTier).toBe("write"); + }); + + it("a role-private set wins a tie against a shared one", () => { + const c = ceiling([ + rule({ setId: 1, scope: "shared", groupLabel: "Identity", maxTier: "destructive" }), + rule({ setId: 2, scope: "role", groupLabel: "Identity", maxTier: "read" }), + ]); + expect(c.maxTier).toBe("read"); + }); + + it("specificity dominates scope — a narrow shared rule beats a broad private one", () => { + const c = ceiling([ + rule({ setId: 2, scope: "role", maxTier: "destructive" }), + rule({ setId: 1, scope: "shared", groupLabel: "Identity", tier: "destructive", maxTier: "none" }), + ]); + expect(c.maxTier).toBe("none"); + }); + + it("is order-independent", () => { + const rules = [ + rule({ maxTier: "destructive" }), + rule({ groupLabel: "Identity", maxTier: "read" }), + rule({ toolName: "delete_user", maxTier: "write" }), + ]; + const forward = ceiling(rules).maxTier; + const backward = ceiling([...rules].reverse()).maxTier; + expect(forward).toBe(backward); + expect(forward).toBe("write"); + }); +}); + +describe("closed world vs legacy", () => { + it("a role with sets excludes what no rule mentions, ignoring the legacy grant", () => { + const c = ceiling([rule({ upstreamId: "cwpsa", maxTier: "write" })], { + hasAnySet: true, + legacyGrant: "destructive", + }); + expect(c.maxTier).toBe("none"); + expect(c.reason.kind).toBe("closed-world"); + }); + + it("a role with NO sets behaves exactly as before: grant, else role default", () => { + const granted = ceiling([], { hasAnySet: false, legacyGrant: "destructive" }); + expect(granted.maxTier).toBe("destructive"); + expect(granted.reason).toEqual({ kind: "legacy", source: "grant" }); + + const fallback = ceiling([], { hasAnySet: false, legacyGrant: null }); + expect(fallback.maxTier).toBe("read"); + expect(fallback.reason).toEqual({ kind: "legacy", source: "role-default" }); + }); + + it("rules inherit: a new tool in a covered category needs no re-save", () => { + const rules = [rule({ groupLabel: "Identity", maxTier: "write" })]; + const brandNew: ToolFacts = { ...facts, toolName: "invite_guest", tier: "write" }; + expect(resolveCeiling({ facts: brandNew, rules, hasAnySet: true, legacyGrant: null, roleDefault: "none" }).maxTier).toBe("write"); + }); +}); + +describe("explaining the decision", () => { + it("names the winning rule, the closed world, and the legacy path", () => { + const winner = rule({ groupLabel: "Identity", maxTier: "read", setId: 7 }); + expect(describeReason({ kind: "rule", rule: winner }, () => "helpdesk")).toBe( + 'rule cipp · Identity → read in set "helpdesk"' + ); + expect(describeReason({ kind: "rule", rule: rule({ groupLabel: "", maxTier: "none" }) })).toContain("(ungrouped)"); + expect(describeReason({ kind: "closed-world" })).toContain("no assigned set"); + expect(describeReason({ kind: "legacy", source: "grant" })).toContain("grant"); + }); +}); diff --git a/packages/gateway/src/domain/toolsets.ts b/packages/gateway/src/domain/toolsets.ts new file mode 100644 index 0000000..8927b56 --- /dev/null +++ b/packages/gateway/src/domain/toolsets.ts @@ -0,0 +1,141 @@ +/** + * Named tool sets (#27) and the self-service ceiling (#35) — the pure decision + * layer. No database, no HTTP: given a tool's facts and the rules that apply to + * a role, produce the ceiling and the reason for it. + * + * A rule is a SELECTOR plus a ceiling: + * + * { upstreamId, groupLabel?, tier?, toolName?, maxTier } + * + * where an absent selector field means "any", and `groupLabel: ""` means the + * ungrouped bucket — a real category, not a wildcard. `maxTier: "none"` is a + * first-class exclusion. + * + * The most SPECIFIC matching rule wins, so a broad "all of cwpsa is read" and a + * narrow "cwpsa/tickets is write" compose the way people expect. Ties are broken + * by scope (a role's private set beats a shared one), then by permissiveness, + * then by id — a total order, so the outcome never depends on row order. + */ + +import type { MaxTier, Tier } from "./policy.js"; + +export type SetScope = "shared" | "role"; +export type SetMode = "granted" | "self-service"; + +export interface ToolSetRule { + id: number; + setId: number; + /** Which set it came from — only its scope matters to resolution. */ + scope: SetScope; + upstreamId: string; + /** null = any category; "" = the ungrouped bucket. */ + groupLabel: string | null; + /** null = any tier; otherwise only tools whose EFFECTIVE tier matches. */ + tier: Tier | null; + /** null = any tool. */ + toolName: string | null; + maxTier: MaxTier; +} + +/** What we know about the tool being judged. */ +export interface ToolFacts { + upstreamId: string; + toolName: string; + /** Effective tier: admin override ?? annotation-derived. */ + tier: Tier; + /** Effective category: explicit label ?? derived ?? "" (ungrouped). */ + group: string; +} + +export function ruleMatches(rule: ToolSetRule, facts: ToolFacts): boolean { + if (rule.upstreamId !== facts.upstreamId) return false; + if (rule.toolName !== null && rule.toolName !== facts.toolName) return false; + if (rule.groupLabel !== null && rule.groupLabel !== facts.group) return false; + if (rule.tier !== null && rule.tier !== facts.tier) return false; + return true; +} + +/** + * Distinct per selector SHAPE, so no two shapes can tie: tool (8) beats group + * (4) beats tier (2) beats the bare upstream (1). The +1 base keeps an + * upstream-only rule above "no rule at all". + */ +export function ruleSpecificity(rule: ToolSetRule): number { + return 1 + (rule.toolName !== null ? 8 : 0) + (rule.groupLabel !== null ? 4 : 0) + (rule.tier !== null ? 2 : 0); +} + +const TIER_RANK: Record = { none: 0, read: 1, write: 2, destructive: 3 }; + +/** + * Total order over matching rules: specificity, then a role-private set over a + * shared one, then the more permissive ceiling, then ids. Deterministic on + * purpose — "which rule won" has to be explainable to an admin. + */ +function betterRule(a: ToolSetRule, b: ToolSetRule): ToolSetRule { + const bySpecificity = ruleSpecificity(a) - ruleSpecificity(b); + if (bySpecificity !== 0) return bySpecificity > 0 ? a : b; + const byScope = (a.scope === "role" ? 1 : 0) - (b.scope === "role" ? 1 : 0); + if (byScope !== 0) return byScope > 0 ? a : b; + const byTier = TIER_RANK[a.maxTier] - TIER_RANK[b.maxTier]; + if (byTier !== 0) return byTier > 0 ? a : b; + if (a.setId !== b.setId) return a.setId < b.setId ? a : b; + return a.id <= b.id ? a : b; +} + +export type CeilingReason = + /** A rule from an assigned set decided it. */ + | { kind: "rule"; rule: ToolSetRule } + /** The role has sets, and none of them mention this tool → closed world. */ + | { kind: "closed-world" } + /** The role has no sets at all → legacy grant/default, byte-for-byte as before. */ + | { kind: "legacy"; source: "grant" | "role-default" }; + +export interface Ceiling { + maxTier: MaxTier; + reason: CeilingReason; +} + +/** + * The ceiling for ONE mode's rules (granted or self-service, never mixed). + * + * `hasAnySet` is what makes a role closed-world: with sets assigned, a tool no + * rule mentions is excluded, and the legacy grant is ignored entirely. Without + * sets, the legacy path runs untouched — that is the migration guarantee. + */ +export function resolveCeiling(input: { + facts: ToolFacts; + rules: readonly ToolSetRule[]; + hasAnySet: boolean; + legacyGrant: MaxTier | null; + roleDefault: MaxTier; +}): Ceiling { + let winner: ToolSetRule | null = null; + for (const rule of input.rules) { + if (!ruleMatches(rule, input.facts)) continue; + winner = winner === null ? rule : betterRule(winner, rule); + } + if (winner) return { maxTier: winner.maxTier, reason: { kind: "rule", rule: winner } }; + if (input.hasAnySet) return { maxTier: "none", reason: { kind: "closed-world" } }; + return input.legacyGrant !== null + ? { maxTier: input.legacyGrant, reason: { kind: "legacy", source: "grant" } } + : { maxTier: input.roleDefault, reason: { kind: "legacy", source: "role-default" } }; +} + +/** Human-readable trace for /me, the admin "why?" popover and gw_explain_access. */ +export function describeReason(reason: CeilingReason, setName?: (setId: number) => string): string { + switch (reason.kind) { + case "rule": { + const { rule } = reason; + const parts = [rule.upstreamId]; + if (rule.toolName !== null) parts.push(`tool ${rule.toolName}`); + if (rule.groupLabel !== null) parts.push(rule.groupLabel === "" ? "(ungrouped)" : rule.groupLabel); + if (rule.tier !== null) parts.push(`${rule.tier} tier`); + const where = setName ? ` in set "${setName(rule.setId)}"` : ""; + return `rule ${parts.join(" · ")} → ${rule.maxTier}${where}`; + } + case "closed-world": + return "no assigned set covers this tool"; + case "legacy": + return reason.source === "grant" ? "per-upstream grant (no sets assigned)" : "role default (no sets assigned)"; + } +} diff --git a/packages/gateway/src/http/admin-api.ts b/packages/gateway/src/http/admin-api.ts index 16965b4..7e653a1 100644 --- a/packages/gateway/src/http/admin-api.ts +++ b/packages/gateway/src/http/admin-api.ts @@ -18,6 +18,7 @@ import { renderPreset, summarize } from "../domain/presets.js"; import { UpstreamConnection } from "../upstream/connection.js"; import { SERVER_VERSION } from "../mcp/gateway-server.js"; import { prefsIdentity, type Principal } from "../auth/principal.js"; +import { createToolSetsRouter } from "./admin-toolsets-api.js"; import type { AppDeps, AuthOutcome } from "./app.js"; const REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers"; @@ -680,5 +681,9 @@ export function createAdminRouter(deps: AppDeps, admin: AdminDeps): Router { }) ); + // Tool sets (#27) + the self-service ceiling (#35). Mounted here so it + // inherits the isAdmin gate above rather than re-implementing it. + router.use(createToolSetsRouter(deps, { onPolicyChanged: admin.onPolicyChanged })); + return router; } diff --git a/packages/gateway/src/http/admin-toolsets-api.test.ts b/packages/gateway/src/http/admin-toolsets-api.test.ts new file mode 100644 index 0000000..a02b275 --- /dev/null +++ b/packages/gateway/src/http/admin-toolsets-api.test.ts @@ -0,0 +1,259 @@ +/** + * The tool-sets admin API: CRUD, live match counts, the assignment preview + * (which must not persist anything), convert-grants, explain, and the admin + * gate. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { AddressInfo } from "node:net"; +import type { Server as HttpServer } from "node:http"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { openDatabase } from "../db/index.js"; +import { Repo } from "../db/repo.js"; +import { PolicyService } from "../domain/policy.js"; +import { UpstreamManager, type UpstreamLink } from "../upstream/manager.js"; +import type { GatewayConfig, UpstreamSpec } from "../config.js"; +import { createApp } from "./app.js"; + +const spec: UpstreamSpec = { + id: "cwpsa", + namespace: "cw", + transport: "http", + url: "http://unused/mcp", + headers: {}, + enabled: true, +}; + +const tools: Tool[] = [ + { name: "cw_get_ticket", description: "[tickets] read", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, + { name: "cw_update_ticket", description: "[tickets] write", inputSchema: { type: "object" } }, + { name: "cw_get_invoice", description: "[finance] read", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, + { name: "cw_delete_entry", description: "[finance] nuke", inputSchema: { type: "object" }, annotations: { destructiveHint: true } }, +]; + +const link: UpstreamLink = { + spec, + onToolListChanged: null, + onRecovered: null, + async connect() {}, + async listTools() { + return tools; + }, + async callTool(): Promise { + return { content: [{ type: "text", text: "ok" }] }; + }, + async close() {}, +}; + +const config: GatewayConfig = { + port: 0, + publicUrl: "http://localhost:0", + configPath: "unused", + dbPath: ":memory:", + selfTools: true, + backup: { dir: "unused", keep: 3, intervalHours: 0 }, + allowedOrigins: [], + upstreamsFromFile: [], + staticTokens: [ + { token: "tok-admin", roleName: "admin", label: "root" }, + { token: "tok-viewer", roleName: "viewer", label: "alice" }, + ], + oidc: null, + login: null, + gatewayJwtSecret: null, + adminBootstrapSubjects: [], + devAllowUnauthenticated: false, + bao: null, + keyVault: null, + mode: "standalone", +}; + +let server: HttpServer; +let base: string; +let repo: Repo; +let techsId: number; + +beforeAll(async () => { + repo = new Repo(openDatabase(":memory:")); + repo.upsertUpstream(spec, "api"); + techsId = repo.createRole("techs", "write").id; + const manager = new UpstreamManager([spec], () => link); + await manager.start(); + const app = createApp({ + config, + repo, + manager, + policy: new PolicyService(repo), + secretStore: null, + oidcVerifier: null, + adminUiDir: null, + }); + server = app.listen(0); + base = `http://localhost:${(server.address() as AddressInfo).port}`; +}); + +afterAll(() => { + server.close(); +}); + +const api = async ( + method: string, + path: string, + body?: unknown, + token = "tok-admin" +): Promise<{ status: number; json: any }> => { + const res = await fetch(`${base}/api${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return { status: res.status, json: await res.json().catch(() => ({})) }; +}; + +describe("tool set CRUD and rules", () => { + it("creates a set, reports what each rule covers, and rejects duplicates", async () => { + const created = await api("POST", "/tool-sets", { name: "helpdesk", description: "front line" }); + expect(created.status).toBe(200); + const setId = created.json.id as number; + + expect((await api("POST", "/tool-sets", { name: "helpdesk" })).status).toBe(409); + expect((await api("POST", "/tool-sets", { name: "Bad Name" })).status).toBe(400); + + // a category rule matches the two ticket tools + const rule = await api("PUT", `/tool-sets/${setId}/rules`, { + upstreamId: "cwpsa", + groupLabel: "tickets", + maxTier: "write", + }); + expect(rule.status).toBe(200); + expect(rule.json.matchCount).toBe(2); + expect(rule.json.sampleMatches).toContain("cw_get_ticket"); + + // a typo'd category matches nothing — the count is the warning + const typo = await api("PUT", `/tool-sets/${setId}/rules`, { + upstreamId: "cwpsa", + groupLabel: "tikcets", + maxTier: "write", + }); + expect(typo.json.matchCount).toBe(0); + + // re-saving the same selector updates rather than duplicating + const again = await api("PUT", `/tool-sets/${setId}/rules`, { + upstreamId: "cwpsa", + groupLabel: "tickets", + maxTier: "read", + }); + expect(again.json.rule.maxTier).toBe("read"); + const rules = await api("GET", `/tool-sets/${setId}/rules`); + expect(rules.json).toHaveLength(2); + + // unknown upstream and unknown set are 404s, not silent no-ops + expect((await api("PUT", `/tool-sets/${setId}/rules`, { upstreamId: "nope", maxTier: "read" })).status).toBe(404); + expect((await api("GET", "/tool-sets/9999/rules")).status).toBe(404); + + // clean up for the next test + const ruleId = (rules.json as Array<{ id: number }>)[0]!.id; + expect((await api("DELETE", `/tool-sets/${setId}/rules/${ruleId}`)).status).toBe(200); + expect((await api("DELETE", `/tool-sets/${setId}`)).status).toBe(200); + }); + + it("is admin-only", async () => { + expect((await api("GET", "/tool-sets", undefined, "tok-viewer")).status).toBe(403); + expect((await api("POST", "/tool-sets", { name: "sneaky" }, "tok-viewer")).status).toBe(403); + }); +}); + +describe("assignment preview", () => { + it("reports the blast radius without persisting anything", async () => { + const setId = (await api("POST", "/tool-sets", { name: "tickets-only" })).json.id as number; + await api("PUT", `/tool-sets/${setId}/rules`, { upstreamId: "cwpsa", groupLabel: "tickets", maxTier: "write" }); + + // techs (write) currently sees the three non-destructive tools + const before = repo.roleHasSets(techsId); + expect(before).toBe(false); + + const dry = await api("PUT", `/tool-sets/${setId}/roles`, { + roleId: techsId, + assigned: true, + mode: "granted", + dryRun: true, + }); + expect(dry.json.dryRun).toBe(true); + expect(dry.json.before).toBe(3); // two tickets + the read invoice + expect(dry.json.after).toBe(2); // closed world: tickets only + expect(dry.json.lost).toBe(1); + expect(dry.json.sampleLost).toContain("cw_get_invoice"); + // nothing was written + expect(repo.roleHasSets(techsId)).toBe(false); + + const real = await api("PUT", `/tool-sets/${setId}/roles`, { + roleId: techsId, + assigned: true, + mode: "granted", + }); + expect(real.json.lost).toBe(1); + expect(repo.roleHasSets(techsId)).toBe(true); + + // self-service assignment does NOT flip the closed world on its own + const offered = (await api("POST", "/tool-sets", { name: "finance-extras" })).json.id as number; + await api("PUT", `/tool-sets/${offered}/rules`, { upstreamId: "cwpsa", groupLabel: "finance", maxTier: "read" }); + const ss = await api("PUT", `/tool-sets/${offered}/roles`, { + roleId: techsId, + assigned: true, + mode: "self-service", + }); + // offered, not granted → the role's live surface is unchanged + expect(ss.json.gained).toBe(0); + expect(repo.setsOfRole(techsId).find((s) => s.id === offered)?.mode).toBe("self-service"); + + // unassigning restores the legacy world + await api("PUT", `/tool-sets/${setId}/roles`, { roleId: techsId, assigned: false }); + expect(repo.roleHasSets(techsId)).toBe(false); + }); + + it("404s an unknown role or set", async () => { + const setId = (await api("POST", "/tool-sets", { name: "orphan" })).json.id as number; + expect((await api("PUT", `/tool-sets/${setId}/roles`, { roleId: 9999, assigned: true })).status).toBe(404); + expect((await api("PUT", "/tool-sets/9999/roles", { roleId: techsId, assigned: true })).status).toBe(404); + }); +}); + +describe("convert-grants", () => { + it("previews one rule per live upstream, then writes and assigns them", async () => { + const editor = repo.roleByName("editor")!; + repo.setGrant(editor.id, "cwpsa", "read"); + + const dry = await api("POST", `/roles/${editor.id}/convert-grants`, { dryRun: true }); + expect(dry.json.rules).toEqual([{ upstreamId: "cwpsa", maxTier: "read" }]); + expect(repo.roleHasSets(editor.id)).toBe(false); + + const done = await api("POST", `/roles/${editor.id}/convert-grants`, {}); + expect(done.json.setName).toBe("editor-converted"); + expect(repo.roleHasSets(editor.id)).toBe(true); + // running twice would need a fresh name rather than silently merging + expect((await api("POST", `/roles/${editor.id}/convert-grants`, {})).status).toBe(409); + }); +}); + +describe("explain", () => { + it("names the winning rule, and the closed world when nothing covers the tool", async () => { + const viewer = repo.roleByName("viewer")!; + const setId = (await api("POST", "/tool-sets", { name: "viewer-reads" })).json.id as number; + await api("PUT", `/tool-sets/${setId}/rules`, { upstreamId: "cwpsa", groupLabel: "tickets", maxTier: "read" }); + await api("PUT", `/tool-sets/${setId}/roles`, { roleId: viewer.id, assigned: true, mode: "granted" }); + + const covered = await api("GET", `/roles/${viewer.id}/explain?upstreamId=cwpsa&toolName=cw_get_ticket`); + expect(covered.json.granted.allowed).toBe(true); + expect(covered.json.granted.why).toContain("viewer-reads"); + expect(covered.json.hasSets).toBe(true); + + const uncovered = await api("GET", `/roles/${viewer.id}/explain?upstreamId=cwpsa&toolName=cw_get_invoice`); + expect(uncovered.json.granted.allowed).toBe(false); + expect(uncovered.json.granted.why).toContain("no assigned set"); + + expect((await api("GET", `/roles/${viewer.id}/explain?upstreamId=cwpsa&toolName=ghost`)).status).toBe(404); + }); +}); diff --git a/packages/gateway/src/http/admin-toolsets-api.ts b/packages/gateway/src/http/admin-toolsets-api.ts new file mode 100644 index 0000000..e032500 --- /dev/null +++ b/packages/gateway/src/http/admin-toolsets-api.ts @@ -0,0 +1,342 @@ +/** + * Admin API for named tool sets (#27) and the self-service ceiling (#35). + * + * Mounted INSIDE the admin router, so it inherits the isAdmin gate. Every + * answer about "what does this rule cover" or "what would this change" is + * computed by running the real resolver over the live catalog — never by a + * second implementation that could drift from the boundary. + * + * GET/POST/DELETE /api/tool-sets[/:id] + * GET/PUT/DELETE /api/tool-sets/:id/rules[/:ruleId] rules carry live match counts + * PUT /api/tool-sets/:id/roles {roleId, assigned, mode, dryRun?} + * POST /api/roles/:id/convert-grants {setName?, dryRun?} + * GET /api/roles/:id/explain ?upstreamId=&toolName= + */ + +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import type { CatalogEntry } from "../domain/catalog.js"; +import { describeReason, ruleMatches, type SetMode, type ToolFacts } from "../domain/toolsets.js"; +import { effectiveGroupOf, effectiveTierOf } from "../domain/tool-targets.js"; +import type { AppDeps } from "./app.js"; + +const maxTierSchema = z.enum(["none", "read", "write", "destructive"]); +const tierSchema = z.enum(["read", "write", "destructive"]); +const modeSchema = z.enum(["granted", "self-service"]); + +/** Selector fields: absent = "any", "" = the ungrouped bucket (a real category). */ +const ruleBody = z.object({ + upstreamId: z.string().min(1), + groupLabel: z.string().nullable().optional(), + tier: tierSchema.nullable().optional(), + toolName: z.string().nullable().optional(), + maxTier: maxTierSchema, +}); + +const idOf = (req: Request, name: string): number => { + const raw = req.params[name]; + return Number(Array.isArray(raw) ? raw[0] : raw); +}; + +export function createToolSetsRouter( + deps: AppDeps, + hooks: { onPolicyChanged: () => void } +): Router { + const { repo, manager, policy } = deps; + const router = Router(); + + const h = + (fn: (req: Request, res: Response) => void) => + (req: Request, res: Response): void => { + try { + fn(req, res); + } catch (err) { + const status = err instanceof z.ZodError ? 400 : 500; + if (!res.headersSent) res.status(status).json({ error: String((err as Error)?.message ?? err) }); + } + }; + + const factsOf = (entry: CatalogEntry): ToolFacts => ({ + upstreamId: entry.upstreamId, + toolName: entry.upstreamToolName, + tier: effectiveTierOf(repo, entry), + group: effectiveGroupOf(repo, entry), + }); + + /** Tools a rule currently covers — a 0 here is the warning an admin needs. */ + const matchesOf = (rule: Parameters[0]): CatalogEntry[] => + [...manager.catalogEntries()].filter((entry) => ruleMatches(rule, factsOf(entry))); + + const visibleNames = (roleId: number): Set => + new Set(policy.visibleEntries(roleId, manager.catalogEntries()).map((e) => e.exposedName)); + + // ── sets ── + + router.get( + "/tool-sets", + h((_req, res) => { + const roles = repo.listRoles(); + res.json( + repo.listToolSets().map((set) => ({ + ...set, + ruleCount: repo.rulesOfSet(set.id).length, + assignedTo: roles + .flatMap((role) => + repo + .setsOfRole(role.id) + .filter((s) => s.id === set.id) + .map((s) => ({ roleId: role.id, roleName: role.name, mode: s.mode })) + ), + })) + ); + }) + ); + + router.post( + "/tool-sets", + h((req, res) => { + const body = z + .object({ + name: z.string().min(1).regex(/^[a-z0-9_-]+$/, "lowercase letters, digits, dash and underscore only"), + description: z.string().optional(), + scope: z.enum(["shared", "role"]).default("shared"), + ownerRoleId: z.number().int().nullable().optional(), + }) + .parse(req.body); + if (repo.toolSetByName(body.name)) { + res.status(409).json({ error: `Tool set "${body.name}" already exists` }); + return; + } + if (body.scope === "role" && (body.ownerRoleId == null || !repo.roleById(body.ownerRoleId))) { + res.status(400).json({ error: "A role-scoped set needs an existing ownerRoleId" }); + return; + } + const set = repo.createToolSet({ + name: body.name, + ...(body.description !== undefined ? { description: body.description } : {}), + scope: body.scope, + ...(body.ownerRoleId != null ? { ownerRoleId: body.ownerRoleId } : {}), + }); + // A role-private set is only ever meant for its owner, so assign it now + // rather than leaving a set nobody can see the effect of. + if (set.scope === "role" && set.ownerRoleId != null) { + repo.assignToolSet(set.ownerRoleId, set.id, "granted"); + hooks.onPolicyChanged(); + } + res.json(set); + }) + ); + + router.delete( + "/tool-sets/:id", + h((req, res) => { + if (!repo.deleteToolSet(idOf(req, "id"))) { + res.status(404).json({ error: "Unknown tool set" }); + return; + } + // Cascades to its rules and assignments — roles that relied on it may + // fall back to legacy or lose their closed world entirely. + hooks.onPolicyChanged(); + res.json({ ok: true }); + }) + ); + + // ── rules ── + + router.get( + "/tool-sets/:id/rules", + h((req, res) => { + const setId = idOf(req, "id"); + if (!repo.listToolSets().some((s) => s.id === setId)) { + res.status(404).json({ error: "Unknown tool set" }); + return; + } + res.json( + repo.rulesOfSet(setId).map((rule) => { + const matches = matchesOf(rule); + return { + ...rule, + matchCount: matches.length, + sampleMatches: matches.slice(0, 5).map((e) => e.exposedName), + }; + }) + ); + }) + ); + + router.put( + "/tool-sets/:id/rules", + h((req, res) => { + const setId = idOf(req, "id"); + if (!repo.listToolSets().some((s) => s.id === setId)) { + res.status(404).json({ error: "Unknown tool set" }); + return; + } + const body = ruleBody.parse(req.body); + if (!repo.getUpstream(body.upstreamId)) { + res.status(404).json({ error: `Unknown upstream "${body.upstreamId}"` }); + return; + } + repo.setToolSetRule({ setId, ...body }); + hooks.onPolicyChanged(); + // Report what it covers RIGHT NOW: a rule matching nothing is usually a + // typo in a category name, and silence is how that goes unnoticed. + const saved = repo.rulesOfSet(setId).find( + (r) => + r.upstreamId === body.upstreamId && + r.groupLabel === (body.groupLabel ?? null) && + r.tier === (body.tier ?? null) && + r.toolName === (body.toolName ?? null) + )!; + const matches = matchesOf(saved); + res.json({ + ok: true, + rule: saved, + matchCount: matches.length, + sampleMatches: matches.slice(0, 5).map((e) => e.exposedName), + }); + }) + ); + + router.delete( + "/tool-sets/:id/rules/:ruleId", + h((req, res) => { + if (!repo.deleteToolSetRule(idOf(req, "ruleId"))) { + res.status(404).json({ error: "Unknown rule" }); + return; + } + hooks.onPolicyChanged(); + res.json({ ok: true }); + }) + ); + + // ── assignment (the blast-radius preview lives here) ── + + router.put( + "/tool-sets/:id/roles", + h((req, res) => { + const setId = idOf(req, "id"); + const set = repo.listToolSets().find((s) => s.id === setId); + if (!set) { + res.status(404).json({ error: "Unknown tool set" }); + return; + } + const body = z + .object({ + roleId: z.number().int(), + assigned: z.boolean(), + mode: modeSchema.default("granted"), + dryRun: z.boolean().default(false), + }) + .parse(req.body); + const role = repo.roleById(body.roleId); + if (!role) { + res.status(404).json({ error: "Unknown role" }); + return; + } + + const before = visibleNames(role.id); + const apply = (): Set => { + if (body.assigned) repo.assignToolSet(role.id, setId, body.mode); + else repo.unassignToolSet(role.id, setId); + return visibleNames(role.id); + }; + // Preview by doing it for real and rolling back — same resolver, same + // catalog, so the number shown is the number that will happen. + const after = body.dryRun ? repo.dryRun(apply) : apply(); + + const gained = [...after].filter((n) => !before.has(n)); + const lost = [...before].filter((n) => !after.has(n)); + if (!body.dryRun) hooks.onPolicyChanged(); + res.json({ + ok: true, + dryRun: body.dryRun, + mode: body.mode, + before: before.size, + after: after.size, + gained: gained.length, + lost: lost.length, + sampleLost: lost.slice(0, 8), + sampleGained: gained.slice(0, 8), + }); + }) + ); + + // ── migration helper: today's grants → an explicit set ── + + router.post( + "/roles/:id/convert-grants", + h((req, res) => { + const role = repo.roleById(idOf(req, "id")); + if (!role) { + res.status(404).json({ error: "Unknown role" }); + return; + } + const body = z + .object({ setName: z.string().min(1).regex(/^[a-z0-9_-]+$/).optional(), dryRun: z.boolean().default(false) }) + .parse(req.body ?? {}); + const name = body.setName ?? `${role.name}-converted`; + + // One rule per upstream in the LIVE catalog: grant ?? role default. The + // single intentional change is that a NEWLY added upstream will be closed + // for this role afterwards instead of inheriting the default. + const upstreams = [...new Set([...manager.catalogEntries()].map((e) => e.upstreamId))].sort(); + const rules = upstreams.map((upstreamId) => ({ + upstreamId, + maxTier: repo.grantFor(role.id, upstreamId) ?? role.defaultMaxTier, + })); + if (body.dryRun) { + res.json({ ok: true, dryRun: true, setName: name, rules, wouldAssign: rules.length > 0 }); + return; + } + if (repo.toolSetByName(name)) { + res.status(409).json({ error: `Tool set "${name}" already exists — pass a different setName` }); + return; + } + const set = repo.createToolSet({ name, description: `Converted from ${role.name}'s grants` }); + for (const rule of rules) repo.setToolSetRule({ setId: set.id, ...rule }); + repo.assignToolSet(role.id, set.id, "granted"); + hooks.onPolicyChanged(); + res.json({ ok: true, setId: set.id, setName: name, rules }); + }) + ); + + // ── why? ── + + router.get( + "/roles/:id/explain", + h((req, res) => { + const role = repo.roleById(idOf(req, "id")); + if (!role) { + res.status(404).json({ error: "Unknown role" }); + return; + } + const upstreamId = String(req.query.upstreamId ?? ""); + const toolName = String(req.query.toolName ?? ""); + const entry = [...manager.catalogEntries()].find( + (e) => e.upstreamId === upstreamId && e.upstreamToolName === toolName + ); + if (!entry) { + res.status(404).json({ error: "That tool is not in the live catalog" }); + return; + } + const setNames = new Map(repo.listToolSets().map((s) => [s.id, s.name])); + const describe = (mode: SetMode) => { + const decision = policy.explain(role.id, entry, mode); + return { + ...decision, + why: describeReason(decision.reason, (setId) => setNames.get(setId) ?? `set ${setId}`), + }; + }; + res.json({ + role: role.name, + tool: entry.exposedName, + hasSets: repo.roleHasSets(role.id), + granted: describe("granted"), + selfService: describe("self-service"), + }); + }) + ); + + return router; +}