feat: preview governed policy impact
This commit is contained in:
@@ -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.");
|
||||
|
||||
@@ -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 }> }>(
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user