Add guarded application delete (Phase 4, PAP-10817)

DELETE /tool-applications/:applicationId with board-only auth + company
check mirroring the PATCH route. Guards with 409 when the application
still has connections (no force-cascade in v1) and writes a
tool_application.deleted activity log entry. Adds deleteApplication to
the UI client and a destructive confirm dialog in the row actions menu
that surfaces the connection-guard guidance inline on 409.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-06-10 16:06:01 +00:00
1 parent a446a67850
commit 6f2490afbe
9 files changed
+582 -3

No files matched your search

+1
View File
@@ -562,6 +562,7 @@ export type ToolAccessReasonCode =
| "deny_default"
| "deny_company_boundary"
| "deny_disabled_connection"
| "deny_disabled_application"
| "deny_archived_application"
| "deny_missing_tool"
| "deny_policy_block"
@@ -238,6 +238,51 @@ describeEmbeddedPostgres("tool access policy service", () => {
});
});
it("denies calls through disabled applications before explicit grants and allows them after reactivation", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { application, connection, catalogEntry } = await createTool(db, company.id);
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "agent",
principalId: agent.id,
permissionKey: "tools:use",
scope: { toolName: "send_email" },
});
const input = {
companyId: company.id,
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
};
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
allowed: true,
decision: "allow",
reasonCode: "allow_explicit_grant",
});
await db
.update(toolApplications)
.set({ status: "disabled", updatedAt: new Date() })
.where(eq(toolApplications.id, application.id));
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
allowed: false,
decision: "deny",
reasonCode: "deny_disabled_application",
explanation: "Application is disabled.",
});
await db
.update(toolApplications)
.set({ status: "active", updatedAt: new Date() })
.where(eq(toolApplications.id, application.id));
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
allowed: true,
decision: "allow",
reasonCode: "allow_explicit_grant",
});
});
it("manages generic tool policies without exposing trust rules", async () => {
const company = await createCompany(db);
const otherCompany = await createCompany(db);
@@ -554,6 +554,91 @@ describeEmbeddedPostgres("tool access service", () => {
expect(missingRes.status).toBe(404);
});
it("deletes an application with zero connections and records activity", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const application = await service.createApplication(company.id, {
name: "Deletable app",
type: "mcp_http",
});
const res = await request(app).delete(`/api/tool-applications/${application.id}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: application.id, name: "Deletable app" });
const remaining = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, application.id));
expect(remaining).toHaveLength(0);
const activities = await db.select().from(activityLog).where(eq(activityLog.entityId, application.id));
expect(activities).toEqual([
expect.objectContaining({
action: "tool_application.deleted",
companyId: company.id,
details: expect.objectContaining({ name: "Deletable app", type: "mcp_http" }),
}),
]);
});
it("returns 409 and keeps the application when it still has connections", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const connection = await service.createConnection(company.id, {
name: "Guarded connection",
transport: "remote_http",
config: { url: "https://fixture.example/mcp" },
});
const res = await request(app).delete(`/api/tool-applications/${connection.applicationId}`);
expect(res.status).toBe(409);
expect(String(res.body.error)).toMatch(/connection/i);
const remaining = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, connection.applicationId));
expect(remaining).toHaveLength(1);
});
it("returns 403 for cross-company application deletes and 404 for missing applications", async () => {
const allowedCompany = await createCompany(db);
const otherCompany = await createCompany(db);
const application = await toolAccessService(db).createApplication(otherCompany.id, {
name: "Other company app",
type: "mcp_http",
});
const app = createRouteApp(db, {
type: "board",
userId: "member-user",
userName: "Member User",
userEmail: null,
companyIds: [allowedCompany.id],
memberships: [
{
companyId: allowedCompany.id,
membershipRole: "owner",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
});
const forbiddenRes = await request(app).delete(`/api/tool-applications/${application.id}`);
const missingRes = await request(createRouteApp(db)).delete(`/api/tool-applications/${randomUUID()}`);
expect(forbiddenRes.status).toBe(403);
expect(missingRes.status).toBe(404);
const stillThere = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, application.id));
expect(stillThere).toHaveLength(1);
});
it("links run tool decisions to invocations, audit events, and pending action requests", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
+17
View File
@@ -144,6 +144,23 @@ export function toolAccessRoutes(
}
});
router.delete("/tool-applications/:applicationId", async (req, res) => {
assertBoard(req);
const existing = await svc.getApplication(req.params.applicationId as string);
assertCompanyAccess(req, existing.companyId);
const application = await svc.deleteApplication(existing.id);
await logActivity(db, {
companyId: application.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_application.deleted",
entityType: "tool_application",
entityId: application.id,
details: { type: application.type, name: application.name },
});
res.json(application);
});
router.get("/companies/:companyId/tools/connections", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
@@ -546,6 +546,9 @@ export function toolAccessPolicyService(db: Db) {
if (!application || application.companyId !== input.companyId) {
return { ok: false, redaction, decision: decision("deny", "deny_company_boundary", "Application is outside the company.", [], [], { redactionPlan: redaction.redactionPlan }) };
}
if (application.status === "disabled") {
return { ok: false, redaction, decision: decision("deny", "deny_disabled_application", "Application is disabled.", [], [], { redactionPlan: redaction.redactionPlan }) };
}
if (application.status === "archived") {
return { ok: false, redaction, decision: decision("deny", "deny_archived_application", "Application is archived.", [], [], { redactionPlan: redaction.redactionPlan }) };
}
+20
View File
@@ -1886,6 +1886,26 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return toApplication(row);
},
deleteApplication: async (applicationId: string): Promise<ToolApplication> => {
const [existing] = await db.select().from(toolApplications).where(eq(toolApplications.id, applicationId));
if (!existing) throw notFound("Tool application not found");
// Guard: never orphan connections. The caller must remove the connections
// or archive the application instead — there is no force-cascade in v1.
const linkedConnections = await db
.select({ id: toolConnections.id })
.from(toolConnections)
.where(eq(toolConnections.applicationId, applicationId));
if (linkedConnections.length > 0) {
throw conflict(
"This application still has connections. Remove its connections or archive the application instead of deleting it.",
{ connectionCount: linkedConnections.length },
);
}
const [row] = await db.delete(toolApplications).where(eq(toolApplications.id, applicationId)).returning();
if (!row) throw notFound("Tool application not found");
return toApplication(row);
},
listConnections: async (companyId: string): Promise<ToolConnection[]> => {
const rows = await db
.select()
+2
View File
@@ -165,6 +165,8 @@ export const toolsApi = {
api.post<ToolApplication>(`/companies/${companyId}/tools/applications`, input),
updateApplication: (applicationId: string, input: UpdateToolApplicationInput) =>
api.patch<ToolApplication>(`/tool-applications/${applicationId}`, input),
deleteApplication: (applicationId: string) =>
api.delete<ToolApplication>(`/tool-applications/${applicationId}`),
// --- Connections ---
listConnections: (companyId: string) =>
+158
View File
@@ -19,6 +19,7 @@ const mockToolsApi = vi.hoisted(() => ({
createConnection: vi.fn(),
updateConnection: vi.fn(),
updateApplication: vi.fn(),
deleteApplication: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
@@ -330,4 +331,161 @@ describe("ApplicationsTab", () => {
expect(document.body.textContent ?? "").toContain("Another application already uses that name.");
});
it("confirms disabling an application with connection and catalog impact", async () => {
mockToolsApi.updateApplication.mockResolvedValue(makeApp({ status: "disabled" }));
await render();
const actions = Array.from(container.querySelectorAll("button")).find(
(button) => button.getAttribute("aria-label") === "Actions for GitHub",
);
expect(actions).toBeTruthy();
await act(() => {
actions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
actions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const disable = Array.from(document.body.querySelectorAll("[role='menuitem']")).find(
(item) => item.textContent === "Disable",
);
expect(disable).toBeTruthy();
await act(() => {
disable!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
disable!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const body = document.body.textContent ?? "";
expect(body).toContain("Disable application");
expect(body).toContain("Impact summary");
expect(body).toContain("1connection affected");
expect(body).toContain("2catalog tools affected");
const confirm = Array.from(document.body.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Disable application"),
);
expect(confirm).toBeTruthy();
await act(() => {
confirm!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockToolsApi.updateApplication).toHaveBeenCalledWith("app-1", { status: "disabled" });
});
it("reactivates disabled applications directly from the row actions menu", async () => {
mockToolsApi.listApplications.mockResolvedValue({
applications: [makeApp({ status: "disabled" }), makeApp({ id: "app-2", name: "Linear", description: null })],
});
mockToolsApi.updateApplication.mockResolvedValue(makeApp({ status: "active" }));
await render();
const actions = Array.from(container.querySelectorAll("button")).find(
(button) => button.getAttribute("aria-label") === "Actions for GitHub",
);
await act(() => {
actions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
actions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const reactivate = Array.from(document.body.querySelectorAll("[role='menuitem']")).find(
(item) => item.textContent === "Reactivate",
);
expect(reactivate).toBeTruthy();
await act(() => {
reactivate!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
reactivate!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockToolsApi.updateApplication).toHaveBeenCalledWith("app-1", { status: "active" });
});
it("deletes an application without connections through the row actions menu", async () => {
mockToolsApi.deleteApplication.mockResolvedValue(makeApp({ id: "app-2", name: "Linear", description: null }));
await render();
const actions = Array.from(container.querySelectorAll("button")).find(
(button) => button.getAttribute("aria-label") === "Actions for Linear",
);
expect(actions).toBeTruthy();
await act(() => {
actions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
actions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const del = Array.from(document.body.querySelectorAll("[role='menuitem']")).find(
(item) => item.textContent === "Delete",
);
expect(del).toBeTruthy();
await act(() => {
del!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
del!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const body = document.body.textContent ?? "";
expect(body).toContain("Delete application");
expect(body).toContain("No connections are attached");
const confirm = Array.from(document.body.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Delete application"),
);
expect(confirm).toBeTruthy();
await act(() => {
confirm!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockToolsApi.deleteApplication).toHaveBeenCalledWith("app-2");
});
it("surfaces the connection guard message inline when delete returns 409", async () => {
mockToolsApi.deleteApplication.mockRejectedValue(
new ApiError(
"This application still has connections. Remove its connections or archive the application instead of deleting it.",
409,
{ error: "conflict" },
),
);
await render();
const actions = Array.from(container.querySelectorAll("button")).find(
(button) => button.getAttribute("aria-label") === "Actions for GitHub",
);
await act(() => {
actions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
actions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const del = Array.from(document.body.querySelectorAll("[role='menuitem']")).find(
(item) => item.textContent === "Delete",
);
await act(() => {
del!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
del!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// GitHub (app-1) has a connection, so the dialog warns before we even submit.
expect(document.body.textContent ?? "").toContain("delete is blocked while connections exist");
const confirm = Array.from(document.body.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Delete application"),
);
await act(() => {
confirm!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(mockToolsApi.deleteApplication).toHaveBeenCalledWith("app-1");
expect(document.body.textContent ?? "").toContain(
"Remove its connections or archive the application instead",
);
});
});
+251 -3
View File
@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AppWindow,
Archive,
Boxes,
ChevronDown,
ChevronRight,
@@ -14,8 +15,10 @@ import {
Plus,
Power,
RefreshCw,
RotateCcw,
Stethoscope,
Terminal,
Trash2,
Upload,
type LucideIcon,
} from "lucide-react";
@@ -38,6 +41,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@@ -183,6 +187,117 @@ function EditApplicationDialog({ application, error, isSaving, onClose, onSubmit
);
}
type ApplicationLifecycleAction = {
application: ToolApplication;
status: "disabled" | "archived";
connectionCount: number;
toolCount: number;
};
function LifecycleConfirmDialog({
action,
isSaving,
onClose,
onConfirm,
}: {
action: ApplicationLifecycleAction;
isSaving: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
const verb = action.status === "archived" ? "Archive" : "Disable";
const lower = verb.toLowerCase();
const pendingLabel = action.status === "archived" ? "Archiving..." : "Disabling...";
const connectionLabel = action.connectionCount === 1 ? "connection" : "connections";
const toolLabel = action.toolCount === 1 ? "catalog tool" : "catalog tools";
return (
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{verb} application</DialogTitle>
<DialogDescription>
{lower === "archive" ? "Archive" : "Disable"} {action.application.name} for every connection attached to it.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border bg-muted/30 px-3 py-3 text-sm">
<div className="font-medium text-foreground">Impact summary</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-muted-foreground">
<div>
<div className="font-mono text-base text-foreground">{action.connectionCount}</div>
<div>{connectionLabel} affected</div>
</div>
<div>
<div className="font-mono text-base text-foreground">{action.toolCount}</div>
<div>{toolLabel} affected</div>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button type="button" variant={action.status === "archived" ? "destructive" : "default"} onClick={onConfirm} disabled={isSaving}>
{isSaving ? pendingLabel : `${verb} application`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DeleteApplicationDialog({
application,
connectionCount,
error,
isDeleting,
onClose,
onConfirm,
}: {
application: ToolApplication;
connectionCount: number;
error: string | null;
isDeleting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete application</DialogTitle>
<DialogDescription>
Permanently delete {application.name}. This cannot be undone.
</DialogDescription>
</DialogHeader>
{connectionCount > 0 ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
This application still has {connectionCount} {connectionCount === 1 ? "connection" : "connections"}. Remove
them or archive the application instead — delete is blocked while connections exist.
</div>
) : (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
No connections are attached, so this application can be deleted safely.
</div>
)}
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={onConfirm} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete application"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ApplicationsTab({ companyId }: { companyId: string }) {
const qc = useQueryClient();
const { pushToast } = useToast();
@@ -192,6 +307,9 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
const [catalogFor, setCatalogFor] = useState<ToolConnection | null>(null);
const [editingApplication, setEditingApplication] = useState<ToolApplication | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const [lifecycleAction, setLifecycleAction] = useState<ApplicationLifecycleAction | null>(null);
const [deletingApplication, setDeletingApplication] = useState<ToolApplication | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("__all");
@@ -253,13 +371,24 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
const invalidateApplications = () => qc.invalidateQueries({ queryKey: queryKeys.tools.applications(companyId) });
const updateApplication = useMutation({
mutationFn: ({ applicationId, input }: { applicationId: string; input: { name: string; description: string | null } }) =>
mutationFn: ({
applicationId,
input,
}: {
applicationId: string;
input: { name?: string; description?: string | null; status?: ToolApplication["status"] };
}) =>
toolsApi.updateApplication(applicationId, input),
onSuccess: () => {
onSuccess: (application, variables) => {
invalidateApplications();
setEditingApplication(null);
setLifecycleAction(null);
setEditError(null);
pushToast({ title: "Application updated", tone: "success" });
const status = variables.input.status;
pushToast({
title: status ? `Application ${status === "active" ? "reactivated" : status}` : "Application updated",
tone: "success",
});
},
onError: (err) => {
const message = err instanceof ApiError ? err.message : String(err);
@@ -276,6 +405,27 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
},
});
const deleteApplication = useMutation({
mutationFn: (applicationId: string) => toolsApi.deleteApplication(applicationId),
onSuccess: (application) => {
invalidateApplications();
invalidateConnections();
setDeletingApplication(null);
setDeleteError(null);
pushToast({ title: `Deleted ${application.name}`, tone: "success" });
},
onError: (err) => {
// The server returns 409 when the application still has connections; show
// the "remove connections or archive instead" guidance inline rather than
// as a transient toast.
const message = err instanceof ApiError ? err.message : String(err);
setDeleteError(message);
if (!(err instanceof ApiError && err.status === 409)) {
pushToast({ title: "Could not delete application", body: message, tone: "error" });
}
},
});
const healthCheck = useMutation({
mutationFn: (id: string) => toolsApi.checkConnectionHealth(id),
onSuccess: (res) => {
@@ -512,6 +662,71 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
{app.status !== "archived" ? (
<>
<DropdownMenuSeparator />
{app.status !== "disabled" ? (
<DropdownMenuItem
disabled={updateApplication.isPending}
onSelect={() =>
setLifecycleAction({
application: app,
status: "disabled",
connectionCount: connCountByApp.get(app.id) ?? 0,
toolCount: toolCountByApp.get(app.id) ?? 0,
})
}
>
<Power className="h-4 w-4" />
Disable
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={updateApplication.isPending}
variant="destructive"
onSelect={() =>
setLifecycleAction({
application: app,
status: "archived",
connectionCount: connCountByApp.get(app.id) ?? 0,
toolCount: toolCountByApp.get(app.id) ?? 0,
})
}
>
<Archive className="h-4 w-4" />
Archive
</DropdownMenuItem>
</>
) : null}
{app.status === "disabled" || app.status === "archived" ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateApplication.isPending}
onSelect={() =>
updateApplication.mutate({
applicationId: app.id,
input: { status: "active" },
})
}
>
<RotateCcw className="h-4 w-4" />
Reactivate
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={deleteApplication.isPending}
onSelect={() => {
setDeleteError(null);
setDeletingApplication(app);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
@@ -651,6 +866,39 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
}}
/>
) : null}
{lifecycleAction ? (
<LifecycleConfirmDialog
action={lifecycleAction}
isSaving={updateApplication.isPending}
onClose={() => {
if (updateApplication.isPending) return;
setLifecycleAction(null);
}}
onConfirm={() =>
updateApplication.mutate({
applicationId: lifecycleAction.application.id,
input: { status: lifecycleAction.status },
})
}
/>
) : null}
{deletingApplication ? (
<DeleteApplicationDialog
application={deletingApplication}
connectionCount={connCountByApp.get(deletingApplication.id) ?? 0}
error={deleteError}
isDeleting={deleteApplication.isPending}
onClose={() => {
if (deleteApplication.isPending) return;
setDeletingApplication(null);
setDeleteError(null);
}}
onConfirm={() => {
setDeleteError(null);
deleteApplication.mutate(deletingApplication.id);
}}
/>
) : null}
{open ? (
<AddConnectionDialog
companyId={companyId}