Implement secrets service contract

This commit is contained in:
Dotta committed 2026-05-07 18:59:04 -05:00
1 parent 0e1a582831
commit c7fcfee836
19 files changed
+927 -22

No files matched your search

@@ -0,0 +1,124 @@
CREATE TABLE IF NOT EXISTS "company_secret_bindings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"secret_id" uuid NOT NULL,
"target_type" text NOT NULL,
"target_id" text NOT NULL,
"config_path" text NOT NULL,
"version_selector" text DEFAULT 'latest' NOT NULL,
"required" boolean DEFAULT true NOT NULL,
"label" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "secret_access_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"secret_id" uuid NOT NULL,
"version" integer,
"provider" text NOT NULL,
"actor_type" text NOT NULL,
"actor_id" text,
"consumer_type" text NOT NULL,
"consumer_id" text NOT NULL,
"config_path" text,
"issue_id" uuid,
"heartbeat_run_id" uuid,
"plugin_id" uuid,
"outcome" text NOT NULL,
"error_code" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "key" text;--> statement-breakpoint
UPDATE "company_secrets"
SET "key" = left(
regexp_replace(
regexp_replace(lower(trim(coalesce("name", "id"::text))), '[^a-z0-9_.-]+', '-', 'g'),
'^-+|-+$',
'',
'g'
),
120
)
WHERE "key" IS NULL;--> statement-breakpoint
UPDATE "company_secrets"
SET "key" = "id"::text
WHERE "key" IS NULL OR "key" = '';--> statement-breakpoint
ALTER TABLE "company_secrets" ALTER COLUMN "key" SET NOT NULL;--> statement-breakpoint
WITH ranked AS (
SELECT
"id",
"key",
row_number() OVER (PARTITION BY "company_id", "key" ORDER BY "created_at", "id") AS rn
FROM "company_secrets"
)
UPDATE "company_secrets"
SET "key" = left(ranked."key", 100) || '-' || ranked.rn::text
FROM ranked
WHERE "company_secrets"."id" = ranked."id"
AND ranked.rn > 1;--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL;--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "managed_mode" text DEFAULT 'paperclip_managed' NOT NULL;--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "provider_config_id" text;--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "provider_metadata" jsonb;--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "last_resolved_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "last_rotated_at" timestamp with time zone;--> statement-breakpoint
UPDATE "company_secrets"
SET "last_rotated_at" = "updated_at"
WHERE "last_rotated_at" IS NULL;--> statement-breakpoint
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "provider_version_ref" text;--> statement-breakpoint
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'current' NOT NULL;--> statement-breakpoint
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "fingerprint_sha256" text;--> statement-breakpoint
UPDATE "company_secret_versions"
SET "fingerprint_sha256" = "value_sha256"
WHERE "fingerprint_sha256" IS NULL;--> statement-breakpoint
ALTER TABLE "company_secret_versions" ALTER COLUMN "fingerprint_sha256" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "rotation_job_id" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_bindings_company_id_companies_id_fk') THEN
ALTER TABLE "company_secret_bindings" ADD CONSTRAINT "company_secret_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_bindings_secret_id_company_secrets_id_fk') THEN
ALTER TABLE "company_secret_bindings" ADD CONSTRAINT "company_secret_bindings_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_company_id_companies_id_fk') THEN
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_secret_id_company_secrets_id_fk') THEN
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_issue_id_issues_id_fk') THEN
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk') THEN
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_plugin_id_plugins_id_fk') THEN
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_bindings_company_idx" ON "company_secret_bindings" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_bindings_secret_idx" ON "company_secret_bindings" USING btree ("secret_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_bindings_target_idx" ON "company_secret_bindings" USING btree ("company_id","target_type","target_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "company_secret_bindings_target_path_uq" ON "company_secret_bindings" USING btree ("company_id","target_type","target_id","config_path");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "secret_access_events_company_created_idx" ON "secret_access_events" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "secret_access_events_secret_created_idx" ON "secret_access_events" USING btree ("secret_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "secret_access_events_consumer_idx" ON "secret_access_events" USING btree ("company_id","consumer_type","consumer_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "secret_access_events_run_idx" ON "secret_access_events" USING btree ("heartbeat_run_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_versions_fingerprint_idx" ON "company_secret_versions" USING btree ("fingerprint_sha256");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_key_uq" ON "company_secrets" USING btree ("company_id","key");
@@ -575,6 +575,13 @@
"when": 1778067785040,
"tag": "0081_optimal_dormammu",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1777239150329,
"tag": "0082_dry_vision",
"breakpoints": true
}
]
}
@@ -0,0 +1,31 @@
import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { companySecrets } from "./company_secrets.js";
export const companySecretBindings = pgTable(
"company_secret_bindings",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
targetType: text("target_type").notNull(),
targetId: text("target_id").notNull(),
configPath: text("config_path").notNull(),
versionSelector: text("version_selector").notNull().default("latest"),
required: boolean("required").notNull().default(true),
label: text("label"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("company_secret_bindings_company_idx").on(table.companyId),
secretIdx: index("company_secret_bindings_secret_idx").on(table.secretId),
targetIdx: index("company_secret_bindings_target_idx").on(table.companyId, table.targetType, table.targetId),
targetPathUq: uniqueIndex("company_secret_bindings_target_path_uq").on(
table.companyId,
table.targetType,
table.targetId,
table.configPath,
),
}),
);
@@ -10,6 +10,10 @@ export const companySecretVersions = pgTable(
version: integer("version").notNull(),
material: jsonb("material").$type<Record<string, unknown>>().notNull(),
valueSha256: text("value_sha256").notNull(),
providerVersionRef: text("provider_version_ref"),
status: text("status").notNull().default("current"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
rotationJobId: text("rotation_job_id"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -18,6 +22,7 @@ export const companySecretVersions = pgTable(
(table) => ({
secretIdx: index("company_secret_versions_secret_idx").on(table.secretId, table.createdAt),
valueHashIdx: index("company_secret_versions_value_sha256_idx").on(table.valueSha256),
fingerprintIdx: index("company_secret_versions_fingerprint_idx").on(table.fingerprintSha256),
secretVersionUq: uniqueIndex("company_secret_versions_secret_version_uq").on(table.secretId, table.version),
}),
);
+10 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
@@ -7,11 +7,19 @@ export const companySecrets = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
key: text("key").notNull(),
name: text("name").notNull(),
provider: text("provider").notNull().default("local_encrypted"),
status: text("status").notNull().default("active"),
managedMode: text("managed_mode").notNull().default("paperclip_managed"),
externalRef: text("external_ref"),
providerConfigId: text("provider_config_id"),
providerMetadata: jsonb("provider_metadata").$type<Record<string, unknown>>(),
latestVersion: integer("latest_version").notNull().default(1),
description: text("description"),
lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }),
lastRotatedAt: timestamp("last_rotated_at", { withTimezone: true }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -21,5 +29,6 @@ export const companySecrets = pgTable(
companyIdx: index("company_secrets_company_idx").on(table.companyId),
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
companyNameUq: uniqueIndex("company_secrets_company_name_uq").on(table.companyId, table.name),
companyKeyUq: uniqueIndex("company_secrets_company_key_uq").on(table.companyId, table.key),
}),
);
+2
View File
@@ -61,6 +61,8 @@ export { approvalComments } from "./approval_comments.js";
export { activityLog } from "./activity_log.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { companySecretBindings } from "./company_secret_bindings.js";
export { secretAccessEvents } from "./secret_access_events.js";
export { companySkills } from "./company_skills.js";
export { plugins } from "./plugins.js";
export { pluginConfig } from "./plugin_config.js";
@@ -0,0 +1,34 @@
import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { companySecrets } from "./company_secrets.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
import { plugins } from "./plugins.js";
export const secretAccessEvents = pgTable(
"secret_access_events",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
version: integer("version"),
provider: text("provider").notNull(),
actorType: text("actor_type").notNull(),
actorId: text("actor_id"),
consumerType: text("consumer_type").notNull(),
consumerId: text("consumer_id").notNull(),
configPath: text("config_path"),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }),
outcome: text("outcome").notNull(),
errorCode: text("error_code"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyCreatedIdx: index("secret_access_events_company_created_idx").on(table.companyId, table.createdAt),
secretCreatedIdx: index("secret_access_events_secret_created_idx").on(table.secretId, table.createdAt),
consumerIdx: index("secret_access_events_consumer_idx").on(table.companyId, table.consumerType, table.consumerId),
runIdx: index("secret_access_events_run_idx").on(table.heartbeatRunId),
}),
);
+30
View File
@@ -395,6 +395,36 @@ export const SECRET_PROVIDERS = [
] as const;
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const;
export type SecretStatus = (typeof SECRET_STATUSES)[number];
export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const;
export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number];
export const SECRET_VERSION_STATUSES = [
"current",
"previous",
"disabled",
"destroyed",
"failed",
] as const;
export type SecretVersionStatus = (typeof SECRET_VERSION_STATUSES)[number];
export const SECRET_BINDING_TARGET_TYPES = [
"agent",
"project",
"environment",
"routine",
"plugin",
"issue",
"run",
"system",
] as const;
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const;
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
+11
View File
@@ -530,7 +530,16 @@ export type {
EnvBinding,
AgentEnvConfig,
CompanySecret,
CompanySecretBinding,
CompanySecretVersion,
SecretAccessEvent,
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
SecretProviderDescriptor,
SecretStatus,
SecretVersionSelector,
SecretVersionStatus,
Routine,
RoutineManagedByPlugin,
RoutineVariable,
@@ -826,7 +835,9 @@ export {
envBindingSchema,
envConfigSchema,
createSecretSchema,
createSecretBindingSchema,
rotateSecretSchema,
secretBindingTargetSchema,
updateSecretSchema,
createRoutineSchema,
updateRoutineSchema,
+8
View File
@@ -244,7 +244,15 @@ export type {
EnvBinding,
AgentEnvConfig,
CompanySecret,
CompanySecretBinding,
CompanySecretVersion,
SecretAccessEvent,
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
SecretProviderDescriptor,
SecretStatus,
SecretVersionStatus,
} from "./secrets.js";
export type {
Routine,
+63 -5
View File
@@ -1,8 +1,13 @@
export type SecretProvider =
| "local_encrypted"
| "aws_secrets_manager"
| "gcp_secret_manager"
| "vault";
import type {
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
SecretProvider,
SecretStatus,
SecretVersionStatus,
} from "../constants.js";
export type { SecretAccessOutcome, SecretBindingTargetType, SecretManagedMode, SecretProvider, SecretStatus, SecretVersionStatus };
export type SecretVersionSelector = number | "latest";
@@ -25,11 +30,19 @@ export type AgentEnvConfig = Record<string, EnvBinding>;
export interface CompanySecret {
id: string;
companyId: string;
key: string;
name: string;
provider: SecretProvider;
status: SecretStatus;
managedMode: SecretManagedMode;
externalRef: string | null;
providerConfigId: string | null;
providerMetadata: Record<string, unknown> | null;
latestVersion: number;
description: string | null;
lastResolvedAt: Date | null;
lastRotatedAt: Date | null;
deletedAt: Date | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdAt: Date;
@@ -41,3 +54,48 @@ export interface SecretProviderDescriptor {
label: string;
requiresExternalRef: boolean;
}
export interface CompanySecretVersion {
id: string;
secretId: string;
version: number;
providerVersionRef: string | null;
status: SecretVersionStatus;
fingerprintSha256: string;
rotationJobId: string | null;
createdAt: Date;
revokedAt: Date | null;
}
export interface CompanySecretBinding {
id: string;
companyId: string;
secretId: string;
targetType: SecretBindingTargetType;
targetId: string;
configPath: string;
versionSelector: SecretVersionSelector;
required: boolean;
label: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface SecretAccessEvent {
id: string;
companyId: string;
secretId: string;
version: number | null;
provider: SecretProvider;
actorType: "agent" | "user" | "system" | "plugin";
actorId: string | null;
consumerType: SecretBindingTargetType;
consumerId: string;
configPath: string | null;
issueId: string | null;
heartbeatRunId: string | null;
pluginId: string | null;
outcome: SecretAccessOutcome;
errorCode: string | null;
createdAt: Date;
}
+3
View File
@@ -282,8 +282,11 @@ export {
envBindingSchema,
envConfigSchema,
createSecretSchema,
createSecretBindingSchema,
rotateSecretSchema,
secretBindingTargetSchema,
updateSecretSchema,
type CreateSecretBinding,
type CreateSecret,
type RotateSecret,
type UpdateSecret,
+28 -1
View File
@@ -1,5 +1,10 @@
import { z } from "zod";
import { SECRET_PROVIDERS } from "../constants.js";
import {
SECRET_BINDING_TARGET_TYPES,
SECRET_MANAGED_MODES,
SECRET_PROVIDERS,
SECRET_STATUSES,
} from "../constants.js";
export const envBindingPlainSchema = z.object({
type: z.literal("plain"),
@@ -23,10 +28,13 @@ export const envConfigSchema = z.record(envBindingSchema);
export const createSecretSchema = z.object({
name: z.string().min(1),
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
provider: z.enum(SECRET_PROVIDERS).optional(),
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
value: z.string().min(1),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(),
});
export type CreateSecret = z.infer<typeof createSecretSchema>;
@@ -34,14 +42,33 @@ export type CreateSecret = z.infer<typeof createSecretSchema>;
export const rotateSecretSchema = z.object({
value: z.string().min(1),
externalRef: z.string().optional().nullable(),
providerVersionRef: z.string().optional().nullable(),
});
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
export const updateSecretSchema = z.object({
name: z.string().min(1).optional(),
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
status: z.enum(SECRET_STATUSES).optional(),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(),
});
export type UpdateSecret = z.infer<typeof updateSecretSchema>;
export const secretBindingTargetSchema = z.object({
targetType: z.enum(SECRET_BINDING_TARGET_TYPES),
targetId: z.string().min(1),
configPath: z.string().min(1),
});
export const createSecretBindingSchema = secretBindingTargetSchema.extend({
secretId: z.string().uuid(),
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"),
required: z.boolean().default(true),
label: z.string().optional().nullable(),
});
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
@@ -0,0 +1,144 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
companies,
companySecretBindings,
companySecretVersions,
companySecrets,
createDb,
secretAccessEvents,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { secretService } from "../services/secrets.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping secrets service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("secretService", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("secrets-service");
stopDb = started.stop;
db = createDb(started.connectionString);
});
afterEach(async () => {
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
});
async function seedCompany(name = "Acme") {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name,
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
return companyId;
}
it("rejects cross-company secret references during env normalization", async () => {
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
const svc = secretService(db);
const foreignSecret = await svc.create(companyB, {
name: `foreign-${randomUUID()}`,
provider: "local_encrypted",
value: "secret-value",
});
await expect(
svc.normalizeEnvBindingsForPersistence(companyA, {
API_KEY: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
}),
).rejects.toThrow(/same company/i);
});
it("prevents duplicate bindings for a target config path", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const firstSecret = await svc.create(companyId, {
name: `first-${randomUUID()}`,
provider: "local_encrypted",
value: "one",
});
const secondSecret = await svc.create(companyId, {
name: `second-${randomUUID()}`,
provider: "local_encrypted",
value: "two",
});
await svc.createBinding({
companyId,
secretId: firstSecret.id,
targetType: "agent",
targetId: "agent-1",
configPath: "env.API_KEY",
});
await expect(
svc.createBinding({
companyId,
secretId: secondSecret.id,
targetType: "agent",
targetId: "agent-1",
configPath: "env.API_KEY",
}),
).rejects.toThrow(/already exists/i);
});
it("enforces binding context and records value-free access events", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `runtime-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const env = {
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-2",
actorType: "agent",
actorId: "agent-2",
}),
).rejects.toThrow(/not bound/i);
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
});
expect(resolved.env.API_KEY).toBe("runtime-secret");
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toHaveLength(2);
expect(events.map((event) => event.outcome).sort()).toEqual(["failure", "success"]);
expect(JSON.stringify(events)).not.toContain("runtime-secret");
});
});
+16
View File
@@ -2189,6 +2189,14 @@ export function agentRoutes(
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
const agentEnv = asRecord(agent.adapterConfig)?.env;
if (agentEnv) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
const actor = getActorInfo(req);
await logActivity(db, {
@@ -2665,6 +2673,14 @@ export function agentRoutes(
res.status(404).json({ error: "Agent not found" });
return;
}
if (touchesAdapterConfiguration) {
const agentEnv = asRecord(agent.adapterConfig)?.env;
await secretsSvc.syncEnvBindingsForTarget?.(
agent.companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
await logActivity(db, {
companyId: agent.companyId,
+14
View File
@@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
);
}
const project = await svc.create(companyId, projectData);
if (project.env) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
let createdWorkspaceId: string | null = null;
if (workspace) {
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
@@ -207,6 +214,13 @@ export function projectRoutes(db: Db) {
res.status(404).json({ error: "Project not found" });
return;
}
if (body.env !== undefined) {
await secretsSvc.syncEnvBindingsForTarget?.(
project.companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
const actor = getActorInfo(req);
await logActivity(db, {
+32
View File
@@ -45,10 +45,13 @@ export function secretRoutes(db: Db) {
companyId,
{
name: req.body.name,
key: req.body.key,
provider: req.body.provider ?? defaultProvider,
managedMode: req.body.managedMode,
value: req.body.value,
description: req.body.description,
externalRef: req.body.externalRef,
providerMetadata: req.body.providerMetadata,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
@@ -110,8 +113,11 @@ export function secretRoutes(db: Db) {
const updated = await svc.update(id, {
name: req.body.name,
key: req.body.key,
status: req.body.status,
description: req.body.description,
externalRef: req.body.externalRef,
providerMetadata: req.body.providerMetadata,
});
if (!updated) {
@@ -132,6 +138,32 @@ export function secretRoutes(db: Db) {
res.json(updated);
});
router.get("/secrets/:id/usage", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const bindings = await svc.listBindings(existing.companyId, existing.id);
res.json({ secretId: existing.id, bindings });
});
router.get("/secrets/:id/access-events", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const events = await svc.listAccessEvents(existing.companyId, existing.id);
res.json(events);
});
router.delete("/secrets/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
+33 -1
View File
@@ -327,6 +327,10 @@ type RuntimeConfigSecretResolver = Pick<
export async function resolveExecutionRunAdapterConfig(input: {
companyId: string;
agentId?: string | null;
issueId?: string | null;
heartbeatRunId?: string | null;
projectId?: string | null;
executionRunConfig: Record<string, unknown>;
projectEnv: unknown;
secretsSvc: RuntimeConfigSecretResolver;
@@ -334,9 +338,32 @@ export async function resolveExecutionRunAdapterConfig(input: {
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
input.companyId,
input.executionRunConfig,
input.agentId
? {
consumerType: "agent",
consumerId: input.agentId,
actorType: "agent",
actorId: input.agentId,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
}
: undefined,
);
const projectEnvResolution = input.projectEnv
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
? await input.secretsSvc.resolveEnvBindings(
input.companyId,
input.projectEnv,
input.projectId
? {
consumerType: "project",
consumerId: input.projectId,
actorType: "agent",
actorId: input.agentId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
}
: undefined,
)
: { env: {}, secretKeys: new Set<string>() };
if (Object.keys(projectEnvResolution.env).length > 0) {
resolvedConfig.env = {
@@ -6790,6 +6817,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const projectContext = executionProjectId
? await db
.select({
id: projects.id,
executionWorkspacePolicy: projects.executionWorkspacePolicy,
env: projects.env,
})
@@ -6997,6 +7025,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
companyId: agent.companyId,
agentId: agent.id,
issueId,
heartbeatRunId: run.id,
projectId: projectContext?.id ?? null,
executionRunConfig,
projectEnv: projectContext?.env ?? null,
secretsSvc,
+332 -14
View File
@@ -1,7 +1,18 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecrets, companySecretVersions } from "@paperclipai/db";
import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/shared";
import {
companySecretBindings,
companySecrets,
companySecretVersions,
secretAccessEvents,
} from "@paperclipai/db";
import type {
AgentEnvConfig,
EnvBinding,
SecretBindingTargetType,
SecretProvider,
SecretVersionSelector,
} from "@paperclipai/shared";
import { envBindingSchema } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js";
@@ -15,6 +26,17 @@ type CanonicalEnvBinding =
| { type: "plain"; value: string }
| { type: "secret_ref"; secretId: string; version: number | "latest" };
type SecretConsumerContext = {
consumerType: SecretBindingTargetType;
consumerId: string;
configPath?: string | null;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
issueId?: string | null;
heartbeatRunId?: string | null;
pluginId?: string | null;
};
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>;
@@ -24,6 +46,15 @@ function isSensitiveEnvKey(key: string) {
return SENSITIVE_ENV_KEY_RE.test(key);
}
function normalizeSecretKey(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120);
}
function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
if (typeof binding === "string") {
return { type: "plain", value: binding };
@@ -73,6 +104,79 @@ export function secretService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function getBinding(input: {
companyId: string;
secretId: string;
consumerType: SecretBindingTargetType;
consumerId: string;
configPath: string;
}) {
return db
.select()
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.companyId, input.companyId),
eq(companySecretBindings.secretId, input.secretId),
eq(companySecretBindings.targetType, input.consumerType),
eq(companySecretBindings.targetId, input.consumerId),
eq(companySecretBindings.configPath, input.configPath),
),
)
.then((rows) => rows[0] ?? null);
}
async function assertBindingContext(
companyId: string,
secretId: string,
context: SecretConsumerContext | undefined,
) {
if (!context) return;
if (!context.configPath) {
throw unprocessable("Secret resolution requires a binding config path");
}
const binding = await getBinding({
companyId,
secretId,
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: context.configPath,
});
if (!binding) {
throw unprocessable(
`Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`,
);
}
}
async function recordAccessEvent(input: {
companyId: string;
secretId: string;
version: number | null;
provider: SecretProvider;
context: SecretConsumerContext | undefined;
outcome: "success" | "failure";
errorCode?: string | null;
}) {
if (!input.context) return;
await db.insert(secretAccessEvents).values({
companyId: input.companyId,
secretId: input.secretId,
version: input.version,
provider: input.provider,
actorType: input.context.actorType ?? "system",
actorId: input.context.actorId ?? null,
consumerType: input.context.consumerType,
consumerId: input.context.consumerId,
configPath: input.context.configPath ?? null,
issueId: input.context.issueId ?? null,
heartbeatRunId: input.context.heartbeatRunId ?? null,
pluginId: input.context.pluginId ?? null,
outcome: input.outcome,
errorCode: input.errorCode ?? null,
});
}
async function assertSecretInCompany(companyId: string, secretId: string) {
const secret = await getById(secretId);
if (!secret) throw notFound("Secret not found");
@@ -84,16 +188,53 @@ export function secretService(db: Db) {
companyId: string,
secretId: string,
version: number | "latest",
context?: SecretConsumerContext,
): Promise<string> {
const secret = await assertSecretInCompany(companyId, secretId);
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
if (!versionRow) throw notFound("Secret version not found");
const provider = getSecretProvider(secret.provider as SecretProvider);
return provider.resolveVersion({
material: versionRow.material as Record<string, unknown>,
externalRef: secret.externalRef,
});
const providerId = secret.provider as SecretProvider;
try {
if (secret.status !== "active") {
throw unprocessable("Secret is not active");
}
await assertBindingContext(companyId, secret.id, context);
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
if (!versionRow) throw notFound("Secret version not found");
if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) {
throw unprocessable("Secret version is not active");
}
const provider = getSecretProvider(providerId);
const value = await provider.resolveVersion({
material: versionRow.material as Record<string, unknown>,
externalRef: secret.externalRef,
});
await Promise.all([
db
.update(companySecrets)
.set({ lastResolvedAt: new Date(), updatedAt: new Date() })
.where(eq(companySecrets.id, secret.id)),
recordAccessEvent({
companyId,
secretId: secret.id,
version: resolvedVersion,
provider: providerId,
context,
outcome: "success",
}),
]);
return value;
} catch (err) {
await recordAccessEvent({
companyId,
secretId: secret.id,
version: resolvedVersion,
provider: providerId,
context,
outcome: "failure",
errorCode: err instanceof Error ? err.message.slice(0, 120) : "resolution_failed",
});
throw err;
}
}
async function normalizeEnvConfig(
@@ -162,6 +303,24 @@ export function secretService(db: Db) {
.where(eq(companySecrets.companyId, companyId))
.orderBy(desc(companySecrets.createdAt)),
listBindings: (companyId: string, secretId?: string) =>
db
.select()
.from(companySecretBindings)
.where(
secretId
? and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId))
: eq(companySecretBindings.companyId, companyId),
)
.orderBy(desc(companySecretBindings.createdAt)),
listAccessEvents: (companyId: string, secretId: string) =>
db
.select()
.from(secretAccessEvents)
.where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId)))
.orderBy(desc(secretAccessEvents.createdAt)),
getById,
getByName,
resolveSecretValue,
@@ -172,13 +331,24 @@ export function secretService(db: Db) {
name: string;
provider: SecretProvider;
value: string;
key?: string | null;
managedMode?: "paperclip_managed" | "external_reference";
description?: string | null;
externalRef?: string | null;
providerMetadata?: Record<string, unknown> | null;
},
actor?: { userId?: string | null; agentId?: string | null },
) => {
const existing = await getByName(companyId, input.name);
if (existing) throw conflict(`Secret already exists: ${input.name}`);
const key = normalizeSecretKey(input.key ?? input.name);
if (!key) throw unprocessable("Secret key is required");
const duplicateKey = await db
.select()
.from(companySecrets)
.where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.key, key)))
.then((rows) => rows[0] ?? null);
if (duplicateKey) throw conflict(`Secret key already exists: ${key}`);
const provider = getSecretProvider(input.provider);
const prepared = await provider.createVersion({
@@ -191,11 +361,16 @@ export function secretService(db: Db) {
.insert(companySecrets)
.values({
companyId,
key,
name: input.name,
provider: input.provider,
status: "active",
managedMode: input.managedMode ?? "paperclip_managed",
externalRef: prepared.externalRef,
providerMetadata: input.providerMetadata ?? null,
latestVersion: 1,
description: input.description ?? null,
lastRotatedAt: new Date(),
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
})
@@ -207,6 +382,8 @@ export function secretService(db: Db) {
version: 1,
material: prepared.material,
valueSha256: prepared.valueSha256,
fingerprintSha256: prepared.valueSha256,
status: "current",
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
});
@@ -230,11 +407,17 @@ export function secretService(db: Db) {
});
return db.transaction(async (tx) => {
await tx
.update(companySecretVersions)
.set({ status: "previous" })
.where(eq(companySecretVersions.secretId, secret.id));
await tx.insert(companySecretVersions).values({
secretId: secret.id,
version: nextVersion,
material: prepared.material,
valueSha256: prepared.valueSha256,
fingerprintSha256: prepared.valueSha256,
status: "current",
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
});
@@ -244,6 +427,7 @@ export function secretService(db: Db) {
.set({
latestVersion: nextVersion,
externalRef: prepared.externalRef,
lastRotatedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(companySecrets.id, secret.id))
@@ -257,7 +441,14 @@ export function secretService(db: Db) {
update: async (
secretId: string,
patch: { name?: string; description?: string | null; externalRef?: string | null },
patch: {
name?: string;
key?: string;
status?: "active" | "disabled" | "archived" | "deleted";
description?: string | null;
externalRef?: string | null;
providerMetadata?: Record<string, unknown> | null;
},
) => {
const secret = await getById(secretId);
if (!secret) throw notFound("Secret not found");
@@ -268,15 +459,33 @@ export function secretService(db: Db) {
throw conflict(`Secret already exists: ${patch.name}`);
}
}
const nextKey = patch.key ? normalizeSecretKey(patch.key) : secret.key;
if (!nextKey) throw unprocessable("Secret key is required");
if (nextKey !== secret.key) {
const duplicateKey = await db
.select()
.from(companySecrets)
.where(and(eq(companySecrets.companyId, secret.companyId), eq(companySecrets.key, nextKey)))
.then((rows) => rows[0] ?? null);
if (duplicateKey && duplicateKey.id !== secret.id) {
throw conflict(`Secret key already exists: ${nextKey}`);
}
}
const deleting = patch.status === "deleted";
return db
.update(companySecrets)
.set({
key: nextKey,
name: patch.name ?? secret.name,
status: patch.status ?? secret.status,
description:
patch.description === undefined ? secret.description : patch.description,
externalRef:
patch.externalRef === undefined ? secret.externalRef : patch.externalRef,
providerMetadata:
patch.providerMetadata === undefined ? secret.providerMetadata : patch.providerMetadata,
deletedAt: deleting ? new Date() : secret.deletedAt,
updatedAt: new Date(),
})
.where(eq(companySecrets.id, secret.id))
@@ -284,6 +493,97 @@ export function secretService(db: Db) {
.then((rows) => rows[0] ?? null);
},
createBinding: async (input: {
companyId: string;
secretId: string;
targetType: SecretBindingTargetType;
targetId: string;
configPath: string;
versionSelector?: SecretVersionSelector;
required?: boolean;
label?: string | null;
}) => {
await assertSecretInCompany(input.companyId, input.secretId);
const existing = await db
.select()
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.companyId, input.companyId),
eq(companySecretBindings.targetType, input.targetType),
eq(companySecretBindings.targetId, input.targetId),
eq(companySecretBindings.configPath, input.configPath),
),
)
.then((rows) => rows[0] ?? null);
if (existing) throw conflict(`Secret binding already exists at ${input.configPath}`);
return db
.insert(companySecretBindings)
.values({
companyId: input.companyId,
secretId: input.secretId,
targetType: input.targetType,
targetId: input.targetId,
configPath: input.configPath,
versionSelector: String(input.versionSelector ?? "latest"),
required: input.required ?? true,
label: input.label ?? null,
})
.returning()
.then((rows) => rows[0]);
},
syncEnvBindingsForTarget: async (
companyId: string,
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
envValue: unknown,
) => {
const record = asRecord(envValue) ?? {};
const refs: Array<{
secretId: string;
configPath: string;
versionSelector: SecretVersionSelector;
}> = [];
const pathPrefix = target.pathPrefix ?? "env";
for (const [key, rawBinding] of Object.entries(record)) {
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) continue;
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type !== "secret_ref") continue;
await assertSecretInCompany(companyId, binding.secretId);
refs.push({
secretId: binding.secretId,
configPath: `${pathPrefix}.${key}`,
versionSelector: binding.version,
});
}
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 (refs.length === 0) return;
await tx.insert(companySecretBindings).values(
refs.map((ref) => ({
companyId,
secretId: ref.secretId,
targetType: target.targetType,
targetId: target.targetId,
configPath: ref.configPath,
versionSelector: String(ref.versionSelector),
required: true,
})),
);
});
return refs;
},
remove: async (secretId: string) => {
const secret = await getById(secretId);
if (!secret) return null;
@@ -320,7 +620,11 @@ export function secretService(db: Db) {
return normalized;
},
resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
resolveEnvBindings: async (
companyId: string,
envValue: unknown,
context?: Omit<SecretConsumerContext, "configPath">,
): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
const record = asRecord(envValue);
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
const resolved: Record<string, string> = {};
@@ -338,14 +642,23 @@ export function secretService(db: Db) {
if (binding.type === "plain") {
resolved[key] = binding.value;
} else {
resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
resolved[key] = await resolveSecretValue(
companyId,
binding.secretId,
binding.version,
context ? { ...context, configPath: `env.${key}` } : undefined,
);
secretKeys.add(key);
}
}
return { env: resolved, secretKeys };
},
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
resolveAdapterConfigForRuntime: async (
companyId: string,
adapterConfig: Record<string, unknown>,
context?: Omit<SecretConsumerContext, "configPath">,
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
const resolved = { ...adapterConfig };
const secretKeys = new Set<string>();
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
@@ -369,7 +682,12 @@ export function secretService(db: Db) {
if (binding.type === "plain") {
env[key] = binding.value;
} else {
env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
env[key] = await resolveSecretValue(
companyId,
binding.secretId,
binding.version,
context ? { ...context, configPath: `env.${key}` } : undefined,
);
secretKeys.add(key);
}
}