diff --git a/cli/src/__tests__/secrets.test.ts b/cli/src/__tests__/secrets.test.ts index 89ea9840091..295a8e5e036 100644 --- a/cli/src/__tests__/secrets.test.ts +++ b/cli/src/__tests__/secrets.test.ts @@ -46,6 +46,9 @@ function secret(partial: Partial): CompanySecret { return { id: "secret-1", companyId: "company-1", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, key: "agent_agent-12_anthropic_api_key", name: "agent_agent-12_anthropic_api_key", provider: "local_encrypted", 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/packages/db/src/migrations/0127_user_specific_secrets.sql b/packages/db/src/migrations/0127_user_specific_secrets.sql new file mode 100644 index 00000000000..02f2979aac8 --- /dev/null +++ b/packages/db/src/migrations/0127_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 fa5b9832c99..00c4fd106d0 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -890,6 +890,13 @@ "when": 1782526400000, "tag": "0126_issue_comment_derived_attribution", "breakpoints": true + }, + { + "idx": 127, + "version": "7", + "when": 1782923938661, + "tag": "0127_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/plugin-llm-wiki/tests/plugin.spec.ts b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts index 18c05ee82c0..37eea47747d 100644 --- a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts +++ b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts @@ -573,6 +573,7 @@ function paperclipIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 769c3471a6e..c53b9303645 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1206,6 +1206,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { parentIssueId: null, title: declaration.title, description: declaration.description ?? null, + responsibleUserId: null, assigneeAgentId, priority: declaration.priority ?? "medium", status: declaration.status ?? (assigneeAgentId ? "active" : "paused"), @@ -1574,6 +1575,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 8674779f43a..8d9a385d934 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, @@ -812,8 +814,12 @@ export type { EnvBinding, EnvPlainBinding, EnvSecretRefBinding, + EnvUserSecretRefBinding, AgentEnvConfig, CompanySecret, + UserSecretDefinition, + UserSecretDeclaration, + UserSecretCoverageSummary, CompanySecretProviderConfig, SecretProviderConfigPayload, SecretProviderConfigHealthDetails, @@ -1280,9 +1286,16 @@ export { type AddApprovalComment, envBindingPlainSchema, envBindingSecretRefSchema, + envBindingUserSecretRefSchema, envBindingSchema, envConfigSchema, createSecretSchema, + createUserSecretDefinitionSchema, + updateUserSecretDefinitionSchema, + createUserSecretValueSchema, + updateUserSecretValueSchema, + rotateUserSecretValueSchema, + 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 60a60195570..64f6d8e607d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -407,9 +407,13 @@ export type { SecretVersionSelector, EnvPlainBinding, EnvSecretRefBinding, + EnvUserSecretRefBinding, EnvBinding, AgentEnvConfig, CompanySecret, + UserSecretDefinition, + UserSecretDeclaration, + UserSecretCoverageSummary, CompanySecretProviderConfig, SecretProviderConfigPayload, SecretProviderConfigHealthDetails, @@ -433,6 +437,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 c676cafb3b8..56426a9b2e8 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..167ac5e581c 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -463,9 +463,16 @@ export { export { envBindingPlainSchema, envBindingSecretRefSchema, + envBindingUserSecretRefSchema, envBindingSchema, envConfigSchema, createSecretSchema, + createUserSecretDefinitionSchema, + updateUserSecretDefinitionSchema, + createUserSecretValueSchema, + updateUserSecretValueSchema, + rotateUserSecretValueSchema, + createUserSecretDeclarationSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, secretProviderConfigDiscoveryPreviewSchema, @@ -483,6 +490,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..e41ee8b1296 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -1,15 +1,90 @@ import { describe, expect, it } from "vitest"; import { + createUserSecretDefinitionSchema, + createUserSecretDeclarationSchema, + createUserSecretValueSchema, createSecretProviderConfigSchema, createSecretSchema, + envBindingUserSecretRefSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, + rotateSecretSchema, + rotateUserSecretValueSchema, secretProviderConfigDiscoveryPreviewSchema, secretProviderConfigPayloadSchema, + updateUserSecretDefinitionSchema, updateSecretProviderConfigSchema, } 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("does not allow user secret definition keys to be renamed through updates", () => { + expect( + updateUserSecretDefinitionSchema.parse({ + key: "renamed_key", + name: "Renamed", + }), + ).toEqual({ + name: "Renamed", + }); + }); + + it("does not allow user secret definitions to be created as deleted", () => { + expect(() => + createUserSecretDefinitionSchema.parse({ + key: "github_api_token", + name: "GitHub API token", + status: "deleted", + }), + ).toThrow(); + }); + + it("requires secret rotation payloads to include rotation input", () => { + expect(() => rotateSecretSchema.parse({})).toThrow(/requires value, externalRef/); + expect(() => rotateUserSecretValueSchema.parse({})).toThrow(/requires value, externalRef/); + expect(rotateUserSecretValueSchema.parse({ value: "new-secret" })).toEqual({ + value: "new-secret", + }); + expect(rotateUserSecretValueSchema.parse({ providerVersionRef: "version-2" })).toEqual({ + providerVersionRef: "version-2", + }); + }); + 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..164ea2b4006 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -7,6 +7,10 @@ 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()]); +const creatableSecretStatusSchema = z.enum(["active", "disabled", "archived"]); + export const envBindingPlainSchema = z.object({ type: z.literal("plain"), value: z.string(), @@ -15,7 +19,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 +35,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(), @@ -67,18 +80,41 @@ export const createSecretSchema = z.object({ export type CreateSecret = z.infer; +function requireSecretRotationInput( + value: { + value?: string | null; + externalRef?: string | null; + providerVersionRef?: string | null; + providerConfigId?: string | null; + }, + ctx: z.RefinementCtx, +) { + if ( + !value.value?.trim() && + !value.externalRef?.trim() && + value.providerVersionRef === undefined && + value.providerConfigId === undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: "Secret rotation requires value, externalRef, providerVersionRef, or providerConfigId", + }); + } +} + export const rotateSecretSchema = z.object({ value: z.string().min(1).optional().nullable(), externalRef: z.string().optional().nullable(), providerVersionRef: z.string().optional().nullable(), providerConfigId: z.string().uuid().optional().nullable(), -}); +}).superRefine(requireSecretRotationInput); 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 +132,94 @@ 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: creatableSecretStatusSchema.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({ + 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 rotateUserSecretValueSchema = z.object({ + value: z.string().min(1).optional().nullable(), + externalRef: z.string().optional().nullable(), + providerVersionRef: z.string().optional().nullable(), + providerConfigId: z.string().uuid().optional().nullable(), +}).superRefine(requireSecretRotationInput); + +export type RotateUserSecretValue = 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/screenshots/PAP-12331/each-user-detail-sheet-coverage.png b/screenshots/PAP-12331/each-user-detail-sheet-coverage.png new file mode 100644 index 00000000000..b1f12795ace Binary files /dev/null and b/screenshots/PAP-12331/each-user-detail-sheet-coverage.png differ diff --git a/screenshots/PAP-12331/new-secret-each-user-dialog.png b/screenshots/PAP-12331/new-secret-each-user-dialog.png new file mode 100644 index 00000000000..1c7044dd654 Binary files /dev/null and b/screenshots/PAP-12331/new-secret-each-user-dialog.png differ diff --git a/screenshots/PAP-12331/unified-secrets-list.png b/screenshots/PAP-12331/unified-secrets-list.png new file mode 100644 index 00000000000..bc9c5c523b1 Binary files /dev/null and b/screenshots/PAP-12331/unified-secrets-list.png differ diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index 8064f4bcf08..277ada7a15c 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -28,6 +28,7 @@ import { const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const execFileAsync = promisify(execFile); +let seedGraphSequence = 0; type TestGraph = { companyId: string; @@ -63,6 +64,8 @@ async function seedGraph(db: Db, input: { projectSourceType?: string; targetProjectSourceType?: string; }): Promise { + seedGraphSequence += 1; + const prefixSuffix = seedGraphSequence.toString(36).toUpperCase().padStart(4, "0"); const suffix = crypto.randomUUID().slice(0, 8); const companyId = crypto.randomUUID(); const otherCompanyId = crypto.randomUUID(); @@ -79,8 +82,8 @@ async function seedGraph(db: Db, input: { const otherIssueId = crypto.randomUUID(); await db.insert(companies).values([ - { id: companyId, name: `Company ${suffix}`, issuePrefix: `F${suffix.slice(0, 4).toUpperCase()}` }, - { id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${suffix.slice(0, 4).toUpperCase()}` }, + { id: companyId, name: `Company ${suffix}`, issuePrefix: `F${prefixSuffix}` }, + { id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${prefixSuffix}` }, ]); await db.insert(goals).values([ { id: goalId, companyId, title: "Goal", level: "company", status: "active" }, 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 0dbc66b68f5..657777c99a8 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2292,6 +2292,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); @@ -2378,6 +2379,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-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 11c556aa717..33e618af2a6 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,100 @@ 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("rejects current-user secret values without a concrete user identity", async () => { + const res = await request(createApp({ + type: "board", + source: "local_implicit", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }], + })).post("/api/companies/company-1/me/user-secrets").send({ + definitionKey: "github_token", + value: "secret-value", + }); + + expect(res.status).toBe(401); + expect(res.body).toMatchObject({ error: "User identity required for user-specific secrets" }); + expect(mockSecretService.createCurrentUserSecretValue).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("rejects empty current-user secret rotation payloads", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/me/user-secrets/secret-1/rotate") + .send({}); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/requires value, externalRef/); + expect(mockSecretService.rotateCurrentUserSecretValue).not.toHaveBeenCalled(); + }); + + 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..4a0d242c84d 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); @@ -342,6 +346,56 @@ describeEmbeddedPostgres("secretService", () => { expect(resolved.manifest[0]?.bindingId).toBe(binding!.id); }); + it("denies user secret resolution outside the low-trust declaration allowlist", async () => { + const companyId = await seedCompany(); + await seedCompanyMember(companyId, "user-1", "owner"); + 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); + await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionKey: "github_token", + value: "user-one-secret", + }); + const [declaration] = await db + .select() + .from(userSecretDeclarations) + .where(eq(userSecretDeclarations.userSecretDefinitionId, definition.id)); + expect(declaration?.id).toBeTruthy(); + + await expect( + svc.resolveEnvBindings(companyId, env, { + consumerType: "agent", + consumerId: "agent-1", + actorType: "agent", + actorId: "agent-1", + responsibleUserId: "user-1", + allowedBindingIds: ["11111111-1111-4111-8111-111111111111"], + }), + ).rejects.toMatchObject({ + status: 422, + details: { code: "binding_not_allowed" }, + }); + + const resolved = await svc.resolveEnvBindings(companyId, env, { + consumerType: "agent", + consumerId: "agent-1", + actorType: "agent", + actorId: "agent-1", + responsibleUserId: "user-1", + allowedBindingIds: [declaration!.id], + }); + expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret"); + expect(resolved.manifest[0]?.bindingId).toBe(declaration!.id); + }); + it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { const companyId = await seedCompany(); const svc = secretService(db); @@ -387,6 +441,264 @@ 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); + 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: [], + }); + + await db + .update(userSecretDefinitions) + .set({ status: "disabled" }) + .where(eq(userSecretDefinitions.id, definition.id)); + await expect( + svc.resolveEnvBindings(companyId, optionalEnv, { + consumerType: "agent", + consumerId: "agent-optional", + actorType: "agent", + actorId: "agent-optional", + responsibleUserId: "user-2", + }), + ).resolves.toMatchObject({ + env: {}, + manifest: [], + }); + await db + .update(userSecretDefinitions) + .set({ status: "deleted", deletedAt: new Date() }) + .where(eq(userSecretDefinitions.id, definition.id)); + await expect( + svc.resolveEnvBindings(companyId, optionalEnv, { + consumerType: "agent", + consumerId: "agent-optional", + actorType: "agent", + actorId: "agent-optional", + responsibleUserId: "user-2", + }), + ).resolves.toMatchObject({ + env: {}, + manifest: [], + }); + await db + .update(userSecretDefinitions) + .set({ status: "active", deletedAt: null }) + .where(eq(userSecretDefinitions.id, definition.id)); + + 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("treats nullable user-secret value patches as non-rotation updates", async () => { + const companyId = await seedCompany(); + await seedCompanyMember(companyId, "user-1", "owner"); + const svc = secretService(db); + await svc.createUserSecretDefinition(companyId, { + key: "github_token", + name: "GitHub token", + provider: "local_encrypted", + }); + const secret = await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionKey: "github_token", + value: "user-one-secret", + }); + + const updated = await svc.updateCurrentUserSecretValue(companyId, "user-1", secret.id, { + value: null, + externalRef: null, + providerVersionRef: null, + providerConfigId: null, + }); + + expect(updated.latestVersion).toBe(secret.latestVersion); + expect(updated.status).toBe(secret.status); + const versions = await db + .select() + .from(companySecretVersions) + .where(eq(companySecretVersions.secretId, secret.id)); + expect(versions).toHaveLength(1); + expect(versions[0]).toMatchObject({ version: secret.latestVersion, status: "current" }); + expect(versions[0]?.material).toBeTruthy(); + }); + + it("reports missing adapter-config user secret refs before runtime resolution", async () => { + const companyId = await seedCompany(); + await seedCompanyMember(companyId, "user-1", "owner"); + const svc = secretService(db); + const definition = await svc.createUserSecretDefinition(companyId, { + key: "hermes_api_key", + name: "Hermes API key", + provider: "local_encrypted", + }); + const adapterConfig = { + apiBaseUrl: "http://127.0.0.1:9119/api", + apiKey: { type: "user_secret_ref" as const, key: "hermes_api_key", version: "latest" as const }, + }; + await svc.syncUserSecretDeclarationsForTarget(companyId, { + targetType: "agent", + targetId: "agent-1", + }, [ + { + definitionKey: "hermes_api_key", + configPath: "apiKey", + envKey: "apiKey", + }, + ]); + + await expect( + svc.collectMissingAdapterConfigRuntimeBindings( + companyId, + adapterConfig, + "hermes_gateway", + { + consumerType: "agent", + consumerId: "agent-1", + responsibleUserId: "user-1", + }, + ), + ).resolves.toEqual([ + expect.objectContaining({ + bindingType: "user_secret_ref", + configPath: "apiKey", + envKey: "apiKey", + userSecretDefinitionId: definition.id, + userSecretDefinitionKey: "hermes_api_key", + responsibleUserId: "user-1", + errorCode: "user_secret_missing", + }), + ]); + + await expect( + svc.collectMissingAdapterConfigRuntimeBindings( + companyId, + { + ...adapterConfig, + apiKey: { + type: "user_secret_ref" as const, + key: "hermes_api_key", + version: "latest" as const, + required: false, + }, + }, + "hermes_gateway", + { + consumerType: "agent", + consumerId: "agent-1", + responsibleUserId: "user-1", + }, + ), + ).resolves.toEqual([]); + }); + 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/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[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); + if (req.actor.userId) return req.actor.userId; + throw unauthorized("User identity required for user-specific secrets"); +} + +function isCompanyScopedSecret(secret: { scope?: string | null }) { + return (secret.scope ?? "company") === "company"; +} export function secretRoutes(db: Db) { const router = Router(); @@ -269,6 +296,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(rotateUserSecretValueSchema), + 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 +689,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 +731,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 +777,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 +794,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 +811,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 63afaca38c1..9c17645de00 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -433,9 +433,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}`; } @@ -514,6 +524,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; @@ -580,7 +591,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, + }, )), ); } @@ -589,7 +604,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") { @@ -598,7 +617,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, + }, )), ); } @@ -608,7 +631,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, + }, )), ); } @@ -617,7 +644,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, + }, )), ); } @@ -673,6 +704,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 } : {}), @@ -689,6 +721,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 } : {}), @@ -715,6 +748,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 } : {}), @@ -741,6 +775,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 } : {}), @@ -4561,6 +4596,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, @@ -4576,13 +4614,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( @@ -4599,6 +4638,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const revision = await db .select({ snapshot: routineRevisions.snapshot, + responsibleUserId: routineRevisions.responsibleUserId, }) .from(routineRevisions) .where( @@ -4611,16 +4651,48 @@ 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?.parentId) { + const parent = await db + .select({ + responsibleUserId: issues.responsibleUserId, + }) + .from(issues) + .where(and(eq(issues.companyId, input.run.companyId), eq(issues.id, input.issueContext.parentId))) + .then((rows) => rows[0] ?? null); + return parent?.responsibleUserId ?? null; + } + return null; } async function getRuntimeState(agentId: string) { @@ -8364,10 +8436,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, }) @@ -9458,6 +9537,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, @@ -9766,6 +9865,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, @@ -12155,6 +12255,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, @@ -12741,6 +12844,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) invocationSource: source, triggerDetail, status: "queued", + responsibleUserId: queuedResponsibleUserId, wakeupRequestId: wakeupRequest.id, contextSnapshot: enrichedContextSnapshot, sessionIdBefore: sessionBefore, @@ -12806,7 +12910,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) Boolean(sameScopeRunningRun) && !sameScopeQueuedRun && shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }); - const rawCoalescedTarget = sameScopeQueuedRun ?? sameScopeScheduledRetryRun ?? @@ -12915,6 +13018,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 686caa0bffa..ec8fa76aafb 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -15,6 +15,7 @@ import { documents, goals, heartbeatRuns, + routineRuns, executionWorkspaces, issueApprovals, issueAttachments, @@ -144,6 +145,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, ) { @@ -412,6 +462,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[]; @@ -2137,6 +2188,7 @@ const issueListSelect = { executionLockedAt: issues.executionLockedAt, createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId, + responsibleUserId: issues.responsibleUserId, issueNumber: issues.issueNumber, identifier: issues.identifier, originKind: issues.originKind, @@ -5180,6 +5232,7 @@ export function issueService(db: Db) { inheritExecutionWorkspaceFromIssueId, watchdog, watchdogActorRunId, + actorRunId, ...issueData } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; @@ -5325,9 +5378,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/pipelines.ts b/server/src/services/pipelines.ts index 4a59fcfc867..558339fb52e 100644 --- a/server/src/services/pipelines.ts +++ b/server/src/services/pipelines.ts @@ -1177,6 +1177,7 @@ function routineRevisionSnapshotRoutine(routine: typeof routines.$inferSelect): originId: routine.originId, variables: routine.variables ?? [], env: routine.env ?? null, + responsibleUserId: routine.responsibleUserId ?? null, }; } diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 3fae80a27df..ce0ce389f27 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -452,6 +452,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], variables: routine.variables ?? [], env: routine.env ?? null, + responsibleUserId: routine.responsibleUserId ?? null, }; } @@ -819,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(); @@ -1136,6 +1138,7 @@ export function routineService( completedAt: triggeredAt, linkedIssueId: null, routineRevisionId: input.routine.latestRevisionId, + responsibleUserId: input.routine.responsibleUserId ?? null, }) .returning(); await updateRoutineTouchedState({ @@ -1485,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({ @@ -1498,6 +1521,7 @@ export function routineService( triggerPayload, dispatchFingerprint, routineRevisionId: input.routine.latestRevisionId, + responsibleUserId, }) .returning(); @@ -1551,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, @@ -1860,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 860c5a7ff61..4b227dfb756 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, @@ -216,12 +219,20 @@ async function cleanupPreparedProviderWrite(input: { type CanonicalEnvBinding = | { type: "plain"; value: string } - | { type: "secret_ref"; secretId: string; version: number | "latest" }; + | { type: "secret_ref"; secretId: string; version: number | "latest" } + | { + type: "user_secret_ref"; + key: string; + version: number | "latest"; + required: boolean; + allowMissingOverride: boolean; + }; 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"; @@ -234,6 +245,7 @@ type SecretConsumerContext = { type SecretResolutionOptions = { bindingContext?: SecretConsumerContext; accessContext?: SecretConsumerContext; + allowUserSecretScope?: boolean; }; export type RuntimeSecretManifestEntry = { @@ -254,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 = { @@ -267,6 +285,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"; @@ -303,6 +325,15 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { if (binding.type === "plain") { return { type: "plain", value: String(binding.value) }; } + if (binding.type === "user_secret_ref") { + return { + type: "user_secret_ref", + key: binding.key, + version: binding.version ?? "latest", + required: binding.required ?? true, + allowMissingOverride: binding.allowMissingOverride ?? false, + }; + } return { type: "secret_ref", secretId: binding.secretId, @@ -328,6 +359,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 ( @@ -383,12 +422,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() @@ -461,9 +580,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; }) { @@ -471,8 +595,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, @@ -495,6 +625,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; } @@ -638,6 +769,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; @@ -681,9 +817,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), ]); @@ -706,9 +847,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); @@ -807,6 +953,10 @@ export function secretService(db: Db) { normalized[key] = binding; continue; } + if (binding.type === "user_secret_ref") { + normalized[key] = binding; + continue; + } await assertSecretInCompany(companyId, binding.secretId); normalized[key] = { @@ -887,6 +1037,9 @@ export function secretService(db: Db) { version: binding.version, }; } + if (binding.type === "user_secret_ref") { + throw unprocessable(`${input.key} must be a string, plain binding, or company secret reference`); + } const value = binding.value.trim(); if (!value) return undefined; if (value === REDACTED_SENTINEL) { @@ -927,6 +1080,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"), )) @@ -1288,6 +1442,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(), @@ -1545,7 +1841,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({ @@ -1596,6 +1896,440 @@ 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 != null || + patch.externalRef != null || + patch.providerVersionRef != null || + patch.providerConfigId != null + ) { + 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 optionalBinding = input.allowMissingOverride || input.required === false; + let definition: typeof userSecretDefinitions.$inferSelect; + try { + definition = await resolveUserSecretDefinition(companyId, input); + } catch (error) { + if (optionalBinding && error instanceof HttpError && error.status === 404) return null; + throw error; + } + if (definition.status !== "active") { + if (optionalBinding) return null; + throw unprocessable("User secret definition is not active"); + } + if (!responsibleUserId?.trim()) { + if (optionalBinding) return null; + throw unprocessable("Responsible user is required for user secret resolution", { + code: "responsible_user_missing", + }); + } + let declaration: typeof userSecretDeclarations.$inferSelect | null = null; + if (context?.configPath) { + 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" }, + ); + } + } + if ( + Array.isArray(context?.allowedBindingIds) && + (!declaration || !context.allowedBindingIds.includes(declaration.id)) + ) { + throw unprocessable( + "User secret declaration is outside the active low-trust boundary", + { code: "binding_not_allowed" }, + ); + } + const secret = await getUserSecretValue({ + companyId, + ownerUserId: responsibleUserId, + definitionId: definition.id, + }); + if (!secret) { + if (optionalBinding) return null; + throw unprocessable("User secret value is not configured", { + code: "user_secret_missing", + definitionId: definition.id, + responsibleUserId, + }); + } + const resolution = await resolveSecretValueInternal( + companyId, + secret.id, + input.version ?? "latest", + { + accessContext: context ? { ...context, responsibleUserId } : undefined, + allowUserSecretScope: true, + }, + ); + return { + ...resolution, + manifestEntry: { + ...resolution.manifestEntry, + bindingId: declaration?.id ?? resolution.manifestEntry.bindingId ?? null, + }, + }; + }, + previewRemoteImport: async ( companyId: string, input: { @@ -1923,6 +2657,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"), )) @@ -2247,6 +2982,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"), )) @@ -2472,12 +3208,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({ @@ -2509,13 +3265,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; }, @@ -2620,7 +3412,7 @@ export function secretService(db: Db) { const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { resolved[key] = binding.value; - } else { + } else if (binding.type === "secret_ref") { const secretResolution = await resolveSecretValueInternal( companyId, binding.secretId, @@ -2635,6 +3427,28 @@ export function secretService(db: Db) { resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); + } else { + 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 }; @@ -2659,7 +3473,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, @@ -2674,7 +3497,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) => [ @@ -2684,14 +3506,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 ( @@ -2708,7 +3624,16 @@ export function secretService(db: Db) { if (binding.type !== "secret_ref") return []; return [{ key, configPath: key, secretId: binding.secretId }]; }); - if (secretRefs.length === 0) return []; + const userSecretRefs = secretFieldKeys.flatMap((key) => { + const parsed = envBindingSchema.safeParse(adapterConfig[key]); + 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: key, binding }]; + }); + if (secretRefs.length === 0 && userSecretRefs.length === 0) return []; + const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({ entry, found: await getBinding({ @@ -2722,7 +3647,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) => [ @@ -2732,14 +3656,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]; }, resolveAdapterConfigForRuntime: async ( @@ -2768,7 +3786,7 @@ export function secretService(db: Db) { const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { env[key] = binding.value; - } else { + } else if (binding.type === "secret_ref") { const secretResolution = await resolveSecretValueInternal( companyId, binding.secretId, @@ -2783,6 +3801,28 @@ export function secretService(db: Db) { env[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); + } else { + 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; @@ -2794,6 +3834,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") { + 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, diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index f04705f3a8b..d0bdca591b7 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,43 @@ 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?: Exclude; + provider?: SecretProvider; + managedMode?: SecretManagedMode; + providerConfigId?: string | null; + providerMetadata?: Record | null; + usageGuidance?: string | null; +} + +export interface UpdateUserSecretDefinitionInput { + 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 +186,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/ActivityCharts.test.tsx b/ui/src/components/ActivityCharts.test.tsx index e9fe837161a..1d17cd38dff 100644 --- a/ui/src/components/ActivityCharts.test.tsx +++ b/ui/src/components/ActivityCharts.test.tsx @@ -38,6 +38,7 @@ function createRun(overrides: Partial = {}): HeartbeatRun { id: "run-1", companyId: "company-1", agentId: "agent-1", + responsibleUserId: null, invocationSource: "on_demand", triggerDetail: "manual", status: "succeeded", 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.test.tsx b/ui/src/components/EnvVarEditor.test.tsx new file mode 100644 index 00000000000..b4c8160b599 --- /dev/null +++ b/ui/src/components/EnvVarEditor.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom + +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { CompanySecret, EnvBinding, UserSecretDefinition } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnvVarEditor } from "./EnvVarEditor"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const definition: UserSecretDefinition = { + id: "def-1", + companyId: "c1", + key: "PERSONAL_GH_TOKEN", + name: "Personal GitHub token", + description: null, + status: "active", + provider: "local_encrypted", + managedMode: "paperclip_managed", + providerConfigId: null, + providerMetadata: null, + usageGuidance: null, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function render(props: Partial>) { + root = createRoot(container); + return act(() => { + root.render( + , + ); + }); +} + +describe("EnvVarEditor user secret binding", () => { + it("renders an existing user_secret_ref as a User secret row with the definition and requirement", async () => { + const value: Record = { + GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true }, + }; + await render({ value, userSecretDefinitions: [definition] }); + + const keyInput = container.querySelector('input[placeholder="KEY"]'); + expect(keyInput?.value).toBe("GH_TOKEN"); + // Radix Select triggers render the selected label as text. + expect(container.textContent).toContain("User secret"); + expect(container.textContent).toContain("Personal GitHub token"); + expect(container.textContent).toContain("Required"); + }); + + it("offers the User secret source option and the company-secret rename", async () => { + await render({ userSecretDefinitions: [definition] }); + // The trailing empty row shows the source select defaulting to Plain. + const modeTrigger = container.querySelector('[aria-label="Binding mode"]'); + expect(modeTrigger?.textContent).toContain("Plain"); + // Helper copy documents both company and user secret sources. + expect(container.textContent).toContain("Company secret"); + expect(container.textContent).toContain("User secret"); + }); + + it("keeps working for company secrets when no user definitions are provided", async () => { + const secret: CompanySecret = { + id: "sec-1", + companyId: "c1", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, + key: "api_key", + name: "API key", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 2, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const value: Record = { + API_KEY: { type: "secret_ref", secretId: "sec-1", version: "latest" }, + }; + await render({ value, secrets: [secret] }); + + const keyInput = container.querySelector('input[placeholder="KEY"]'); + expect(keyInput?.value).toBe("API_KEY"); + expect(container.textContent).toContain("API key"); + }); +}); 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/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 4884beea5d3..bc3580d2995 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -238,6 +238,7 @@ function createIssue(): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: "user-1", issueNumber: 807, diff --git a/ui/src/components/IssueLinkQuicklook.test.tsx b/ui/src/components/IssueLinkQuicklook.test.tsx index 95cad42e69e..a0f0012f160 100644 --- a/ui/src/components/IssueLinkQuicklook.test.tsx +++ b/ui/src/components/IssueLinkQuicklook.test.tsx @@ -34,6 +34,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 1, diff --git a/ui/src/components/IssueMonitorActivityCard.test.tsx b/ui/src/components/IssueMonitorActivityCard.test.tsx index dcd4da6c501..7a32226b67f 100644 --- a/ui/src/components/IssueMonitorActivityCard.test.tsx +++ b/ui/src/components/IssueMonitorActivityCard.test.tsx @@ -23,6 +23,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 3e0d5f22339..ea1f465c0bd 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -178,6 +178,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 7b80cd145d9..28a2ce6e878 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -43,6 +43,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 1, diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index a2dc5b48ab9..fc879ff7fe6 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -93,6 +93,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index a789f203546..35ee6c0bf1e 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -92,6 +92,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 81, diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 3562fbef403..1be7d87ff8c 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -176,6 +176,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 1, diff --git a/ui/src/components/KanbanBoard.test.tsx b/ui/src/components/KanbanBoard.test.tsx index ecc24f40822..1d6cc89be37 100644 --- a/ui/src/components/KanbanBoard.test.tsx +++ b/ui/src/components/KanbanBoard.test.tsx @@ -45,6 +45,7 @@ function createIssue(index: number, status: IssueStatus): Issue { priority: "medium", assigneeAgentId: index === 1 ? "agent-1" : null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: index, diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index bcbda2d457d..bd88484b8b1 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -7,6 +7,7 @@ import { useCompany } from "../context/CompanyContext"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; +import { MissingUserSecretsBanner } from "../pages/secrets/MissingUserSecretsBanner"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; @@ -1354,6 +1355,12 @@ export function NewIssueDialog() { />
+ {effectiveCompanyId ? ( +
+ +
+ ) : null} +
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/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx index e55fb3e7c9f..f5f83c88061 100644 --- a/ui/src/components/RoutineHistoryTab.test.tsx +++ b/ui/src/components/RoutineHistoryTab.test.tsx @@ -89,6 +89,7 @@ function snapshotV1(overrides?: Partial): projectId: null, goalId: null, parentIssueId: null, + responsibleUserId: null, title: "Daily standup digest", description: "Summarize standup notes", assigneeAgentId: null, @@ -130,6 +131,7 @@ function createRoutine(overrides: Partial = {}): Routine { projectId: null, goalId: null, parentIssueId: null, + responsibleUserId: null, title: "Daily standup digest", description: "Summarize standup notes", assigneeAgentId: null, @@ -346,6 +348,9 @@ describe("RoutineHistoryTab", () => { { id: "secret-1", companyId: "company-1", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, key: "gh_token", name: "github-bot", provider: "local_encrypted", @@ -408,6 +413,9 @@ describe("RoutineHistoryTab", () => { { id: "secret-1", companyId: "company-1", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, key: "old_token", name: "old-token", provider: "local_encrypted", @@ -429,6 +437,9 @@ describe("RoutineHistoryTab", () => { { id: "secret-2", companyId: "company-1", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, key: "new_token", name: "new-token", provider: "local_encrypted", diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index e17c8418f61..6569b6f33de 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -133,6 +133,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, id, companyId: "company-1", agentId, + responsibleUserId: null, invocationSource: "assignment", triggerDetail: null, status, @@ -190,6 +191,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 1, diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts index 4e210b19bfb..f87e7232a0d 100644 --- a/ui/src/lib/issue-filters.test.ts +++ b/ui/src/lib/issue-filters.test.ts @@ -24,6 +24,7 @@ function makeIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/ui/src/lib/issue-tree.test.ts b/ui/src/lib/issue-tree.test.ts index 1fb43eeda88..28c94f2be94 100644 --- a/ui/src/lib/issue-tree.test.ts +++ b/ui/src/lib/issue-tree.test.ts @@ -18,6 +18,7 @@ function makeIssue(id: string, parentId: string | null = null): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 1, diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts index cc92b21c5e9..3c64ce33d0e 100644 --- a/ui/src/lib/issueDetailBreadcrumb.test.ts +++ b/ui/src/lib/issueDetailBreadcrumb.test.ts @@ -46,6 +46,7 @@ describe("issueDetailBreadcrumb", () => { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts index c843af6a52f..58678bfaa08 100644 --- a/ui/src/lib/issueDetailCache.test.ts +++ b/ui/src/lib/issueDetailCache.test.ts @@ -31,6 +31,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 1, diff --git a/ui/src/lib/issueDetailQuery.test.tsx b/ui/src/lib/issueDetailQuery.test.tsx index 541350ee1f0..0f70f5bc6ca 100644 --- a/ui/src/lib/issueDetailQuery.test.tsx +++ b/ui/src/lib/issueDetailQuery.test.tsx @@ -33,6 +33,7 @@ function makeIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index cd912379d07..c9017254335 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -450,6 +450,7 @@ describe("optimistic issue comments", () => { priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, @@ -520,6 +521,7 @@ describe("optimistic issue comments", () => { priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, @@ -694,6 +696,7 @@ describe("optimistic issue comments", () => { priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, @@ -736,6 +739,7 @@ describe("optimistic issue comments", () => { priority: "medium", assigneeAgentId: "agent-2", assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, 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/lib/subIssueDefaults.test.ts b/ui/src/lib/subIssueDefaults.test.ts index 6d45f5d8592..46224a09bea 100644 --- a/ui/src/lib/subIssueDefaults.test.ts +++ b/ui/src/lib/subIssueDefaults.test.ts @@ -48,6 +48,7 @@ function makeIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/ui/src/lib/workspace-routines.test.ts b/ui/src/lib/workspace-routines.test.ts index c585a8014bf..40f647d3f9b 100644 --- a/ui/src/lib/workspace-routines.test.ts +++ b/ui/src/lib/workspace-routines.test.ts @@ -13,6 +13,7 @@ function createRoutine(overrides: Partial = {}): RoutineListIte projectId: "project-1", goalId: null, parentIssueId: null, + responsibleUserId: null, title: "Routine title", description: null, assigneeAgentId: "agent-1", diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index b59c8f9c256..8ded8c6dfa7 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -158,6 +158,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: null, assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 904, @@ -398,6 +399,7 @@ describe("FailedRunInboxRow", () => { id: "run-1", companyId: "company-1", agentId: "agent-1", + responsibleUserId: null, invocationSource: "assignment", triggerDetail: null, status: "failed", diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index f3f795a06c5..2022b98f959 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -253,6 +253,7 @@ function createRoutine(overrides: Partial): RoutineListItem { projectId: "project-1", goalId: null, parentIssueId: null, + responsibleUserId: null, title: "Routine title", description: null, assigneeAgentId: "agent-1", @@ -293,6 +294,7 @@ function createIssue(overrides: Partial = {}): Issue { priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, + responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 1000, diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index 35ffc857317..fedc48e4da1 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -5,10 +5,13 @@ import { flushSync } from "react-dom"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { + CompanySecret, CompanySecretProviderConfig, RemoteSecretImportPreviewResult, SecretProviderConfigDiscoveryPreviewResult, SecretProviderDescriptor, + UserSecretCoverageSummary, + UserSecretDefinition, } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ProviderVaultsTab, Secrets } from "./Secrets"; @@ -37,6 +40,16 @@ const mockSecretsApi = vi.hoisted(() => ({ remove: vi.fn(), usage: vi.fn(), accessEvents: vi.fn(), + listUserSecretDefinitions: vi.fn(), + createUserSecretDefinition: vi.fn(), + updateUserSecretDefinition: vi.fn(), + removeUserSecretDefinition: vi.fn(), + userSecretDefinitionCoverage: vi.fn(), + listMyUserSecrets: vi.fn(), + createMyUserSecret: vi.fn(), + updateMyUserSecret: vi.fn(), + rotateMyUserSecret: vi.fn(), + removeMyUserSecret: vi.fn(), })); const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); @@ -210,12 +223,78 @@ function makeRemoteImportPreview( }; } +function makeCompanySecret(overrides: Partial = {}): CompanySecret { + return { + id: "secret-openai", + companyId: "company-1", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, + key: "openai_api_key", + name: "OPENAI_API_KEY", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + referenceCount: 2, + createdAt: new Date("2026-05-06T00:00:00.000Z"), + updatedAt: new Date("2026-05-06T00:00:00.000Z"), + ...overrides, + }; +} + +function makeUserSecretDefinition(overrides: Partial = {}): UserSecretDefinition { + return { + id: "def-github", + companyId: "company-1", + key: "PERSONAL_GH_TOKEN", + name: "Personal GitHub token", + description: "Used when the responsible user's own repos must be reached.", + status: "active", + provider: "local_encrypted", + managedMode: "paperclip_managed", + providerConfigId: null, + providerMetadata: null, + usageGuidance: "Create a fine-grained PAT with repo read access.", + createdByAgentId: null, + createdByUserId: "user-1", + updatedByAgentId: null, + updatedByUserId: "user-1", + deletedAt: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-02T00:00:00.000Z"), + ...overrides, + }; +} + +const userSecretCoverage: UserSecretCoverageSummary = { + definitionId: "def-github", + configuredCount: 3, + missingCount: 2, + inactiveCount: 0, +}; + function setInputValue(input: HTMLInputElement, value: string) { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; setter?.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); } +function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; + setter?.call(textarea, value); + textarea.dispatchEvent(new Event("input", { bubbles: true })); +} + async function openAwsVaultDialog() { const vaultTabButton = [...document.querySelectorAll("button")].find( (button) => button.textContent?.includes("Provider vaults"), @@ -258,6 +337,9 @@ describe("Secrets page layout", () => { mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs); mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview()); mockSecretsApi.remoteImportPreview.mockResolvedValue(makeRemoteImportPreview()); + mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]); + mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage); + mockSecretsApi.listMyUserSecrets.mockResolvedValue([]); }); afterEach(() => { @@ -435,31 +517,8 @@ describe("Secrets page layout", () => { }); }); - it("opens reference details from the secrets table count", async () => { - mockSecretsApi.list.mockResolvedValue([ - { - id: "secret-openai", - companyId: "company-1", - key: "openai_api_key", - name: "OPENAI_API_KEY", - provider: "local_encrypted", - status: "active", - managedMode: "paperclip_managed", - externalRef: null, - providerConfigId: null, - providerMetadata: null, - latestVersion: 1, - description: null, - lastResolvedAt: null, - lastRotatedAt: null, - deletedAt: null, - createdByAgentId: null, - createdByUserId: "user-1", - referenceCount: 2, - createdAt: new Date("2026-05-06T00:00:00.000Z"), - updatedAt: new Date("2026-05-06T00:00:00.000Z"), - }, - ]); + it("keeps references reachable from the compact secrets row and detail drawer", async () => { + mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]); mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", bindings: [ @@ -504,17 +563,27 @@ describe("Secrets page layout", () => { await flushReact(); const referencesButton = container.querySelector( - 'button[aria-label="View references for OPENAI_API_KEY"]', + 'button[aria-label="Actions for OPENAI_API_KEY"]', ) as HTMLButtonElement | null; - expect(referencesButton?.textContent).toBe("2"); + expect(referencesButton).not.toBeNull(); + const companyRow = Array.from(container.querySelectorAll("[role='row']")).find( + (row) => row.textContent?.includes("OPENAI_API_KEY"), + ) as HTMLElement | undefined; await act(async () => { - referencesButton?.click(); + companyRow?.click(); + }); + await flushReact(); + + const viewUsageButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("View in Usage"), + ) as HTMLButtonElement | undefined; + await act(async () => { + viewUsageButton?.click(); }); await flushReact(); expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai"); - expect(document.body.textContent).toContain("Secret references"); expect(document.body.textContent).toContain("CodexCoder"); expect(document.body.textContent).toContain("env.OPENAI_API_KEY"); @@ -523,6 +592,224 @@ describe("Secrets page layout", () => { }); }); + it("merges company secrets and each-user definitions into the Secrets list", async () => { + mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]); + mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([makeUserSecretDefinition()]); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("OPENAI_API_KEY"); + expect(container.textContent).toContain("Personal GitHub token"); + expect(container.textContent).toContain("Company"); + expect(container.textContent).toContain("Each user"); + expect(container.textContent).toContain("3/5 set"); + expect(container.textContent).not.toContain("User secret definitions"); + + const listContainer = container.querySelector('[data-testid="secrets-list-container"]'); + const tableView = container.querySelector('[data-testid="secrets-table-view"]'); + const cardView = container.querySelector('[data-testid="secrets-card-view"]'); + expect(listContainer?.className).toContain("@container"); + expect(tableView?.className).toContain("@min-[40rem]:block"); + expect(tableView?.className).not.toContain("md:block"); + expect(tableView?.querySelector("[role='row']")?.className).toContain("minmax(12rem,2.4fr)"); + expect(cardView?.className).toContain("@min-[40rem]:hidden"); + expect(cardView?.className).not.toContain("md:hidden"); + + expect(mockSecretsApi.list).toHaveBeenCalledWith("company-1"); + expect(mockSecretsApi.listUserSecretDefinitions).toHaveBeenCalledWith("company-1"); + + await act(async () => { + root.unmount(); + }); + }); + + it("creates an each-user secret from the unified New secret dialog", async () => { + const definition = makeUserSecretDefinition({ name: "Personal GitHub token" }); + mockSecretsApi.createUserSecretDefinition.mockResolvedValueOnce(definition); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const newSecretButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("New secret"), + ) as HTMLButtonElement | undefined; + await act(async () => { + newSecretButton?.click(); + }); + await flushReact(); + + const eachUserButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Each user", + ) as HTMLButtonElement | undefined; + await act(async () => { + eachUserButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + eachUserButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + eachUserButton?.click(); + }); + await flushReact(); + + const nameInput = document.getElementById("new-secret-name") as HTMLInputElement; + const keyInput = document.getElementById("new-secret-key") as HTMLInputElement; + const usageGuidance = document.getElementById("new-secret-usage-guidance") as HTMLTextAreaElement; + expect(document.getElementById("new-secret-provider")).toBeNull(); + expect(document.getElementById("new-secret-vault")).toBeNull(); + expect(document.getElementById("new-secret-value")).toBeNull(); + + await act(async () => { + setInputValue(nameInput, "Personal GitHub token"); + setTextareaValue(usageGuidance, "Create a fine-grained PAT with repo read access."); + }); + await flushReact(); + + expect(keyInput.value).toBe("PERSONAL_GITHUB_TOKEN"); + + const createButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Create user-provided secret"), + ) as HTMLButtonElement | undefined; + await act(async () => { + createButton?.click(); + }); + await flushReact(); + + expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith("company-1", { + name: "Personal GitHub token", + description: null, + usageGuidance: "Create a fine-grained PAT with repo read access.", + key: "PERSONAL_GITHUB_TOKEN", + status: "active", + }); + expect(mockSecretsApi.create).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + + it("opens the New secret dialog when provider queries fail", async () => { + mockSecretsApi.providers.mockRejectedValueOnce(new ApiError("Providers unavailable", 403, null)); + mockSecretsApi.providerConfigs.mockRejectedValueOnce(new ApiError("Provider vaults unavailable", 403, null)); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const newSecretButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("New secret"), + ) as HTMLButtonElement | undefined; + await act(async () => { + newSecretButton?.click(); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Create secret"); + expect(document.body.textContent).toContain("Select a provider."); + + await act(async () => { + root.unmount(); + }); + }); + + it("opens the each-user detail sheet with coverage and set-my-value actions", async () => { + const definition = makeUserSecretDefinition(); + mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([definition]); + mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition, secret: null }]); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const definitionRow = Array.from(container.querySelectorAll("[role='row']")).find( + (row) => row.textContent?.includes("Personal GitHub token"), + ) as HTMLElement | undefined; + await act(async () => { + definitionRow?.click(); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Personal GitHub token"); + expect(document.body.textContent).toContain("Details"); + expect(document.body.textContent).toContain("Coverage"); + expect(document.body.textContent).toContain("Usage"); + expect(document.body.textContent).toContain("Access events"); + + const viewCoverageButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("View in Coverage"), + ) as HTMLButtonElement | undefined; + await act(async () => { + viewCoverageButton?.click(); + }); + await flushReact(); + + expect(document.body.textContent).toContain("3 of 5 set"); + expect(document.body.textContent).toContain("Secret values are never shown here"); + + const setValueButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Set my value"), + ) as HTMLButtonElement | undefined; + await act(async () => { + setValueButton?.click(); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Set your value"); + expect(document.body.textContent).toContain("PERSONAL_GH_TOKEN"); + + await act(async () => { + root.unmount(); + }); + }); + it("keeps the new secret value textarea width-constrained for long tokens", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/pages/Secrets.test.ts b/ui/src/pages/Secrets.test.ts index e650ab7a590..771babbca51 100644 --- a/ui/src/pages/Secrets.test.ts +++ b/ui/src/pages/Secrets.test.ts @@ -1,8 +1,9 @@ // @vitest-environment jsdom import { describe, expect, it } from "vitest"; -import type { SecretProviderDescriptor } from "@paperclipai/shared"; +import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared"; import { + findCreateProviderReplacement, getAwsManagedPathPreview, getCreateProviderBlockReason, getDefaultProviderConfigId, @@ -19,6 +20,37 @@ const awsProvider: SecretProviderDescriptor = { configured: true, }; +const localProvider: SecretProviderDescriptor = { + id: "local_encrypted", + label: "Local encrypted (default)", + requiresExternalRef: false, + supportsManagedValues: true, + supportsExternalReferences: false, + configured: true, +}; + +function providerConfig( + overrides: Partial & Pick, +): CompanySecretProviderConfig { + return { + companyId: "company-1", + displayName: overrides.id, + status: "ready", + isDefault: false, + config: {}, + healthStatus: null, + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + disabledAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + describe("Secrets page provider helpers", () => { it("previews the derived AWS managed path from provider health details", () => { const health: SecretProviderHealthResponse = { @@ -52,7 +84,9 @@ describe("Secrets page provider helpers", () => { "managed", null, ), - ).toBe("AWS Secrets Manager is not configured in this deployment."); + ).toBe( + "Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default.", + ); }); it("uses provider health copy when an unconfigured provider reports missing bootstrap inputs", () => { @@ -74,27 +108,72 @@ describe("Secrets page provider helpers", () => { health, ), ).toBe( - "AWS Secrets Manager is not configured in this deployment. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.", + "Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.", ); }); + it("allows an unconfigured AWS deployment default when a ready AWS provider vault is selected", () => { + expect( + getCreateProviderBlockReason( + { ...awsProvider, configured: false }, + "external", + null, + providerConfig({ + id: "aws-prod", + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + }), + ), + ).toBeNull(); + }); + + it("names the selected provider vault block before deployment-default AWS config", () => { + expect( + getCreateProviderBlockReason( + { ...awsProvider, configured: false }, + "external", + null, + providerConfig({ + id: "aws-disabled", + provider: "aws_secrets_manager", + displayName: "AWS disabled", + status: "disabled", + }), + ), + ).toBe("This provider vault is disabled."); + }); + it("blocks provider modes the backend does not support", () => { expect( getCreateProviderBlockReason( - { - id: "local_encrypted", - label: "Local encrypted (default)", - requiresExternalRef: false, - supportsManagedValues: true, - supportsExternalReferences: false, - configured: true, - }, + localProvider, "external", null, ), ).toBe("Local encrypted (default) does not support linked external references."); }); + it("switching to external mode prefers AWS instead of staying on local encrypted when a ready AWS vault exists", () => { + expect( + findCreateProviderReplacement({ + providers: [localProvider, { ...awsProvider, configured: false }], + providerConfigs: [ + providerConfig({ + id: "aws-prod", + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + isDefault: true, + }), + ], + currentProvider: "local_encrypted", + mode: "external", + health: null, + })?.id, + ).toBe("aws_secrets_manager"); + }); + it("chooses the ready default provider vault for a provider", () => { expect( getDefaultProviderConfigId( diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 7623ca79f15..0a9cf3ce476 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -8,12 +8,15 @@ import { Ban, CheckCircle2, Cloud, + Copy, Database, Edit3, ExternalLink, KeyRound, Link2, + Lock, Loader2, + MoreHorizontal, Plus, RefreshCw, Search, @@ -23,6 +26,9 @@ import { X, Filter, Info, + Pencil, + UserRound, + Users, } from "lucide-react"; import { Link } from "react-router-dom"; import type { @@ -37,6 +43,8 @@ import type { SecretProviderConfigStatus, SecretProviderDescriptor, SecretStatus, + UserSecretCoverageSummary, + UserSecretDefinition, } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -49,6 +57,7 @@ import { type UpdateSecretProviderConfigInput, } from "../api/secrets"; import { ApiError } from "../api/client"; +import { accessApi, type CompanyUserDirectoryEntry } from "../api/access"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { Button } from "@/components/ui/button"; @@ -73,12 +82,34 @@ import { } from "@/components/ui/dialog"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "../lib/utils"; +import { copyTextToClipboard } from "../lib/clipboard"; import { PageTabBar } from "../components/PageTabBar"; import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog"; +import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab"; +import { SetMyUserSecretDialog } from "./secrets/SetMyUserSecretDialog"; +import { + coverageSummaryLabel, + UserSecretChip, +} from "./secrets/user-secret-presentation"; +import type { MyUserSecretEntry } from "../api/secrets"; type CreateMode = "managed" | "external"; -type SecretsTab = "secrets" | "vaults"; +type SecretValueProvider = "company" | "user"; +type ProvidedByFilter = "all" | SecretValueProvider; +type SecretsTab = "secrets" | "my-secrets" | "vaults"; + +type UnifiedSecretRow = + | { id: string; kind: "company"; secret: CompanySecret } + | { id: string; kind: "user"; definition: UserSecretDefinition }; type ProviderVaultForm = { provider: SecretProvider; @@ -112,6 +143,12 @@ type SafeProviderErrorDetails = { safeAlternative?: string; }; +const EMPTY_SECRETS: CompanySecret[] = []; +const EMPTY_USER_SECRET_DEFINITIONS: UserSecretDefinition[] = []; +const EMPTY_MY_USER_SECRETS: MyUserSecretEntry[] = []; +const EMPTY_SECRET_PROVIDERS: SecretProviderDescriptor[] = []; +const EMPTY_PROVIDER_CONFIGS: CompanySecretProviderConfig[] = []; + const PROVIDER_ORDER: SecretProvider[] = [ "local_encrypted", "aws_secrets_manager", @@ -245,6 +282,15 @@ function normalizeSecretKeyForPreview(input: string) { .slice(0, 120); } +function normalizeUserSecretKeyForPreview(input: string) { + return input + .trim() + .toUpperCase() + .replace(/[^A-Z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 120); +} + function modeLabel(managedMode: SecretManagedMode) { return managedMode === "paperclip_managed" ? "Paperclip-managed" : "Linked external"; @@ -256,6 +302,107 @@ function modeDescription(managedMode: SecretManagedMode) { : "Paperclip resolves this provider reference but does not rotate the provider value."; } +function statusLabel(status: SecretStatus) { + return status.charAt(0).toUpperCase() + status.slice(1); +} + +function statusDotTone(status: SecretStatus) { + switch (status) { + case "active": + return "bg-emerald-500"; + case "disabled": + return "bg-amber-500"; + case "archived": + return "bg-muted-foreground"; + case "deleted": + return "bg-destructive"; + default: + return "bg-muted-foreground"; + } +} + +function StatusBadge({ status }: { status: SecretStatus }) { + return ( + + + ); +} + +function MetaChip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function providerIndicatorLabel( + secret: CompanySecret, + providers: SecretProviderDescriptor[], + providerConfigs: CompanySecretProviderConfig[], +) { + const provider = providerLabel(providers, secret.provider); + const vault = providerVaultLabel(providerConfigs, secret.providerConfigId); + const custody = modeLabel(secret.managedMode); + return [ + `${custody} · ${provider}`, + vault ? `Vault: ${vault}` : null, + secret.externalRef ? `Reference: ${secret.externalRef}` : null, + ] + .filter(Boolean) + .join("\n"); +} + +function SecretProviderIndicator({ + secret, + providers, + providerConfigs, +}: { + secret: CompanySecret; + providers: SecretProviderDescriptor[]; + providerConfigs: CompanySecretProviderConfig[]; +}) { + const label = providerIndicatorLabel(secret, providers, providerConfigs); + const Icon = secret.managedMode === "external_reference" ? ExternalLink : Lock; + return ( + + + + + + + {label} + + ); +} + +function UpdatedWithTooltip({ + updatedAt, + tooltip, +}: { + updatedAt: Date | string | null | undefined; + tooltip: string; +}) { + return ( + + + + {formatRelative(updatedAt)} + + + {tooltip} + + ); +} + function healthEntryForProvider( health: SecretProviderHealthResponse | null, providerId: SecretProvider, @@ -267,6 +414,7 @@ export function getCreateProviderBlockReason( provider: SecretProviderDescriptor | null | undefined, mode: CreateMode, health: SecretProviderHealthResponse | null, + providerConfig?: CompanySecretProviderConfig | null, ) { if (!provider) return "Select a provider."; if (mode === "managed" && provider.supportsManagedValues === false) { @@ -275,11 +423,20 @@ export function getCreateProviderBlockReason( if (mode === "external" && provider.supportsExternalReferences === false) { return `${provider.label} does not support linked external references.`; } + const selectedProviderConfigBlockReason = providerConfig?.provider === provider.id + ? getProviderConfigBlockReason(providerConfig) + : null; + const selectedProviderConfigReady = + providerConfig?.provider === provider.id && !selectedProviderConfigBlockReason; if (provider.configured === false) { + if (selectedProviderConfigReady) return null; + if (selectedProviderConfigBlockReason) return selectedProviderConfigBlockReason; const healthEntry = healthEntryForProvider(health, provider.id); + const deploymentMessage = `Deployment default ${provider.label} is not configured.`; + const nextStep = " Select a ready provider vault or configure the deployment default."; return healthEntry?.message - ? `${provider.label} is not configured in this deployment. ${healthEntry.message}` - : `${provider.label} is not configured in this deployment.`; + ? `${deploymentMessage}${nextStep} ${healthEntry.message}` + : `${deploymentMessage}${nextStep}`; } const healthEntry = healthEntryForProvider(health, provider.id); if (healthEntry?.status === "error") { @@ -291,8 +448,16 @@ export function getCreateProviderBlockReason( function providerHealthText( provider: SecretProviderDescriptor | null | undefined, health: SecretProviderHealthResponse | null, + providerConfig?: CompanySecretProviderConfig | null, ) { if (!provider) return null; + if ( + provider.configured === false && + providerConfig?.provider === provider.id && + !getProviderConfigBlockReason(providerConfig) + ) { + return `Using selected provider vault. Deployment default ${provider.label} is not configured.`; + } const entry = healthEntryForProvider(health, provider.id); if (!entry) return null; const warnings = entry.warnings?.join(" "); @@ -316,20 +481,57 @@ export function getProviderConfigBlockReason( return null; } -export function getDefaultProviderConfigId( +export function getSelectableProviderConfig( configs: CompanySecretProviderConfig[], provider: SecretProvider, ) { const providerConfigs = configs.filter((config) => config.provider === provider); - const selectable = providerConfigs.filter((config) => !getProviderConfigBlockReason(config)); return ( - selectable.find((config) => config.isDefault)?.id ?? - selectable[0]?.id ?? + providerConfigs.find((config) => config.isDefault && !getProviderConfigBlockReason(config)) ?? + providerConfigs.find((config) => !getProviderConfigBlockReason(config)) ?? + null + ); +} + +export function getDefaultProviderConfigId( + configs: CompanySecretProviderConfig[], + provider: SecretProvider, +) { + const selected = getSelectableProviderConfig(configs, provider); + const providerConfigs = configs.filter((config) => config.provider === provider); + return ( + selected?.id ?? providerConfigs.find((config) => config.isDefault)?.id ?? "" ); } +export function findCreateProviderReplacement({ + providers, + providerConfigs, + currentProvider, + mode, + health, +}: { + providers: SecretProviderDescriptor[]; + providerConfigs: CompanySecretProviderConfig[]; + currentProvider: SecretProvider; + mode: CreateMode; + health: SecretProviderHealthResponse | null; +}) { + return ( + providers.find((provider) => { + const selectedConfig = + provider.id === currentProvider + ? providerConfigs.find( + (config) => config.provider === provider.id && !getProviderConfigBlockReason(config), + ) ?? null + : getSelectableProviderConfig(providerConfigs, provider.id); + return !getCreateProviderBlockReason(provider, mode, health, selectedConfig); + }) ?? null + ); +} + function providerVaultLabel(configs: CompanySecretProviderConfig[], id: string | null | undefined) { if (!id) return "Deployment default"; return configs.find((config) => config.id === id)?.displayName ?? "Unknown vault"; @@ -402,17 +604,23 @@ export function Secrets() { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("active"); const [providerFilter, setProviderFilter] = useState("all"); + const [providedByFilter, setProvidedByFilter] = useState("all"); const [selectedSecretId, setSelectedSecretId] = useState(null); + const [selectedDefinitionId, setSelectedDefinitionId] = useState(null); const [usageDialogSecretId, setUsageDialogSecretId] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); const [importInitialVaultId, setImportInitialVaultId] = useState(null); + const [secretValueProvider, setSecretValueProvider] = useState("company"); const [createMode, setCreateMode] = useState("managed"); + const [editingDefinition, setEditingDefinition] = useState(null); + const [createKeyDirty, setCreateKeyDirty] = useState(false); const [createForm, setCreateForm] = useState({ name: "", key: "", value: "", description: "", + usageGuidance: "", externalRef: "", provider: "local_encrypted" as SecretProvider, providerConfigId: "", @@ -424,6 +632,8 @@ export function Secrets() { const [rotateProviderConfigId, setRotateProviderConfigId] = useState(""); const [rotateError, setRotateError] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); + const [definitionDeleteConfirm, setDefinitionDeleteConfirm] = useState(null); + const [setMyValueFor, setSetMyValueFor] = useState(null); const [vaultDialogOpen, setVaultDialogOpen] = useState(false); const [editingVault, setEditingVault] = useState(null); const [removeVaultConfirm, setRemoveVaultConfirm] = useState(null); @@ -445,6 +655,22 @@ export function Secrets() { enabled: Boolean(selectedCompanyId), }); + const userDefinitionsQuery = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.secrets.userDefinitions(selectedCompanyId) + : ["user-secret-definitions", "__disabled__"], + queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + const myUserSecretsQuery = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.secrets.myUserSecrets(selectedCompanyId) + : ["my-user-secrets", "__disabled__"], + queryFn: () => secretsApi.listMyUserSecrets(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + const providersQuery = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.providers(selectedCompanyId) @@ -473,13 +699,26 @@ export function Secrets() { retry: false, }); - const secrets = secretsQuery.data ?? []; - const providers = providersQuery.data ?? []; - const providerConfigs = providerConfigsQuery.data ?? []; + const secrets = secretsQuery.data ?? EMPTY_SECRETS; + const userDefinitions = userDefinitionsQuery.data ?? EMPTY_USER_SECRET_DEFINITIONS; + const myUserSecrets = myUserSecretsQuery.data ?? EMPTY_MY_USER_SECRETS; + const providers = providersQuery.data ?? EMPTY_SECRET_PROVIDERS; + const providerConfigs = providerConfigsQuery.data ?? EMPTY_PROVIDER_CONFIGS; const selectedSecret = useMemo( () => secrets.find((secret) => secret.id === selectedSecretId) ?? null, [secrets, selectedSecretId], ); + const selectedDefinition = useMemo( + () => userDefinitions.find((definition) => definition.id === selectedDefinitionId) ?? null, + [selectedDefinitionId, userDefinitions], + ); + const selectedDefinitionMyEntry = useMemo(() => { + if (!selectedDefinition) return null; + return myUserSecrets.find((entry) => entry.definition.id === selectedDefinition.id) ?? { + definition: selectedDefinition, + secret: null, + }; + }, [myUserSecrets, selectedDefinition]); const usageDialogSecret = useMemo( () => secrets.find((secret) => secret.id === usageDialogSecretId) ?? null, [secrets, usageDialogSecretId], @@ -508,11 +747,13 @@ export function Secrets() { selectedCreateProvider, createMode, providerHealthQuery.data ?? null, + selectedCreateProviderConfig, ) ?? getProviderConfigBlockReason(selectedCreateProviderConfig); const rotateProviderBlockReason = getProviderConfigBlockReason(selectedRotateProviderConfig); const createProviderHealthText = providerHealthText( selectedCreateProvider, providerHealthQuery.data ?? null, + selectedCreateProviderConfig, ); const awsManagedPathPreview = getAwsManagedPathPreview({ provider: selectedCreateProvider, @@ -521,21 +762,49 @@ export function Secrets() { secretKeySource: createForm.key.trim() || createForm.name, }); - const filtered = useMemo(() => { + const unifiedRows = useMemo( + () => [ + ...secrets.map((secret) => ({ id: `company:${secret.id}`, kind: "company" as const, secret })), + ...userDefinitions.map((definition) => ({ + id: `user:${definition.id}`, + kind: "user" as const, + definition, + })), + ], + [secrets, userDefinitions], + ); + + const filteredRows = useMemo(() => { const needle = search.trim().toLowerCase(); - return secrets.filter((secret) => { - if (statusFilter !== "all" && secret.status !== statusFilter) return false; - if (providerFilter !== "all" && secret.provider !== providerFilter) return false; + return unifiedRows.filter((row) => { + const providedBy: SecretValueProvider = row.kind === "company" ? "company" : "user"; + const status = row.kind === "company" ? row.secret.status : row.definition.status; + if (providedByFilter !== "all" && providedBy !== providedByFilter) return false; + if (statusFilter !== "all" && status !== statusFilter) return false; + if (providerFilter !== "all" && row.kind === "company" && row.secret.provider !== providerFilter) { + return false; + } if (!needle) return true; + if (row.kind === "company") { + return ( + row.secret.name.toLowerCase().includes(needle) || + row.secret.key.toLowerCase().includes(needle) || + (row.secret.description?.toLowerCase().includes(needle) ?? false) || + (row.secret.externalRef?.toLowerCase().includes(needle) ?? false) + ); + } return ( - secret.name.toLowerCase().includes(needle) || - secret.key.toLowerCase().includes(needle) || - (secret.description?.toLowerCase().includes(needle) ?? false) || - (secret.externalRef?.toLowerCase().includes(needle) ?? false) + row.definition.name.toLowerCase().includes(needle) || + row.definition.key.toLowerCase().includes(needle) || + (row.definition.description?.toLowerCase().includes(needle) ?? false) || + (row.definition.usageGuidance?.toLowerCase().includes(needle) ?? false) ); }); - }, [secrets, search, statusFilter, providerFilter]); - const activeSecretFilterCount = (statusFilter === "active" ? 0 : 1) + (providerFilter === "all" ? 0 : 1); + }, [providedByFilter, providerFilter, search, statusFilter, unifiedRows]); + const activeSecretFilterCount = + (statusFilter === "active" ? 0 : 1) + + (providerFilter === "all" ? 0 : 1) + + (providedByFilter === "all" ? 0 : 1); const usageQuery = useQuery({ queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"], @@ -561,15 +830,78 @@ export function Secrets() { function invalidateAll(extraIds: string[] = []) { if (!selectedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.secrets.providerConfigs(selectedCompanyId) }); for (const id of extraIds) { queryClient.invalidateQueries({ queryKey: queryKeys.secrets.usage(id) }); queryClient.invalidateQueries({ queryKey: queryKeys.secrets.accessEvents(id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitionCoverage(selectedCompanyId, id) }); } } + function openCreateSecret() { + setEditingDefinition(null); + setSecretValueProvider("company"); + setCreateMode("managed"); + setCreateKeyDirty(false); + setCreateError(null); + setCreateForm({ + name: "", + key: "", + value: "", + description: "", + usageGuidance: "", + externalRef: "", + provider: "local_encrypted", + providerConfigId: getDefaultProviderConfigId(providerConfigs, "local_encrypted"), + }); + setCreateOpen(true); + } + + function openEditDefinition(definition: UserSecretDefinition) { + setEditingDefinition(definition); + setSecretValueProvider("user"); + setCreateMode("managed"); + setCreateKeyDirty(true); + setCreateError(null); + setCreateForm({ + name: definition.name, + key: definition.key, + value: "", + description: definition.description ?? "", + usageGuidance: definition.usageGuidance ?? "", + externalRef: "", + provider: "local_encrypted", + providerConfigId: "", + }); + setCreateOpen(true); + } + const createMutation = useMutation({ - mutationFn: () => { + mutationFn: async () => { + const sharedDefinitionPayload = { + name: createForm.name.trim(), + description: createForm.description.trim() || null, + usageGuidance: createForm.usageGuidance.trim() || null, + }; + if (editingDefinition) { + const definition = await secretsApi.updateUserSecretDefinition( + selectedCompanyId!, + editingDefinition.id, + sharedDefinitionPayload, + ); + return { kind: "user" as const, item: definition, action: "updated" as const }; + } + if (secretValueProvider === "user") { + const definition = await secretsApi.createUserSecretDefinition(selectedCompanyId!, { + ...sharedDefinitionPayload, + key: createForm.key.trim(), + status: "active", + }); + return { kind: "user" as const, item: definition, action: "created" as const }; + } + const input: CreateSecretInput = { name: createForm.name.trim(), provider: createForm.provider, @@ -583,23 +915,44 @@ export function Secrets() { } else { input.externalRef = createForm.externalRef.trim(); } - return secretsApi.create(selectedCompanyId!, input); + const secret = await secretsApi.create(selectedCompanyId!, input); + return { kind: "company" as const, item: secret, action: "created" as const }; }, - onSuccess: (created) => { - pushToast({ title: "Secret created", body: created.name, tone: "success" }); + onSuccess: (result) => { + pushToast({ + title: + result.kind === "company" + ? "Secret created" + : result.action === "updated" + ? "User-provided secret updated" + : "User-provided secret created", + body: result.item.name, + tone: "success", + }); setCreateOpen(false); + setEditingDefinition(null); + setSecretValueProvider("company"); + setCreateKeyDirty(false); setCreateForm({ name: "", key: "", value: "", description: "", + usageGuidance: "", externalRef: "", provider: createForm.provider, providerConfigId: getDefaultProviderConfigId(providerConfigs, createForm.provider), }); setCreateError(null); - setSelectedSecretId(created.id); - invalidateAll([created.id]); + if (result.kind === "company") { + setSelectedSecretId(result.item.id); + setSelectedDefinitionId(null); + invalidateAll([result.item.id]); + } else { + setSelectedDefinitionId(result.item.id); + setSelectedSecretId(null); + invalidateAll([result.item.id]); + } }, onError: (error) => { setCreateError(error instanceof ApiError ? error.message : (error as Error).message); @@ -660,6 +1013,22 @@ export function Secrets() { }, }); + const definitionStatusMutation = useMutation({ + mutationFn: ({ definition, status }: { definition: UserSecretDefinition; status: SecretStatus }) => + secretsApi.updateUserSecretDefinition(selectedCompanyId!, definition.id, { status }), + onSuccess: (updated) => { + pushToast({ title: `User-provided secret ${updated.status}`, body: updated.name, tone: "info" }); + invalidateAll([updated.id]); + }, + onError: (error) => { + pushToast({ + title: "Status update failed", + body: error instanceof Error ? error.message : "Try again", + tone: "error", + }); + }, + }); + const deleteMutation = useMutation({ mutationFn: (id: string) => secretsApi.remove(id), onSuccess: (_response, id) => { @@ -677,6 +1046,24 @@ export function Secrets() { }, }); + const deleteDefinitionMutation = useMutation({ + mutationFn: (definition: UserSecretDefinition) => + secretsApi.removeUserSecretDefinition(selectedCompanyId!, definition.id), + onSuccess: (_response, definition) => { + pushToast({ title: "User-provided secret removed", body: definition.name, tone: "info" }); + setDefinitionDeleteConfirm(null); + if (selectedDefinitionId === definition.id) setSelectedDefinitionId(null); + invalidateAll([definition.id]); + }, + onError: (error) => { + pushToast({ + title: "Delete failed", + body: error instanceof Error ? error.message : "Try again", + tone: "error", + }); + }, + }); + const saveVaultMutation = useMutation({ mutationFn: () => { const data: CreateSecretProviderConfigInput | UpdateSecretProviderConfigInput = { @@ -795,12 +1182,16 @@ export function Secrets() { providers.find((provider) => provider.id === createForm.provider) ?? null, createMode, providerHealthQuery.data ?? null, + providerConfigs.find((config) => config.id === createForm.providerConfigId) ?? null, ); if (!currentBlockReason) return; - const replacement = providers.find( - (provider) => - !getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null), - ); + const replacement = findCreateProviderReplacement({ + providers, + providerConfigs, + currentProvider: createForm.provider, + mode: createMode, + health: providerHealthQuery.data ?? null, + }); if (replacement && replacement.id !== createForm.provider) { setCreateForm((current) => ({ ...current, @@ -814,9 +1205,11 @@ export function Secrets() { if (!createOpen) return; const current = providerConfigs.find((config) => config.id === createForm.providerConfigId); if (current?.provider === createForm.provider) return; + const nextProviderConfigId = getDefaultProviderConfigId(providerConfigs, createForm.provider); + if (nextProviderConfigId === createForm.providerConfigId) return; setCreateForm((form) => ({ ...form, - providerConfigId: getDefaultProviderConfigId(providerConfigs, form.provider), + providerConfigId: nextProviderConfigId, })); }, [createForm.provider, createForm.providerConfigId, createOpen, providerConfigs]); @@ -865,6 +1258,172 @@ export function Secrets() { })); } + function openCompanySecret(secret: CompanySecret) { + setSecretDetailTab("details"); + setSelectedSecretId(secret.id); + setSelectedDefinitionId(null); + } + + function openUserDefinition(definition: UserSecretDefinition) { + setSecretDetailTab("details"); + setSelectedDefinitionId(definition.id); + setSelectedSecretId(null); + } + + function openRotateSecret(secret: CompanySecret) { + openCompanySecret(secret); + setRotateOpen(true); + setRotateValue(""); + setRotateExternalRef(""); + setRotateProviderConfigId( + secret.providerConfigId ?? getDefaultProviderConfigId(providerConfigs, secret.provider), + ); + setRotateError(null); + } + + function copySecretKey(key: string) { + void copyTextToClipboard(key) + .then(() => pushToast({ title: "Secret key copied", body: key, tone: "success" })) + .catch((error) => + pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy secret key", + tone: "error", + }), + ); + } + + function renderRowActions(row: UnifiedSecretRow) { + const name = row.kind === "company" ? row.secret.name : row.definition.name; + return ( + + + + + + { + if (row.kind === "company") openCompanySecret(row.secret); + else openUserDefinition(row.definition); + }} + > + View details + + {row.kind === "company" ? ( + <> + setUsageDialogSecretId(row.secret.id)}> + View references ({row.secret.referenceCount ?? 0}) + + openRotateSecret(row.secret)}> + + {row.secret.managedMode === "external_reference" ? "Update reference" : "Update value"} + + + + statusMutation.mutate({ + id: row.secret.id, + status: row.secret.status === "active" ? "disabled" : "active", + }) + } + > + {row.secret.status === "active" ? : } + {row.secret.status === "active" ? "Disable" : "Activate"} + + + statusMutation.mutate({ + id: row.secret.id, + status: row.secret.status === "archived" ? "active" : "archived", + }) + } + > + {row.secret.status === "archived" ? ( + + ) : ( + + )} + {row.secret.status === "archived" ? "Unarchive" : "Archive"} + + + setDeleteConfirm(row.secret)}> + Delete secret + + + ) : ( + <> + + setSetMyValueFor( + myUserSecrets.find((entry) => entry.definition.id === row.definition.id) ?? { + definition: row.definition, + secret: null, + }, + ) + } + > + + {myUserSecrets.find((entry) => entry.definition.id === row.definition.id)?.secret + ? "Update my value" + : "Set my value"} + + openEditDefinition(row.definition)}> + Edit definition + + + + definitionStatusMutation.mutate({ + definition: row.definition, + status: row.definition.status === "active" ? "disabled" : "active", + }) + } + > + {row.definition.status === "active" ? ( + + ) : ( + + )} + {row.definition.status === "active" ? "Disable" : "Activate"} + + + definitionStatusMutation.mutate({ + definition: row.definition, + status: row.definition.status === "archived" ? "active" : "archived", + }) + } + > + {row.definition.status === "archived" ? ( + + ) : ( + + )} + {row.definition.status === "archived" ? "Unarchive" : "Archive"} + + + setDefinitionDeleteConfirm(row.definition)}> + Delete definition + + + )} + + + ); + } + if (!selectedCompanyId) { return (
Select a company to manage secrets.
@@ -872,6 +1431,7 @@ export function Secrets() { } return ( +
@@ -886,6 +1446,7 @@ export function Secrets() { setActiveTab("vaults")} className="ml-auto" /> -
- {secretsQuery.isError ? ( + {secretsQuery.isError || userDefinitionsQuery.isError ? (
Failed to load secrets:{" "} - {(secretsQuery.error as Error).message} -
- ) : secrets.length === 0 && !secretsQuery.isPending ? ( + ) : unifiedRows.length === 0 && !secretsQuery.isPending && !userDefinitionsQuery.isPending ? ( setCreateOpen(true)} + onAction={openCreateSecret} /> - ) : filtered.length === 0 ? ( + ) : filteredRows.length === 0 ? ( ) : ( - - - - - - - - - - - - - - - - - {filtered.map((secret) => ( - setSelectedSecretId(secret.id)} +
+
+
-
- - - - - - - - - - - ))} - -
NameModeProviderStatusVersionLast rotatedLast resolvedReferencesReference
-
{secret.name}
-
- {modeLabel(secret.managedMode)} - -
{providerLabel(providers, secret.provider)}
-
- - {secret.status} - - v{secret.latestVersion} - {formatRelative(secret.lastRotatedAt)} - - {formatRelative(secret.lastResolvedAt)} - - - - {secret.managedMode === "external_reference" ? ( - - - {secret.externalRef ?? "—"} - - ) : ( - Owned - )} - - -
+
+
+
+ {row.kind === "company" ? row.secret.name : row.definition.name} +
+ + {row.kind === "company" ? row.secret.key : row.definition.key} + +
+
event.stopPropagation()}>{renderRowActions(row)}
+
+
+ {row.kind === "company" ? ( + <> + + Company + + + + + ) : ( + <> + + + + + )} +
+
+ + {row.kind === "company" ? ( + <> + v{row.secret.latestVersion} ·{" "} + {row.secret.managedMode === "external_reference" ? "linked" : "managed"} + + ) : ( + "Member-owned values" + )} + + Updated {formatRelative(row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt)} +
+
+ ); + })} +
+
)}
+ + + - !open && setSelectedSecretId(null)}> + { + if (!open) { + setSelectedSecretId(null); + setSelectedDefinitionId(null); + } + }} + > {selectedSecret ? ( <> - - - - {selectedSecret.name} - - {selectedSecret.status} + + + + {selectedSecret.name} + + - - {providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} · {modeLabel(selectedSecret.managedMode)} + + {providerLabel(providers, selectedSecret.provider)} secret {selectedSecret.key} +
+ + {selectedSecret.key} + + +
+
+ + Company + + {modeLabel(selectedSecret.managedMode)} + {providerLabel(providers, selectedSecret.provider)} + v{selectedSecret.latestVersion} +
-
+
- {selectedSecret.status === "active" ? ( - - ) : ( - - )} - {selectedSecret.status === "archived" ? ( - - ) : ( - - )} - + + + + + + + statusMutation.mutate({ + id: selectedSecret.id, + status: selectedSecret.status === "active" ? "disabled" : "active", + }) + } + > + {selectedSecret.status === "active" ? ( + + ) : ( + + )} + {selectedSecret.status === "active" ? "Disable" : "Activate"} + + + statusMutation.mutate({ + id: selectedSecret.id, + status: selectedSecret.status === "archived" ? "active" : "archived", + }) + } + > + {selectedSecret.status === "archived" ? ( + + ) : ( + + )} + {selectedSecret.status === "archived" ? "Unarchive" : "Archive"} + + + setDeleteConfirm(selectedSecret)}> + Delete secret + + +
@@ -1154,13 +1835,156 @@ export function Secrets() {
- + setSecretDetailTab("usage")} + /> - + + +
+
+ + ) : selectedDefinition ? ( + <> + + + + {selectedDefinition.name} + + + + + + Each user secret definition {selectedDefinition.key} + +
+ + {selectedDefinition.key} + + +
+
+ + + + +
+
+
+ + + + + + + openEditDefinition(selectedDefinition)}> + Edit definition + + + + definitionStatusMutation.mutate({ + definition: selectedDefinition, + status: selectedDefinition.status === "active" ? "disabled" : "active", + }) + } + > + {selectedDefinition.status === "active" ? ( + + ) : ( + + )} + {selectedDefinition.status === "active" ? "Disable" : "Activate"} + + + definitionStatusMutation.mutate({ + definition: selectedDefinition, + status: selectedDefinition.status === "archived" ? "active" : "archived", + }) + } + > + {selectedDefinition.status === "archived" ? ( + + ) : ( + + )} + {selectedDefinition.status === "archived" ? "Unarchive" : "Archive"} + + + setDefinitionDeleteConfirm(selectedDefinition)}> + Delete definition + + + +
+ +
+ +
+
+ + setSecretDetailTab("coverage")} + /> + + + + + + + + +
@@ -1214,123 +2038,202 @@ export function Secrets() { )} - + - Create secret + {editingDefinition ? "Edit user-provided secret" : "Create secret"} - Choose whether Paperclip should own future provider writes, or only resolve an existing - provider reference at runtime. + Choose who provides the value. Shared fields keep their values when you switch modes. - setCreateMode(value as CreateMode)}> - - Managed value - External reference - -
-
+
- setCreateForm((current) => ({ ...current, name: event.target.value })) - } - placeholder="OPENAI_API_KEY" + onChange={(event) => { + const name = event.target.value; + setCreateForm((current) => ({ + ...current, + name, + key: createKeyDirty + ? current.key + : secretValueProvider === "user" + ? normalizeUserSecretKeyForPreview(name) + : normalizeSecretKeyForPreview(name), + })); + }} + placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "OPENAI_API_KEY"} autoFocus />
- setCreateForm((current) => ({ ...current, key: event.target.value })) - } - placeholder="auto from name" + onChange={(event) => { + setCreateKeyDirty(true); + setCreateForm((current) => ({ ...current, key: event.target.value })); + }} + placeholder={secretValueProvider === "user" ? "PERSONAL_GH_TOKEN" : "auto from name"} + disabled={Boolean(editingDefinition)} + className={secretValueProvider === "user" ? "font-mono text-sm" : undefined} /> +

+ {secretValueProvider === "user" + ? editingDefinition + ? "Stable env binding key. Cannot be changed." + : "Env-style key used by user-secret bindings." + : "Shared secret keys keep lowercase dash normalization."} +

- - - setCreateForm((current) => { - const provider = event.target.value as SecretProvider; - return { + setCreateForm((current) => ({ ...current, description: event.target.value })) + } + placeholder="What is this secret used for? (no values)" + /> +
+ + {!editingDefinition ? ( +
+

Who provides the value?

+ { + const next = value as SecretValueProvider; + setSecretValueProvider(next); + setCreateForm((current) => ({ ...current, - provider, - providerConfigId: getDefaultProviderConfigId(providerConfigs, provider), - }; - }) - } - > - {providers.map((provider) => ( - - ))} - - {createProviderBlockReason ? ( -

- - {createProviderBlockReason} + key: createKeyDirty + ? current.key + : next === "user" + ? normalizeUserSecretKeyForPreview(current.name) + : normalizeSecretKeyForPreview(current.name), + })); + }} + > + + Company + Each user + + +

+ Company stores one shared value. Each user lets every member supply their own value under My secrets.

- ) : createProviderHealthText ? ( -

{createProviderHealthText}

- ) : null} -
-
- - - {selectedCreateProviderConfig ? ( - - ) : ( -

- Existing deployment-level provider settings stay available for backwards compatibility. -

- )} -
- {createMode === "managed" ? ( +
+ ) : null} + + {secretValueProvider === "company" ? ( <> + setCreateMode(value as CreateMode)}> + + + Managed value + + + External reference + + + +
+ + + {createProviderBlockReason ? ( +

+ + {createProviderBlockReason} +

+ ) : createProviderHealthText ? ( +

{createProviderHealthText}

+ ) : null} +
+
+ + + {selectedCreateProviderConfig ? ( + + ) : ( +

+ Existing deployment-level provider settings stay available for backwards compatibility. +

+ )} +
+ {createMode === "managed" ? ( + <>
Paperclip-managed secrets are created in the selected provider and future rotations write a new provider version through Paperclip. @@ -1356,38 +2259,48 @@ export function Secrets() { placeholder="Stored once, never re-displayed" />
+ + ) : ( +
+ + + setCreateForm((current) => ({ ...current, externalRef: event.target.value })) + } + placeholder="arn:aws:secretsmanager:..." + className="font-mono text-xs" + /> +

+ Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider, + then update this reference only if the path, ARN, or version changes. +

+
+ )} ) : ( -
- - - setCreateForm((current) => ({ ...current, externalRef: event.target.value })) - } - placeholder="arn:aws:secretsmanager:..." - className="font-mono text-xs" - /> -

- Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider, - then update this reference only if the path, ARN, or version changes. -

-
+ <> +
+ Every member supplies their own value under My secrets. Agents resolve the responsible + user's value at runtime. +
+
+ +