mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add provider vault removal
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
03c30fdce0
commit
03976b8d41
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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`, {}),
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user