From ef1f56ef4cbcee46d2caf0e18fd26e41c9d2f8fc Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 6 May 2026 08:41:11 -0500 Subject: [PATCH] Implement provider vault data model and API Co-Authored-By: Paperclip --- .../0082_company_secret_provider_configs.sql | 51 +++ packages/db/src/migrations/meta/_journal.json | 7 + .../schema/company_secret_provider_configs.ts | 33 ++ packages/db/src/schema/company_secrets.ts | 4 +- packages/db/src/schema/index.ts | 1 + packages/shared/src/api.ts | 1 + packages/shared/src/constants.ts | 18 ++ packages/shared/src/index.ts | 17 + packages/shared/src/types/index.ts | 4 + packages/shared/src/types/secrets.ts | 81 ++++- packages/shared/src/validators/index.ts | 9 + packages/shared/src/validators/secret.ts | 114 +++++++ server/src/__tests__/secrets-routes.test.ts | 135 +++++++- server/src/__tests__/secrets-service.test.ts | 66 +++- server/src/routes/secrets.ts | 195 +++++++++++ server/src/services/secrets.ts | 305 +++++++++++++++++- 16 files changed, 1026 insertions(+), 15 deletions(-) create mode 100644 packages/db/src/migrations/0082_company_secret_provider_configs.sql create mode 100644 packages/db/src/schema/company_secret_provider_configs.ts diff --git a/packages/db/src/migrations/0082_company_secret_provider_configs.sql b/packages/db/src/migrations/0082_company_secret_provider_configs.sql new file mode 100644 index 00000000000..b3426f52960 --- /dev/null +++ b/packages/db/src/migrations/0082_company_secret_provider_configs.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS "company_secret_provider_configs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "provider" text NOT NULL, + "display_name" text NOT NULL, + "status" text DEFAULT 'ready' NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "health_status" text, + "health_checked_at" timestamp with time zone, + "health_message" text, + "health_details" jsonb, + "disabled_at" timestamp with time zone, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_provider_configs_company_id_companies_id_fk') THEN + ALTER TABLE "company_secret_provider_configs" ADD CONSTRAINT "company_secret_provider_configs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("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 = 'company_secret_provider_configs_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "company_secret_provider_configs" ADD CONSTRAINT "company_secret_provider_configs_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +UPDATE "company_secrets" +SET "provider_config_id" = NULL +WHERE "provider_config_id" IS NOT NULL + AND "provider_config_id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; +--> statement-breakpoint +ALTER TABLE "company_secrets" ALTER COLUMN "provider_config_id" TYPE uuid USING "provider_config_id"::uuid; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secrets_provider_config_id_company_secret_provider_configs_id_fk') THEN + ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_provider_config_id_company_secret_provider_configs_id_fk" FOREIGN KEY ("provider_config_id") REFERENCES "public"."company_secret_provider_configs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_secret_provider_configs_company_idx" ON "company_secret_provider_configs" USING btree ("company_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_secret_provider_configs_company_provider_idx" ON "company_secret_provider_configs" USING btree ("company_id","provider"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_secret_provider_configs_default_uq" ON "company_secret_provider_configs" USING btree ("company_id","provider") WHERE "is_default" = true; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_secrets_provider_config_idx" ON "company_secrets" USING btree ("provider_config_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index a69b5eeb54c..f5fd6934cf4 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -582,6 +582,13 @@ "when": 1777239150329, "tag": "0082_dry_vision", "breakpoints": true + }, + { + "idx": 82, + "version": "7", + "when": 1778074536410, + "tag": "0082_company_secret_provider_configs", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_secret_provider_configs.ts b/packages/db/src/schema/company_secret_provider_configs.ts new file mode 100644 index 00000000000..4f877b62afd --- /dev/null +++ b/packages/db/src/schema/company_secret_provider_configs.ts @@ -0,0 +1,33 @@ +import { sql } from "drizzle-orm"; +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { agents } from "./agents.js"; + +export const companySecretProviderConfigs = pgTable( + "company_secret_provider_configs", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + displayName: text("display_name").notNull(), + status: text("status").notNull().default("ready"), + isDefault: boolean("is_default").notNull().default(false), + config: jsonb("config").$type>().notNull().default({}), + healthStatus: text("health_status"), + healthCheckedAt: timestamp("health_checked_at", { withTimezone: true }), + healthMessage: text("health_message"), + healthDetails: jsonb("health_details").$type>(), + disabledAt: timestamp("disabled_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(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("company_secret_provider_configs_company_idx").on(table.companyId), + companyProviderIdx: index("company_secret_provider_configs_company_provider_idx").on(table.companyId, table.provider), + companyDefaultProviderUq: uniqueIndex("company_secret_provider_configs_default_uq") + .on(table.companyId, table.provider) + .where(sql`${table.isDefault} = true`), + }), +); diff --git a/packages/db/src/schema/company_secrets.ts b/packages/db/src/schema/company_secrets.ts index 0d8eb24e29f..9499d20cc8c 100644 --- a/packages/db/src/schema/company_secrets.ts +++ b/packages/db/src/schema/company_secrets.ts @@ -1,6 +1,7 @@ import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { agents } from "./agents.js"; +import { companySecretProviderConfigs } from "./company_secret_provider_configs.js"; export const companySecrets = pgTable( "company_secrets", @@ -13,7 +14,7 @@ export const companySecrets = pgTable( status: text("status").notNull().default("active"), managedMode: text("managed_mode").notNull().default("paperclip_managed"), externalRef: text("external_ref"), - providerConfigId: text("provider_config_id"), + providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }), providerMetadata: jsonb("provider_metadata").$type>(), latestVersion: integer("latest_version").notNull().default(1), description: text("description"), @@ -28,6 +29,7 @@ export const companySecrets = pgTable( (table) => ({ companyIdx: index("company_secrets_company_idx").on(table.companyId), companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider), + providerConfigIdx: index("company_secrets_provider_config_idx").on(table.providerConfigId), 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 b517dbae516..9099f9044c6 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -59,6 +59,7 @@ export { financeEvents } from "./finance_events.js"; export { approvals } from "./approvals.js"; export { approvalComments } from "./approval_comments.js"; export { activityLog } from "./activity_log.js"; +export { companySecretProviderConfigs } from "./company_secret_provider_configs.js"; export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; export { companySecretBindings } from "./company_secret_bindings.js"; diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index eef841f234a..38988c6fc2e 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -11,6 +11,7 @@ export const API = { goals: `${API_PREFIX}/goals`, approvals: `${API_PREFIX}/approvals`, secrets: `${API_PREFIX}/secrets`, + secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`, costs: `${API_PREFIX}/costs`, activity: `${API_PREFIX}/activity`, dashboard: `${API_PREFIX}/dashboard`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index a3d30cf1eba..640f7563c05 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -395,6 +395,24 @@ export const SECRET_PROVIDERS = [ ] as const; export type SecretProvider = (typeof SECRET_PROVIDERS)[number]; +export const SECRET_PROVIDER_CONFIG_STATUSES = [ + "ready", + "warning", + "coming_soon", + "disabled", +] as const; +export type SecretProviderConfigStatus = (typeof SECRET_PROVIDER_CONFIG_STATUSES)[number]; + +export const SECRET_PROVIDER_CONFIG_HEALTH_STATUSES = [ + "ready", + "warning", + "error", + "coming_soon", + "disabled", +] as const; +export type SecretProviderConfigHealthStatus = + (typeof SECRET_PROVIDER_CONFIG_HEALTH_STATUSES)[number]; + export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const; export type SecretStatus = (typeof SECRET_STATUSES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 456f1ddcb7f..4e7e702c76b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -71,6 +71,8 @@ export { APPROVAL_TYPES, APPROVAL_STATUSES, SECRET_PROVIDERS, + SECRET_PROVIDER_CONFIG_STATUSES, + SECRET_PROVIDER_CONFIG_HEALTH_STATUSES, STORAGE_PROVIDERS, BILLING_TYPES, FINANCE_EVENT_KINDS, @@ -182,6 +184,8 @@ export { type ApprovalType, type ApprovalStatus, type SecretProvider, + type SecretProviderConfigStatus, + type SecretProviderConfigHealthStatus, type StorageProvider, type BillingType, type FinanceEventKind, @@ -530,6 +534,10 @@ export type { EnvBinding, AgentEnvConfig, CompanySecret, + CompanySecretProviderConfig, + SecretProviderConfigPayload, + SecretProviderConfigHealthDetails, + SecretProviderConfigHealthResponse, CompanySecretBinding, CompanySecretVersion, SecretAccessEvent, @@ -835,6 +843,13 @@ export { envBindingSchema, envConfigSchema, createSecretSchema, + createSecretProviderConfigSchema, + updateSecretProviderConfigSchema, + localEncryptedProviderConfigSchema, + awsSecretsManagerProviderConfigSchema, + gcpSecretManagerProviderConfigSchema, + vaultProviderConfigSchema, + secretProviderConfigPayloadSchema, createSecretBindingSchema, rotateSecretSchema, secretBindingTargetSchema, @@ -851,6 +866,8 @@ export { routineRevisionSnapshotV1Schema, routineRevisionSnapshotSchema, type CreateSecret, + type CreateSecretProviderConfig, + type UpdateSecretProviderConfig, type RotateSecret, type UpdateSecret, type CreateRoutine, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0fa0b8d3b09..e05b308ab24 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -244,6 +244,10 @@ export type { EnvBinding, AgentEnvConfig, CompanySecret, + CompanySecretProviderConfig, + SecretProviderConfigPayload, + SecretProviderConfigHealthDetails, + SecretProviderConfigHealthResponse, CompanySecretBinding, CompanySecretVersion, SecretAccessEvent, diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index cd1b566623c..5f2d4db90a9 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -3,11 +3,22 @@ import type { SecretBindingTargetType, SecretManagedMode, SecretProvider, + SecretProviderConfigHealthStatus, + SecretProviderConfigStatus, SecretStatus, SecretVersionStatus, } from "../constants.js"; -export type { SecretAccessOutcome, SecretBindingTargetType, SecretManagedMode, SecretProvider, SecretStatus, SecretVersionStatus }; +export type { + SecretAccessOutcome, + SecretBindingTargetType, + SecretManagedMode, + SecretProvider, + SecretProviderConfigHealthStatus, + SecretProviderConfigStatus, + SecretStatus, + SecretVersionStatus, +}; export type SecretVersionSelector = number | "latest"; @@ -58,6 +69,74 @@ export interface SecretProviderDescriptor { configured?: boolean; } +export interface LocalEncryptedProviderConfig { + backupReminderAcknowledged?: boolean; +} + +export interface AwsSecretsManagerProviderConfig { + region: string; + namespace?: string | null; + secretNamePrefix?: string | null; + kmsKeyId?: string | null; + ownerTag?: string | null; + environmentTag?: string | null; +} + +export interface GcpSecretManagerProviderConfig { + projectId?: string | null; + location?: string | null; + namespace?: string | null; + secretNamePrefix?: string | null; +} + +export interface VaultProviderConfig { + address?: string | null; + namespace?: string | null; + mountPath?: string | null; + secretPathPrefix?: string | null; +} + +export type SecretProviderConfigPayload = + | LocalEncryptedProviderConfig + | AwsSecretsManagerProviderConfig + | GcpSecretManagerProviderConfig + | VaultProviderConfig; + +export interface SecretProviderConfigHealthDetails { + code: string; + message: string; + missingFields?: string[]; + guidance?: string[]; +} + +export interface CompanySecretProviderConfig { + id: string; + companyId: string; + provider: SecretProvider; + displayName: string; + status: SecretProviderConfigStatus; + isDefault: boolean; + config: SecretProviderConfigPayload; + healthStatus: SecretProviderConfigHealthStatus | null; + healthCheckedAt: Date | null; + healthMessage: string | null; + healthDetails: SecretProviderConfigHealthDetails | null; + disabledAt: Date | null; + createdByAgentId: string | null; + createdByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface SecretProviderConfigHealthResponse { + configId: string; + provider: SecretProvider; + status: SecretProviderConfigHealthStatus; + message: string; + details: SecretProviderConfigHealthDetails; + checkedAt: Date; +} + export interface CompanySecretVersion { id: string; secretId: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 9667a5b8e13..dffcd5e2d1d 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -282,12 +282,21 @@ export { envBindingSchema, envConfigSchema, createSecretSchema, + createSecretProviderConfigSchema, + updateSecretProviderConfigSchema, + localEncryptedProviderConfigSchema, + awsSecretsManagerProviderConfigSchema, + gcpSecretManagerProviderConfigSchema, + vaultProviderConfigSchema, + secretProviderConfigPayloadSchema, createSecretBindingSchema, rotateSecretSchema, secretBindingTargetSchema, updateSecretSchema, type CreateSecretBinding, type CreateSecret, + type CreateSecretProviderConfig, + type UpdateSecretProviderConfig, type RotateSecret, type UpdateSecret, } from "./secret.js"; diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index 12d38e52fde..e42f2a8dc45 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { SECRET_BINDING_TARGET_TYPES, SECRET_MANAGED_MODES, + SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDERS, SECRET_STATUSES, } from "../constants.js"; @@ -30,6 +31,7 @@ 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(), + providerConfigId: z.string().uuid().optional().nullable(), managedMode: z.enum(SECRET_MANAGED_MODES).optional(), value: z.string().min(1).optional().nullable(), description: z.string().optional().nullable(), @@ -77,6 +79,7 @@ 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(), + providerConfigId: z.string().uuid().optional().nullable(), description: z.string().optional().nullable(), externalRef: z.string().optional().nullable(), providerMetadata: z.record(z.unknown()).optional().nullable(), @@ -98,3 +101,114 @@ export const createSecretBindingSchema = secretBindingTargetSchema.extend({ }); export type CreateSecretBinding = z.infer; + +const safeShortText = z.string().trim().min(1).max(160); +const optionalSafeShortText = safeShortText.optional().nullable(); + +const deniedProviderConfigKeyPattern = + /^(access[-_]?key([-_]?id)?|secret[-_]?access[-_]?key|secret[-_]?key|token|password|passwd|credential|credentials|private[-_]?key|pem|jwt|session[-_]?token|service[-_]?account([-_]?json)?|client[-_]?secret|secret[-_]?id|unseal[-_]?key|recovery[-_]?key|key[-_]?file([-_]?path)?|token[-_]?file([-_]?path)?)$/i; + +function rejectSensitiveProviderConfigKeys(value: unknown, ctx: z.RefinementCtx) { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + for (const key of Object.keys(value)) { + if (!deniedProviderConfigKeyPattern.test(key)) continue; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["config", key], + message: `Provider vault config cannot persist sensitive field: ${key}`, + }); + } +} + +export const localEncryptedProviderConfigSchema = z.object({ + backupReminderAcknowledged: z.boolean().optional(), +}).strict(); + +export const awsSecretsManagerProviderConfigSchema = z.object({ + region: z.string().trim().regex(/^[a-z]{2}(?:-gov)?-[a-z]+-\d+$/, "Invalid AWS region"), + namespace: optionalSafeShortText, + secretNamePrefix: optionalSafeShortText, + kmsKeyId: z.string().trim().min(1).max(512).optional().nullable(), + ownerTag: optionalSafeShortText, + environmentTag: optionalSafeShortText, +}).strict(); + +export const gcpSecretManagerProviderConfigSchema = z.object({ + projectId: z.string().trim().min(1).max(128).regex(/^[a-z][a-z0-9-]{4,127}$/).optional().nullable(), + location: optionalSafeShortText, + namespace: optionalSafeShortText, + secretNamePrefix: optionalSafeShortText, +}).strict(); + +export const vaultProviderConfigSchema = z.object({ + address: z.string().trim().url().optional().nullable(), + namespace: optionalSafeShortText, + mountPath: optionalSafeShortText, + secretPathPrefix: optionalSafeShortText, +}).strict(); + +export const secretProviderConfigPayloadSchema = z.discriminatedUnion("provider", [ + z.object({ provider: z.literal("local_encrypted"), config: localEncryptedProviderConfigSchema }), + z.object({ provider: z.literal("aws_secrets_manager"), config: awsSecretsManagerProviderConfigSchema }), + z.object({ provider: z.literal("gcp_secret_manager"), config: gcpSecretManagerProviderConfigSchema }), + z.object({ provider: z.literal("vault"), config: vaultProviderConfigSchema }), +]); + +export const createSecretProviderConfigSchema = z.object({ + provider: z.enum(SECRET_PROVIDERS), + displayName: z.string().trim().min(1).max(120), + status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), + isDefault: z.boolean().optional(), + config: z.record(z.unknown()).default({}), +}).superRefine((value, ctx) => { + rejectSensitiveProviderConfigKeys(value.config, ctx); + const parsed = secretProviderConfigPayloadSchema.safeParse({ + provider: value.provider, + config: value.config, + }); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + ...issue, + path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path], + }); + } + } + const status = value.status ?? (["gcp_secret_manager", "vault"].includes(value.provider) ? "coming_soon" : "ready"); + if ((value.provider === "gcp_secret_manager" || value.provider === "vault") && status !== "coming_soon" && status !== "disabled") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["status"], + message: `${value.provider} provider vaults are locked while coming soon`, + }); + } + if ((status === "coming_soon" || status === "disabled") && value.isDefault) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["isDefault"], + message: "Only ready or warning provider vaults can be default", + }); + } +}); + +export type CreateSecretProviderConfig = z.infer; + +export const updateSecretProviderConfigSchema = z.object({ + displayName: z.string().trim().min(1).max(120).optional(), + status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), + isDefault: z.boolean().optional(), + config: z.record(z.unknown()).optional(), +}).superRefine((value, ctx) => { + if (value.config !== undefined) { + rejectSensitiveProviderConfigKeys(value.config, ctx); + } + if ((value.status === "coming_soon" || value.status === "disabled") && value.isDefault) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["isDefault"], + message: "Only ready or warning provider vaults can be default", + }); + } +}); + +export type UpdateSecretProviderConfig = z.infer; diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 228bcf7e788..da6f084a0fb 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -7,6 +7,13 @@ import { errorHandler } from "../middleware/error-handler.js"; const mockSecretService = vi.hoisted(() => ({ listProviders: vi.fn(), checkProviders: vi.fn(), + listProviderConfigs: vi.fn(), + getProviderConfigById: vi.fn(), + createProviderConfig: vi.fn(), + updateProviderConfig: vi.fn(), + disableProviderConfig: vi.fn(), + setDefaultProviderConfig: vi.fn(), + checkProviderConfigHealth: vi.fn(), create: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); @@ -16,17 +23,17 @@ vi.mock("../services/index.js", () => ({ logActivity: mockLogActivity, })); -function createApp() { +function createApp(actor: Record = { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }], +}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - userId: "user-1", - source: "session", - companyIds: ["company-1"], - memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }], - }; + (req as any).actor = actor; next(); }); app.use("/api", secretRoutes({} as any)); @@ -36,9 +43,9 @@ function createApp() { describe("secret routes", () => { beforeEach(() => { - mockSecretService.listProviders.mockReset(); - mockSecretService.checkProviders.mockReset(); - mockSecretService.create.mockReset(); + for (const mock of Object.values(mockSecretService)) { + mock.mockReset(); + } mockLogActivity.mockReset(); }); @@ -79,4 +86,110 @@ describe("secret routes", () => { expect(JSON.stringify(res.body)).toMatch(/Managed secrets cannot set externalRef/); expect(mockSecretService.create).not.toHaveBeenCalled(); }); + + it("rejects provider vault routes for non-board actors", async () => { + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + })).get("/api/companies/company-1/secret-provider-configs"); + + expect(res.status).toBe(403); + expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled(); + }); + + it("rejects provider vault cross-company access before calling the service", async () => { + const res = await request(createApp({ + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-2"], + memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }], + })).get("/api/companies/company-1/secret-provider-configs"); + + expect(res.status).toBe(403); + expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled(); + }); + + it("rejects sensitive provider vault config fields", async () => { + const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ + provider: "aws_secrets_manager", + displayName: "AWS prod", + config: { + region: "us-east-1", + accessKeyId: "AKIA...", + }, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/sensitive field/i); + expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled(); + }); + + it("rejects ready status for coming-soon provider vaults", async () => { + const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ + provider: "vault", + displayName: "Vault draft", + status: "ready", + config: { + address: "https://vault.example.com", + }, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/locked while coming soon/i); + expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled(); + }); + + it("creates provider vaults and logs safe activity details", async () => { + const createdAt = new Date("2026-05-06T00:00:00.000Z"); + mockSecretService.createProviderConfig.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + isDefault: true, + config: { region: "us-east-1" }, + healthStatus: null, + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + disabledAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + createdAt, + updatedAt: createdAt, + }); + + const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ + provider: "aws_secrets_manager", + displayName: "AWS prod", + isDefault: true, + config: { region: "us-east-1" }, + }); + + expect(res.status).toBe(201); + expect(mockSecretService.createProviderConfig).toHaveBeenCalledWith( + "company-1", + { + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: undefined, + isDefault: true, + config: { region: "us-east-1" }, + }, + { userId: "user-1", agentId: null }, + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "secret_provider_config.created", + details: { + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + isDefault: true, + }, + })); + expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey"); + }); }); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 1241dde790c..624085cb7ce 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -7,6 +7,7 @@ import { eq } from "drizzle-orm"; import { companies, companySecretBindings, + companySecretProviderConfigs, companySecretVersions, companySecrets, createDb, @@ -35,7 +36,7 @@ describeEmbeddedPostgres("secretService", () => { mkdirSync(secretsTmpDir, { recursive: true }); process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); const started = await startEmbeddedPostgresTestDatabase("secrets-service"); - stopDb = started.stop; + stopDb = started.cleanup; db = createDb(started.connectionString); }); @@ -45,6 +46,7 @@ describeEmbeddedPostgres("secretService", () => { await db.delete(companySecretBindings); await db.delete(companySecretVersions); await db.delete(companySecrets); + await db.delete(companySecretProviderConfigs); await db.delete(companies); }); @@ -191,6 +193,68 @@ describeEmbeddedPostgres("secretService", () => { ).rejects.toThrow(/not bound/i); }); + it("keeps one default provider vault per company provider", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + + const first = await svc.createProviderConfig(companyId, { + provider: "local_encrypted", + displayName: "Local primary", + isDefault: true, + config: {}, + }); + const second = await svc.createProviderConfig(companyId, { + provider: "local_encrypted", + displayName: "Local secondary", + isDefault: true, + config: {}, + }); + + const rows = await svc.listProviderConfigs(companyId); + expect(rows.find((row) => row.id === first.id)?.isDefault).toBe(false); + expect(rows.find((row) => row.id === second.id)?.isDefault).toBe(true); + }); + + it("rejects provider vaults from another company when creating a secret", async () => { + const companyA = await seedCompany("A"); + const companyB = await seedCompany("B"); + const svc = secretService(db); + const foreignVault = await svc.createProviderConfig(companyB, { + provider: "local_encrypted", + displayName: "Foreign vault", + config: {}, + }); + + await expect( + svc.create(companyA, { + name: `managed-${randomUUID()}`, + provider: "local_encrypted", + providerConfigId: foreignVault.id, + value: "runtime-secret", + }), + ).rejects.toThrow(/same company/i); + }); + + it("blocks coming-soon provider vaults from secret selection", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const draftVault = await svc.createProviderConfig(companyId, { + provider: "gcp_secret_manager", + displayName: "GCP draft", + config: { projectId: "paperclip-prod1" }, + }); + + expect(draftVault.status).toBe("coming_soon"); + await expect( + svc.create(companyId, { + name: `draft-${randomUUID()}`, + provider: "gcp_secret_manager", + providerConfigId: draftVault.id, + value: "runtime-secret", + }), + ).rejects.toThrow(/coming soon/i); + }); + it("rejects externalRef overrides on managed secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index b10e47bbd4e..eba97021948 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -1,8 +1,10 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { + createSecretProviderConfigSchema, createSecretSchema, rotateSecretSchema, + updateSecretProviderConfigSchema, updateSecretSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; @@ -30,6 +32,197 @@ export function secretRoutes(db: Db) { res.json({ providers: checks }); }); + router.get("/companies/:companyId/secret-provider-configs", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.listProviderConfigs(companyId)); + }); + + router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const created = await svc.createProviderConfig( + companyId, + { + provider: req.body.provider, + displayName: req.body.displayName, + status: req.body.status, + isDefault: req.body.isDefault, + config: req.body.config, + }, + { userId: req.actor.userId ?? "board", agentId: null }, + ); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret_provider_config.created", + entityType: "secret_provider_config", + entityId: created.id, + details: { + provider: created.provider, + displayName: created.displayName, + status: created.status, + isDefault: created.isDefault, + }, + }); + + res.status(201).json(created); + }); + + router.get("/secret-provider-configs/:id", async (req, res) => { + assertBoard(req); + const existing = await svc.getProviderConfigById(req.params.id as string); + if (!existing) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + res.json(existing); + }); + + router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getProviderConfigById(id); + if (!existing) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const updated = await svc.updateProviderConfig(id, { + displayName: req.body.displayName, + status: req.body.status, + isDefault: req.body.isDefault, + config: req.body.config, + }); + if (!updated) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + + await logActivity(db, { + companyId: updated.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret_provider_config.updated", + entityType: "secret_provider_config", + entityId: updated.id, + details: { + provider: updated.provider, + displayName: updated.displayName, + status: updated.status, + isDefault: updated.isDefault, + }, + }); + + res.json(updated); + }); + + router.delete("/secret-provider-configs/:id", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getProviderConfigById(id); + if (!existing) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const disabled = await svc.disableProviderConfig(id); + if (!disabled) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + + await logActivity(db, { + companyId: disabled.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret_provider_config.disabled", + entityType: "secret_provider_config", + entityId: disabled.id, + details: { + provider: disabled.provider, + displayName: disabled.displayName, + status: disabled.status, + }, + }); + + res.json(disabled); + }); + + router.post("/secret-provider-configs/:id/default", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getProviderConfigById(id); + if (!existing) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const updated = await svc.setDefaultProviderConfig(id); + if (!updated) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + + await logActivity(db, { + companyId: updated.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret_provider_config.default_set", + entityType: "secret_provider_config", + entityId: updated.id, + details: { + provider: updated.provider, + displayName: updated.displayName, + isDefault: updated.isDefault, + }, + }); + + res.json(updated); + }); + + router.post("/secret-provider-configs/:id/health", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getProviderConfigById(id); + if (!existing) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const health = await svc.checkProviderConfigHealth(id); + if (!health) { + res.status(404).json({ error: "Provider vault not found" }); + return; + } + + await logActivity(db, { + companyId: existing.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret_provider_config.health_checked", + entityType: "secret_provider_config", + entityId: existing.id, + details: { + provider: existing.provider, + status: health.status, + code: health.details.code, + }, + }); + + res.json(health); + }); + router.get("/companies/:companyId/secrets", async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; @@ -49,6 +242,7 @@ export function secretRoutes(db: Db) { name: req.body.name, key: req.body.key, provider: req.body.provider ?? defaultProvider, + providerConfigId: req.body.providerConfigId, managedMode: req.body.managedMode, value: req.body.value, description: req.body.description, @@ -119,6 +313,7 @@ export function secretRoutes(db: Db) { name: req.body.name, key: req.body.key, status: req.body.status, + providerConfigId: req.body.providerConfigId, description: req.body.description, externalRef: req.body.externalRef, providerMetadata: req.body.providerMetadata, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 4ff2d00e629..a895a41d020 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -2,6 +2,7 @@ import { and, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companySecretBindings, + companySecretProviderConfigs, companySecrets, companySecretVersions, secretAccessEvents, @@ -11,9 +12,17 @@ import type { EnvBinding, SecretBindingTargetType, SecretProvider, + SecretProviderConfigHealthResponse, + SecretProviderConfigHealthStatus, + SecretProviderConfigStatus, SecretVersionSelector, } from "@paperclipai/shared"; -import { envBindingSchema } from "@paperclipai/shared"; +import { + createSecretProviderConfigSchema, + envBindingSchema, + secretProviderConfigPayloadSchema, + updateSecretProviderConfigSchema, +} from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { checkSecretProviders, @@ -25,6 +34,10 @@ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; const REDACTED_SENTINEL = "***REDACTED***"; +const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ + "gcp_secret_manager", + "vault", +]); type CanonicalEnvBinding = | { type: "plain"; value: string } @@ -89,6 +102,25 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { }; } +function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus { + return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; +} + +function assertSelectableProviderConfig(config: { + provider: string; + status: string; + companyId: string; +}, companyId: string, provider: SecretProvider) { + if (config.companyId !== companyId) throw unprocessable("Provider vault must belong to same company"); + if (config.provider !== provider) throw unprocessable("Provider vault must match the secret provider"); + if (config.status === "coming_soon") { + throw unprocessable("Provider vault is locked while coming soon"); + } + if (config.status === "disabled") { + throw unprocessable("Provider vault is disabled"); + } +} + export function secretService(db: Db) { type NormalizeEnvOptions = { strictMode?: boolean; @@ -204,6 +236,96 @@ export function secretService(db: Db) { return secret; } + async function getProviderConfigById(id: string) { + return db + .select() + .from(companySecretProviderConfigs) + .where(eq(companySecretProviderConfigs.id, id)) + .then((rows) => rows[0] ?? null); + } + + async function assertProviderConfigForSecret( + companyId: string, + provider: SecretProvider, + providerConfigId: string | null | undefined, + ) { + if (!providerConfigId) return null; + const providerConfig = await getProviderConfigById(providerConfigId); + if (!providerConfig) throw notFound("Provider vault not found"); + assertSelectableProviderConfig(providerConfig, companyId, provider); + return providerConfig; + } + + function validateProviderConfigPayload( + provider: SecretProvider, + config: Record, + ): Record { + const parsed = secretProviderConfigPayloadSchema.safeParse({ provider, config }); + if (!parsed.success) { + throw unprocessable("Invalid provider vault config", parsed.error.flatten()); + } + return parsed.data.config; + } + + function providerConfigHealth(input: { + id: string; + provider: SecretProvider; + status: SecretProviderConfigStatus; + config: Record; + }): Omit { + if (input.status === "disabled") { + return { + configId: input.id, + provider: input.provider, + status: "disabled", + message: "Provider vault is disabled.", + details: { code: "disabled", message: "Provider vault is disabled." }, + }; + } + if (input.status === "coming_soon" || COMING_SOON_SECRET_PROVIDERS.has(input.provider)) { + return { + configId: input.id, + provider: input.provider, + status: "coming_soon", + message: "Provider vault runtime is locked while coming soon.", + details: { + code: "runtime_locked", + message: "Provider vault runtime is locked while coming soon.", + guidance: ["Draft metadata may be saved, but create, rotate, and resolve stay unavailable."], + }, + }; + } + if (input.provider === "aws_secrets_manager" && typeof input.config.region !== "string") { + return { + configId: input.id, + provider: input.provider, + status: "warning", + message: "AWS Secrets Manager vault is missing region routing metadata.", + details: { + code: "missing_config", + message: "AWS Secrets Manager vault is missing region routing metadata.", + missingFields: ["region"], + }, + }; + } + return { + configId: input.id, + provider: input.provider, + status: input.status === "warning" ? "warning" : "ready", + message: + input.status === "warning" + ? "Provider vault is saved but needs operator attention." + : "Provider vault metadata is ready.", + details: { + code: input.status === "warning" ? "needs_attention" : "metadata_ready", + message: + input.status === "warning" + ? "Provider vault is saved but needs operator attention." + : "Provider vault metadata is ready.", + }, + }; + } + async function resolveSecretValueInternal( companyId: string, secretId: string, @@ -347,6 +469,174 @@ export function secretService(db: Db) { checkProviders: () => checkSecretProviders(), + listProviderConfigs: (companyId: string) => + db + .select() + .from(companySecretProviderConfigs) + .where(eq(companySecretProviderConfigs.companyId, companyId)) + .orderBy(desc(companySecretProviderConfigs.createdAt)), + + getProviderConfigById, + + createProviderConfig: async ( + companyId: string, + input: { + provider: SecretProvider; + displayName: string; + status?: SecretProviderConfigStatus; + isDefault?: boolean; + config?: Record; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const parsed = createSecretProviderConfigSchema.safeParse(input); + if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten()); + const status = input.status ?? defaultProviderConfigStatus(input.provider); + if ((status === "coming_soon" || status === "disabled") && input.isDefault) { + throw unprocessable("Only ready or warning provider vaults can be default"); + } + const normalizedConfig = validateProviderConfigPayload(input.provider, input.config ?? {}); + return db.transaction(async (tx) => { + if (input.isDefault) { + await tx + .update(companySecretProviderConfigs) + .set({ isDefault: false, updatedAt: new Date() }) + .where(and( + eq(companySecretProviderConfigs.companyId, companyId), + eq(companySecretProviderConfigs.provider, input.provider), + )); + } + return tx + .insert(companySecretProviderConfigs) + .values({ + companyId, + provider: input.provider, + displayName: input.displayName.trim(), + status, + isDefault: input.isDefault ?? false, + config: normalizedConfig, + disabledAt: status === "disabled" ? new Date() : null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + }); + }, + + updateProviderConfig: async ( + id: string, + patch: { + displayName?: string; + status?: SecretProviderConfigStatus; + isDefault?: boolean; + config?: Record; + }, + ) => { + const existing = await getProviderConfigById(id); + if (!existing) return null; + const parsed = updateSecretProviderConfigSchema.safeParse(patch); + if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten()); + const provider = existing.provider as SecretProvider; + const status = patch.status ?? (existing.status as SecretProviderConfigStatus); + if (COMING_SOON_SECRET_PROVIDERS.has(provider) && status !== "coming_soon" && status !== "disabled") { + throw unprocessable(`${provider} provider vaults are locked while coming soon`); + } + if ((status === "coming_soon" || status === "disabled") && patch.isDefault) { + throw unprocessable("Only ready or warning provider vaults can be default"); + } + const normalizedConfig = + patch.config === undefined + ? existing.config + : validateProviderConfigPayload(provider, patch.config); + return db.transaction(async (tx) => { + if (patch.isDefault) { + await tx + .update(companySecretProviderConfigs) + .set({ isDefault: false, updatedAt: new Date() }) + .where(and( + eq(companySecretProviderConfigs.companyId, existing.companyId), + eq(companySecretProviderConfigs.provider, existing.provider), + )); + } + return tx + .update(companySecretProviderConfigs) + .set({ + displayName: patch.displayName?.trim() ?? existing.displayName, + status, + isDefault: status === "disabled" || status === "coming_soon" ? false : patch.isDefault ?? existing.isDefault, + config: normalizedConfig, + disabledAt: status === "disabled" ? existing.disabledAt ?? new Date() : null, + updatedAt: new Date(), + }) + .where(eq(companySecretProviderConfigs.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }); + }, + + disableProviderConfig: async (id: string) => { + const existing = await getProviderConfigById(id); + if (!existing) return null; + return db + .update(companySecretProviderConfigs) + .set({ + status: "disabled", + isDefault: false, + disabledAt: existing.disabledAt ?? new Date(), + updatedAt: new Date(), + }) + .where(eq(companySecretProviderConfigs.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + setDefaultProviderConfig: async (id: string) => { + const existing = await getProviderConfigById(id); + if (!existing) return null; + if (existing.status === "coming_soon" || existing.status === "disabled") { + throw unprocessable("Only ready or warning provider vaults can be default"); + } + return db.transaction(async (tx) => { + await tx + .update(companySecretProviderConfigs) + .set({ isDefault: false, updatedAt: new Date() }) + .where(and( + eq(companySecretProviderConfigs.companyId, existing.companyId), + eq(companySecretProviderConfigs.provider, existing.provider), + )); + return tx + .update(companySecretProviderConfigs) + .set({ isDefault: true, updatedAt: new Date() }) + .where(eq(companySecretProviderConfigs.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }); + }, + + checkProviderConfigHealth: async (id: string) => { + const existing = await getProviderConfigById(id); + if (!existing) return null; + const checkedAt = new Date(); + const health = providerConfigHealth({ + id: existing.id, + provider: existing.provider as SecretProvider, + status: existing.status as SecretProviderConfigStatus, + config: existing.config ?? {}, + }); + await db + .update(companySecretProviderConfigs) + .set({ + healthStatus: health.status, + healthCheckedAt: checkedAt, + healthMessage: health.message, + healthDetails: health.details as unknown as Record, + updatedAt: new Date(), + }) + .where(eq(companySecretProviderConfigs.id, id)); + return { ...health, checkedAt }; + }, + list: (companyId: string) => db .select() @@ -381,6 +671,7 @@ export function secretService(db: Db) { input: { name: string; provider: SecretProvider; + providerConfigId?: string | null; value?: string | null; key?: string | null; managedMode?: "paperclip_managed" | "external_reference"; @@ -404,6 +695,7 @@ export function secretService(db: Db) { const managedMode = input.managedMode ?? "paperclip_managed"; const provider = getSecretProvider(input.provider); + await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId); if (managedMode === "external_reference" && !input.externalRef?.trim()) { throw unprocessable("External reference secrets require externalRef"); } @@ -444,6 +736,7 @@ export function secretService(db: Db) { key, name: input.name, provider: input.provider, + providerConfigId: input.providerConfigId ?? null, status: "active", managedMode, externalRef: prepared.externalRef, @@ -554,6 +847,7 @@ export function secretService(db: Db) { name?: string; key?: string; status?: "active" | "disabled" | "archived" | "deleted"; + providerConfigId?: string | null; description?: string | null; externalRef?: string | null; providerMetadata?: Record | null; @@ -584,6 +878,13 @@ export function secretService(db: Db) { if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) { throw unprocessable("Managed secrets cannot override externalRef"); } + if (patch.providerConfigId !== undefined) { + await assertProviderConfigForSecret( + secret.companyId, + secret.provider as SecretProvider, + patch.providerConfigId, + ); + } return db .update(companySecrets) @@ -591,6 +892,8 @@ export function secretService(db: Db) { key: nextKey, name: patch.name ?? secret.name, status: patch.status ?? secret.status, + providerConfigId: + patch.providerConfigId === undefined ? secret.providerConfigId : patch.providerConfigId, description: patch.description === undefined ? secret.description : patch.description, externalRef: