diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index aedfa85dc03..b78921054e0 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,6 +112,14 @@ 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) => diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index a61ba7cd2db..39dd51b8e74 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -4,15 +4,21 @@ 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(), @@ -133,6 +139,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 +232,7 @@ describe("Secrets page layout", () => { ], }); mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs); + mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview()); }); afterEach(() => { @@ -344,4 +424,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 fea13de947c..e5cf34a2597 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 [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 +663,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) => { @@ -735,6 +768,8 @@ export function Secrets() { setEditingVault(null); setVaultForm(emptyProviderVaultForm(provider)); setVaultError(null); + setVaultDiscovery(null); + setVaultDiscoveryError(null); setVaultDialogOpen(true); } @@ -742,9 +777,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.
@@ -1305,6 +1357,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 +1421,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 @@ -2002,6 +2071,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,