fix(nodes): gate node-management actions by role and release pilot tunnels on delete (#1280)

* fix(nodes): gate node-management actions by role and release pilot tunnels on delete

The node-management write actions in the Nodes panel (add, edit, delete,
generate node token, reset fleet-sync anchor) rendered for every signed-in role,
but the API enforces the manage-nodes permission on them, so lower-privilege
roles saw buttons that returned 403. The panel now renders each action against
the same permission its route enforces; the read-only node table stays visible
to every role.

Deleting a node now also tears down its live pilot-agent tunnel (and any mesh
bridge) immediately, releasing the loopback server, heartbeat timer, and open
streams instead of leaving them until the agent next disconnects, matching the
cleanup the re-enrollment path already performed.

Adds backend route tests for the permission boundaries and tunnel teardown, and
a Nodes panel render test covering the viewer, admin, and node-admin views.

* fix(nodes): close proxy mesh bridges via the dialer on node delete to skip a redial

Deleting a node closed any active mesh bridge through PilotTunnelManager, but a
proxy-mode bridge is owned by the mesh dialer, whose close listener then treated
the close as unexpected and scheduled a reactive redial against the node being
removed. The delete handler now closes a proxy bridge through the dialer's
intentional-close path first (which suppresses the redial), then closes a
pilot-agent tunnel as before. Adds a backend test that primes a live proxy
bridge and asserts deletion closes it without scheduling a redial.
This commit is contained in:
Anso
2026-06-02 09:51:42 -04:00
committed by GitHub
parent 2dd0660491
commit 35a1182890
5 changed files with 402 additions and 42 deletions
+65 -42
View File
@@ -35,8 +35,15 @@ export interface SenchoNavigateDetail {
export function NodeManager() {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { isAdmin, can } = useAuth();
const canEditLabels = isPaid && isAdmin;
// Mirror the backend node:manage guard. This top-level flag checks the global
// role only (admin or global node-admin); the per-row Edit/Delete buttons below
// additionally honor scoped per-node grants via can('node:manage', 'node', id).
// Admins resolve immediately via isAdmin; node-admins once /permissions/me lands.
// Generate-token and reset-anchor below stay admin-only to match their stricter
// backend guards (requireAdmin, and requireAdmin + requirePaid).
const canManageNodes = isAdmin || can('node:manage');
const { nodes, refreshNodeMeta } = useNodes();
useMastheadStats([
{ label: 'NODES', value: `${nodes.length}` },
@@ -189,21 +196,29 @@ export function NodeManager() {
return (
<div className="space-y-6">
{/* Actions */}
<div className="flex justify-end">
<SettingsPrimaryButton
size="sm"
className="gap-1 shrink-0"
onClick={openCreate}
>
<Plus className="w-4 h-4" />
Add node
</SettingsPrimaryButton>
</div>
{/* Actions (node management is admin / node-admin only, mirroring the
node:manage backend guard). The read-only table below stays visible to
every role with node:read. */}
{canManageNodes && (
<>
<div className="flex justify-end">
<SettingsPrimaryButton
size="sm"
className="gap-1 shrink-0"
onClick={openCreate}
>
<Plus className="w-4 h-4" />
Add node
</SettingsPrimaryButton>
</div>
<Separator />
<Separator />
</>
)}
{/* Generate Node Token - for use on THIS instance as a remote target */}
{/* Generate a node token so THIS instance can serve as a remote target.
Admin-only, matching the requireAdmin guard on /auth/generate-node-token. */}
{isAdmin && (
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
@@ -235,6 +250,7 @@ export function NodeManager() {
</div>
)}
</div>
)}
{/* Sync issues: surfaces FleetSync sticky errors (currently CONTROL_IDENTITY_MISMATCH). */}
{anchorMismatches.length > 0 && (
@@ -257,15 +273,17 @@ export function NodeManager() {
{' '}Reset the anchor on the peer to resume sync, or remove the node from this fleet.
</div>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
size="sm"
variant="destructive"
onClick={() => handleResetAnchor(nodeId)}
disabled={resettingAnchor === nodeId}
>
{resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'}
</Button>
{node && !node.is_default && (
{isAdmin && isPaid && (
<Button
size="sm"
variant="destructive"
onClick={() => handleResetAnchor(nodeId)}
disabled={resettingAnchor === nodeId}
>
{resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'}
</Button>
)}
{node && !node.is_default && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
<Button
size="sm"
variant="outline"
@@ -299,7 +317,9 @@ export function NodeManager() {
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => (
{nodes.map((node) => {
const canManageThis = isAdmin || can('node:manage', 'node', String(node.id));
return (
<TableRow key={node.id}>
<TableCell>
{node.is_default && (
@@ -451,24 +471,26 @@ export function NodeManager() {
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(node)}
aria-label="Edit node"
>
<Pencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit Node</TooltipContent>
</Tooltip>
</TooltipProvider>
{canManageThis && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(node)}
aria-label="Edit node"
>
<Pencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit Node</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!node.is_default && (
{!node.is_default && canManageThis && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -489,7 +511,8 @@ export function NodeManager() {
</div>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { Node } from '@/context/NodeContext';
// NodeManager pulls in several contexts and a child action hook. Mock them so
// the test renders the panel in isolation and can drive the role/permission
// inputs that gate the write affordances.
const useAuthMock = vi.fn();
const useLicenseMock = vi.fn();
const testNode: Node = {
id: 2,
name: 'Edge',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/app/compose',
is_default: false,
status: 'online',
created_at: 0,
api_url: '',
pilot_last_seen: Date.now(),
};
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ nodes: [testNode], refreshNodeMeta: vi.fn() }),
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));
vi.mock('@/hooks/useFleetSyncStatus', () => ({
useFleetSyncStatus: () => ({ statuses: [], refresh: vi.fn() }),
}));
vi.mock('../settings/MastheadStatsContext', () => ({ useMastheadStats: vi.fn() }));
vi.mock('../nodes/useNodeActions', () => ({
useNodeActions: () => ({
openCreate: vi.fn(),
openEdit: vi.fn(),
openDelete: vi.fn(),
NodeActionModals: null,
}),
}));
vi.mock('../blueprints/NodeLabelPicker', () => ({ NodeLabelPicker: () => null }));
import { NodeManager } from '../NodeManager';
/** can() that grants only the named action regardless of resource scope. */
function canFor(...granted: string[]) {
return (action: string) => granted.includes(action);
}
beforeEach(() => {
useLicenseMock.mockReturnValue({ isPaid: false });
});
afterEach(() => vi.clearAllMocks());
describe('NodeManager write-affordance gating', () => {
it('hides every write affordance from a viewer but still shows the node table', () => {
useAuthMock.mockReturnValue({ isAdmin: false, can: canFor() });
render(<NodeManager />);
// Read-only surface stays visible.
expect(screen.getByText('Edge')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add node/i })).not.toBeInTheDocument();
expect(screen.queryByText('Generate Node Token')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit node' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Delete node' })).not.toBeInTheDocument();
});
it('shows every affordance to an admin', () => {
useAuthMock.mockReturnValue({ isAdmin: true, can: canFor('node:manage') });
render(<NodeManager />);
expect(screen.getByRole('button', { name: /Add node/i })).toBeInTheDocument();
expect(screen.getByText('Generate Node Token')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Edit node' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete node' })).toBeInTheDocument();
});
it('lets a node-admin manage nodes but hides the admin-only token card', () => {
// node-admin: holds node:manage but is not a global admin.
useAuthMock.mockReturnValue({ isAdmin: false, can: canFor('node:manage') });
render(<NodeManager />);
expect(screen.getByRole('button', { name: /Add node/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Edit node' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete node' })).toBeInTheDocument();
// Generate Node Token mirrors requireAdmin, so a node-admin must not see it.
expect(screen.queryByText('Generate Node Token')).not.toBeInTheDocument();
});
});