From 9346994b2700533550af333e1df825a82edf77bd Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 16:43:12 +0000 Subject: [PATCH 01/26] Add user-specific secret contracts Co-Authored-By: Paperclip --- .../migrations/0126_user_specific_secrets.sql | 178 ++++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/company_secrets.ts | 37 +++- packages/db/src/schema/heartbeat_runs.ts | 6 + packages/db/src/schema/index.ts | 2 + packages/db/src/schema/issues.ts | 2 + packages/db/src/schema/routines.ts | 14 ++ .../db/src/schema/secret_access_events.ts | 18 +- .../db/src/schema/user_secret_declarations.ts | 40 ++++ .../db/src/schema/user_secret_definitions.ts | 37 ++++ packages/plugins/sdk/src/testing.ts | 1 + packages/shared/src/api.ts | 5 + packages/shared/src/constants.ts | 13 +- packages/shared/src/index.ts | 12 ++ packages/shared/src/types/heartbeat.ts | 1 + packages/shared/src/types/index.ts | 5 + packages/shared/src/types/issue.ts | 1 + packages/shared/src/types/routine.ts | 2 + packages/shared/src/types/secrets.ts | 67 ++++++- packages/shared/src/validators/index.ts | 11 ++ packages/shared/src/validators/routine.ts | 1 + packages/shared/src/validators/secret.test.ts | 39 ++++ packages/shared/src/validators/secret.ts | 93 ++++++++- server/src/redaction.ts | 10 + server/src/services/issues.ts | 1 + server/src/services/pipelines.ts | 1 + server/src/services/routines.ts | 1 + server/src/services/secrets.ts | 38 +++- 28 files changed, 629 insertions(+), 14 deletions(-) create mode 100644 packages/db/src/migrations/0126_user_specific_secrets.sql create mode 100644 packages/db/src/schema/user_secret_declarations.ts create mode 100644 packages/db/src/schema/user_secret_definitions.ts 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 Date: Wed, 1 Jul 2026 16:54:31 +0000 Subject: [PATCH 02/26] Implement user-specific secret service routes --- doc/DATABASE.md | 14 + doc/SECRETS-AWS-PROVIDER.md | 45 +- docs/api/secrets.md | 24 + docs/deploy/secrets.md | 123 ++- server/src/__tests__/secrets-routes.test.ts | 78 ++ server/src/__tests__/secrets-service.test.ts | 78 ++ server/src/routes/secrets.ts | 324 ++++++++ server/src/services/agent-secret-bindings.ts | 74 ++ server/src/services/heartbeat.ts | 5 + server/src/services/secrets.ts | 814 ++++++++++++++++++- 10 files changed, 1561 insertions(+), 18 deletions(-) diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 43dfc05a8af..d4dffb37ce7 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -174,11 +174,21 @@ up separately when you need full instance disaster recovery. Paperclip stores secret metadata and versions in: +- `user_secret_definitions` +- `user_secret_declarations` - `company_secrets` - `company_secret_versions` - `company_secret_bindings` - `secret_access_events` +Company secrets use `company_secrets.scope = 'company'` and are bound directly +through `company_secret_bindings`. User-specific secrets reuse the same provider +and version storage, but each value is a `company_secrets.scope = 'user'` row +with `owner_user_id` and `user_secret_definition_id` set. Definitions describe +the reusable company-level slot, declarations record where `user_secret_ref` +bindings are required, and the concrete value is selected later for the +responsible user. + Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`. For local/default installs, the active provider is `local_encrypted`: @@ -188,6 +198,10 @@ For local/default installs, the active provider is `local_encrypted`: - CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`. - Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient. - The server best-effort enforces `0600` key file permissions and provider health reports permission warnings. +- User-scoped values use the same local encrypted provider path. Database + backups preserve definitions, declarations, owner metadata, version metadata, + and access events, but restored user-scoped values are decryptable only when + the matching local master key is restored with the database. Optional overrides: diff --git a/doc/SECRETS-AWS-PROVIDER.md b/doc/SECRETS-AWS-PROVIDER.md index c7cce82e57a..4f150dccdfe 100644 --- a/doc/SECRETS-AWS-PROVIDER.md +++ b/doc/SECRETS-AWS-PROVIDER.md @@ -4,7 +4,7 @@ Operational contract for the hosted `aws_secrets_manager` secret provider used b ## Scope -- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS. +- Hosted provider for Paperclip-managed company and user-scoped secrets when Paperclip Cloud runs on AWS. - Source of truth for secret values is AWS Secrets Manager, not Postgres. - Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution. - AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets. @@ -120,6 +120,25 @@ Tag set for Paperclip-managed secrets: - `paperclip:secret-key=` - `paperclip:environment=` +When the user-secret service creates Paperclip-managed AWS values, keep them +under the same deployment/company namespace. The secret-key segment should be +non-sensitive and collision-safe for the user-secret value, normally derived +from the definition key plus an opaque credential-owner subject. Do not put +emails, personal names, OAuth scopes, ticket identifiers, or plaintext +credential material in AWS secret names, descriptions, or tags. + +For operator-owned external references, prefer a separate approved prefix such +as: + +```text +paperclip-ext///user-secrets// +``` + +Paperclip stores the external ref and provider version as metadata. Treat those +fields as secret-adjacent: they must be redacted from comments, activity logs, +run transcripts, issue documents, and broad board views unless a route is +explicitly designed to show sanitized provider metadata. + ## IAM And KMS Assumptions Launch posture: @@ -223,6 +242,14 @@ If selected external secrets use customer-managed KMS keys, also grant permissions scoped to `paperclip//*`; do not broaden them for remote import. +The same rule applies to user-scoped external refs. Paperclip derives the +responsible user and credential owner before resolution, but AWS IAM still +decides whether the runtime role can read the selected ARN/path. If the runtime +role can read a broad external prefix, that access is broader than Paperclip's +metadata policy. Use dedicated provider vaults, accounts, Regions, prefixes, +KMS keys, or runtime roles when provider-side isolation must match a +user-secret boundary. + Safe scoping guidance: - Prefer one Paperclip runtime role per environment/account. @@ -288,16 +315,24 @@ Guidance: What must survive: - Paperclip database metadata for secret ownership, bindings, status, and provider version refs. +- User-secret definitions, declarations, `company_secrets.scope = 'user'` + value rows, owner user ids, responsible-user snapshots, access-event + metadata, and provider version refs. - AWS Secrets Manager namespace under the configured deployment prefix. +- Any operator-owned external AWS prefixes linked by user-scoped external refs. - The configured KMS key and its decrypt permissions. Restore checklist: 1. Restore Paperclip database metadata. 2. Confirm the same AWS Secrets Manager namespace still exists. -3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix. -4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`. -5. Run the live smoke below or a targeted runtime secret resolution test. +3. Confirm any linked user-scoped external prefixes still exist. +4. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored + managed prefix and approved external prefixes. +5. Confirm the role still has decrypt access to the CMKs referenced by + `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID` and by any external user-secret values. +6. Run the live smoke below or a targeted runtime secret resolution test for + both a company secret and a user-secret value. ## Provider Outage Runbook @@ -342,6 +377,8 @@ Response steps: 1. Stop or pause affected Paperclip runs. 2. Audit recent Paperclip secret access events for impacted secret ids and consumers. + For user-scoped incidents, include `credentialOwnerUserId`, + `responsibleUserId`, and `userSecretDefinitionId` in the review. 3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`, `PutSecretValue`, and `DeleteSecret` calls on the relevant vault account, Region, deployment prefix, and approved external prefixes. diff --git a/docs/api/secrets.md b/docs/api/secrets.md index 93a56b60803..f1aad3b2a36 100644 --- a/docs/api/secrets.md +++ b/docs/api/secrets.md @@ -399,6 +399,30 @@ end at injection: the agent process can read, log, or forward the value, so treat any secret bound to an agent as exposed to that agent. See the custody boundaries note in the [secrets deploy guide](/deploy/secrets#custody-boundaries). +User-specific env bindings use a definition key instead of a concrete +`secretId`. The concrete value is resolved for the run's responsible user: + +```json +{ + "env": { + "GITHUB_TOKEN": { + "type": "user_secret_ref", + "key": "github_api_token", + "version": "latest", + "required": true, + "allowMissingOverride": false + } + } +} +``` + +`required` defaults to `true` and `allowMissingOverride` defaults to `false`. +Missing required user-secret values must fail closed before adapter dispatch. +Optional missing values omit the environment variable; they must not inject an +empty string or another user's value. Paperclip records value-free access +events with `secretScope`, `responsibleUserId`, `credentialOwnerUserId`, and +`userSecretDefinitionId`. + ## Portability Company export/import APIs represent agent and project environment requirements diff --git a/docs/deploy/secrets.md b/docs/deploy/secrets.md index 41fa9df3757..0d6591574aa 100644 --- a/docs/deploy/secrets.md +++ b/docs/deploy/secrets.md @@ -51,6 +51,114 @@ Project env applies to every issue run in that project. When a project env key matches an agent env key, the project value wins before Paperclip injects its own `PAPERCLIP_*` runtime variables. +## User-Specific Secrets + +User-specific secrets let a shared agent or project declare a slot such as +`github_api_token`, then resolve the value owned by the run's responsible user +at dispatch time. The environment binding stores only the definition key: + +```json +{ + "env": { + "GITHUB_TOKEN": { + "type": "user_secret_ref", + "key": "github_api_token", + "required": true, + "allowMissingOverride": false + } + } +} +``` + +Paperclip stores the feature as value-free metadata plus the user's own secret +value record: + +- `user_secret_definitions`: company-level metadata for the reusable slot. +- `user_secret_declarations`: target/config-path declarations for + `user_secret_ref` bindings, including required/optional policy. +- `company_secrets` rows with `scope = "user"`, `owner_user_id`, and + `user_secret_definition_id`: the current user's actual value record. +- `company_secret_versions`: encrypted or provider-backed version metadata for + the value record. + +Board/admin operators manage definitions and coverage. Individual users manage +their own values. Board/admin coverage views must stay metadata-only: they can +show missing, configured, inactive, provider, and vault status, but not +plaintext values, raw external refs, provider credentials, or provider error +payloads. + +Required user-secret refs fail closed when the responsible user is missing, +the definition is missing, or the responsible user has no active value. +Optional refs may omit the env var. They must not inject blank credentials or +fall back to another user's value. + +### Vault Placement + +User-scoped values can use the same provider families as company secrets: + +- `local_encrypted`: the default local path. It is appropriate for local + trusted installs and small self-hosted deployments. The same master key + protects both company and user-scoped values. +- `aws_secrets_manager`: the hosted/provider-vault path. Use it when the + deployment already relies on AWS Secrets Manager, KMS, CloudTrail, and + infrastructure IAM for custody. +- Dedicated provider vaults: optional. Use a dedicated vault only when you need + a separate AWS account, Region, KMS key, prefix, retention posture, or + import boundary for user-owned values. Do not create one vault per user by + default; prefer one vault per environment or compliance boundary. + +A user-secret definition can carry provider and vault defaults. A user's value +may also carry provider/vault metadata when the implementation path supports +overrides. In both cases, provider vault config contains non-secret routing +metadata only. Provider credentials still come from deployment infrastructure +identity, not from Paperclip secrets. + +### External Reference Naming + +External vault references are secret-adjacent metadata. Treat paths, ARNs, +versions, aliases, and tags as operationally sensitive even though they are not +plaintext secret values. + +Recommended external ref shape for operator-owned AWS paths: + +```text +paperclip-ext/{environment}/{company-id}/user-secrets/{definition-key}/{opaque-owner-id} +``` + +Guidance: + +- Use the user-secret definition key for the credential type, such as + `github_api_token`. +- Use an opaque stable user id or one-way mapped subject id for the owner + segment. Avoid emails, personal names, customer names, OAuth scopes, or + ticket identifiers in provider paths. +- Keep provider metadata value-free. Safe metadata examples are provider id, + vault id, Region, KMS key id or alias, tag counts, and fingerprint hashes. + Do not store raw AWS descriptions, full tag maps, token scopes, provider + error bodies, or anything copied from the secret value. +- Keep Paperclip-managed AWS values under the Paperclip managed namespace. + External refs under that namespace are blocked by guardrails; use the + Paperclip-managed flow when Paperclip should create and rotate the value. + +### IAM Caveats + +Paperclip enforces company scoping, responsible-user derivation, declaration +policy, current-user value APIs, redaction, and access-event metadata. It does +not replace the IAM policy of an external vault. + +For AWS Secrets Manager, the Paperclip runtime role needs `GetSecretValue` and +any required KMS decrypt permission for every user-scoped value it may resolve. +If you link user-specific external refs outside the Paperclip managed prefix, +scope those permissions to the approved external prefixes and KMS keys. AWS +tag/name filters help operators search, but they are not a reliable permission +boundary for resolution. + +If stronger provider-side isolation is required, split user-secret workloads by +provider vault, AWS account, Region, prefix, or runtime role before linking the +refs. Paperclip can prevent an agent from choosing another credential owner, +but a runtime role with broad external vault read permissions can still read +what IAM allows if another code path is introduced outside Paperclip. + ## Default Provider: `local_encrypted` Secrets are encrypted with a local master key stored at: @@ -319,13 +427,18 @@ Each provider family has a different backup story: - `local_encrypted`: back up the local master key file and the Paperclip database together. Either alone is not enough to restore the encrypted values, and the vault row only records the path and acknowledgement, not the - key bytes. + key bytes. This includes user-scoped values: the database holds + `user_secret_definitions`, `user_secret_declarations`, + `company_secrets.scope = "user"` rows, version metadata, and owner ids; the + key file is required to decrypt their local material. - `aws_secrets_manager`: back up Paperclip's database for vault metadata (vault id, region, prefix, KMS key id, default flag, bindings, version - pointers). The actual secret values live in AWS Secrets Manager under the - configured prefix; restore by pointing the same Paperclip company at the - same AWS namespace and confirming the runtime role still has - `GetSecretValue` plus KMS decrypt. The full restore checklist lives in + pointers, user-secret definitions/declarations, owner ids, and access-event + metadata). The actual secret values live in AWS Secrets Manager under the + configured prefix or operator-owned external refs; restore by pointing the + same Paperclip company at the same AWS namespace and confirming the runtime + role still has `GetSecretValue` plus KMS decrypt for both managed and linked + user-scoped values. The full restore checklist lives in `doc/SECRETS-AWS-PROVIDER.md`. - `gcp_secret_manager` and `vault`: while these are coming soon, only the draft vault config exists in Paperclip. Database backups capture it. There diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 11c556aa717..b7531c6d972 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -19,8 +19,19 @@ const mockSecretService = vi.hoisted(() => ({ checkProviderConfigHealth: vi.fn(), getById: vi.fn(), create: vi.fn(), + rotate: vi.fn(), update: vi.fn(), remove: vi.fn(), + listUserSecretDefinitions: vi.fn(), + createUserSecretDefinition: vi.fn(), + updateUserSecretDefinition: vi.fn(), + removeUserSecretDefinition: vi.fn(), + getUserSecretDefinitionCoverage: vi.fn(), + listCurrentUserSecretValues: vi.fn(), + createCurrentUserSecretValue: vi.fn(), + updateCurrentUserSecretValue: vi.fn(), + rotateCurrentUserSecretValue: vi.fn(), + removeCurrentUserSecretValue: vi.fn(), previewRemoteImport: vi.fn(), importRemoteSecrets: vi.fn(), })); @@ -95,6 +106,73 @@ describe("secret routes", () => { expect(mockSecretService.create).not.toHaveBeenCalled(); }); + it("restricts user secret definition management to company admins", async () => { + const res = await request(createApp({ + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }], + })).post("/api/companies/company-1/user-secret-definitions").send({ + key: "github_token", + name: "GitHub token", + provider: "local_encrypted", + }); + + expect(res.status).toBe(403); + expect(mockSecretService.createUserSecretDefinition).not.toHaveBeenCalled(); + }); + + it("creates current-user secret values for the authenticated user only", async () => { + mockSecretService.createCurrentUserSecretValue.mockResolvedValue({ + id: "secret-1", + companyId: "company-1", + scope: "user", + ownerUserId: "user-1", + userSecretDefinitionId: "definition-1", + provider: "local_encrypted", + latestVersion: 1, + }); + + const res = await request(createApp()).post("/api/companies/company-1/me/user-secrets").send({ + definitionKey: "github_token", + value: "secret-value", + }); + + expect(res.status).toBe(201); + expect(mockSecretService.createCurrentUserSecretValue).toHaveBeenCalledWith( + "company-1", + "user-1", + { + definitionKey: "github_token", + definitionId: undefined, + value: "secret-value", + externalRef: undefined, + providerVersionRef: undefined, + providerConfigId: undefined, + }, + { userId: "user-1", agentId: null }, + ); + expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("secret-value"); + }); + + it("hides user-scoped secrets from legacy company secret mutation routes", async () => { + mockSecretService.getById.mockResolvedValue({ + id: "secret-1", + companyId: "company-1", + scope: "user", + ownerUserId: "user-2", + status: "active", + }); + + const res = await request(createApp()).post("/api/secrets/secret-1/rotate").send({ + value: "new-secret-value", + }); + + expect(res.status).toBe(404); + expect(mockSecretService.rotate).not.toHaveBeenCalled(); + }); + it("rejects provider vault routes for non-board actors", async () => { const res = await request(createApp({ type: "agent", diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index eda8c2f9533..071f2c0995c 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -14,6 +14,8 @@ import { companySecrets, createDb, secretAccessEvents, + userSecretDeclarations, + userSecretDefinitions, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; @@ -47,9 +49,11 @@ describeEmbeddedPostgres("secretService", () => { afterEach(async () => { vi.restoreAllMocks(); await db.delete(secretAccessEvents); + await db.delete(userSecretDeclarations); await db.delete(companySecretBindings); await db.delete(companySecretVersions); await db.delete(companySecrets); + await db.delete(userSecretDefinitions); await db.delete(companySecretProviderConfigs); await db.delete(companyMemberships); await db.delete(agents); @@ -387,6 +391,80 @@ describeEmbeddedPostgres("secretService", () => { expect(JSON.stringify(events)).not.toContain("routine-super-secret"); }); + it("resolves user secret refs through responsible-user values and records owner metadata", async () => { + const companyId = await seedCompany(); + await seedCompanyMember(companyId, "user-1", "owner"); + await seedCompanyMember(companyId, "user-2", "member"); + const svc = secretService(db); + const definition = await svc.createUserSecretDefinition(companyId, { + key: "github_token", + name: "GitHub token", + provider: "local_encrypted", + }); + const env = { + GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const }, + }; + + await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env); + const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionKey: "github_token", + value: "user-one-secret", + }); + + await expect( + svc.resolveEnvBindings(companyId, env, { + consumerType: "agent", + consumerId: "agent-1", + actorType: "agent", + actorId: "agent-1", + responsibleUserId: "user-2", + }), + ).rejects.toThrow(/not configured/i); + + const resolved = await svc.resolveEnvBindings(companyId, env, { + consumerType: "agent", + consumerId: "agent-1", + actorType: "agent", + actorId: "agent-1", + responsibleUserId: "user-1", + }); + + expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret"); + expect(resolved.manifest[0]).toMatchObject({ + configPath: "env.GITHUB_TOKEN", + envKey: "GITHUB_TOKEN", + secretId: userOneSecret.id, + secretKey: userOneSecret.key, + outcome: "success", + }); + expect((await svc.list(companyId)).map((secret) => secret.id)).not.toContain(userOneSecret.id); + await expect( + svc.resolveSecretValue(companyId, userOneSecret.id, "latest", { + consumerType: "agent", + consumerId: "agent-1", + configPath: "env.GITHUB_TOKEN", + }), + ).rejects.toThrow(/User-scoped secrets/i); + + const events = await db + .select() + .from(secretAccessEvents) + .where(eq(secretAccessEvents.secretId, userOneSecret.id)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + companyId, + secretId: userOneSecret.id, + userSecretDefinitionId: definition.id, + secretScope: "user", + responsibleUserId: "user-1", + credentialOwnerUserId: "user-1", + credentialSubjectType: "user", + credentialSubjectId: "user-1", + outcome: "success", + }); + expect(JSON.stringify(events)).not.toContain("user-one-secret"); + }); + it("records stable redacted failure codes for routine env secret resolution", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 760d36c7cd5..12267a05f1c 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -3,17 +3,42 @@ import type { Db } from "@paperclipai/db"; import { createSecretProviderConfigSchema, createSecretSchema, + createUserSecretDefinitionSchema, + createUserSecretValueSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, rotateSecretSchema, secretProviderConfigDiscoveryPreviewSchema, updateSecretProviderConfigSchema, updateSecretSchema, + updateUserSecretDefinitionSchema, + updateUserSecretValueSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; import { logActivity, secretService } from "../services/index.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; +import { forbidden } from "../errors.js"; + +function assertSecretDefinitionAdmin(req: Parameters[0], companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const membership = req.actor.memberships?.find((item) => item.companyId === companyId); + if (membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole))) { + return; + } + throw forbidden("Company admin access required"); +} + +function currentUserId(req: Parameters[0]) { + assertBoard(req); + return req.actor.userId ?? "board"; +} + +function isCompanyScopedSecret(secret: { scope?: string | null }) { + return (secret.scope ?? "company") === "company"; +} export function secretRoutes(db: Db) { const router = Router(); @@ -269,6 +294,285 @@ export function secretRoutes(db: Db) { res.json(secrets); }); + router.get("/companies/:companyId/user-secret-definitions", async (req, res) => { + const companyId = req.params.companyId as string; + assertSecretDefinitionAdmin(req, companyId); + res.json(await svc.listUserSecretDefinitions(companyId)); + }); + + router.post( + "/companies/:companyId/user-secret-definitions", + validate(createUserSecretDefinitionSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertSecretDefinitionAdmin(req, companyId); + + const created = await svc.createUserSecretDefinition( + companyId, + { + key: req.body.key, + name: req.body.name, + description: req.body.description, + status: req.body.status, + provider: req.body.provider ?? defaultProvider, + providerConfigId: req.body.providerConfigId, + managedMode: req.body.managedMode, + providerMetadata: req.body.providerMetadata, + usageGuidance: req.body.usageGuidance, + }, + { userId: req.actor.userId ?? "board", agentId: null }, + ); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "user_secret_definition.created", + entityType: "user_secret_definition", + entityId: created.id, + details: { key: created.key, provider: created.provider }, + }); + + res.status(201).json(created); + }, + ); + + router.patch( + "/companies/:companyId/user-secret-definitions/:definitionId", + validate(updateUserSecretDefinitionSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const definitionId = req.params.definitionId as string; + assertSecretDefinitionAdmin(req, companyId); + + const updated = await svc.updateUserSecretDefinition( + companyId, + definitionId, + { + key: req.body.key, + name: req.body.name, + description: req.body.description, + status: req.body.status, + providerConfigId: req.body.providerConfigId, + providerMetadata: req.body.providerMetadata, + usageGuidance: req.body.usageGuidance, + }, + { userId: req.actor.userId ?? "board", agentId: null }, + ); + if (!updated) { + res.status(404).json({ error: "User secret definition not found" }); + return; + } + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "user_secret_definition.updated", + entityType: "user_secret_definition", + entityId: updated.id, + details: { key: updated.key, status: updated.status }, + }); + + res.json(updated); + }, + ); + + router.delete("/companies/:companyId/user-secret-definitions/:definitionId", async (req, res) => { + const companyId = req.params.companyId as string; + const definitionId = req.params.definitionId as string; + assertSecretDefinitionAdmin(req, companyId); + + const removed = await svc.removeUserSecretDefinition( + companyId, + definitionId, + { userId: req.actor.userId ?? "board", agentId: null }, + ); + if (!removed) { + res.status(404).json({ error: "User secret definition not found" }); + return; + } + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "user_secret_definition.deleted", + entityType: "user_secret_definition", + entityId: removed.id, + details: { key: removed.key }, + }); + + res.json({ ok: true }); + }); + + router.get("/companies/:companyId/user-secret-definitions/:definitionId/coverage", async (req, res) => { + const companyId = req.params.companyId as string; + const definitionId = req.params.definitionId as string; + assertSecretDefinitionAdmin(req, companyId); + res.json(await svc.getUserSecretDefinitionCoverage(companyId, definitionId)); + }); + + router.get("/companies/:companyId/me/user-secrets", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.listCurrentUserSecretValues(companyId, currentUserId(req))); + }); + + router.post( + "/companies/:companyId/me/user-secrets", + validate(createUserSecretValueSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const ownerUserId = currentUserId(req); + const created = await svc.createCurrentUserSecretValue( + companyId, + ownerUserId, + { + definitionKey: req.body.definitionKey, + definitionId: req.body.definitionId, + value: req.body.value, + externalRef: req.body.externalRef, + providerVersionRef: req.body.providerVersionRef, + providerConfigId: req.body.providerConfigId, + }, + { userId: ownerUserId, agentId: null }, + ); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: ownerUserId, + action: "user_secret_value.created", + entityType: "secret", + entityId: created.id, + details: { + userSecretDefinitionId: created.userSecretDefinitionId, + ownerUserId: created.ownerUserId, + provider: created.provider, + }, + }); + + res.status(201).json(created); + }, + ); + + router.patch( + "/companies/:companyId/me/user-secrets/:secretId", + validate(updateUserSecretValueSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + const secretId = req.params.secretId as string; + assertCompanyAccess(req, companyId); + const ownerUserId = currentUserId(req); + const updated = await svc.updateCurrentUserSecretValue( + companyId, + ownerUserId, + secretId, + { + status: req.body.status, + value: req.body.value, + externalRef: req.body.externalRef, + providerVersionRef: req.body.providerVersionRef, + providerConfigId: req.body.providerConfigId, + }, + { userId: ownerUserId, agentId: null }, + ); + if (!updated) { + res.status(404).json({ error: "User secret value not found" }); + return; + } + + await logActivity(db, { + companyId, + actorType: "user", + actorId: ownerUserId, + action: "user_secret_value.updated", + entityType: "secret", + entityId: updated.id, + details: { + userSecretDefinitionId: updated.userSecretDefinitionId, + ownerUserId: updated.ownerUserId, + status: updated.status, + }, + }); + + res.json(updated); + }, + ); + + router.post( + "/companies/:companyId/me/user-secrets/:secretId/rotate", + validate(updateUserSecretValueSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + const secretId = req.params.secretId as string; + assertCompanyAccess(req, companyId); + const ownerUserId = currentUserId(req); + const rotated = await svc.rotateCurrentUserSecretValue( + companyId, + ownerUserId, + secretId, + { + value: req.body.value, + externalRef: req.body.externalRef, + providerVersionRef: req.body.providerVersionRef, + providerConfigId: req.body.providerConfigId, + }, + { userId: ownerUserId, agentId: null }, + ); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: ownerUserId, + action: "user_secret_value.rotated", + entityType: "secret", + entityId: rotated.id, + details: { + userSecretDefinitionId: rotated.userSecretDefinitionId, + ownerUserId: rotated.ownerUserId, + version: rotated.latestVersion, + }, + }); + + res.json(rotated); + }, + ); + + router.delete("/companies/:companyId/me/user-secrets/:secretId", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + const secretId = req.params.secretId as string; + assertCompanyAccess(req, companyId); + const ownerUserId = currentUserId(req); + const removed = await svc.removeCurrentUserSecretValue(companyId, ownerUserId, secretId); + if (!removed) { + res.status(404).json({ error: "User secret value not found" }); + return; + } + + await logActivity(db, { + companyId, + actorType: "user", + actorId: ownerUserId, + action: "user_secret_value.deleted", + entityType: "secret", + entityId: removed.id, + details: { + userSecretDefinitionId: removed.userSecretDefinitionId, + ownerUserId: removed.ownerUserId, + }, + }); + + res.json({ ok: true }); + }); + router.post("/companies/:companyId/secrets", validate(createSecretSchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; @@ -383,6 +687,10 @@ export function secretRoutes(db: Db) { res.status(404).json({ error: "Secret not found" }); return; } + if (!isCompanyScopedSecret(existing)) { + res.status(404).json({ error: "Secret not found" }); + return; + } assertCompanyAccess(req, existing.companyId); if (existing.status === "deleted") { res.status(404).json({ error: "Secret not found" }); @@ -421,6 +729,10 @@ export function secretRoutes(db: Db) { res.status(404).json({ error: "Secret not found" }); return; } + if (!isCompanyScopedSecret(existing)) { + res.status(404).json({ error: "Secret not found" }); + return; + } assertCompanyAccess(req, existing.companyId); if (existing.status === "deleted") { res.status(404).json({ error: "Secret not found" }); @@ -463,6 +775,10 @@ export function secretRoutes(db: Db) { res.status(404).json({ error: "Secret not found" }); return; } + if (!isCompanyScopedSecret(existing)) { + res.status(404).json({ error: "Secret not found" }); + return; + } assertCompanyAccess(req, existing.companyId); const bindings = await svc.listBindingReferences(existing.companyId, existing.id); res.json({ secretId: existing.id, bindings }); @@ -476,6 +792,10 @@ export function secretRoutes(db: Db) { res.status(404).json({ error: "Secret not found" }); return; } + if (!isCompanyScopedSecret(existing)) { + res.status(404).json({ error: "Secret not found" }); + return; + } assertCompanyAccess(req, existing.companyId); const events = await svc.listAccessEvents(existing.companyId, existing.id); res.json(events); @@ -489,6 +809,10 @@ export function secretRoutes(db: Db) { res.status(404).json({ error: "Secret not found" }); return; } + if (!isCompanyScopedSecret(existing)) { + res.status(404).json({ error: "Secret not found" }); + return; + } assertCompanyAccess(req, existing.companyId); const removed = await svc.remove(id); diff --git a/server/src/services/agent-secret-bindings.ts b/server/src/services/agent-secret-bindings.ts index 9e266f21b63..51aa935f4ce 100644 --- a/server/src/services/agent-secret-bindings.ts +++ b/server/src/services/agent-secret-bindings.ts @@ -18,6 +18,20 @@ interface AgentSecretBindingSyncService { target: { targetType: "agent"; targetId: string; pathPrefix?: string }, envValue: unknown, ) => Promise; + syncUserSecretDeclarationsForTarget?: ( + companyId: string, + target: { targetType: "agent"; targetId: string; pathPrefix?: string }, + refs: Array<{ + definitionKey: string; + configPath: string; + envKey: string; + versionSelector?: SecretVersionSelector; + required?: boolean; + allowMissingOverride?: boolean; + label?: string | null; + }>, + options?: { replaceAll?: boolean }, + ) => Promise; } function asRecord(value: unknown): Record | null { @@ -67,6 +81,60 @@ function collectSecretRefs(adapterConfig: unknown): Array<{ return refs; } +function collectUserSecretRefs(adapterConfig: unknown): Array<{ + definitionKey: string; + configPath: string; + envKey: string; + versionSelector?: SecretVersionSelector; + required?: boolean; + allowMissingOverride?: boolean; +}> { + const config = asRecord(adapterConfig); + if (!config) return []; + const refs: Array<{ + definitionKey: string; + configPath: string; + envKey: string; + versionSelector?: SecretVersionSelector; + required?: boolean; + allowMissingOverride?: boolean; + }> = []; + + const envValue = asRecord(config.env); + for (const [key, rawBinding] of Object.entries(envValue ?? {})) { + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) continue; + const binding = parsed.data; + if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue; + refs.push({ + definitionKey: binding.key, + configPath: `env.${key}`, + envKey: key, + versionSelector: binding.version ?? "latest", + required: binding.required ?? true, + allowMissingOverride: binding.allowMissingOverride ?? false, + }); + } + + for (const [key, rawBinding] of Object.entries(config)) { + if (key === "env") continue; + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) continue; + const binding = parsed.data; + if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue; + refs.push({ + definitionKey: binding.key, + configPath: key, + envKey: key, + versionSelector: binding.version ?? "latest", + required: binding.required ?? true, + allowMissingOverride: binding.allowMissingOverride ?? false, + }); + } + + return refs; +} + export async function syncAgentAdapterEnvBindings(input: { secretsSvc: AgentSecretBindingSyncService; companyId: string; @@ -80,6 +148,12 @@ export async function syncAgentAdapterEnvBindings(input: { collectSecretRefs(input.adapterConfig), { replaceAll: true }, ); + await input.secretsSvc.syncUserSecretDeclarationsForTarget?.( + input.companyId, + { targetType: "agent", targetId: input.agentId }, + collectUserSecretRefs(input.adapterConfig), + { replaceAll: true }, + ); return; } const envValue = asRecord(asRecord(input.adapterConfig)?.env); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5c6a2b17d15..d1d9a59e721 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -512,6 +512,7 @@ export async function resolveExecutionRunAdapterConfig(input: { adapterType?: string | null; issueId?: string | null; heartbeatRunId?: string | null; + responsibleUserId?: string | null; environmentId?: string | null; environmentEnv?: unknown; projectId?: string | null; @@ -671,6 +672,7 @@ export async function resolveExecutionRunAdapterConfig(input: { consumerId: input.environmentId, actorType: "agent", actorId: input.agentId ?? null, + responsibleUserId: input.responsibleUserId ?? null, issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null, ...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}), @@ -687,6 +689,7 @@ export async function resolveExecutionRunAdapterConfig(input: { consumerId: input.agentId, actorType: "agent", actorId: input.agentId, + responsibleUserId: input.responsibleUserId ?? null, issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null, ...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}), @@ -713,6 +716,7 @@ export async function resolveExecutionRunAdapterConfig(input: { consumerId: input.projectId, actorType: "agent", actorId: input.agentId ?? null, + responsibleUserId: input.responsibleUserId ?? null, issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null, ...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}), @@ -739,6 +743,7 @@ export async function resolveExecutionRunAdapterConfig(input: { consumerId: input.routineId, actorType: "agent", actorId: input.agentId ?? null, + responsibleUserId: input.responsibleUserId ?? null, issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null, ...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}), diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index d0249dc72e1..6c413283bb1 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -7,12 +7,15 @@ import { companySecretProviderConfigs, companySecrets, companySecretVersions, + companyMemberships, environments, heartbeatRuns, issues, projects, routines, secretAccessEvents, + userSecretDeclarations, + userSecretDefinitions, } from "@paperclipai/db"; import type { AgentEnvConfig, @@ -229,6 +232,7 @@ type SecretConsumerContext = { consumerType: SecretBindingTargetType; consumerId: string; configPath?: string | null; + responsibleUserId?: string | null; actorType?: "agent" | "user" | "system" | "plugin"; actorId?: string | null; actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant"; @@ -241,6 +245,7 @@ type SecretConsumerContext = { type SecretResolutionOptions = { bindingContext?: SecretConsumerContext; accessContext?: SecretConsumerContext; + allowUserSecretScope?: boolean; }; export type RuntimeSecretManifestEntry = { @@ -274,6 +279,10 @@ type SecretResolutionErrorCode = | "binding_missing" | "secret_deleted" | "secret_inactive" + | "secret_scope_invalid" + | "responsible_user_missing" + | "user_secret_definition_missing" + | "user_secret_missing" | "version_missing" | "version_inactive" | "provider_error"; @@ -326,10 +335,6 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { }; } -function userSecretResolverUnavailable(key: string): never { - throw unprocessable(`User secret ref is declared but runtime resolution is not enabled yet: ${key}`); -} - function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus { return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; } @@ -348,6 +353,14 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { return details.code; } if (error.message === "Secret is not active") return "secret_inactive"; + if (error.message === "User secret value is not configured") return "user_secret_missing"; + if (error.message === "Responsible user is required for user secret resolution") { + return "responsible_user_missing"; + } + if (error.message === "User secret definition not found") return "user_secret_definition_missing"; + if (error.message === "User-scoped secrets must be resolved through user secret declarations") { + return "secret_scope_invalid"; + } if (error.message === "Secret version not found") return "version_missing"; if (error.message === "Secret version is not active") return "version_inactive"; if ( @@ -403,12 +416,92 @@ export function secretService(db: Db) { .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "company"), eq(companySecrets.name, name), ne(companySecrets.status, "deleted"), )) .then((rows) => rows[0] ?? null); } + async function getUserSecretDefinitionById( + companyId: string, + definitionId: string, + source: Pick = db, + ) { + return source + .select() + .from(userSecretDefinitions) + .where(and( + eq(userSecretDefinitions.companyId, companyId), + eq(userSecretDefinitions.id, definitionId), + )) + .then((rows) => rows[0] ?? null); + } + + async function getUserSecretDefinitionByKey( + companyId: string, + key: string, + source: Pick = db, + ) { + return source + .select() + .from(userSecretDefinitions) + .where(and( + eq(userSecretDefinitions.companyId, companyId), + eq(userSecretDefinitions.key, key), + ne(userSecretDefinitions.status, "deleted"), + )) + .then((rows) => rows[0] ?? null); + } + + async function resolveUserSecretDefinition( + companyId: string, + input: { definitionId?: string | null; definitionKey?: string | null }, + source: Pick = db, + ) { + const definition = input.definitionId + ? await getUserSecretDefinitionById(companyId, input.definitionId, source) + : input.definitionKey + ? await getUserSecretDefinitionByKey(companyId, input.definitionKey, source) + : null; + if (!definition || definition.deletedAt || definition.status === "deleted") { + throw notFound("User secret definition not found"); + } + if (definition.companyId !== companyId) { + throw unprocessable("User secret definition must belong to same company"); + } + return definition; + } + + async function getUserSecretValue(input: { + companyId: string; + ownerUserId: string; + definitionId: string; + }) { + return db + .select() + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, input.companyId), + eq(companySecrets.scope, "user"), + eq(companySecrets.ownerUserId, input.ownerUserId), + eq(companySecrets.userSecretDefinitionId, input.definitionId), + ne(companySecrets.status, "deleted"), + )) + .then((rows) => rows[0] ?? null); + } + + async function getUserSecretValueById(companyId: string, ownerUserId: string, secretId: string) { + const secret = await getById(secretId); + if (!secret || secret.status === "deleted" || secret.scope !== "user") { + throw notFound("User secret value not found"); + } + if (secret.companyId !== companyId || secret.ownerUserId !== ownerUserId) { + throw notFound("User secret value not found"); + } + return secret; + } + async function getSecretVersion(secretId: string, version: number) { return db .select() @@ -481,9 +574,14 @@ export function secretService(db: Db) { async function recordAccessEvent(input: { companyId: string; secretId: string; + userSecretDefinitionId?: string | null; + secretScope?: string | null; version: number | null; provider: SecretProvider; context: SecretConsumerContext | undefined; + credentialOwnerUserId?: string | null; + credentialSubjectType?: string | null; + credentialSubjectId?: string | null; outcome: "success" | "failure"; errorCode?: string | null; }) { @@ -491,8 +589,14 @@ export function secretService(db: Db) { await db.insert(secretAccessEvents).values({ companyId: input.companyId, secretId: input.secretId, + userSecretDefinitionId: input.userSecretDefinitionId ?? null, + secretScope: input.secretScope ?? "company", version: input.version, provider: input.provider, + responsibleUserId: input.context.responsibleUserId ?? null, + credentialOwnerUserId: input.credentialOwnerUserId ?? null, + credentialSubjectType: input.credentialSubjectType ?? null, + credentialSubjectId: input.credentialSubjectId ?? null, actorType: input.context.actorType ?? "system", actorId: input.context.actorId ?? null, consumerType: input.context.consumerType, @@ -515,6 +619,7 @@ export function secretService(db: Db) { if (!secret) throw notFound("Secret not found"); if (secret.status === "deleted") throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); + if (secret.scope !== "company") throw unprocessable("Secret references require company-scoped secrets"); return secret; } @@ -658,6 +763,11 @@ export function secretService(db: Db) { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); + if (secret.scope !== "company" && !options?.allowUserSecretScope) { + throw unprocessable("User-scoped secrets must be resolved through user secret declarations", { + code: "secret_scope_invalid", + }); + } const resolvedVersion = version === "latest" ? secret.latestVersion : version; const providerId = secret.provider as SecretProvider; const configPath = accessContext?.configPath ?? null; @@ -701,9 +811,14 @@ export function secretService(db: Db) { recordAccessEvent({ companyId, secretId: secret.id, + userSecretDefinitionId: secret.userSecretDefinitionId ?? null, + secretScope: secret.scope, version: resolvedVersion, provider: providerId, context: accessContext, + credentialOwnerUserId: secret.ownerUserId ?? null, + credentialSubjectType: secret.scope === "user" ? "user" : null, + credentialSubjectId: secret.ownerUserId ?? null, outcome: "success", }).catch(() => undefined), ]); @@ -726,9 +841,14 @@ export function secretService(db: Db) { await recordAccessEvent({ companyId, secretId: secret.id, + userSecretDefinitionId: secret.userSecretDefinitionId ?? null, + secretScope: secret.scope, version: resolvedVersion, provider: providerId, context: accessContext, + credentialOwnerUserId: secret.ownerUserId ?? null, + credentialSubjectType: secret.scope === "user" ? "user" : null, + credentialSubjectId: secret.ownerUserId ?? null, outcome: "failure", errorCode, }).catch(() => undefined); @@ -954,6 +1074,7 @@ export function secretService(db: Db) { .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "company"), eq(companySecrets.key, key), ne(companySecrets.status, "deleted"), )) @@ -1315,6 +1436,148 @@ export function secretService(db: Db) { return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) }; } + async function createUserSecretValueInternal( + companyId: string, + ownerUserId: string, + input: { + definitionId?: string | null; + definitionKey?: string | null; + value?: string | null; + externalRef?: string | null; + providerVersionRef?: string | null; + providerConfigId?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) { + const definition = await resolveUserSecretDefinition(companyId, input); + if (definition.status !== "active") { + throw unprocessable("User secret definition is not active"); + } + const existing = await getUserSecretValue({ + companyId, + ownerUserId, + definitionId: definition.id, + }); + if (existing) throw conflict("User secret value already exists"); + + const providerId = definition.provider as SecretProvider; + const managedMode = definition.managedMode as "paperclip_managed" | "external_reference"; + if (managedMode === "external_reference" && !input.externalRef?.trim()) { + throw unprocessable("External reference user secrets require externalRef"); + } + if (managedMode === "paperclip_managed" && input.externalRef?.trim()) { + throw unprocessable("Managed user secrets cannot override externalRef"); + } + if (managedMode === "paperclip_managed" && !input.value?.trim()) { + throw unprocessable("Managed user secrets require value"); + } + + const providerConfigId = + input.providerConfigId === undefined ? definition.providerConfigId : input.providerConfigId; + const provider = getSecretProvider(providerId); + const providerConfig = await getSelectableRuntimeProviderConfig({ + companyId, + provider: providerId, + providerConfigId, + }); + const idSuffix = randomUUID(); + const key = normalizeSecretKey(`user.${definition.key}.${idSuffix}`); + const name = `${definition.name} (${ownerUserId})`; + const providerWriteContext = { + companyId, + secretKey: key, + secretName: definition.name, + version: 1, + }; + const reservedSecret = await db + .insert(companySecrets) + .values({ + companyId, + scope: "user", + ownerUserId, + userSecretDefinitionId: definition.id, + key, + name, + provider: providerId, + providerConfigId: providerConfigId ?? null, + status: "archived", + managedMode, + externalRef: null, + providerMetadata: definition.providerMetadata ?? null, + latestVersion: 0, + description: definition.description ?? null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + + let prepared: PreparedSecretVersion; + try { + prepared = + managedMode === "external_reference" + ? await provider.linkExternalSecret({ + externalRef: input.externalRef ?? "", + providerVersionRef: input.providerVersionRef ?? null, + providerConfig, + context: providerWriteContext, + }) + : await provider.createSecret({ + value: input.value ?? "", + externalRef: null, + providerConfig, + context: providerWriteContext, + }); + } catch (error) { + await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); + throw error; + } + + try { + return await db.transaction(async (tx) => { + await tx.insert(companySecretVersions).values({ + secretId: reservedSecret.id, + version: 1, + material: prepared.material, + valueSha256: prepared.valueSha256, + fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256, + providerVersionRef: prepared.providerVersionRef ?? null, + status: "current", + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }); + const secret = await tx + .update(companySecrets) + .set({ + status: "active", + externalRef: prepared.externalRef, + latestVersion: 1, + lastRotatedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(companySecrets.id, reservedSecret.id)) + .returning() + .then((rows) => rows[0]); + if (!secret) throw notFound("User secret value not found"); + return secret; + }); + } catch (error) { + if (managedMode === "paperclip_managed") { + await cleanupPreparedProviderWrite({ + provider, + prepared, + providerConfig, + context: providerWriteContext, + mode: "delete", + operation: "user_secret_value.create_rollback", + }).catch(() => false); + } + await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined); + await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); + throw error; + } + } + return { listProviders: () => listSecretProviders(), @@ -1572,7 +1835,11 @@ export function secretService(db: Db) { db .select() .from(companySecrets) - .where(and(eq(companySecrets.companyId, companyId), ne(companySecrets.status, "deleted"))) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "company"), + ne(companySecrets.status, "deleted"), + )) .orderBy(desc(companySecrets.createdAt)), db .select({ @@ -1623,6 +1890,414 @@ export function secretService(db: Db) { .where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId))) .orderBy(desc(secretAccessEvents.createdAt)), + listUserSecretDefinitions: (companyId: string) => + db + .select() + .from(userSecretDefinitions) + .where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted"))) + .orderBy(desc(userSecretDefinitions.createdAt)), + + getUserSecretDefinitionById: (companyId: string, definitionId: string) => + getUserSecretDefinitionById(companyId, definitionId), + + createUserSecretDefinition: async ( + companyId: string, + input: { + key: string; + name: string; + description?: string | null; + status?: string; + provider: SecretProvider; + providerConfigId?: string | null; + managedMode?: "paperclip_managed" | "external_reference"; + providerMetadata?: Record | null; + usageGuidance?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const key = input.key.trim(); + const duplicate = await getUserSecretDefinitionByKey(companyId, key); + if (duplicate) throw conflict(`User secret definition already exists: ${key}`); + await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId); + return db + .insert(userSecretDefinitions) + .values({ + companyId, + key, + name: input.name.trim(), + description: input.description ?? null, + status: input.status ?? "active", + provider: input.provider, + providerConfigId: input.providerConfigId ?? null, + managedMode: input.managedMode ?? "paperclip_managed", + providerMetadata: input.providerMetadata ?? null, + usageGuidance: input.usageGuidance ?? null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + updatedByAgentId: actor?.agentId ?? null, + updatedByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + }, + + updateUserSecretDefinition: async ( + companyId: string, + definitionId: string, + patch: { + key?: string; + name?: string; + description?: string | null; + status?: string; + providerConfigId?: string | null; + providerMetadata?: Record | null; + usageGuidance?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const existing = await resolveUserSecretDefinition(companyId, { definitionId }); + const nextKey = patch.key?.trim() ?? existing.key; + if (nextKey !== existing.key) { + const duplicate = await getUserSecretDefinitionByKey(companyId, nextKey); + if (duplicate && duplicate.id !== existing.id) { + throw conflict(`User secret definition already exists: ${nextKey}`); + } + } + if (patch.providerConfigId !== undefined) { + await assertProviderConfigForSecret( + companyId, + existing.provider as SecretProvider, + patch.providerConfigId, + ); + } + const deleting = patch.status === "deleted"; + return db + .update(userSecretDefinitions) + .set({ + key: deleting ? `${existing.key}__deleted__${existing.id}` : nextKey, + name: patch.name?.trim() ?? existing.name, + description: patch.description === undefined ? existing.description : patch.description, + status: patch.status ?? existing.status, + providerConfigId: + patch.providerConfigId === undefined ? existing.providerConfigId : patch.providerConfigId, + providerMetadata: + patch.providerMetadata === undefined ? existing.providerMetadata : patch.providerMetadata, + usageGuidance: + patch.usageGuidance === undefined ? existing.usageGuidance : patch.usageGuidance, + updatedByAgentId: actor?.agentId ?? null, + updatedByUserId: actor?.userId ?? null, + deletedAt: deleting ? new Date() : existing.deletedAt, + updatedAt: new Date(), + }) + .where(and( + eq(userSecretDefinitions.companyId, companyId), + eq(userSecretDefinitions.id, definitionId), + )) + .returning() + .then((rows) => rows[0] ?? null); + }, + + removeUserSecretDefinition: async ( + companyId: string, + definitionId: string, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const existing = await resolveUserSecretDefinition(companyId, { definitionId }); + return db + .update(userSecretDefinitions) + .set({ + key: `${existing.key}__deleted__${existing.id}`, + status: "deleted", + deletedAt: existing.deletedAt ?? new Date(), + updatedByAgentId: actor?.agentId ?? null, + updatedByUserId: actor?.userId ?? null, + updatedAt: new Date(), + }) + .where(and( + eq(userSecretDefinitions.companyId, companyId), + eq(userSecretDefinitions.id, definitionId), + )) + .returning() + .then((rows) => rows[0] ?? null); + }, + + getUserSecretDefinitionCoverage: async (companyId: string, definitionId: string) => { + await resolveUserSecretDefinition(companyId, { definitionId }); + const [members, values] = await Promise.all([ + db + .select({ principalId: companyMemberships.principalId }) + .from(companyMemberships) + .where(and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + )), + db + .select({ status: companySecrets.status, ownerUserId: companySecrets.ownerUserId }) + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "user"), + eq(companySecrets.userSecretDefinitionId, definitionId), + ne(companySecrets.status, "deleted"), + )), + ]); + const memberIds = new Set(members.map((member) => member.principalId)); + const configuredCount = values.filter((value) => + value.status === "active" && value.ownerUserId && memberIds.has(value.ownerUserId) + ).length; + const inactiveCount = values.filter((value) => + value.status !== "active" && value.ownerUserId && memberIds.has(value.ownerUserId) + ).length; + return { + definitionId, + configuredCount, + inactiveCount, + missingCount: Math.max(0, memberIds.size - configuredCount - inactiveCount), + }; + }, + + listCurrentUserSecretValues: async (companyId: string, ownerUserId: string) => { + const definitions = await db + .select() + .from(userSecretDefinitions) + .where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted"))) + .orderBy(desc(userSecretDefinitions.createdAt)); + const values = await db + .select() + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "user"), + eq(companySecrets.ownerUserId, ownerUserId), + ne(companySecrets.status, "deleted"), + )); + const valuesByDefinitionId = new Map(values.map((value) => [value.userSecretDefinitionId, value])); + return definitions.map((definition) => ({ + definition, + secret: valuesByDefinitionId.get(definition.id) ?? null, + })); + }, + + createCurrentUserSecretValue: createUserSecretValueInternal, + + rotateCurrentUserSecretValue: async ( + companyId: string, + ownerUserId: string, + secretId: string, + input: { + value?: string | null; + externalRef?: string | null; + providerVersionRef?: string | null; + providerConfigId?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const secret = await getUserSecretValueById(companyId, ownerUserId, secretId); + return await (async () => { + await resolveUserSecretDefinition(companyId, { definitionId: secret.userSecretDefinitionId }); + return (await secretService(db).rotate(secret.id, input, actor)); + })(); + }, + + updateCurrentUserSecretValue: async ( + companyId: string, + ownerUserId: string, + secretId: string, + patch: { + status?: "active" | "disabled" | "archived" | "deleted"; + value?: string | null; + externalRef?: string | null; + providerVersionRef?: string | null; + providerConfigId?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const secret = await getUserSecretValueById(companyId, ownerUserId, secretId); + if ( + patch.value !== undefined || + patch.externalRef !== undefined || + patch.providerVersionRef !== undefined || + patch.providerConfigId !== undefined + ) { + return await secretService(db).rotateCurrentUserSecretValue( + companyId, + ownerUserId, + secret.id, + patch, + actor, + ); + } + if (patch.status === "deleted") { + return await secretService(db).removeCurrentUserSecretValue(companyId, ownerUserId, secret.id); + } + return db + .update(companySecrets) + .set({ + status: patch.status ?? secret.status, + updatedAt: new Date(), + }) + .where(eq(companySecrets.id, secret.id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + removeCurrentUserSecretValue: async (companyId: string, ownerUserId: string, secretId: string) => { + const secret = await getUserSecretValueById(companyId, ownerUserId, secretId); + return await secretService(db).remove(secret.id); + }, + + syncUserSecretDeclarationsForTarget: async ( + companyId: string, + target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string }, + refs: Array<{ + definitionKey: string; + configPath: string; + envKey: string; + versionSelector?: SecretVersionSelector; + required?: boolean; + allowMissingOverride?: boolean; + label?: string | null; + }>, + options?: { db?: SecretBindingDb; replaceAll?: boolean }, + ) => { + const targetDb = options?.db ?? db; + const normalizedRefs: Array<{ + definitionId: string; + configPath: string; + envKey: string; + versionSelector: SecretVersionSelector; + required: boolean; + allowMissingOverride: boolean; + label: string | null; + }> = []; + for (const ref of refs) { + const definition = await resolveUserSecretDefinition( + companyId, + { definitionKey: ref.definitionKey }, + targetDb, + ); + normalizedRefs.push({ + definitionId: definition.id, + configPath: ref.configPath, + envKey: ref.envKey, + versionSelector: ref.versionSelector ?? "latest", + required: ref.required ?? true, + allowMissingOverride: ref.allowMissingOverride ?? false, + label: ref.label ?? null, + }); + } + + const pathPrefix = target.pathPrefix ?? "env"; + const writeDeclarations = async (executor: SecretBindingDb) => { + if (options?.replaceAll) { + await executor + .delete(userSecretDeclarations) + .where(and( + eq(userSecretDeclarations.companyId, companyId), + eq(userSecretDeclarations.targetType, target.targetType), + eq(userSecretDeclarations.targetId, target.targetId), + )); + } else { + await executor + .delete(userSecretDeclarations) + .where(and( + eq(userSecretDeclarations.companyId, companyId), + eq(userSecretDeclarations.targetType, target.targetType), + eq(userSecretDeclarations.targetId, target.targetId), + like(userSecretDeclarations.configPath, `${pathPrefix}.%`), + )); + } + if (normalizedRefs.length === 0) return; + await executor.insert(userSecretDeclarations).values( + normalizedRefs.map((ref) => ({ + companyId, + userSecretDefinitionId: ref.definitionId, + targetType: target.targetType, + targetId: target.targetId, + configPath: ref.configPath, + envKey: ref.envKey, + versionSelector: String(ref.versionSelector), + required: ref.required, + allowMissingOverride: ref.allowMissingOverride, + label: ref.label, + })), + ); + }; + + if (options?.db) { + await writeDeclarations(targetDb); + } else { + await db.transaction(async (tx) => writeDeclarations(tx)); + } + return normalizedRefs; + }, + + resolveUserSecretValue: async ( + companyId: string, + input: { + definitionKey?: string | null; + definitionId?: string | null; + responsibleUserId?: string | null; + version?: SecretVersionSelector; + required?: boolean; + allowMissingOverride?: boolean; + }, + context?: SecretConsumerContext, + ): Promise => { + const responsibleUserId = input.responsibleUserId ?? context?.responsibleUserId ?? null; + const definition = await resolveUserSecretDefinition(companyId, input); + if (definition.status !== "active") { + throw unprocessable("User secret definition is not active"); + } + if (!responsibleUserId?.trim()) { + throw unprocessable("Responsible user is required for user secret resolution", { + code: "responsible_user_missing", + }); + } + if (context?.configPath) { + const declaration = await db + .select() + .from(userSecretDeclarations) + .where(and( + eq(userSecretDeclarations.companyId, companyId), + eq(userSecretDeclarations.userSecretDefinitionId, definition.id), + eq(userSecretDeclarations.targetType, context.consumerType), + eq(userSecretDeclarations.targetId, context.consumerId), + eq(userSecretDeclarations.configPath, context.configPath), + )) + .then((rows) => rows[0] ?? null); + if (!declaration) { + throw unprocessable( + `User secret is not declared for ${context.consumerType}:${context.consumerId} at ${context.configPath}`, + { code: "binding_missing" }, + ); + } + } + const secret = await getUserSecretValue({ + companyId, + ownerUserId: responsibleUserId, + definitionId: definition.id, + }); + if (!secret) { + if (input.allowMissingOverride || input.required === false) return null; + throw unprocessable("User secret value is not configured", { + code: "user_secret_missing", + definitionId: definition.id, + responsibleUserId, + }); + } + return await resolveSecretValueInternal( + companyId, + secret.id, + input.version ?? "latest", + { + accessContext: context ? { ...context, responsibleUserId } : undefined, + allowUserSecretScope: true, + }, + ); + }, + previewRemoteImport: async ( companyId: string, input: { @@ -1950,6 +2625,7 @@ export function secretService(db: Db) { .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "company"), eq(companySecrets.key, key), ne(companySecrets.status, "deleted"), )) @@ -2274,6 +2950,7 @@ export function secretService(db: Db) { .from(companySecrets) .where(and( eq(companySecrets.companyId, secret.companyId), + eq(companySecrets.scope, "company"), eq(companySecrets.key, nextKey), ne(companySecrets.status, "deleted"), )) @@ -2499,12 +3176,32 @@ export function secretService(db: Db) { configPath: string; versionSelector: SecretVersionSelector; }> = []; + const userRefs: Array<{ + definitionKey: string; + configPath: string; + envKey: string; + versionSelector: SecretVersionSelector; + required: boolean; + allowMissingOverride: boolean; + }> = []; const pathPrefix = target.pathPrefix ?? "env"; const bindingDb = options?.db ?? db; for (const [key, rawBinding] of Object.entries(record)) { const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "user_secret_ref") { + await resolveUserSecretDefinition(companyId, { definitionKey: binding.key }, bindingDb); + userRefs.push({ + definitionKey: binding.key, + configPath: `${pathPrefix}.${key}`, + envKey: key, + versionSelector: binding.version, + required: binding.required, + allowMissingOverride: binding.allowMissingOverride, + }); + continue; + } if (binding.type !== "secret_ref") continue; await assertSecretInCompany(companyId, binding.secretId, bindingDb); refs.push({ @@ -2536,13 +3233,49 @@ export function secretService(db: Db) { versionSelector: String(ref.versionSelector), required: true, })), + ); + }; + + const writeUserDeclarations = async (targetDb: SecretBindingDb) => { + await targetDb + .delete(userSecretDeclarations) + .where( + and( + eq(userSecretDeclarations.companyId, companyId), + eq(userSecretDeclarations.targetType, target.targetType), + eq(userSecretDeclarations.targetId, target.targetId), + like(userSecretDeclarations.configPath, `${pathPrefix}.%`), + ), + ); + if (userRefs.length === 0) return; + const definitions = new Map(); + for (const ref of userRefs) { + const definition = await resolveUserSecretDefinition(companyId, { definitionKey: ref.definitionKey }, targetDb); + definitions.set(ref.definitionKey, definition.id); + } + await targetDb.insert(userSecretDeclarations).values( + userRefs.map((ref) => ({ + companyId, + userSecretDefinitionId: definitions.get(ref.definitionKey)!, + targetType: target.targetType, + targetId: target.targetId, + configPath: ref.configPath, + envKey: ref.envKey, + versionSelector: String(ref.versionSelector), + required: ref.required, + allowMissingOverride: ref.allowMissingOverride, + })), ); }; if (options?.db) { await writeBindings(options.db); + await writeUserDeclarations(options.db); } else { - await db.transaction(async (tx) => writeBindings(tx)); + await db.transaction(async (tx) => { + await writeBindings(tx); + await writeUserDeclarations(tx); + }); } return refs; }, @@ -2663,7 +3396,27 @@ export function secretService(db: Db) { manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } else { - userSecretResolverUnavailable(binding.key); + const secretResolution = await secretService(db).resolveUserSecretValue( + companyId, + { + definitionKey: binding.key, + version: binding.version, + required: binding.required, + allowMissingOverride: binding.allowMissingOverride, + }, + context + ? { + ...context, + configPath: `env.${key}`, + responsibleUserId: context.responsibleUserId ?? null, + } + : undefined, + ); + if (secretResolution) { + resolved[key] = secretResolution.value; + manifest.push(secretResolution.manifestEntry); + secretKeys.add(key); + } } } return { env: resolved, secretKeys, manifest }; @@ -2813,7 +3566,27 @@ export function secretService(db: Db) { manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } else { - userSecretResolverUnavailable(binding.key); + const secretResolution = await secretService(db).resolveUserSecretValue( + companyId, + { + definitionKey: binding.key, + version: binding.version, + required: binding.required, + allowMissingOverride: binding.allowMissingOverride, + }, + context + ? { + ...context, + configPath: `env.${key}`, + responsibleUserId: context.responsibleUserId ?? null, + } + : undefined, + ); + if (secretResolution) { + env[key] = secretResolution.value; + manifest.push(secretResolution.manifestEntry); + secretKeys.add(key); + } } } resolved.env = env; @@ -2825,7 +3598,30 @@ export function secretService(db: Db) { if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") continue; - if (binding.type === "user_secret_ref") userSecretResolverUnavailable(binding.key); + if (binding.type === "user_secret_ref") { + const secretResolution = await secretService(db).resolveUserSecretValue( + companyId, + { + definitionKey: binding.key, + version: binding.version, + required: binding.required, + allowMissingOverride: binding.allowMissingOverride, + }, + context + ? { + ...context, + configPath: key, + responsibleUserId: context.responsibleUserId ?? null, + } + : undefined, + ); + if (secretResolution) { + resolved[key] = secretResolution.value; + manifest.push(secretResolution.manifestEntry); + secretKeys.add(key); + } + continue; + } const secretResolution = await resolveSecretValueInternal( companyId, binding.secretId, From 2f8bab01bc91dbb88a0abde795d3c7e83d76da7d Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 17:09:39 +0000 Subject: [PATCH 03/26] Implement user-secret runtime responsibility gates Co-Authored-By: Paperclip --- .../__tests__/heartbeat-project-env.test.ts | 66 ++++++++++ server/src/__tests__/issues-service.test.ts | 52 ++++++++ server/src/__tests__/routines-service.test.ts | 41 ++++++ server/src/__tests__/secrets-service.test.ts | 46 +++++++ server/src/routes/issues.ts | 3 + server/src/services/heartbeat.ts | 123 ++++++++++++++++-- server/src/services/issues.ts | 61 +++++++++ server/src/services/routines.ts | 25 ++++ server/src/services/secrets.ts | 117 ++++++++++++++++- 9 files changed, 519 insertions(+), 15 deletions(-) diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 3fd5112a324..c311d064dad 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -283,6 +283,72 @@ describe("resolveExecutionRunAdapterConfig", () => { }); }); + it("blocks required missing user secrets before runtime env resolution", async () => { + const resolveAdapterConfigForRuntime = vi.fn(); + const resolveEnvBindings = vi.fn(); + const collectMissingRuntimeBindings = vi.fn(async (_companyId, _env, context) => + context.consumerType === "agent" + ? [ + { + consumerType: "agent", + consumerId: "agent-1", + configPath: "env.GITHUB_TOKEN", + envKey: "GITHUB_TOKEN", + bindingType: "user_secret_ref", + secretId: null, + secretName: null, + userSecretDefinitionId: "definition-1", + userSecretDefinitionKey: "github_token", + userSecretDefinitionName: "GitHub token", + responsibleUserId: context.responsibleUserId, + errorCode: "user_secret_missing", + }, + ] + : [], + ); + + await expect(resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + responsibleUserId: "user-1", + executionRunConfig: { + env: { + GITHUB_TOKEN: { type: "user_secret_ref", key: "github_token", required: true }, + }, + }, + projectEnv: null, + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings, + collectMissingRuntimeBindings, + } as any, + })).rejects.toMatchObject({ + code: "configuration_incomplete", + resultJson: { + configurationIncomplete: { + reason: "secret_binding_missing", + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + missingBindings: [ + expect.objectContaining({ + bindingType: "user_secret_ref", + userSecretDefinitionKey: "github_token", + responsibleUserId: "user-1", + }), + ], + }, + }, + }); + expect(collectMissingRuntimeBindings.mock.calls[0]?.[2]).toMatchObject({ + responsibleUserId: "user-1", + }); + expect(resolveAdapterConfigForRuntime).not.toHaveBeenCalled(); + expect(resolveEnvBindings).not.toHaveBeenCalled(); + }); + it("rejects inline sensitive env values for low-trust runs", async () => { await expect(resolveExecutionRunAdapterConfig({ companyId: "company-1", diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 8064953df4d..97dd540c9fd 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2151,6 +2151,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(goals); + await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(environments); await db.delete(instanceSettings); @@ -2237,6 +2238,57 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); + it("inherits responsible user for agent-created child issues", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const responsibleUserId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const parent = await svc.create(companyId, { + title: "Parent issue", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + createdByUserId: responsibleUserId, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "running", + responsibleUserId, + contextSnapshot: { issueId: parent.id }, + }); + + const child = await svc.create(companyId, { + parentId: parent.id, + title: "Agent-created child", + createdByAgentId: agentId, + actorRunId: runId, + }); + + expect(parent.responsibleUserId).toBe(responsibleUserId); + expect(child.responsibleUserId).toBe(responsibleUserId); + }); + it("does not stamp the assignee default environment onto new issues", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 183913f2273..15137f3b7c8 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -712,6 +712,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { id: issues.id, assigneeAgentId: issues.assigneeAgentId, createdByUserId: issues.createdByUserId, + responsibleUserId: issues.responsibleUserId, }) .from(issues) .where(eq(issues.id, run.linkedIssueId!)); @@ -719,6 +720,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { id: run.linkedIssueId, assigneeAgentId: agentId, createdByUserId: userId, + responsibleUserId: userId, }); const inboxIssues = await issueSvc.list(companyId, { @@ -729,6 +731,45 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(inboxIssues.map((issue) => issue.id)).toContain(run.linkedIssueId); }); + it("uses the routine revision responsible-user snapshot for automatic runs", async () => { + const { companyId, agentId, projectId, svc } = await seedFixture(); + const responsibleUserId = randomUUID(); + const driftUserId = randomUUID(); + const routine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "snapshotted owner routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + { userId: responsibleUserId }, + ); + + await db + .update(routines) + .set({ responsibleUserId: driftUserId, updatedAt: new Date() }) + .where(eq(routines.id, routine.id)); + + const run = await svc.runRoutine(routine.id, { source: "schedule" }); + + expect(run.status).toBe("issue_created"); + expect(run.responsibleUserId).toBe(responsibleUserId); + const [createdIssue] = await db + .select({ + responsibleUserId: issues.responsibleUserId, + }) + .from(issues) + .where(eq(issues.id, run.linkedIssueId!)); + expect(createdIssue?.responsibleUserId).toBe(responsibleUserId); + }); + it("waits for the assignee wakeup to be queued before returning the routine run", async () => { let wakeupResolved = false; const { routine, svc } = await seedFixture({ diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 071f2c0995c..14dbf2775e6 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -420,6 +420,52 @@ describeEmbeddedPostgres("secretService", () => { responsibleUserId: "user-2", }), ).rejects.toThrow(/not configured/i); + await expect( + svc.collectMissingRuntimeBindings(companyId, env, { + consumerType: "agent", + consumerId: "agent-1", + responsibleUserId: "user-2", + }), + ).resolves.toEqual([ + expect.objectContaining({ + bindingType: "user_secret_ref", + configPath: "env.GITHUB_TOKEN", + envKey: "GITHUB_TOKEN", + userSecretDefinitionId: definition.id, + userSecretDefinitionKey: "github_token", + responsibleUserId: "user-2", + errorCode: "user_secret_missing", + }), + ]); + + const optionalEnv = { + OPTIONAL_GITHUB_TOKEN: { + type: "user_secret_ref" as const, + key: "github_token", + version: "latest" as const, + required: false, + }, + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-optional" }, optionalEnv); + await expect( + svc.collectMissingRuntimeBindings(companyId, optionalEnv, { + consumerType: "agent", + consumerId: "agent-optional", + responsibleUserId: "user-2", + }), + ).resolves.toEqual([]); + await expect( + svc.resolveEnvBindings(companyId, optionalEnv, { + consumerType: "agent", + consumerId: "agent-optional", + actorType: "agent", + actorId: "agent-optional", + responsibleUserId: "user-2", + }), + ).resolves.toMatchObject({ + env: {}, + manifest: [], + }); const resolved = await svc.resolveEnvBindings(companyId, env, { consumerType: "agent", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8e0889c6190..11d82bd08f8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -5229,6 +5229,7 @@ export function issueRoutes( ...(sourceTrust ? { sourceTrust } : {}), createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, watchdogActorRunId: actor.runId, }); await issueReferencesSvc.syncIssue(issue.id); @@ -5397,6 +5398,7 @@ export function issueRoutes( ...(sourceTrust ? { sourceTrust } : {}), createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, actorAgentId: actor.agentId, actorUserId: actor.actorType === "user" ? actor.actorId : null, watchdogActorRunId: actor.runId, @@ -5562,6 +5564,7 @@ export function issueRoutes( ...(sourceTrust ? { sourceTrust } : {}), createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, actorAgentId: actor.agentId, actorUserId: actor.actorType === "user" ? actor.actorId : null, }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d1d9a59e721..ecfcd4971f6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -431,9 +431,19 @@ type RuntimeConfigSecretResolver = Pick< >; function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string { + if (missing.bindingType === "user_secret_ref") { + const definitionLabel = + missing.userSecretDefinitionName + ? `"${missing.userSecretDefinitionName}"` + : missing.userSecretDefinitionKey + ? `"${missing.userSecretDefinitionKey}"` + : "declared user secret"; + const ownerLabel = missing.responsibleUserId ? ` for responsible user ${missing.responsibleUserId}` : ""; + return `user secret ${definitionLabel}${ownerLabel} not available at ${missing.consumerType} ${missing.configPath}`; + } const secretLabel = missing.secretName ? `"${missing.secretName}"` - : missing.secretId; + : missing.secretId ?? "unknown"; return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`; } @@ -579,7 +589,11 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(await input.secretsSvc.collectMissingRuntimeBindings( input.companyId, environmentEnv, - { consumerType: "environment", consumerId: input.environmentId }, + { + consumerType: "environment", + consumerId: input.environmentId, + responsibleUserId: input.responsibleUserId ?? null, + }, )), ); } @@ -588,7 +602,11 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(await input.secretsSvc.collectMissingRuntimeBindings( input.companyId, parseObject(executionRunConfig.env), - { consumerType: "agent", consumerId: input.agentId }, + { + consumerType: "agent", + consumerId: input.agentId, + responsibleUserId: input.responsibleUserId ?? null, + }, )), ); if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") { @@ -597,7 +615,11 @@ export async function resolveExecutionRunAdapterConfig(input: { input.companyId, executionRunConfig, input.adapterType ?? null, - { consumerType: "agent", consumerId: input.agentId }, + { + consumerType: "agent", + consumerId: input.agentId, + responsibleUserId: input.responsibleUserId ?? null, + }, )), ); } @@ -607,7 +629,11 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(await input.secretsSvc.collectMissingRuntimeBindings( input.companyId, projectEnv, - { consumerType: "project", consumerId: input.projectId }, + { + consumerType: "project", + consumerId: input.projectId, + responsibleUserId: input.responsibleUserId ?? null, + }, )), ); } @@ -616,7 +642,11 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(await input.secretsSvc.collectMissingRuntimeBindings( input.companyId, routineEnv, - { consumerType: "routine", consumerId: input.routineId }, + { + consumerType: "routine", + consumerId: input.routineId, + responsibleUserId: input.responsibleUserId ?? null, + }, )), ); } @@ -4498,6 +4528,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionPolicy: issues.executionPolicy, executionWorkspaceSettings: issues.executionWorkspaceSettings, + parentId: issues.parentId, + createdByUserId: issues.createdByUserId, + responsibleUserId: issues.responsibleUserId, originKind: issues.originKind, originId: issues.originId, originRunId: issues.originRunId, @@ -4513,13 +4546,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueContext: Awaited> | null, ) { if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) { - return { routineId: null, env: null }; + return { routineId: null, env: null, responsibleUserId: null }; } const routineRun = issueContext.originRunId ? await db .select({ routineRevisionId: routineRuns.routineRevisionId, + responsibleUserId: routineRuns.responsibleUserId, }) .from(routineRuns) .where( @@ -4536,6 +4570,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const revision = await db .select({ snapshot: routineRevisions.snapshot, + responsibleUserId: routineRevisions.responsibleUserId, }) .from(routineRevisions) .where( @@ -4548,16 +4583,50 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .then((rows) => rows[0] ?? null); const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined; if (snapshot?.version === 1) { - return { routineId: issueContext.originId, env: snapshot.routine.env ?? null }; + return { + routineId: issueContext.originId, + env: snapshot.routine.env ?? null, + responsibleUserId: revision?.responsibleUserId ?? snapshot.routine.responsibleUserId ?? null, + }; } } const routine = await db - .select({ env: routines.env }) + .select({ env: routines.env, responsibleUserId: routines.responsibleUserId }) .from(routines) .where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId))) .then((rows) => rows[0] ?? null); - return { routineId: issueContext.originId, env: routine?.env ?? null }; + return { + routineId: issueContext.originId, + env: routine?.env ?? null, + responsibleUserId: routineRun?.responsibleUserId ?? routine?.responsibleUserId ?? null, + }; + } + + async function resolveResponsibleUserIdForRun(input: { + run: typeof heartbeatRuns.$inferSelect; + contextSnapshot: Record; + issueContext: Awaited> | null; + routineEnvContext: Awaited>; + }) { + const contextResponsibleUserId = readNonEmptyString(input.contextSnapshot.responsibleUserId); + if (contextResponsibleUserId) return contextResponsibleUserId; + if (input.run.responsibleUserId) return input.run.responsibleUserId; + if (input.routineEnvContext.responsibleUserId) return input.routineEnvContext.responsibleUserId; + if (input.issueContext?.responsibleUserId) return input.issueContext.responsibleUserId; + if (input.issueContext?.createdByUserId) return input.issueContext.createdByUserId; + if (input.issueContext?.parentId) { + const parent = await db + .select({ + responsibleUserId: issues.responsibleUserId, + createdByUserId: issues.createdByUserId, + }) + .from(issues) + .where(and(eq(issues.companyId, input.run.companyId), eq(issues.id, input.issueContext.parentId))) + .then((rows) => rows[0] ?? null); + return parent?.responsibleUserId ?? parent?.createdByUserId ?? null; + } + return null; } async function getRuntimeState(agentId: string) { @@ -8301,10 +8370,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } const claimedAt = new Date(); + const responsibleUserId = await resolveResponsibleUserIdForRun({ + run, + contextSnapshot: context, + issueContext: issueId ? await getIssueExecutionContext(run.companyId, issueId) : null, + routineEnvContext: { routineId: null, env: null, responsibleUserId: null }, + }); const claimed = await db .update(heartbeatRuns) .set({ status: "running", + responsibleUserId, startedAt: run.startedAt ?? claimedAt, updatedAt: claimedAt, }) @@ -9395,6 +9471,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) delete context.acceptedPlanWakeRouting; } const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext); + const responsibleUserId = await resolveResponsibleUserIdForRun({ + run, + contextSnapshot: context, + issueContext, + routineEnvContext, + }); + if (responsibleUserId && run.responsibleUserId !== responsibleUserId) { + await db + .update(heartbeatRuns) + .set({ responsibleUserId, updatedAt: new Date() }) + .where(eq(heartbeatRuns.id, run.id)); + run = { ...run, responsibleUserId }; + } + if (responsibleUserId && issueContext && !issueContext.responsibleUserId) { + await db + .update(issues) + .set({ responsibleUserId, updatedAt: new Date() }) + .where(and(eq(issues.companyId, agent.companyId), eq(issues.id, issueContext.id), isNull(issues.responsibleUserId))); + issueContext = { ...issueContext, responsibleUserId }; + } const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy( parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy), isolatedWorkspacesEnabled, @@ -9703,6 +9799,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) environmentEnv: selectedEnvironmentForConfig?.envVars ?? null, projectId: projectContext?.id ?? null, routineId: routineEnvContext.routineId, + responsibleUserId, executionRunConfig, projectEnv: projectContext?.env ?? null, routineEnv: routineEnvContext.env, @@ -12017,6 +12114,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) { enrichedContextSnapshot.projectId = projectId; } + const queuedResponsibleUserId = + readNonEmptyString(enrichedContextSnapshot.responsibleUserId) ?? + (opts.requestedByActorType === "user" ? opts.requestedByActorId ?? null : null); const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, { issueId, @@ -12603,6 +12703,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) invocationSource: source, triggerDetail, status: "queued", + responsibleUserId: queuedResponsibleUserId, wakeupRequestId: wakeupRequest.id, contextSnapshot: enrichedContextSnapshot, sessionIdBefore: sessionBefore, @@ -12668,7 +12769,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) Boolean(sameScopeRunningRun) && !sameScopeQueuedRun && shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }); - const rawCoalescedTarget = sameScopeQueuedRun ?? sameScopeScheduledRetryRun ?? @@ -12777,6 +12877,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) invocationSource: source, triggerDetail, status: "queued", + responsibleUserId: queuedResponsibleUserId, wakeupRequestId: wakeupRequest.id, contextSnapshot: enrichedContextSnapshot, sessionIdBefore: sessionBefore, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 733c3c9c20b..3d9bc1d1505 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -14,6 +14,7 @@ import { documents, goals, heartbeatRuns, + routineRuns, executionWorkspaces, issueApprovals, issueAttachments, @@ -137,6 +138,55 @@ function readStringFromRecord(record: unknown, key: string) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +async function resolveResponsibleUserIdForIssueCreate( + reader: DbReader, + companyId: string, + input: { + explicitResponsibleUserId?: string | null; + createdByUserId?: string | null; + parentId?: string | null; + originKind?: string | null; + originRunId?: string | null; + actorRunId?: string | null; + }, +) { + const explicitResponsibleUserId = readStringFromRecord(input, "explicitResponsibleUserId"); + if (explicitResponsibleUserId) return explicitResponsibleUserId; + + if (input.originKind === "routine_execution" && input.originRunId) { + const routineRun = await reader + .select({ responsibleUserId: routineRuns.responsibleUserId }) + .from(routineRuns) + .where(and(eq(routineRuns.companyId, companyId), eq(routineRuns.id, input.originRunId))) + .then((rows) => rows[0] ?? null); + if (routineRun?.responsibleUserId) return routineRun.responsibleUserId; + } + + if (input.actorRunId) { + const actorRun = await reader + .select({ responsibleUserId: heartbeatRuns.responsibleUserId }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, input.actorRunId))) + .then((rows) => rows[0] ?? null); + if (actorRun?.responsibleUserId) return actorRun.responsibleUserId; + } + + if (input.parentId) { + const parent = await reader + .select({ + responsibleUserId: issues.responsibleUserId, + createdByUserId: issues.createdByUserId, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, input.parentId))) + .then((rows) => rows[0] ?? null); + if (parent?.responsibleUserId) return parent.responsibleUserId; + if (parent?.createdByUserId) return parent.createdByUserId; + } + + return input.createdByUserId ?? null; +} + function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings( settings: ReturnType, ) { @@ -366,6 +416,7 @@ type IssueCreateInput = Omit & { inheritExecutionWorkspaceFromIssueId?: string | null; watchdog?: { agentId: string; instructions?: string | null } | null; watchdogActorRunId?: string | null; + actorRunId?: string | null; }; type IssueChildCreateInput = IssueCreateInput & { acceptanceCriteria?: string[]; @@ -5014,6 +5065,7 @@ export function issueService(db: Db) { inheritExecutionWorkspaceFromIssueId, watchdog, watchdogActorRunId, + actorRunId, ...issueData } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; @@ -5159,9 +5211,18 @@ export function issueService(db: Db) { const issueNumber = company.issueCounter; const identifier = `${company.issuePrefix}-${issueNumber}`; + const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(tx, companyId, { + explicitResponsibleUserId: issueData.responsibleUserId ?? null, + createdByUserId: issueData.createdByUserId ?? null, + parentId: issueData.parentId ?? null, + originKind: issueData.originKind ?? "manual", + originRunId: issueData.originRunId ?? null, + actorRunId: actorRunId ?? null, + }); const values = { ...issueData, + responsibleUserId, requestDepth: clampIssueRequestDepth(issueData.requestDepth), originKind: issueData.originKind ?? "manual", goalId: resolveIssueGoalId({ diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index d7ab92c517f..ce0ce389f27 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -820,6 +820,7 @@ export function routineService( createdByAgentId: actor.agentId ?? null, createdByUserId: actor.userId ?? null, createdByRunId: actor.runId ?? null, + responsibleUserId: snapshot.routine.responsibleUserId ?? null, createdAt: now, }) .returning(); @@ -1137,6 +1138,7 @@ export function routineService( completedAt: triggeredAt, linkedIssueId: null, routineRevisionId: input.routine.latestRevisionId, + responsibleUserId: input.routine.responsibleUserId ?? null, }) .returning(); await updateRoutineTouchedState({ @@ -1486,6 +1488,26 @@ export function routineService( const triggeredAt = new Date(); const manualRunnerUserId = input.source === "manual" ? input.actor?.userId ?? null : null; + const latestRevisionResponsibleUserId = input.routine.latestRevisionId + ? await txDb + .select({ + responsibleUserId: routineRevisions.responsibleUserId, + snapshot: routineRevisions.snapshot, + }) + .from(routineRevisions) + .where(and( + eq(routineRevisions.companyId, input.routine.companyId), + eq(routineRevisions.routineId, input.routine.id), + eq(routineRevisions.id, input.routine.latestRevisionId), + )) + .then((rows) => { + const row = rows[0] ?? null; + const snapshot = row?.snapshot as RoutineRevisionSnapshotV1 | undefined; + return row?.responsibleUserId ?? snapshot?.routine.responsibleUserId ?? null; + }) + : null; + const responsibleUserId = + manualRunnerUserId ?? latestRevisionResponsibleUserId ?? input.routine.responsibleUserId ?? null; const [createdRun] = await txDb .insert(routineRuns) .values({ @@ -1499,6 +1521,7 @@ export function routineService( triggerPayload, dispatchFingerprint, routineRevisionId: input.routine.latestRevisionId, + responsibleUserId, }) .returning(); @@ -1552,6 +1575,7 @@ export function routineService( assigneeAgentId, createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null, createdByUserId: manualRunnerUserId, + responsibleUserId, originKind: issueOriginKind, originId: issueOriginId, originRunId: createdRun.id, @@ -1861,6 +1885,7 @@ export function routineService( catchUpPolicy: input.catchUpPolicy, variables, env, + responsibleUserId: actor.userId ?? null, createdByAgentId: actor.agentId ?? null, createdByUserId: actor.userId ?? null, updatedByAgentId: actor.agentId ?? null, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 6c413283bb1..99c4a12dae0 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -266,8 +266,14 @@ export type MissingRuntimeBinding = { consumerId: string; configPath: string; envKey: string; - secretId: string; + bindingType?: "secret_ref" | "user_secret_ref"; + secretId: string | null; secretName: string | null; + userSecretDefinitionId?: string | null; + userSecretDefinitionKey?: string | null; + userSecretDefinitionName?: string | null; + responsibleUserId?: string | null; + errorCode?: SecretResolutionErrorCode; }; type RuntimeSecretResolution = { @@ -2251,6 +2257,7 @@ export function secretService(db: Db) { throw unprocessable("User secret definition is not active"); } if (!responsibleUserId?.trim()) { + if (input.allowMissingOverride || input.required === false) return null; throw unprocessable("Responsible user is required for user secret resolution", { code: "responsible_user_missing", }); @@ -3441,7 +3448,16 @@ export function secretService(db: Db) { if (binding.type !== "secret_ref") return []; return [{ key, configPath: `env.${key}`, secretId: binding.secretId }]; }); - if (secretRefs.length === 0) return []; + const userSecretRefs = Object.entries(record).flatMap(([key, rawBinding]) => { + if (!ENV_KEY_RE.test(key)) return []; + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) return []; + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type !== "user_secret_ref") return []; + if (!binding.required || binding.allowMissingOverride) return []; + return [{ key, configPath: `env.${key}`, binding }]; + }); + if (secretRefs.length === 0 && userSecretRefs.length === 0) return []; const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({ entry, @@ -3456,7 +3472,6 @@ export function secretService(db: Db) { const missingEntries = bindingChecks .filter((check) => !check.found) .map((check) => check.entry); - if (missingEntries.length === 0) return []; const secretRows = await Promise.all( [...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [ @@ -3466,14 +3481,108 @@ export function secretService(db: Db) { ); const secretsById = new Map(secretRows); - return missingEntries.map((entry) => ({ + const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, + bindingType: "secret_ref", secretId: entry.secretId, secretName: secretsById.get(entry.secretId)?.name ?? null, })); + + const missingUserSecretBindings: MissingRuntimeBinding[] = []; + for (const entry of userSecretRefs) { + let definition: typeof userSecretDefinitions.$inferSelect | null = null; + try { + definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key }); + } catch { + missingUserSecretBindings.push({ + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + envKey: entry.key, + bindingType: "user_secret_ref", + secretId: null, + secretName: null, + userSecretDefinitionKey: entry.binding.key, + responsibleUserId: context.responsibleUserId ?? null, + errorCode: "user_secret_definition_missing", + }); + continue; + } + + const declaration = await db + .select() + .from(userSecretDeclarations) + .where(and( + eq(userSecretDeclarations.companyId, companyId), + eq(userSecretDeclarations.userSecretDefinitionId, definition.id), + eq(userSecretDeclarations.targetType, context.consumerType), + eq(userSecretDeclarations.targetId, context.consumerId), + eq(userSecretDeclarations.configPath, entry.configPath), + )) + .then((rows) => rows[0] ?? null); + if (!declaration) { + missingUserSecretBindings.push({ + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + envKey: entry.key, + bindingType: "user_secret_ref", + secretId: null, + secretName: null, + userSecretDefinitionId: definition.id, + userSecretDefinitionKey: definition.key, + userSecretDefinitionName: definition.name, + responsibleUserId: context.responsibleUserId ?? null, + errorCode: "binding_missing", + }); + continue; + } + + if (!context.responsibleUserId?.trim()) { + missingUserSecretBindings.push({ + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + envKey: entry.key, + bindingType: "user_secret_ref", + secretId: null, + secretName: null, + userSecretDefinitionId: definition.id, + userSecretDefinitionKey: definition.key, + userSecretDefinitionName: definition.name, + responsibleUserId: null, + errorCode: "responsible_user_missing", + }); + continue; + } + + const secret = await getUserSecretValue({ + companyId, + ownerUserId: context.responsibleUserId, + definitionId: definition.id, + }); + if (!secret || secret.status !== "active") { + missingUserSecretBindings.push({ + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + envKey: entry.key, + bindingType: "user_secret_ref", + secretId: secret?.id ?? null, + secretName: null, + userSecretDefinitionId: definition.id, + userSecretDefinitionKey: definition.key, + userSecretDefinitionName: definition.name, + responsibleUserId: context.responsibleUserId, + errorCode: secret ? "secret_inactive" : "user_secret_missing", + }); + } + } + + return [...missingSecretBindings, ...missingUserSecretBindings]; }, collectMissingAdapterConfigRuntimeBindings: async ( From a5673e81491bdcf1ee1175646034d0a4b2478ae5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 18:14:52 +0000 Subject: [PATCH 04/26] Add user-secret Secrets tabs and env binding picker Phase 6 UI (PAP-12280) for user-specific secrets: - Secrets page: new "User secret definitions" (admin) and "My secrets" (owner) tabs; rename first tab to "Company secrets". Tabs are visually distinct via a violet accent vs company secrets' neutral/emerald. - UserSecretDefinitionsTab: create/edit/delete company definitions with coverage shown as counts only (never values). - MyUserSecretsTab + SetMyUserSecretDialog: each member sets/updates/clears their own value; dialog is shared so it can also satisfy a missing required secret from warning surfaces. - EnvVarEditor: third binding source "User secret" (user_secret_ref) with a definition picker and Required/Optional toggle; rename bare "Secret" to "Company secret". Wired user-secret definitions into agent and project env. - api/secrets + queryKeys: user-secret-definition, coverage, and my-secrets endpoints. Co-Authored-By: Paperclip --- ui/src/api/secrets.ts | 77 ++++ ui/src/components/AgentConfigForm.tsx | 11 + ui/src/components/EnvVarEditor.tsx | 243 ++++++++--- ui/src/components/ProjectProperties.tsx | 9 + ui/src/lib/queryKeys.ts | 4 + ui/src/pages/Secrets.tsx | 20 +- ui/src/pages/secrets/MyUserSecretsTab.tsx | 180 +++++++++ .../pages/secrets/SetMyUserSecretDialog.tsx | 174 ++++++++ .../secrets/UserSecretDefinitionsTab.tsx | 381 ++++++++++++++++++ ui/src/pages/secrets/my-value-state.ts | 20 + .../secrets/user-secret-presentation.tsx | 79 ++++ 11 files changed, 1139 insertions(+), 59 deletions(-) create mode 100644 ui/src/pages/secrets/MyUserSecretsTab.tsx create mode 100644 ui/src/pages/secrets/SetMyUserSecretDialog.tsx create mode 100644 ui/src/pages/secrets/UserSecretDefinitionsTab.tsx create mode 100644 ui/src/pages/secrets/my-value-state.ts create mode 100644 ui/src/pages/secrets/user-secret-presentation.tsx diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index f04705f3a8b..139d1afaf13 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -12,6 +12,8 @@ import type { SecretProviderConfigHealthResponse, SecretProviderDescriptor, SecretStatus, + UserSecretCoverageSummary, + UserSecretDefinition, } from "@paperclipai/shared"; import { api } from "./client"; @@ -20,6 +22,44 @@ export interface SecretUsageResponse { bindings: CompanySecretUsageBinding[]; } +/** One "My secrets" row: a company definition paired with the current user's own value (if set). */ +export interface MyUserSecretEntry { + definition: UserSecretDefinition; + secret: CompanySecret | null; +} + +export interface CreateUserSecretDefinitionInput { + key: string; + name: string; + description?: string | null; + status?: SecretStatus; + provider?: SecretProvider; + managedMode?: SecretManagedMode; + providerConfigId?: string | null; + providerMetadata?: Record | null; + usageGuidance?: string | null; +} + +export interface UpdateUserSecretDefinitionInput { + key?: string; + name?: string; + description?: string | null; + status?: SecretStatus; + providerConfigId?: string | null; + providerMetadata?: Record | null; + usageGuidance?: string | null; +} + +/** Owner-supplied value for a user secret. Either `value` (managed) or `externalRef`. */ +export interface UpsertMyUserSecretInput { + definitionId?: string; + definitionKey?: string; + value?: string | null; + externalRef?: string | null; + providerVersionRef?: string | null; + providerConfigId?: string | null; +} + export interface CreateSecretInput { name: string; key?: string; @@ -147,6 +187,43 @@ export const secretsApi = { remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`), usage: (id: string) => api.get(`/secrets/${id}/usage`), accessEvents: (id: string) => api.get(`/secrets/${id}/access-events`), + + // --- User-specific secrets --------------------------------------------- + // Admin: shared definitions each member fills in with their own value. + listUserSecretDefinitions: (companyId: string) => + api.get(`/companies/${companyId}/user-secret-definitions`), + createUserSecretDefinition: (companyId: string, data: CreateUserSecretDefinitionInput) => + api.post(`/companies/${companyId}/user-secret-definitions`, data), + updateUserSecretDefinition: ( + companyId: string, + definitionId: string, + data: UpdateUserSecretDefinitionInput, + ) => + api.patch( + `/companies/${companyId}/user-secret-definitions/${definitionId}`, + data, + ), + removeUserSecretDefinition: (companyId: string, definitionId: string) => + api.delete<{ ok: true }>(`/companies/${companyId}/user-secret-definitions/${definitionId}`), + userSecretDefinitionCoverage: (companyId: string, definitionId: string) => + api.get( + `/companies/${companyId}/user-secret-definitions/${definitionId}/coverage`, + ), + + // Current user ("My secrets"): each definition paired with my own value. + listMyUserSecrets: (companyId: string) => + api.get(`/companies/${companyId}/me/user-secrets`), + createMyUserSecret: (companyId: string, data: UpsertMyUserSecretInput) => + api.post(`/companies/${companyId}/me/user-secrets`, data), + updateMyUserSecret: ( + companyId: string, + secretId: string, + data: Partial & { status?: SecretStatus }, + ) => api.patch(`/companies/${companyId}/me/user-secrets/${secretId}`, data), + rotateMyUserSecret: (companyId: string, secretId: string, data: UpsertMyUserSecretInput) => + api.post(`/companies/${companyId}/me/user-secrets/${secretId}/rotate`, data), + removeMyUserSecret: (companyId: string, secretId: string) => + api.delete<{ ok: true }>(`/companies/${companyId}/me/user-secrets/${secretId}`), remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) => api.post( `/companies/${companyId}/secrets/remote-import/preview`, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 7026078c21f..513217b847e 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -217,6 +217,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { queryFn: () => secretsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); + // User-secret definitions power the "User secret" env binding source. Requires + // secret-admin; non-admins simply get the free-text key fallback in the editor. + const { data: userSecretDefinitions = [] } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.secrets.userDefinitions(selectedCompanyId) + : ["user-secret-definitions", "none"], + queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + retry: false, + }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), @@ -1358,6 +1368,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ) } secrets={availableSecrets} + userSecretDefinitions={userSecretDefinitions} onCreateSecret={async (name, value) => { const created = await createSecret.mutateAsync({ name, value }); return created; diff --git a/ui/src/components/EnvVarEditor.tsx b/ui/src/components/EnvVarEditor.tsx index faabf9db09f..e2ef81b5a23 100644 --- a/ui/src/components/EnvVarEditor.tsx +++ b/ui/src/components/EnvVarEditor.tsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useState } from "react"; -import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared"; -import { AlertCircle, KeyRound, X } from "lucide-react"; +import type { + CompanySecret, + EnvBinding, + SecretVersionSelector, + UserSecretDefinition, +} from "@paperclipai/shared"; +import { AlertCircle, KeyRound, UserRound, X } from "lucide-react"; import { cn } from "../lib/utils"; import { Select, @@ -30,60 +35,78 @@ function envKeyFromSecretName(name: string): string { .slice(0, 64); } +type RowSource = "plain" | "secret" | "user_secret"; + type Row = { key: string; - source: "plain" | "secret"; + source: RowSource; plainValue: string; secretId: string; version: SecretVersionSelector; + /** For user_secret rows: the user-secret definition key this env var resolves from. */ + userSecretKey: string; + /** For user_secret rows: whether a run must fail if the responsible user has not set a value. */ + required: boolean; }; function emptyRow(): Row { - return { key: "", source: "plain", plainValue: "", secretId: "", version: "latest" }; + return { + key: "", + source: "plain", + plainValue: "", + secretId: "", + version: "latest", + userSecretKey: "", + required: true, + }; } function toRows(rec: Record | null | undefined): Row[] { if (!rec || typeof rec !== "object") { return [emptyRow()]; } - const entries = Object.entries(rec).map(([key, binding]) => { + const entries = Object.entries(rec).map(([key, binding]): Row => { if (typeof binding === "string") { - return { key, source: "plain" as const, plainValue: binding, secretId: "", version: "latest" as const }; + return { ...emptyRow(), key, source: "plain", plainValue: binding }; } - if ( - typeof binding === "object" && - binding !== null && - "type" in binding && - (binding as { type?: unknown }).type === "secret_ref" - ) { - const record = binding as { secretId?: unknown; version?: unknown }; - const version: SecretVersionSelector = typeof record.version === "number" - ? record.version - : "latest"; - return { - key, - source: "secret" as const, - plainValue: "", - secretId: typeof record.secretId === "string" ? record.secretId : "", - version, - }; + if (typeof binding === "object" && binding !== null && "type" in binding) { + const type = (binding as { type?: unknown }).type; + if (type === "secret_ref") { + const record = binding as { secretId?: unknown; version?: unknown }; + const version: SecretVersionSelector = + typeof record.version === "number" ? record.version : "latest"; + return { + ...emptyRow(), + key, + source: "secret", + secretId: typeof record.secretId === "string" ? record.secretId : "", + version, + }; + } + if (type === "user_secret_ref") { + const record = binding as { key?: unknown; version?: unknown; required?: unknown }; + const version: SecretVersionSelector = + typeof record.version === "number" ? record.version : "latest"; + return { + ...emptyRow(), + key, + source: "user_secret", + userSecretKey: typeof record.key === "string" ? record.key : "", + version, + required: record.required !== false, + }; + } + if (type === "plain") { + const record = binding as { value?: unknown }; + return { + ...emptyRow(), + key, + source: "plain", + plainValue: typeof record.value === "string" ? record.value : "", + }; + } } - if ( - typeof binding === "object" && - binding !== null && - "type" in binding && - (binding as { type?: unknown }).type === "plain" - ) { - const record = binding as { value?: unknown }; - return { - key, - source: "plain" as const, - plainValue: typeof record.value === "string" ? record.value : "", - secretId: "", - version: "latest" as const, - }; - } - return { key, source: "plain" as const, plainValue: "", secretId: "", version: "latest" as const }; + return { ...emptyRow(), key, source: "plain" }; }); return [...entries, emptyRow()]; } @@ -91,12 +114,19 @@ function toRows(rec: Record | null | undefined): Row[] { export function EnvVarEditor({ value, secrets, + userSecretDefinitions, onCreateSecret, onChange, recentlyUsedSecrets, }: { value: Record; secrets: CompanySecret[]; + /** + * Optional company user-secret definitions. When present, the "User secret" + * source becomes a picker over these definitions; otherwise the user types + * the definition key directly. Absent for non-admin contexts. + */ + userSecretDefinitions?: UserSecretDefinition[]; onCreateSecret: (name: string, value: string) => Promise; onChange: (env: Record | undefined) => void; /** @@ -109,6 +139,7 @@ export function EnvVarEditor({ const [sealError, setSealError] = useState(null); const valueRef = useRef(value); const emittingRef = useRef(false); + const userSecretsEnabled = (userSecretDefinitions?.length ?? 0) > 0; useEffect(() => { if (emittingRef.current) { @@ -133,6 +164,18 @@ export function EnvVarEditor({ } else { rec[key] = { type: "plain", value: row.plainValue }; } + } else if (row.source === "user_secret") { + const definitionKey = row.userSecretKey.trim(); + if (definitionKey) { + rec[key] = { + type: "user_secret_ref", + key: definitionKey, + version: row.version, + required: row.required, + }; + } else { + rec[key] = { type: "plain", value: row.plainValue }; + } } else { rec[key] = { type: "plain", value: row.plainValue }; } @@ -145,11 +188,8 @@ export function EnvVarEditor({ const withPatch: Row[] = rows.map((row, rowIndex) => rowIndex === index ? { ...row, ...patch, version: patch.version ?? row.version } : row, ); - if ( - withPatch[withPatch.length - 1].key || - withPatch[withPatch.length - 1].plainValue || - withPatch[withPatch.length - 1].secretId - ) { + const last = withPatch[withPatch.length - 1]; + if (last.key || last.plainValue || last.secretId || last.userSecretKey) { withPatch.push(emptyRow()); } setRows(withPatch); @@ -158,12 +198,8 @@ export function EnvVarEditor({ function removeRow(index: number) { const next = rows.filter((_, rowIndex) => rowIndex !== index); - if ( - next.length === 0 || - next[next.length - 1].key || - next[next.length - 1].plainValue || - next[next.length - 1].secretId - ) { + const last = next[next.length - 1]; + if (next.length === 0 || last.key || last.plainValue || last.secretId || last.userSecretKey) { next.push(emptyRow()); } setRows(next); @@ -175,7 +211,13 @@ export function EnvVarEditor({ const next = rows.map((row) => ({ ...row })); const trailing = next[next.length - 1]; let target: Row; - if (trailing && !trailing.key && !trailing.plainValue && !trailing.secretId) { + if ( + trailing && + !trailing.key && + !trailing.plainValue && + !trailing.secretId && + !trailing.userSecretKey + ) { target = trailing; } else { target = emptyRow(); @@ -226,7 +268,8 @@ export function EnvVarEditor({ index === rows.length - 1 && !row.key && !row.plainValue && - !row.secretId; + !row.secretId && + !row.userSecretKey; return (
updateRow(index, { - source: next === "secret" ? "secret" : "plain", - ...(next === "plain" ? { secretId: "" } : {}), + source: next as RowSource, + ...(next === "plain" ? { secretId: "", userSecretKey: "" } : {}), + ...(next === "secret" ? { userSecretKey: "" } : {}), + ...(next === "user_secret" ? { secretId: "" } : {}), }) } > @@ -249,7 +294,8 @@ export function EnvVarEditor({ Plain - Secret + Company secret + User secret {row.source === "secret" ? ( @@ -325,6 +371,75 @@ export function EnvVarEditor({ New + ) : row.source === "user_secret" ? ( + <> + {userSecretsEnabled ? ( + + ) : ( + updateRow(index, { userSecretKey: event.target.value })} + /> + )} + +
+ ) : ( <> ); })()} + {(() => { + const userRows = rows.filter((row) => row.source === "user_secret" && row.userSecretKey); + if (!userRows.length) return null; + return ( +

+ + + User secrets resolve to the value set by the user responsible for the run. Required + bindings fail the run until that user sets their value under Secrets → My secrets. + +

+ ); + })()}

- Set KEY to the env var name the process expects, for example GH_TOKEN. Choose Secret to resolve a stored - value at run start. PAPERCLIP_* variables are injected automatically. + Set KEY to the env var name the process expects, for example GH_TOKEN. Choose Company secret + to resolve a shared stored value, or User secret to resolve each user's own value at run + start. PAPERCLIP_* variables are injected automatically.

); diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index b2d159e7dbb..63bcac000f1 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -256,6 +256,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa queryFn: () => secretsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); + const { data: userSecretDefinitions = [] } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.secrets.userDefinitions(selectedCompanyId) + : ["user-secret-definitions", "none"], + queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + retry: false, + }); const createSecret = useMutation({ mutationFn: (input: { name: string; value: string }) => { if (!selectedCompanyId) throw new Error("Select a company to create secrets"); @@ -626,6 +634,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa { const created = await createSecret.mutateAsync({ name, value }); return created; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index d4ca9c06dff..7507fbbd4e0 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -245,6 +245,10 @@ export const queryKeys = { providerConfigs: (companyId: string) => ["secret-provider-configs", companyId] as const, usage: (secretId: string) => ["secrets", "usage", secretId] as const, accessEvents: (secretId: string) => ["secrets", "access-events", secretId] as const, + userDefinitions: (companyId: string) => ["user-secret-definitions", companyId] as const, + userDefinitionCoverage: (companyId: string, definitionId: string) => + ["user-secret-definitions", companyId, definitionId, "coverage"] as const, + myUserSecrets: (companyId: string) => ["my-user-secrets", companyId] as const, }, companySearch: { search: (companyId: string, q: string, scope: string, limit: number, offset: number) => diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 7623ca79f15..c1a79b79ba1 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -76,9 +76,11 @@ import { Badge } from "@/components/ui/badge"; import { cn } from "../lib/utils"; import { PageTabBar } from "../components/PageTabBar"; import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog"; +import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab"; +import { UserSecretDefinitionsTab } from "./secrets/UserSecretDefinitionsTab"; type CreateMode = "managed" | "external"; -type SecretsTab = "secrets" | "vaults"; +type SecretsTab = "secrets" | "user-definitions" | "my-secrets" | "vaults"; type ProviderVaultForm = { provider: SecretProvider; @@ -885,7 +887,9 @@ export function Secrets() { > + + + + + + (null); + + const mySecretsQuery = useQuery({ + queryKey: queryKeys.secrets.myUserSecrets(companyId), + queryFn: () => secretsApi.listMyUserSecrets(companyId), + }); + const entries = mySecretsQuery.data ?? []; + + const clear = useMutation({ + mutationFn: (secret: CompanySecret) => secretsApi.removeMyUserSecret(companyId, secret.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(companyId) }); + pushToast({ title: "Value cleared", tone: "info" }); + }, + onError: (err) => + pushToast({ + title: "Could not clear value", + body: err instanceof Error ? err.message : undefined, + tone: "error", + }), + }); + + const missingCount = entries.filter( + (entry) => entry.definition.status === "active" && !entry.secret, + ).length; + + return ( +
+
+ +

+ These are credentials only you provide. Each value is yours alone — used when you are the + user responsible for a run — and is never shown back to anyone, including admins. + {missingCount > 0 ? ( + + {" "} + {missingCount} required secret{missingCount === 1 ? "" : "s"} still need your value. + + ) : null} +

+
+ +
+ {mySecretsQuery.isError ? ( +
+ Failed to load your secrets:{" "} + {(mySecretsQuery.error as Error).message} + +
+ ) : entries.length === 0 && !mySecretsQuery.isPending ? ( + + ) : ( +
    + {entries.map((entry) => ( + setDialogFor(entry)} + onClear={() => entry.secret && clear.mutate(entry.secret)} + clearing={clear.isPending} + /> + ))} +
+ )} +
+ + { + if (!open) setDialogFor(null); + }} + /> +
+ ); +} + +function MyUserSecretRow({ + entry, + onSet, + onClear, + clearing, +}: { + entry: MyUserSecretEntry; + onSet: () => void; + onClear: () => void; + clearing: boolean; +}) { + const { definition, secret } = entry; + const state = myValueState(definition, secret); + const disabledDefinition = definition.status !== "active"; + + return ( +
  • +
    +
    + {definition.name} + + {definition.key} + + {disabledDefinition ? ( + + {definition.status} + + ) : null} +
    + {definition.description ? ( +

    {definition.description}

    + ) : null} + {definition.usageGuidance ? ( +

    {definition.usageGuidance}

    + ) : null} +
    + +
    + + {myValueLabel(state)} + + {!disabledDefinition ? ( + + ) : null} + {secret ? ( + + ) : null} +
    +
  • + ); +} diff --git a/ui/src/pages/secrets/SetMyUserSecretDialog.tsx b/ui/src/pages/secrets/SetMyUserSecretDialog.tsx new file mode 100644 index 00000000000..77a98f5d961 --- /dev/null +++ b/ui/src/pages/secrets/SetMyUserSecretDialog.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { secretsApi } from "../../api/secrets"; +import { ApiError } from "../../api/client"; +import { queryKeys } from "../../lib/queryKeys"; +import { useToastActions } from "../../context/ToastContext"; +import { UserSecretChip } from "./user-secret-presentation"; + +/** + * Shared "set my value" dialog for a user-secret definition. Used both from the + * Secrets → My secrets tab and from the missing-required-secret warning surfaces + * (task run / issue failure), so a user can satisfy a required secret from either + * place with identical behavior. + */ +export function SetMyUserSecretDialog({ + companyId, + definition, + existingSecret, + open, + onOpenChange, + onSaved, +}: { + companyId: string; + definition: UserSecretDefinition | null; + existingSecret?: CompanySecret | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved?: (secret: CompanySecret) => void; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const [value, setValue] = useState(""); + const [externalRef, setExternalRef] = useState(""); + const [error, setError] = useState(null); + + const isExternal = definition?.managedMode === "external_reference"; + + useEffect(() => { + if (open) { + setValue(""); + setExternalRef(""); + setError(null); + } + }, [open, definition?.id]); + + const save = useMutation({ + mutationFn: async () => { + if (!definition) throw new Error("No definition selected"); + const payload = isExternal + ? { externalRef: externalRef.trim() } + : { value: value.trim() }; + if (existingSecret) { + // A stored value already exists → rotate it in place. + return secretsApi.rotateMyUserSecret(companyId, existingSecret.id, payload); + } + return secretsApi.createMyUserSecret(companyId, { + definitionId: definition.id, + definitionKey: definition.key, + ...payload, + }); + }, + onSuccess: (secret) => { + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(companyId) }); + pushToast({ + title: existingSecret ? "Value updated" : "Value saved", + body: definition?.name, + tone: "success", + }); + onSaved?.(secret); + onOpenChange(false); + }, + onError: (err) => { + setError( + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Failed to save value", + ); + }, + }); + + const canSave = isExternal ? externalRef.trim().length > 0 : value.trim().length > 0; + + return ( + + + + + {existingSecret ? "Update your value" : "Set your value"} + + + + {definition ? ( + <> + This value is yours only. It is used when you are the user responsible for a run that + needs {definition.key}. + + ) : null} + + + + {definition ? ( +
    +
    +
    {definition.name}
    + {definition.description ? ( +

    {definition.description}

    + ) : null} + {definition.usageGuidance ? ( +

    {definition.usageGuidance}

    + ) : null} +
    + + {isExternal ? ( +
    + + setExternalRef(event.target.value)} + placeholder="provider reference or ARN" + className="font-mono text-sm" + autoFocus + /> +

    + Points at your own credential in the configured provider. Paperclip stores the + reference, not the value. +

    +
    + ) : ( +
    + +