diff --git a/doc/screenshots/pr-6381/aws-discovery-candidates.png b/doc/screenshots/pr-6381/aws-discovery-candidates.png new file mode 100644 index 00000000..9006d175 Binary files /dev/null and b/doc/screenshots/pr-6381/aws-discovery-candidates.png differ diff --git a/doc/screenshots/pr-6381/provider-vaults-tab.png b/doc/screenshots/pr-6381/provider-vaults-tab.png new file mode 100644 index 00000000..6d304c20 Binary files /dev/null and b/doc/screenshots/pr-6381/provider-vaults-tab.png differ diff --git a/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png b/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png new file mode 100644 index 00000000..40a4c81a Binary files /dev/null and b/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png differ diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 38988c6f..7fe53f4c 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -12,6 +12,7 @@ export const API = { approvals: `${API_PREFIX}/approvals`, secrets: `${API_PREFIX}/secrets`, secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`, + secretProviderConfigDiscoveryPreview: `${API_PREFIX}/companies/:companyId/secret-provider-configs/discovery/preview`, costs: `${API_PREFIX}/costs`, activity: `${API_PREFIX}/activity`, dashboard: `${API_PREFIX}/dashboard`, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dcd92bcf..a851acf0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -558,6 +558,10 @@ export type { SecretProviderConfigPayload, SecretProviderConfigHealthDetails, SecretProviderConfigHealthResponse, + SecretProviderConfigDiscoveryCandidate, + SecretProviderConfigDiscoveryPreviewResult, + SecretProviderConfigDiscoverySample, + SecretProviderConfigDiscoverySignal, CompanySecretBinding, CompanySecretBindingTarget, CompanySecretUsageBinding, @@ -885,6 +889,7 @@ export { createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, + secretProviderConfigDiscoveryPreviewSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, remoteSecretImportSelectionSchema, @@ -911,6 +916,7 @@ export { type CreateSecret, type CreateSecretProviderConfig, type UpdateSecretProviderConfig, + type SecretProviderConfigDiscoveryPreview, type RemoteSecretImportPreview, type RemoteSecretImport, type RemoteSecretImportSelection, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a2354757..cfd7c74b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -258,6 +258,10 @@ export type { SecretProviderConfigPayload, SecretProviderConfigHealthDetails, SecretProviderConfigHealthResponse, + SecretProviderConfigDiscoveryCandidate, + SecretProviderConfigDiscoveryPreviewResult, + SecretProviderConfigDiscoverySample, + SecretProviderConfigDiscoverySignal, CompanySecretBinding, CompanySecretBindingTarget, CompanySecretUsageBinding, diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 7a4f0ae3..9c01ff40 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -138,6 +138,43 @@ export interface SecretProviderConfigHealthResponse { checkedAt: Date; } +export interface SecretProviderConfigDiscoverySignal { + namespace: string | null; + secretNamePrefix: string | null; + environmentTag: string | null; + ownerTag: string | null; + kmsKeyId: string | null; + hasKmsKey: boolean; + sampleCount: number; + paperclipManagedSampleCount: number; + skippedForeignPaperclipSampleCount: number; +} + +export interface SecretProviderConfigDiscoverySample { + name: string; + hasKmsKey: boolean; + tagKeys: string[]; +} + +export interface SecretProviderConfigDiscoveryCandidate { + provider: SecretProvider; + displayName: string; + config: SecretProviderConfigPayload; + sampleCount: number; + samples: SecretProviderConfigDiscoverySample[]; + signals: SecretProviderConfigDiscoverySignal; + warnings: string[]; +} + +export interface SecretProviderConfigDiscoveryPreviewResult { + provider: SecretProvider; + nextToken: string | null; + sampledSecretCount: number; + skippedForeignPaperclipSampleCount: number; + candidates: SecretProviderConfigDiscoveryCandidate[]; + warnings: string[]; +} + export interface CompanySecretVersion { id: string; secretId: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3380c553..e8410a01 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -293,6 +293,7 @@ export { createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, + secretProviderConfigDiscoveryPreviewSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, remoteSecretImportSelectionSchema, @@ -309,6 +310,7 @@ export { type CreateSecret, type CreateSecretProviderConfig, type UpdateSecretProviderConfig, + type SecretProviderConfigDiscoveryPreview, type RemoteSecretImportPreview, type RemoteSecretImport, type RemoteSecretImportSelection, diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index c8a8163d..10d81a9d 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -4,6 +4,7 @@ import { createSecretSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, + secretProviderConfigDiscoveryPreviewSchema, secretProviderConfigPayloadSchema, updateSecretProviderConfigSchema, } from "./secret.js"; @@ -140,6 +141,40 @@ describe("secret validators", () => { }); }); + it("validates AWS provider vault discovery draft config without allowing sensitive keys", () => { + expect( + secretProviderConfigDiscoveryPreviewSchema.parse({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + namespace: "production", + secretNamePrefix: "paperclip", + }, + query: "paperclip", + pageSize: 50, + }), + ).toEqual({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + namespace: "production", + secretNamePrefix: "paperclip", + }, + query: "paperclip", + pageSize: 50, + }); + + expect(() => + secretProviderConfigDiscoveryPreviewSchema.parse({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + accessKeyId: "AKIA...", + }, + }), + ).toThrow(/sensitive field/i); + }); + it("caps AWS remote import paging and row counts", () => { expect(() => remoteSecretImportPreviewSchema.parse({ diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index 1ee1952c..a90bf064 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -262,6 +262,30 @@ export const remoteSecretImportPreviewSchema = z.object({ export type RemoteSecretImportPreview = z.infer; +export const secretProviderConfigDiscoveryPreviewSchema = z.object({ + provider: z.enum(SECRET_PROVIDERS), + config: z.record(z.unknown()).default({}), + 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(), +}).superRefine((value, ctx) => { + rejectSensitiveProviderConfigKeys(value.config, ctx); + const parsed = secretProviderConfigPayloadSchema.safeParse({ + provider: value.provider, + config: value.config, + }); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + ...issue, + path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path], + }); + } + } +}); + +export type SecretProviderConfigDiscoveryPreview = 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(), diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index 488f3415..90fec295 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -454,6 +454,103 @@ describe("awsSecretsManagerProvider", () => { expect(JSON.stringify(listed)).not.toContain("team"); }); + it("discovers AWS provider vault prefill candidates from metadata without reading values", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + 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 provider vault discovery"); + }, + async deleteSecret() { + throw new Error("not used"); + }, + async listSecrets(input) { + calls.push({ op: "listSecrets", input }); + return { + NextToken: "next-page", + SecretList: [ + { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai", + Name: "paperclip/prod-use1/company-1/openai", + KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod", + Tags: [ + { Key: "paperclip:managed-by", Value: "paperclip" }, + { Key: "paperclip:deployment-id", Value: "prod-use1" }, + { Key: "paperclip:company-id", Value: "company-1" }, + { Key: "paperclip:environment", Value: "production" }, + { Key: "paperclip:provider-owner", Value: "platform" }, + ], + }, + { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/stripe", + Name: "paperclip/prod-use1/company-2/stripe", + Tags: [ + { Key: "paperclip:managed-by", Value: "paperclip" }, + { Key: "paperclip:company-id", Value: "company-2" }, + ], + }, + ], + }; + }, + }, + }); + + const preview = await provider.discoverProviderConfigs?.({ + companyId: "company-1", + providerConfig: { + id: "draft", + provider: "aws_secrets_manager", + status: "ready", + config: { region: "us-east-1" }, + }, + query: "paperclip", + pageSize: 25, + }); + + expect(calls).toEqual([ + { + op: "listSecrets", + input: { + MaxResults: 25, + NextToken: undefined, + IncludePlannedDeletion: false, + Filters: [{ Key: "all", Values: ["paperclip"] }], + }, + }, + ]); + expect(preview).toMatchObject({ + provider: "aws_secrets_manager", + nextToken: "next-page", + sampledSecretCount: 1, + skippedForeignPaperclipSampleCount: 1, + candidates: [ + expect.objectContaining({ + displayName: "AWS production", + config: expect.objectContaining({ + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod", + ownerTag: "platform", + environmentTag: "production", + }), + signals: expect.objectContaining({ + paperclipManagedSampleCount: 1, + skippedForeignPaperclipSampleCount: 1, + }), + }), + ], + }); + expect(JSON.stringify(preview)).not.toContain("SecretString"); + expect(JSON.stringify(preview)).not.toContain("company-2/stripe"); + }); + it("redacts AWS provider exception text when remote listing fails", async () => { const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai"; diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 86d4b7cb..33bdfc67 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -9,10 +9,12 @@ const mockSecretService = vi.hoisted(() => ({ listProviders: vi.fn(), checkProviders: vi.fn(), listProviderConfigs: vi.fn(), + previewProviderConfigDiscovery: vi.fn(), getProviderConfigById: vi.fn(), createProviderConfig: vi.fn(), updateProviderConfig: vi.fn(), disableProviderConfig: vi.fn(), + removeProviderConfig: vi.fn(), setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), getById: vi.fn(), @@ -117,6 +119,22 @@ describe("secret routes", () => { expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled(); }); + it("rejects provider vault discovery preview for non-board actors", async () => { + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + })) + .post("/api/companies/company-1/secret-provider-configs/discovery/preview") + .send({ + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + }); + + expect(res.status).toBe(403); + expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled(); + }); + it("rejects sensitive provider vault config fields", async () => { const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ provider: "aws_secrets_manager", @@ -132,6 +150,92 @@ describe("secret routes", () => { expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled(); }); + it("rejects sensitive provider vault discovery draft config fields", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/secret-provider-configs/discovery/preview") + .send({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + secretAccessKey: "secret", + }, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/sensitive field/i); + expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled(); + }); + + it("previews provider vault discovery and logs only aggregate metadata", async () => { + mockSecretService.previewProviderConfigDiscovery.mockResolvedValue({ + provider: "aws_secrets_manager", + nextToken: null, + sampledSecretCount: 2, + skippedForeignPaperclipSampleCount: 0, + candidates: [ + { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: null, + }, + sampleCount: 2, + samples: [ + { name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["environment"] }, + ], + signals: { + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: null, + hasKmsKey: false, + sampleCount: 2, + paperclipManagedSampleCount: 0, + skippedForeignPaperclipSampleCount: 0, + }, + warnings: [], + }, + ], + warnings: [], + }); + + const res = await request(createApp()) + .post("/api/companies/company-1/secret-provider-configs/discovery/preview") + .send({ + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + query: "paperclip", + pageSize: 25, + }); + + expect(res.status).toBe(200); + expect(mockSecretService.previewProviderConfigDiscovery).toHaveBeenCalledWith("company-1", { + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + query: "paperclip", + nextToken: undefined, + pageSize: 25, + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "secret_provider_config.discovery_previewed", + entityType: "secret_provider_config_discovery", + entityId: "company-1", + details: { + provider: "aws_secrets_manager", + candidateCount: 1, + sampledSecretCount: 2, + warningCount: 0, + }, + })); + expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("paperclip/prod-use1/company-1/openai"); + }); + it("rejects ready status for coming-soon provider vaults", async () => { const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ provider: "vault", @@ -241,6 +345,48 @@ describe("secret routes", () => { expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey"); }); + it("removes provider vault config locally without deleting remote provider data", async () => { + const createdAt = new Date("2026-05-06T00:00:00.000Z"); + const providerConfig = { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + isDefault: false, + config: { region: "us-east-1" }, + healthStatus: null, + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + disabledAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + createdAt, + updatedAt: createdAt, + }; + mockSecretService.getProviderConfigById.mockResolvedValue(providerConfig); + mockSecretService.removeProviderConfig.mockResolvedValue(providerConfig); + + const res = await request(createApp()).delete( + "/api/secret-provider-configs/11111111-1111-4111-8111-111111111111", + ); + + expect(res.status).toBe(200); + expect(mockSecretService.removeProviderConfig).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + ); + expect(mockSecretService.disableProviderConfig).not.toHaveBeenCalled(); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "secret_provider_config.removed", + details: { + provider: "aws_secrets_manager", + displayName: "AWS prod", + remoteDeleted: false, + }, + })); + }); + it("rejects remote import preview 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 d01b6777..c9513079 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -492,6 +492,35 @@ describeEmbeddedPostgres("secretService", () => { ); }); + it("removes provider vault config locally without deleting remote AWS secrets", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const vault = await svc.createProviderConfig(companyId, { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }); + const secret = await svc.create(companyId, { + name: `external-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: vault.id, + managedMode: "external_reference", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/external", + }); + const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); + + const removed = await svc.removeProviderConfig(vault.id); + + expect(removed?.id).toBe(vault.id); + await expect(svc.getProviderConfigById(vault.id)).resolves.toBeNull(); + const [persistedSecret] = await db + .select() + .from(companySecrets) + .where(eq(companySecrets.id, secret.id)); + expect(persistedSecret?.providerConfigId).toBeNull(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + it("hides soft-deleted secrets and allows name/key reuse", async () => { const companyId = await seedCompany(); const svc = secretService(db); @@ -1207,6 +1236,111 @@ describeEmbeddedPostgres("secretService", () => { expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws"); }); + it("previews AWS provider vault discovery from draft config without persisting a provider vault", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const discoverSpy = vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockResolvedValue({ + provider: "aws_secrets_manager", + nextToken: null, + sampledSecretCount: 1, + skippedForeignPaperclipSampleCount: 0, + candidates: [ + { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + kmsKeyId: null, + ownerTag: "platform", + environmentTag: "production", + }, + sampleCount: 1, + samples: [ + { name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["paperclip:environment"] }, + ], + signals: { + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: null, + hasKmsKey: false, + sampleCount: 1, + paperclipManagedSampleCount: 0, + skippedForeignPaperclipSampleCount: 0, + }, + warnings: [], + }, + ], + warnings: [], + }); + + const preview = await svc.previewProviderConfigDiscovery(companyId, { + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + query: "openai", + pageSize: 25, + }); + + expect(discoverSpy).toHaveBeenCalledWith({ + companyId, + providerConfig: { + id: `discovery-preview-${companyId}`, + provider: "aws_secrets_manager", + status: "ready", + config: { region: "us-east-1" }, + }, + query: "openai", + nextToken: undefined, + pageSize: 25, + }); + expect(preview.candidates[0]?.config).toMatchObject({ + region: "us-east-1", + namespace: "prod-use1", + }); + expect(JSON.stringify(preview)).not.toContain("runtime-secret"); + const persistedVaults = await db.select().from(companySecretProviderConfigs); + expect(persistedVaults).toHaveLength(0); + }); + + it("sanitizes AWS provider vault discovery errors before crossing the service boundary", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const rawProviderMessage = + "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets"; + + vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockRejectedValueOnce( + new SecretProviderClientError({ + code: "access_denied", + provider: "aws_secrets_manager", + operation: "discoverProviderConfigs", + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + rawMessage: rawProviderMessage, + }), + ); + + let thrown: unknown; + try { + await svc.previewProviderConfigDiscovery(companyId, { + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + status: 403, + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + details: { code: "access_denied" }, + }); + expect(JSON.stringify(thrown)).not.toContain("arn:aws"); + expect(JSON.stringify(thrown)).not.toContain("123456789012"); + expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws"); + }); + it("imports AWS remote references row-by-row without fetching plaintext", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index be9d503f..760d36c7 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -6,6 +6,7 @@ import { remoteSecretImportPreviewSchema, remoteSecretImportSchema, rotateSecretSchema, + secretProviderConfigDiscoveryPreviewSchema, updateSecretProviderConfigSchema, updateSecretSchema, } from "@paperclipai/shared"; @@ -41,6 +42,41 @@ export function secretRoutes(db: Db) { res.json(await svc.listProviderConfigs(companyId)); }); + router.post( + "/companies/:companyId/secret-provider-configs/discovery/preview", + validate(secretProviderConfigDiscoveryPreviewSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const preview = await svc.previewProviderConfigDiscovery(companyId, { + provider: req.body.provider, + config: req.body.config, + 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_provider_config.discovery_previewed", + entityType: "secret_provider_config_discovery", + entityId: companyId, + details: { + provider: preview.provider, + candidateCount: preview.candidates.length, + sampledSecretCount: preview.sampledSecretCount, + warningCount: preview.warnings.length, + }, + }); + + res.json(preview); + }, + ); + router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; @@ -136,27 +172,27 @@ export function secretRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); - const disabled = await svc.disableProviderConfig(id); - if (!disabled) { + const removed = await svc.removeProviderConfig(id); + if (!removed) { res.status(404).json({ error: "Provider vault not found" }); return; } await logActivity(db, { - companyId: disabled.companyId, + companyId: removed.companyId, actorType: "user", actorId: req.actor.userId ?? "board", - action: "secret_provider_config.disabled", + action: "secret_provider_config.removed", entityType: "secret_provider_config", - entityId: disabled.id, + entityId: removed.id, details: { - provider: disabled.provider, - displayName: disabled.displayName, - status: disabled.status, + provider: removed.provider, + displayName: removed.displayName, + remoteDeleted: false, }, }); - res.json(disabled); + res.json(removed); }); router.post("/secret-provider-configs/:id/default", async (req, res) => { diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts index 8c638594..a697556a 100644 --- a/server/src/secrets/aws-secrets-manager-provider.ts +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -1,6 +1,6 @@ import { createHash, createHmac } from "node:crypto"; import { S3Client } from "@aws-sdk/client-s3"; -import type { DeploymentMode } from "@paperclipai/shared"; +import type { DeploymentMode, SecretProviderConfigDiscoveryPreviewResult } from "@paperclipai/shared"; import { unprocessable } from "../errors.js"; import type { PreparedSecretVersion, @@ -24,6 +24,8 @@ const DEFAULT_DELETE_RECOVERY_WINDOW_DAYS = 30; const AWS_SECRETS_MANAGER_REQUEST_TIMEOUT_MS = 30_000; const AWS_CREDENTIAL_CACHE_TTL_MS = 5 * 60_000; const AWS_CREDENTIAL_EXPIRATION_SKEW_MS = 60_000; +const PROVIDER_CONFIG_DISCOVERY_SAMPLE_LIMIT = 3; +const PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT = 6; const AWS_RUNTIME_CREDENTIAL_WARNING = "AWS bootstrap credentials must be available to the Paperclip server runtime through the AWS SDK default credential provider chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials."; const AWS_CREDENTIAL_CUSTODY_WARNING = @@ -590,6 +592,234 @@ function createRemoteSecretMetadata(entry: AwsSecretsManagerListSecretEntry): Re }; } +function tagValue(tags: Map, keys: string[]) { + for (const key of keys) { + const value = tags.get(key.toLowerCase()); + if (value) return value; + } + return null; +} + +function normalizeAwsTags(tags: AwsSecretsManagerTag[] | undefined) { + const normalized = new Map(); + for (const tag of tags ?? []) { + const key = tag.Key?.trim(); + const value = tag.Value?.trim(); + if (key && value) normalized.set(key.toLowerCase(), value); + } + return normalized; +} + +function commonValue(values: Array) { + const nonEmpty = values.filter((value): value is string => Boolean(value?.trim())); + if (nonEmpty.length === 0) return null; + const first = nonEmpty[0]; + return nonEmpty.every((value) => value === first) ? first : null; +} + +function uniqueValues(values: Array) { + return [...new Set(values.filter((value): value is string => Boolean(value?.trim())))]; +} + +function pathSegments(name: string) { + return name.split("/").map((segment) => segment.trim()).filter(Boolean); +} + +function inferPathSignals(entry: AwsSecretsManagerListSecretEntry, tags: Map) { + const name = entry.Name?.trim() || entry.ARN?.trim() || ""; + const segments = pathSegments(name); + const paperclipDeploymentId = tagValue(tags, ["paperclip:deployment-id"]); + const paperclipManaged = tagValue(tags, ["paperclip:managed-by"])?.toLowerCase() === "paperclip"; + + if (paperclipDeploymentId || paperclipManaged) { + return { + prefix: segments[0] ?? DEFAULT_PREFIX, + namespace: paperclipDeploymentId ?? segments[1] ?? null, + }; + } + + if (segments.length >= 3) { + return { + prefix: segments[0] ?? null, + namespace: segments[1] ?? null, + }; + } + + return { + prefix: segments[0] ?? null, + namespace: null, + }; +} + +function discoveryDisplayName(input: { + environmentTag: string | null; + ownerTag: string | null; + namespace: string | null; + secretNamePrefix: string | null; +}) { + const qualifier = + input.environmentTag ?? + input.namespace ?? + input.secretNamePrefix ?? + input.ownerTag ?? + "discovered"; + return `AWS ${qualifier}`; +} + +function discoverAwsProviderConfigCandidates(input: { + companyId: string; + config: AwsSecretsManagerConfig; + draftConfig: Record; + entries: AwsSecretsManagerListSecretEntry[]; + nextToken: string | null; +}): SecretProviderConfigDiscoveryPreviewResult { + type DiscoverySample = { + entry: AwsSecretsManagerListSecretEntry; + name: string; + tags: Map; + prefix: string | null; + namespace: string | null; + environmentTag: string | null; + ownerTag: string | null; + kmsKeyId: string | null; + paperclipManaged: boolean; + paperclipCompanyId: string | null; + }; + + const skippedWarnings: string[] = []; + let skippedForeignPaperclipSampleCount = 0; + const samples: DiscoverySample[] = []; + + for (const entry of input.entries) { + const name = entry.Name?.trim() || entry.ARN?.trim(); + if (!name) continue; + const tags = normalizeAwsTags(entry.Tags); + const paperclipManaged = tagValue(tags, ["paperclip:managed-by"])?.toLowerCase() === "paperclip"; + const paperclipCompanyId = tagValue(tags, ["paperclip:company-id"]); + if (paperclipManaged && paperclipCompanyId !== input.companyId) { + skippedForeignPaperclipSampleCount += 1; + continue; + } + const path = inferPathSignals(entry, tags); + samples.push({ + entry, + name, + tags, + prefix: path.prefix, + namespace: path.namespace, + environmentTag: tagValue(tags, ["paperclip:environment", "environment", "env", "stage"]), + ownerTag: tagValue(tags, ["paperclip:provider-owner", "owner", "team", "service", "application"]), + kmsKeyId: asOptionalNonEmptyString(entry.KmsKeyId), + paperclipManaged, + paperclipCompanyId, + }); + } + + if (skippedForeignPaperclipSampleCount > 0) { + skippedWarnings.push( + `Skipped ${skippedForeignPaperclipSampleCount} Paperclip-managed AWS secret sample(s) that were not tagged for this company.`, + ); + } + + const draftNamespace = asOptionalNonEmptyString(input.draftConfig.namespace); + const draftPrefix = asOptionalNonEmptyString(input.draftConfig.secretNamePrefix); + const draftKmsKeyId = asOptionalNonEmptyString(input.draftConfig.kmsKeyId); + const draftEnvironmentTag = asOptionalNonEmptyString(input.draftConfig.environmentTag); + const draftOwnerTag = asOptionalNonEmptyString(input.draftConfig.ownerTag); + const groups = new Map(); + + for (const sample of samples) { + const key = [ + draftPrefix ?? sample.prefix ?? "", + draftNamespace ?? sample.namespace ?? "", + ].join("\0"); + groups.set(key, [...(groups.get(key) ?? []), sample]); + } + + const candidates = [...groups.values()] + .sort((a, b) => b.length - a.length) + .slice(0, PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT) + .map((group) => { + const prefix = draftPrefix ?? commonValue(group.map((sample) => sample.prefix)) ?? input.config.prefix; + const namespace = draftNamespace ?? commonValue(group.map((sample) => sample.namespace)) ?? null; + const environmentTag = draftEnvironmentTag ?? commonValue(group.map((sample) => sample.environmentTag)); + const ownerTag = draftOwnerTag ?? commonValue(group.map((sample) => sample.ownerTag)); + const kmsKeys = uniqueValues(group.map((sample) => sample.kmsKeyId)); + const commonKmsKey = commonValue(group.map((sample) => sample.kmsKeyId)); + const kmsKeyId = draftKmsKeyId ?? commonKmsKey; + const candidateWarnings: string[] = []; + + if (!namespace) { + candidateWarnings.push("No stable namespace signal was found in the sampled AWS secret names or tags."); + } + if (!environmentTag) { + candidateWarnings.push("No common environment tag was found in the sampled AWS secrets."); + } + if (!ownerTag) { + candidateWarnings.push("No common owner/team tag was found in the sampled AWS secrets."); + } + if (kmsKeys.length > 1 && !draftKmsKeyId) { + candidateWarnings.push("Sampled AWS secrets use multiple KMS keys; choose the intended KMS key before saving."); + } + if (group.some((sample) => sample.paperclipManaged && sample.paperclipCompanyId === input.companyId)) { + candidateWarnings.push("Sample includes Paperclip-managed secrets for this company; do not import them as external references."); + } + + return { + provider: "aws_secrets_manager" as const, + displayName: discoveryDisplayName({ + environmentTag, + ownerTag, + namespace, + secretNamePrefix: prefix, + }), + config: { + region: input.config.region, + namespace, + secretNamePrefix: prefix, + kmsKeyId: kmsKeyId ?? null, + ownerTag, + environmentTag, + }, + sampleCount: group.length, + samples: group.slice(0, PROVIDER_CONFIG_DISCOVERY_SAMPLE_LIMIT).map((sample) => ({ + name: sample.name, + hasKmsKey: Boolean(sample.kmsKeyId), + tagKeys: [...sample.tags.keys()].sort(), + })), + signals: { + namespace, + secretNamePrefix: prefix, + environmentTag, + ownerTag, + kmsKeyId: kmsKeyId ?? null, + hasKmsKey: kmsKeys.length > 0, + sampleCount: group.length, + paperclipManagedSampleCount: group.filter((sample) => sample.paperclipManaged).length, + skippedForeignPaperclipSampleCount, + }, + warnings: candidateWarnings, + }; + }); + + const warnings = [...skippedWarnings]; + if (samples.length === 0) { + warnings.push("AWS Secrets Manager returned no metadata samples for this draft provider vault config."); + } + if (groups.size > PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT) { + warnings.push("Additional AWS secret name groups were omitted from this preview; refine the query to inspect them."); + } + + return { + provider: "aws_secrets_manager", + nextToken: input.nextToken, + sampledSecretCount: samples.length, + skippedForeignPaperclipSampleCount, + candidates, + warnings, + }; +} + function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSecretsManagerMaterial { if ( value && @@ -983,6 +1213,36 @@ export function createAwsSecretsManagerProvider( normalizeAwsError("listSecrets", error); } }, + async discoverProviderConfigs(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) + : 100; + + 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 discoverAwsProviderConfigCandidates({ + companyId: input.companyId, + config, + draftConfig: input.providerConfig.config, + entries: listed.SecretList ?? [], + nextToken: listed.NextToken ?? null, + }); + } catch (error) { + normalizeAwsError("discoverProviderConfigs", 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 341163e6..9edf7766 100644 --- a/server/src/secrets/types.ts +++ b/server/src/secrets/types.ts @@ -1,4 +1,8 @@ -import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared"; +import type { + SecretProvider, + SecretProviderConfigDiscoveryPreviewResult, + SecretProviderDescriptor, +} from "@paperclipai/shared"; import type { DeploymentMode } from "@paperclipai/shared"; export interface StoredSecretVersionMaterial { @@ -152,6 +156,13 @@ export interface SecretProviderModule { nextToken?: string | null; pageSize?: number; }): Promise; + discoverProviderConfigs?(input: { + companyId: string; + providerConfig: SecretProviderVaultRuntimeConfig; + 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 ce9ee967..327b250e 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -20,6 +20,7 @@ import type { RemoteSecretImportCandidate, RemoteSecretImportConflict, RemoteSecretImportRowResult, + SecretProviderConfigDiscoveryPreviewResult, SecretBindingTargetType, SecretProvider, SecretProviderConfigHealthResponse, @@ -34,6 +35,7 @@ import { isUuidLike, normalizeAgentUrlKey, secretProviderConfigPayloadSchema, + secretProviderConfigDiscoveryPreviewSchema, updateSecretProviderConfigSchema, } from "@paperclipai/shared"; import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; @@ -471,6 +473,19 @@ export function secretService(db: Db) { return parsed.data.config; } + function toDraftProviderVaultRuntimeConfig(input: { + companyId: string; + provider: SecretProvider; + config: Record; + }): SecretProviderVaultRuntimeConfig { + return { + id: `discovery-preview-${input.companyId}`, + provider: input.provider, + status: "ready", + config: validateProviderConfigPayload(input.provider, input.config), + }; + } + function providerConfigHealth(input: { id: string; provider: SecretProvider; @@ -949,6 +964,54 @@ export function secretService(db: Db) { checkProviders: () => checkSecretProviders(), + previewProviderConfigDiscovery: async ( + companyId: string, + input: { + provider: SecretProvider; + config?: Record; + query?: string | null; + nextToken?: string | null; + pageSize?: number; + }, + ): Promise => { + const parsed = secretProviderConfigDiscoveryPreviewSchema.safeParse({ + provider: input.provider, + config: input.config ?? {}, + query: input.query, + nextToken: input.nextToken, + pageSize: input.pageSize, + }); + if (!parsed.success) { + throw unprocessable("Invalid provider vault discovery config", parsed.error.flatten()); + } + const providerId = parsed.data.provider as SecretProvider; + const provider = getSecretProvider(providerId); + if (!provider.discoverProviderConfigs) { + throw unprocessable(`${providerId} provider does not support provider vault discovery`); + } + const runtimeConfig = toDraftProviderVaultRuntimeConfig({ + companyId, + provider: providerId, + config: parsed.data.config, + }); + try { + return await provider.discoverProviderConfigs({ + companyId, + providerConfig: runtimeConfig, + query: parsed.data.query, + nextToken: parsed.data.nextToken, + pageSize: parsed.data.pageSize, + }); + } catch (error) { + throw remoteProviderHttpError(error, { + companyId, + provider: providerId, + providerConfigId: "discovery-preview", + operation: "secret_provider_config.discovery.preview", + }); + } + }, + listProviderConfigs: (companyId: string) => db .select() @@ -1071,6 +1134,13 @@ export function secretService(db: Db) { .then((rows) => rows[0] ?? null); }, + removeProviderConfig: async (id: string) => + db + .delete(companySecretProviderConfigs) + .where(eq(companySecretProviderConfigs.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + setDefaultProviderConfig: async (id: string) => { const existing = await getProviderConfigById(id); if (!existing) return null; diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index aedfa85d..f04705f3 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -2,6 +2,7 @@ import type { CompanySecret, CompanySecretUsageBinding, CompanySecretProviderConfig, + SecretProviderConfigDiscoveryPreviewResult, RemoteSecretImportPreviewResult, RemoteSecretImportResult, SecretAccessEvent, @@ -95,6 +96,14 @@ export interface RemoteImportInput { secrets: RemoteImportSelectionInput[]; } +export interface SecretProviderConfigDiscoveryPreviewInput { + provider: SecretProvider; + config?: Record; + query?: string | null; + nextToken?: string | null; + pageSize?: number; +} + export const secretsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/secrets`), providers: (companyId: string) => @@ -103,11 +112,21 @@ export const secretsApi = { api.get(`/companies/${companyId}/secret-providers/health`), providerConfigs: (companyId: string) => api.get(`/companies/${companyId}/secret-provider-configs`), + providerConfigDiscoveryPreview: ( + companyId: string, + data: SecretProviderConfigDiscoveryPreviewInput, + ) => + api.post( + `/companies/${companyId}/secret-provider-configs/discovery/preview`, + data, + ), createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) => api.post(`/companies/${companyId}/secret-provider-configs`, data), updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) => api.patch(`/secret-provider-configs/${id}`, data), disableProviderConfig: (id: string) => + api.patch(`/secret-provider-configs/${id}`, { status: "disabled" }), + removeProviderConfig: (id: string) => api.delete(`/secret-provider-configs/${id}`), setDefaultProviderConfig: (id: string) => api.post(`/secret-provider-configs/${id}/default`, {}), diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index a61ba7cd..8a274d89 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -4,18 +4,25 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared"; +import type { + CompanySecretProviderConfig, + SecretProviderConfigDiscoveryPreviewResult, + SecretProviderDescriptor, +} from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ProviderVaultsTab, Secrets } from "./Secrets"; +import { ApiError } from "../api/client"; const mockSecretsApi = vi.hoisted(() => ({ list: vi.fn(), providers: vi.fn(), providerHealth: vi.fn(), providerConfigs: vi.fn(), + providerConfigDiscoveryPreview: vi.fn(), createProviderConfig: vi.fn(), updateProviderConfig: vi.fn(), disableProviderConfig: vi.fn(), + removeProviderConfig: vi.fn(), setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), create: vi.fn(), @@ -133,6 +140,79 @@ async function flushReact() { }); } +function makeDiscoveryPreview( + overrides: Partial = {}, +): SecretProviderConfigDiscoveryPreviewResult { + return { + provider: "aws_secrets_manager", + nextToken: null, + sampledSecretCount: 2, + skippedForeignPaperclipSampleCount: 0, + warnings: [], + candidates: [ + { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + kmsKeyId: "alias/paperclip-secrets", + ownerTag: "platform", + environmentTag: "production", + }, + sampleCount: 2, + samples: [ + { + name: "paperclip/prod-use1/company-1/openai", + hasKmsKey: true, + tagKeys: ["owner", "environment"], + }, + ], + signals: { + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: "alias/paperclip-secrets", + hasKmsKey: true, + sampleCount: 2, + paperclipManagedSampleCount: 0, + skippedForeignPaperclipSampleCount: 0, + }, + warnings: [], + }, + ], + ...overrides, + }; +} + +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 })); +} + +async function openAwsVaultDialog() { + const vaultTabButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Provider vaults"), + ) as HTMLButtonElement | undefined; + await act(async () => { + vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + vaultTabButton?.click(); + }); + await flushReact(); + + const addVaultButtons = [...document.querySelectorAll("button")].filter( + (button) => button.textContent?.includes("Add vault"), + ) as HTMLButtonElement[]; + await act(async () => { + addVaultButtons[1]?.click(); + }); + await flushReact(); +} + describe("Secrets page layout", () => { let container: HTMLDivElement; @@ -153,6 +233,7 @@ describe("Secrets page layout", () => { ], }); mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs); + mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview()); }); afterEach(() => { @@ -200,6 +281,7 @@ describe("Secrets page layout", () => { onCreate={vi.fn()} onEdit={vi.fn()} onDisable={vi.fn()} + onRemove={vi.fn()} onSetDefault={vi.fn()} onHealthCheck={vi.fn()} pendingActionId={null} @@ -218,6 +300,64 @@ describe("Secrets page layout", () => { }); }); + it("warns that removing a provider vault only removes Paperclip config", async () => { + mockSecretsApi.removeProviderConfig.mockResolvedValueOnce(providerConfigs[1]); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const vaultTabButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Provider vaults"), + ) as HTMLButtonElement | undefined; + await act(async () => { + vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + vaultTabButton?.click(); + }); + await flushReact(); + + const removeButtons = [...document.querySelectorAll("button")].filter( + (button) => button.textContent?.trim() === "Remove", + ) as HTMLButtonElement[]; + await act(async () => { + removeButtons[1]?.click(); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Remove provider vault"); + expect(document.body.textContent).toContain("from Paperclip only"); + expect(document.body.textContent).toContain("does not delete"); + expect(document.body.textContent).toContain("AWS Secrets Manager"); + + const confirmButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Remove from Paperclip"), + ) as HTMLButtonElement | undefined; + await act(async () => { + confirmButton?.click(); + }); + await flushReact(); + + expect(mockSecretsApi.removeProviderConfig).toHaveBeenCalledWith("vault-aws"); + expect(mockSecretsApi.disableProviderConfig).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + it("opens reference details from the secrets table count", async () => { mockSecretsApi.list.mockResolvedValue([ { @@ -344,4 +484,173 @@ describe("Secrets page layout", () => { root.unmount(); }); }); + + it("discovers AWS provider vault candidates and applies selected values as prefill", async () => { + mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview()); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + await openAwsVaultDialog(); + + const discoveryButton = document.querySelector( + '[data-testid="aws-vault-discovery-button"]', + ) as HTMLButtonElement | null; + expect(discoveryButton).not.toBeNull(); + expect(discoveryButton?.disabled).toBe(true); + + const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement | null; + const prefixInput = document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement | null; + expect(regionInput).not.toBeNull(); + await act(async () => { + setInputValue(regionInput!, "us-east-1"); + setInputValue(prefixInput!, "paperclip"); + }); + await flushReact(); + + expect(discoveryButton?.disabled).toBe(false); + await act(async () => { + discoveryButton?.click(); + }); + await flushReact(); + await flushReact(); + + expect(mockSecretsApi.providerConfigDiscoveryPreview).toHaveBeenCalledWith("company-1", { + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + namespace: null, + secretNamePrefix: "paperclip", + kmsKeyId: null, + ownerTag: null, + environmentTag: null, + }, + query: "paperclip", + pageSize: 25, + }); + expect(document.body.textContent).toContain("AWS production"); + + const useValuesButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Use values"), + ) as HTMLButtonElement | undefined; + await act(async () => { + useValuesButton?.click(); + }); + await flushReact(); + + expect((document.getElementById("vault-name") as HTMLInputElement).value).toBe("AWS production"); + expect((document.getElementById("provider-vault-namespace") as HTMLInputElement).value).toBe("prod-use1"); + expect((document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement).value).toBe("paperclip"); + expect((document.getElementById("provider-vault-kms-key-id") as HTMLInputElement).value).toBe("alias/paperclip-secrets"); + expect((document.getElementById("provider-vault-owner-tag") as HTMLInputElement).value).toBe("platform"); + expect((document.getElementById("provider-vault-environment-tag") as HTMLInputElement).value).toBe("production"); + expect(mockSecretsApi.createProviderConfig).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows AWS discovery errors without replacing manual vault form values", async () => { + mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce( + new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, { + details: { code: "access_denied" }, + }), + ); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + await openAwsVaultDialog(); + + const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement; + const namespaceInput = document.getElementById("provider-vault-namespace") as HTMLInputElement; + await act(async () => { + setInputValue(regionInput, "us-west-2"); + setInputValue(namespaceInput, "manual-prod"); + }); + await flushReact(); + + const discoveryButton = document.querySelector( + '[data-testid="aws-vault-discovery-button"]', + ) as HTMLButtonElement | null; + await act(async () => { + discoveryButton?.click(); + }); + await flushReact(); + await flushReact(); + + expect(document.body.textContent).toContain("AWS Secrets Manager denied the request"); + expect(regionInput.value).toBe("us-west-2"); + expect(namespaceInput.value).toBe("manual-prod"); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows an empty AWS discovery result without blocking manual entry", async () => { + mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce( + makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }), + ); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + await openAwsVaultDialog(); + + const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement; + await act(async () => { + setInputValue(regionInput, "us-east-2"); + }); + await flushReact(); + await act(async () => { + (document.querySelector('[data-testid="aws-vault-discovery-button"]') as HTMLButtonElement | null)?.click(); + }); + await flushReact(); + await flushReact(); + + expect(document.body.textContent).toContain("No AWS vault metadata candidates found"); + expect(regionInput.value).toBe("us-east-2"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index fea13de9..985df8ce 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -29,6 +29,8 @@ import type { CompanySecret, CompanySecretUsageBinding, CompanySecretProviderConfig, + SecretProviderConfigDiscoveryCandidate, + SecretProviderConfigDiscoveryPreviewResult, SecretAccessEvent, SecretManagedMode, SecretProvider, @@ -325,6 +327,16 @@ function buildProviderVaultConfig(form: ProviderVaultForm): Record(null); const [vaultDialogOpen, setVaultDialogOpen] = useState(false); const [editingVault, setEditingVault] = useState(null); + const [removeVaultConfirm, setRemoveVaultConfirm] = useState(null); const [vaultForm, setVaultForm] = useState(() => emptyProviderVaultForm()); const [vaultError, setVaultError] = useState(null); + const [vaultDiscovery, setVaultDiscovery] = + useState(null); + const [vaultDiscoveryError, setVaultDiscoveryError] = useState(null); useEffect(() => { setBreadcrumbs([{ label: "Secrets" }]); @@ -648,6 +664,24 @@ export function Secrets() { }, }); + const discoverVaultMutation = useMutation({ + mutationFn: () => + secretsApi.providerConfigDiscoveryPreview(selectedCompanyId!, { + provider: "aws_secrets_manager", + config: buildProviderVaultConfig(vaultForm), + query: getAwsProviderVaultDiscoveryQuery(vaultForm), + pageSize: 25, + }), + onSuccess: (preview) => { + setVaultDiscovery(preview); + setVaultDiscoveryError(null); + }, + onError: (error) => { + setVaultDiscovery(null); + setVaultDiscoveryError(error instanceof ApiError ? error.message : (error as Error).message); + }, + }); + const disableVaultMutation = useMutation({ mutationFn: (id: string) => secretsApi.disableProviderConfig(id), onSuccess: (updated) => { @@ -663,6 +697,26 @@ export function Secrets() { }, }); + const removeVaultMutation = useMutation({ + mutationFn: (id: string) => secretsApi.removeProviderConfig(id), + onSuccess: (removed) => { + pushToast({ + title: "Provider vault removed", + body: `${removed.displayName} was removed from Paperclip only.`, + tone: "info", + }); + setRemoveVaultConfirm(null); + invalidateAll(); + }, + onError: (error) => { + pushToast({ + title: "Remove failed", + body: error instanceof Error ? error.message : "Try again", + tone: "error", + }); + }, + }); + const defaultVaultMutation = useMutation({ mutationFn: (id: string) => secretsApi.setDefaultProviderConfig(id), onSuccess: (updated) => { @@ -735,6 +789,8 @@ export function Secrets() { setEditingVault(null); setVaultForm(emptyProviderVaultForm(provider)); setVaultError(null); + setVaultDiscovery(null); + setVaultDiscoveryError(null); setVaultDialogOpen(true); } @@ -742,9 +798,26 @@ export function Secrets() { setEditingVault(config); setVaultForm(providerVaultFormFromConfig(config)); setVaultError(null); + setVaultDiscovery(null); + setVaultDiscoveryError(null); setVaultDialogOpen(true); } + function applyVaultDiscoveryCandidate(candidate: SecretProviderConfigDiscoveryCandidate) { + if (candidate.provider !== "aws_secrets_manager") return; + const config = candidate.config as Record; + setVaultForm((current) => ({ + ...current, + displayName: current.displayName.trim() ? current.displayName : candidate.displayName, + region: providerConfigValue(config, "region"), + namespace: providerConfigValue(config, "namespace"), + secretNamePrefix: providerConfigValue(config, "secretNamePrefix"), + kmsKeyId: providerConfigValue(config, "kmsKeyId"), + ownerTag: providerConfigValue(config, "ownerTag"), + environmentTag: providerConfigValue(config, "environmentTag"), + })); + } + if (!selectedCompanyId) { return (
Select a company to manage secrets.
@@ -923,10 +996,12 @@ export function Secrets() { onCreate={openCreateVault} onEdit={openEditVault} onDisable={(config) => disableVaultMutation.mutate(config.id)} + onRemove={(config) => setRemoveVaultConfirm(config)} onSetDefault={(config) => defaultVaultMutation.mutate(config.id)} onHealthCheck={(config) => healthVaultMutation.mutate(config.id)} pendingActionId={ disableVaultMutation.variables ?? + removeVaultMutation.variables ?? defaultVaultMutation.variables ?? healthVaultMutation.variables ?? null @@ -1305,6 +1380,8 @@ export function Secrets() { onChange={(event) => { const provider = event.target.value as SecretProvider; setVaultForm(emptyProviderVaultForm(provider)); + setVaultDiscovery(null); + setVaultDiscoveryError(null); }} > {PROVIDER_ORDER.map((provider) => ( @@ -1367,6 +1444,21 @@ export function Secrets() { + {!editingVault && vaultForm.provider === "aws_secrets_manager" ? ( + { + setVaultDiscovery(null); + setVaultDiscoveryError(null); + discoverVaultMutation.mutate(); + }} + onApply={applyVaultDiscoveryCandidate} + /> + ) : null} + {vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? (
This provider can save draft routing metadata, but runtime writes and resolution stay disabled until @@ -1510,6 +1602,32 @@ export function Secrets() { + + !open && setRemoveVaultConfirm(null)}> + + + Remove provider vault + + Removes {removeVaultConfirm?.displayName} from Paperclip only.{" "} + {removeVaultConfirm?.provider === "aws_secrets_manager" + ? "This does not delete the remote AWS Secrets Manager vault, secrets, or any AWS data." + : "This does not delete any remote provider data."}{" "} + Secrets using this vault will lose the vault association until you assign another one. + + + + + + + +
); } @@ -1748,6 +1866,7 @@ export function ProviderVaultsTab({ onCreate, onEdit, onDisable, + onRemove, onSetDefault, onHealthCheck, pendingActionId, @@ -1760,6 +1879,7 @@ export function ProviderVaultsTab({ onCreate: (provider: SecretProvider) => void; onEdit: (config: CompanySecretProviderConfig) => void; onDisable: (config: CompanySecretProviderConfig) => void; + onRemove: (config: CompanySecretProviderConfig) => void; onSetDefault: (config: CompanySecretProviderConfig) => void; onHealthCheck: (config: CompanySecretProviderConfig) => void; pendingActionId: string | null; @@ -1840,6 +1960,7 @@ export function ProviderVaultsTab({ pending={pendingActionId === config.id} onEdit={() => onEdit(config)} onDisable={() => onDisable(config)} + onRemove={() => onRemove(config)} onSetDefault={() => onSetDefault(config)} onHealthCheck={() => onHealthCheck(config)} /> @@ -1858,6 +1979,7 @@ function ProviderVaultCard({ pending, onEdit, onDisable, + onRemove, onSetDefault, onHealthCheck, }: { @@ -1865,6 +1987,7 @@ function ProviderVaultCard({ pending: boolean; onEdit: () => void; onDisable: () => void; + onRemove: () => void; onSetDefault: () => void; onHealthCheck: () => void; }) { @@ -1936,6 +2059,16 @@ function ProviderVaultCard({ Disable + ); @@ -2002,6 +2135,162 @@ function ProviderVaultFields({ ); } +function AwsProviderVaultDiscoveryPanel({ + form, + preview, + error, + loading, + onDiscover, + onApply, +}: { + form: ProviderVaultForm; + preview: SecretProviderConfigDiscoveryPreviewResult | null; + error: string | null; + loading: boolean; + onDiscover: () => void; + onApply: (candidate: SecretProviderConfigDiscoveryCandidate) => void; +}) { + const canDiscover = Boolean(form.region.trim()); + const warnings = preview?.warnings ?? []; + + return ( +
+
+
+

AWS discovery

+

+ Uses the current draft routing fields to inspect AWS Secrets Manager metadata. Values are not read. +

+
+ +
+ + {!canDiscover ? ( +

Enter an AWS region before discovery.

+ ) : null} + + {loading ? ( +
+ + Searching AWS Secrets Manager metadata +
+ ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + + {warnings.length > 0 ? ( +
+ {warnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+ ) : null} + + {preview && preview.candidates.length === 0 && !loading ? ( +
+ No AWS vault metadata candidates found. Manual entry is still available. +
+ ) : null} + + {preview && preview.candidates.length > 0 ? ( +
+
+ + + {preview.candidates.length} candidate{preview.candidates.length === 1 ? "" : "s"} from{" "} + {preview.sampledSecretCount} sampled secret{preview.sampledSecretCount === 1 ? "" : "s"} + +
+
+ {preview.candidates.map((candidate, index) => ( + onApply(candidate)} + /> + ))} +
+
+ ) : null} +
+ ); +} + +function AwsProviderVaultDiscoveryCandidateRow({ + candidate, + onApply, +}: { + candidate: SecretProviderConfigDiscoveryCandidate; + onApply: () => void; +}) { + const fieldSummary = [ + providerConfigValue(candidate.config, "region"), + providerConfigValue(candidate.config, "namespace"), + providerConfigValue(candidate.config, "secretNamePrefix"), + ].filter(Boolean); + + return ( +
+
+
+
+

{candidate.displayName}

+ + {candidate.sampleCount} sample{candidate.sampleCount === 1 ? "" : "s"} + +
+

+ {fieldSummary.length > 0 ? fieldSummary.join(" / ") : "No stable namespace or prefix detected"} +

+ {candidate.samples[0] ? ( +

+ {candidate.samples[0].name} +

+ ) : null} +
+ +
+ {candidate.warnings.length > 0 ? ( +
+ {candidate.warnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+ ) : null} +
+ ); +} + function TextField({ label, value, diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index df150978..011ec14b 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -22,6 +22,8 @@ import { storybookProjects, storybookSecretAccessEvents, storybookSecretBindings, + storybookSecretProviderConfigs, + storybookSecretProviderDiscoveryPreview, storybookSecretProviderHealth, storybookSecretProviders, storybookSecrets, @@ -187,6 +189,20 @@ function installStorybookApiFixtures() { return Response.json(storybookSecretProviderHealth); } + const secretProviderConfigsMatch = url.pathname.match( + /^\/api\/companies\/([^/]+)\/secret-provider-configs$/, + ); + if (secretProviderConfigsMatch) { + return Response.json(storybookSecretProviderConfigs); + } + + const secretProviderConfigDiscoveryPreviewMatch = url.pathname.match( + /^\/api\/companies\/([^/]+)\/secret-provider-configs\/discovery\/preview$/, + ); + if (secretProviderConfigDiscoveryPreviewMatch && init?.method?.toUpperCase() === "POST") { + return Response.json(storybookSecretProviderDiscoveryPreview); + } + const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/); if (secretUsageMatch) { const [, secretId] = secretUsageMatch; diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts index 0984f9d5..d798c9ee 100644 --- a/ui/storybook/fixtures/paperclipData.ts +++ b/ui/storybook/fixtures/paperclipData.ts @@ -7,6 +7,7 @@ import type { Company, CompanySecret, CompanySecretBinding, + CompanySecretProviderConfig, DashboardSummary, ExecutionWorkspace, Goal, @@ -15,6 +16,7 @@ import type { IssueLabel, Project, SecretAccessEvent, + SecretProviderConfigDiscoveryPreviewResult, SecretProviderDescriptor, SidebarBadges, WorkspaceRuntimeService, @@ -1324,6 +1326,98 @@ export const storybookSecretProviders: SecretProviderDescriptor[] = [ { id: "vault", label: "HashiCorp Vault", requiresExternalRef: false }, ]; +export const storybookSecretProviderConfigs: CompanySecretProviderConfig[] = [ + { + id: "provider-config-local", + companyId: "company-storybook", + provider: "local_encrypted", + displayName: "Local encrypted default", + status: "ready", + isDefault: true, + config: { backupReminderAcknowledged: true }, + healthStatus: "ready", + healthCheckedAt: recent(45), + healthMessage: "Local encrypted provider is healthy.", + healthDetails: null, + disabledAt: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdAt: recent(2_400), + updatedAt: recent(45), + }, + { + id: "provider-config-aws-prod", + companyId: "company-storybook", + provider: "aws_secrets_manager", + displayName: "AWS production", + status: "warning", + isDefault: false, + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + kmsKeyId: "alias/paperclip-secrets", + ownerTag: "platform", + environmentTag: "production", + }, + healthStatus: "warning", + healthCheckedAt: recent(18), + healthMessage: "Connected; KMS key rotation policy not yet enforced.", + healthDetails: { + code: "kms_rotation_policy", + message: "Connected; KMS key rotation policy not yet enforced.", + guidance: ["Enable automatic key rotation before using this vault for production agents."], + }, + disabledAt: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdAt: recent(1_800), + updatedAt: recent(18), + }, +]; + +export const storybookSecretProviderDiscoveryPreview: SecretProviderConfigDiscoveryPreviewResult = { + provider: "aws_secrets_manager", + nextToken: null, + sampledSecretCount: 6, + skippedForeignPaperclipSampleCount: 1, + warnings: ["Skipped 1 Paperclip-managed AWS secret from a different deployment namespace."], + candidates: [ + { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + kmsKeyId: "alias/paperclip-secrets", + ownerTag: "platform", + environmentTag: "production", + }, + sampleCount: 5, + samples: [ + { + name: "paperclip/prod-use1/company-storybook/openai_api_key", + hasKmsKey: true, + tagKeys: ["paperclip:managed-by", "paperclip:environment", "paperclip:provider-owner"], + }, + ], + signals: { + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: "alias/paperclip-secrets", + hasKmsKey: true, + sampleCount: 5, + paperclipManagedSampleCount: 5, + skippedForeignPaperclipSampleCount: 1, + }, + warnings: [], + }, + ], +}; + export const storybookSecrets: CompanySecret[] = [ { id: "secret-openai", diff --git a/ui/storybook/stories/secrets.stories.tsx b/ui/storybook/stories/secrets.stories.tsx index 5c3d09ea..0b787829 100644 --- a/ui/storybook/stories/secrets.stories.tsx +++ b/ui/storybook/stories/secrets.stories.tsx @@ -22,7 +22,6 @@ if (typeof window !== "undefined") { function StorybookSecretsFixtures({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); // Seed query caches synchronously so children hydrate from cache on first render. - queryClient.setQueryData(queryKeys.companies.all, storybookCompanies); queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets); const { selectedCompanyId, setSelectedCompanyId } = useCompany();