diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index ffda21c61ee..292c32481e4 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -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, diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 8490b04e418..55653be3692 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -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(), + 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(); }); }); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index fd7976cadae..5d1e306960d 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -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, diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 35b60812609..e9f5667bfb2 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -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>(); + 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, diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 2c9bdf413b0..79b0c3a5933 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -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; schema: Record | null; + context: { + consumerId: string; + issueId?: string | null; + heartbeatRunId?: string | null; + }; }): Promise> { 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; +}): Promise> { + 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, 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 { const { provider: _provider, ...driverConfig } = config as Record; return driverConfig; @@ -442,10 +485,15 @@ export async function normalizeEnvironmentConfigForPersistence(input: { export async function resolveEnvironmentDriverConfigForRuntime( db: Db, companyId: string, - environment: Pick, + environment: Pick & Partial>, + context?: { issueId?: string | null; heartbeatRunId?: string | null }, ): Promise { 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, schema: await getSandboxProviderConfigSchema(db, parsed.config.provider), + context: { + consumerId: environmentId!, + issueId: context?.issueId ?? null, + heartbeatRunId: context?.heartbeatRunId ?? null, + }, }) as SandboxEnvironmentConfig, }; } diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 733d03b2951..292d630dcfd 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -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, }) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 46eaa4142b1..29cdda9e1c5 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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() }; + : { env: {}, secretKeys: new Set(), 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, diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts index b80ae187bda..8a3ada13d77 100644 --- a/server/src/services/plugin-secrets-handler.ts +++ b/server/src/services/plugin-secrets-handler.ts @@ -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 | null, ): Set { - const refs = new Set(); - if (configJson == null || typeof configJson !== "object") return refs; + return new Set(extractSecretRefPathsFromConfig(configJson, schema).keys()); +} + +export function extractSecretRefPathsFromConfig( + configJson: unknown, + schema?: Record | null, +): Map> { + const refs = new Map>(); + const addRef = (secretRef: string, path: string) => { + const existing = refs.get(secretRef) ?? new Set(); + 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, 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 | null = null; + let cachedAllowedRefs: Map> | 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 | null) ?.instanceConfigSchema as Record | 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, - 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; }, }; } diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 14f244155a2..d40d62a8c23 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -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; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 7af1cf8f36f..4ff2d00e629 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -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 | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -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 { + ): Promise { 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 { + 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, - ): Promise<{ env: Record; secretKeys: Set }> => { + ): Promise<{ env: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const record = asRecord(envValue); - if (!record) return { env: {} as Record, secretKeys: new Set() }; + if (!record) return { env: {} as Record, secretKeys: new Set(), manifest: [] }; const resolved: Record = {}; const secretKeys = new Set(); + 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, context?: Omit, - ): Promise<{ config: Record; secretKeys: Set }> => { + ): Promise<{ config: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const resolved = { ...adapterConfig }; const secretKeys = new Set(); + 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 = {}; 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 }; }, }; }