mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
fix(rbac): enforce operational permission parity (#1736)
This commit is contained in:
@@ -431,7 +431,7 @@ export function AppStoreView({ onDeploySuccess, headerActions }: AppStoreViewPro
|
||||
label: isDeploying ? 'Deploying…' : `Deploy ${selectedTemplate.title}`,
|
||||
icon: isDeploying ? Loader2 : Rocket,
|
||||
onClick: handleDeploy,
|
||||
disabled: isDeploying || !stackName.trim() || !can('stack:create'),
|
||||
disabled: isDeploying || !stackName.trim() || !can('stack:create') || !can('stack:deploy'),
|
||||
} : undefined}
|
||||
footerContext={isDeploying ? 'This may take a few minutes for large images.' : undefined}
|
||||
size="md"
|
||||
|
||||
@@ -443,7 +443,6 @@ export function EditorView(props: EditorViewProps) {
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
|
||||
@@ -147,7 +147,6 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
|
||||
@@ -222,7 +222,7 @@ export function ShellOverlays({
|
||||
<VulnerabilityScanSheet
|
||||
scanId={stackMisconfigScanId}
|
||||
onClose={() => setStackMisconfigScanId(null)}
|
||||
canManageSuppressions={isAdmin}
|
||||
canManageSuppressions={can('stack:edit')}
|
||||
/>
|
||||
|
||||
{/* Compose diff preview */}
|
||||
|
||||
@@ -39,7 +39,6 @@ function renderHeader(over: Partial<ComponentProps<typeof StackIdentityHeader>>
|
||||
safeContainers={CONTAINERS}
|
||||
isRunning
|
||||
can={() => true}
|
||||
isAdmin
|
||||
trivy={{ available: false }}
|
||||
backupInfo={{ exists: false, timestamp: null }}
|
||||
loadingAction={null}
|
||||
@@ -114,4 +113,15 @@ describe('StackIdentityHeader', () => {
|
||||
|
||||
expect(onOpenMonitor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows stack config scanning to a deployer without requiring Admin', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeader({
|
||||
trivy: { available: true },
|
||||
can: (action) => action === 'stack:deploy',
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'More actions' }));
|
||||
expect(screen.getByRole('menuitem', { name: 'Scan config' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,7 +127,6 @@ export interface StackIdentityHeaderProps {
|
||||
safeContainers: ContainerInfo[];
|
||||
isRunning: boolean;
|
||||
can: ReturnType<typeof useAuth>['can'];
|
||||
isAdmin: boolean;
|
||||
trivy: { available: boolean };
|
||||
backupInfo: { exists: boolean; timestamp: number | null };
|
||||
loadingAction: StackAction | null;
|
||||
@@ -156,7 +155,6 @@ export function StackIdentityHeader({
|
||||
safeContainers,
|
||||
isRunning,
|
||||
can,
|
||||
isAdmin,
|
||||
trivy,
|
||||
backupInfo,
|
||||
loadingAction,
|
||||
@@ -206,7 +204,7 @@ export function StackIdentityHeader({
|
||||
const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id);
|
||||
const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id);
|
||||
const canRollback = canDeploy && backupInfo.exists;
|
||||
const canScan = trivy.available && isAdmin;
|
||||
const canScan = trivy.available && canDeploy;
|
||||
const canMute = stackMuteActions?.canMute ?? false;
|
||||
const hasOverflowExtras = canRollback || canScan;
|
||||
const hasOverflow = hasOverflowExtras || canDelete || canMute || onOpenMonitor;
|
||||
|
||||
@@ -63,7 +63,9 @@ export function FleetView({
|
||||
onFleetActiveTabChange,
|
||||
}: FleetViewProps) {
|
||||
const { isPaid, licenseStatus } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canManageFleet = can('node:manage');
|
||||
const canExportDossier = can('node:read') && can('stack:read');
|
||||
const { hasCapability } = useNodes();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
const containerLabelsEnabled = hasCapability('container-label-inventory');
|
||||
@@ -240,7 +242,7 @@ export function FleetView({
|
||||
<TooltipContent>Refresh</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAdmin && (
|
||||
{canExportDossier && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -289,8 +291,8 @@ export function FleetView({
|
||||
onRetryUpdate={updateStatus.retryNodeUpdate}
|
||||
onDismissUpdate={updateStatus.dismissNodeUpdate}
|
||||
onCordonChange={() => { void overview.fetchOverview(true); }}
|
||||
onEditNode={isAdmin ? openEdit : undefined}
|
||||
onDeleteNode={isAdmin ? openDelete : undefined}
|
||||
onEditNode={openEdit}
|
||||
onDeleteNode={openDelete}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
|
||||
onCheckUpdates={updateStatus.checkUpdates}
|
||||
@@ -324,12 +326,18 @@ export function FleetView({
|
||||
{canDiscoverRouting && (
|
||||
<TabsContent value="routing">
|
||||
<PaidGate>
|
||||
<RoutingTab canManage={isAdmin} />
|
||||
<RoutingTab
|
||||
canManageNode={(nodeId) => can('node:manage', 'node', String(nodeId))}
|
||||
canManageMembership={isAdmin}
|
||||
/>
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="federation">
|
||||
<FederationTab canManage={isAdmin} />
|
||||
<FederationTab
|
||||
canManage={canManageFleet}
|
||||
canManageNode={(nodeId) => can('node:manage', 'node', String(nodeId))}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="actions">
|
||||
{/* Fleet Actions runs against the whole fleet, so it takes the
|
||||
|
||||
@@ -79,10 +79,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
const canManageNode = can('node:manage', 'node', String(node.id));
|
||||
const canEdit = Boolean(canManageNode && onEdit && registryNode);
|
||||
const canDelete = Boolean(canManageNode && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
// Cordon is permission-gated only (node:manage), matching the backend route guard.
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const canCordon = canManageNode;
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
node.name,
|
||||
|
||||
@@ -62,6 +62,20 @@ describe('NodeCard', () => {
|
||||
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
|
||||
});
|
||||
|
||||
it('shows edit and delete controls to a scoped node manager who is not an admin', async () => {
|
||||
const node = onlineNode();
|
||||
const registryNode = { id: 2, name: 'Edge', type: 'remote', is_default: false };
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
useNodesMock.mockReturnValue({ nodes: [registryNode, { id: 1, type: 'local' }], hasCapability: vi.fn(() => false) });
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn((action: string) => action === 'node:manage') });
|
||||
render(<NodeCard {...baseProps(node)} onEdit={onEdit} onDelete={onDelete} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
expect(await screen.findByText('Edit node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the cordon control from a user lacking node:manage', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface SenchoNavigateDetail {
|
||||
export function NodeManager() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canEditLabels = isAdmin;
|
||||
// Mirror the backend node:manage guard. This top-level flag checks the global
|
||||
// role only (admin or global node-admin); the per-row Test/Edit/Delete buttons
|
||||
// below additionally honor scoped per-node grants via can('node:manage', 'node', id).
|
||||
@@ -374,7 +373,7 @@ export function NodeManager() {
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(node.status)}</TableCell>
|
||||
<TableCell>
|
||||
<NodeLabelPicker nodeId={node.id} canEdit={canEditLabels} />
|
||||
<NodeLabelPicker nodeId={node.id} canEdit={can('node:manage', 'node', String(node.id))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
|
||||
@@ -367,7 +367,10 @@ interface ResourcesViewProps {
|
||||
export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images');
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canReadResources = can('stack:read');
|
||||
const canDeployResources = can('stack:deploy');
|
||||
const canEditSecurityPolicy = can('stack:edit');
|
||||
const { activeNode } = useNodes();
|
||||
const [usage, setUsage] = useState<UsageData | null>(null);
|
||||
const [images, setImages] = useState<DockerImage[]>([]);
|
||||
@@ -1076,7 +1079,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
<TooltipContent>Inspect image</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
{trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
<DropdownMenu>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -1214,7 +1217,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isAdmin && (
|
||||
{canReadResources && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1479,11 +1482,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
|
||||
canGenerateSbom={isAdmin}
|
||||
canExportSarif={isAdmin}
|
||||
onRescan={canDeployResources ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
|
||||
canGenerateSbom={canReadResources}
|
||||
canExportSarif={canReadResources}
|
||||
canCompare
|
||||
canManageSuppressions={isAdmin}
|
||||
canManageSuppressions={canEditSecurityPolicy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ const MOBILE_MASTHEAD_TONE: Record<MastheadTone, { dot: Tone; word: StateWordCla
|
||||
};
|
||||
|
||||
export function SecurityView({ activeTab, onTabChange, headerActions }: SecurityViewProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const isMobile = useIsMobile();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
@@ -104,7 +104,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
|
||||
// Scanner readiness gates the Images Actions column; an admin on a node whose
|
||||
// scanner is available can trigger scans inline.
|
||||
const canScan = isAdmin && !!overview?.scanner.available;
|
||||
const canScanImages = can('stack:deploy') && !!overview?.scanner.available;
|
||||
const canScanNode = can('node:manage') && !!overview?.scanner.available;
|
||||
const canReadSecurityExports = can('stack:read');
|
||||
const canEditSecurityPolicy = can('stack:edit');
|
||||
const { scanningRef, scanImage } = useImageScan({
|
||||
onComplete: (scanId) => onInspect(scanId, 'vulns'),
|
||||
onSummaries: setSummaries,
|
||||
@@ -264,7 +267,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
exploitTruncated={exploitTruncated}
|
||||
onNavigate={handleNavigate}
|
||||
onInspect={onInspect}
|
||||
canScan={canScan}
|
||||
canScan={canScanNode}
|
||||
onScanComplete={() => setReloadToken((t) => t + 1)}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -276,7 +279,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
loading={summariesLoading}
|
||||
error={summariesError}
|
||||
onInspect={onInspect}
|
||||
canScan={canScan}
|
||||
canScan={canScanImages}
|
||||
scanningRef={scanningRef}
|
||||
onScan={scanImage}
|
||||
initialFilter={imagesFilter ?? undefined}
|
||||
@@ -336,10 +339,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
scanId={inspectScanId}
|
||||
initialTab={inspectInitialTab}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
canGenerateSbom={isAdmin}
|
||||
canExportSarif={isAdmin}
|
||||
canGenerateSbom={canReadSecurityExports}
|
||||
canExportSarif={canReadSecurityExports}
|
||||
canCompare
|
||||
canManageSuppressions={isAdmin}
|
||||
canManageSuppressions={canEditSecurityPolicy}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => ({ isPaid: true }),
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
useAuth: () => ({ isAdmin: true, can: () => true }),
|
||||
}));
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ hasCapability: () => false }),
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => licenseState }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, can: () => true }) }));
|
||||
|
||||
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState }));
|
||||
|
||||
@@ -12,7 +12,9 @@ import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
interface BlueprintDeploymentTableProps {
|
||||
deployments: BlueprintDeployment[];
|
||||
classification: BlueprintClassification;
|
||||
canEdit: boolean;
|
||||
canDeploy: (nodeId: number) => boolean;
|
||||
canWithdraw: (nodeId: number) => boolean;
|
||||
canRetry: boolean;
|
||||
busyNodeId: number | null;
|
||||
onWithdraw: (nodeId: number) => void;
|
||||
onAcceptStateReview: (nodeId: number) => void;
|
||||
@@ -51,7 +53,7 @@ function statusDotClass(status: BlueprintDeploymentStatus): string {
|
||||
}
|
||||
|
||||
export function BlueprintDeploymentTable({
|
||||
deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
|
||||
deployments, classification, canDeploy, canWithdraw, canRetry, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
|
||||
}: BlueprintDeploymentTableProps) {
|
||||
const { nodes } = useNodes();
|
||||
const nodesById = new Map(nodes.map(n => [n.id, n]));
|
||||
@@ -122,7 +124,7 @@ export function BlueprintDeploymentTable({
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{dep.status === 'pending_state_review' && canEdit && (
|
||||
{dep.status === 'pending_state_review' && canDeploy(dep.node_id) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
@@ -138,7 +140,7 @@ export function BlueprintDeploymentTable({
|
||||
Resolve manually
|
||||
</span>
|
||||
)}
|
||||
{dep.status === 'failed' && canEdit && (
|
||||
{dep.status === 'failed' && canRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -148,7 +150,7 @@ export function BlueprintDeploymentTable({
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canEdit && (
|
||||
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canWithdraw(dep.node_id) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Render-gate coverage for BlueprintDetail's action bar.
|
||||
*
|
||||
* The Apply / Edit / Disable / Delete actions all hit admin-only routes
|
||||
* (e.g. POST /api/blueprints/:id/apply requires admin). This locks the UI gate:
|
||||
* an admin (canEdit) sees the action affordances; a non-admin viewer sees none
|
||||
* of them, so the sheet can never issue a request the API answers with 403.
|
||||
* Blueprint actions use distinct stack permissions. These tests lock the UI
|
||||
* gates so each role sees only actions accepted by the API.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
@@ -149,4 +147,24 @@ describe('BlueprintDetail action gating', () => {
|
||||
// The detail is still viewable: the compose source and deployment table render.
|
||||
expect(screen.getByTestId('deployment-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets a deployer apply without exposing edit or delete', async () => {
|
||||
const can = vi.fn((action: string) => action === 'stack:create' || action === 'stack:deploy');
|
||||
render(
|
||||
<BlueprintDetail
|
||||
blueprintId={1}
|
||||
open
|
||||
onOpenChange={noop}
|
||||
onChanged={noop}
|
||||
canEdit={false}
|
||||
can={can}
|
||||
distinctLabels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Show compose source')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /apply now/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^delete$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,9 @@ import { StateReviewDialog } from './StateReviewDialog';
|
||||
import { RolloutPreviewDialog } from './RolloutPreviewDialog';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
|
||||
type PermissionResolver = (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||
|
||||
interface BlueprintDetailProps {
|
||||
blueprintId: number;
|
||||
@@ -33,10 +36,11 @@ interface BlueprintDetailProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChanged: () => void;
|
||||
canEdit: boolean;
|
||||
can?: PermissionResolver;
|
||||
distinctLabels: string[];
|
||||
}
|
||||
|
||||
export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, canEdit, distinctLabels }: BlueprintDetailProps) {
|
||||
export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, canEdit, can, distinctLabels }: BlueprintDetailProps) {
|
||||
const [summary, setSummary] = useState<BlueprintSummary | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -78,6 +82,12 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
if (!open) return null;
|
||||
|
||||
const blueprint = summary?.blueprint;
|
||||
const canApply = !!blueprint && (can ? can('stack:create') && can('stack:deploy') : canEdit);
|
||||
const canDeleteBlueprint = !!blueprint && (can ? can('stack:delete') : canEdit);
|
||||
const canDeployOnNode = (nodeId: number) => !!blueprint
|
||||
&& (can ? can('stack:deploy', 'stack', blueprint.name, nodeId) : canEdit);
|
||||
const canWithdrawFromNode = (nodeId: number) => !!blueprint
|
||||
&& (can ? can('stack:delete', 'stack', blueprint.name, nodeId) : canEdit);
|
||||
|
||||
async function handleRolloutApplied() {
|
||||
await refresh();
|
||||
@@ -236,14 +246,14 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
crumb={['Blueprints', blueprint?.name ?? '…']}
|
||||
name={blueprint?.name ?? <Skeleton className="h-7 w-40 inline-block" />}
|
||||
meta={meta}
|
||||
primaryAction={blueprint && canEdit ? {
|
||||
primaryAction={canApply ? {
|
||||
label: 'Apply now',
|
||||
icon: Play,
|
||||
onClick: () => setPreviewOpen(true),
|
||||
disabled: submitting || !blueprint.enabled || editMode,
|
||||
} : undefined}
|
||||
secondaryActions={secondaryActions}
|
||||
destructiveAction={blueprint && canEdit ? {
|
||||
destructiveAction={canDeleteBlueprint ? {
|
||||
label: 'Delete',
|
||||
icon: Trash2,
|
||||
onClick: () => setDeleteOpen(true),
|
||||
@@ -294,7 +304,9 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
<BlueprintDeploymentTable
|
||||
deployments={summary.deployments}
|
||||
classification={blueprint.classification}
|
||||
canEdit={canEdit}
|
||||
canDeploy={canDeployOnNode}
|
||||
canWithdraw={canWithdrawFromNode}
|
||||
canRetry={canApply}
|
||||
busyNodeId={busyNodeId}
|
||||
onWithdraw={openWithdraw}
|
||||
onAcceptStateReview={openAcceptStateReview}
|
||||
|
||||
@@ -19,8 +19,9 @@ import { BlueprintEditor } from './BlueprintEditor';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export function DeploymentsTab() {
|
||||
const { isAdmin } = useAuth();
|
||||
const canEdit = isAdmin;
|
||||
const { can } = useAuth();
|
||||
const canCreate = can('stack:create');
|
||||
const canEdit = can('stack:edit');
|
||||
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
|
||||
const [distinctLabels, setDistinctLabels] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -100,7 +101,7 @@ export function DeploymentsTab() {
|
||||
<FleetTabHeading
|
||||
title="Blueprints"
|
||||
subtitle="Declare compose templates once and keep matching nodes in sync."
|
||||
action={canEdit ? (
|
||||
action={canCreate ? (
|
||||
<Button size="sm" className="gap-1.5" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
||||
New Blueprint
|
||||
@@ -108,7 +109,7 @@ export function DeploymentsTab() {
|
||||
) : undefined}
|
||||
/>
|
||||
<FleetEmptyState>
|
||||
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canEdit} />
|
||||
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canCreate} />
|
||||
</FleetEmptyState>
|
||||
</>
|
||||
) : (
|
||||
@@ -116,7 +117,7 @@ export function DeploymentsTab() {
|
||||
blueprints={blueprints}
|
||||
onSelect={setSelectedId}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
canCreate={canEdit}
|
||||
canCreate={canCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -127,6 +128,7 @@ export function DeploymentsTab() {
|
||||
onOpenChange={(o) => { if (!o) setSelectedId(null); }}
|
||||
onChanged={refresh}
|
||||
canEdit={canEdit}
|
||||
can={can}
|
||||
distinctLabels={distinctLabels}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Render-gate coverage for FederationTab's pin control.
|
||||
*
|
||||
* Pinning a blueprint to a node is admin-only on the backend
|
||||
* (PUT /api/blueprints/:id/pin requires admin). This test locks the matching UI
|
||||
* gate: an admin sees an editable Select, a non-admin sees the placement
|
||||
* Pinning a blueprint to a node is permission-gated on the backend. This test
|
||||
* locks the matching UI gate: a manager sees an editable Select, while a user
|
||||
* without permission sees the placement
|
||||
* read-only with an explanatory hint. Without this the affordance can drift
|
||||
* back to rendering an enabled control that the API rejects with 403.
|
||||
*/
|
||||
@@ -67,7 +67,7 @@ describe('FederationTab pin gating', () => {
|
||||
|
||||
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Pin changes require an administrator/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the pin placement read-only for a non-admin', async () => {
|
||||
@@ -75,9 +75,9 @@ describe('FederationTab pin gating', () => {
|
||||
|
||||
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Pin changes require an administrator/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/do not have permission to change pin placement/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('(unpinned)')).toBeInTheDocument();
|
||||
// The read-only branch must never be able to issue the admin-only pin request.
|
||||
// The read-only branch must never be able to issue the pin request.
|
||||
expect(vi.mocked(pinBlueprint)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -91,4 +91,12 @@ describe('FederationTab pin gating', () => {
|
||||
// "Effective" column, so getAllByText (not getByText) is required.
|
||||
expect(screen.getAllByText('node-alpha').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows pin controls for a scoped node manager', async () => {
|
||||
render(<FederationTab canManage={false} canManageNode={(nodeId) => nodeId === 1} />);
|
||||
|
||||
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,12 +26,12 @@ function formatTimestamp(ms: number | null): string {
|
||||
}
|
||||
|
||||
interface FederationTabProps {
|
||||
/** Whether the current user may change pin placement. Pinning is admin-only on the backend
|
||||
* (PUT /api/blueprints/:id/pin requires admin); non-admins see the placement read-only. */
|
||||
/** Whether the current user may change pin placement. */
|
||||
canManage: boolean;
|
||||
canManageNode?: (nodeId: number) => boolean;
|
||||
}
|
||||
|
||||
export function FederationTab({ canManage }: FederationTabProps) {
|
||||
export function FederationTab({ canManage, canManageNode }: FederationTabProps) {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -61,6 +61,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
for (const node of nodes) map.set(node.id, node.name);
|
||||
return map;
|
||||
}, [nodes]);
|
||||
const canManageAnyNode = canManage || nodes.some(node => canManageNode?.(node.id));
|
||||
|
||||
const handlePinChange = useCallback(async (blueprintId: number, value: string) => {
|
||||
const nodeId = value === UNPINNED ? null : Number.parseInt(value, 10);
|
||||
@@ -149,7 +150,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
<h3 className="text-sm font-medium">Pin policy</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Force a blueprint onto a specific node, overriding its selector.
|
||||
{!canManage && ' Pin changes require an administrator.'}
|
||||
{!canManageAnyNode && ' You do not have permission to change pin placement.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
@@ -188,7 +189,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
{describeSelector(bp.selector)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 align-top">
|
||||
{canManage ? (
|
||||
{canManageAnyNode ? (
|
||||
<Select
|
||||
value={bp.pinned_node_id !== null ? String(bp.pinned_node_id) : UNPINNED}
|
||||
onValueChange={(value) => void handlePinChange(bp.id, value)}
|
||||
@@ -198,9 +199,13 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
<SelectValue placeholder="(unpinned)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNPINNED}>(unpinned)</SelectItem>
|
||||
<SelectItem value={UNPINNED} disabled={!canManage}>(unpinned)</SelectItem>
|
||||
{nodes.map(node => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
value={String(node.id)}
|
||||
disabled={!canManage && !canManageNode?.(node.id)}
|
||||
>
|
||||
{node.name}
|
||||
{node.cordoned ? ' · cordoned' : ''}
|
||||
</SelectItem>
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
|
||||
import { BulkLabelAssignCard } from './cards/BulkLabelAssignCard';
|
||||
import { FleetPruneCard } from './cards/FleetPruneCard';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
}
|
||||
|
||||
export function FleetActionsTab({ nodes }: Props) {
|
||||
const { isAdmin } = useAuth();
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-stat-subtitle">Add a node to the fleet to use bulk actions.</div>
|
||||
@@ -22,7 +24,7 @@ export function FleetActionsTab({ nodes }: Props) {
|
||||
// Order: Prune top-left, Bulk top-right, Stop on row 2.
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
|
||||
<FleetPruneCard nodes={nodes} />
|
||||
{isAdmin && <FleetPruneCard nodes={nodes} />}
|
||||
<BulkLabelAssignCard nodes={nodes} />
|
||||
<LabelFleetStopCard />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => true }) }));
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { type Label, type LabelColor, LABEL_COLORS } from '@/components/label-types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string }
|
||||
interface AssignNodeResult {
|
||||
@@ -50,6 +51,7 @@ type PreviewNode = { nodeId: number; nodeName: string; willCreate: boolean; stac
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
|
||||
export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
const { can } = useAuth();
|
||||
const [nodeData, setNodeData] = useState<NodeData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<LabelTemplate | null>(null);
|
||||
@@ -144,6 +146,10 @@ export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
() => Array.from(selected.values()).filter(s => s.size > 0).length,
|
||||
[selected],
|
||||
);
|
||||
const canApplySelection = useMemo(() =>
|
||||
[...selected].every(([nodeId, stackNames]) =>
|
||||
[...stackNames].every(stackName => can('stack:edit', 'stack', stackName, nodeId))),
|
||||
[selected, can]);
|
||||
|
||||
function toggleStack(nodeId: number, stackName: string) {
|
||||
setSelected(prev => {
|
||||
@@ -180,7 +186,7 @@ export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!selectedTemplate || totalSelected === 0) return;
|
||||
if (!selectedTemplate || totalSelected === 0 || !canApplySelection) return;
|
||||
const targets = Array.from(selected.entries())
|
||||
.filter(([, set]) => set.size > 0)
|
||||
.map(([nodeId, set]) => ({ nodeId, stackNames: Array.from(set) }));
|
||||
@@ -282,7 +288,7 @@ export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
label: 'Apply',
|
||||
onClick: () => setConfirmOpen(true),
|
||||
variant: 'primary',
|
||||
disabled: running || !selectedTemplate || totalSelected === 0,
|
||||
disabled: running || !selectedTemplate || totalSelected === 0 || !canApplySelection,
|
||||
}}
|
||||
footerContext="Reversible · yes · reassign anytime"
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
fetchForNode: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => true }) }));
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean }
|
||||
interface FleetStopNodeResult {
|
||||
@@ -73,6 +74,7 @@ function isMatchPreviewResponse(value: unknown): value is MatchPreviewResponse {
|
||||
}
|
||||
|
||||
export function LabelFleetStopCard() {
|
||||
const { can } = useAuth();
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<FleetStopLabelSuggestion[]>([]);
|
||||
const [suggestUnreachable, setSuggestUnreachable] = useState(0);
|
||||
@@ -193,6 +195,8 @@ export function LabelFleetStopCard() {
|
||||
async function run(opts: { dryRun: boolean; targets?: { nodeId: number; stackNames: string[] }[] }) {
|
||||
const trimmed = labelName.trim();
|
||||
if (!trimmed) return;
|
||||
if (opts.targets?.some(target => target.stackNames.some(stackName =>
|
||||
!can('stack:deploy', 'stack', stackName, target.nodeId)))) return;
|
||||
const verb = opts.dryRun ? 'Dry-running' : 'Stopping';
|
||||
const toastId = toast.loading(`${verb} stacks with the stack label "${trimmed}" across the fleet…`);
|
||||
setRunning(true);
|
||||
@@ -303,7 +307,11 @@ export function LabelFleetStopCard() {
|
||||
// The real Stop is enabled only once the blast radius is resolved to at least
|
||||
// one stack, and never while a run is in flight. Loading/unavailable previews,
|
||||
// 0-match results, and an invalidated dry-run snapshot all leave it disabled.
|
||||
const canStopFleet = !running && resolvedTargets !== null && resolvedTargets.some(t => t.stackNames.length > 0);
|
||||
const canStopFleet = !running
|
||||
&& resolvedTargets !== null
|
||||
&& resolvedTargets.some(t => t.stackNames.length > 0)
|
||||
&& resolvedTargets.every(target => target.stackNames.every(stackName =>
|
||||
can('stack:deploy', 'stack', stackName, target.nodeId)));
|
||||
|
||||
const previewSection = renderPreviewSection(preview, trimmed);
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Render-gate coverage for MeshOptInSheet's opt-in/out controls.
|
||||
*
|
||||
* Opting a stack in or out is admin-only on the backend
|
||||
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-in|opt-out require admin). This
|
||||
* test locks the matching UI gate: a manager sees Add/Remove buttons, a
|
||||
* Opting a stack in or out is Admin-only because it cascades across mesh stacks.
|
||||
* This test locks the matching UI gate: an Admin sees Add/Remove buttons, while a user without
|
||||
* non-manager sees the membership read-only with a hint. The read-only branch
|
||||
* must never issue the admin-only mutation.
|
||||
* must never issue the mutation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
@@ -51,7 +50,7 @@ describe('MeshOptInSheet canManage gate', () => {
|
||||
expect(await screen.findByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Remove from mesh/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Add to mesh/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the membership read-only for a non-manager', async () => {
|
||||
@@ -59,7 +58,7 @@ describe('MeshOptInSheet canManage gate', () => {
|
||||
expect(await screen.findByText('web')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Add to mesh/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Changing mesh membership requires an administrator/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/changing mesh membership requires an administrator/i)).toBeInTheDocument();
|
||||
// The read-only branch must never issue an opt-in/opt-out request.
|
||||
expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('/opt-'),
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
onChanged: () => void;
|
||||
/** Opt-in/out is admin-only on the backend; non-admins see the list read-only. */
|
||||
/** Whether the user may start the mesh-wide membership cascade. */
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Render-gate coverage for the alias-detail Remove control and transport line.
|
||||
*
|
||||
* Removing a route opts its owning stack out of the mesh, an admin-only mutation
|
||||
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-out requires admin). This locks the
|
||||
* Removing a route opts its owning stack out of the mesh, an Admin-only cascade.
|
||||
* This locks the
|
||||
* matching UI gate: a manager sees "Remove from mesh", a non-manager does not,
|
||||
* while the read-only route detail stays available to both. It also pins the
|
||||
* transport line to the node's actual transport so a proxy peer never reports a
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alias: string | null;
|
||||
/** Removing a route opts its owning stack out of the mesh; admin-only, mirrors the backend gate. */
|
||||
/** Whether the user may start the mesh-wide opt-out cascade. */
|
||||
canManage: boolean;
|
||||
status: MeshNodeStatus[];
|
||||
aliases: MeshAlias[];
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
onTestUpstream: (alias: string) => Promise<void>;
|
||||
onChanged: () => void;
|
||||
canManage: boolean;
|
||||
canManageMembership?: boolean;
|
||||
}
|
||||
|
||||
const REVERSE_BRIDGE: Record<MeshNodeStatus['reverseCallbackStatus'], RoutingNodeCardMeta['reverseBridge']> = {
|
||||
@@ -60,7 +61,7 @@ function buildFooterContext(
|
||||
}
|
||||
|
||||
export function RoutingNodeCard({
|
||||
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage,
|
||||
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage, canManageMembership = canManage,
|
||||
}: Props) {
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [testingAlias, setTestingAlias] = useState<string | null>(null);
|
||||
@@ -200,6 +201,7 @@ export function RoutingNodeCard({
|
||||
footerContext={footerContext}
|
||||
offlineReason={status.reachableReason}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ function readStoredEdgeMode(): MeshGraphEdgeMode {
|
||||
}
|
||||
}
|
||||
|
||||
export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
export function RoutingTab({ canManageNode, canManageMembership }: {
|
||||
canManageNode: (nodeId: number) => boolean;
|
||||
canManageMembership: boolean;
|
||||
}) {
|
||||
const [status, setStatus] = useState<MeshNodeStatus[]>([]);
|
||||
const [localDataPlane, setLocalDataPlane] = useState<MeshDataPlaneStatus | null>(null);
|
||||
const [aliases, setAliases] = useState<MeshAlias[]>([]);
|
||||
@@ -177,13 +180,14 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
onShowAlias={(alias) => setRouteDetailAlias(alias)}
|
||||
onTestUpstream={testUpstream}
|
||||
onChanged={() => { void refresh(); }}
|
||||
canManage={canManage}
|
||||
canManage={canManageNode(s.nodeId)}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SheetsRoot
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
optInNode={optInNode} setOptInNode={setOptInNode}
|
||||
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
|
||||
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
|
||||
@@ -234,7 +238,8 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
onShowAlias={(alias) => setRouteDetailAlias(alias)}
|
||||
onTestUpstream={testUpstream}
|
||||
onChanged={() => { void refresh(); }}
|
||||
canManage={canManage}
|
||||
canManage={canManageNode(s.nodeId)}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -247,7 +252,7 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
/>
|
||||
)}
|
||||
<SheetsRoot
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
optInNode={optInNode} setOptInNode={setOptInNode}
|
||||
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
|
||||
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
|
||||
@@ -287,7 +292,7 @@ function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActi
|
||||
}
|
||||
|
||||
function SheetsRoot(props: {
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
optInNode: { id: number; name: string } | null;
|
||||
setOptInNode: (v: { id: number; name: string } | null) => void;
|
||||
diagnosticsNode: { id: number; name: string } | null;
|
||||
@@ -313,7 +318,7 @@ function SheetsRoot(props: {
|
||||
onOpenChange={(open) => { if (!open) props.setOptInNode(null); }}
|
||||
nodeId={optInNode.id}
|
||||
nodeName={optInNode.name}
|
||||
canManage={props.canManage}
|
||||
canManage={props.canManageMembership}
|
||||
onChanged={props.onChanged}
|
||||
/>
|
||||
)}
|
||||
@@ -328,7 +333,7 @@ function SheetsRoot(props: {
|
||||
open={!!props.routeDetailAlias}
|
||||
onOpenChange={(open) => { if (!open) props.setRouteDetailAlias(null); }}
|
||||
alias={props.routeDetailAlias}
|
||||
canManage={props.canManage}
|
||||
canManage={props.canManageMembership}
|
||||
status={props.status}
|
||||
aliases={props.aliases}
|
||||
onChanged={props.onChanged}
|
||||
|
||||
@@ -57,7 +57,8 @@ const EMPTY_FORM: PolicyFormState = {
|
||||
* mirroring how the rest of the fleet-governance UI behaves.
|
||||
*/
|
||||
export function ScanPolicyManager() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canManagePolicies = can('stack:edit');
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const { status: trivy, refresh: refreshTrivy } = useTrivyStatus();
|
||||
@@ -264,7 +265,7 @@ export function ScanPolicyManager() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Deploy enforcement policies</h3>
|
||||
{isAdmin && !isRemote && !isReplica && (
|
||||
{canManagePolicies && !isRemote && !isReplica && (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add policy
|
||||
@@ -387,7 +388,7 @@ export function ScanPolicyManager() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && !isReplica && (
|
||||
{canManagePolicies && !isReplica && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -28,7 +28,7 @@ function jsonResponse(status: number, body: unknown): Response {
|
||||
}
|
||||
|
||||
function setup() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true, can: () => true } as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({
|
||||
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, cveIntelEnabled: true, busy: false },
|
||||
|
||||
@@ -35,7 +35,8 @@ interface MisconfigAckPanelProps {
|
||||
}
|
||||
|
||||
export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const canManage = can('stack:edit');
|
||||
const [rows, setRows] = useState<MisconfigAcknowledgement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -179,7 +180,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
title="Misconfig acknowledgements"
|
||||
subtitle="Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time across the fleet and never modify stored scan data."
|
||||
action={
|
||||
isAdmin && !isReplica ? (
|
||||
canManage && !isReplica ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add acknowledgement
|
||||
@@ -255,7 +256,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
by {row.created_by} · expires {formatExpiry(row)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
|
||||
{canManage && !isReplica && row.replicated_from_control === 0 && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -52,7 +52,9 @@ interface SuppressionsPanelProps {
|
||||
}
|
||||
|
||||
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const canRead = can('stack:read');
|
||||
const canManage = can('stack:edit');
|
||||
const [rows, setRows] = useState<CveSuppression[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -228,16 +230,20 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
title="CVE suppressions"
|
||||
subtitle="Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across the fleet and never modify stored scan data."
|
||||
action={
|
||||
isAdmin && !isReplica ? (
|
||||
!isReplica && (canRead || canManage) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleExportVex}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
Export VEX
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add suppression
|
||||
</Button>
|
||||
{canRead && (
|
||||
<Button size="sm" variant="outline" onClick={handleExportVex}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
Export VEX
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add suppression
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -325,7 +331,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
by {row.created_by} - expires {formatExpiry(row)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
|
||||
{canManage && !isReplica && row.replicated_from_control === 0 && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
useAuth: () => ({ isAdmin: true, can: () => true }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
useAuth: () => ({ isAdmin: true, can: () => true }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface RoutingNodeCardProps {
|
||||
* management view it did not intend.
|
||||
*/
|
||||
canManage: boolean;
|
||||
/** Whether mesh membership actions may start their fleet-wide cascade. */
|
||||
canManageMembership?: boolean;
|
||||
}
|
||||
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
@@ -89,6 +91,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
|
||||
onAddStack, onRetry, footerContext, offlineReason,
|
||||
canManage,
|
||||
} = props;
|
||||
const canManageMembership = props.canManageMembership ?? canManage;
|
||||
const [density] = useDensity();
|
||||
const compact = density === 'compact';
|
||||
|
||||
@@ -120,6 +123,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
footerContext={footerContext}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onRetry}
|
||||
@@ -137,6 +141,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
aliases={aliases}
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
@@ -161,6 +166,7 @@ interface BodyChrome {
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
onShowDiagnostics: () => void;
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
footerContext: string;
|
||||
onAddStack?: () => void;
|
||||
onRetry?: () => void;
|
||||
@@ -179,7 +185,7 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
crumb, name, isLocal, chip, meta, nodeState, isEnabled,
|
||||
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
|
||||
aliases, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason,
|
||||
canManage,
|
||||
canManage, canManageMembership,
|
||||
} = props;
|
||||
const published = aliases.filter((a) => a.kind === 'alias').length;
|
||||
const showAliases = aliases.length > 0;
|
||||
@@ -228,7 +234,7 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
onAddStack={onAddStack}
|
||||
canManage={canManage}
|
||||
canManage={canManageMembership}
|
||||
/>
|
||||
: <EmptyState
|
||||
nodeState={nodeState}
|
||||
@@ -238,6 +244,7 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
onRetry={onRetry}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
@@ -250,7 +257,7 @@ function CompactBody(props: BodyChrome) {
|
||||
const {
|
||||
name, isLocal, chip, meta, nodeState, isEnabled,
|
||||
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
|
||||
footerContext, onAddStack, onRetry, canManage,
|
||||
footerContext, onAddStack, onRetry, canManage, canManageMembership,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -314,6 +321,7 @@ function CompactBody(props: BodyChrome) {
|
||||
onRetry={onRetry}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -478,14 +486,15 @@ interface EmptyStateProps {
|
||||
onRetry?: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
}
|
||||
|
||||
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage }: EmptyStateProps) {
|
||||
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: EmptyStateProps) {
|
||||
const { headline, sub, cta } = emptyStateCopy(nodeState, name, offlineReason);
|
||||
// The idle and meshed CTAs (enable mesh, add stack) are management actions
|
||||
// the backend gates on the admin role, so a non-admin viewer sees a hint
|
||||
// instead. The degraded/offline retry is a read-only refresh and stays.
|
||||
// Enabling a node uses node:manage. Adding a stack starts an Admin-only
|
||||
// mesh membership cascade. The degraded/offline retry stays available.
|
||||
const isManagementState = nodeState === 'idle' || nodeState === 'meshed';
|
||||
const canManageState = nodeState === 'meshed' ? canManageMembership : canManage;
|
||||
// `connecting` is transient while the mesh bridge dials; show the headline
|
||||
// only, with no retry button (it clears on its own once the bridge is up).
|
||||
const isConnecting = nodeState === 'connecting';
|
||||
@@ -507,7 +516,7 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
|
||||
</Button>
|
||||
);
|
||||
if (isConnecting) action = null;
|
||||
else if (!canManage && isManagementState) {
|
||||
else if (!canManageState && isManagementState) {
|
||||
action = (
|
||||
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
Managing the mesh requires an administrator.
|
||||
@@ -546,13 +555,12 @@ function ctaToneFor(state: RoutingNodeState): string {
|
||||
return CTA_TONE[state];
|
||||
}
|
||||
|
||||
// Management CTAs (enable mesh / add stack) need admin; the read-only retry on a
|
||||
// degraded/offline node stays available to everyone. `meshed` always offers "add
|
||||
// stack" to admins so a node with aliases is never a dead end, and the transient
|
||||
// `connecting` state shows no CTA.
|
||||
function shouldShowCta(state: RoutingNodeState, canManage: boolean): boolean {
|
||||
// Enabling mesh uses node management, while adding a stack starts an Admin-only
|
||||
// membership cascade. Read-only retry stays available to everyone.
|
||||
function shouldShowCta(state: RoutingNodeState, canManage: boolean, canManageMembership: boolean): boolean {
|
||||
if (state === 'connecting') return false;
|
||||
if (state === 'idle' || state === 'meshed') return canManage;
|
||||
if (state === 'idle') return canManage;
|
||||
if (state === 'meshed') return canManageMembership;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -615,10 +623,11 @@ interface CompactFooterProps {
|
||||
onRetry?: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
}
|
||||
|
||||
function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) {
|
||||
const showCta = shouldShowCta(nodeState, canManage);
|
||||
function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: CompactFooterProps) {
|
||||
const showCta = shouldShowCta(nodeState, canManage, canManageMembership);
|
||||
const { cta } = emptyStateCopy(nodeState, name);
|
||||
const handleClick = () => {
|
||||
if (nodeState === 'idle') onToggleEnabled(true);
|
||||
|
||||
Reference in New Issue
Block a user