feat: preview governed policy impact

This commit is contained in:
2026-08-20 20:27:17 +02:00
parent be5e3a7d72
commit 861e9b8b8d
10 changed files with 1232 additions and 36 deletions
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const panel = readFileSync(resolve(webuiRoot, "src/features/policy/RetentionPoliciesPanel.tsx"), "utf8");
const viewPanel = readFileSync(resolve(webuiRoot, "src/features/policy/ViewPoliciesPanel.tsx"), "utf8");
assert.match(panel, /<RetentionPolicyScopeManager/, "Policy delegates effective-policy blockers and provenance to the shared Core contract");
assert.match(panel, /contextId: "policy\.retention"/, "retention exposes stable contextual documentation");
@@ -18,4 +19,11 @@ assert.match(panel, /<DataGrid/, "retention outcomes use the shared data-grid pa
assert.doesNotMatch(panel, /admin-json-preview/, "retention outcome is not presented as raw JSON");
assert.doesNotMatch(panel, /<pre/, "retention outcome is a typed projection");
assert.match(viewPanel, /policy\.impact-preview\.action\.preview/, "View policy exposes stable impact-preview help");
assert.match(viewPanel, /previewCurrent/, "View policy binds Save to the current dirty-draft preview");
assert.match(viewPanel, /updateViewPolicy\([\s\S]*impactPreview/, "View policy carries preview evidence into the commit request");
assert.match(viewPanel, /prepareResetPolicy/, "inherited-policy removal receives its own impact preview");
assert.match(viewPanel, /newly_allowed/, "View policy presents typed impact outcome counts");
assert.match(viewPanel, /populations\.map/, "View policy explains provider coverage state");
console.log("Policy interface-pattern contracts passed.");
+91 -4
View File
@@ -46,6 +46,46 @@ export type ViewPolicyReferenceData = {
surfaces: Array<{ id: string; label: string; module_id: string; kind: string }>;
};
export type PolicyImpactCategory = "newly_allowed" | "newly_denied" | "unchanged" | "indeterminate";
export type PolicyImpactPreviewResponse = {
preview_id: string;
proposal_hash: string;
policy_family: string;
scope_type: string;
scope_id?: string | null;
base_revision?: number | null;
counts: Record<PolicyImpactCategory, number>;
effects: Array<{
category: PolicyImpactCategory;
subject: {
module_id: string;
resource_type: string;
resource_id: string;
action: string;
label?: string | null;
scope_type?: string | null;
scope_id?: string | null;
};
current_allowed?: boolean | null;
proposed_allowed?: boolean | null;
rule: string;
current_sources: Array<{ path: string; label: string }>;
proposed_sources: Array<{ path: string; label: string }>;
explanation?: string | null;
}>;
populations: Array<{
provider_id: string;
state: "complete" | "sampled" | "truncated" | "unavailable";
returned: number;
total_available?: number | null;
explanation?: string | null;
}>;
details_hidden: boolean;
details_explanation?: string | null;
high_impact: boolean;
};
export function fetchViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
@@ -60,26 +100,73 @@ export function updateViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId: string | null | undefined,
policy: ViewPolicyItem
policy: ViewPolicyItem,
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}), {
method: "PUT",
body: JSON.stringify({ policy })
body: JSON.stringify({
policy,
impact_preview_id: impactPreview?.preview_id,
impact_proposal_hash: impactPreview?.proposal_hash
})
});
}
export function deleteViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId?: string | null
scopeId?: string | null,
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
scope_id: scopeId || undefined,
impact_preview_id: impactPreview?.preview_id,
impact_proposal_hash: impactPreview?.proposal_hash
}), { method: "DELETE" });
}
export function previewViewPolicyImpact(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId: string | null | undefined,
policy: ViewPolicyItem,
population: { viewIds: string[]; surfaceIds: string[] }
): Promise<PolicyImpactPreviewResponse> {
return apiFetch(settings, "/api/v1/admin/policy-impact/preview", {
method: "POST",
body: JSON.stringify({
policy_family: "view",
scope_type: scope,
scope_id: scopeId || null,
proposed_policy: policy,
populations: [
{
provider_id: "views",
selector: {
include_views: true,
include_surfaces: false,
view_ids: population.viewIds.slice(0, 500)
},
limit: 500
},
{
provider_id: "views",
selector: {
include_views: false,
include_surfaces: true,
surface_ids: population.surfaceIds.slice(0, 500)
},
limit: 500
}
],
include_details: true
})
});
}
export async function fetchViewPolicyReferences(settings: ApiSettings): Promise<ViewPolicyReferenceData> {
const [definitionResult, surfaceResult] = await Promise.allSettled([
apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>(
+133 -10
View File
@@ -19,14 +19,16 @@ import {
type ReferenceOption,
type SearchableSelectOption
} from "@govoplan/core-webui";
import { RefreshCw, Save, Trash2, Undo2 } from "lucide-react";
import { RefreshCw, Save, ScanSearch, Trash2, Undo2 } from "lucide-react";
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
import {
deleteViewPolicy,
fetchViewPolicy,
fetchViewPolicyReferences,
previewViewPolicyImpact,
updateViewPolicy,
type EffectiveViewPolicy,
type PolicyImpactPreviewResponse,
type ViewPolicyItem,
type ViewPolicyScope,
type ViewPolicyScopeResponse
@@ -91,6 +93,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [confirmReset, setConfirmReset] = useState(false);
const [impactPreview, setImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
const [previewDraftKey, setPreviewDraftKey] = useState("");
const [resetImpactPreview, setResetImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
const needsTarget = scopeType === "group" || scopeType === "user";
const parentViewIds = state?.parent_policy.allowed_view_ids;
@@ -109,6 +114,8 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
&& stablePolicy(buildPolicy(draft))
!== stablePolicy(buildPolicy(draftFromPolicy(state.policy)))
);
const draftKey = draft ? stablePolicy(buildPolicy(draft)) : "";
const previewCurrent = Boolean(impactPreview && previewDraftKey === draftKey);
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
@@ -165,6 +172,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
const loaded = await fetchViewPolicy(settings, scopeType, nextTargetId || null);
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setResetImpactPreview(null);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
@@ -180,19 +190,63 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
function discard() {
if (state) setDraft(draftFromPolicy(state.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setResetImpactPreview(null);
setError("");
setSuccess("");
}
async function save(): Promise<boolean> {
if (!draft || !state || !dirty) return true;
async function previewImpact() {
if (!draft || !state || !dirty) return;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await updateViewPolicy(settings, scopeType, targetId || null, buildPolicy(draft));
const policy = buildPolicy(draft);
const preview = await previewViewPolicyImpact(
settings,
scopeType,
targetId || null,
policy,
{
viewIds: viewOptions.map((option) => option.value),
surfaceIds: surfaceOptions.map((option) => option.value)
}
);
setImpactPreview(preview);
setPreviewDraftKey(stablePolicy(policy));
setSuccess("Policy impact preview completed without saving the draft.");
} catch (err) {
setImpactPreview(null);
setPreviewDraftKey("");
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
async function save(): Promise<boolean> {
if (!draft || !state || !dirty) return true;
if (!previewCurrent) {
setError("Preview the current policy draft before saving it.");
return false;
}
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await updateViewPolicy(
settings,
scopeType,
targetId || null,
buildPolicy(draft),
impactPreview
);
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setSuccess("View policy saved.");
return true;
} catch (err) {
@@ -203,14 +257,50 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
}
}
async function resetPolicy() {
async function prepareResetPolicy() {
if (!state?.id) return;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await deleteViewPolicy(settings, scopeType, targetId || null);
const preview = await previewViewPolicyImpact(
settings,
scopeType,
targetId || null,
{},
{
viewIds: viewOptions.map((option) => option.value),
surfaceIds: surfaceOptions.map((option) => option.value)
}
);
setResetImpactPreview(preview);
setConfirmReset(true);
setSuccess("Inherited-policy impact preview completed without removing the override.");
} catch (err) {
setResetImpactPreview(null);
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
async function resetPolicy() {
if (!resetImpactPreview) return;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await deleteViewPolicy(
settings,
scopeType,
targetId || null,
resetImpactPreview
);
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setResetImpactPreview(null);
setSuccess("Local View policy removed; inherited policy now applies.");
setConfirmReset(false);
} catch (err) {
@@ -236,8 +326,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
<RefreshCw size={16} />
</Button>
<Button onClick={discard} disabled={!dirty || busy}><Undo2 size={16} /> Discard</Button>
<Button onClick={() => setConfirmReset(true)} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}><Save size={16} /> {busy ? "Saving..." : "Save"}</Button>
<Button onClick={() => void prepareResetPolicy()} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
<Button helpContextId="policy.impact-preview.action.preview" onClick={() => void previewImpact()} disabled={!canWrite || !dirty || busy}><ScanSearch size={16} /> Preview impact</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy || !previewCurrent} disabledReason={dirty && !previewCurrent ? "Preview the current draft before saving." : undefined}><Save size={16} /> {busy ? "Working..." : "Save"}</Button>
<DocumentationHelpLink reference={DOCUMENTATION} label="Open View policy documentation" />
</>
}
@@ -341,6 +432,35 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
<DescriptionItem term={<>Policy path</>}>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}</DescriptionItem>
</DescriptionList>
</Card>
{impactPreview && (
<Card title="Policy impact preview">
<DescriptionList>
<DescriptionItem term={<>Preview</>}><code>{impactPreview.preview_id}</code></DescriptionItem>
<DescriptionItem term={<>Draft state</>}><StatusBadge status={previewCurrent ? "success" : "warning"} label={previewCurrent ? "Current" : "Outdated"} /></DescriptionItem>
<DescriptionItem term={<>Newly allowed</>}>{impactPreview.counts.newly_allowed}</DescriptionItem>
<DescriptionItem term={<>Newly denied</>}>{impactPreview.counts.newly_denied}</DescriptionItem>
<DescriptionItem term={<>Unchanged</>}>{impactPreview.counts.unchanged}</DescriptionItem>
<DescriptionItem term={<>Indeterminate</>}>{impactPreview.counts.indeterminate}</DescriptionItem>
<DescriptionItem term={<>Risk</>}><StatusBadge status={impactPreview.high_impact ? "warning" : "neutral"} label={impactPreview.high_impact ? "High impact - recent login required" : "Bounded change"} /></DescriptionItem>
<DescriptionItem term={<>Coverage</>}>{impactPreview.populations.map((population) => `${population.provider_id}: ${population.state} (${population.returned}${population.total_available == null ? "" : `/${population.total_available}`})${population.explanation ? ` - ${population.explanation}` : ""}`).join("; ")}</DescriptionItem>
{impactPreview.details_hidden && <DescriptionItem term={<>Details</>}>{impactPreview.details_explanation || "Subject details are hidden by policy."}</DescriptionItem>}
</DescriptionList>
{impactPreview.effects.length > 0 && (
<div help-context-id="policy.impact-preview.results">
<h4>Changed subjects</h4>
<ul>
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").slice(0, 20).map((effect) => (
<li key={`${effect.subject.module_id}:${effect.subject.resource_type}:${effect.subject.resource_id}:${effect.subject.action}`}>
<strong>{effect.category.replaceAll("_", " ")}</strong>: {effect.subject.label || effect.subject.resource_id} - {effect.subject.action} ({effect.rule})
</li>
))}
</ul>
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").length > 20 && <p>Only the first 20 changed subjects are shown; aggregate counts cover the complete returned population.</p>}
</div>
)}
</Card>
)}
</>
)}
</AdminPageLayout>
@@ -348,11 +468,14 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
<ConfirmDialog
open={confirmReset}
title="Use inherited View policy?"
message="The local override will be removed. All restrictions inherited from higher scopes continue to apply."
message={resetImpactPreview ? `The local override will be removed. The bounded preview found ${resetImpactPreview.counts.newly_allowed} newly allowed, ${resetImpactPreview.counts.newly_denied} newly denied, and ${resetImpactPreview.counts.indeterminate} indeterminate effects. All restrictions inherited from higher scopes continue to apply.` : "Previewing inherited-policy impact..."}
confirmLabel="Use inherited policy"
busy={busy}
onConfirm={() => void resetPolicy()}
onCancel={() => setConfirmReset(false)}
onCancel={() => {
setConfirmReset(false);
setResetImpactPreview(null);
}}
/>
</>
);