feat(permissions): expose network permission state (#103)

This commit is contained in:
2026-07-25 23:05:55 +02:00
parent e8731834f4
commit 3047dc9195
31 changed files with 2080 additions and 94 deletions

View File

@ -1,7 +1,13 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Panel, Spinner, cn } from "@/shared";
import type { PermissionSet, PermissionPosture } from "@/domain";
import type {
NetworkPolicy,
PermissionSet,
PermissionPosture,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
import {
type CapabilityChoice,
@ -42,6 +48,12 @@ const POSTURE_LABELS: Record<PermissionPosture, string> = {
deny: "Deny",
};
const NETWORK_LABELS: Record<NetworkPolicy, string> = {
allow: "Autorisé",
deny: "Interdit",
ask: "Demande",
};
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
const vm = usePermissions(projectId);
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
@ -51,6 +63,16 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
: null;
const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null;
const activeSystemSet =
target.type === "project"
? vm.systemDocument?.projectDefault ?? null
: selectedAgent?.systemOverride ??
vm.systemDocument?.projectDefault ??
null;
const activeResolvedSystem =
target.type === "project"
? vm.rows[0]?.resolvedSystem ?? null
: selectedAgent?.resolvedSystem ?? null;
const activeDraft = target.type === "project"
? vm.projectDraft
: draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null);
@ -75,6 +97,14 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
}
}
async function handleSaveNetwork(permissions: SystemPermissionSet | null) {
if (target.type === "project") {
await vm.saveProjectSystemPermissions(permissions);
} else {
await vm.saveAgentSystemPermissions(target.agentId, permissions);
}
}
return (
<Panel
title="Permissions"
@ -202,6 +232,24 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
onSave={(draft) => void handleSave(draft)}
onClear={() => void handleClear()}
/>
<NetworkPermissionEditor
key={`network-${target.type === "project" ? "project" : target.agentId}`}
title={
target.type === "project"
? "Réseau — defaults projet"
: `Réseau — ${target.agentName}`
}
source={activeSystemSet}
resolved={activeResolvedSystem}
inherited={
target.type === "agent" && selectedAgent?.systemOverride == null
? vm.systemDocument?.projectDefault ?? null
: null
}
busy={vm.busy}
onSave={(permissions) => void handleSaveNetwork(permissions)}
/>
</div>
<div
@ -267,6 +315,138 @@ function PolicyCard({
);
}
interface NetworkPermissionEditorProps {
title: string;
source: SystemPermissionSet | null;
inherited: SystemPermissionSet | null;
resolved: ResolvedAgentSystemPermissions | null;
busy: boolean;
onSave: (permissions: SystemPermissionSet | null) => void;
}
function NetworkPermissionEditor({
title,
source,
inherited,
resolved,
busy,
onSave,
}: NetworkPermissionEditorProps) {
const current = source?.network ?? "";
const [local, setLocal] = useState<NetworkPolicy | "">(current);
useEffect(() => {
setLocal(current);
}, [current]);
const readOnly = resolved?.control.mode === "readOnly";
const changed = local !== current;
const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
const effective = resolved?.effective ?? null;
const runtimeLocked = resolved?.runtimeLock.state === "locked";
const lockReason = resolved?.runtimeLock.reason ?? resolved?.control.reason ?? null;
return (
<section className="rounded-md border border-border bg-surface">
<header className="border-b border-border px-3 py-2.5">
<h4 className="text-sm font-semibold text-content">{title}</h4>
<p className="text-xs text-muted">
Politique voulue, état effectif et verrou runtime.
</p>
</header>
<div className="flex flex-col gap-3 p-3">
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
<StatusLine
label="Wanted"
value={wanted ? `Réseau ${NETWORK_LABELS[wanted].toLowerCase()}` : "Non configuré"}
/>
<StatusLine
label="Effective"
value={
effective
? `Réseau ${NETWORK_LABELS[effective].toLowerCase()}`
: "Indisponible"
}
/>
<StatusLine
label="Runtime"
value={runtimeLocked ? "Verrouillé par le runtime" : "Aucun verrou connu"}
/>
<StatusLine
label="Control"
value={readOnly ? "Lecture seule" : "Modifiable"}
/>
</div>
{readOnly && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{lockReason ?? "Le runtime actif ne permet pas de modifier cette permission."}
</p>
)}
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Réseau</span>
<select
aria-label={`${title} network`}
value={local}
disabled={busy || readOnly}
onChange={(e) => setLocal(e.target.value as NetworkPolicy | "")}
className={cn(
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content",
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value="">Non configuré</option>
<option value="allow">Réseau autorisé</option>
<option value="deny">Réseau interdit</option>
<option value="ask">Demande d&apos;autorisation</option>
</select>
</label>
{inherited?.network && !source?.network && (
<p className="text-xs text-muted">
Hérite du projet : Réseau {NETWORK_LABELS[inherited.network].toLowerCase()}.
</p>
)}
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="ghost"
aria-label="Clear network"
disabled={busy || readOnly || !source?.network}
onClick={() => onSave(null)}
>
Clear
</Button>
<Button
size="sm"
variant="primary"
aria-label="Save network"
disabled={busy || readOnly || !changed}
loading={busy && changed}
onClick={() => onSave(local ? { network: local } : null)}
>
Save
</Button>
</div>
</div>
</section>
);
}
function StatusLine({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border/70 bg-raised px-3 py-2">
<span className="block text-[11px] font-medium uppercase text-faint">
{label}
</span>
<span className="text-xs text-content">{value}</span>
</div>
);
}
interface PermissionEditorProps {
title: string;
draft: PolicyDraft;

View File

@ -86,4 +86,58 @@ describe("PermissionsPanel", () => {
expect(doc.agents).toEqual([]);
});
});
it("saves project network defaults in the separate system permissions document", async () => {
const { permission } = await renderPanel();
fireEvent.change(screen.getByLabelText("Réseau — defaults projet network"), {
target: { value: "allow" },
});
fireEvent.click(screen.getByRole("button", { name: "Save network" }));
await waitFor(async () => {
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
expect(doc.projectDefault?.network).toBe("allow");
});
expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0);
});
it("renders the network control read-only when the resolved runtime says so", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Builder",
profileId: "p1",
});
permission.setResolvedAgentSystemPermissions(created.id, {
wanted: "allow",
effective: "deny",
runtimeLock: {
state: "locked",
source: "external-runtime",
reason: "Runtime network is locked.",
},
control: { mode: "readOnly", reason: "Runtime network is locked." },
});
const gateways = {
agent,
permission,
profile: new MockProfileGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
</DIProvider>,
);
await waitFor(() => {
expect(screen.getByText("Verrouillé par le runtime")).toBeTruthy();
});
expect(screen.getByText("Runtime network is locked.")).toBeTruthy();
expect(
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
.disabled,
).toBe(true);
});
});

View File

@ -9,6 +9,9 @@ import type {
PermissionRule,
PermissionSet,
ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
export type CapabilityChoice = "none" | PermissionEffect;
@ -24,12 +27,15 @@ export interface PolicyDraft {
export interface AgentPermissionRow {
agent: Agent;
override: PermissionSet | null;
systemOverride: SystemPermissionSet | null;
resolvedSystem: ResolvedAgentSystemPermissions | null;
}
export interface PermissionsViewModel {
agents: Agent[];
rows: AgentPermissionRow[];
document: ProjectPermissions | null;
systemDocument: ProjectSystemPermissions | null;
projectDraft: PolicyDraft;
busy: boolean;
error: string | null;
@ -38,6 +44,13 @@ export interface PermissionsViewModel {
clearProjectDefaults: () => Promise<void>;
saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise<void>;
clearAgentOverride: (agentId: string) => Promise<void>;
saveProjectSystemPermissions: (
permissions: SystemPermissionSet | null,
) => Promise<void>;
saveAgentSystemPermissions: (
agentId: string,
permissions: SystemPermissionSet | null,
) => Promise<void>;
}
const DEFAULT_DRAFT: PolicyDraft = {
@ -114,6 +127,11 @@ export function usePermissions(projectId: string): PermissionsViewModel {
const { agent, permission } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [document, setDocument] = useState<ProjectPermissions | null>(null);
const [systemDocument, setSystemDocument] =
useState<ProjectSystemPermissions | null>(null);
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
Record<string, ResolvedAgentSystemPermissions>
>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -121,12 +139,34 @@ export function usePermissions(projectId: string): PermissionsViewModel {
setBusy(true);
setError(null);
try {
const [agentList, permissionDoc] = await Promise.all([
const [agentList, permissionDoc, systemPermissionDoc] = await Promise.all([
agent.listAgents(projectId),
permission.getProjectPermissions(projectId),
permission.getProjectSystemPermissions(projectId),
]);
const resolvedPairs = await Promise.all(
agentList.map(async (candidate) => {
try {
return [
candidate.id,
await permission.resolveAgentSystemPermissions(projectId, candidate.id),
] as const;
} catch {
return [candidate.id, null] as const;
}
}),
);
setAgents(agentList);
setDocument(permissionDoc);
setSystemDocument(systemPermissionDoc);
setResolvedSystemByAgent(
Object.fromEntries(
resolvedPairs.filter(
(pair): pair is readonly [string, ResolvedAgentSystemPermissions] =>
pair[1] !== null,
),
),
);
} catch (e) {
setError(describe(e));
} finally {
@ -142,11 +182,19 @@ export function usePermissions(projectId: string): PermissionsViewModel {
const overrides = new Map(
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
);
const systemOverrides = new Map(
(systemDocument?.agents ?? []).map((entry) => [
entry.agentId,
entry.permissions,
]),
);
return agents.map((candidate) => ({
agent: candidate,
override: overrides.get(candidate.id) ?? null,
systemOverride: systemOverrides.get(candidate.id) ?? null,
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
}));
}, [agents, document]);
}, [agents, document, systemDocument, resolvedSystemByAgent]);
const projectDraft = useMemo(
() => draftFromSet(document?.projectDefaults ?? null),
@ -220,10 +268,52 @@ export function usePermissions(projectId: string): PermissionsViewModel {
[permission, projectId],
);
const saveProjectSystemPermissions = useCallback(
async (permissionsToSave: SystemPermissionSet | null) => {
setBusy(true);
setError(null);
try {
const next = await permission.updateProjectSystemPermissions(
projectId,
permissionsToSave,
);
setSystemDocument(next);
await refresh();
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[permission, projectId, refresh],
);
const saveAgentSystemPermissions = useCallback(
async (agentId: string, permissionsToSave: SystemPermissionSet | null) => {
setBusy(true);
setError(null);
try {
const next = await permission.updateAgentSystemPermissions(
projectId,
agentId,
permissionsToSave,
);
setSystemDocument(next);
await refresh();
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[permission, projectId, refresh],
);
return {
agents,
rows,
document,
systemDocument,
projectDraft,
busy,
error,
@ -232,5 +322,7 @@ export function usePermissions(projectId: string): PermissionsViewModel {
clearProjectDefaults,
saveAgentOverride,
clearAgentOverride,
saveProjectSystemPermissions,
saveAgentSystemPermissions,
};
}