PAP-2394 PAP-2395 Fail closed plugin secret refs

This commit is contained in:
Dotta committed 2026-05-07 18:59:05 -05:00
1 parent c74574c74a
commit 87011615fb
12 files changed
+427 -104

No files matched your search

@@ -0,0 +1,86 @@
# Plugin Secret Refs: Company Scope Reintroduction Plan
Date: 2026-04-26
Status: follow-up after fail-closed mitigation
Related issue: PAP-2394
## Current state
`PAP-2394` now fails closed:
- `POST /api/plugins/:pluginId/config` rejects any config containing plugin secret refs.
- `ctx.secrets.resolve()` is disabled for plugin workers.
This removes the release-blocking cross-company exposure path, but it also disables plugin secret-ref support until the runtime carries company scope end to end.
## Vulnerability summary
The original design mixed an instance-global config store with company-scoped secret bindings:
- [server/src/routes/plugins.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/routes/plugins.ts:1898) saved one global plugin config row, then wrote bindings into `company_secret_bindings` grouped by each referenced secret's owning company.
- [packages/db/src/schema/plugin_config.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/plugin_config.ts:15) stored one config row per plugin, with no company dimension.
- [packages/db/src/schema/company_secret_bindings.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/company_secret_bindings.ts:5) already modeled bindings as company-scoped.
- [server/src/services/plugin-secrets-handler.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/services/plugin-secrets-handler.ts:212) resolved by `pluginId` + secret UUID, with no active company context from the bridge call.
- [packages/plugins/sdk/src/worker-rpc-host.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/plugins/sdk/src/worker-rpc-host.ts:384) exposed `ctx.config.get()` and `ctx.secrets.resolve()` without a company parameter.
This violated Least Privilege, Complete Mediation, and Secure Defaults.
## Recommended end state
Re-enable plugin secret refs only after both of these are true:
1. Plugin config reads/writes are company-scoped.
2. Runtime secret resolution carries explicit company context and enforces it at resolution time.
## Implementation plan
### 1. Make plugin config company-scoped
- Add `company_id` to `plugin_config`, with a unique index on `(plugin_id, company_id)`.
- Update registry helpers to require `companyId` for `getConfig`, `upsertConfig`, `patchConfig`, and `deleteConfig`.
- Update plugin config routes to require `companyId` and call `assertCompanyAccess(req, companyId)`.
- Keep instance-global plugin lifecycle state separate from company-scoped plugin config.
### 2. Propagate company context through the worker runtime
- Extend the SDK so `ctx.config.get()` and `ctx.secrets.resolve()` can receive or derive `companyId`.
- Introduce worker request context storage for handlers that already run with company scope:
- `getData`
- `performAction`
- scoped API routes
- tool executions
- environment driver calls
- Fail closed when plugin code tries to read company-scoped config or secrets outside an active company context.
### 3. Rebind secrets by `(companyId, pluginId, configPath)`
- On config save, validate every referenced secret belongs to the authorized company.
- Store bindings only for that company.
- Resolve secrets only by the current company-scoped binding, never by bare plugin ID plus UUID.
- Treat stale bindings as invalid and remove them on config replacement.
### 4. Prevent cross-company config disclosure
- When returning config to the UI, only materialize the selected company's secret refs.
- Never expose another company's secret UUIDs through the global plugin config surface.
## Required regression coverage
- Company A board user cannot save plugin config that references a Company B secret.
- Company A plugin execution cannot resolve a Company B secret even if the same plugin is configured for Company B.
- Company-scoped config reads only return the selected company's secret bindings.
- Config replacement removes stale bindings for the same `(companyId, pluginId)` target.
- Runtime calls without company context fail closed.
## Migration notes
- Existing `plugin_config` rows need a migration strategy before re-enable.
- Safest default: do not auto-assume a company for historical secret refs.
- Prefer one of:
- explicit admin migration per company, or
- import existing rows as non-secret config only and require re-entry of secret refs.
## Release posture
- Keep plugin secret refs disabled until all steps above land.
- Do not restore the feature behind a soft warning; the insecure path must remain unavailable by default.
@@ -36,10 +36,13 @@ const mockProbeEnvironment = vi.hoisted(() => vi.fn());
const mockSecretService = vi.hoisted(() => ({
create: vi.fn(),
resolveSecretValue: vi.fn(),
syncSecretRefsForTarget: vi.fn(),
remove: vi.fn(),
}));
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn());
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
vi.mock("../services/index.js", () => ({
@@ -69,6 +72,7 @@ vi.mock("../services/execution-workspaces.js", () => ({
vi.mock("../services/plugin-environment-driver.js", () => ({
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig,
}));
@@ -96,6 +100,7 @@ let currentActor: Record<string, unknown> = {
source: "local_implicit",
};
const routeOptions: Record<string, unknown> = {};
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
function createApp(actor: Record<string, unknown>, options: Record<string, unknown> = {}) {
currentActor = actor;
@@ -119,6 +124,11 @@ function createApp(actor: Record<string, unknown>, options: Record<string, unkno
describe("environment routes", () => {
afterAll(async () => {
if (originalSecretsProviderEnv === undefined) {
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
} else {
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
}
if (!server) return;
await new Promise<void>((resolve, reject) => {
server?.close((err) => {
@@ -145,9 +155,14 @@ describe("environment routes", () => {
mockProbeEnvironment.mockReset();
mockSecretService.create.mockReset();
mockSecretService.resolveSecretValue.mockReset();
mockSecretService.syncSecretRefsForTarget.mockReset();
mockSecretService.remove.mockReset();
mockSecretService.create.mockResolvedValue({
id: "11111111-1111-1111-1111-111111111111",
});
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
mockSecretService.remove.mockResolvedValue(null);
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
mockValidatePluginEnvironmentDriverConfig.mockReset();
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
mockValidatePluginSandboxProviderConfig.mockReset();
@@ -162,6 +177,29 @@ describe("environment routes", () => {
configSchema: { type: "object" },
},
}));
mockResolvePluginSandboxProviderDriverByKey.mockReset();
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(async ({ driverKey }) => (
driverKey === "secure-plugin"
? {
pluginId: "plugin-secure",
pluginKey: "acme.secure-sandbox-provider",
driver: {
driverKey: "secure-plugin",
kind: "sandbox_provider",
displayName: "Secure Sandbox",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
apiKey: { type: "string", format: "secret-ref" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
}
: null
));
mockListReadyPluginEnvironmentDrivers.mockReset();
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]);
});
@@ -555,6 +593,59 @@ describe("environment routes", () => {
);
});
it("uses the configured provider for SSH private key secret materialization", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const environment = {
...createEnvironment(),
id: "env-ssh",
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "latest",
},
knownHosts: null,
strictHostKeyChecking: true,
},
};
mockEnvironmentService.create.mockResolvedValue(environment);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "SSH Fixture",
driver: "ssh",
config: {
host: "ssh.example.test",
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: "super-secret-key",
},
});
expect(res.status).toBe(201);
expect(mockSecretService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "aws_secrets_manager",
value: "super-secret-key",
}),
expect.any(Object),
);
});
it("rejects persisted fake sandbox environments", async () => {
const app = createApp({
type: "board",
@@ -732,6 +823,78 @@ describe("environment routes", () => {
);
});
it("uses the configured provider for schema-driven sandbox secret fields", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const environment = {
...createEnvironment(),
id: "env-sandbox-secure-plugin",
name: "Secure Sandbox",
driver: "sandbox" as const,
config: {
provider: "secure-plugin",
template: "base",
apiKey: "11111111-1111-1111-1111-111111111111",
timeoutMs: 450000,
reuseLease: true,
},
};
mockEnvironmentService.create.mockResolvedValue(environment);
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
normalizedConfig: {
template: "base",
apiKey: "test-provider-key",
timeoutMs: 450000,
reuseLease: true,
},
pluginId: "plugin-secure",
pluginKey: "acme.secure-sandbox-provider",
driver: {
driverKey: "secure-plugin",
kind: "sandbox_provider",
displayName: "Secure Sandbox",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
apiKey: { type: "string", format: "secret-ref" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
});
const pluginWorkerManager = {};
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
}, { pluginWorkerManager });
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "Secure Sandbox",
driver: "sandbox",
config: {
provider: "secure-plugin",
template: "base",
apiKey: "test-provider-key",
timeoutMs: "450000",
reuseLease: true,
},
});
expect(res.status).toBe(201);
expect(mockSecretService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "aws_secrets_manager",
value: "test-provider-key",
}),
expect.any(Object),
);
});
it("validates plugin environment config through the plugin driver host", async () => {
const environment = {
...createEnvironment(),
@@ -224,6 +224,30 @@ describe.sequential("plugin install and upgrade authz", () => {
expect(mockLifecycle.disable).not.toHaveBeenCalled();
}, 20_000);
it("rejects plugin config saves that contain secret refs even for instance admins", async () => {
readyPlugin();
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [companyA],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config`)
.send({
configJson: {
apiKeyRef: "77777777-7777-4777-8777-777777777777",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toMatch(/secret references are disabled/i);
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
createPluginSecretsHandler,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
describe("createPluginSecretsHandler", () => {
it("fails closed for plugin secret resolution until company scoping lands", async () => {
const handler = createPluginSecretsHandler({
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
await expect(
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }),
).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
});
it("still rejects malformed secret refs before the feature-disable guard", async () => {
const handler = createPluginSecretsHandler({
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
await expect(
handler.resolve({ secretRef: "not-a-uuid" }),
).rejects.toThrow(/invalid secret reference/i);
});
});
+84 -1
View File
@@ -1,6 +1,6 @@
import { createHmac, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
@@ -26,10 +26,12 @@ import {
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import * as providerRegistry from "../secrets/provider-registry.ts";
import { routineService } from "../services/routines.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -47,6 +49,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
}, 20_000);
afterEach(async () => {
if (originalSecretsProviderEnv === undefined) {
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
} else {
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
}
await db.delete(activityLog);
await db.delete(issueInboxArchives);
await db.delete(issueReadStates);
@@ -1272,6 +1279,82 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(run.linkedIssueId).toBeTruthy();
});
it("uses the configured provider for generated webhook trigger secrets", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const originalGetSecretProvider = providerRegistry.getSecretProvider;
const getSecretProviderSpy = vi.spyOn(providerRegistry, "getSecretProvider").mockImplementation((provider) => {
if (provider !== "aws_secrets_manager") {
return originalGetSecretProvider(provider);
}
return {
id: "aws_secrets_manager",
descriptor: () => ({
id: "aws_secrets_manager",
label: "AWS Secrets Manager",
supportsManaged: true,
supportsExternalReference: true,
}),
validateConfig: async () => ({ ok: true, warnings: [] }),
createSecret: async ({ value }) => ({
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v1" },
valueSha256: `sha:${value}`,
fingerprintSha256: `sha:${value}`,
externalRef: "arn:aws:secretsmanager:stub",
providerVersionRef: "v1",
}),
createVersion: async ({ value }) => ({
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v2" },
valueSha256: `sha:${value}`,
fingerprintSha256: `sha:${value}`,
externalRef: "arn:aws:secretsmanager:stub",
providerVersionRef: "v2",
}),
linkExternalSecret: async ({ externalRef, providerVersionRef }) => ({
material: { source: "external", secretId: externalRef, versionId: providerVersionRef ?? null },
valueSha256: "external",
fingerprintSha256: "external",
externalRef,
providerVersionRef: providerVersionRef ?? null,
}),
resolveVersion: async () => "resolved-secret",
deleteOrArchive: async () => undefined,
healthCheck: async () => ({
provider: "aws_secrets_manager",
status: "ok",
message: "stubbed",
}),
};
});
try {
const { routine, svc } = await seedFixture();
const { trigger } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "hmac_sha256",
replayWindowSec: 300,
},
{},
);
const [secret] = await db
.select({
id: companySecrets.id,
provider: companySecrets.provider,
})
.from(companySecrets)
.where(eq(companySecrets.id, trigger.secretId!));
expect(secret).toMatchObject({
id: trigger.secretId,
provider: "aws_secrets_manager",
});
} finally {
getSecretProviderSpy.mockRestore();
}
});
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
const { routine, svc } = await seedFixture();
const { trigger, secretMaterial } = await svc.createTrigger(
+3
View File
@@ -27,6 +27,7 @@ import {
import { probeEnvironment } from "../services/environment-probe.js";
import { secretService } from "../services/secrets.js";
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
@@ -203,6 +204,7 @@ export function environmentRoutes(
companyId,
environmentName: req.body.name,
driver: req.body.driver,
secretProvider: getConfiguredSecretProvider(),
config: req.body.config,
actor: {
agentId: actor.agentId,
@@ -311,6 +313,7 @@ export function environmentRoutes(
companyId: existing.companyId,
environmentName: nextName,
driver: nextDriver,
secretProvider: getConfiguredSecretProvider(),
config: configSource,
actor: {
agentId: actor.agentId,
+10 -25
View File
@@ -73,8 +73,10 @@ import {
requireLocalFolderDeclaration,
setStoredLocalFolder,
} from "../services/plugin-local-folders.js";
import { extractSecretRefPathsFromConfig } from "../services/plugin-secrets-handler.js";
import { secretService } from "../services/secrets.js";
import {
extractSecretRefPathsFromConfig,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
/** UI slot declaration extracted from plugin manifest */
@@ -1943,32 +1945,15 @@ export function pluginRoutes(
}
try {
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
if (secretRefsByPath.size > 0) {
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
return;
}
const result = await registry.upsertConfig(plugin.id, {
configJson: body.configJson,
});
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
const secrets = secretService(db);
const refsByCompany = new Map<string, Array<{ secretId: string; configPath: string; versionSelector: "latest" }>>();
for (const secretRef of secretRefsByPath.keys()) {
const secret = await secrets.getById(secretRef);
if (!secret) continue;
const refs = refsByCompany.get(secret.companyId) ?? [];
for (const configPath of secretRefsByPath.get(secretRef) ?? []) {
refs.push({
secretId: secretRef,
configPath,
versionSelector: "latest",
});
}
refsByCompany.set(secret.companyId, refs);
}
for (const [companyId, refs] of refsByCompany) {
await secrets.syncSecretRefsForTarget(
companyId,
{ targetType: "plugin", targetId: plugin.id },
refs,
);
}
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
+2 -8
View File
@@ -1,8 +1,6 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
SECRET_PROVIDERS,
type SecretProvider,
createSecretSchema,
rotateSecretSchema,
updateSecretSchema,
@@ -10,16 +8,12 @@ import {
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
export function secretRoutes(db: Db) {
const router = Router();
const svc = secretService(db);
const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
const defaultProvider = (
configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider)
? configuredDefaultProvider
: "local_encrypted"
) as SecretProvider;
const defaultProvider = getConfiguredSecretProvider();
router.get("/companies/:companyId/secret-providers", (req, res) => {
assertBoard(req);
@@ -0,0 +1,8 @@
import { SECRET_PROVIDERS, type SecretProvider } from "@paperclipai/shared";
export function getConfiguredSecretProvider(): SecretProvider {
const configuredProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
return configuredProvider && SECRET_PROVIDERS.includes(configuredProvider as SecretProvider)
? configuredProvider as SecretProvider
: "local_encrypted";
}
+8 -1
View File
@@ -9,6 +9,7 @@ import type {
PluginEnvironmentConfig,
PluginSandboxEnvironmentConfig,
SandboxEnvironmentConfig,
SecretProvider,
SecretVersionSelector,
SshEnvironmentConfig,
} from "@paperclipai/shared";
@@ -166,6 +167,7 @@ async function createEnvironmentSecret(input: {
environmentName: string;
driver: EnvironmentDriver;
field: string;
provider: SecretProvider;
value: string;
actor?: { userId?: string | null; agentId?: string | null };
}) {
@@ -173,7 +175,7 @@ async function createEnvironmentSecret(input: {
input.companyId,
{
name: secretName(input),
provider: "local_encrypted",
provider: input.provider,
value: input.value,
description: `Secret for ${input.environmentName} ${input.field}.`,
},
@@ -191,6 +193,7 @@ async function persistConfigSecretRefs(input: {
companyId: string;
environmentName: string;
driver: EnvironmentDriver;
secretProvider: SecretProvider;
config: Record<string, unknown>;
schema: Record<string, unknown> | null;
actor?: { userId?: string | null; agentId?: string | null };
@@ -214,6 +217,7 @@ async function persistConfigSecretRefs(input: {
environmentName: input.environmentName,
driver: input.driver,
field: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(),
provider: input.secretProvider,
value: trimmed,
actor: input.actor,
});
@@ -383,6 +387,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
companyId: string;
environmentName: string;
driver: EnvironmentDriver;
secretProvider: SecretProvider;
config: Record<string, unknown> | null | undefined;
actor?: { userId?: string | null; agentId?: string | null };
pluginWorkerManager?: PluginWorkerManager;
@@ -404,6 +409,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
environmentName: input.environmentName,
driver: input.driver,
field: "private-key",
provider: input.secretProvider,
value: privateKey,
actor: input.actor,
});
@@ -447,6 +453,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
companyId: input.companyId,
environmentName: input.environmentName,
driver: input.driver,
secretProvider: input.secretProvider,
config: {
provider: parsed.data.provider,
...validated.normalizedConfig,
+7 -68
View File
@@ -33,31 +33,20 @@
* @see services/secrets.ts — secretService used by agent env bindings
*/
import { eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecrets, pluginConfig } from "@paperclipai/db";
import { pluginRegistryService } from "./plugin-registry.js";
import { secretService } from "./secrets.js";
import {
collectSecretRefPaths,
isUuidSecretRef,
readConfigValueAtPath,
} from "./json-schema-secret-refs.js";
export const PLUGIN_SECRET_REFS_DISABLED_MESSAGE =
"Plugin secret references are disabled until company-scoped plugin config lands";
// ---------------------------------------------------------------------------
// Error helpers
// ---------------------------------------------------------------------------
/**
* Create a sanitised error that never leaks secret material.
* Only the ref identifier is included; never the resolved value.
*/
function secretNotFound(secretRef: string): Error {
const err = new Error(`Secret not found: ${secretRef}`);
err.name = "SecretNotFoundError";
return err;
}
function invalidSecretRef(secretRef: string): Error {
const err = new Error(`Invalid secret reference: ${secretRef}`);
err.name = "InvalidSecretRefError";
@@ -210,17 +199,11 @@ function createRateLimiter(maxAttempts: number, windowMs: number) {
export function createPluginSecretsHandler(
options: PluginSecretsHandlerOptions,
): PluginSecretsService {
const { db, pluginId } = options;
const registry = pluginRegistryService(db);
const secrets = secretService(db);
const { pluginId } = options;
// Rate limit: max 30 resolution attempts per plugin per minute
const rateLimiter = createRateLimiter(30, 60_000);
let cachedAllowedRefs: Map<string, Set<string>> | null = null;
let cachedAllowedRefsExpiry = 0;
const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL
return {
async resolve(params: PluginSecretsResolveParams): Promise<string> {
const { secretRef } = params;
@@ -247,53 +230,9 @@ export function createPluginSecretsHandler(
throw invalidSecretRef(trimmedRef);
}
// ---------------------------------------------------------------
// 1b. Scope check — only allow secrets referenced in this plugin's config
// ---------------------------------------------------------------
const now = Date.now();
if (!cachedAllowedRefs || now > cachedAllowedRefsExpiry) {
const [configRow, plugin] = await Promise.all([
db
.select()
.from(pluginConfig)
.where(eq(pluginConfig.pluginId, pluginId))
.then((rows) => rows[0] ?? null),
registry.getById(pluginId),
]);
const schema = (plugin?.manifestJson as unknown as Record<string, unknown> | null)
?.instanceConfigSchema as Record<string, unknown> | undefined;
cachedAllowedRefs = extractSecretRefPathsFromConfig(configRow?.configJson, schema);
cachedAllowedRefsExpiry = now + CONFIG_CACHE_TTL_MS;
}
const allowedPaths = cachedAllowedRefs.get(trimmedRef);
if (!allowedPaths) {
// Return "not found" to avoid leaking whether the secret exists
throw secretNotFound(trimmedRef);
}
// ---------------------------------------------------------------
// 2. Look up the secret record by UUID
// ---------------------------------------------------------------
const secret = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, trimmedRef))
.then((rows) => rows[0] ?? null);
if (!secret) {
throw secretNotFound(trimmedRef);
}
return await secrets.resolveSecretValue(secret.companyId, secret.id, "latest", {
consumerType: "plugin",
consumerId: pluginId,
actorType: "plugin",
actorId: pluginId,
pluginId,
configPath: [...allowedPaths][0] ?? "$",
});
// Fail closed until plugin config and worker runtime both carry an
// explicit company scope for secret bindings and resolution.
throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
},
};
}
+3 -1
View File
@@ -50,6 +50,7 @@ import { trackRoutineRun } from "@paperclipai/shared/telemetry";
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { issueService } from "./issues.js";
import { secretService } from "./secrets.js";
import { getSecretProvider } from "../secrets/provider-registry.js";
@@ -951,9 +952,10 @@ export function routineService(
executor?: Db,
) {
const secretValue = crypto.randomBytes(24).toString("hex");
const providerId = getConfiguredSecretProvider();
const input = {
name: `routine-${routineId}-${crypto.randomBytes(6).toString("hex")}`,
provider: "local_encrypted" as const,
provider: providerId,
value: secretValue,
description: `Webhook auth for routine ${routineId}`,
};