mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: collapse plugin runtime to one tier behind a plugin SDK
This commit is contained in:
1 parent
6e058f6e9e
commit
4758016f41
96 files changed
+34631
-8551
No files matched your search
@@ -1,20 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
* /plugin-api is mounted behind authenticateJWT in database.ts, the same way
|
||||
* every other router is gated, so an unauthenticated caller can never reach
|
||||
* plugin code.
|
||||
*
|
||||
* The dispatcher itself has no routes registered in A1: the worker HTTP
|
||||
* bridge was removed with the worker tier and A4 rebuilds routing on top of
|
||||
* the SDK. What still has to hold, and what this covers, is that the auth
|
||||
* gate sits in front of the mount and that an unknown plugin id is a 404
|
||||
* rather than a crash.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, 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",
|
||||
@@ -22,13 +21,6 @@ const state = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
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(),
|
||||
@@ -38,133 +30,108 @@ vi.mock("../../../utils/logger.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
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;
|
||||
/** Stands in for AuthManager's middleware, with the same 401 behaviour. */
|
||||
function authenticateJWT(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): void {
|
||||
const header = req.headers.authorization;
|
||||
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
|
||||
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 = "";
|
||||
if (token !== state.validToken) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
}
|
||||
|
||||
const app = express();
|
||||
// Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes).
|
||||
app.use("/plugin-api", authenticateJWT, pluginApi.default);
|
||||
let server: Server | null = null;
|
||||
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
async function startServer(): Promise<string> {
|
||||
const app = express();
|
||||
// The same order database.ts uses: the gate, then the dispatcher.
|
||||
app.use("/plugin-api", authenticateJWT, pluginApi.default);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server!.address() as AddressInfo;
|
||||
resolve(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
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()));
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||
server = null;
|
||||
while (fixtures.length) fixtures.pop()!.cleanup();
|
||||
}
|
||||
for (const id of pluginApi.getRegisteredPluginIds()) {
|
||||
pluginApi.unregisterPluginRouter(id);
|
||||
}
|
||||
});
|
||||
|
||||
describe("/plugin-api authentication", () => {
|
||||
it("rejects a request with no token", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
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 the wrong token", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
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,
|
||||
);
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: "Bearer nope" },
|
||||
});
|
||||
|
||||
it(
|
||||
"forwards the verified userId once authenticated",
|
||||
async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
|
||||
headers: { authorization: `Bearer ${state.validToken}` },
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
// Past the gate, an id nothing registered is a plain 404. The auth check
|
||||
// runs first either way, so a caller cannot use the difference between 401
|
||||
// and 404 to find out which plugins are installed.
|
||||
it("returns 404 for an unregistered plugin id when authenticated", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("dispatches to a registered router only when authenticated", async () => {
|
||||
const router = express.Router();
|
||||
router.get("/thing", (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
userId: (req as express.Request & { userId?: string }).userId,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ userId: state.userId });
|
||||
},
|
||||
WORKER_TIMEOUT,
|
||||
);
|
||||
});
|
||||
pluginApi.registerPluginRouter("sample-plugin", router);
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const unauthorized = await fetch(`${base}/plugin-api/sample-plugin/thing`);
|
||||
expect(unauthorized.status).toBe(401);
|
||||
|
||||
const authorized = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
||||
});
|
||||
|
||||
expect(authorized.status).toBe(200);
|
||||
// The user id the plugin sees is the one the gate verified, never one the
|
||||
// caller supplied.
|
||||
expect(await authorized.json()).toEqual({
|
||||
ok: true,
|
||||
userId: state.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,12 +17,18 @@ interface PluginRow {
|
||||
tier: string;
|
||||
source: string;
|
||||
state: string;
|
||||
lastError?: string | null;
|
||||
manifestJson: string;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
plugins: new Map<string, PluginRow>(),
|
||||
grants: [] as { pluginId: string; capability: string; grantedBy: string }[],
|
||||
grants: [] as {
|
||||
pluginId: string;
|
||||
capability: string;
|
||||
grantedBy: string | null;
|
||||
source?: string;
|
||||
}[],
|
||||
// Which users have admin.plugins.manage, keyed by userId.
|
||||
managers: new Set<string>(["admin-1"]),
|
||||
}));
|
||||
@@ -62,6 +68,9 @@ vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
// The list route asks directly rather than gating the whole mount, so
|
||||
// it can return a narrower shape to a non-admin instead of a 403.
|
||||
hasPermission: async (userId: string) => state.managers.has(userId),
|
||||
requirePermission:
|
||||
(_permission: string) =>
|
||||
(
|
||||
@@ -113,7 +122,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
grant: async (input: {
|
||||
pluginId: string;
|
||||
capability: string;
|
||||
grantedBy: string;
|
||||
grantedBy: string | null;
|
||||
source?: string;
|
||||
}) => {
|
||||
state.grants.push(input);
|
||||
return input;
|
||||
@@ -140,7 +150,7 @@ function makePlugin(overrides: Partial<PluginRow> = {}): PluginRow {
|
||||
source: "community",
|
||||
state: "enabled",
|
||||
manifestJson: JSON.stringify({
|
||||
permissions: ["hosts.read", "storage.own"],
|
||||
capabilities: ["hosts:read", "kv:own"],
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
@@ -170,11 +180,11 @@ describe("plugins route", () => {
|
||||
server = null;
|
||||
});
|
||||
|
||||
it("lists a plugin's declared permissions and granted capabilities", async () => {
|
||||
it("lists a plugin's declared and granted capabilities", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
pluginId: "sample-plugin",
|
||||
capability: "hosts.read",
|
||||
capability: "hosts:read",
|
||||
grantedBy: "admin-1",
|
||||
});
|
||||
|
||||
@@ -184,8 +194,8 @@ describe("plugins route", () => {
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "sample-plugin",
|
||||
permissions: ["hosts.read", "storage.own"],
|
||||
grantedCapabilities: ["hosts.read"],
|
||||
capabilities: ["hosts:read", "kv:own"],
|
||||
grantedCapabilities: ["hosts:read"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -196,19 +206,19 @@ describe("plugins route", () => {
|
||||
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ capability: "hosts.read" }),
|
||||
body: JSON.stringify({ capability: "hosts:read" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
id: "sample-plugin",
|
||||
capability: "hosts.read",
|
||||
capability: "hosts:read",
|
||||
granted: true,
|
||||
});
|
||||
expect(state.grants).toEqual([
|
||||
{
|
||||
pluginId: "sample-plugin",
|
||||
capability: "hosts.read",
|
||||
capability: "hosts:read",
|
||||
grantedBy: "admin-1",
|
||||
},
|
||||
]);
|
||||
@@ -231,7 +241,7 @@ describe("plugins route", () => {
|
||||
const res = await fetch(`${baseUrl}/plugins/ghost/grants`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ capability: "hosts.read" }),
|
||||
body: JSON.stringify({ capability: "hosts:read" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
@@ -243,12 +253,12 @@ describe("plugins route", () => {
|
||||
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ capability: "hosts.read" }),
|
||||
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" }),
|
||||
body: JSON.stringify({ capability: "hosts:read" }),
|
||||
});
|
||||
|
||||
expect(state.grants).toHaveLength(1);
|
||||
@@ -258,19 +268,19 @@ describe("plugins route", () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
pluginId: "sample-plugin",
|
||||
capability: "hosts.read",
|
||||
capability: "hosts:read",
|
||||
grantedBy: "admin-1",
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
|
||||
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
id: "sample-plugin",
|
||||
capability: "hosts.read",
|
||||
capability: "hosts:read",
|
||||
granted: false,
|
||||
});
|
||||
expect(state.grants).toEqual([]);
|
||||
@@ -280,7 +290,7 @@ describe("plugins route", () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
|
||||
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
@@ -288,7 +298,7 @@ describe("plugins route", () => {
|
||||
});
|
||||
|
||||
describe("admin.plugins.manage gate", () => {
|
||||
it("a non-admin authenticated user cannot list plugins", async () => {
|
||||
it("lets a non-admin 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());
|
||||
@@ -300,6 +310,58 @@ describe("plugins route", () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// What a plugin may do, what it has been granted and why it failed are
|
||||
// operational details. The shell needs none of them to render a tab.
|
||||
it("hides capabilities, grants and lastError from a non-admin", async () => {
|
||||
state.plugins.set(
|
||||
"sample-plugin",
|
||||
makePlugin({ lastError: "port already in use" }),
|
||||
);
|
||||
state.grants.push({
|
||||
pluginId: "sample-plugin",
|
||||
capability: "hosts:read",
|
||||
grantedBy: "admin-1",
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins`, {
|
||||
headers: { "x-test-user-id": "regular-user" },
|
||||
});
|
||||
const [plugin] = await res.json();
|
||||
|
||||
expect(plugin).toEqual({
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
contributes: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the full record to a holder of admin.plugins.manage", async () => {
|
||||
state.plugins.set(
|
||||
"sample-plugin",
|
||||
makePlugin({ lastError: "port already in use" }),
|
||||
);
|
||||
state.grants.push({
|
||||
pluginId: "sample-plugin",
|
||||
capability: "hosts:read",
|
||||
grantedBy: "admin-1",
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins`, {
|
||||
headers: { "x-test-user-id": "admin-1" },
|
||||
});
|
||||
const [plugin] = await res.json();
|
||||
|
||||
expect(plugin).toMatchObject({
|
||||
id: "sample-plugin",
|
||||
capabilities: ["hosts:read", "kv:own"],
|
||||
grantedCapabilities: ["hosts:read"],
|
||||
lastError: "port already in use",
|
||||
});
|
||||
});
|
||||
|
||||
it("403s a non-admin enabling or disabling a plugin", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
@@ -325,7 +387,7 @@ describe("plugins route", () => {
|
||||
"content-type": "application/json",
|
||||
"x-test-user-id": "regular-user",
|
||||
},
|
||||
body: JSON.stringify({ capability: "hosts.read" }),
|
||||
body: JSON.stringify({ capability: "hosts:read" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
@@ -336,12 +398,12 @@ describe("plugins route", () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
pluginId: "sample-plugin",
|
||||
capability: "hosts.read",
|
||||
capability: "hosts:read",
|
||||
grantedBy: "admin-1",
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
|
||||
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "x-test-user-id": "regular-user" },
|
||||
|
||||
Reference in new issue
Block a user