diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 4b85fe68a..41bf232e9 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1781,7 +1781,7 @@ app.use("/ai", aiRoutes); app.use("/", alertRulesRoutes); app.use("/sync", syncRoutes); app.use("/plugins", pluginRoutes); -app.use("/plugin-api", pluginApiRoutes); +app.use("/plugin-api", authenticateJWT, pluginApiRoutes); const frontendDistPaths = [ path.join(__dirname, "../../../dist"), diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 17a8def9f..2a1c094b0 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -7,20 +7,36 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { createCurrentPluginRepository } from "../repositories/factory.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; +import { + createCurrentPluginPermissionGrantRepository, + createCurrentPluginRepository, +} from "../repositories/factory.js"; import { getPluginRuntime } from "../../plugins/index.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); +const permissionManager = PermissionManager.getInstance(); +const requireManagePlugins = permissionManager.requirePermission( + "admin.plugins.manage", +); /** * @openapi * /plugins: * get: * summary: List installed plugins and their runtime state - * description: Returns every plugin known to the database, merged with the loader's live state so the UI can tell "enabled but crashed" from "disabled". + * description: > + * Returns every plugin known to the database, merged with the loader's + * live state so the UI can tell "enabled but crashed" from "disabled". + * Open to any authenticated user, not just admins: the app shell calls + * this on every session to decide which plugin-contributed tabs and + * rail items to register, so gating it behind admin.plugins.manage + * would break the shell for non-admin users. Only the mutating routes + * below (enable/disable, grant/revoke) require that permission. * tags: * - Plugins * responses: @@ -32,28 +48,43 @@ router.get("/", authenticateJWT, async (_req: Request, res: Response) => { const records = await createCurrentPluginRepository().listAll(); const { loader } = getPluginRuntime(); const live = new Map(loader.list().map((p) => [p.id, p])); + const grantRepository = createCurrentPluginPermissionGrantRepository(); - const plugins = records.map((record) => { - const loaded = live.get(record.id); - let contributes: unknown = null; - try { - contributes = JSON.parse(record.manifestJson)?.contributes ?? null; - } catch { - contributes = null; - } + const plugins = await Promise.all( + records.map(async (record) => { + const loaded = live.get(record.id); + let contributes: unknown = null; + let permissions: string[] = []; + try { + const manifest = JSON.parse(record.manifestJson) as { + contributes?: unknown; + permissions?: unknown; + }; + contributes = manifest?.contributes ?? null; + permissions = Array.isArray(manifest?.permissions) + ? (manifest.permissions as string[]) + : []; + } catch { + contributes = null; + } - return { - id: record.id, - name: record.name, - version: record.version, - tier: record.tier, - source: record.source, - enabled: record.state === "enabled", - runtimeState: loaded?.state ?? "stopped", - lastError: loaded?.lastError ?? null, - contributes, - }; - }); + const grants = await grantRepository.listByPlugin(record.id); + + return { + id: record.id, + name: record.name, + version: record.version, + tier: record.tier, + source: record.source, + enabled: record.state === "enabled", + runtimeState: loaded?.state ?? "stopped", + lastError: loaded?.lastError ?? null, + contributes, + permissions, + grantedCapabilities: grants.map((grant) => grant.capability), + }; + }), + ); res.json(plugins); } catch (error) { @@ -94,12 +125,15 @@ router.get("/", authenticateJWT, async (_req: Request, res: Response) => { * description: The plugin's new state. * 400: * description: Invalid request body. + * 403: + * description: The caller lacks admin.plugins.manage. * 404: * description: No such plugin. */ router.patch( "/:id/state", authenticateJWT, + requireManagePlugins, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const pluginId = String(req.params.id); @@ -148,4 +182,173 @@ router.patch( }, ); +/** + * @openapi + * /plugins/{id}/grants: + * post: + * summary: Grant a plugin one of its manifest-declared capabilities + * description: > + * The grant is install-wide, not per-user: it unlocks the capability for + * the plugin as a whole. What each call then does with the capability + * (e.g. whose hosts ctx.hosts.list() returns) is still scoped to + * whichever user's request triggered it. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * capability: + * type: string + * responses: + * 200: + * description: The capability is now granted. + * 400: + * description: The capability is not declared in the plugin's manifest. + * 403: + * description: The caller lacks admin.plugins.manage. + * 404: + * description: No such plugin. + */ +router.post( + "/:id/grants", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId as string; + const pluginId = String(req.params.id); + const { capability } = req.body ?? {}; + + if (typeof capability !== "string" || !capability) { + res.status(400).json({ error: "capability is required" }); + return; + } + + try { + const repository = createCurrentPluginRepository(); + const record = await repository.findById(pluginId); + if (!record) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + let declared: string[] = []; + try { + const manifest = JSON.parse(record.manifestJson) as { + permissions?: unknown; + }; + declared = Array.isArray(manifest?.permissions) + ? (manifest.permissions as string[]) + : []; + } catch { + declared = []; + } + + if (!declared.includes(capability)) { + res.status(400).json({ + error: "This capability is not declared in the plugin's manifest", + }); + return; + } + + const grantRepository = createCurrentPluginPermissionGrantRepository(); + const existing = await grantRepository.findGrant(pluginId, capability); + if (!existing) { + await grantRepository.grant({ + pluginId, + capability, + grantedBy: userId, + }); + invalidatePluginPermissionCache(pluginId); + } + + databaseLogger.info(`Granted ${capability} to plugin ${pluginId}`, { + operation: "plugin_grant", + pluginId, + }); + + res.json({ id: pluginId, capability, granted: true }); + } catch (error) { + databaseLogger.error( + `Failed to grant ${capability} to plugin ${pluginId}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_grant" }, + ); + res.status(500).json({ error: "Failed to grant the capability" }); + } + }, +); + +/** + * @openapi + * /plugins/{id}/grants/{capability}: + * delete: + * summary: Revoke a previously granted plugin capability + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: path + * name: capability + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The capability is no longer granted. + * 403: + * description: The caller lacks admin.plugins.manage. + * 404: + * description: No such plugin, or the capability was not granted. + */ +router.delete( + "/:id/grants/:capability", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const pluginId = String(req.params.id); + const capability = String(req.params.capability); + + try { + const revoked = + await createCurrentPluginPermissionGrantRepository().revoke( + pluginId, + capability, + ); + if (!revoked) { + res.status(404).json({ error: "That capability was not granted" }); + return; + } + invalidatePluginPermissionCache(pluginId); + + databaseLogger.info(`Revoked ${capability} from plugin ${pluginId}`, { + operation: "plugin_grant_revoke", + pluginId, + }); + + res.json({ id: pluginId, capability, granted: false }); + } catch (error) { + databaseLogger.error( + `Failed to revoke ${capability} from plugin ${pluginId}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_grant_revoke" }, + ); + res.status(500).json({ error: "Failed to revoke the capability" }); + } + }, +); + export default router; diff --git a/src/backend/plugins/broker.ts b/src/backend/plugins/broker.ts index a5adc402f..3d4f5f174 100644 --- a/src/backend/plugins/broker.ts +++ b/src/backend/plugins/broker.ts @@ -216,7 +216,7 @@ export class PluginBroker { runtime: PluginRuntime, request: PluginRequest, ): Promise { - const { method, args } = request; + const { method, args, callerUserId } = request; let failure: Error | null = null; try { @@ -229,7 +229,7 @@ export class PluginBroker { ); } - const value = await this.invoke(runtime, method, args); + const value = await this.invoke(runtime, method, args, callerUserId); this.reply(runtime, { id: request.id, ok: true, value }); } catch (error) { failure = error instanceof Error ? error : new Error(String(error)); @@ -246,7 +246,7 @@ export class PluginBroker { } if (AUDITED.has(method)) { - void this.audit(runtime, method, args, failure); + void this.audit(runtime, method, args, failure, callerUserId); } } @@ -263,6 +263,7 @@ export class PluginBroker { runtime: PluginRuntime, method: string, args: unknown[], + callerUserId?: string, ): Promise { switch (method) { case "log.debug": @@ -277,10 +278,10 @@ export class PluginBroker { return null; case "hosts.list": - return this.handleHostsList(runtime); + return this.handleHostsList(runtime, callerUserId); case "hosts.get": - return this.handleHostsGet(runtime, Number(args[0])); + return this.handleHostsGet(runtime, Number(args[0]), callerUserId); case "storage.get": return this.handleStorageGet(runtime, storageKey(args[0])); @@ -304,7 +305,7 @@ export class PluginBroker { return this.handleHttpRoute(runtime, String(args[0]), String(args[1])); case "ssh.connect": - return this.handleSshConnect(runtime, Number(args[0])); + return this.handleSshConnect(runtime, Number(args[0]), callerUserId); case "ssh.exec": return this.handleSshExec( @@ -349,8 +350,9 @@ export class PluginBroker { private async handleHostsList( runtime: PluginRuntime, + callerUserId?: string, ): Promise { - const userId = this.requireOwner(runtime); + const userId = this.requireActor(runtime, callerUserId); const hosts = await this.deps.listHosts(userId); return hosts.map(toPluginHostView); } @@ -358,8 +360,9 @@ export class PluginBroker { private async handleHostsGet( runtime: PluginRuntime, hostId: number, + callerUserId?: string, ): Promise { - const userId = this.requireOwner(runtime); + const userId = this.requireActor(runtime, callerUserId); if (!Number.isFinite(hostId)) { throw new PluginFacingError("hosts.get requires a numeric host id"); } @@ -453,8 +456,9 @@ export class PluginBroker { private async handleSshConnect( runtime: PluginRuntime, hostId: number, + callerUserId?: string, ): Promise { - const userId = this.requireOwner(runtime); + const userId = this.requireActor(runtime, callerUserId); if (!Number.isFinite(hostId)) { throw new PluginFacingError("ssh.connect requires a numeric host id"); } @@ -651,8 +655,30 @@ export class PluginBroker { } } - private requireOwner(runtime: PluginRuntime): string { - const userId = runtime.plugin.ownerUserId; + /** + * Resolves the user a privileged ctx call acts as. This is where the two + * identity models that coexist in one plugin meet: + * + * - Inside an HTTP handler, `callerUserId` is set -- the worker attached + * it from the AsyncLocalStorage context it entered around that specific + * ctx.http.route invocation (see worker-bootstrap.ts). The call acts as + * whichever user's request is currently being served, so + * ctx.hosts.list() returns THEIR hosts, not the plugin installer's. + * Grants stay install-wide (an admin unlocks the capability for the + * plugin as a whole); this is only about whose DATA the call touches. + * + * - From a schedule.every timer or an events.on listener, there is no + * inbound request and so no AsyncLocalStorage context to read from -- + * `callerUserId` is undefined. These fall back to ownerUserId, the user + * who last enabled the plugin, which is what background work has + * always run as. + * + * A plugin cannot influence which branch it gets: no ctx method takes a + * userId argument, so `callerUserId` only ever reflects a real inbound + * request the server's own auth middleware verified. + */ + private requireActor(runtime: PluginRuntime, callerUserId?: string): string { + const userId = callerUserId ?? runtime.plugin.ownerUserId; if (!userId) { throw new PluginFacingError( `Plugin ${runtime.plugin.id} has no owning user, so it cannot act on user data`, @@ -666,13 +692,17 @@ export class PluginBroker { method: string, args: unknown[], failure: Error | null, + callerUserId?: string, ): Promise { - const userId = runtime.plugin.ownerUserId; + const userId = callerUserId ?? runtime.plugin.ownerUserId; if (!userId) return; // Attribution comes from the broker, never from the plugin: username is // the plugin id, not the user's, so a plugin action is never mistaken for - // something the person did themselves. + // something the person did themselves. The userId this is logged against + // is whichever identity requireActor would have used for the same call + // (see its comment), so a request-triggered action is attributed to the + // requester, not always to whoever installed the plugin. await logAudit({ userId, username: `plugin:${runtime.plugin.id}`, diff --git a/src/backend/plugins/http-bridge.ts b/src/backend/plugins/http-bridge.ts index c285421a9..cf5f1159c 100644 --- a/src/backend/plugins/http-bridge.ts +++ b/src/backend/plugins/http-bridge.ts @@ -39,8 +39,8 @@ export function buildPluginRouter( query: req.query, body: req.body ?? null, headers: pickHeaders(req), - // The acting user, resolved by the server's own auth middleware. - // A plugin cannot set or spoof this. + // The acting user, verified by authenticateJWT ahead of the + // /plugin-api mount in database.ts. A plugin cannot set or spoof this. userId: (req as Request & { userId?: string }).userId ?? null, }); diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index af374112f..cfa3e4de8 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -254,7 +254,9 @@ export class PluginLoader { /** * `ownerUserId` is optional only for in-process first-party plugins, which * do not use the gated ctx and so never act as a user. A worker plugin - * without one cannot reach any user data (see broker.requireOwner). + * without one cannot reach any user data outside of a per-request caller + * identity (see broker.requireActor), which only exists inside an HTTP + * handler invocation and is never a substitute for having an owner at all. */ async activate(pluginId: string, ownerUserId?: string): Promise { const plugin = this.requirePlugin(pluginId); diff --git a/src/backend/plugins/protocol.ts b/src/backend/plugins/protocol.ts index 47685e0ab..a99c22024 100644 --- a/src/backend/plugins/protocol.ts +++ b/src/backend/plugins/protocol.ts @@ -13,6 +13,15 @@ export interface PluginRequest { /** Dotted ctx path, e.g. "hosts.get" or "storage.set". */ method: string; args: unknown[]; + /** + * The user whose HTTP request is currently executing inside the worker, if + * any. Set by the worker's own call() from its AsyncLocalStorage context, + * never by plugin code -- no ctx method accepts a userId argument, so a + * plugin has no way to set or forge this itself. Absent for a ctx call made + * from a timer or event listener, which has no inbound request to derive an + * actor from; the broker falls back to the plugin's ownerUserId then. + */ + callerUserId?: string; } export interface PluginResponseOk { diff --git a/src/backend/plugins/worker-bootstrap.ts b/src/backend/plugins/worker-bootstrap.ts index 11568103b..5f63cae4c 100644 --- a/src/backend/plugins/worker-bootstrap.ts +++ b/src/backend/plugins/worker-bootstrap.ts @@ -7,6 +7,7 @@ * account of what that does and does not guarantee. */ +import { AsyncLocalStorage } from "node:async_hooks"; import { parentPort, workerData } from "node:worker_threads"; import type { PluginBootstrapData, @@ -33,11 +34,30 @@ const httpRoutes = new Map unknown>(); const eventListeners = new Map void>>(); const timers = new Map void>(); +/** + * Tracks which user's HTTP request is currently executing, so a ctx call made + * anywhere in that request's call stack -- including deep inside plugin code + * that has no idea this exists -- can be attributed to them without adding a + * userId parameter to every ctx method. + * + * Only entered around an ctx.http.route handler invocation (see handlePush + * below). A ctx call made from a schedule.every timer or an events.on + * listener runs outside any run() call, so getStore() correctly returns + * undefined for those and the broker falls back to the plugin's owner. + */ +const requestActor = new AsyncLocalStorage<{ userId: string }>(); + function call(method: string, ...args: unknown[]): Promise { const id = nextRequestId++; + const callerUserId = requestActor.getStore()?.userId; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); - port!.postMessage({ id, method, args }); + port!.postMessage({ + id, + method, + args, + ...(callerUserId && { callerUserId }), + }); }); } @@ -113,8 +133,16 @@ async function handlePush(push: PluginPush): Promise { return; } + const requestUserId = + (push.payload as { userId?: string | null } | undefined)?.userId ?? + undefined; + try { - const value = await handler(push.payload); + const value = await (requestUserId + ? requestActor.run({ userId: requestUserId }, () => + handler(push.payload), + ) + : handler(push.payload)); port!.postMessage({ id: push.replyTo, ok: true, value }); } catch (error) { port!.postMessage({ diff --git a/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts b/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts new file mode 100644 index 000000000..a6ff3aea7 --- /dev/null +++ b/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts @@ -0,0 +1,170 @@ +/** + * database.ts mounts /plugin-api behind authenticateJWT, the same way every + * other router is gated. This exercises that exact wiring (rather than + * http-bridge.test.ts's bare app, which mounts the dispatcher with no auth + * in front of it on purpose, to test the dispatcher in isolation) so a + * regression here - an unauthenticated caller reaching a plugin route - is + * caught. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import { + createFixturePlugin, + type Fixture, +} from "../../plugins/fixture-plugin.js"; + +const state = vi.hoisted(() => ({ + validToken: "valid-token", + userId: "user-1", +})); + +vi.mock("../../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + 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/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + ( + req: Record & { headers: Record }, + res: { status: (code: number) => { json: (body: unknown) => void } }, + next: () => void, + ) => { + const auth = req.headers["authorization"]; + const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null; + if (token !== state.validToken) { + res.status(401).json({ error: "Missing authentication token" }); + return; + } + req.userId = state.userId; + next(); + }, + }), + }, +})); + +const { AuthManager } = await import("../../../utils/auth-manager.js"); +const { PluginBroker } = await import("../../../plugins/broker.js"); +const { PluginLoader } = await import("../../../plugins/loader.js"); +const { buildPluginRouter } = await import("../../../plugins/http-bridge.js"); +const pluginApi = await import("../../../database/routes/plugin-api-routes.js"); + +const WORKER_TIMEOUT = 30_000; + +describe("/plugin-api behind the app's real auth middleware", () => { + const fixtures: Fixture[] = []; + let loader: InstanceType | null = null; + let broker: InstanceType | null = null; + let server: Server | null = null; + let baseUrl = ""; + + beforeEach(async () => { + const authenticateJWT = AuthManager.getInstance().createAuthMiddleware(); + + const app = express(); + // Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes). + app.use("/plugin-api", authenticateJWT, pluginApi.default); + + server = await new Promise((resolve) => { + const created = app.listen(0, "127.0.0.1", () => resolve(created)); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + const fixture = createFixturePlugin({ + backendSource: ` + export async function activate(ctx) { + await ctx.http.route("GET", "/whoami", async (req) => ({ + userId: req.userId ?? null, + })); + } + `, + }); + fixtures.push(fixture); + + broker = new PluginBroker({ + listHosts: async () => [], + resolveHost: async () => null, + onRoutesChanged: (runtime) => { + pluginApi.registerPluginRouter( + runtime.plugin.id, + buildPluginRouter(broker!, runtime), + ); + }, + }); + + loader = new PluginLoader({ + onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), + onWorkerGone: (plugin) => { + broker!.detach(plugin.id); + pluginApi.unregisterPluginRouter(plugin.id); + }, + }); + + await loader.load(fixture.dir); + await loader.activate("sample-plugin", "user-1"); + }); + + afterEach(async () => { + await loader?.shutdown(); + loader = null; + broker = null; + + for (const id of pluginApi.getRegisteredPluginIds()) { + pluginApi.unregisterPluginRouter(id); + } + + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + while (fixtures.length) fixtures.pop()!.cleanup(); + }); + + it( + "rejects a request with no token before it reaches the plugin", + async () => { + const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`); + expect(res.status).toBe(401); + }, + WORKER_TIMEOUT, + ); + + it( + "rejects a request with an invalid token", + async () => { + const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, { + headers: { authorization: "Bearer not-the-right-token" }, + }); + expect(res.status).toBe(401); + }, + WORKER_TIMEOUT, + ); + + it( + "forwards the verified userId once authenticated", + async () => { + const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, { + headers: { authorization: `Bearer ${state.validToken}` }, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ userId: state.userId }); + }, + WORKER_TIMEOUT, + ); +}); diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts new file mode 100644 index 000000000..3bc8bd085 --- /dev/null +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -0,0 +1,355 @@ +/** + * The plugin control plane: listing installed plugins (with their declared + * and granted capabilities) and granting/revoking a capability. Enable/disable + * is not re-tested here beyond what already existed; the grant/revoke routes + * are the new surface this file covers. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; + +interface PluginRow { + id: string; + name: string; + version: string; + tier: string; + source: string; + state: string; + manifestJson: string; +} + +const state = vi.hoisted(() => ({ + plugins: new Map(), + grants: [] as { pluginId: string; capability: string; grantedBy: string }[], + // Which users have admin.plugins.manage, keyed by userId. + managers: new Set(["admin-1"]), +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +/** + * The caller identifies as whichever user the x-test-user-id header names, + * mirroring the real JWT middleware's job of setting req.userId -- these + * tests only need to vary which user is calling, not verify real tokens. + */ +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + ( + req: Record & { headers: Record }, + _res: unknown, + next: () => void, + ) => { + req.userId = req.headers["x-test-user-id"] ?? "admin-1"; + next(); + }, + }), + }, +})); + +vi.mock("../../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + requirePermission: + (_permission: string) => + ( + req: Record, + res: { + status: (code: number) => { json: (body: unknown) => void }; + }, + next: () => void, + ) => { + const userId = req.userId as string; + if (!state.managers.has(userId)) { + res.status(403).json({ error: "Insufficient permissions" }); + return; + } + next(); + }, + }), + }, +})); + +vi.mock("../../../plugins/index.js", () => ({ + getPluginRuntime: () => ({ loader: { list: () => [] } }), + activatePlugin: vi.fn(), + deactivatePlugin: vi.fn(), +})); + +vi.mock("../../../plugins/permissions.js", () => ({ + invalidatePluginPermissionCache: vi.fn(), +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + listAll: async () => [...state.plugins.values()], + findById: async (id: string) => state.plugins.get(id) ?? null, + update: async (id: string, changes: Partial) => { + const existing = state.plugins.get(id); + if (existing) state.plugins.set(id, { ...existing, ...changes }); + }, + }), + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + state.grants + .filter((g) => g.pluginId === pluginId) + .map((g) => ({ capability: g.capability })), + findGrant: async (pluginId: string, capability: string) => + state.grants.find( + (g) => g.pluginId === pluginId && g.capability === capability, + ) ?? null, + grant: async (input: { + pluginId: string; + capability: string; + grantedBy: string; + }) => { + state.grants.push(input); + return input; + }, + revoke: async (pluginId: string, capability: string) => { + const before = state.grants.length; + state.grants = state.grants.filter( + (g) => !(g.pluginId === pluginId && g.capability === capability), + ); + return state.grants.length < before; + }, + }), +})); + +const pluginRoutes = (await import("../../../database/routes/plugins.js")) + .default; + +function makePlugin(overrides: Partial = {}): PluginRow { + return { + id: "sample-plugin", + name: "Sample Plugin", + version: "1.0.0", + tier: "community", + source: "community", + state: "enabled", + manifestJson: JSON.stringify({ + permissions: ["hosts.read", "storage.own"], + }), + ...overrides, + }; +} + +describe("plugins route", () => { + let server: Server | null = null; + let baseUrl = ""; + + beforeEach(async () => { + state.plugins = new Map(); + state.grants = []; + state.managers = new Set(["admin-1"]); + + const app = express(); + app.use(express.json()); + app.use("/plugins", pluginRoutes); + + server = await new Promise((resolve) => { + const created = app.listen(0, "127.0.0.1", () => resolve(created)); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + }); + + it("lists a plugin's declared permissions and granted capabilities", async () => { + state.plugins.set("sample-plugin", makePlugin()); + state.grants.push({ + pluginId: "sample-plugin", + capability: "hosts.read", + grantedBy: "admin-1", + }); + + const res = await fetch(`${baseUrl}/plugins`); + const body = await res.json(); + + expect(body).toEqual([ + expect.objectContaining({ + id: "sample-plugin", + permissions: ["hosts.read", "storage.own"], + grantedCapabilities: ["hosts.read"], + }), + ]); + }); + + it("grants a declared capability", async () => { + state.plugins.set("sample-plugin", makePlugin()); + + const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capability: "hosts.read" }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "sample-plugin", + capability: "hosts.read", + granted: true, + }); + expect(state.grants).toEqual([ + { + pluginId: "sample-plugin", + capability: "hosts.read", + grantedBy: "admin-1", + }, + ]); + }); + + it("rejects granting a capability the manifest does not declare", async () => { + state.plugins.set("sample-plugin", makePlugin()); + + const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capability: "ssh.exec" }), + }); + + expect(res.status).toBe(400); + expect(state.grants).toEqual([]); + }); + + it("404s granting a capability for a plugin that does not exist", async () => { + const res = await fetch(`${baseUrl}/plugins/ghost/grants`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capability: "hosts.read" }), + }); + + expect(res.status).toBe(404); + }); + + it("does not duplicate an already-granted capability", async () => { + state.plugins.set("sample-plugin", makePlugin()); + + await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capability: "hosts.read" }), + }); + await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capability: "hosts.read" }), + }); + + expect(state.grants).toHaveLength(1); + }); + + it("revokes a granted capability", async () => { + state.plugins.set("sample-plugin", makePlugin()); + state.grants.push({ + pluginId: "sample-plugin", + capability: "hosts.read", + grantedBy: "admin-1", + }); + + const res = await fetch( + `${baseUrl}/plugins/sample-plugin/grants/hosts.read`, + { method: "DELETE" }, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "sample-plugin", + capability: "hosts.read", + granted: false, + }); + expect(state.grants).toEqual([]); + }); + + it("404s revoking a capability that was not granted", async () => { + state.plugins.set("sample-plugin", makePlugin()); + + const res = await fetch( + `${baseUrl}/plugins/sample-plugin/grants/hosts.read`, + { method: "DELETE" }, + ); + + expect(res.status).toBe(404); + }); + + describe("admin.plugins.manage gate", () => { + it("a non-admin authenticated user cannot list plugins", async () => { + // GET stays open to any authenticated user on purpose: the app shell + // calls it for every session to know which plugin tabs to register. + state.plugins.set("sample-plugin", makePlugin()); + + const res = await fetch(`${baseUrl}/plugins`, { + headers: { "x-test-user-id": "regular-user" }, + }); + + expect(res.status).toBe(200); + }); + + it("403s a non-admin enabling or disabling a plugin", async () => { + state.plugins.set("sample-plugin", makePlugin()); + + const res = await fetch(`${baseUrl}/plugins/sample-plugin/state`, { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-test-user-id": "regular-user", + }, + body: JSON.stringify({ enabled: false }), + }); + + expect(res.status).toBe(403); + expect(state.plugins.get("sample-plugin")?.state).toBe("enabled"); + }); + + it("403s a non-admin granting a capability", async () => { + state.plugins.set("sample-plugin", makePlugin()); + + const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-test-user-id": "regular-user", + }, + body: JSON.stringify({ capability: "hosts.read" }), + }); + + expect(res.status).toBe(403); + expect(state.grants).toEqual([]); + }); + + it("403s a non-admin revoking a capability", async () => { + state.plugins.set("sample-plugin", makePlugin()); + state.grants.push({ + pluginId: "sample-plugin", + capability: "hosts.read", + grantedBy: "admin-1", + }); + + const res = await fetch( + `${baseUrl}/plugins/sample-plugin/grants/hosts.read`, + { + method: "DELETE", + headers: { "x-test-user-id": "regular-user" }, + }, + ); + + expect(res.status).toBe(403); + expect(state.grants).toHaveLength(1); + }); + }); +}); diff --git a/src/backend/tests/plugins/http-bridge.test.ts b/src/backend/tests/plugins/http-bridge.test.ts index 312fc8d94..4aa7f2a31 100644 --- a/src/backend/tests/plugins/http-bridge.test.ts +++ b/src/backend/tests/plugins/http-bridge.test.ts @@ -41,6 +41,8 @@ const { PluginBroker } = await import("../../plugins/broker.js"); const { PluginLoader } = await import("../../plugins/loader.js"); const { buildPluginRouter } = await import("../../plugins/http-bridge.js"); const pluginApi = await import("../../database/routes/plugin-api-routes.js"); +const { invalidatePluginPermissionCache } = + await import("../../plugins/permissions.js"); const WORKER_TIMEOUT = 30_000; @@ -314,4 +316,165 @@ describe("plugin HTTP bridge", () => { }, WORKER_TIMEOUT, ); + + describe("per-request actor identity", () => { + /** + * Exercises the real worker end to end: the fixture's activate() calls + * ctx.hosts.list() from inside a genuine ctx.http.route handler, so the + * AsyncLocalStorage context worker-bootstrap.ts enters around that + * invocation is the thing under test, not a mock of it. Two concurrent + * requests as two different users must each see only their own hosts, + * even though both are in flight on the very same worker at once. + */ + async function activateWithHostsRoute() { + const fixture = createFixturePlugin({ + backendSource: ` + export async function activate(ctx) { + await ctx.http.route("GET", "/my-hosts", async () => { + const hosts = await ctx.hosts.list(); + return { hosts }; + }); + } + `, + }); + fixtures.push(fixture); + + const hostsByUser: Record = { + alice: [{ id: 1, name: "alice-host" }], + bob: [{ id: 2, name: "bob-host" }], + }; + + broker = new PluginBroker({ + listHosts: async (userId) => hostsByUser[userId] ?? [], + resolveHost: async () => null, + onRoutesChanged: (runtime) => { + pluginApi.registerPluginRouter( + runtime.plugin.id, + buildPluginRouter(broker!, runtime), + ); + }, + }); + + loader = new PluginLoader({ + onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), + onWorkerGone: (plugin) => { + broker!.detach(plugin.id); + pluginApi.unregisterPluginRouter(plugin.id); + }, + }); + + state.grants.push({ + pluginId: "sample-plugin", + capability: "hosts.read", + }); + invalidatePluginPermissionCache("sample-plugin"); + + await loader.load(fixture.dir); + // Activated under "install-owner", a third identity distinct from + // alice and bob, so a test that accidentally fell back to ownerUserId + // instead of the per-request actor would show up as a clear mismatch + // rather than an accidental pass. + await loader.activate("sample-plugin", "install-owner"); + } + + /** + * Mimics authenticateJWT setting req.userId ahead of the dispatcher, the + * way database.ts really wires it (see plugin-api-routes-auth.test.ts for + * that wiring itself). This file's own app has no auth in front of + * pluginApi.default on purpose, to test the dispatcher in isolation, so + * the per-user identity is injected the same minimal way here. + */ + function fetchAsUser(path: string, userId: string) { + return fetch(`${baseUrl}${path}`, { + headers: { "x-test-user-id": userId }, + }); + } + + it( + "two concurrent requests as different users each see only their own hosts", + async () => { + await activateWithHostsRoute(); + + // Reopen the server with a stand-in auth middleware that reads the + // test header, since the beforeEach server has none. + await new Promise((resolve) => server?.close(() => resolve())); + const app = express(); + app.use((req, _res, next) => { + const userId = req.headers["x-test-user-id"]; + if (typeof userId === "string") { + (req as typeof req & { userId?: string }).userId = userId; + } + next(); + }); + app.use("/plugin-api", pluginApi.default); + server = await new Promise((resolve) => { + const created = app.listen(0, "127.0.0.1", () => resolve(created)); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + const [aliceRes, bobRes] = await Promise.all([ + fetchAsUser("/plugin-api/sample-plugin/my-hosts", "alice"), + fetchAsUser("/plugin-api/sample-plugin/my-hosts", "bob"), + ]); + + expect(await aliceRes.json()).toMatchObject({ + hosts: [{ id: 1, name: "alice-host" }], + }); + expect(await bobRes.json()).toMatchObject({ + hosts: [{ id: 2, name: "bob-host" }], + }); + }, + WORKER_TIMEOUT, + ); + + it( + "falls back to the install owner when a call has no inbound request", + async () => { + const fixture = createFixturePlugin({ + backendSource: ` + export async function activate(ctx) { + const hosts = await ctx.hosts.list(); + await ctx.log.info(JSON.stringify({ hosts })); + } + `, + }); + fixtures.push(fixture); + + const hostsByUser: Record = { + "install-owner": [{ id: 9, name: "owner-host" }], + }; + + broker = new PluginBroker({ + listHosts: async (userId) => hostsByUser[userId] ?? [], + resolveHost: async () => null, + }); + + const logged: string[] = []; + const { pluginLogger } = await import("../../utils/logger.js"); + vi.mocked(pluginLogger.info).mockImplementation((message: string) => { + logged.push(message); + }); + + loader = new PluginLoader({ + onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), + onWorkerGone: (plugin) => broker!.detach(plugin.id), + }); + + state.grants.push({ + pluginId: "sample-plugin", + capability: "hosts.read", + }); + invalidatePluginPermissionCache("sample-plugin"); + + await loader.load(fixture.dir); + await loader.activate("sample-plugin", "install-owner"); + + const line = logged.find((entry) => entry.startsWith("{")); + expect(line && JSON.parse(line)).toMatchObject({ + hosts: [{ id: 9, name: "owner-host" }], + }); + }, + WORKER_TIMEOUT, + ); + }); }); diff --git a/src/backend/utils/permission-catalog.ts b/src/backend/utils/permission-catalog.ts index f1ded2b53..f5f818d98 100644 --- a/src/backend/utils/permission-catalog.ts +++ b/src/backend/utils/permission-catalog.ts @@ -59,6 +59,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [ "admin.roles.manage", "admin.settings.manage", "admin.sessions.manage", + "admin.plugins.manage", ], }, ]; diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 75a90cda8..97c5f0a4e 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -21,6 +21,10 @@ export interface PluginSummary { runtimeState: string; lastError: string | null; contributes: PluginContributions | null; + /** Capabilities this plugin's manifest declares it may ask for. */ + permissions: string[]; + /** The subset of `permissions` an admin has actually granted. */ + grantedCapabilities: string[]; } export async function getPlugins(): Promise { @@ -36,3 +40,21 @@ export async function setPluginEnabled( enabled, }); } + +export async function grantPluginCapability( + pluginId: string, + capability: string, +): Promise { + await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, { + capability, + }); +} + +export async function revokePluginCapability( + pluginId: string, + capability: string, +): Promise { + await rbacApi.delete( + `/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`, + ); +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 52f47a8a2..a9204eeb5 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -3984,7 +3984,37 @@ "pluginToggleFailed": "Failed to change the plugin", "pluginBuiltIn": "BUILT IN", "pluginEnabled": "{{name}} enabled", - "pluginDisabled": "{{name}} disabled" + "pluginDisabled": "{{name}} disabled", + "pluginPermissions": "Permissions", + "pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.", + "pluginPermissionGranted": "Granted", + "pluginPermissionNotGranted": "Not granted", + "pluginGrantFailed": "Failed to grant the capability", + "pluginRevokeFailed": "Failed to revoke the capability", + "pluginPermissionHostsRead": "Read hosts", + "pluginPermissionHostsReadDesc": "See the list of your hosts and their non-secret details.", + "pluginPermissionHostsWrite": "Modify hosts", + "pluginPermissionHostsWriteDesc": "Create, edit or delete hosts on your behalf.", + "pluginPermissionCredentialsUse": "Use stored credentials", + "pluginPermissionCredentialsUseDesc": "Connect using a saved credential without you retyping it.", + "pluginPermissionSshExec": "Run commands over SSH", + "pluginPermissionSshExecDesc": "Open a connection to a host and run commands on it.", + "pluginPermissionSshSftp": "Transfer files over SFTP", + "pluginPermissionSshSftpDesc": "Read or write files on a host over SFTP.", + "pluginPermissionStorageOwn": "Store its own data", + "pluginPermissionStorageOwnDesc": "Save and read back its own settings and data.", + "pluginPermissionStorageSecrets": "Store secrets", + "pluginPermissionStorageSecretsDesc": "Save and read back sensitive values it manages itself.", + "pluginPermissionNetworkOutbound": "Make outbound network requests", + "pluginPermissionNetworkOutboundDesc": "Contact services outside this server.", + "pluginPermissionEventsRead": "Subscribe to server events", + "pluginPermissionEventsReadDesc": "React to things happening elsewhere in Termix.", + "pluginPermissionNotifySend": "Send notifications", + "pluginPermissionNotifySendDesc": "Deliver a notification through your configured channels.", + "pluginPermissionUsersRead": "Read user accounts", + "pluginPermissionUsersReadDesc": "See basic details about accounts on this server.", + "pluginPermissionProcessSidecar": "Run a background process", + "pluginPermissionProcessSidecarDesc": "Start and manage its own helper process." }, "newUi": { "sidebar": { diff --git a/src/ui/sidebar/AdminPluginsSection.tsx b/src/ui/sidebar/AdminPluginsSection.tsx index 6fc452da6..f94317d27 100644 --- a/src/ui/sidebar/AdminPluginsSection.tsx +++ b/src/ui/sidebar/AdminPluginsSection.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Puzzle } from "lucide-react"; +import { Puzzle, ShieldCheck } from "lucide-react"; import { toast } from "sonner"; import { SettingRow } from "@/components/section-card"; +import { Button } from "@/components/button"; import { getPlugins, setPluginEnabled, @@ -10,6 +11,7 @@ import { } from "@/api/plugins-api"; import { refreshPluginState } from "@/shell/pluginLoader"; import { AccordionSection, AdminToggle } from "./AdminSettingsShared"; +import { PluginPermissionsDialog } from "./PluginPermissionsDialog"; export function AdminPluginsSection({ open, @@ -22,6 +24,11 @@ export function AdminPluginsSection({ const [plugins, setPlugins] = useState([]); const [busy, setBusy] = useState(null); const [loaded, setLoaded] = useState(false); + const [permissionsTargetId, setPermissionsTargetId] = useState( + null, + ); + const permissionsTarget = + plugins.find((plugin) => plugin.id === permissionsTargetId) ?? null; const load = useCallback(async () => { try { @@ -57,40 +64,64 @@ export function AdminPluginsSection({ }; return ( - } - open={open} - onToggle={onToggle} - > - {plugins.length === 0 ? ( -

- {loaded ? t("admin.pluginsNone") : t("common.loading")} -

- ) : ( - plugins.map((plugin) => ( - - void toggle(plugin)} - disabled={busy === plugin.id} - /> - - )) - )} -
+ <> + } + open={open} + onToggle={onToggle} + > + {plugins.length === 0 ? ( +

+ {loaded ? t("admin.pluginsNone") : t("common.loading")} +

+ ) : ( + plugins.map((plugin) => ( + +
+ {plugin.permissions.length > 0 && ( + + )} + void toggle(plugin)} + disabled={busy === plugin.id} + /> +
+
+ )) + )} +
+ + { + if (!next) setPermissionsTargetId(null); + }} + onChanged={() => void load()} + /> + ); } diff --git a/src/ui/sidebar/PluginPermissionsDialog.tsx b/src/ui/sidebar/PluginPermissionsDialog.tsx new file mode 100644 index 000000000..aa116c137 --- /dev/null +++ b/src/ui/sidebar/PluginPermissionsDialog.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { ShieldCheck } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/dialog"; +import { + grantPluginCapability, + revokePluginCapability, + type PluginSummary, +} from "@/api/plugins-api"; +import { AdminToggle } from "./AdminSettingsShared"; + +/** + * Every permission a plugin's manifest can declare, with the label and + * one-line description shown here. process:transport-owner is left out on + * purpose: it is reserved for first-party plugins and enforced by a hardcoded + * allowlist, not something an admin grants, so showing a toggle for it would + * be misleading. ui.* permissions describe what a plugin contributes to the + * shell rather than a capability the broker gates, but they are still shown + * here since the manifest can declare them and a plugin's grant list should + * not silently hide part of what it asked for. + */ +const LABELED_PERMISSIONS: Array<{ + capability: string; + labelKey: string; + descriptionKey: string; +}> = [ + { + capability: "hosts.read", + labelKey: "admin.pluginPermissionHostsRead", + descriptionKey: "admin.pluginPermissionHostsReadDesc", + }, + { + capability: "hosts.write", + labelKey: "admin.pluginPermissionHostsWrite", + descriptionKey: "admin.pluginPermissionHostsWriteDesc", + }, + { + capability: "credentials.use", + labelKey: "admin.pluginPermissionCredentialsUse", + descriptionKey: "admin.pluginPermissionCredentialsUseDesc", + }, + { + capability: "ssh.exec", + labelKey: "admin.pluginPermissionSshExec", + descriptionKey: "admin.pluginPermissionSshExecDesc", + }, + { + capability: "ssh.sftp", + labelKey: "admin.pluginPermissionSshSftp", + descriptionKey: "admin.pluginPermissionSshSftpDesc", + }, + { + capability: "storage.own", + labelKey: "admin.pluginPermissionStorageOwn", + descriptionKey: "admin.pluginPermissionStorageOwnDesc", + }, + { + capability: "storage.secrets", + labelKey: "admin.pluginPermissionStorageSecrets", + descriptionKey: "admin.pluginPermissionStorageSecretsDesc", + }, + { + capability: "network.outbound", + labelKey: "admin.pluginPermissionNetworkOutbound", + descriptionKey: "admin.pluginPermissionNetworkOutboundDesc", + }, + { + capability: "events.read", + labelKey: "admin.pluginPermissionEventsRead", + descriptionKey: "admin.pluginPermissionEventsReadDesc", + }, + { + capability: "notify.send", + labelKey: "admin.pluginPermissionNotifySend", + descriptionKey: "admin.pluginPermissionNotifySendDesc", + }, + { + capability: "users.read", + labelKey: "admin.pluginPermissionUsersRead", + descriptionKey: "admin.pluginPermissionUsersReadDesc", + }, + { + capability: "process.sidecar", + labelKey: "admin.pluginPermissionProcessSidecar", + descriptionKey: "admin.pluginPermissionProcessSidecarDesc", + }, +]; + +export function PluginPermissionsDialog({ + plugin, + open, + onOpenChange, + onChanged, +}: { + plugin: PluginSummary | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(null); + + if (!plugin) return null; + + const rows = LABELED_PERMISSIONS.filter((row) => + plugin.permissions.includes(row.capability), + ); + const granted = new Set(plugin.grantedCapabilities); + + const toggle = async (capability: string, isGranted: boolean) => { + setBusy(capability); + try { + if (isGranted) { + await revokePluginCapability(plugin.id, capability); + } else { + await grantPluginCapability(plugin.id, capability); + } + onChanged(); + } catch { + toast.error( + isGranted + ? t("admin.pluginRevokeFailed") + : t("admin.pluginGrantFailed"), + ); + } finally { + setBusy(null); + } + }; + + return ( + + + + + + {t("admin.pluginPermissions")} + + + {plugin.name} + + + +
+ {rows.length === 0 ? ( +

+ {t("admin.pluginPermissionsNone")} +

+ ) : ( +
+ {rows.map((row, i) => { + const isGranted = granted.has(row.capability); + return ( +
+
+ + {t(row.labelKey)} + + + {t(row.descriptionKey)} + + + {isGranted + ? t("admin.pluginPermissionGranted") + : t("admin.pluginPermissionNotGranted")} + +
+ void toggle(row.capability, isGranted)} + disabled={busy === row.capability} + /> +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/ui/tests/shell/pluginLoader.test.ts b/src/ui/tests/shell/pluginLoader.test.ts index b9f89a10e..2c84fb562 100644 --- a/src/ui/tests/shell/pluginLoader.test.ts +++ b/src/ui/tests/shell/pluginLoader.test.ts @@ -43,6 +43,8 @@ function plugin(overrides: Partial = {}): PluginSummary { }, ], }, + permissions: [], + grantedCapabilities: [], ...overrides, }; }