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:
LukeGus committed 2026-09-21 14:32:59 -05:00
1 parent 19b70430a1
commit c58ccbfc3f
16 files changed
+1317 -78

No files matched your search

@@ -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,
);
});
});