From 03976b8d41975a192f237349ee88bad68b42eb52 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 18 May 2026 08:13:08 -0500 Subject: [PATCH] Add provider vault removal Co-Authored-By: Paperclip --- server/src/__tests__/secrets-routes.test.ts | 43 +++++++++++++ server/src/__tests__/secrets-service.test.ts | 29 +++++++++ server/src/routes/secrets.ts | 18 +++--- server/src/services/secrets.ts | 7 +++ ui/src/api/secrets.ts | 2 + ui/src/pages/Secrets.render.test.tsx | 60 ++++++++++++++++++ ui/src/pages/Secrets.tsx | 64 ++++++++++++++++++++ 7 files changed, 214 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 5ec31f021ab..33bdfc672a7 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -14,6 +14,7 @@ const mockSecretService = vi.hoisted(() => ({ createProviderConfig: vi.fn(), updateProviderConfig: vi.fn(), disableProviderConfig: vi.fn(), + removeProviderConfig: vi.fn(), setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), getById: vi.fn(), @@ -344,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 bfd0ec68669..c9513079f97 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); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 632600654eb..760d36c7cd5 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -172,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/services/secrets.ts b/server/src/services/secrets.ts index 0f05d922c61..327b250ef32 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1134,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 b78921054e0..f04705f3a8b 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -125,6 +125,8 @@ export const secretsApi = { 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 39dd51b8e74..8a274d894e2 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -22,6 +22,7 @@ const mockSecretsApi = vi.hoisted(() => ({ createProviderConfig: vi.fn(), updateProviderConfig: vi.fn(), disableProviderConfig: vi.fn(), + removeProviderConfig: vi.fn(), setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), create: vi.fn(), @@ -280,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} @@ -298,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([ { diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index e5cf34a2597..985df8ce111 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -384,6 +384,7 @@ export function Secrets() { const [deleteConfirm, setDeleteConfirm] = useState(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] = @@ -696,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) => { @@ -975,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 @@ -1579,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. + + + + + + + + ); } @@ -1817,6 +1866,7 @@ export function ProviderVaultsTab({ onCreate, onEdit, onDisable, + onRemove, onSetDefault, onHealthCheck, pendingActionId, @@ -1829,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; @@ -1909,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)} /> @@ -1927,6 +1979,7 @@ function ProviderVaultCard({ pending, onEdit, onDisable, + onRemove, onSetDefault, onHealthCheck, }: { @@ -1934,6 +1987,7 @@ function ProviderVaultCard({ pending: boolean; onEdit: () => void; onDisable: () => void; + onRemove: () => void; onSetDefault: () => void; onHealthCheck: () => void; }) { @@ -2005,6 +2059,16 @@ function ProviderVaultCard({ Disable + );