/** * A5: plugin permissions in core RBAC. * * Covers the four things that were structurally wrong before: a plugin could * name any namespace it liked, a disabled plugin's permissions stopped being * grantable, a revoked role default came back on the next boot, and the ai * group was declared twice. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import path from "node:path"; import type { Fixture } from "./fixture-plugin.js"; const state = vi.hoisted(() => ({ /** roleName -> the permission array stored on that role. */ roles: new Map(), /** The rbac_applied_defaults rows, as ":". */ appliedDefaults: new Set(), /** The rbac_known_permissions rows. */ known: new Set(), warnings: [] as string[], })); vi.mock("../../utils/logger.js", () => ({ pluginLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn((message: string) => { state.warnings.push(message); }), error: vi.fn(), success: vi.fn(), }, databaseLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), }, })); vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn(async () => {}), getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`), })); vi.mock("../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ hasPermission: async () => true, invalidateUserPermissionCache: vi.fn(), }), }, })); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentRoleRepository: () => ({ findRoleByName: async (name: string) => { const role = state.roles.get(name); if (!role) return null; return { id: role.id, name, permissions: JSON.stringify(role.permissions), }; }, updateRole: async (id: number, updates: { permissions?: string }) => { for (const [name, role] of state.roles) { if (role.id !== id) continue; if (updates.permissions === undefined) continue; state.roles.set(name, { id, permissions: JSON.parse(updates.permissions) as string[], }); } }, listRoleUserIds: async () => [], listRoles: async () => [...state.roles.entries()].map(([name, role]) => ({ id: role.id, name, permissions: JSON.stringify(role.permissions), })), }), createCurrentRbacPermissionRepository: () => ({ listKnown: async () => [...state.known].map((permission) => ({ permission, pluginId: null })), recordKnown: async (rows: { permission: string }[]) => { for (const row of rows) state.known.add(row.permission); return rows.length; }, listAppliedDefaults: async () => [...state.appliedDefaults].map((key) => { const [roleName, ...rest] = key.split(":"); return { roleName, permission: rest.join(":") }; }), recordAppliedDefaults: async ( rows: { roleName: string; permission: string }[], ) => { for (const row of rows) { state.appliedDefaults.add(`${row.roleName}:${row.permission}`); } return rows.length; }, }), })); const { PluginLoader } = await import("../../plugins/loader.js"); const { createFixturePlugin } = await import("./fixture-plugin.js"); const { getPermissionCatalog, isValidPermission, markPluginPermissionsDisabled, registerPluginPermissions, resetPermissionCatalog, } = await import("../../utils/permission-catalog.js"); const { parseManifest, qualifyPermission } = await import("../../plugins/manifest.js"); const { resolvePermission, declaredPermissions } = await import("../../plugins/rbac.js"); const { createRbacMiddleware } = await import("../../plugins/http.js"); function manifest( id: string, permissions: Record[], extra: Record = {}, ) { return { id, name: id, version: "1.0.0", description: "A fixture.", author: { name: "Termix" }, license: "MIT", category: "Productivity", engine: { termix: ">=2.9.0", api: "1" }, capabilities: ["kv:own"], contributes: { permissions }, ...extra, }; } function permission(name: string, defaultRoles?: string[]) { const entry: Record = { name, titleKey: `permissions.${name}.title`, descriptionKey: `permissions.${name}.description`, }; if (defaultRoles) entry.defaultRoles = defaultRoles; return entry; } beforeEach(() => { state.roles.clear(); state.appliedDefaults.clear(); state.known.clear(); state.warnings.length = 0; resetPermissionCatalog(); }); afterEach(() => { resetPermissionCatalog(); }); describe("namespacing", () => { it("registers a short name as .", () => { expect(qualifyPermission("ai", "services.use")).toBe("ai.services.use"); expect(qualifyPermission("ai", "use")).toBe("ai.use"); }); it("qualifies the ids a manifest declares", () => { const { manifest: parsed, errors } = parseManifest( manifest("ai", [permission("use"), permission("services.use")]), ); expect(errors).toEqual([]); expect( parsed?.contributes?.permissions?.map((entry) => qualifyPermission("ai", entry.name), ), ).toEqual(["ai.use", "ai.services.use"]); }); it("prefixes a bare short name and leaves a full id alone", () => { const parsed = parseManifest( manifest("ai", [permission("services.use")]), ).manifest!; expect(resolvePermission(parsed, "services.use")).toBe("ai.services.use"); // Its own full id is already qualified. expect(resolvePermission(parsed, "ai.services.use")).toBe( "ai.services.use", ); // A core group is used as given. expect(resolvePermission(parsed, "hosts.view")).toBe("hosts.view"); }); it("leaves another plugin's id alone, for a cross-plugin check", () => { registerPluginPermissions({ group: "automations", pluginId: "automations", label: "Automations", permissions: ["automations.run"], }); const parsed = parseManifest(manifest("ai", [permission("use")])).manifest!; expect(resolvePermission(parsed, "automations.run")).toBe( "automations.run", ); }); }); describe("namespace escalation is refused", () => { it.each([ ["admin.users.manage", /reserved core group/], ["hosts.view", /reserved core group/], ["credentials.read", /reserved core group/], ["snippets.edit", /reserved core group/], ])("refuses the core name %s", (name, pattern) => { const { errors } = parseManifest(manifest("sample", [permission(name)])); expect(errors.join()).toMatch(pattern); }); it("refuses a name that repeats the plugin's own id", () => { const { errors } = parseManifest(manifest("ai", [permission("ai.use")])); expect(errors.join()).toMatch(/already starts with this plugin/); }); // The manifest validator cannot know which other plugins exist, so the // runtime re-checks against the live catalog when the plugin registers. it("drops a permission naming another plugin's namespace at registration", async () => { registerPluginPermissions({ group: "automations", pluginId: "automations", label: "Automations", permissions: ["automations.run"], }); const fixture = createFixturePlugin({ id: "docker", capabilities: ["kv:own"], manifestOverrides: { category: "Infrastructure", contributes: { permissions: [permission("automations.run"), permission("ps")], }, }, }); const { resetPluginRuntime, getPluginRuntime, activatePlugin } = await import("../../plugins/index.js"); resetPluginRuntime(); const loader = getPluginRuntime().loader; try { const plugin = await loader.load(fixture.dir); await activatePlugin(plugin.id); // Its own name registered; the one reaching into automations did not. expect(isValidPermission("docker.ps")).toBe(true); expect(isValidPermission("docker.automations.run")).toBe(false); expect(state.warnings.join()).toMatch(/belongs to someone else/); } finally { await loader.shutdown(); fixture.cleanup(); } }); it("only lets a route require a permission the plugin declares", () => { const parsed = parseManifest(manifest("ai", [permission("use")])).manifest!; expect(declaredPermissions(parsed).has("ai.use")).toBe(true); expect(() => createRbacMiddleware(parsed, "use")).not.toThrow(); expect(() => createRbacMiddleware(parsed, "admin.users.manage")).toThrow( /cannot require permission/, ); }); }); describe("a disabled plugin's permissions survive", () => { beforeEach(() => { registerPluginPermissions({ group: "ai", pluginId: "ai", label: "AI Assistant", icon: "Sparkles", permissions: ["ai.use", "ai.services.use"], }); }); // The exact shape PUT /rbac/roles/:id validates, which used to 400 the whole // role while the plugin was off. it("keeps validating a role that holds them", () => { markPluginPermissionsDisabled("ai"); const saved = ["hosts.view", "ai.use", "ai.services.use"]; const invalid = saved.filter((entry) => !isValidPermission(entry)); expect(invalid).toEqual([]); }); it("keeps the group listed, marked disabled", () => { markPluginPermissionsDisabled("ai"); const entry = getPermissionCatalog().find((row) => row.group === "ai"); expect(entry?.enabled).toBe(false); expect(entry?.permissions).toContain("ai.use"); }); it("re-enables on the next registration", () => { markPluginPermissionsDisabled("ai"); registerPluginPermissions({ group: "ai", pluginId: "ai", label: "AI Assistant", permissions: ["ai.use", "ai.services.use"], }); const entry = getPermissionCatalog().find((row) => row.group === "ai"); expect(entry?.enabled).toBe(true); }); }); describe("the duplicate ai group is gone", () => { it("lists ai exactly once when the plugin registers", () => { registerPluginPermissions({ group: "ai", pluginId: "ai", label: "AI Assistant", permissions: ["ai.use"], }); const matches = getPermissionCatalog().filter( (entry) => entry.group === "ai", ); expect(matches).toHaveLength(1); expect(matches[0].pluginId).toBe("ai"); }); }); describe("shipped manifests keep their permission ids", () => { const root = path.resolve(import.meta.dirname, "../../../../plugins"); function idsFor(pluginId: string): string[] { const raw = JSON.parse( fs.readFileSync(path.join(root, pluginId, "manifest.json"), "utf8"), ); const { manifest: parsed, errors } = parseManifest(raw); expect(errors).toEqual([]); return (parsed?.contributes?.permissions ?? []).map((entry) => qualifyPermission(pluginId, entry.name), ); } // A role holding these today must keep working, which is only true because // . reproduces the old id byte for byte. it("ai", () => { expect(idsFor("ai")).toEqual([ "ai.use", "ai.manage_providers", "ai.apply_proposals", "ai.services.use", "ai.secrets.share", ]); }); it("automations", () => { expect(idsFor("automations")).toEqual([ "automations.view", "automations.create", "automations.edit", "automations.delete", "automations.run", ]); }); it("tailscale", () => { expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]); }); it("snippets", () => { expect(idsFor("snippets")).toEqual([ "snippets.view", "snippets.create", "snippets.edit", "snippets.delete", "snippets.share", ]); }); it("a role holding the old ids still validates once registered", () => { for (const pluginId of ["ai", "automations", "tailscale", "snippets"]) { const ids = idsFor(pluginId); registerPluginPermissions({ group: pluginId, pluginId, label: pluginId, permissions: ids, }); } const stored = [ "ai.use", "ai.manage_providers", "ai.apply_proposals", "automations.run", "automations.view", "tailscale.devices.view", "snippets.view", "snippets.share", ]; expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]); }); }); describe("role defaults apply exactly once", () => { let fixture: Fixture; let loader: InstanceType; beforeEach(() => { state.roles.set("user", { id: 2, permissions: ["hosts.*"] }); state.roles.set("admin", { id: 1, permissions: ["*"] }); }); afterEach(async () => { await loader?.shutdown(); fixture?.cleanup(); }); async function registerOnce() { const { resetPluginRuntime, getPluginRuntime } = await import("../../plugins/index.js"); resetPluginRuntime(); loader = getPluginRuntime().loader; const plugin = await loader.load(fixture.dir); const { activatePlugin } = await import("../../plugins/index.js"); await activatePlugin(plugin.id); } it("adds a declared default to the named role", async () => { fixture = createFixturePlugin({ id: "docker", capabilities: ["kv:own"], manifestOverrides: { category: "Infrastructure", contributes: { permissions: [permission("run", ["user"])] }, }, }); await registerOnce(); expect(state.roles.get("user")?.permissions).toContain("docker.run"); expect(state.appliedDefaults.has("user:docker.run")).toBe(true); }); // The whole point of the ledger: an admin's revoke is a decision, not a gap. it("does not re-add a default an admin removed", async () => { fixture = createFixturePlugin({ id: "docker", capabilities: ["kv:own"], manifestOverrides: { category: "Infrastructure", contributes: { permissions: [permission("run", ["user"])] }, }, }); await registerOnce(); expect(state.roles.get("user")?.permissions).toContain("docker.run"); // The admin revokes it. state.roles.set("user", { id: 2, permissions: ["hosts.*"] }); await loader.shutdown(); await registerOnce(); expect(state.roles.get("user")?.permissions).not.toContain("docker.run"); }); it("leaves a role alone when the plugin declares no defaults", async () => { fixture = createFixturePlugin({ id: "docker", capabilities: ["kv:own"], manifestOverrides: { category: "Infrastructure", contributes: { permissions: [permission("run")] }, }, }); await registerOnce(); expect(state.roles.get("user")?.permissions).toEqual(["hosts.*"]); expect(isValidPermission("docker.run")).toBe(true); }); it("records the qualified id, not the short name", async () => { fixture = createFixturePlugin({ id: "docker", capabilities: ["kv:own"], manifestOverrides: { category: "Infrastructure", contributes: { permissions: [permission("containers.run", ["user"])] }, }, }); await registerOnce(); expect(state.roles.get("user")?.permissions).toContain( "docker.containers.run", ); expect(state.known.has("docker.containers.run")).toBe(true); }); });