mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Enforce runtime secret binding materialization
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e117692027
commit
e8be4c0f81
10 files changed
+346
-67
No files matched your search
@@ -177,6 +177,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
provider: "local_encrypted",
|
||||
value: config.privateKey,
|
||||
});
|
||||
await secretService(db).createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "environment",
|
||||
targetId: environmentId,
|
||||
configPath: "privateKeySecretRef",
|
||||
});
|
||||
config = {
|
||||
...config,
|
||||
privateKey: null,
|
||||
@@ -548,6 +555,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driver: "sandbox",
|
||||
config: providerConfig,
|
||||
};
|
||||
await secretService(db).createBinding({
|
||||
companyId,
|
||||
secretId: apiSecret.id,
|
||||
targetType: "environment",
|
||||
targetId: environment.id,
|
||||
configPath: "apiKey",
|
||||
});
|
||||
await environmentService(db).update(environment.id, {
|
||||
driver: "sandbox",
|
||||
name: environment.name,
|
||||
|
||||
@@ -17,6 +17,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
other: "value",
|
||||
},
|
||||
secretKeys: new Set(["AGENT_SECRET"]),
|
||||
manifest: [
|
||||
{
|
||||
configPath: "env.AGENT_SECRET",
|
||||
envKey: "AGENT_SECRET",
|
||||
secretId: "secret-agent",
|
||||
secretKey: "agent-secret",
|
||||
version: 1,
|
||||
provider: "local_encrypted",
|
||||
outcome: "success",
|
||||
},
|
||||
],
|
||||
});
|
||||
const resolveEnvBindings = vi.fn().mockResolvedValue({
|
||||
env: {
|
||||
@@ -24,6 +35,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
PROJECT_ONLY: "project-only",
|
||||
},
|
||||
secretKeys: new Set(["PROJECT_SECRET"]),
|
||||
manifest: [
|
||||
{
|
||||
configPath: "env.PROJECT_SECRET",
|
||||
envKey: "PROJECT_SECRET",
|
||||
secretId: "secret-project",
|
||||
secretKey: "project-secret",
|
||||
version: 1,
|
||||
provider: "local_encrypted",
|
||||
outcome: "success",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await resolveExecutionRunAdapterConfig({
|
||||
@@ -45,12 +67,19 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
},
|
||||
});
|
||||
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
|
||||
expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([
|
||||
"secret-agent",
|
||||
"secret-project",
|
||||
]);
|
||||
expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only");
|
||||
expect(JSON.stringify(result.secretManifest)).not.toContain("project-only");
|
||||
});
|
||||
|
||||
it("skips project env resolution when the project has no bindings", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||
config: { env: { AGENT_ONLY: "agent-only" } },
|
||||
secretKeys: new Set<string>(),
|
||||
manifest: [],
|
||||
});
|
||||
const resolveEnvBindings = vi.fn();
|
||||
|
||||
@@ -65,6 +94,7 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
|
||||
expect(result.secretManifest).toEqual([]);
|
||||
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
import {
|
||||
collectEnvironmentSecretRefs,
|
||||
normalizeEnvironmentConfigForPersistence,
|
||||
normalizeEnvironmentConfigForProbe,
|
||||
parseEnvironmentDriverConfig,
|
||||
@@ -211,6 +212,11 @@ export function environmentRoutes(
|
||||
}),
|
||||
};
|
||||
const environment = await svc.create(companyId, input);
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
companyId,
|
||||
{ targetType: "environment", targetId: environment.id },
|
||||
await collectEnvironmentSecretRefs({ db, environment }),
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -320,6 +326,13 @@ export function environmentRoutes(
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
if (patch.config !== undefined || patch.driver !== undefined) {
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
environment.companyId,
|
||||
{ targetType: "environment", targetId: environment.id },
|
||||
await collectEnvironmentSecretRefs({ db, environment }),
|
||||
);
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: environment.companyId,
|
||||
actorType: actor.actorType,
|
||||
|
||||
@@ -73,6 +73,8 @@ import {
|
||||
requireLocalFolderDeclaration,
|
||||
setStoredLocalFolder,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
import { extractSecretRefPathsFromConfig } from "../services/plugin-secrets-handler.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
@@ -1944,6 +1946,29 @@ export function pluginRoutes(
|
||||
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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PluginEnvironmentConfig,
|
||||
PluginSandboxEnvironmentConfig,
|
||||
SandboxEnvironmentConfig,
|
||||
SecretVersionSelector,
|
||||
SshEnvironmentConfig,
|
||||
} from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
@@ -226,6 +227,11 @@ async function resolveConfigSecretRefsForRuntime(input: {
|
||||
companyId: string;
|
||||
config: Record<string, unknown>;
|
||||
schema: Record<string, unknown> | null;
|
||||
context: {
|
||||
consumerId: string;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
};
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const secrets = secretService(input.db);
|
||||
let nextConfig = { ...input.config };
|
||||
@@ -234,15 +240,52 @@ async function resolveConfigSecretRefsForRuntime(input: {
|
||||
if (typeof current !== "string") continue;
|
||||
const trimmed = current.trim();
|
||||
if (!isUuidSecretRef(trimmed)) continue;
|
||||
if (!input.context.consumerId) {
|
||||
throw unprocessable("Runtime secret resolution requires an environment id");
|
||||
}
|
||||
nextConfig = writeConfigValueAtPath(
|
||||
nextConfig,
|
||||
path,
|
||||
await secrets.resolveSecretValue(input.companyId, trimmed, "latest"),
|
||||
await secrets.resolveSecretValue(input.companyId, trimmed, "latest", {
|
||||
consumerType: "environment",
|
||||
consumerId: input.context.consumerId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
issueId: input.context.issueId ?? null,
|
||||
heartbeatRunId: input.context.heartbeatRunId ?? null,
|
||||
configPath: path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
export async function collectEnvironmentSecretRefs(input: {
|
||||
db: Db;
|
||||
environment: Pick<Environment, "id" | "driver" | "config">;
|
||||
}): Promise<Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }>> {
|
||||
const parsed = parseEnvironmentDriverConfig(input.environment);
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||
return [{
|
||||
secretId: parsed.config.privateKeySecretRef.secretId,
|
||||
configPath: "privateKeySecretRef",
|
||||
versionSelector: parsed.config.privateKeySecretRef.version ?? "latest",
|
||||
}];
|
||||
}
|
||||
if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") {
|
||||
const schema = await getSandboxProviderConfigSchema(input.db, parsed.config.provider);
|
||||
const refs: Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }> = [];
|
||||
for (const path of collectSecretRefPaths(schema)) {
|
||||
const current = readConfigValueAtPath(parsed.config as Record<string, unknown>, path);
|
||||
if (typeof current === "string" && isUuidSecretRef(current.trim())) {
|
||||
refs.push({ secretId: current.trim(), configPath: path, versionSelector: "latest" });
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig): Record<string, unknown> {
|
||||
const { provider: _provider, ...driverConfig } = config as Record<string, unknown>;
|
||||
return driverConfig;
|
||||
@@ -442,10 +485,15 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||
export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
environment: Pick<Environment, "driver" | "config"> & Partial<Pick<Environment, "id">>,
|
||||
context?: { issueId?: string | null; heartbeatRunId?: string | null },
|
||||
): Promise<ParsedEnvironmentConfig> {
|
||||
const parsed = parseEnvironmentDriverConfig(environment);
|
||||
const secrets = secretService(db);
|
||||
const environmentId = environment.id;
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef && !environmentId) {
|
||||
throw unprocessable("Runtime secret resolution requires an environment id");
|
||||
}
|
||||
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||
return {
|
||||
@@ -456,6 +504,15 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
companyId,
|
||||
parsed.config.privateKeySecretRef.secretId,
|
||||
parsed.config.privateKeySecretRef.version ?? "latest",
|
||||
{
|
||||
consumerType: "environment",
|
||||
consumerId: environmentId!,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
issueId: context?.issueId ?? null,
|
||||
heartbeatRunId: context?.heartbeatRunId ?? null,
|
||||
configPath: "privateKeySecretRef",
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -469,6 +526,11 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
companyId,
|
||||
config: parsed.config as Record<string, unknown>,
|
||||
schema: await getSandboxProviderConfigSchema(db, parsed.config.provider),
|
||||
context: {
|
||||
consumerId: environmentId!,
|
||||
issueId: context?.issueId ?? null,
|
||||
heartbeatRunId: context?.heartbeatRunId ?? null,
|
||||
},
|
||||
}) as SandboxEnvironmentConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,7 +228,10 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
||||
driver: "ssh",
|
||||
|
||||
async acquireRunLease(input) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
|
||||
issueId: input.issueId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
});
|
||||
if (parsed.driver !== "ssh") {
|
||||
throw new Error(`Expected SSH environment config for driver "${input.environment.driver}".`);
|
||||
}
|
||||
@@ -346,6 +349,7 @@ function createSandboxEnvironmentDriver(
|
||||
const metadataConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
|
||||
if (metadataConfig && metadataConfig.provider === input.provider) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
||||
id: input.environment.id,
|
||||
driver: "sandbox",
|
||||
config: sandboxConfigForLeaseMetadata(metadataConfig),
|
||||
});
|
||||
@@ -381,7 +385,10 @@ function createSandboxEnvironmentDriver(
|
||||
|
||||
async acquireRunLease(input) {
|
||||
const storedParsed = parseEnvironmentDriverConfig(input.environment);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
|
||||
issueId: input.issueId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
});
|
||||
if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") {
|
||||
throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`);
|
||||
}
|
||||
@@ -562,6 +569,7 @@ function createSandboxEnvironmentDriver(
|
||||
|
||||
const parsed = metadataConfig
|
||||
? await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
||||
id: input.environment.id,
|
||||
driver: "sandbox",
|
||||
config: metadataConfig as unknown as Record<string, unknown>,
|
||||
})
|
||||
|
||||
@@ -335,7 +335,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
projectEnv: unknown;
|
||||
secretsSvc: RuntimeConfigSecretResolver;
|
||||
}) {
|
||||
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
input.executionRunConfig,
|
||||
input.agentId
|
||||
@@ -364,7 +364,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: { env: {}, secretKeys: new Set<string>() };
|
||||
: { env: {}, secretKeys: new Set<string>(), manifest: [] };
|
||||
if (Object.keys(projectEnvResolution.env).length > 0) {
|
||||
resolvedConfig.env = {
|
||||
...parseObject(resolvedConfig.env),
|
||||
@@ -374,7 +374,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
return { resolvedConfig, secretKeys };
|
||||
return {
|
||||
resolvedConfig,
|
||||
secretKeys,
|
||||
secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
export function extractMentionedSkillIdsFromSources(
|
||||
@@ -7023,7 +7027,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
||||
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
issueId,
|
||||
@@ -7033,6 +7037,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
projectEnv: projectContext?.env ?? null,
|
||||
secretsSvc,
|
||||
});
|
||||
if (secretManifest.length > 0) {
|
||||
context.paperclipSecrets = {
|
||||
manifest: secretManifest,
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipSecrets;
|
||||
}
|
||||
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
|
||||
@@ -33,12 +33,11 @@
|
||||
* @see services/secrets.ts — secretService used by agent env bindings
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, companySecretVersions, pluginConfig } from "@paperclipai/db";
|
||||
import type { SecretProvider } from "@paperclipai/shared";
|
||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||
import { companySecrets, pluginConfig } from "@paperclipai/db";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import {
|
||||
collectSecretRefPaths,
|
||||
isUuidSecretRef,
|
||||
@@ -59,12 +58,6 @@ function secretNotFound(secretRef: string): Error {
|
||||
return err;
|
||||
}
|
||||
|
||||
function secretVersionNotFound(secretRef: string): Error {
|
||||
const err = new Error(`No version found for secret: ${secretRef}`);
|
||||
err.name = "SecretVersionNotFoundError";
|
||||
return err;
|
||||
}
|
||||
|
||||
function invalidSecretRef(secretRef: string): Error {
|
||||
const err = new Error(`Invalid secret reference: ${secretRef}`);
|
||||
err.name = "InvalidSecretRefError";
|
||||
@@ -86,8 +79,20 @@ export function extractSecretRefsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
if (configJson == null || typeof configJson !== "object") return refs;
|
||||
return new Set(extractSecretRefPathsFromConfig(configJson, schema).keys());
|
||||
}
|
||||
|
||||
export function extractSecretRefPathsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): Map<string, Set<string>> {
|
||||
const refs = new Map<string, Set<string>>();
|
||||
const addRef = (secretRef: string, path: string) => {
|
||||
const existing = refs.get(secretRef) ?? new Set<string>();
|
||||
existing.add(path);
|
||||
refs.set(secretRef, existing);
|
||||
};
|
||||
if (configJson == null || typeof configJson !== "object") return new Map();
|
||||
|
||||
const secretPaths = collectSecretRefPaths(schema);
|
||||
|
||||
@@ -96,7 +101,7 @@ export function extractSecretRefsFromConfig(
|
||||
for (const dotPath of secretPaths) {
|
||||
const current = readConfigValueAtPath(configJson as Record<string, unknown>, dotPath);
|
||||
if (typeof current === "string" && isUuidSecretRef(current)) {
|
||||
refs.add(current);
|
||||
addRef(current, dotPath);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
@@ -107,7 +112,7 @@ export function extractSecretRefsFromConfig(
|
||||
// instanceConfigSchema.
|
||||
function walkAll(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
if (isUuidSecretRef(value)) refs.add(value);
|
||||
if (isUuidSecretRef(value)) addRef(value, "$");
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) walkAll(item);
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
@@ -207,11 +212,12 @@ export function createPluginSecretsHandler(
|
||||
): PluginSecretsService {
|
||||
const { db, pluginId } = options;
|
||||
const registry = pluginRegistryService(db);
|
||||
const secrets = secretService(db);
|
||||
|
||||
// Rate limit: max 30 resolution attempts per plugin per minute
|
||||
const rateLimiter = createRateLimiter(30, 60_000);
|
||||
|
||||
let cachedAllowedRefs: Set<string> | null = null;
|
||||
let cachedAllowedRefs: Map<string, Set<string>> | null = null;
|
||||
let cachedAllowedRefsExpiry = 0;
|
||||
const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL
|
||||
|
||||
@@ -257,11 +263,12 @@ export function createPluginSecretsHandler(
|
||||
|
||||
const schema = (plugin?.manifestJson as unknown as Record<string, unknown> | null)
|
||||
?.instanceConfigSchema as Record<string, unknown> | undefined;
|
||||
cachedAllowedRefs = extractSecretRefsFromConfig(configRow?.configJson, schema);
|
||||
cachedAllowedRefs = extractSecretRefPathsFromConfig(configRow?.configJson, schema);
|
||||
cachedAllowedRefsExpiry = now + CONFIG_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
if (!cachedAllowedRefs.has(trimmedRef)) {
|
||||
const allowedPaths = cachedAllowedRefs.get(trimmedRef);
|
||||
if (!allowedPaths) {
|
||||
// Return "not found" to avoid leaking whether the secret exists
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
@@ -279,34 +286,14 @@ export function createPluginSecretsHandler(
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Fetch the latest version's material
|
||||
// ---------------------------------------------------------------
|
||||
const versionRow = await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretVersions.secretId, secret.id),
|
||||
eq(companySecretVersions.version, secret.latestVersion),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!versionRow) {
|
||||
throw secretVersionNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 4. Resolve through the appropriate secret provider
|
||||
// ---------------------------------------------------------------
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
const resolved = await provider.resolveVersion({
|
||||
material: versionRow.material as Record<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
return await secrets.resolveSecretValue(secret.companyId, secret.id, "latest", {
|
||||
consumerType: "plugin",
|
||||
consumerId: pluginId,
|
||||
actorType: "plugin",
|
||||
actorId: pluginId,
|
||||
pluginId,
|
||||
configPath: [...allowedPaths][0] ?? "$",
|
||||
});
|
||||
|
||||
return resolved;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, ne, not, or, sql }
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companySecretBindings,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
executionWorkspaces,
|
||||
@@ -957,9 +958,15 @@ export function routineService(
|
||||
description: `Webhook auth for routine ${routineId}`,
|
||||
};
|
||||
const provider = getSecretProvider(input.provider);
|
||||
const prepared = await provider.createVersion({
|
||||
const prepared = await provider.createSecret({
|
||||
value: input.value,
|
||||
externalRef: null,
|
||||
context: {
|
||||
companyId,
|
||||
secretKey: input.name,
|
||||
secretName: input.name,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const insertSecret = async (secretDb: Db) => {
|
||||
@@ -967,11 +974,16 @@ export function routineService(
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
companyId,
|
||||
key: input.name,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: prepared.externalRef,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: input.description,
|
||||
lastRotatedAt: new Date(),
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
})
|
||||
@@ -983,10 +995,21 @@ export function routineService(
|
||||
version: 1,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
||||
providerVersionRef: prepared.providerVersionRef ?? null,
|
||||
status: "current",
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
});
|
||||
|
||||
await secretDb.insert(companySecretBindings).values({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "routine",
|
||||
targetId: routineId,
|
||||
configPath: "webhookSecret",
|
||||
});
|
||||
|
||||
return secret;
|
||||
};
|
||||
|
||||
@@ -1004,7 +1027,13 @@ export function routineService(
|
||||
.where(eq(companySecrets.id, trigger.secretId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!secret || secret.companyId !== companyId) throw notFound("Routine trigger secret not found");
|
||||
const value = await secretsSvc.resolveSecretValue(companyId, trigger.secretId, "latest");
|
||||
const value = await secretsSvc.resolveSecretValue(companyId, trigger.secretId, "latest", {
|
||||
consumerType: "routine",
|
||||
consumerId: trigger.routineId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
configPath: "webhookSecret",
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
+113
-13
@@ -41,6 +41,22 @@ type SecretConsumerContext = {
|
||||
pluginId?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeSecretManifestEntry = {
|
||||
configPath: string;
|
||||
envKey: string | null;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
version: number;
|
||||
provider: SecretProvider;
|
||||
outcome: "success" | "failure";
|
||||
errorCode?: string | null;
|
||||
};
|
||||
|
||||
type RuntimeSecretResolution = {
|
||||
value: string;
|
||||
manifestEntry: RuntimeSecretManifestEntry;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
@@ -188,15 +204,16 @@ export function secretService(db: Db) {
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function resolveSecretValue(
|
||||
async function resolveSecretValueInternal(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
context?: SecretConsumerContext,
|
||||
): Promise<string> {
|
||||
): Promise<RuntimeSecretResolution> {
|
||||
const secret = await assertSecretInCompany(companyId, secretId);
|
||||
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
|
||||
const providerId = secret.provider as SecretProvider;
|
||||
const configPath = context?.configPath ?? null;
|
||||
try {
|
||||
if (secret.status !== "active") {
|
||||
throw unprocessable("Secret is not active");
|
||||
@@ -233,8 +250,20 @@ export function secretService(db: Db) {
|
||||
outcome: "success",
|
||||
}),
|
||||
]);
|
||||
return value;
|
||||
return {
|
||||
value,
|
||||
manifestEntry: {
|
||||
configPath: configPath ?? "",
|
||||
envKey: configPath?.startsWith("env.") ? configPath.slice("env.".length) : null,
|
||||
secretId: secret.id,
|
||||
secretKey: secret.key,
|
||||
version: resolvedVersion,
|
||||
provider: providerId,
|
||||
outcome: "success",
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const errorCode = err instanceof Error ? err.message.slice(0, 120) : "resolution_failed";
|
||||
await recordAccessEvent({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
@@ -242,12 +271,21 @@ export function secretService(db: Db) {
|
||||
provider: providerId,
|
||||
context,
|
||||
outcome: "failure",
|
||||
errorCode: err instanceof Error ? err.message.slice(0, 120) : "resolution_failed",
|
||||
errorCode,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSecretValue(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
context?: SecretConsumerContext,
|
||||
): Promise<string> {
|
||||
return (await resolveSecretValueInternal(companyId, secretId, version, context)).value;
|
||||
}
|
||||
|
||||
async function normalizeEnvConfig(
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
@@ -607,6 +645,62 @@ export function secretService(db: Db) {
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
syncSecretRefsForTarget: async (
|
||||
companyId: string,
|
||||
target: { targetType: SecretBindingTargetType; targetId: string },
|
||||
refs: Array<{
|
||||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
label?: string | null;
|
||||
}>,
|
||||
) => {
|
||||
const normalizedRefs: Array<{
|
||||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector: SecretVersionSelector;
|
||||
required: boolean;
|
||||
label: string | null;
|
||||
}> = [];
|
||||
for (const ref of refs) {
|
||||
await assertSecretInCompany(companyId, ref.secretId);
|
||||
normalizedRefs.push({
|
||||
secretId: ref.secretId,
|
||||
configPath: ref.configPath,
|
||||
versionSelector: ref.versionSelector ?? "latest",
|
||||
required: ref.required ?? true,
|
||||
label: ref.label ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, target.targetType),
|
||||
eq(companySecretBindings.targetId, target.targetId),
|
||||
),
|
||||
);
|
||||
if (normalizedRefs.length === 0) return;
|
||||
await tx.insert(companySecretBindings).values(
|
||||
normalizedRefs.map((ref) => ({
|
||||
companyId,
|
||||
secretId: ref.secretId,
|
||||
targetType: target.targetType,
|
||||
targetId: target.targetId,
|
||||
configPath: ref.configPath,
|
||||
versionSelector: String(ref.versionSelector),
|
||||
required: ref.required,
|
||||
label: ref.label,
|
||||
})),
|
||||
);
|
||||
});
|
||||
return normalizedRefs;
|
||||
},
|
||||
|
||||
syncEnvBindingsForTarget: async (
|
||||
companyId: string,
|
||||
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
|
||||
@@ -711,11 +805,12 @@ export function secretService(db: Db) {
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
|
||||
): Promise<{ env: Record<string, string>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
|
||||
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>(), manifest: [] };
|
||||
const resolved: Record<string, string> = {};
|
||||
const secretKeys = new Set<string>();
|
||||
const manifest: RuntimeSecretManifestEntry[] = [];
|
||||
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
@@ -729,32 +824,35 @@ export function secretService(db: Db) {
|
||||
if (binding.type === "plain") {
|
||||
resolved[key] = binding.value;
|
||||
} else {
|
||||
resolved[key] = await resolveSecretValue(
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
binding.version,
|
||||
context ? { ...context, configPath: `env.${key}` } : undefined,
|
||||
);
|
||||
resolved[key] = secretResolution.value;
|
||||
manifest.push(secretResolution.manifestEntry);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
return { env: resolved, secretKeys };
|
||||
return { env: resolved, secretKeys, manifest };
|
||||
},
|
||||
|
||||
resolveAdapterConfigForRuntime: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
|
||||
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
const secretKeys = new Set<string>();
|
||||
const manifest: RuntimeSecretManifestEntry[] = [];
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
return { config: resolved, secretKeys };
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
}
|
||||
const record = asRecord(adapterConfig.env);
|
||||
if (!record) {
|
||||
resolved.env = {};
|
||||
return { config: resolved, secretKeys };
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
@@ -769,17 +867,19 @@ export function secretService(db: Db) {
|
||||
if (binding.type === "plain") {
|
||||
env[key] = binding.value;
|
||||
} else {
|
||||
env[key] = await resolveSecretValue(
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
binding.version,
|
||||
context ? { ...context, configPath: `env.${key}` } : undefined,
|
||||
);
|
||||
env[key] = secretResolution.value;
|
||||
manifest.push(secretResolution.manifestEntry);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
resolved.env = env;
|
||||
return { config: resolved, secretKeys };
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user