Enforce runtime secret binding materialization

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
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();
});
});
+13
View File
@@ -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,
+25
View File
@@ -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,
+64 -2
View File
@@ -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,
};
}
+10 -2
View File
@@ -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>,
})
+15 -4
View File
@@ -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,
+31 -44
View File
@@ -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;
},
};
}
+31 -2
View File
@@ -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
View File
@@ -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 };
},
};
}