Scope plugin config save/test by company

This commit is contained in:
Paperclip Bot 2026-06-03 13:15:30 +00:00
parent 4272b31136
commit 5317029ef4
6 changed files with 348 additions and 43 deletions

View file

@ -23,6 +23,11 @@ const mockLifecycle = vi.hoisted(() => ({
disable: vi.fn(),
}));
const mockWorkerManager = vi.hoisted(() => ({
call: vi.fn(),
isRunning: vi.fn(() => false),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
@ -437,6 +442,61 @@ describe.sequential("plugin install and upgrade authz", () => {
expect(res.body.configJson).toEqual({ botName: "company-a" });
}, 20_000);
it("rejects plugin config tests with secret refs when company scope is missing", async () => {
readyPlugin();
const { app } = await createApp(boardActor({
isInstanceAdmin: true,
companyIds: [companyA],
}), {}, {
bridgeDeps: { workerManager: mockWorkerManager },
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config/test`)
.send({
configJson: {
apiKeyRef: "77777777-7777-4777-8777-777777777777",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toMatch(/secret references require companyId/i);
expect(mockWorkerManager.call).not.toHaveBeenCalled();
}, 20_000);
it("passes company-scoped plugin config tests through when companyId is provided", async () => {
readyPlugin();
mockWorkerManager.call.mockResolvedValueOnce({ ok: true, warnings: [] });
const { app } = await createApp(boardActor({
isInstanceAdmin: true,
companyIds: [companyA],
}), {}, {
bridgeDeps: { workerManager: mockWorkerManager },
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config/test`)
.send({
companyId: companyA,
configJson: {
apiKeyRef: "77777777-7777-4777-8777-777777777777",
},
});
expect(res.status).toBe(200);
expect(mockWorkerManager.call).toHaveBeenCalledWith(
pluginId,
"validateConfig",
{
config: {
apiKeyRef: "77777777-7777-4777-8777-777777777777",
},
},
);
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({

View file

@ -2298,11 +2298,12 @@ export function pluginRoutes(
return;
}
const body = req.body as { configJson?: Record<string, unknown> } | undefined;
const body = req.body as { configJson?: Record<string, unknown>; companyId?: string } | undefined;
if (!body?.configJson || typeof body.configJson !== "object") {
res.status(400).json({ error: '"configJson" is required and must be an object' });
return;
}
const companyId = resolvePluginConfigCompanyId(req);
// Fast schema-level rejection before hitting the worker RPC.
const schema = plugin.manifestJson?.instanceConfigSchema;
@ -2318,6 +2319,11 @@ export function pluginRoutes(
}
try {
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
if (secretRefsByPath.size > 0 && !companyId) {
res.status(422).json({ error: "Plugin secret references require companyId" });
return;
}
const result = await bridgeDeps.workerManager.call(
plugin.id,
"validateConfig",