feat: add a permission-gated backend service registry for plugins

This commit is contained in:
LukeGus committed 2026-09-21 16:23:00 -05:00
1 parent ee2d6b4697
commit 225f618337
17 files changed
+1755 -2

No files matched your search

@@ -53,6 +53,103 @@ describe("plugin manifest validation", () => {
);
});
describe("provides / requires", () => {
const permissionGroup = {
group: "testplugin",
permissions: ["testplugin.greet.use"],
};
it("accepts a declared service and requirement", () => {
const errors = validateManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.greet.use",
},
],
requires: [
{ service: "other.thing", versionRange: "^1.0.0", optional: true },
],
contributes: { permissionGroup },
}),
);
expect(errors).toEqual([]);
});
it("rejects a service name that is not dotted", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true);
});
it("rejects a non-semver service version", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "a.b", version: "1.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true);
});
it("rejects a duplicate provided service", () => {
const errors = validateManifest(
validManifest({
provides: [
{ service: "a.b", version: "1.0.0", permission: "x.y" },
{ service: "a.b", version: "2.0.0", permission: "x.y" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("rejects an invalid semver range", () => {
const errors = validateManifest(
validManifest({
requires: [{ service: "a.b", versionRange: "not a range" }],
}),
);
expect(errors.some((e) => e.includes("versionRange"))).toBe(true);
});
it("rejects a non-boolean optional", () => {
const errors = validateManifest(
validManifest({
requires: [
{ service: "a.b", versionRange: "^1.0.0", optional: "yes" },
],
}),
);
expect(errors.some((e) => e.includes("optional"))).toBe(true);
});
it("refuses a service gated by a permission the group never declares", () => {
// Otherwise every call through it would deny against a permission no
// admin could ever grant.
const { manifest, errors } = parseManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.undeclared",
},
],
contributes: { permissionGroup },
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("testplugin.undeclared");
expect(errors[0]).toContain("permissionGroup");
});
});
it("rejects an unknown permission", () => {
const errors = validateManifest(
validManifest({ permissions: ["credentials.read"] }),
@@ -0,0 +1,342 @@
/**
* Two real plugins on disk, one providing a service and one requiring it,
* driven through the actual PluginLoader.
*
* The ids are the real first-party ones because the in-process allowlist is
* hardcoded and deliberately unfakeable -- the same thing in-process-loader
* does. The service ("testplugin.greet") and the permission gating it
* ("testplugin.greet.use") are fixture-owned, so nothing about the shipped
* ssh-terminal or docker plugins is under test here; only their ids are
* borrowed to reach the in-process tier.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import type { Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
/** Permissions the acting user currently holds. Mutated mid-test. */
granted: new Set<string>(),
auditEntries: [] as Record<string, unknown>[],
/** Records every delegated call, to prove the gate runs before the provider. */
providerCalls: [] as string[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async (params: Record<string, unknown>) => {
state.auditEntries.push(params);
}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_userId: string, permission: string) =>
state.granted.has(permission),
invalidateUserPermissionCache: vi.fn(),
}),
},
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { TRANSPORT_OWNER_CAPABILITY } =
await import("../../plugins/first-party.js");
const {
clearServiceRegistry,
getRegistration,
PluginServicePermissionError,
PluginServiceUnavailableError,
} = await import("../../plugins/service-registry.js");
const { pluginEvents } = await import("../../plugins/events.js");
const { createFixturePlugin } = await import("./fixture-plugin.js");
const SERVICE = "testplugin.greet";
const PERMISSION = "testplugin.greet.use";
/**
* The provider publishes a greet service and records every delegated call to
* a file, so the test can prove a denied call never reached it. A module-level
* variable would not work: the plugin is imported by URL into this process and
* lives in its own ESM module instance.
*/
function providerSource(callLog: string): string {
const literal = JSON.stringify(callLog);
return `
import fs from "node:fs";
export async function activate(ctx) {
ctx.services.provide(${JSON.stringify(SERVICE)}, {
hello: async (name) => {
fs.appendFileSync(${literal}, name + "\\n");
return "hello " + name;
},
});
}
`;
}
/**
* The consumer takes a handle at activation and stashes it on globalThis so
* the test can call it as a specific user afterwards.
*/
const CONSUMER_SOURCE = `
export async function activate(ctx) {
globalThis.__consumerHandle = ctx.services.get(${JSON.stringify(SERVICE)});
}
`;
function providerFixture(): Fixture {
return createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: {
category: "Terminal",
provides: [
{ service: SERVICE, version: "1.2.0", permission: PERMISSION },
],
contributes: {
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
},
},
});
}
function consumerFixture(
requires: Array<Record<string, unknown>> = [
{ service: SERVICE, versionRange: "^1.2.0" },
],
): Fixture {
return createFixturePlugin({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requires },
});
}
type GreetHandle = {
hello: (name: string) => Promise<string>;
asUser: (userId: string) => { hello: (name: string) => Promise<string> };
};
function handle(): GreetHandle {
return (globalThis as Record<string, unknown>)
.__consumerHandle as GreetHandle;
}
describe("plugin service contracts", () => {
let provider: Fixture | null = null;
let consumer: Fixture | null = null;
let loader: InstanceType<typeof PluginLoader> | null = null;
let callLog = "";
beforeEach(() => {
state.granted = new Set([PERMISSION]);
state.auditEntries = [];
state.providerCalls = [];
clearServiceRegistry();
pluginEvents.clear();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
provider?.cleanup();
consumer?.cleanup();
provider = null;
consumer = null;
delete (globalThis as Record<string, unknown>).__consumerHandle;
clearServiceRegistry();
pluginEvents.clear();
});
async function activateBoth(
consumerRequires?: Array<Record<string, unknown>>,
) {
provider = providerFixture();
callLog = path.join(provider.root, "calls.txt");
fs.writeFileSync(
path.join(provider.dir, "backend", "index.mjs"),
providerSource(callLog),
);
consumer = consumerRequires
? consumerFixture(consumerRequires)
: consumerFixture();
loader = new PluginLoader();
const providerPlugin = await loader.load(provider.dir);
await loader.activate(providerPlugin.id);
const consumerPlugin = await loader.load(consumer.dir);
await loader.activate(consumerPlugin.id);
return { providerPlugin, consumerPlugin };
}
function providerCallCount(): number {
if (!fs.existsSync(callLog)) return 0;
return fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
.length;
}
it("resolves structurally when both plugins are active", async () => {
const { providerPlugin, consumerPlugin } = await activateBoth();
expect(providerPlugin.state).toBe("active");
expect(consumerPlugin.state).toBe("active");
const registration = getRegistration(SERVICE);
expect(registration?.version).toBe("1.2.0");
expect(registration?.pluginId).toBe("ssh-terminal");
expect(registration?.permission).toBe(PERMISSION);
});
it("fails activation when a hard requirement is unsatisfied", async () => {
consumer = consumerFixture([{ service: SERVICE, versionRange: "^9.0.0" }]);
loader = new PluginLoader();
const plugin = await loader.load(consumer.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow(
/no active plugin provides|provides 1\.2\.0/,
);
expect(plugin.state).toBe("crashed");
});
it("activates anyway when an unsatisfied requirement is optional", async () => {
consumer = consumerFixture([
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
]);
loader = new PluginLoader();
const plugin = await loader.load(consumer.dir);
await loader.activate(plugin.id);
expect(plugin.state).toBe("active");
});
it("allows a call from a user who holds the permission", async () => {
await activateBoth();
await expect(handle().asUser("alice").hello("world")).resolves.toBe(
"hello world",
);
expect(providerCallCount()).toBe(1);
const entry = state.auditEntries.at(-1);
expect(entry?.success).toBe(true);
expect(entry?.username).toBe("plugin:docker");
expect(entry?.userId).toBe("alice");
expect(entry?.action).toBe("plugin_service_testplugin_greet_hello");
expect(entry?.resourceId).toBe("ssh-terminal");
});
it("denies a user without the permission, with the standard shape", async () => {
await activateBoth();
state.granted.delete(PERMISSION);
await handle()
.asUser("mallory")
.hello("world")
.then(
() => expect.unreachable("should have been denied"),
(error: InstanceType<typeof PluginServicePermissionError>) => {
expect(error.name).toBe("PluginServicePermissionError");
expect(error.status).toBe(403);
expect(error.body).toEqual({
error: "Insufficient permissions",
required: PERMISSION,
});
expect(error.message).toContain("do not have access");
},
);
// The gate runs before delegation: the provider never saw the call.
expect(providerCallCount()).toBe(0);
const entry = state.auditEntries.at(-1);
expect(entry?.success).toBe(false);
expect(entry?.userId).toBe("mallory");
expect(String(entry?.errorMessage)).toContain(PERMISSION);
});
it("honours a revoke mid-session on the very next call", async () => {
await activateBoth();
const live = handle().asUser("alice");
await expect(live.hello("first")).resolves.toBe("hello first");
// No restart, no reinstall, same handle: the check is per call, which is
// exactly why it lives in the proxy rather than at handle creation.
state.granted.delete(PERMISSION);
await expect(live.hello("second")).rejects.toThrow(
PluginServicePermissionError,
);
expect(providerCallCount()).toBe(1);
// And granting it back takes effect just as immediately.
state.granted.add(PERMISSION);
await expect(live.hello("third")).resolves.toBe("hello third");
expect(providerCallCount()).toBe(2);
});
it("surfaces a clear error once the provider is deactivated, without tearing the consumer down", async () => {
const { consumerPlugin } = await activateBoth();
const live = handle().asUser("alice");
await expect(live.hello("before")).resolves.toBe("hello before");
await loader!.deactivate("ssh-terminal");
expect(getRegistration(SERVICE)).toBeUndefined();
// Documented behaviour: the consumer stays active and decides for itself
// what to do. Deactivating one plugin must not silently stop others.
expect(consumerPlugin.state).toBe("active");
let thrown: unknown;
try {
await live.hello("after");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(PluginServiceUnavailableError);
expect((thrown as { code: string }).code).toBe("EPLUGINSVCGONE");
expect((thrown as Error).message).toContain(SERVICE);
expect(providerCallCount()).toBe(1);
});
it("refuses to provide a service the manifest never declared", async () => {
provider = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
backendSource: `
export async function activate(ctx) {
ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" });
}
`,
manifestOverrides: { category: "Terminal" },
});
loader = new PluginLoader();
const plugin = await loader.load(provider.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow(
/not declared in the manifest's provides array/,
);
});
});
@@ -0,0 +1,284 @@
/**
* Unit coverage for the declared service registry: registration, versioned
* structural resolution, and the per-call permission gate on a handle.
*
* The two-fixture end-to-end test lives in service-contract.test.ts.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const {
clearServiceRegistry,
createServiceHandle,
getRegistration,
listServices,
provideService,
resolveRequirements,
revokeService,
PluginServiceActorError,
PluginServicePermissionError,
PluginServiceUnavailableError,
} = await import("../../plugins/service-registry.js");
type Registration = ReturnType<typeof provideService>;
function register(
overrides: Partial<Parameters<typeof provideService>[0]> = {},
): Registration {
return provideService({
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.greet.use",
pluginId: "provider-plugin",
pluginName: "Provider Plugin",
implementation: { hello: async (name: string) => `hello ${name}` },
...overrides,
});
}
function manifestRequiring(
requires: Array<{
service: string;
versionRange: string;
optional?: boolean;
}>,
) {
return { id: "consumer-plugin", requires } as never;
}
describe("service registry", () => {
beforeEach(() => clearServiceRegistry());
afterEach(() => clearServiceRegistry());
it("round-trips a registration", () => {
const registration = register();
expect(getRegistration("testplugin.greet")).toBe(registration);
expect(listServices()).toHaveLength(1);
expect(registration.generation).toBeGreaterThan(0);
});
it("only revokes the registration that is actually current", () => {
const first = register();
const second = register();
// A crashed-and-restarted provider must not revoke the replacement its own
// restart installed.
expect(revokeService("testplugin.greet", first)).toBe(false);
expect(getRegistration("testplugin.greet")).toBe(second);
expect(revokeService("testplugin.greet", second)).toBe(true);
expect(getRegistration("testplugin.greet")).toBeUndefined();
});
it("reports a revoke of something never registered", () => {
expect(revokeService("nothing.here")).toBe(false);
});
describe("resolution", () => {
it("is satisfied when the version is in range", () => {
register({ version: "1.2.0" });
const resolution = resolveRequirements(
manifestRequiring([
{ service: "testplugin.greet", versionRange: "^1.2.0" },
]),
);
expect(resolution.satisfied).toBe(true);
expect(resolution.errors).toEqual([]);
});
it("fails a hard requirement nothing provides", () => {
const resolution = resolveRequirements(
manifestRequiring([
{ service: "testplugin.greet", versionRange: "^1.0.0" },
]),
);
expect(resolution.satisfied).toBe(false);
expect(resolution.errors[0]).toContain("no active plugin provides");
});
it("fails when the provided version is out of range", () => {
register({ version: "2.0.0" });
const resolution = resolveRequirements(
manifestRequiring([
{ service: "testplugin.greet", versionRange: "^1.0.0" },
]),
);
expect(resolution.satisfied).toBe(false);
expect(resolution.errors[0]).toContain("provides 2.0.0");
});
it("skips an unsatisfied optional requirement", () => {
const resolution = resolveRequirements(
manifestRequiring([
{
service: "testplugin.greet",
versionRange: "^1.0.0",
optional: true,
},
]),
);
expect(resolution.satisfied).toBe(true);
expect(resolution.missingOptional).toEqual(["testplugin.greet"]);
});
it("treats a manifest with no requires as satisfied", () => {
expect(resolveRequirements({ id: "x" } as never).satisfied).toBe(true);
});
});
describe("handle", () => {
function handleFor(allowed: boolean, ...actor: [] | [string | undefined]) {
const userId = actor.length === 0 ? "user-1" : actor[0];
const audits: Array<Record<string, unknown>> = [];
const handle = createServiceHandle<{
hello: (name: string) => Promise<string>;
}>("testplugin.greet", "consumer-plugin", {
resolveUserId: () => userId,
hasPermission: async () => allowed,
audit: (entry) => void audits.push(entry as never),
});
return { handle, audits };
}
it("delegates to the implementation when permitted", async () => {
register();
const { handle, audits } = handleFor(true);
await expect(handle.hello("world")).resolves.toBe("hello world");
expect(audits).toHaveLength(1);
expect(audits[0].success).toBe(true);
});
it("denies without the permission and never reaches the provider", async () => {
const hello = vi.fn(async () => "should not run");
register({ implementation: { hello } });
const { handle, audits } = handleFor(false);
await expect(handle.hello("world")).rejects.toThrow(
PluginServicePermissionError,
);
// The gate has to run before delegation, not after.
expect(hello).not.toHaveBeenCalled();
expect(audits[0].success).toBe(false);
});
it("denies with the same 403 shape requirePermission sends", async () => {
register();
const { handle } = handleFor(false);
await handle.hello("world").then(
() => expect.unreachable("should have denied"),
(error: InstanceType<typeof PluginServicePermissionError>) => {
expect(error.status).toBe(403);
expect(error.body).toEqual({
error: "Insufficient permissions",
required: "testplugin.greet.use",
});
},
);
});
it("refuses a call it cannot attribute to a user", async () => {
register();
const { handle } = handleFor(true, undefined);
await expect(handle.hello("world")).rejects.toThrow(
PluginServiceActorError,
);
});
it("throws once the provider is revoked", async () => {
const registration = register();
const { handle } = handleFor(true);
await expect(handle.hello("world")).resolves.toBe("hello world");
revokeService("testplugin.greet", registration);
expect(() => handle.hello("world")).toThrow(
/no longer provided|is not a function/,
);
});
it("exposes only the functions the provider handed out", () => {
register({
implementation: {
hello: async () => "hi",
secret: "not a function",
},
});
const { handle } = handleFor(true);
expect(typeof handle.hello).toBe("function");
expect(
(handle as unknown as Record<string, unknown>).secret,
).toBeUndefined();
expect(Object.keys(handle)).toEqual(["hello"]);
});
it("rebinds the acting user with asUser", async () => {
register();
const seen: string[] = [];
const handle = createServiceHandle<{
hello: (name: string) => Promise<string>;
asUser: (userId: string) => { hello: (n: string) => Promise<string> };
}>("testplugin.greet", "consumer-plugin", {
resolveUserId: () => undefined,
hasPermission: async (userId) => {
seen.push(userId);
return true;
},
audit: () => {},
});
await handle.asUser("user-42").hello("world");
expect(seen).toEqual(["user-42"]);
});
it("surfaces an unavailable service rather than a generic undefined", () => {
const { handle } = handleFor(true);
expect(
(handle as unknown as Record<string, unknown>).hello,
).toBeUndefined();
});
it("audits a failure thrown by the provider itself", async () => {
register({
implementation: {
hello: async () => {
throw new Error("provider exploded");
},
},
});
const { handle, audits } = handleFor(true);
await expect(handle.hello("world")).rejects.toThrow("provider exploded");
expect(audits[0].success).toBe(false);
expect(audits[0].errorMessage).toBe("provider exploded");
});
});
it("exposes the unavailable error with a stable code", () => {
const error = new PluginServiceUnavailableError("a.b", "provider");
expect(error.code).toBe("EPLUGINSVCGONE");
expect(error.message).toContain("a.b");
expect(error.message).toContain("provider");
});
});