fix(plugin): stop secret auth fallback to global config

This commit is contained in:
Paperclip Bot 2026-06-05 08:21:17 +00:00
parent 8969a713a1
commit 1b3e1e3745
3 changed files with 35 additions and 8 deletions

View file

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

@ -287,6 +287,14 @@ export function pluginRegistryService(db: Db) {
// ----- Config --------------------------------------------------------- // ----- 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. */ /** Retrieve a plugin's company-scoped config, or the legacy global fallback. */
getConfig: async (pluginId: string, companyId?: string | null) => { getConfig: async (pluginId: string, companyId?: string | null) => {
if (companyId) { if (companyId) {

View file

@ -242,7 +242,10 @@ export function createPluginSecretsHandler(
throw new Error(PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE); 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( const refsBySecret = extractSecretRefPathsFromConfig(
configRow?.configJson ?? {}, configRow?.configJson ?? {},
manifest?.instanceConfigSchema, manifest?.instanceConfigSchema,