From b890008b5da2d17e59d09e13679ce6906895e795 Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Fri, 14 Aug 2026 18:25:46 -0400 Subject: [PATCH 1/2] domain: tool sets and the self-service ceiling (resolution layer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of #27 + #35: schema, the pure resolver, and the policy wiring. No API or UI yet, and nothing changes for anyone until a set is assigned. Migration v6 adds tool_sets, tool_set_rules, role_tool_sets (with the granted / self-service mode from #35) and access_requests (inert until #29 — one migration rather than two). Selector semantics in the rules table: NULL means "any", '' means the ungrouped bucket, and the unique index COALESCEs both to a sentinel because a plain UNIQUE treats NULLs as distinct and would happily store the same selector twice. domain/toolsets.ts is pure: ruleMatches, ruleSpecificity, resolveCeiling. The most specific matching rule wins (tool > group > tier > server), ties break by scope (a role's private set over a shared one), then permissiveness, then ids — a total order, so "which rule won" is explainable and never depends on row order. `hasAnySet` is what flips a role to a closed world; without sets the legacy grant/default path runs untouched, which is the migration guarantee and is asserted by the existing policy tests passing unchanged. PolicyService.explain returns the decision AND its inputs, and allows() is now explain().allowed — an explanation cannot describe a different outcome than the boundary enforces. allowsFor gains the self-service zone: outside the granted envelope, offered by a set assigned in self-service mode, live only once the user writes an opt-in row, still capped by that set's own ceiling. A personal deny still wins, and un-assigning the set takes the tool away regardless. Tests: the resolution truth table (specificity shapes are provably distinct, order-independence, '' is a category not a wildcard, closed world vs legacy, inheritance of new tools into a covered category) and the same through a real Repo + PolicyService, including every self-service case above. Co-Authored-By: Claude Opus 5 --- packages/gateway/src/db/index.ts | 80 ++++++++ packages/gateway/src/db/migrate-v5.test.ts | 6 +- packages/gateway/src/db/repo.ts | 169 +++++++++++++++++ packages/gateway/src/domain/policy.ts | 100 ++++++++-- .../src/domain/toolsets-policy.test.ts | 160 ++++++++++++++++ packages/gateway/src/domain/toolsets.test.ts | 172 ++++++++++++++++++ packages/gateway/src/domain/toolsets.ts | 141 ++++++++++++++ 7 files changed, 810 insertions(+), 18 deletions(-) create mode 100644 packages/gateway/src/domain/toolsets-policy.test.ts create mode 100644 packages/gateway/src/domain/toolsets.test.ts create mode 100644 packages/gateway/src/domain/toolsets.ts 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..ee2a27a 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,145 @@ export class Repo { ).map(mapRole); } + // ── 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 +947,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)"; + } +} From 00827500cba5ee9d7633434f34c03a0721b9b579 Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Mon, 17 Aug 2026 06:34:41 -0400 Subject: [PATCH 2/2] admin: tool set API and UI (rules, assignment preview, convert, explain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #27 + #35: the admin surface over the resolution layer. New sub-router mounted inside the admin router (so it inherits the isAdmin gate): tool-set CRUD, rules upserted BY SELECTOR, assignment with a mode, the convert-grants migration helper, and explain. Two things it deliberately does not do twice: every "what does this rule cover" count comes from running the real matcher over the LIVE catalog, and every "what would this change" preview comes from making the change and rolling it back (Repo.dryRun) rather than from a second resolver that could disagree with the boundary. So the number in the confirm dialog is the number that happens. The UI is a new Tool sets tab: sets with their rules, a category picker built from the catalog's actual categories (a free-text box silently matching nothing was the failure mode of the old group switches), a "0 tools — check the category" badge on a rule that covers nothing, granted/self-service mode chips wherever a set is assigned, and an assignment flow that shows gains, LOSSES with examples, and a plain-language note that self-service goes live only when each user switches it on. Convert grants explains its one real consequence: the role becomes closed-world, so a newly added server is denied until a rule covers it. Verified against a dev gateway in a browser: tab renders, set creation, rule add with the 0-match warning, and the assignment preview over HTTP. (Pixel clicks miss in that pane because of its coordinate scaling, so the buttons were driven through their own handlers; no console errors either way.) Tests: CRUD and 409/400/404 paths, live match counts including the typo case, the preview persisting nothing, self-service assignment not flipping the closed world, convert-grants dry run then write then 409, explain naming the winning rule and the closed world, and the whole router refusing non-admins. Co-Authored-By: Claude Opus 5 --- packages/gateway/public/admin.html | 232 +++++++++++- packages/gateway/src/db/repo.ts | 15 + packages/gateway/src/http/admin-api.ts | 5 + .../src/http/admin-toolsets-api.test.ts | 259 +++++++++++++ .../gateway/src/http/admin-toolsets-api.ts | 342 ++++++++++++++++++ 5 files changed, 852 insertions(+), 1 deletion(-) create mode 100644 packages/gateway/src/http/admin-toolsets-api.test.ts create mode 100644 packages/gateway/src/http/admin-toolsets-api.ts 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/repo.ts b/packages/gateway/src/db/repo.ts index ee2a27a..77caf44 100644 --- a/packages/gateway/src/db/repo.ts +++ b/packages/gateway/src/db/repo.ts @@ -518,6 +518,21 @@ 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[] { 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; +}