Migrate Policy retention interfaces
This commit is contained in:
@@ -44,3 +44,8 @@ closed. The shared privacy-retention run calls the optional
|
|||||||
`reporting.retention` capability to clear expired provider-report payloads
|
`reporting.retention` capability to clear expired provider-report payloads
|
||||||
without importing Reporting models, while Reporting keeps hashes and bounded
|
without importing Reporting models, while Reporting keeps hashes and bounded
|
||||||
provenance as audit evidence.
|
provenance as audit evidence.
|
||||||
|
|
||||||
|
The retention administration interface follows the platform pattern language
|
||||||
|
documented in [docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
It uses Core's effective-policy editor and renders system retention runs as
|
||||||
|
typed outcome evidence rather than raw JSON.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Policy Interface Pattern Migration
|
||||||
|
|
||||||
|
Policy contributes four retention-administration sections to the Access-owned
|
||||||
|
administration host. It does not own an independent route or shell.
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence and provenance | Evidence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| System retention | Effective-policy editor plus destructive operation | Typed values show their effective source path. Applying retention can irreversibly redact or delete eligible content and therefore requires explicit confirmation; dry-run and applied outcomes are distinguished. | `RetentionPoliciesPanel.tsx`, Core `RetentionPolicyScopeManager`, interface-pattern structural test |
|
||||||
|
| Tenant retention | Effective-policy editor | A tenant may only narrow fields that system policy allows. Read-only authority and parent locks are explicit. | Core policy-source and blocker components |
|
||||||
|
| Group retention | Targeted effective-policy editor | Group selection is loaded through bounded delta requests; missing target, parent lock, and write authority remain distinct states. | Target loader plus Core retention editor |
|
||||||
|
| User retention | Targeted effective-policy editor | User labels expose only authorized account metadata; retained data itself is never returned by this administration surface. | Target loader plus Core retention editor |
|
||||||
|
|
||||||
|
The retention execution result is a typed, filterable outcome table. Raw JSON
|
||||||
|
is neither the primary policy editor nor the operator result view. The backend
|
||||||
|
remains authoritative for policy validation, narrowing rules, destructive
|
||||||
|
effects, redaction, and audit evidence.
|
||||||
|
|
||||||
|
Contextual help uses `policy.retention` and `privacy.retention`, which resolve
|
||||||
|
through the optional Docs module or the hosted documentation fallback.
|
||||||
@@ -120,6 +120,13 @@ System: Allow
|
|||||||
When a parent disallows lower-level limits or changes, the UI should disable
|
When a parent disallows lower-level limits or changes, the UI should disable
|
||||||
the affected controls and avoid sending those fields in the save payload.
|
the affected controls and avoid sending those fields in the save payload.
|
||||||
|
|
||||||
|
System retention execution is a separate high-consequence operation. The UI
|
||||||
|
must distinguish dry-run evidence from an applied run, render bounded counts as
|
||||||
|
typed rows rather than raw JSON, explain missing write authority, and require a
|
||||||
|
destructive confirmation that names deletion/redaction and recovery
|
||||||
|
expectations. The backend remains authoritative and records the mode plus
|
||||||
|
bounded outcome counts in audit evidence.
|
||||||
|
|
||||||
The shared core WebUI helper `PolicySourcePath` renders the source path shape
|
The shared core WebUI helper `PolicySourcePath` renders the source path shape
|
||||||
for module UIs. Modules may use their own field layout, but the data contract
|
for module UIs. Modules may use their own field layout, but the data contract
|
||||||
should remain this shape.
|
should remain this shape.
|
||||||
|
|||||||
@@ -161,7 +161,24 @@ manifest = ModuleManifest(
|
|||||||
"scheduling",
|
"scheduling",
|
||||||
"reporting",
|
"reporting",
|
||||||
),
|
),
|
||||||
metadata={"kind": "reference"},
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/admin?section=system-retention",
|
||||||
|
"screen": "Retention administration",
|
||||||
|
"help_contexts": ["policy.retention", "privacy.retention"],
|
||||||
|
"prerequisites": [
|
||||||
|
"Policy and Access are enabled.",
|
||||||
|
"The actor may read policy settings at the selected scope.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Inspect the effective value and its policy source path.",
|
||||||
|
"Narrow only fields that the parent policy permits this scope to override.",
|
||||||
|
"Save the policy, then run a system dry run before applying retention.",
|
||||||
|
"Verify bounded outcome and audit evidence after an applied run.",
|
||||||
|
],
|
||||||
|
"outcome": "The selected scope has an explainable retention policy and any destructive application is preceded by a dry-run review.",
|
||||||
|
"verification": "Reload the policy, confirm its source path, and compare the dry-run or applied outcome table with audit evidence.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ class PolicyModuleContractTests(unittest.TestCase):
|
|||||||
set(manifest.capability_factories),
|
set(manifest.capability_factories),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_retention_documentation_exposes_stable_help_contexts(self) -> None:
|
||||||
|
topic = next(
|
||||||
|
item
|
||||||
|
for item in manifest.documentation
|
||||||
|
if item.id == "policy.hierarchy-overrides-and-retention"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["policy.retention", "privacy.retention"],
|
||||||
|
topic.metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
self.assertEqual("workflow", topic.metadata["kind"])
|
||||||
|
self.assertIn("/admin", topic.metadata["route"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"import": "./src/index.ts"
|
"import": "./src/index.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
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");
|
||||||
|
|
||||||
|
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");
|
||||||
|
assert.match(panel, /disabledReason=\{actionDisabledReason\}/, "retention execution explains unavailable actions");
|
||||||
|
assert.match(panel, /<ConfirmDialog/, "destructive retention uses shared confirmation");
|
||||||
|
assert.match(panel, /<DataGrid/, "retention outcomes use the shared data-grid pattern");
|
||||||
|
assert.doesNotMatch(panel, /admin-json-preview/, "retention outcome is not presented as raw JSON");
|
||||||
|
assert.doesNotMatch(panel, /<pre/, "retention outcome is a typed projection");
|
||||||
|
|
||||||
|
console.log("Policy interface-pattern contracts passed.");
|
||||||
@@ -5,16 +5,21 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
DataGrid,
|
||||||
|
DocumentationHelpLink,
|
||||||
mergeDeltaRows,
|
mergeDeltaRows,
|
||||||
RetentionPolicyScopeManager,
|
RetentionPolicyScopeManager,
|
||||||
runRetentionPolicy,
|
runRetentionPolicy,
|
||||||
|
StatusBadge,
|
||||||
useDeltaWatermarks,
|
useDeltaWatermarks,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
|
type DataGridColumn,
|
||||||
type DeltaDeletedItem,
|
type DeltaDeletedItem,
|
||||||
type PrivacyRetentionPolicyScope,
|
type PrivacyRetentionPolicyScope,
|
||||||
type RetentionPolicyTargetOption,
|
type RetentionPolicyTargetOption,
|
||||||
type RetentionRunResponse
|
type RetentionRunResponse
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
|
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -30,6 +35,28 @@ type DeltaResponse = {
|
|||||||
full: boolean;
|
full: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface RetentionCountTree {
|
||||||
|
[key: string]: number | RetentionCountTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetentionCountRow = {
|
||||||
|
id: string;
|
||||||
|
area: string;
|
||||||
|
measure: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RETENTION_DOCUMENTATION = {
|
||||||
|
contextId: "policy.retention",
|
||||||
|
documentationType: "admin" as const
|
||||||
|
};
|
||||||
|
|
||||||
|
const RETENTION_RESULT_COLUMNS: DataGridColumn<RetentionCountRow>[] = [
|
||||||
|
{ id: "area", header: "Area", value: (row) => row.area, width: "minmax(180px, 1fr)", filterType: "list", sortable: true },
|
||||||
|
{ id: "measure", header: "Outcome", value: (row) => row.measure, width: "minmax(220px, 1.4fr)", filterType: "text", sortable: true },
|
||||||
|
{ id: "count", header: "Records", value: (row) => row.count, width: "120px", align: "right", sortable: true }
|
||||||
|
];
|
||||||
|
|
||||||
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
|
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
|
||||||
system: {
|
system: {
|
||||||
title: "System retention",
|
title: "System retention",
|
||||||
@@ -151,10 +178,38 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labels = copy[scopeType];
|
const labels = copy[scopeType];
|
||||||
|
const resultRows = flattenRetentionCounts(retentionResult?.result.counts);
|
||||||
|
const actionDisabledReason = busy
|
||||||
|
? "A retention operation is already running."
|
||||||
|
: !canWrite
|
||||||
|
? "Your account may inspect retention policy but cannot run retention operations."
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
|
<AdminPageLayout
|
||||||
|
title={labels.title}
|
||||||
|
description={labels.description}
|
||||||
|
loading={loadingTargets}
|
||||||
|
error={targetError || runError}
|
||||||
|
success={success}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{(scopeType === "user" || scopeType === "group") && (
|
||||||
|
<Button
|
||||||
|
title="Reload policy targets"
|
||||||
|
aria-label="Reload policy targets"
|
||||||
|
onClick={() => void loadTargets()}
|
||||||
|
disabled={loadingTargets}
|
||||||
|
disabledReason={loadingTargets ? "Policy targets are already loading." : undefined}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<RetentionPolicyScopeManager
|
<RetentionPolicyScopeManager
|
||||||
settings={settings}
|
settings={settings}
|
||||||
scopeType={scopeType}
|
scopeType={scopeType}
|
||||||
@@ -166,22 +221,46 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{scopeType === "system" && (
|
{scopeType === "system" && (
|
||||||
<div className="retention-run-card">
|
<div className="retention-run-section">
|
||||||
<Card title="Retention execution">
|
<Card
|
||||||
|
title="Retention execution"
|
||||||
|
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
|
||||||
|
>
|
||||||
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p>
|
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p>
|
||||||
<div className="button-row compact-actions subsection-bottom-actions">
|
<div className="button-row compact-actions subsection-bottom-actions">
|
||||||
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
|
<Button onClick={() => void runRetention(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Dry run</Button>
|
||||||
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
|
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Apply retention</Button>
|
||||||
</div>
|
</div>
|
||||||
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
{retentionResult && (
|
||||||
|
<Card title="Latest retention outcome">
|
||||||
|
<dl className="detail-list compact-detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>Operation</dt>
|
||||||
|
<dd><StatusBadge status={retentionResult.result.dry_run ? "info" : "success"} label={retentionResult.result.dry_run ? "Dry run" : "Applied"} /></dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Policy scope</dt><dd>{humanize(retentionResult.result.effective_policy_scope || "system")}</dd></div>
|
||||||
|
<div><dt>Reported outcomes</dt><dd>{resultRows.length}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div className="admin-table-surface">
|
||||||
|
<DataGrid
|
||||||
|
id="policy-retention-outcomes"
|
||||||
|
rows={resultRows}
|
||||||
|
columns={RETENTION_RESULT_COLUMNS}
|
||||||
|
initialFit="container"
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
emptyText="No retained records currently match the effective policy."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmRetentionRun}
|
open={confirmRetentionRun}
|
||||||
title="Apply retention policy"
|
title="Apply retention policy"
|
||||||
message="This will redact or delete eligible retained data according to the saved policy."
|
message="This will redact or delete eligible retained data according to the saved policy. The application cannot restore deleted content; the run and bounded outcome counts remain in audit evidence. Run a dry run first and verify recovery evidence before continuing."
|
||||||
confirmLabel="Apply retention"
|
confirmLabel="Apply retention"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -192,6 +271,35 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flattenRetentionCounts(
|
||||||
|
counts: RetentionRunResponse["result"]["counts"] | undefined
|
||||||
|
): RetentionCountRow[] {
|
||||||
|
const rows: RetentionCountRow[] = [];
|
||||||
|
const visit = (value: number | RetentionCountTree, path: string[]) => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
const [area = "retention", ...measureParts] = path;
|
||||||
|
rows.push({
|
||||||
|
id: path.join("."),
|
||||||
|
area: humanize(area),
|
||||||
|
measure: humanize(measureParts.join(" ") || "records"),
|
||||||
|
count: value
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [key, child] of Object.entries(value)) visit(child, [...path, key]);
|
||||||
|
};
|
||||||
|
if (counts) visit(counts as unknown as RetentionCountTree, []);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value
|
||||||
|
.split(/[._\s-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
|
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
|
||||||
current: TItem[],
|
current: TItem[],
|
||||||
key: string,
|
key: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user