diff --git a/server/src/__tests__/plugin-secrets-handler-runtime.test.ts b/server/src/__tests__/plugin-secrets-handler-runtime.test.ts index da152656..504ad9e0 100644 --- a/server/src/__tests__/plugin-secrets-handler-runtime.test.ts +++ b/server/src/__tests__/plugin-secrets-handler-runtime.test.ts @@ -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(); + }); }); diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 355717e3..c80c0cf4 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -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) { diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts index cadb33c7..84d370d0 100644 --- a/server/src/services/plugin-secrets-handler.ts +++ b/server/src/services/plugin-secrets-handler.ts @@ -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,