diff --git a/docs/api/secrets-remote-import.md b/docs/api/secrets-remote-import.md new file mode 100644 index 00000000000..d75eaca6166 --- /dev/null +++ b/docs/api/secrets-remote-import.md @@ -0,0 +1,103 @@ +--- +title: Secrets Remote Import +summary: AWS Secrets Manager metadata-only remote import API +--- + +Remote import lets the board link existing AWS Secrets Manager entries as +Paperclip `external_reference` secrets without copying plaintext into +Paperclip. + +Both routes are board-only and company-scoped. The selected provider vault must +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. + +## Preview Remote AWS Secrets + +``` +POST /api/companies/{companyId}/secrets/remote-import/preview +{ + "providerConfigId": "", + "query": "stripe", + "nextToken": "optional-provider-page-token", + "pageSize": 50 +} +``` + +The backend calls AWS Secrets Manager `ListSecrets` only. It never calls +`GetSecretValue`, never requests `SecretString`, and never returns plaintext. + +Response: + +```json +{ + "providerConfigId": "", + "provider": "aws_secrets_manager", + "nextToken": null, + "candidates": [ + { + "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe", + "remoteName": "prod/stripe", + "name": "prod/stripe", + "key": "prod-stripe", + "providerVersionRef": null, + "providerMetadata": { "name": "prod/stripe" }, + "status": "ready", + "importable": true, + "conflicts": [] + } + ] +} +``` + +Candidate `status` values: + +- `ready`: no existing exact external reference and no name/key collision. +- `duplicate`: an existing secret already has the exact provider `externalRef`. +- `conflict`: the suggested Paperclip `name` or `key` is already in use. + +## Import Remote AWS Secret References + +``` +POST /api/companies/{companyId}/secrets/remote-import +{ + "providerConfigId": "", + "secrets": [ + { + "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe", + "name": "Stripe production key", + "key": "stripe-production-key", + "providerVersionRef": null, + "providerMetadata": { "name": "prod/stripe" } + } + ] +} +``` + +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. + +```json +{ + "providerConfigId": "", + "provider": "aws_secrets_manager", + "importedCount": 1, + "skippedCount": 1, + "errorCount": 0, + "results": [ + { + "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe", + "name": "Stripe production key", + "key": "stripe-production-key", + "status": "imported", + "reason": null, + "secretId": "", + "conflicts": [] + } + ] +} +``` + +Activity logs record aggregate counts and provider/vault ids only, not remote +secret names, ARNs, tags, or values. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 08f775510f2..2239bbf4cb7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -543,6 +543,13 @@ export type { CompanySecretUsageBinding, CompanySecretVersion, SecretAccessEvent, + RemoteSecretImportCandidate, + RemoteSecretImportCandidateStatus, + RemoteSecretImportConflict, + RemoteSecretImportPreviewResult, + RemoteSecretImportResult, + RemoteSecretImportRowResult, + RemoteSecretImportRowStatus, SecretAccessOutcome, SecretBindingTargetType, SecretManagedMode, @@ -847,6 +854,9 @@ export { createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, + remoteSecretImportPreviewSchema, + remoteSecretImportSchema, + remoteSecretImportSelectionSchema, localEncryptedProviderConfigSchema, awsSecretsManagerProviderConfigSchema, gcpSecretManagerProviderConfigSchema, @@ -870,6 +880,9 @@ export { type CreateSecret, type CreateSecretProviderConfig, type UpdateSecretProviderConfig, + type RemoteSecretImportPreview, + type RemoteSecretImport, + type RemoteSecretImportSelection, type RotateSecret, type UpdateSecret, type CreateRoutine, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 4afa0a046e7..39ad1993c95 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -253,6 +253,13 @@ export type { CompanySecretUsageBinding, CompanySecretVersion, SecretAccessEvent, + RemoteSecretImportCandidate, + RemoteSecretImportCandidateStatus, + RemoteSecretImportConflict, + RemoteSecretImportPreviewResult, + RemoteSecretImportResult, + RemoteSecretImportRowResult, + RemoteSecretImportRowStatus, SecretAccessOutcome, SecretBindingTargetType, SecretManagedMode, diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 1cf151673f4..4b9474adb3b 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -194,3 +194,51 @@ export interface SecretAccessEvent { errorCode: string | null; createdAt: Date; } + +export type RemoteSecretImportCandidateStatus = "ready" | "duplicate" | "conflict"; + +export interface RemoteSecretImportConflict { + type: "exact_reference" | "name" | "key"; + message: string; + existingSecretId?: string; +} + +export interface RemoteSecretImportCandidate { + externalRef: string; + remoteName: string; + name: string; + key: string; + providerVersionRef: string | null; + providerMetadata: Record | null; + status: RemoteSecretImportCandidateStatus; + importable: boolean; + conflicts: RemoteSecretImportConflict[]; +} + +export interface RemoteSecretImportPreviewResult { + providerConfigId: string; + provider: SecretProvider; + nextToken: string | null; + candidates: RemoteSecretImportCandidate[]; +} + +export type RemoteSecretImportRowStatus = "imported" | "skipped" | "error"; + +export interface RemoteSecretImportRowResult { + externalRef: string; + name: string; + key: string; + status: RemoteSecretImportRowStatus; + reason: string | null; + secretId: string | null; + conflicts: RemoteSecretImportConflict[]; +} + +export interface RemoteSecretImportResult { + providerConfigId: string; + provider: SecretProvider; + importedCount: number; + skippedCount: number; + errorCount: number; + results: RemoteSecretImportRowResult[]; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index dffcd5e2d1d..14b30989d17 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -284,6 +284,9 @@ export { createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, + remoteSecretImportPreviewSchema, + remoteSecretImportSchema, + remoteSecretImportSelectionSchema, localEncryptedProviderConfigSchema, awsSecretsManagerProviderConfigSchema, gcpSecretManagerProviderConfigSchema, @@ -297,6 +300,9 @@ export { type CreateSecret, type CreateSecretProviderConfig, type UpdateSecretProviderConfig, + type RemoteSecretImportPreview, + type RemoteSecretImport, + type RemoteSecretImportSelection, type RotateSecret, type UpdateSecret, } from "./secret.js"; diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index 19ca3460b1f..a1f5e6a3be3 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { createSecretProviderConfigSchema, createSecretSchema, + remoteSecretImportPreviewSchema, + remoteSecretImportSchema, secretProviderConfigPayloadSchema, updateSecretProviderConfigSchema, } from "./secret.js"; @@ -100,4 +102,50 @@ describe("secret validators", () => { }), ).toThrow(/origin-only HTTP\(S\) URL/i); }); + + it("validates AWS remote import preview and import payloads", () => { + expect( + remoteSecretImportPreviewSchema.parse({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + query: "openai", + pageSize: 50, + }), + ).toEqual({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + query: "openai", + pageSize: 50, + }); + + expect( + remoteSecretImportSchema.parse({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + secrets: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "OpenAI API key", + key: "OPENAI_API_KEY", + providerMetadata: { name: "prod/openai" }, + }, + ], + }), + ).toMatchObject({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + secrets: [expect.objectContaining({ key: "OPENAI_API_KEY" })], + }); + }); + + it("caps AWS remote import paging and row counts", () => { + expect(() => + remoteSecretImportPreviewSchema.parse({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + pageSize: 101, + }), + ).toThrow(); + expect(() => + remoteSecretImportSchema.parse({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + secrets: [], + }), + ).toThrow(); + }); }); diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index f78cfc70af1..b62ab967b67 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -252,3 +252,28 @@ export const updateSecretProviderConfigSchema = z.object({ }); export type UpdateSecretProviderConfig = z.infer; + +export const remoteSecretImportPreviewSchema = z.object({ + providerConfigId: z.string().uuid(), + query: z.string().trim().max(200).optional().nullable(), + nextToken: z.string().trim().min(1).max(4096).optional().nullable(), + pageSize: z.number().int().min(1).max(100).optional(), +}); + +export type RemoteSecretImportPreview = z.infer; + +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(), + providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(), + providerMetadata: z.record(z.unknown()).optional().nullable(), +}); + +export const remoteSecretImportSchema = z.object({ + providerConfigId: z.string().uuid(), + secrets: z.array(remoteSecretImportSelectionSchema).min(1).max(100), +}); + +export type RemoteSecretImportSelection = z.infer; +export type RemoteSecretImport = z.infer; diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index 2a463db632e..2e0075c39bb 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -283,6 +283,86 @@ describe("awsSecretsManagerProvider", () => { expect(prepared.valueSha256).toBeTruthy(); }); + it("lists remote AWS secrets with metadata only and never resolves plaintext", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + gateway: { + async createSecret() { + throw new Error("not used"); + }, + async putSecretValue() { + throw new Error("not used"); + }, + async getSecretValue() { + throw new Error("GetSecretValue must not be used for remote import preview"); + }, + async deleteSecret() { + throw new Error("not used"); + }, + async listSecrets(input) { + calls.push({ op: "listSecrets", input }); + return { + NextToken: "token-2", + SecretList: [ + { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + Name: "prod/openai", + Description: "OpenAI API key", + CreatedDate: new Date("2026-05-06T00:00:00.000Z"), + Tags: [{ Key: "team", Value: "platform" }], + }, + ], + }; + }, + }, + }); + + const listed = await provider.listRemoteSecrets?.({ + query: "openai", + nextToken: "token-1", + pageSize: 25, + }); + + expect(calls).toEqual([ + { + op: "listSecrets", + input: { + MaxResults: 25, + NextToken: "token-1", + IncludePlannedDeletion: false, + Filters: [{ Key: "all", Values: ["openai"] }], + }, + }, + ]); + expect(listed).toEqual({ + nextToken: "token-2", + secrets: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "prod/openai", + providerVersionRef: null, + metadata: expect.objectContaining({ + arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "prod/openai", + description: "OpenAI API key", + createdDate: "2026-05-06T00:00:00.000Z", + }), + }, + ], + }); + expect(JSON.stringify(listed)).not.toContain("SecretString"); + }); + it("resolves AWS secret values by provider version reference", async () => { const calls: Array<{ op: string; input: Record }> = []; const provider = createAwsSecretsManagerProvider({ diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 1b70a544100..1a3cd557346 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -15,6 +15,8 @@ const mockSecretService = vi.hoisted(() => ({ setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), create: vi.fn(), + previewRemoteImport: vi.fn(), + importRemoteSecrets: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); @@ -234,4 +236,125 @@ describe("secret routes", () => { })); expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey"); }); + + it("rejects remote import preview for non-board actors", async () => { + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + })).post("/api/companies/company-1/secrets/remote-import/preview").send({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + }); + + expect(res.status).toBe(403); + expect(mockSecretService.previewRemoteImport).not.toHaveBeenCalled(); + }); + + it("previews remote imports and logs only aggregate metadata", async () => { + mockSecretService.previewRemoteImport.mockResolvedValue({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + provider: "aws_secrets_manager", + nextToken: null, + candidates: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + remoteName: "prod/openai", + name: "openai", + key: "openai", + providerVersionRef: null, + providerMetadata: { description: "OpenAI API key" }, + status: "ready", + importable: true, + conflicts: [], + }, + ], + }); + + const res = await request(createApp()) + .post("/api/companies/company-1/secrets/remote-import/preview") + .send({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + query: "openai", + pageSize: 25, + }); + + expect(res.status).toBe(200); + expect(mockSecretService.previewRemoteImport).toHaveBeenCalledWith("company-1", { + providerConfigId: "11111111-1111-4111-8111-111111111111", + query: "openai", + nextToken: undefined, + pageSize: 25, + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "secret.remote_import.previewed", + details: { + provider: "aws_secrets_manager", + candidateCount: 1, + readyCount: 1, + duplicateCount: 0, + conflictCount: 0, + }, + })); + expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai"); + }); + + it("imports remote references and logs aggregate row counts", async () => { + mockSecretService.importRemoteSecrets.mockResolvedValue({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + provider: "aws_secrets_manager", + importedCount: 1, + skippedCount: 0, + errorCount: 0, + results: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "OpenAI API key", + key: "openai-api-key", + status: "imported", + reason: null, + secretId: "22222222-2222-4222-8222-222222222222", + conflicts: [], + }, + ], + }); + + const res = await request(createApp()) + .post("/api/companies/company-1/secrets/remote-import") + .send({ + providerConfigId: "11111111-1111-4111-8111-111111111111", + secrets: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "OpenAI API key", + key: "openai-api-key", + }, + ], + }); + + expect(res.status).toBe(200); + expect(mockSecretService.importRemoteSecrets).toHaveBeenCalledWith( + "company-1", + { + providerConfigId: "11111111-1111-4111-8111-111111111111", + secrets: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "OpenAI API key", + key: "openai-api-key", + }, + ], + }, + { userId: "user-1", agentId: null }, + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "secret.remote_import.completed", + details: { + provider: "aws_secrets_manager", + importedCount: 1, + skippedCount: 0, + errorCount: 0, + }, + })); + expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai"); + }); }); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index be006ed31ed..2f54f228dc7 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -364,6 +364,177 @@ describeEmbeddedPostgres("secretService", () => { expect(JSON.stringify(resolveSpy.mock.calls[0]?.[0])).not.toContain("resolved-secret"); }); + it("previews AWS remote import candidates with duplicate and collision enrichment", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const awsVault = await svc.createProviderConfig(companyId, { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }); + const duplicate = await svc.create(companyId, { + name: "Existing duplicate", + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + managedMode: "external_reference", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate", + }); + const nameConflict = await svc.create(companyId, { + name: "Prod Conflict", + provider: "local_encrypted", + value: "runtime-secret", + }); + + const listSpy = vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({ + nextToken: "next-page", + secrets: [ + { + externalRef: duplicate.externalRef!, + name: "prod/duplicate", + providerVersionRef: null, + metadata: { arn: duplicate.externalRef }, + }, + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict", + name: nameConflict.name, + providerVersionRef: null, + metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict" }, + }, + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready", + name: "prod/ready", + providerVersionRef: null, + metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready" }, + }, + ], + }); + + const preview = await svc.previewRemoteImport(companyId, { + providerConfigId: awsVault.id, + query: "prod", + pageSize: 25, + }); + + expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({ + providerConfig: expect.objectContaining({ id: awsVault.id }), + query: "prod", + pageSize: 25, + })); + expect(preview.nextToken).toBe("next-page"); + expect(preview.candidates.map((candidate) => candidate.status)).toEqual([ + "duplicate", + "conflict", + "ready", + ]); + expect(preview.candidates[0]?.conflicts[0]).toMatchObject({ + type: "exact_reference", + existingSecretId: duplicate.id, + }); + expect(preview.candidates[1]?.conflicts[0]).toMatchObject({ + type: "name", + existingSecretId: nameConflict.id, + }); + expect(preview.candidates[2]).toMatchObject({ + importable: true, + name: "prod/ready", + key: "prod-ready", + }); + }); + + it("imports AWS remote references row-by-row without fetching plaintext", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const awsVault = await svc.createProviderConfig(companyId, { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }); + const duplicate = await svc.create(companyId, { + name: "Existing duplicate", + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + managedMode: "external_reference", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate", + }); + + const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion"); + const result = await svc.importRemoteSecrets( + companyId, + { + providerConfigId: awsVault.id, + secrets: [ + { + externalRef: duplicate.externalRef!, + name: "Existing duplicate", + key: "existing-duplicate", + }, + { + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + name: "OpenAI API key", + key: "openai-api-key", + providerMetadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai" }, + }, + ], + }, + { userId: "user-1" }, + ); + + expect(result.importedCount).toBe(1); + expect(result.skippedCount).toBe(1); + expect(result.results.map((row) => row.status)).toEqual(["skipped", "imported"]); + expect(result.results[0]).toMatchObject({ + reason: "exact_reference_duplicate", + conflicts: [expect.objectContaining({ type: "exact_reference", existingSecretId: duplicate.id })], + }); + expect(resolveSpy).not.toHaveBeenCalled(); + + const imported = await db + .select() + .from(companySecrets) + .where(eq(companySecrets.key, "openai-api-key")) + .then((rows) => rows[0]); + expect(imported).toMatchObject({ + companyId, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + managedMode: "external_reference", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai", + createdByUserId: "user-1", + }); + + const versions = await db + .select() + .from(companySecretVersions) + .where(eq(companySecretVersions.secretId, imported!.id)); + expect(versions).toHaveLength(1); + expect(JSON.stringify(versions[0])).not.toContain("runtime-secret"); + expect(JSON.stringify(versions[0])).not.toContain("sk-"); + }); + + it("rejects remote import for disabled or cross-company provider vaults", async () => { + const companyA = await seedCompany("A"); + const companyB = await seedCompany("B"); + const svc = secretService(db); + const disabledVault = await svc.createProviderConfig(companyA, { + provider: "aws_secrets_manager", + displayName: "AWS disabled", + status: "disabled", + config: { region: "us-east-1" }, + }); + const foreignVault = await svc.createProviderConfig(companyB, { + provider: "aws_secrets_manager", + displayName: "AWS foreign", + config: { region: "us-east-1" }, + }); + + await expect( + svc.previewRemoteImport(companyA, { providerConfigId: disabledVault.id }), + ).rejects.toThrow(/disabled/i); + await expect( + svc.previewRemoteImport(companyA, { providerConfigId: foreignVault.id }), + ).rejects.toThrow(/same company/i); + }); + it("rejects externalRef overrides on managed secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 4f211859db4..6fe1b7ecf7e 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -3,6 +3,8 @@ import type { Db } from "@paperclipai/db"; import { createSecretProviderConfigSchema, createSecretSchema, + remoteSecretImportPreviewSchema, + remoteSecretImportSchema, rotateSecretSchema, updateSecretProviderConfigSchema, updateSecretSchema, @@ -266,6 +268,77 @@ export function secretRoutes(db: Db) { res.status(201).json(created); }); + router.post( + "/companies/:companyId/secrets/remote-import/preview", + validate(remoteSecretImportPreviewSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const preview = await svc.previewRemoteImport(companyId, { + providerConfigId: req.body.providerConfigId, + query: req.body.query, + nextToken: req.body.nextToken, + pageSize: req.body.pageSize, + }); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret.remote_import.previewed", + entityType: "secret_provider_config", + entityId: preview.providerConfigId, + details: { + provider: preview.provider, + candidateCount: preview.candidates.length, + readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length, + duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length, + conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length, + }, + }); + + res.json(preview); + }, + ); + + router.post( + "/companies/:companyId/secrets/remote-import", + validate(remoteSecretImportSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const result = await svc.importRemoteSecrets( + companyId, + { + providerConfigId: req.body.providerConfigId, + secrets: req.body.secrets, + }, + { userId: req.actor.userId ?? "board", agentId: null }, + ); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret.remote_import.completed", + entityType: "secret_provider_config", + entityId: result.providerConfigId, + details: { + provider: result.provider, + importedCount: result.importedCount, + skippedCount: result.skippedCount, + errorCount: result.errorCount, + }, + }); + + res.json(result); + }, + ); + router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts index 808032d92c8..4e8425e2ff9 100644 --- a/server/src/secrets/aws-secrets-manager-provider.ts +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -7,6 +7,7 @@ import type { DeploymentMode } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import type { PreparedSecretVersion, + RemoteSecretListResult, SecretProviderHealthCheck, SecretProviderModule, SecretProviderValidationResult, @@ -48,6 +49,18 @@ interface AwsSecretsManagerTag { Value: string; } +interface AwsSecretsManagerListSecretEntry { + ARN?: string; + Name?: string; + Description?: string; + KmsKeyId?: string; + CreatedDate?: string | number | Date; + LastAccessedDate?: string | number | Date; + LastChangedDate?: string | number | Date; + DeletedDate?: string | number | Date; + Tags?: AwsSecretsManagerTag[]; +} + type ManagedSecretNamespaceContext = Pick; interface AwsSecretsManagerGateway { @@ -84,6 +97,18 @@ interface AwsSecretsManagerGateway { SecretId: string; RecoveryWindowInDays: number; }): Promise; + listSecrets?(input: { + MaxResults?: number; + NextToken?: string; + Filters?: Array<{ + Key: "all" | "name" | "description" | "tag-key" | "tag-value" | "primary-region" | "owning-service"; + Values: string[]; + }>; + IncludePlannedDeletion?: boolean; + }): Promise<{ + SecretList?: AwsSecretsManagerListSecretEntry[]; + NextToken?: string; + }>; } function sha256Hex(value: string): string { @@ -381,6 +406,26 @@ function createManagedMaterial(secretId: string, versionId: string | null): AwsS }; } +function serializeAwsDate(value: string | number | Date | undefined): string | null { + if (value === undefined) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function createRemoteSecretMetadata(entry: AwsSecretsManagerListSecretEntry): Record { + return { + arn: entry.ARN ?? null, + name: entry.Name ?? null, + description: entry.Description ?? null, + kmsKeyId: entry.KmsKeyId ?? null, + createdDate: serializeAwsDate(entry.CreatedDate), + lastAccessedDate: serializeAwsDate(entry.LastAccessedDate), + lastChangedDate: serializeAwsDate(entry.LastChangedDate), + deletedDate: serializeAwsDate(entry.DeletedDate), + tags: entry.Tags ?? [], + }; +} + function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSecretsManagerMaterial { if ( value && @@ -465,6 +510,21 @@ class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway { return this.call("DeleteSecret", input); } + listSecrets(input: { + MaxResults?: number; + NextToken?: string; + Filters?: Array<{ + Key: "all" | "name" | "description" | "tag-key" | "tag-value" | "primary-region" | "owning-service"; + Values: string[]; + }>; + IncludePlannedDeletion?: boolean; + }) { + return this.call<{ + SecretList?: AwsSecretsManagerListSecretEntry[]; + NextToken?: string; + }>("ListSecrets", input); + } + private async call(operation: string, payload: Record): Promise { const body = JSON.stringify(payload); const request = new HttpRequest({ @@ -682,6 +742,40 @@ export function createAwsSecretsManagerProvider( async linkExternalSecret(input) { return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null); }, + async listRemoteSecrets(input): Promise { + const config = resolveConfig(input.providerConfig); + const gateway = resolveGateway(config); + const query = input.query?.trim(); + const pageSize = + input.pageSize && Number.isFinite(input.pageSize) + ? Math.min(Math.max(Math.trunc(input.pageSize), 1), 100) + : 50; + + try { + if (!gateway.listSecrets) { + throw new Error("ListSecrets gateway operation is unavailable"); + } + const listed = await gateway.listSecrets({ + MaxResults: pageSize, + NextToken: input.nextToken?.trim() || undefined, + IncludePlannedDeletion: false, + Filters: query ? [{ Key: "all", Values: [query] }] : undefined, + }); + return { + nextToken: listed.NextToken ?? null, + secrets: (listed.SecretList ?? []) + .filter((entry) => Boolean(entry.ARN ?? entry.Name)) + .map((entry) => ({ + externalRef: entry.ARN ?? entry.Name ?? "", + name: entry.Name ?? entry.ARN ?? "", + providerVersionRef: null, + metadata: createRemoteSecretMetadata(entry), + })), + }; + } catch (error) { + normalizeAwsError("listSecrets", error); + } + }, async resolveVersion(input) { const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); diff --git a/server/src/secrets/types.ts b/server/src/secrets/types.ts index 6ad97d1d8d1..461063f56d0 100644 --- a/server/src/secrets/types.ts +++ b/server/src/secrets/types.ts @@ -29,6 +29,18 @@ export interface PreparedSecretVersion { providerVersionRef?: string | null; } +export interface RemoteSecretListEntry { + externalRef: string; + name: string; + providerVersionRef?: string | null; + metadata?: Record | null; +} + +export interface RemoteSecretListResult { + secrets: RemoteSecretListEntry[]; + nextToken?: string | null; +} + export interface SecretProviderRuntimeContext { companyId: string; secretId: string; @@ -76,6 +88,12 @@ export interface SecretProviderModule { context?: SecretProviderWriteContext; providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; + listRemoteSecrets?(input: { + providerConfig?: SecretProviderVaultRuntimeConfig | null; + query?: string | null; + nextToken?: string | null; + pageSize?: number; + }): Promise; resolveVersion(input: { material: StoredSecretVersionMaterial; externalRef: string | null; diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index c3a8060f666..387afdb8c9e 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -17,6 +17,9 @@ import type { AgentEnvConfig, CompanySecretBindingTarget, EnvBinding, + RemoteSecretImportCandidate, + RemoteSecretImportConflict, + RemoteSecretImportRowResult, SecretBindingTargetType, SecretProvider, SecretProviderConfigHealthResponse, @@ -99,6 +102,13 @@ function normalizeSecretKey(input: string) { .slice(0, 120); } +function deriveSecretNameFromExternalRef(externalRef: string) { + const trimmed = externalRef.trim(); + const arnMatch = /^arn:[^:]+:secretsmanager:[^:]*:[^:]*:secret:(.+)$/i.exec(trimmed); + const name = arnMatch?.[1] ?? trimmed; + return name.split("/").filter(Boolean).at(-1) ?? name; +} + function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { if (typeof binding === "string") { return { type: "plain", value: binding }; @@ -672,6 +682,71 @@ export function secretService(db: Db) { return targetMap; } + async function buildRemoteImportConflictMaps(companyId: string, provider: SecretProvider) { + const existingSecrets = await db + .select({ + id: companySecrets.id, + name: companySecrets.name, + key: companySecrets.key, + provider: companySecrets.provider, + externalRef: companySecrets.externalRef, + }) + .from(companySecrets) + .where(eq(companySecrets.companyId, companyId)); + return { + byExternalRef: new Map( + existingSecrets + .filter((secret) => secret.provider === provider && typeof secret.externalRef === "string" && secret.externalRef.trim()) + .map((secret) => [secret.externalRef!.trim(), secret]), + ), + byName: new Map(existingSecrets.map((secret) => [secret.name, secret])), + byKey: new Map(existingSecrets.map((secret) => [secret.key, secret])), + }; + } + + function remoteImportConflictsFor(input: { + externalRef: string; + name: string; + key: string; + maps: Awaited>; + }): RemoteSecretImportConflict[] { + const conflicts: RemoteSecretImportConflict[] = []; + const duplicate = input.maps.byExternalRef.get(input.externalRef); + if (duplicate) { + conflicts.push({ + type: "exact_reference", + existingSecretId: duplicate.id, + message: "An existing secret already links this exact provider reference.", + }); + return conflicts; + } + const nameConflict = input.maps.byName.get(input.name); + if (nameConflict) { + conflicts.push({ + type: "name", + existingSecretId: nameConflict.id, + message: `Secret name already exists: ${input.name}`, + }); + } + const keyConflict = input.maps.byKey.get(input.key); + if (keyConflict) { + conflicts.push({ + type: "key", + existingSecretId: keyConflict.id, + message: `Secret key already exists: ${input.key}`, + }); + } + return conflicts; + } + + async function getRemoteImportProviderConfig(companyId: string, providerConfigId: string) { + const providerConfig = await getProviderConfigById(providerConfigId); + if (!providerConfig) throw notFound("Provider vault not found"); + const provider = providerConfig.provider as SecretProvider; + assertSelectableProviderConfig(providerConfig, companyId, provider); + return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) }; + } + return { listProviders: () => listSecretProviders(), @@ -910,6 +985,196 @@ export function secretService(db: Db) { .where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId))) .orderBy(desc(secretAccessEvents.createdAt)), + previewRemoteImport: async ( + companyId: string, + input: { + providerConfigId: string; + query?: string | null; + nextToken?: string | null; + pageSize?: number; + }, + ) => { + const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig( + companyId, + input.providerConfigId, + ); + const provider = getSecretProvider(providerId); + if (!provider.listRemoteSecrets) { + throw unprocessable(`${providerId} provider does not support remote import listing`); + } + const listed = await provider.listRemoteSecrets({ + providerConfig: runtimeConfig, + query: input.query, + nextToken: input.nextToken, + pageSize: input.pageSize, + }); + const maps = await buildRemoteImportConflictMaps(companyId, providerId); + const candidates: RemoteSecretImportCandidate[] = listed.secrets.map((remote) => { + const externalRef = remote.externalRef.trim(); + const remoteName = remote.name.trim() || deriveSecretNameFromExternalRef(externalRef); + const name = remoteName || deriveSecretNameFromExternalRef(externalRef); + const key = normalizeSecretKey(name); + const conflicts = remoteImportConflictsFor({ externalRef, name, key, maps }); + const hasDuplicate = conflicts.some((conflict) => conflict.type === "exact_reference"); + const hasConflict = conflicts.length > 0; + return { + externalRef, + remoteName, + name, + key, + providerVersionRef: remote.providerVersionRef ?? null, + providerMetadata: remote.metadata ?? null, + status: hasDuplicate ? "duplicate" : hasConflict ? "conflict" : "ready", + importable: !hasConflict, + conflicts, + }; + }); + return { + providerConfigId: providerConfig.id, + provider: providerId, + nextToken: listed.nextToken ?? null, + candidates, + }; + }, + + importRemoteSecrets: async ( + companyId: string, + input: { + providerConfigId: string; + secrets: Array<{ + externalRef: string; + name?: string | null; + key?: string | null; + providerVersionRef?: string | null; + providerMetadata?: Record | null; + }>; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig( + companyId, + input.providerConfigId, + ); + const provider = getSecretProvider(providerId); + if (provider.descriptor().supportsExternalReferences === false) { + throw unprocessable(`${providerId} provider does not support linked external references`); + } + const maps = await buildRemoteImportConflictMaps(companyId, providerId); + const results: RemoteSecretImportRowResult[] = []; + + for (const selection of input.secrets) { + const externalRef = selection.externalRef.trim(); + const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef); + const key = normalizeSecretKey(selection.key?.trim() || name); + const conflicts = remoteImportConflictsFor({ externalRef, name, key, maps }); + if (!key) { + results.push({ + externalRef, + name, + key, + status: "error", + reason: "Secret key is required", + secretId: null, + conflicts, + }); + continue; + } + if (conflicts.length > 0) { + results.push({ + externalRef, + name, + key, + status: "skipped", + reason: conflicts.some((conflict) => conflict.type === "exact_reference") + ? "exact_reference_duplicate" + : "name_or_key_conflict", + secretId: null, + conflicts, + }); + continue; + } + + try { + const prepared = await provider.linkExternalSecret({ + externalRef, + providerVersionRef: selection.providerVersionRef ?? null, + providerConfig: runtimeConfig, + context: { + companyId, + secretKey: key, + secretName: name, + version: 1, + }, + }); + const secret = await db.transaction(async (tx) => { + const inserted = await tx + .insert(companySecrets) + .values({ + companyId, + key, + name, + provider: providerId, + providerConfigId: providerConfig.id, + status: "active", + managedMode: "external_reference", + externalRef: prepared.externalRef, + providerMetadata: selection.providerMetadata ?? null, + latestVersion: 1, + description: null, + lastRotatedAt: new Date(), + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + await tx.insert(companySecretVersions).values({ + secretId: inserted.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, + }); + return inserted; + }); + maps.byExternalRef.set(externalRef, secret); + maps.byName.set(name, secret); + maps.byKey.set(key, secret); + results.push({ + externalRef, + name, + key, + status: "imported", + reason: null, + secretId: secret.id, + conflicts: [], + }); + } catch (error) { + results.push({ + externalRef, + name, + key, + status: "error", + reason: error instanceof Error ? error.message : "Import failed", + secretId: null, + conflicts: [], + }); + } + } + + return { + providerConfigId: providerConfig.id, + provider: providerId, + importedCount: results.filter((result) => result.status === "imported").length, + skippedCount: results.filter((result) => result.status === "skipped").length, + errorCount: results.filter((result) => result.status === "error").length, + results, + }; + }, + getById, getByName, resolveSecretValue,