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";
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

@ -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,