diff --git a/packages/db/src/migrations/0126_user_specific_secrets.sql b/packages/db/src/migrations/0126_user_specific_secrets.sql new file mode 100644 index 00000000000..02f2979aac8 --- /dev/null +++ b/packages/db/src/migrations/0126_user_specific_secrets.sql @@ -0,0 +1,178 @@ +CREATE TABLE IF NOT EXISTS "user_secret_definitions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "description" text, + "status" text DEFAULT 'active' NOT NULL, + "provider" text DEFAULT 'local_encrypted' NOT NULL, + "managed_mode" text DEFAULT 'paperclip_managed' NOT NULL, + "provider_config_id" uuid, + "provider_metadata" jsonb, + "usage_guidance" text, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "updated_by_agent_id" uuid, + "updated_by_user_id" text, + "deleted_at" timestamp with time zone, + "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 "user_secret_declarations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_secret_definition_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" text NOT NULL, + "config_path" text NOT NULL, + "env_key" text NOT NULL, + "version_selector" text DEFAULT 'latest' NOT NULL, + "required" boolean DEFAULT true NOT NULL, + "allow_missing_override" boolean DEFAULT false 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 +ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "scope" text DEFAULT 'company' NOT NULL; +--> statement-breakpoint +ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "owner_user_id" text; +--> statement-breakpoint +ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "user_secret_definition_id" uuid; +--> statement-breakpoint +UPDATE "company_secrets" +SET "scope" = 'company' +WHERE "scope" IS NULL; +--> statement-breakpoint +ALTER TABLE "secret_access_events" ALTER COLUMN "secret_id" DROP NOT NULL; +--> statement-breakpoint +ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "user_secret_definition_id" uuid; +--> statement-breakpoint +ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "secret_scope" text DEFAULT 'company' NOT NULL; +--> statement-breakpoint +ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "responsible_user_id" text; +--> statement-breakpoint +ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_owner_user_id" text; +--> statement-breakpoint +ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_subject_type" text; +--> statement-breakpoint +ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_subject_id" text; +--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "responsible_user_id" text; +--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "responsible_user_id" text; +--> statement-breakpoint +ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "responsible_user_id" text; +--> statement-breakpoint +ALTER TABLE "routine_revisions" ADD COLUMN IF NOT EXISTS "responsible_user_id" text; +--> statement-breakpoint +ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "responsible_user_id" text; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_company_id_companies_id_fk') THEN + ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_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 = 'user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk') THEN + ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_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 +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_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 +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_updated_by_agent_id_agents_id_fk') THEN + ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("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 = 'user_secret_declarations_company_id_companies_id_fk') THEN + ALTER TABLE "user_secret_declarations" ADD CONSTRAINT "user_secret_declarations_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 = 'user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk') THEN + ALTER TABLE "user_secret_declarations" ADD CONSTRAINT "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("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_secrets_user_secret_definition_id_user_secret_definitions_id_fk') THEN + ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("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_user_secret_definition_id_user_secret_definitions_id_fk') THEN + ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("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 = 'company_secrets_scope_shape_check') THEN + ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_scope_shape_check" CHECK ( + ("scope" = 'company' AND "owner_user_id" IS NULL AND "user_secret_definition_id" IS NULL) + OR + ("scope" = 'user' AND "owner_user_id" IS NOT NULL AND "user_secret_definition_id" IS NOT NULL) + ); + END IF; +END $$; +--> statement-breakpoint +DROP INDEX IF EXISTS "company_secrets_company_name_uq"; +--> statement-breakpoint +DROP INDEX IF EXISTS "company_secrets_company_key_uq"; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_definitions_company_status_idx" ON "user_secret_definitions" USING btree ("company_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_definitions_company_provider_idx" ON "user_secret_definitions" USING btree ("company_id","provider"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_definitions_provider_config_idx" ON "user_secret_definitions" USING btree ("provider_config_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_secret_definitions_company_key_uq" ON "user_secret_definitions" USING btree ("company_id","key") WHERE "user_secret_definitions"."deleted_at" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_declarations_company_idx" ON "user_secret_declarations" USING btree ("company_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_declarations_definition_idx" ON "user_secret_declarations" USING btree ("user_secret_definition_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_declarations_target_idx" ON "user_secret_declarations" USING btree ("company_id","target_type","target_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_declarations_company_required_idx" ON "user_secret_declarations" USING btree ("company_id","required"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_secret_declarations_target_path_uq" ON "user_secret_declarations" USING btree ("company_id","target_type","target_id","config_path"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_secret_declarations_required_override_idx" ON "user_secret_declarations" USING btree ("company_id","allow_missing_override") WHERE "user_secret_declarations"."allow_missing_override" = true; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_secrets_company_scope_idx" ON "company_secrets" USING btree ("company_id","scope"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_secrets_company_owner_idx" ON "company_secrets" USING btree ("company_id","owner_user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_secrets_user_definition_owner_idx" ON "company_secrets" USING btree ("company_id","user_secret_definition_id","owner_user_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_name_uq" ON "company_secrets" USING btree ("company_id","name") WHERE "company_secrets"."scope" = 'company' AND "company_secrets"."deleted_at" IS NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_key_uq" ON "company_secrets" USING btree ("company_id","key") WHERE "company_secrets"."scope" = 'company' AND "company_secrets"."deleted_at" IS NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_user_definition_owner_uq" ON "company_secrets" USING btree ("company_id","user_secret_definition_id","owner_user_id") WHERE "company_secrets"."scope" = 'user' AND "company_secrets"."deleted_at" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "secret_access_events_user_definition_created_idx" ON "secret_access_events" USING btree ("user_secret_definition_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "secret_access_events_company_credential_owner_idx" ON "secret_access_events" USING btree ("company_id","credential_owner_user_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issues_company_responsible_user_idx" ON "issues" USING btree ("company_id","responsible_user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_responsible_user_idx" ON "heartbeat_runs" USING btree ("company_id","responsible_user_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routines_company_responsible_user_idx" ON "routines" USING btree ("company_id","responsible_user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_revisions_company_responsible_user_idx" ON "routine_revisions" USING btree ("company_id","responsible_user_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_runs_company_responsible_user_idx" ON "routine_runs" USING btree ("company_id","responsible_user_id","created_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5d52de43059..c391ab601b9 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -883,6 +883,13 @@ "when": 1782440100000, "tag": "0125_environment_custom_image_templates", "breakpoints": true + }, + { + "idx": 126, + "version": "7", + "when": 1782923938661, + "tag": "0126_user_specific_secrets", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_secrets.ts b/packages/db/src/schema/company_secrets.ts index 9499d20cc8c..9ca340d2240 100644 --- a/packages/db/src/schema/company_secrets.ts +++ b/packages/db/src/schema/company_secrets.ts @@ -1,13 +1,18 @@ -import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { check, 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"; +import { userSecretDefinitions } from "./user_secret_definitions.js"; export const companySecrets = pgTable( "company_secrets", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), + scope: text("scope").notNull().default("company"), + ownerUserId: text("owner_user_id"), + userSecretDefinitionId: uuid("user_secret_definition_id").references(() => userSecretDefinitions.id, { onDelete: "set null" }), key: text("key").notNull(), name: text("name").notNull(), provider: text("provider").notNull().default("local_encrypted"), @@ -28,9 +33,35 @@ export const companySecrets = pgTable( }, (table) => ({ companyIdx: index("company_secrets_company_idx").on(table.companyId), + companyScopeIdx: index("company_secrets_company_scope_idx").on(table.companyId, table.scope), + companyOwnerIdx: index("company_secrets_company_owner_idx").on(table.companyId, table.ownerUserId), + userDefinitionOwnerIdx: index("company_secrets_user_definition_owner_idx").on( + table.companyId, + table.userSecretDefinitionId, + table.ownerUserId, + ), 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), + companyNameUq: uniqueIndex("company_secrets_company_name_uq") + .on(table.companyId, table.name) + .where(sql`${table.scope} = 'company' and ${table.deletedAt} is null`), + companyKeyUq: uniqueIndex("company_secrets_company_key_uq") + .on(table.companyId, table.key) + .where(sql`${table.scope} = 'company' and ${table.deletedAt} is null`), + userDefinitionOwnerUq: uniqueIndex("company_secrets_user_definition_owner_uq") + .on(table.companyId, table.userSecretDefinitionId, table.ownerUserId) + .where(sql`${table.scope} = 'user' and ${table.deletedAt} is null`), + scopeShapeCheck: check( + "company_secrets_scope_shape_check", + sql`( + ${table.scope} = 'company' + and ${table.ownerUserId} is null + and ${table.userSecretDefinitionId} is null + ) or ( + ${table.scope} = 'user' + and ${table.ownerUserId} is not null + and ${table.userSecretDefinitionId} is not null + )`, + ), }), ); diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index c975892a1e0..3d0e39f6710 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -12,6 +12,7 @@ export const heartbeatRuns = pgTable( invocationSource: text("invocation_source").notNull().default("on_demand"), triggerDetail: text("trigger_detail"), status: text("status").notNull().default("queued"), + responsibleUserId: text("responsible_user_id"), startedAt: timestamp("started_at", { withTimezone: true }), finishedAt: timestamp("finished_at", { withTimezone: true }), error: text("error"), @@ -63,6 +64,11 @@ export const heartbeatRuns = pgTable( table.agentId, table.startedAt, ), + companyResponsibleUserIdx: index("heartbeat_runs_company_responsible_user_idx").on( + table.companyId, + table.responsibleUserId, + table.createdAt, + ), companyLivenessIdx: index("heartbeat_runs_company_liveness_idx").on( table.companyId, table.livenessState, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 314b7834412..6d418922cce 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -84,9 +84,11 @@ 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 { userSecretDefinitions } from "./user_secret_definitions.js"; export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; export { companySecretBindings } from "./company_secret_bindings.js"; +export { userSecretDeclarations } from "./user_secret_declarations.js"; export { secretAccessEvents } from "./secret_access_events.js"; export { companySkills, companySkillVersions, companySkillStars, companySkillComments } from "./company_skills.js"; export { plugins } from "./plugins.js"; diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index 5c945b5a5bf..18164184f4f 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -41,6 +41,7 @@ export const issues = pgTable( executionLockedAt: timestamp("execution_locked_at", { withTimezone: true }), createdByAgentId: uuid("created_by_agent_id").references(() => agents.id), createdByUserId: text("created_by_user_id"), + responsibleUserId: text("responsible_user_id"), issueNumber: integer("issue_number"), identifier: text("identifier"), originKind: text("origin_kind").notNull().default("manual"), @@ -82,6 +83,7 @@ export const issues = pgTable( table.assigneeUserId, table.status, ), + responsibleUserIdx: index("issues_company_responsible_user_idx").on(table.companyId, table.responsibleUserId), parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId), projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId), originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId), diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index 08f7abcce65..3142d887ace 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -42,6 +42,7 @@ export const routines = pgTable( latestRevisionNumber: integer("latest_revision_number").notNull().default(1), createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), createdByUserId: text("created_by_user_id"), + responsibleUserId: text("responsible_user_id"), updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }), updatedByUserId: text("updated_by_user_id"), lastTriggeredAt: timestamp("last_triggered_at", { withTimezone: true }), @@ -53,6 +54,7 @@ export const routines = pgTable( companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status), companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId), companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId), + companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId), companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId), }), ); @@ -75,6 +77,7 @@ export const routineRevisions = pgTable( createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), createdByUserId: text("created_by_user_id"), createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + responsibleUserId: text("responsible_user_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ @@ -87,6 +90,11 @@ export const routineRevisions = pgTable( table.routineId, table.createdAt, ), + companyResponsibleUserIdx: index("routine_revisions_company_responsible_user_idx").on( + table.companyId, + table.responsibleUserId, + table.createdAt, + ), }), ); @@ -136,6 +144,7 @@ export const routineRuns = pgTable( status: text("status").notNull().default("received"), triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(), routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }), + responsibleUserId: text("responsible_user_id"), idempotencyKey: text("idempotency_key"), triggerPayload: jsonb("trigger_payload").$type>(), dispatchFingerprint: text("dispatch_fingerprint"), @@ -149,6 +158,11 @@ export const routineRuns = pgTable( (table) => ({ companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt), routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId), + companyResponsibleUserIdx: index("routine_runs_company_responsible_user_idx").on( + table.companyId, + table.responsibleUserId, + table.createdAt, + ), triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt), dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint), linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId), diff --git a/packages/db/src/schema/secret_access_events.ts b/packages/db/src/schema/secret_access_events.ts index b4967f13fdb..15a34720e4b 100644 --- a/packages/db/src/schema/secret_access_events.ts +++ b/packages/db/src/schema/secret_access_events.ts @@ -4,15 +4,22 @@ import { companySecrets } from "./company_secrets.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; import { issues } from "./issues.js"; import { plugins } from "./plugins.js"; +import { userSecretDefinitions } from "./user_secret_definitions.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" }), + secretId: uuid("secret_id").references(() => companySecrets.id, { onDelete: "cascade" }), + userSecretDefinitionId: uuid("user_secret_definition_id").references(() => userSecretDefinitions.id, { onDelete: "set null" }), + secretScope: text("secret_scope").notNull().default("company"), version: integer("version"), provider: text("provider").notNull(), + responsibleUserId: text("responsible_user_id"), + credentialOwnerUserId: text("credential_owner_user_id"), + credentialSubjectType: text("credential_subject_type"), + credentialSubjectId: text("credential_subject_id"), actorType: text("actor_type").notNull(), actorId: text("actor_id"), consumerType: text("consumer_type").notNull(), @@ -28,6 +35,15 @@ export const secretAccessEvents = pgTable( (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), + userDefinitionCreatedIdx: index("secret_access_events_user_definition_created_idx").on( + table.userSecretDefinitionId, + table.createdAt, + ), + credentialOwnerIdx: index("secret_access_events_company_credential_owner_idx").on( + table.companyId, + table.credentialOwnerUserId, + 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/db/src/schema/user_secret_declarations.ts b/packages/db/src/schema/user_secret_declarations.ts new file mode 100644 index 00000000000..5dc67c0f728 --- /dev/null +++ b/packages/db/src/schema/user_secret_declarations.ts @@ -0,0 +1,40 @@ +import { sql } from "drizzle-orm"; +import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { userSecretDefinitions } from "./user_secret_definitions.js"; + +export const userSecretDeclarations = pgTable( + "user_secret_declarations", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userSecretDefinitionId: uuid("user_secret_definition_id") + .notNull() + .references(() => userSecretDefinitions.id, { onDelete: "cascade" }), + targetType: text("target_type").notNull(), + targetId: text("target_id").notNull(), + configPath: text("config_path").notNull(), + envKey: text("env_key").notNull(), + versionSelector: text("version_selector").notNull().default("latest"), + required: boolean("required").notNull().default(true), + allowMissingOverride: boolean("allow_missing_override").notNull().default(false), + label: text("label"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("user_secret_declarations_company_idx").on(table.companyId), + definitionIdx: index("user_secret_declarations_definition_idx").on(table.userSecretDefinitionId), + targetIdx: index("user_secret_declarations_target_idx").on(table.companyId, table.targetType, table.targetId), + companyRequiredIdx: index("user_secret_declarations_company_required_idx").on(table.companyId, table.required), + targetPathUq: uniqueIndex("user_secret_declarations_target_path_uq").on( + table.companyId, + table.targetType, + table.targetId, + table.configPath, + ), + requiredOverrideCheck: index("user_secret_declarations_required_override_idx") + .on(table.companyId, table.allowMissingOverride) + .where(sql`${table.allowMissingOverride} = true`), + }), +); diff --git a/packages/db/src/schema/user_secret_definitions.ts b/packages/db/src/schema/user_secret_definitions.ts new file mode 100644 index 00000000000..0bc64e3fb52 --- /dev/null +++ b/packages/db/src/schema/user_secret_definitions.ts @@ -0,0 +1,37 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { companySecretProviderConfigs } from "./company_secret_provider_configs.js"; + +export const userSecretDefinitions = pgTable( + "user_secret_definitions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + key: text("key").notNull(), + name: text("name").notNull(), + description: text("description"), + status: text("status").notNull().default("active"), + provider: text("provider").notNull().default("local_encrypted"), + managedMode: text("managed_mode").notNull().default("paperclip_managed"), + providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }), + providerMetadata: jsonb("provider_metadata").$type>(), + usageGuidance: text("usage_guidance"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + updatedByUserId: text("updated_by_user_id"), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyStatusIdx: index("user_secret_definitions_company_status_idx").on(table.companyId, table.status), + companyProviderIdx: index("user_secret_definitions_company_provider_idx").on(table.companyId, table.provider), + providerConfigIdx: index("user_secret_definitions_provider_config_idx").on(table.providerConfigId), + companyKeyUq: uniqueIndex("user_secret_definitions_company_key_uq") + .on(table.companyId, table.key) + .where(sql`${table.deletedAt} is null`), + }), +); diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 769c3471a6e..dad39b5eca0 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1574,6 +1574,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { executionLockedAt: null, createdByAgentId: null, createdByUserId: null, + responsibleUserId: null, issueNumber: null, identifier: null, originKind, diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 5ed37e0ea1b..57c61bcd7cf 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -20,6 +20,11 @@ export const API = { goals: `${API_PREFIX}/goals`, approvals: `${API_PREFIX}/approvals`, secrets: `${API_PREFIX}/secrets`, + userSecretDefinitions: `${API_PREFIX}/companies/:companyId/user-secret-definitions`, + userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`, + userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`, + myUserSecrets: `${API_PREFIX}/companies/:companyId/me/user-secrets`, + myUserSecret: `${API_PREFIX}/companies/:companyId/me/user-secrets/:secretId`, secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`, secretProviderConfigDiscoveryPreview: `${API_PREFIX}/companies/:companyId/secret-provider-configs/discovery/preview`, costs: `${API_PREFIX}/costs`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 8e0461ecdd1..6d124a95311 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -618,6 +618,9 @@ export type SecretProviderConfigHealthStatus = export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const; export type SecretStatus = (typeof SECRET_STATUSES)[number]; +export const SECRET_SCOPES = ["company", "user"] as const; +export type SecretScope = (typeof SECRET_SCOPES)[number]; + export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const; export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number]; @@ -642,7 +645,15 @@ export const SECRET_BINDING_TARGET_TYPES = [ ] as const; export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number]; -export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const; +export const SECRET_ACCESS_OUTCOMES = [ + "success", + "failure", + "missing", + "inactive", + "not_allowed", + "optional_omitted", + "provider_error", +] as const; export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number]; export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b61d9c57de0..ea3c5dd6673 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -183,6 +183,7 @@ export { SECRET_PROVIDERS, SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDER_CONFIG_HEALTH_STATUSES, + SECRET_SCOPES, STORAGE_PROVIDERS, BILLING_TYPES, FINANCE_EVENT_KINDS, @@ -320,6 +321,7 @@ export { type SecretProvider, type SecretProviderConfigStatus, type SecretProviderConfigHealthStatus, + type SecretScope, type StorageProvider, type BillingType, type FinanceEventKind, @@ -811,8 +813,12 @@ export type { EnvBinding, EnvPlainBinding, EnvSecretRefBinding, + EnvUserSecretRefBinding, AgentEnvConfig, CompanySecret, + UserSecretDefinition, + UserSecretDeclaration, + UserSecretCoverageSummary, CompanySecretProviderConfig, SecretProviderConfigPayload, SecretProviderConfigHealthDetails, @@ -1279,9 +1285,15 @@ export { type AddApprovalComment, envBindingPlainSchema, envBindingSecretRefSchema, + envBindingUserSecretRefSchema, envBindingSchema, envConfigSchema, createSecretSchema, + createUserSecretDefinitionSchema, + updateUserSecretDefinitionSchema, + createUserSecretValueSchema, + updateUserSecretValueSchema, + createUserSecretDeclarationSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, secretProviderConfigDiscoveryPreviewSchema, diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 6392e268bf1..ce736ca8469 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -15,6 +15,7 @@ export interface HeartbeatRun { invocationSource: HeartbeatInvocationSource; triggerDetail: WakeupTriggerDetail | null; status: HeartbeatRunStatus; + responsibleUserId: string | null; startedAt: Date | null; finishedAt: Date | null; error: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 13f972d940e..dc3d9229ca1 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -406,9 +406,13 @@ export type { SecretVersionSelector, EnvPlainBinding, EnvSecretRefBinding, + EnvUserSecretRefBinding, EnvBinding, AgentEnvConfig, CompanySecret, + UserSecretDefinition, + UserSecretDeclaration, + UserSecretCoverageSummary, CompanySecretProviderConfig, SecretProviderConfigPayload, SecretProviderConfigHealthDetails, @@ -432,6 +436,7 @@ export type { SecretAccessOutcome, SecretBindingTargetType, SecretManagedMode, + SecretScope, SecretProviderDescriptor, SecretStatus, SecretVersionStatus, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 1fed1d34d06..4127c5d1b36 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -550,6 +550,7 @@ export interface Issue { executionLockedAt: Date | null; createdByAgentId: string | null; createdByUserId: string | null; + responsibleUserId: string | null; issueNumber: number | null; identifier: string | null; originKind?: IssueOriginKind; diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index e7089994883..571d7492ef4 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -88,6 +88,7 @@ export interface Routine { latestRevisionNumber: number; createdByAgentId: string | null; createdByUserId: string | null; + responsibleUserId: string | null; updatedByAgentId: string | null; updatedByUserId: string | null; lastTriggeredAt: Date | null; @@ -126,6 +127,7 @@ export interface RoutineRevisionSnapshotRoutineV1 { originId?: string | null; variables: RoutineVariable[]; env: RoutineEnvConfig | null; + responsibleUserId: string | null; } export interface RoutineRevisionSnapshotTriggerV1 { diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 9c01ff40fff..742e7acd831 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -5,6 +5,7 @@ import type { SecretProvider, SecretProviderConfigHealthStatus, SecretProviderConfigStatus, + SecretScope, SecretStatus, SecretVersionStatus, } from "../constants.js"; @@ -16,6 +17,7 @@ export type { SecretProvider, SecretProviderConfigHealthStatus, SecretProviderConfigStatus, + SecretScope, SecretStatus, SecretVersionStatus, }; @@ -33,14 +35,25 @@ export interface EnvSecretRefBinding { version?: SecretVersionSelector; } +export interface EnvUserSecretRefBinding { + type: "user_secret_ref"; + key: string; + version?: SecretVersionSelector; + required?: boolean; + allowMissingOverride?: boolean; +} + // Backward-compatible: legacy plaintext string values are still accepted. -export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding; +export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding | EnvUserSecretRefBinding; export type AgentEnvConfig = Record; export interface CompanySecret { id: string; companyId: string; + scope: SecretScope; + ownerUserId: string | null; + userSecretDefinitionId: string | null; key: string; name: string; provider: SecretProvider; @@ -61,6 +74,50 @@ export interface CompanySecret { updatedAt: Date; } +export interface UserSecretDefinition { + id: string; + companyId: string; + key: string; + name: string; + description: string | null; + status: SecretStatus; + provider: SecretProvider; + managedMode: SecretManagedMode; + providerConfigId: string | null; + providerMetadata: Record | null; + usageGuidance: string | null; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface UserSecretDeclaration { + id: string; + companyId: string; + userSecretDefinitionId: string; + targetType: SecretBindingTargetType; + targetId: string; + configPath: string; + envKey: string; + versionSelector: SecretVersionSelector; + required: boolean; + allowMissingOverride: boolean; + label: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface UserSecretCoverageSummary { + definitionId: string; + configuredCount: number; + missingCount: number; + inactiveCount: number; +} + export interface SecretProviderDescriptor { id: SecretProvider; label: string; @@ -216,9 +273,15 @@ export interface CompanySecretUsageBinding extends CompanySecretBinding { export interface SecretAccessEvent { id: string; companyId: string; - secretId: string; + secretId: string | null; + userSecretDefinitionId: string | null; + secretScope: SecretScope; version: number | null; provider: SecretProvider; + responsibleUserId: string | null; + credentialOwnerUserId: string | null; + credentialSubjectType: string | null; + credentialSubjectId: string | null; actorType: "agent" | "user" | "system" | "plugin"; actorId: string | null; consumerType: SecretBindingTargetType; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 6bf6f3e4de7..6ff08e8cd75 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -463,9 +463,15 @@ export { export { envBindingPlainSchema, envBindingSecretRefSchema, + envBindingUserSecretRefSchema, envBindingSchema, envConfigSchema, createSecretSchema, + createUserSecretDefinitionSchema, + updateUserSecretDefinitionSchema, + createUserSecretValueSchema, + updateUserSecretValueSchema, + createUserSecretDeclarationSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, secretProviderConfigDiscoveryPreviewSchema, @@ -483,6 +489,11 @@ export { updateSecretSchema, type CreateSecretBinding, type CreateSecret, + type CreateUserSecretDefinition, + type UpdateUserSecretDefinition, + type CreateUserSecretValue, + type UpdateUserSecretValue, + type CreateUserSecretDeclaration, type CreateSecretProviderConfig, type UpdateSecretProviderConfig, type SecretProviderConfigDiscoveryPreview, diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index 0506e6a2fb5..56629340fd5 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -96,6 +96,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES), variables: z.array(routineVariableSchema), env: envConfigSchema.nullable().default(null), + responsibleUserId: z.string().nullable().default(null), }).strict(); export const routineRevisionSnapshotTriggerV1Schema = z.object({ diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index 10d81a9d587..4cdfc887e43 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { + createUserSecretDeclarationSchema, + createUserSecretValueSchema, createSecretProviderConfigSchema, createSecretSchema, + envBindingUserSecretRefSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, secretProviderConfigDiscoveryPreviewSchema, @@ -10,6 +13,42 @@ import { } from "./secret.js"; describe("secret validators", () => { + it("defaults user secret refs to required and no missing override", () => { + expect( + envBindingUserSecretRefSchema.parse({ + type: "user_secret_ref", + key: "github_api_token", + }), + ).toEqual({ + type: "user_secret_ref", + key: "github_api_token", + required: true, + allowMissingOverride: false, + }); + }); + + it("validates user secret declarations and current-user value payloads", () => { + expect( + createUserSecretDeclarationSchema.parse({ + targetType: "agent", + targetId: "agent-1", + configPath: "env.GITHUB_TOKEN", + envKey: "GITHUB_TOKEN", + definitionKey: "github_api_token", + }), + ).toMatchObject({ + versionSelector: "latest", + required: true, + allowMissingOverride: false, + }); + + expect(() => + createUserSecretValueSchema.parse({ + value: "secret-value", + }), + ).toThrow(/definitionId or definitionKey/); + }); + it("rejects externalRef on managed secrets", () => { expect(() => createSecretSchema.parse({ diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index a90bf064f18..a01d197ae9f 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -7,6 +7,9 @@ import { SECRET_STATUSES, } from "../constants.js"; +const secretKeySchema = z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/); +const secretVersionSelectorSchema = z.union([z.literal("latest"), z.number().int().positive()]); + export const envBindingPlainSchema = z.object({ type: z.literal("plain"), value: z.string(), @@ -15,7 +18,15 @@ export const envBindingPlainSchema = z.object({ export const envBindingSecretRefSchema = z.object({ type: z.literal("secret_ref"), secretId: z.string().uuid(), - version: z.union([z.literal("latest"), z.number().int().positive()]).optional(), + version: secretVersionSelectorSchema.optional(), +}); + +export const envBindingUserSecretRefSchema = z.object({ + type: z.literal("user_secret_ref"), + key: secretKeySchema, + version: secretVersionSelectorSchema.optional(), + required: z.boolean().optional().default(true), + allowMissingOverride: z.boolean().optional().default(false), }); // Backward-compatible union that accepts legacy inline values. @@ -23,13 +34,14 @@ export const envBindingSchema = z.union([ z.string(), envBindingPlainSchema, envBindingSecretRefSchema, + envBindingUserSecretRefSchema, ]); export const envConfigSchema = z.record(z.string(), envBindingSchema); export const createSecretSchema = z.object({ name: z.string().min(1), - key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(), + key: secretKeySchema.optional(), provider: z.enum(SECRET_PROVIDERS).optional(), providerConfigId: z.string().uuid().optional().nullable(), managedMode: z.enum(SECRET_MANAGED_MODES).optional(), @@ -78,7 +90,7 @@ 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(), + key: secretKeySchema.optional(), status: z.enum(SECRET_STATUSES).optional(), providerConfigId: z.string().uuid().optional().nullable(), description: z.string().optional().nullable(), @@ -96,13 +108,86 @@ export const secretBindingTargetSchema = z.object({ export const createSecretBindingSchema = secretBindingTargetSchema.extend({ secretId: z.string().uuid(), - versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"), + versionSelector: secretVersionSelectorSchema.default("latest"), required: z.boolean().default(true), label: z.string().optional().nullable(), }); export type CreateSecretBinding = z.infer; +export const createUserSecretDefinitionSchema = z.object({ + key: secretKeySchema, + name: z.string().trim().min(1).max(160), + description: z.string().trim().max(500).optional().nullable(), + status: z.enum(SECRET_STATUSES).optional(), + provider: z.enum(SECRET_PROVIDERS).optional(), + providerConfigId: z.string().uuid().optional().nullable(), + managedMode: z.enum(SECRET_MANAGED_MODES).optional(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), + usageGuidance: z.string().trim().max(1000).optional().nullable(), +}); + +export type CreateUserSecretDefinition = z.infer; + +export const updateUserSecretDefinitionSchema = z.object({ + key: secretKeySchema.optional(), + name: z.string().trim().min(1).max(160).optional(), + description: z.string().trim().max(500).optional().nullable(), + status: z.enum(SECRET_STATUSES).optional(), + providerConfigId: z.string().uuid().optional().nullable(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), + usageGuidance: z.string().trim().max(1000).optional().nullable(), +}); + +export type UpdateUserSecretDefinition = z.infer; + +export const createUserSecretValueSchema = z.object({ + definitionKey: secretKeySchema.optional(), + definitionId: z.string().uuid().optional(), + value: z.string().min(1).optional().nullable(), + externalRef: z.string().optional().nullable(), + providerVersionRef: z.string().optional().nullable(), + providerConfigId: z.string().uuid().optional().nullable(), +}).superRefine((value, ctx) => { + if (!value.definitionKey && !value.definitionId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["definitionId"], + message: "User secret value requires definitionId or definitionKey", + }); + } + if (!value.value?.trim() && !value.externalRef?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: "User secret value requires value or externalRef", + }); + } +}); + +export type CreateUserSecretValue = z.infer; + +export const updateUserSecretValueSchema = z.object({ + status: z.enum(SECRET_STATUSES).optional(), + value: z.string().min(1).optional().nullable(), + externalRef: z.string().optional().nullable(), + providerVersionRef: z.string().optional().nullable(), + providerConfigId: z.string().uuid().optional().nullable(), +}); + +export type UpdateUserSecretValue = z.infer; + +export const createUserSecretDeclarationSchema = secretBindingTargetSchema.extend({ + definitionKey: secretKeySchema, + envKey: z.string().trim().min(1), + versionSelector: secretVersionSelectorSchema.default("latest"), + required: z.boolean().default(true), + allowMissingOverride: z.boolean().default(false), + label: z.string().optional().nullable(), +}); + +export type CreateUserSecretDeclaration = z.infer; + const safeShortText = z.string().trim().min(1).max(160); const optionalSafeShortText = safeShortText.optional().nullable(); diff --git a/server/src/redaction.ts b/server/src/redaction.ts index ebb1e6636a4..52b79ba5987 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -54,6 +54,7 @@ function sanitizeValue(value: unknown): unknown { if (value === null || value === undefined) return value; if (Array.isArray(value)) return value.map(sanitizeValue); if (isSecretRefBinding(value)) return value; + if (isUserSecretRefBinding(value)) return value; if (isPlainBinding(value)) return { type: "plain", value: sanitizeValue(value.value) }; if (!isPlainObject(value)) return value; return sanitizeRecord(value); @@ -64,6 +65,11 @@ function isSecretRefBinding(value: unknown): value is { type: "secret_ref"; secr return value.type === "secret_ref" && typeof value.secretId === "string"; } +function isUserSecretRefBinding(value: unknown): value is { type: "user_secret_ref"; key: string; version?: unknown } { + if (!isPlainObject(value)) return false; + return value.type === "user_secret_ref" && typeof value.key === "string"; +} + function isPlainBinding(value: unknown): value is { type: "plain"; value: unknown } { if (!isPlainObject(value)) return false; return value.type === "plain" && "value" in value; @@ -101,6 +107,10 @@ export function sanitizeRecord(record: Record): Record