diff --git a/docs/api/secrets-remote-import.md b/docs/api/secrets-remote-import.md index d75eaca6166..8b3c9099f58 100644 --- a/docs/api/secrets-remote-import.md +++ b/docs/api/secrets-remote-import.md @@ -12,6 +12,12 @@ belong to the company, use `aws_secrets_manager`, and have a selectable status (`ready` or `warning`). Disabled, coming-soon, or cross-company vaults are rejected. +Remote import is an inventory and metadata workflow. Preview calls AWS +`ListSecrets` only and import stores a Paperclip external reference plus +fingerprint/version metadata. Neither route calls `GetSecretValue` or +`BatchGetSecretValue`, requests `SecretString`, requires KMS decrypt, logs raw +remote metadata, or copies secret plaintext into Paperclip. + ## Preview Remote AWS Secrets ``` @@ -24,8 +30,10 @@ POST /api/companies/{companyId}/secrets/remote-import/preview } ``` -The backend calls AWS Secrets Manager `ListSecrets` only. It never calls -`GetSecretValue`, never requests `SecretString`, and never returns plaintext. +`query` is optional and is sent to AWS as an inventory filter. Treat it as +non-secret metadata because AWS may record list request parameters in +CloudTrail. `nextToken` is an opaque AWS cursor; pass it back unchanged. +`pageSize` is capped at 100. Response: @@ -41,7 +49,10 @@ Response: "name": "prod/stripe", "key": "prod-stripe", "providerVersionRef": null, - "providerMetadata": { "name": "prod/stripe" }, + "providerMetadata": { + "lastChangedDate": "2026-05-06T00:00:00.000Z", + "hasDescription": true + }, "status": "ready", "importable": true, "conflicts": [] @@ -56,6 +67,11 @@ Candidate `status` values: - `duplicate`: an existing secret already has the exact provider `externalRef`. - `conflict`: the suggested Paperclip `name` or `key` is already in use. +Conflict `type` values are `exact_reference`, `name`, `key`, and +`provider_guardrail`. AWS refs under Paperclip's own managed namespace are +blocked as external references so one company cannot import another company's +Paperclip-managed AWS secret through a broad runtime role. + ## Import Remote AWS Secret References ``` @@ -67,8 +83,12 @@ POST /api/companies/{companyId}/secrets/remote-import "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe", "name": "Stripe production key", "key": "stripe-production-key", + "description": "Stripe key used by production checkout", "providerVersionRef": null, - "providerMetadata": { "name": "prod/stripe" } + "providerMetadata": { + "lastChangedDate": "2026-05-06T00:00:00.000Z", + "hasDescription": true + } } ] } @@ -77,6 +97,11 @@ POST /api/companies/{companyId}/secrets/remote-import The import response is row-level. Ready rows become active `external_reference` secrets with version metadata only. Exact-reference duplicates and name/key conflicts are skipped without failing the whole request. +The `secrets` array accepts 1-100 rows, and the backend re-checks duplicates and +conflicts at submit time. +Each row may include an optional Paperclip `description` entered during review; +blank descriptions are stored as `null`. AWS provider descriptions are not +copied into this field. ```json { @@ -101,3 +126,8 @@ duplicates and name/key conflicts are skipped without failing the whole request. Activity logs record aggregate counts and provider/vault ids only, not remote secret names, ARNs, tags, or values. + +Imported references may still fail during a future bound runtime resolution if +the Paperclip runtime role can list the AWS secret but lacks +`secretsmanager:GetSecretValue` or required KMS decrypt permission for that +specific secret. diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index a1f5e6a3be3..c8a8163d3d2 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -124,13 +124,19 @@ describe("secret validators", () => { externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", name: "OpenAI API key", key: "OPENAI_API_KEY", + description: " Operator-entered Paperclip description ", providerMetadata: { name: "prod/openai" }, }, ], }), ).toMatchObject({ providerConfigId: "11111111-1111-4111-8111-111111111111", - secrets: [expect.objectContaining({ key: "OPENAI_API_KEY" })], + secrets: [ + expect.objectContaining({ + key: "OPENAI_API_KEY", + description: "Operator-entered Paperclip description", + }), + ], }); }); diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index b62ab967b67..ae36461746b 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -266,6 +266,7 @@ export const remoteSecretImportSelectionSchema = z.object({ externalRef: z.string().trim().min(1).max(2048), name: z.string().trim().min(1).max(160).optional().nullable(), key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(), + description: z.string().trim().max(500).optional().nullable(), providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(), providerMetadata: z.record(z.unknown()).optional().nullable(), }); diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 039289b9e00..cd7152e3b0e 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -355,6 +355,7 @@ describe("secret routes", () => { externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", name: "OpenAI API key", key: "openai-api-key", + description: "Operator-entered Paperclip description", }, ], }); @@ -369,6 +370,7 @@ describe("secret routes", () => { externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", name: "OpenAI API key", key: "openai-api-key", + description: "Operator-entered Paperclip description", }, ], }, diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 5a22f0dd5b4..5ab31fc6380 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -518,6 +518,7 @@ describeEmbeddedPostgres("secretService", () => { externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", name: "OpenAI API key", key: "openai-api-key", + description: " Operator-entered production OpenAI key ", providerMetadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai" }, }, ], @@ -547,6 +548,7 @@ describeEmbeddedPostgres("secretService", () => { externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", createdByUserId: "user-1", providerMetadata: null, + description: "Operator-entered production OpenAI key", }); const versions = await db diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 7365f80aa48..cafce1579b7 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1197,6 +1197,7 @@ export function secretService(db: Db) { externalRef: string; name?: string | null; key?: string | null; + description?: string | null; providerVersionRef?: string | null; providerMetadata?: Record | null; }>; @@ -1218,6 +1219,7 @@ export function secretService(db: Db) { const externalRef = selection.externalRef.trim(); const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef); const key = normalizeSecretKey(selection.key?.trim() || name); + const description = selection.description?.trim() || null; let prepared: PreparedSecretVersion | undefined; const conflicts = remoteImportConflictsFor({ providerConfigId: providerConfig.id, @@ -1326,7 +1328,7 @@ export function secretService(db: Db) { externalRef: preparedSecret.externalRef, providerMetadata: null, latestVersion: 1, - description: null, + description, lastRotatedAt: new Date(), createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index fc46d4bc484..aedfa85dc03 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -85,6 +85,7 @@ export interface RemoteImportSelectionInput { externalRef: string; name?: string | null; key?: string | null; + description?: string | null; providerVersionRef?: string | null; providerMetadata?: Record | null; } diff --git a/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx index 3ff3a0a33f3..3dab9f4442b 100644 --- a/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx +++ b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx @@ -357,6 +357,113 @@ describe("ImportFromVaultDialog", () => { }); }); + it("submits the operator-entered review description", async () => { + const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ"; + mockSecretsApi.remoteImportPreview.mockResolvedValueOnce( + makePreview([ + makeCandidate({ + externalRef, + remoteName: "prod/openai", + name: "OpenAI API key", + key: "openai-api-key", + providerMetadata: { + description: "Raw AWS description should not seed the review field", + }, + }), + ]), + ); + mockSecretsApi.remoteImport.mockResolvedValueOnce({ + providerConfigId: awsVault.id, + provider: "aws_secrets_manager", + importedCount: 1, + skippedCount: 0, + errorCount: 0, + results: [ + { + externalRef, + name: "OpenAI API key", + key: "openai-api-key", + status: "imported", + reason: null, + secretId: "secret-openai", + conflicts: [], + }, + ], + }); + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const row = document.querySelector( + `[data-testid="vault-row-${externalRef}"]`, + ) as HTMLElement | null; + await act(async () => { + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const continueBtn = Array.from(document.querySelectorAll("button")).find( + (btn) => btn.textContent?.includes("Continue"), + ); + await act(async () => { + continueBtn!.click(); + }); + await flush(); + + const descriptionInput = document.querySelector( + `[data-testid="review-description-${externalRef}"]`, + ) as HTMLInputElement | null; + expect(descriptionInput?.value).toBe(""); + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + valueSetter?.call(descriptionInput, "Operator-entered OpenAI key"); + descriptionInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flush(); + + const importBtn = Array.from(document.querySelectorAll("button")).find( + (btn) => btn.textContent?.startsWith("Import "), + ) as HTMLButtonElement | undefined; + await act(async () => { + importBtn!.click(); + }); + await flush(); + await flush(); + + expect(mockSecretsApi.remoteImport).toHaveBeenCalledWith("company-1", { + providerConfigId: awsVault.id, + secrets: [ + expect.objectContaining({ + externalRef, + description: "Operator-entered OpenAI key", + providerMetadata: null, + }), + ], + }); + + await act(async () => { + root.unmount(); + }); + }); + it("renders mixed import results (created/skipped/failed) and shows error reason", async () => { mockSecretsApi.remoteImportPreview.mockResolvedValueOnce( makePreview([ diff --git a/ui/src/pages/secrets/ImportFromVaultDialog.tsx b/ui/src/pages/secrets/ImportFromVaultDialog.tsx index 88f831d6a65..5bfe6e6c56c 100644 --- a/ui/src/pages/secrets/ImportFromVaultDialog.tsx +++ b/ui/src/pages/secrets/ImportFromVaultDialog.tsx @@ -234,19 +234,30 @@ function isThrottlingError(error: unknown): boolean { } function buildDraft(candidate: RemoteSecretImportCandidate): DraftSelection { - const meta = candidate.providerMetadata ?? {}; - const description = - typeof (meta as Record).description === "string" - ? ((meta as Record).description as string) - : ""; return { candidate, name: candidate.name, key: candidate.key, - description, + description: "", }; } +function safeImportProviderMetadata( + metadata: Record | null | undefined, +): Record | null { + if (!metadata) return null; + const safe: Record = {}; + for (const key of ["createdDate", "lastAccessedDate", "lastChangedDate", "deletedDate"]) { + const value = metadata[key]; + if (typeof value === "string" || value === null) safe[key] = value; + } + for (const key of ["hasDescription", "hasKmsKey", "tagCount"]) { + const value = metadata[key]; + if (typeof value === "boolean" || typeof value === "number") safe[key] = value; + } + return Object.keys(safe).length > 0 ? safe : null; +} + function validateDraftRow( draft: DraftSelection, existing: CompanySecret[], @@ -610,8 +621,9 @@ export function ImportFromVaultDialog({ externalRef: draft.candidate.externalRef, name: draft.name.trim(), key: draft.key.trim(), + description: draft.description.trim() || null, providerVersionRef: draft.candidate.providerVersionRef, - providerMetadata: draft.candidate.providerMetadata, + providerMetadata: safeImportProviderMetadata(draft.candidate.providerMetadata), })); importMutation.mutate({ providerConfigId: vaultId, secrets: items }); } @@ -999,8 +1011,6 @@ function SelectStep(props: SelectStepProps) { : typeof meta.lastChangedDate === "string" ? meta.lastChangedDate : null; - const description = - typeof meta.description === "string" ? meta.description : null; return (
{candidate.remoteName}
- {description && ( -
- {description} -
- )}