Add provider vault removal

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-19 14:25:33 -05:00
1 parent 9edac5ca20
commit 33b522aa10
7 files changed
+214 -9

No files matched your search

@@ -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",
@@ -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);
+9 -9
View File
@@ -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) => {
+7
View File
@@ -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;
+2
View File
@@ -125,6 +125,8 @@ export const secretsApi = {
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, data),
disableProviderConfig: (id: string) =>
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, { status: "disabled" }),
removeProviderConfig: (id: string) =>
api.delete<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`),
setDefaultProviderConfig: (id: string) =>
api.post<CompanySecretProviderConfig>(`/secret-provider-configs/${id}/default`, {}),
+60
View File
@@ -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(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
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([
{
+64
View File
@@ -384,6 +384,7 @@ export function Secrets() {
const [deleteConfirm, setDeleteConfirm] = useState<CompanySecret | null>(null);
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
const [removeVaultConfirm, setRemoveVaultConfirm] = useState<CompanySecretProviderConfig | null>(null);
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
const [vaultError, setVaultError] = useState<string | null>(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() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(removeVaultConfirm)} onOpenChange={(open) => !open && setRemoveVaultConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove provider vault</DialogTitle>
<DialogDescription>
Removes <strong>{removeVaultConfirm?.displayName}</strong> 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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveVaultConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => removeVaultConfirm && removeVaultMutation.mutate(removeVaultConfirm.id)}
disabled={removeVaultMutation.isPending}
>
{removeVaultMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
Remove from Paperclip
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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({
<Ban className="h-3.5 w-3.5 mr-1" />
Disable
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onRemove}
disabled={pending}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
</div>
</div>
);