mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 21:32:38 +00:00
fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes behind admin.plugins.manage, and thread the calling user through worker ctx.hosts/ctx.ssh calls instead of always using the install owner. Also adds the plugin permissions grant/revoke UI.
This commit is contained in:
1 parent
19b70430a1
commit
c58ccbfc3f
16 files changed
+1317
-78
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
@@ -216,7 +216,7 @@ export class PluginBroker {
|
||||
runtime: PluginRuntime,
|
||||
request: PluginRequest,
|
||||
): Promise<void> {
|
||||
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<unknown> {
|
||||
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<PluginHostView[]> {
|
||||
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<PluginHostView | null> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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}`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
const plugin = this.requirePlugin(pluginId);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, (req: unknown) => unknown>();
|
||||
const eventListeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
const timers = new Map<string, () => 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<unknown> {
|
||||
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<void> {
|
||||
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({
|
||||
|
||||
@@ -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<string, unknown> & { headers: Record<string, string> },
|
||||
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<typeof PluginLoader> | null = null;
|
||||
let broker: InstanceType<typeof PluginBroker> | 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<Server>((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<void>((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,
|
||||
);
|
||||
});
|
||||
@@ -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<string, PluginRow>(),
|
||||
grants: [] as { pluginId: string; capability: string; grantedBy: string }[],
|
||||
// Which users have admin.plugins.manage, keyed by userId.
|
||||
managers: new Set<string>(["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<string, unknown> & { headers: Record<string, string> },
|
||||
_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<string, unknown>,
|
||||
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<PluginRow>) => {
|
||||
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> = {}): 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<Server>((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<void>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, { id: number; name: string }[]> = {
|
||||
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<void>((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<string, { id: number; name: string }[]> = {
|
||||
"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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
"admin.roles.manage",
|
||||
"admin.settings.manage",
|
||||
"admin.sessions.manage",
|
||||
"admin.plugins.manage",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<PluginSummary[]> {
|
||||
@@ -36,3 +40,21 @@ export async function setPluginEnabled(
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantPluginCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
): Promise<void> {
|
||||
await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, {
|
||||
capability,
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokePluginCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
): Promise<void> {
|
||||
await rbacApi.delete(
|
||||
`/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`,
|
||||
);
|
||||
}
|
||||
+31
-1
@@ -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": {
|
||||
|
||||
@@ -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<PluginSummary[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [permissionsTargetId, setPermissionsTargetId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const permissionsTarget =
|
||||
plugins.find((plugin) => plugin.id === permissionsTargetId) ?? null;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -57,40 +64,64 @@ export function AdminPluginsSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.plugins")}
|
||||
icon={<Puzzle className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{plugins.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-3">
|
||||
{loaded ? t("admin.pluginsNone") : t("common.loading")}
|
||||
</p>
|
||||
) : (
|
||||
plugins.map((plugin) => (
|
||||
<SettingRow
|
||||
key={plugin.id}
|
||||
label={plugin.name}
|
||||
badge={
|
||||
plugin.tier === "first-party"
|
||||
? t("admin.pluginBuiltIn")
|
||||
: undefined
|
||||
}
|
||||
description={
|
||||
plugin.lastError
|
||||
? plugin.lastError
|
||||
: `v${plugin.version} · ${plugin.runtimeState}`
|
||||
}
|
||||
>
|
||||
<AdminToggle
|
||||
on={plugin.enabled}
|
||||
onToggle={() => void toggle(plugin)}
|
||||
disabled={busy === plugin.id}
|
||||
/>
|
||||
</SettingRow>
|
||||
))
|
||||
)}
|
||||
</AccordionSection>
|
||||
<>
|
||||
<AccordionSection
|
||||
label={t("admin.plugins")}
|
||||
icon={<Puzzle className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{plugins.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-3">
|
||||
{loaded ? t("admin.pluginsNone") : t("common.loading")}
|
||||
</p>
|
||||
) : (
|
||||
plugins.map((plugin) => (
|
||||
<SettingRow
|
||||
key={plugin.id}
|
||||
label={plugin.name}
|
||||
badge={
|
||||
plugin.tier === "first-party"
|
||||
? t("admin.pluginBuiltIn")
|
||||
: undefined
|
||||
}
|
||||
description={
|
||||
plugin.lastError
|
||||
? plugin.lastError
|
||||
: `v${plugin.version} · ${plugin.runtimeState}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{plugin.permissions.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-none h-7 px-2 text-[10px] font-bold uppercase tracking-widest"
|
||||
onClick={() => setPermissionsTargetId(plugin.id)}
|
||||
>
|
||||
<ShieldCheck className="size-3" />
|
||||
{t("admin.pluginPermissions")}
|
||||
</Button>
|
||||
)}
|
||||
<AdminToggle
|
||||
on={plugin.enabled}
|
||||
onToggle={() => void toggle(plugin)}
|
||||
disabled={busy === plugin.id}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
))
|
||||
)}
|
||||
</AccordionSection>
|
||||
|
||||
<PluginPermissionsDialog
|
||||
plugin={permissionsTarget}
|
||||
open={permissionsTargetId !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setPermissionsTargetId(null);
|
||||
}}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 text-accent-brand" />
|
||||
{t("admin.pluginPermissions")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground">
|
||||
{plugin.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-3">
|
||||
{t("admin.pluginPermissionsNone")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="border border-border overflow-hidden">
|
||||
{rows.map((row, i) => {
|
||||
const isGranted = granted.has(row.capability);
|
||||
return (
|
||||
<div
|
||||
key={row.capability}
|
||||
className={`flex items-center justify-between gap-3 px-3 py-3 ${
|
||||
i < rows.length - 1 ? "border-b border-border" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold">
|
||||
{t(row.labelKey)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(row.descriptionKey)}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-0.5">
|
||||
{isGranted
|
||||
? t("admin.pluginPermissionGranted")
|
||||
: t("admin.pluginPermissionNotGranted")}
|
||||
</span>
|
||||
</div>
|
||||
<AdminToggle
|
||||
on={isGranted}
|
||||
onToggle={() => void toggle(row.capability, isGranted)}
|
||||
disabled={busy === row.capability}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,8 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
},
|
||||
],
|
||||
},
|
||||
permissions: [],
|
||||
grantedCapabilities: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user