Compare commits

...

5 commits

8 changed files with 168 additions and 11 deletions

View file

@ -627,8 +627,16 @@ export function createHostClientHandlers(
return {
// Config
"config.get": gated("config.get", async (params) => {
return services.config.get(params);
"config.get": gated("config.get", async (params, context) => {
const scopedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
const explicitCompanyId = Object.prototype.hasOwnProperty.call(params ?? {}, "companyId")
? params.companyId ?? null
: undefined;
return services.config.get(
explicitCompanyId === undefined
? (scopedCompanyId ? { companyId: scopedCompanyId } : {})
: { companyId: explicitCompanyId },
);
}),
"localFolders.declarations": gated("localFolders.declarations", async (params) => {

View file

@ -514,4 +514,5 @@ describe("startWorkerRpcHost runtime company context", () => {
stdout.destroy();
}
});
});

View file

@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
describe("plugin config bridge scoping", () => {
it("falls back to the invocation company scope for config.get when the worker omits companyId", async () => {
const getConfig = vi.fn(async (params: { companyId?: string | null }) => ({
scope: params.companyId ?? null,
}));
const handlers = createHostClientHandlers({
pluginId: "test.plugin",
capabilities: [],
services: {
config: { get: getConfig },
} as never,
});
await expect(
handlers["config.get"]({}, { invocationScope: { companyId: "company-a" } }),
).resolves.toEqual({ scope: "company-a" });
expect(getConfig).toHaveBeenCalledWith({ companyId: "company-a" });
});
it("preserves an explicit global config.get request even inside a company-scoped invocation", async () => {
const getConfig = vi.fn(async (params: { companyId?: string | null }) => ({
scope: params.companyId ?? null,
}));
const handlers = createHostClientHandlers({
pluginId: "test.plugin",
capabilities: [],
services: {
config: { get: getConfig },
} as never,
});
await expect(
handlers["config.get"]({ companyId: null }, { invocationScope: { companyId: "company-a" } }),
).resolves.toEqual({ scope: null });
expect(getConfig).toHaveBeenCalledWith({ companyId: null });
});
});

View file

@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
getConfigExactScope: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => ({
getConfig: mocks.getConfig,
getConfigExactScope: mocks.getConfigExactScope,
}),
}));
import { buildHostServices } from "../services/plugin-host-services.js";
function createEventBusStub() {
return {
forPlugin() {
return {
emit: vi.fn(),
subscribe: vi.fn(),
clear: vi.fn(),
};
},
} as any;
}
describe("plugin host services config scoping", () => {
beforeEach(() => {
mocks.getConfig.mockReset();
mocks.getConfigExactScope.mockReset();
});
it("does not fall back to legacy global config for company-scoped reads", async () => {
mocks.getConfigExactScope.mockResolvedValue(null);
mocks.getConfig.mockResolvedValue({
configJson: { tokenRef: "global-token-ref" },
});
const services = buildHostServices(
{} as never,
"plugin-record-id",
"paperclip.example",
createEventBusStub(),
);
await expect(
services.config.get({ companyId: "company-a" }),
).resolves.toEqual({});
expect(mocks.getConfigExactScope).toHaveBeenCalledWith("plugin-record-id", "company-a");
expect(mocks.getConfig).not.toHaveBeenCalled();
services.dispose();
});
it("still reads the exact global row for explicit global requests", async () => {
mocks.getConfigExactScope.mockResolvedValue({
configJson: { tokenRef: "global-token-ref" },
});
const services = buildHostServices(
{} as never,
"plugin-record-id",
"paperclip.example",
createEventBusStub(),
);
await expect(
services.config.get({ companyId: null }),
).resolves.toEqual({ tokenRef: "global-token-ref" });
expect(mocks.getConfigExactScope).toHaveBeenCalledWith("plugin-record-id", null);
services.dispose();
});
});

View file

@ -1,13 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
getConfigExactScope: vi.fn(),
resolveSecretValue: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => ({
getConfig: mocks.getConfig,
getConfigExactScope: mocks.getConfigExactScope,
}),
}));
@ -40,7 +40,7 @@ const manifest = {
describe("createPluginSecretsHandler runtime company scoping", () => {
beforeEach(() => {
mocks.getConfig.mockReset();
mocks.getConfigExactScope.mockReset();
mocks.resolveSecretValue.mockReset();
});
@ -54,12 +54,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => {
await expect(handler.resolve({ secretRef })).rejects.toThrow(
PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE,
);
expect(mocks.getConfig).not.toHaveBeenCalled();
expect(mocks.getConfigExactScope).not.toHaveBeenCalled();
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
});
it("rejects a secret ref that is not referenced by that company's plugin config", async () => {
mocks.getConfig.mockResolvedValue({
mocks.getConfigExactScope.mockResolvedValue({
configJson: {
apiKeyRef: "88888888-8888-4888-8888-888888888888",
},
@ -74,12 +74,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => {
await expect(handler.resolve({ secretRef, companyId })).rejects.toThrow(
/not referenced by this company's plugin config/i,
);
expect(mocks.getConfig).toHaveBeenCalledWith(pluginId, companyId);
expect(mocks.getConfigExactScope).toHaveBeenCalledWith(pluginId, companyId);
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
});
it("resolves only through the company plugin binding context from saved config", async () => {
mocks.getConfig.mockResolvedValue({
mocks.getConfigExactScope.mockResolvedValue({
configJson: {
apiKeyRef: secretRef,
},
@ -102,4 +102,20 @@ describe("createPluginSecretsHandler runtime company scoping", () => {
pluginId,
});
});
it("does not authorize a company-scoped secret resolution from legacy global config fallback", async () => {
mocks.getConfigExactScope.mockResolvedValue(null);
const handler = createPluginSecretsHandler({
db: {} as never,
pluginId,
manifest: manifest as never,
});
await expect(handler.resolve({ secretRef, companyId })).rejects.toThrow(
/not referenced by this company's plugin config/i,
);
expect(mocks.getConfigExactScope).toHaveBeenCalledWith(pluginId, companyId);
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
});
});

View file

@ -1054,7 +1054,10 @@ export function buildHostServices(
return {
config: {
async get(params) {
const configRow = await registry.getConfig(pluginId, params?.companyId ?? null);
const companyId = Object.prototype.hasOwnProperty.call(params ?? {}, "companyId")
? params.companyId ?? null
: null;
const configRow = await registry.getConfigExactScope(pluginId, companyId);
return configRow?.configJson ?? {};
},
},

View file

@ -287,6 +287,14 @@ export function pluginRegistryService(db: Db) {
// ----- Config ---------------------------------------------------------
/** Retrieve a plugin's config at the exact requested scope. */
getConfigExactScope: (pluginId: string, companyId?: string | null) =>
db
.select()
.from(pluginConfig)
.where(pluginConfigExactScopeCondition(pluginId, companyId))
.then((rows) => rows[0] ?? null),
/** Retrieve a plugin's company-scoped config, or the legacy global fallback. */
getConfig: async (pluginId: string, companyId?: string | null) => {
if (companyId) {

View file

@ -242,7 +242,10 @@ export function createPluginSecretsHandler(
throw new Error(PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE);
}
const configRow = await registry.getConfig(pluginId, companyId);
// Authorize secret refs only from the exact company-scoped config row.
// Falling back to legacy/global config would let a live worker keep using
// stale global refs after a company-scoped invocation takes over.
const configRow = await registry.getConfigExactScope(pluginId, companyId);
const refsBySecret = extractSecretRefPathsFromConfig(
configRow?.configJson ?? {},
manifest?.instanceConfigSchema,