From c7fcfee836f6cc5265e5ad5d644c886acf530d05 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 26 Apr 2026 16:39:47 -0500 Subject: [PATCH] Implement secrets service contract --- .../db/src/migrations/0082_dry_vision.sql | 124 +++++++ packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/company_secret_bindings.ts | 31 ++ .../db/src/schema/company_secret_versions.ts | 5 + packages/db/src/schema/company_secrets.ts | 11 +- packages/db/src/schema/index.ts | 2 + .../db/src/schema/secret_access_events.ts | 34 ++ packages/shared/src/constants.ts | 30 ++ packages/shared/src/index.ts | 11 + packages/shared/src/types/index.ts | 8 + packages/shared/src/types/secrets.ts | 68 +++- packages/shared/src/validators/index.ts | 3 + packages/shared/src/validators/secret.ts | 29 +- server/src/__tests__/secrets-service.test.ts | 144 ++++++++ server/src/routes/agents.ts | 16 + server/src/routes/projects.ts | 14 + server/src/routes/secrets.ts | 32 ++ server/src/services/heartbeat.ts | 34 +- server/src/services/secrets.ts | 346 +++++++++++++++++- 19 files changed, 927 insertions(+), 22 deletions(-) create mode 100644 packages/db/src/migrations/0082_dry_vision.sql create mode 100644 packages/db/src/schema/company_secret_bindings.ts create mode 100644 packages/db/src/schema/secret_access_events.ts create mode 100644 server/src/__tests__/secrets-service.test.ts diff --git a/packages/db/src/migrations/0082_dry_vision.sql b/packages/db/src/migrations/0082_dry_vision.sql new file mode 100644 index 00000000000..e10e470e7a8 --- /dev/null +++ b/packages/db/src/migrations/0082_dry_vision.sql @@ -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"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 418bb6e6bd1..a69b5eeb54c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -575,6 +575,13 @@ "when": 1778067785040, "tag": "0081_optimal_dormammu", "breakpoints": true + }, + { + "idx": 82, + "version": "7", + "when": 1777239150329, + "tag": "0082_dry_vision", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_secret_bindings.ts b/packages/db/src/schema/company_secret_bindings.ts new file mode 100644 index 00000000000..06f926914ae --- /dev/null +++ b/packages/db/src/schema/company_secret_bindings.ts @@ -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, + ), + }), +); diff --git a/packages/db/src/schema/company_secret_versions.ts b/packages/db/src/schema/company_secret_versions.ts index c17426e654f..899e8fdffdb 100644 --- a/packages/db/src/schema/company_secret_versions.ts +++ b/packages/db/src/schema/company_secret_versions.ts @@ -10,6 +10,10 @@ export const companySecretVersions = pgTable( version: integer("version").notNull(), material: jsonb("material").$type>().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), }), ); diff --git a/packages/db/src/schema/company_secrets.ts b/packages/db/src/schema/company_secrets.ts index ec8c595d8a8..0d8eb24e29f 100644 --- a/packages/db/src/schema/company_secrets.ts +++ b/packages/db/src/schema/company_secrets.ts @@ -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>(), 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), }), ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 67308bd0357..b517dbae516 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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"; diff --git a/packages/db/src/schema/secret_access_events.ts b/packages/db/src/schema/secret_access_events.ts new file mode 100644 index 00000000000..b4967f13fdb --- /dev/null +++ b/packages/db/src/schema/secret_access_events.ts @@ -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), + }), +); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 12cf3b0a538..a3d30cf1eba 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9908db17a3b..456f1ddcb7f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ddc8162d27b..0fa0b8d3b09 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -244,7 +244,15 @@ export type { EnvBinding, AgentEnvConfig, CompanySecret, + CompanySecretBinding, + CompanySecretVersion, + SecretAccessEvent, + SecretAccessOutcome, + SecretBindingTargetType, + SecretManagedMode, SecretProviderDescriptor, + SecretStatus, + SecretVersionStatus, } from "./secrets.js"; export type { Routine, diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index dc020e2b3d1..ebffb18706c 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -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; 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 | 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; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 5b89735d653..9667a5b8e13 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -282,8 +282,11 @@ export { envBindingSchema, envConfigSchema, createSecretSchema, + createSecretBindingSchema, rotateSecretSchema, + secretBindingTargetSchema, updateSecretSchema, + type CreateSecretBinding, type CreateSecret, type RotateSecret, type UpdateSecret, diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index fc2dba3c23f..18848f831f7 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -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; @@ -34,14 +42,33 @@ export type CreateSecret = z.infer; 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; 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; + +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; diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts new file mode 100644 index 00000000000..2f64fbbe926 --- /dev/null +++ b/server/src/__tests__/secrets-service.test.ts @@ -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) | null = null; + let db!: ReturnType; + + 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"); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index ce830140e42..d1b6d78cb96 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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, diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index ea9debea5bd..eccf3f7f746 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -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, { diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 1ea99ee1362..e6e2f0205e5 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -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; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dc719cb69f5..46eaa4142b1 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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; 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() }; 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, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index d288d4b3b46..c2c0a02cb37 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -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 | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -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 { 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, - 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, + 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 | 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 | 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; secretKeys: Set }> => { + resolveEnvBindings: async ( + companyId: string, + envValue: unknown, + context?: Omit, + ): Promise<{ env: Record; secretKeys: Set }> => { const record = asRecord(envValue); if (!record) return { env: {} as Record, secretKeys: new Set() }; const resolved: Record = {}; @@ -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): Promise<{ config: Record; secretKeys: Set }> => { + resolveAdapterConfigForRuntime: async ( + companyId: string, + adapterConfig: Record, + context?: Omit, + ): Promise<{ config: Record; secretKeys: Set }> => { const resolved = { ...adapterConfig }; const secretKeys = new Set(); 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); } }