From fc6d333a6457250b0106139e1f3779cf38a07cd4 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 08:21:49 +0200 Subject: [PATCH] Add hierarchical View policy administration --- README.md | 7 + src/govoplan_policy/backend/manifest.py | 50 ++ tests/test_policy_module_contract.py | 21 + webui/src/api/viewPolicies.ts | 98 ++++ .../src/features/policy/ViewPoliciesPanel.tsx | 440 ++++++++++++++++++ webui/src/index.ts | 1 + webui/src/module.ts | 65 +++ 7 files changed, 682 insertions(+) create mode 100644 webui/src/api/viewPolicies.ts create mode 100644 webui/src/features/policy/ViewPoliciesPanel.tsx diff --git a/README.md b/README.md index 2559fe1..120a79e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ user retention sections through the shared `admin.sections` UI capability. The admin shell does not render retention policy panels unless this module is installed and enabled. +The same administration contribution exposes hierarchical **View policy** at +system, tenant, group, and user scope. Administrators can inherit, allow, or +block View use, selection, assignment, editing, derivation, and workflow +activation. Optional View-ID and surface-ID ceilings are intersected across the +scope path, and the UI displays effective limits and provenance. Lower scopes +can narrow but never broaden an ancestor restriction. + Policy decision and provenance payloads use the shared kernel DTOs documented in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md) and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`. diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py index 01454f0..9fb9dcb 100644 --- a/src/govoplan_policy/backend/manifest.py +++ b/src/govoplan_policy/backend/manifest.py @@ -136,6 +136,28 @@ manifest = ModuleManifest( ), route_factory=_route_factory, documentation=( + DocumentationTopic( + id="policy.view-governance-administration", + title="Govern View availability and actions", + summary="View policy limits which definitions and surfaces remain available and which View actions lower scopes may perform.", + body=( + "System, tenant, group, and user View policies form a restrictive hierarchy. Each scope may inherit, allow, or block viewing, selecting, assigning, editing, deriving, and workflow activation. Optional View-ID and surface-ID ceilings are intersected through the hierarchy, so a lower scope cannot restore an item excluded above it. Available, default, and required View assignments remain owned by Views; Policy supplies the action and catalogue ceiling and records provenance and malformed-policy diagnostics." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("system_admin", "tenant_admin", "policy_admin"), + related_modules=("views", "admin", "access"), + metadata={ + "kind": "reference", + "help_contexts": [ + "policy.view-governance", + "policy.admin.system-view-policy", + "policy.admin.tenant-view-policy", + "policy.admin.group-view-policy", + "policy.admin.user-view-policy", + ], + }, + ), DocumentationTopic( id="policy.effective-decisions-and-provenance", title="Understand effective policy decisions", @@ -205,6 +227,34 @@ manifest = ModuleManifest( module_id="policy", package_name="@govoplan/policy-webui", view_surfaces=( + ViewSurface( + id="policy.admin.system-view-policy", + module_id="policy", + kind="section", + label="System View policy", + order=70, + ), + ViewSurface( + id="policy.admin.tenant-view-policy", + module_id="policy", + kind="section", + label="Tenant View policy", + order=70, + ), + ViewSurface( + id="policy.admin.group-view-policy", + module_id="policy", + kind="section", + label="Group View policy", + order=70, + ), + ViewSurface( + id="policy.admin.user-view-policy", + module_id="policy", + kind="section", + label="User View policy", + order=70, + ), ViewSurface( id="policy.admin.system-retention", module_id="policy", diff --git a/tests/test_policy_module_contract.py b/tests/test_policy_module_contract.py index 49a89e9..337e19f 100644 --- a/tests/test_policy_module_contract.py +++ b/tests/test_policy_module_contract.py @@ -72,6 +72,27 @@ class PolicyModuleContractTests(unittest.TestCase): self.assertEqual("workflow", topic.metadata["kind"]) self.assertIn("/admin", topic.metadata["route"]) + def test_view_policy_administration_contract_is_documented_and_exposed(self) -> None: + topic = next( + item + for item in manifest.documentation + if item.id == "policy.view-governance-administration" + ) + self.assertIn("policy.view-governance", topic.metadata["help_contexts"]) + self.assertEqual( + { + "policy.admin.system-view-policy", + "policy.admin.tenant-view-policy", + "policy.admin.group-view-policy", + "policy.admin.user-view-policy", + }, + { + surface.id + for surface in manifest.frontend.view_surfaces + if "view-policy" in surface.id + }, + ) + if __name__ == "__main__": unittest.main() diff --git a/webui/src/api/viewPolicies.ts b/webui/src/api/viewPolicies.ts new file mode 100644 index 0000000..58e1997 --- /dev/null +++ b/webui/src/api/viewPolicies.ts @@ -0,0 +1,98 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + +export type ViewPolicyScope = "system" | "tenant" | "group" | "user"; + +export type ViewPolicyItem = { + allow_view?: boolean; + allow_select?: boolean; + allow_assign?: boolean; + allow_edit?: boolean; + allow_derive?: boolean; + allow_workflow_activate?: boolean; + allowed_view_ids?: string[]; + visible_surface_ids?: string[]; +}; + +export type EffectiveViewPolicy = { + allow_view: boolean; + allow_select: boolean; + allow_assign: boolean; + allow_edit: boolean; + allow_derive: boolean; + allow_workflow_activate: boolean; + allowed_view_ids?: string[] | null; + visible_surface_ids?: string[] | null; +}; + +export type ViewPolicyScopeResponse = { + scope_type: ViewPolicyScope; + scope_id?: string | null; + id?: string | null; + revision?: number | null; + policy: ViewPolicyItem; + effective_policy: EffectiveViewPolicy; + parent_policy: EffectiveViewPolicy; + source_path: Array<{ + scope_type: string; + scope_id?: string | null; + source_id?: string | null; + fields?: string[]; + }>; + diagnostics: Array>; +}; + +export type ViewPolicyReferenceData = { + views: Array<{ id: string; name: string; scope_type?: string }>; + surfaces: Array<{ id: string; label: string; module_id: string; kind: string }>; +}; + +export function fetchViewPolicy( + settings: ApiSettings, + scope: ViewPolicyScope, + scopeId?: string | null +): Promise { + return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, { + scope_id: scopeId || undefined + })); +} + +export function updateViewPolicy( + settings: ApiSettings, + scope: ViewPolicyScope, + scopeId: string | null | undefined, + policy: ViewPolicyItem +): Promise { + return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, { + scope_id: scopeId || undefined + }), { + method: "PUT", + body: JSON.stringify({ policy }) + }); +} + +export function deleteViewPolicy( + settings: ApiSettings, + scope: ViewPolicyScope, + scopeId?: string | null +): Promise { + return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, { + scope_id: scopeId || undefined + }), { method: "DELETE" }); +} + +export async function fetchViewPolicyReferences(settings: ApiSettings): Promise { + const [definitionResult, surfaceResult] = await Promise.allSettled([ + apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>( + settings, + apiPath("/api/v1/views/definitions", { scope_type: "tenant", include_inherited: true }) + ), + apiFetch<{ surfaces: Array<{ id: string; label: string; module_id: string; kind: string }> }>( + settings, + "/api/v1/views/surfaces" + ) + ]); + return { + views: definitionResult.status === "fulfilled" ? definitionResult.value.definitions : [], + surfaces: surfaceResult.status === "fulfilled" ? surfaceResult.value.surfaces : [] + }; +} diff --git a/webui/src/features/policy/ViewPoliciesPanel.tsx b/webui/src/features/policy/ViewPoliciesPanel.tsx new file mode 100644 index 0000000..42bc1cd --- /dev/null +++ b/webui/src/features/policy/ViewPoliciesPanel.tsx @@ -0,0 +1,440 @@ +import { useEffect, useMemo, useState } from "react"; +import { + AdminPageLayout, + adminErrorMessage, + Button, + Card, + ConfirmDialog, + DocumentationHelpLink, + FormField, + ReferenceMultiSelect, + SearchableSelect, + SegmentedControl, + staticReferenceOptionProvider, + StatusBadge, + ToggleSwitch, + useUnsavedDraftGuard, + type ApiSettings, + type ReferenceOption, + type SearchableSelectOption +} from "@govoplan/core-webui"; +import { RefreshCw, Save, Trash2, Undo2 } from "lucide-react"; +import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets"; +import { + deleteViewPolicy, + fetchViewPolicy, + fetchViewPolicyReferences, + updateViewPolicy, + type EffectiveViewPolicy, + type ViewPolicyItem, + type ViewPolicyScope, + type ViewPolicyScopeResponse +} from "../../api/viewPolicies"; + +type Props = { + settings: ApiSettings; + scopeType: ViewPolicyScope; + canWrite: boolean; +}; + +type Decision = "inherit" | "allow" | "block"; + +type Draft = { + allow_view: Decision; + allow_select: Decision; + allow_assign: Decision; + allow_edit: Decision; + allow_derive: Decision; + allow_workflow_activate: Decision; + limitViews: boolean; + allowedViewIds: string[]; + limitSurfaces: boolean; + visibleSurfaceIds: string[]; +}; + +type Target = SearchableSelectOption; + +const BOOLEAN_FIELDS: Array<{ + id: keyof Pick; + label: string; + description: string; +}> = [ + { id: "allow_view", label: "View", description: "Allow affected accounts to apply and use Views." }, + { id: "allow_select", label: "Select", description: "Allow affected accounts to choose among available Views." }, + { id: "allow_assign", label: "Assign", description: "Allow administrators at this scope to assign Views." }, + { id: "allow_edit", label: "Edit", description: "Allow View definitions to be edited at this scope." }, + { id: "allow_derive", label: "Derive", description: "Allow a new View to derive from an inherited definition." }, + { id: "allow_workflow_activate", label: "Workflow activation", description: "Allow workflows to activate a View for affected accounts." } +]; + +const DOCUMENTATION = { + contextId: "policy.view-governance", + documentationType: "admin" as const +}; + +const DECISION_OPTIONS = [ + { id: "inherit" as const, label: "Inherit" }, + { id: "allow" as const, label: "Allow" }, + { id: "block" as const, label: "Block" } +]; + +export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Props) { + const [targets, setTargets] = useState([]); + const [targetId, setTargetId] = useState(""); + const [state, setState] = useState(null); + const [draft, setDraft] = useState(null); + const [viewOptions, setViewOptions] = useState([]); + const [surfaceOptions, setSurfaceOptions] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [confirmReset, setConfirmReset] = useState(false); + + const needsTarget = scopeType === "group" || scopeType === "user"; + const parentViewIds = state?.parent_policy.allowed_view_ids; + const parentSurfaceIds = state?.parent_policy.visible_surface_ids; + const viewProvider = useMemo( + () => staticReferenceOptionProvider(optionsWithinCeiling(viewOptions, parentViewIds)), + [parentViewIds, viewOptions] + ); + const surfaceProvider = useMemo( + () => staticReferenceOptionProvider(optionsWithinCeiling(surfaceOptions, parentSurfaceIds)), + [parentSurfaceIds, surfaceOptions] + ); + const dirty = Boolean( + state + && draft + && stablePolicy(buildPolicy(draft)) + !== stablePolicy(buildPolicy(draftFromPolicy(state.policy))) + ); + + useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard }); + + useEffect(() => { + void initialize(); + }, [scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + + async function initialize() { + setLoading(true); + setError(""); + setSuccess(""); + try { + const [references, loadedTargets] = await Promise.all([ + fetchViewPolicyReferences(settings), + loadTargets(settings, scopeType) + ]); + setViewOptions(references.views.map((view) => ({ + value: view.id, + label: view.name, + description: view.scope_type ? `${view.scope_type} View` : "View", + searchText: `${view.name} ${view.id}` + }))); + setSurfaceOptions(references.surfaces.map((surface) => ({ + value: surface.id, + label: surface.label || surface.id, + description: `${surface.module_id} - ${surface.kind}`, + searchText: `${surface.id} ${surface.module_id} ${surface.label}` + }))); + setTargets(loadedTargets); + const nextTarget = needsTarget + ? (loadedTargets.some((target) => target.value === targetId) ? targetId : loadedTargets[0]?.value ?? "") + : ""; + setTargetId(nextTarget); + if (!needsTarget || nextTarget) await load(nextTarget, false); + else { + setState(null); + setDraft(null); + } + } catch (err) { + setError(adminErrorMessage(err)); + setState(null); + setDraft(null); + } finally { + setLoading(false); + } + } + + async function load(nextTargetId = targetId, manageLoading = true) { + if (needsTarget && !nextTargetId) return; + if (manageLoading) setLoading(true); + setError(""); + setSuccess(""); + try { + const loaded = await fetchViewPolicy(settings, scopeType, nextTargetId || null); + setState(loaded); + setDraft(draftFromPolicy(loaded.policy)); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + if (manageLoading) setLoading(false); + } + } + + async function selectTarget(nextTargetId: string) { + if (!nextTargetId || nextTargetId === targetId) return; + setTargetId(nextTargetId); + await load(nextTargetId); + } + + function discard() { + if (state) setDraft(draftFromPolicy(state.policy)); + setError(""); + setSuccess(""); + } + + async function save(): Promise { + if (!draft || !state || !dirty) return true; + setBusy(true); + setError(""); + setSuccess(""); + try { + const loaded = await updateViewPolicy(settings, scopeType, targetId || null, buildPolicy(draft)); + setState(loaded); + setDraft(draftFromPolicy(loaded.policy)); + setSuccess("View policy saved."); + return true; + } catch (err) { + setError(adminErrorMessage(err)); + return false; + } finally { + setBusy(false); + } + } + + async function resetPolicy() { + setBusy(true); + setError(""); + setSuccess(""); + try { + const loaded = await deleteViewPolicy(settings, scopeType, targetId || null); + setState(loaded); + setDraft(draftFromPolicy(loaded.policy)); + setSuccess("Local View policy removed; inherited policy now applies."); + setConfirmReset(false); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + const scopeLabel = scopeType === "system" ? "System" : scopeType === "tenant" ? "Tenant" : scopeType === "group" ? "Group" : "User"; + + return ( + <> + + + + + + + + } + > + {needsTarget && ( + + void selectTarget(value)} + placeholder={`Select ${scopeType}`} + searchPlaceholder={`Search ${scopeType}s...`} + disabled={loading || busy || targets.length === 0} + /> + + )} + + {state && draft && ( + <> + +
+ {BOOLEAN_FIELDS.map((field) => ( +
+ + {field.label} + {field.description} Effective: {effectiveLabel(state.effective_policy, field.id)}. + + ( + option.id === "allow" && state.parent_policy[field.id] === false + ? { + ...option, + disabled: true, + title: "A parent policy blocks this action." + } + : option + ))} + value={draft[field.id]} + onChange={(value) => setDraft({ ...draft, [field.id]: value })} + role="group" + size="equal" + width="fill" + disabled={!canWrite || busy} + ariaLabel={`${field.label} policy`} + /> +
+ ))} +
+
+ + +
+ setDraft({ ...draft, limitViews: checked, allowedViewIds: checked ? draft.allowedViewIds : [] })} + disabled={!canWrite || busy} + help="A lower scope may narrow this list but cannot add Views excluded by an ancestor." + /> + {draft.limitViews && ( + + setDraft({ ...draft, allowedViewIds: values })} + provider={viewProvider} + createCustomOption={(value) => customReference(value, parentViewIds)} + placeholder="Add View" + searchPlaceholder="Search Views or enter an ID..." + disabled={!canWrite || busy} + /> + + )} + setDraft({ ...draft, limitSurfaces: checked, visibleSurfaceIds: checked ? draft.visibleSurfaceIds : [] })} + disabled={!canWrite || busy} + help="The effective View may only expose surfaces retained by every ancestor policy." + /> + {draft.limitSurfaces && ( + + setDraft({ ...draft, visibleSurfaceIds: values })} + provider={surfaceProvider} + createCustomOption={(value) => customReference(value, parentSurfaceIds)} + placeholder="Add surface" + searchPlaceholder="Search surfaces or enter an ID..." + disabled={!canWrite || busy} + /> + + )} +
+
+ + +
+
Local override
+
Allowed Views
{ceilingLabel(state.effective_policy.allowed_view_ids)}
+
Visible surfaces
{ceilingLabel(state.effective_policy.visible_surface_ids)}
+
Policy path
{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}
+
+
+ + )} +
+ + void resetPolicy()} + onCancel={() => setConfirmReset(false)} + /> + + ); +} + +async function loadTargets(settings: ApiSettings, scope: ViewPolicyScope): Promise { + if (scope === "user") { + const response = await fetchUsersDelta(settings, { limit: 200 }); + return response.users.map((user) => ({ + value: user.id, + label: user.display_name || user.email, + description: user.display_name ? user.email : undefined, + searchText: `${user.display_name ?? ""} ${user.email}` + })); + } + if (scope === "group") { + const response = await fetchGroupsDelta(settings, { limit: 200 }); + return response.groups.map((group) => ({ + value: group.id, + label: group.name, + description: group.slug, + searchText: `${group.name} ${group.slug}` + })); + } + return []; +} + +function draftFromPolicy(policy: ViewPolicyItem): Draft { + return { + allow_view: decision(policy.allow_view), + allow_select: decision(policy.allow_select), + allow_assign: decision(policy.allow_assign), + allow_edit: decision(policy.allow_edit), + allow_derive: decision(policy.allow_derive), + allow_workflow_activate: decision(policy.allow_workflow_activate), + limitViews: Array.isArray(policy.allowed_view_ids), + allowedViewIds: [...(policy.allowed_view_ids ?? [])].sort(), + limitSurfaces: Array.isArray(policy.visible_surface_ids), + visibleSurfaceIds: [...(policy.visible_surface_ids ?? [])].sort() + }; +} + +function buildPolicy(draft: Draft): ViewPolicyItem { + const policy: ViewPolicyItem = {}; + for (const field of BOOLEAN_FIELDS) { + const value = draft[field.id]; + if (value !== "inherit") policy[field.id] = value === "allow"; + } + if (draft.limitViews) policy.allowed_view_ids = [...new Set(draft.allowedViewIds)].sort(); + if (draft.limitSurfaces) policy.visible_surface_ids = [...new Set(draft.visibleSurfaceIds)].sort(); + return policy; +} + +function decision(value: boolean | undefined): Decision { + return value === undefined ? "inherit" : value ? "allow" : "block"; +} + +function stablePolicy(policy: ViewPolicyItem): string { + return JSON.stringify(Object.fromEntries(Object.entries(policy).sort(([left], [right]) => left.localeCompare(right)))); +} + +function effectiveLabel(policy: EffectiveViewPolicy, field: keyof EffectiveViewPolicy): string { + return policy[field] === true ? "allowed" : "blocked"; +} + +function ceilingLabel(values: string[] | null | undefined): string { + return values == null ? "Unrestricted by ID" : values.length ? `${values.length} entries` : "None"; +} + +function optionsWithinCeiling( + options: ReferenceOption[], + ceiling: string[] | null | undefined +): ReferenceOption[] { + if (!Array.isArray(ceiling)) return options; + const allowed = new Set(ceiling); + return options.filter((option) => allowed.has(option.value)); +} + +function customReference( + value: string, + ceiling: string[] | null | undefined +): ReferenceOption | null { + const clean = value.trim(); + if (!clean || (Array.isArray(ceiling) && !ceiling.includes(clean))) return null; + return { value: clean, label: clean, description: "Unresolved identifier", custom: true }; +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 692d704..13e75df 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -1,4 +1,5 @@ export { default } from "./module"; +export { default as ViewPoliciesPanel } from "./features/policy/ViewPoliciesPanel"; export * from "./module"; export * from "./api/adminTargets"; export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel"; diff --git a/webui/src/module.ts b/webui/src/module.ts index 300e8c1..f13a799 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -2,9 +2,70 @@ import { createElement, lazy } from "react"; import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui"; const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel")); +const ViewPoliciesPanel = lazy(() => import("./features/policy/ViewPoliciesPanel")); const policyAdminSections: AdminSectionsUiCapability = { sections: [ + { + id: "system-view-policy", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.system-view-policy", + label: "View policy", + group: "SYSTEM", + order: 70, + allOf: ["system:settings:read"], + render: ({ settings, auth }) => createElement(ViewPoliciesPanel, { + settings, + scopeType: "system", + canWrite: hasScope(auth, "system:settings:write") + }) + }, + { + id: "tenant-view-policy", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.tenant-view-policy", + label: "View policy", + group: "TENANT", + order: 70, + allOf: ["admin:policies:read"], + render: ({ settings, auth }) => createElement(ViewPoliciesPanel, { + settings, + scopeType: "tenant", + canWrite: hasScope(auth, "admin:policies:write") + }) + }, + { + id: "group-view-policy", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.group-view-policy", + label: "View policy", + group: "GROUP", + order: 20, + allOf: ["admin:policies:read", "admin:groups:read"], + render: ({ settings, auth }) => createElement(ViewPoliciesPanel, { + settings, + scopeType: "group", + canWrite: hasScope(auth, "admin:policies:write") + }) + }, + { + id: "user-view-policy", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.user-view-policy", + label: "View policy", + group: "USER", + order: 20, + allOf: ["admin:policies:read", "admin:users:read"], + render: ({ settings, auth }) => createElement(ViewPoliciesPanel, { + settings, + scopeType: "user", + canWrite: hasScope(auth, "admin:policies:write") + }) + }, { id: "system-retention", moduleId: "policy", @@ -74,6 +135,10 @@ export const policyModule: PlatformWebModule = { version: "0.1.9", dependencies: ["access", "admin"], viewSurfaces: [ + { id: "policy.admin.system-view-policy", moduleId: "policy", kind: "section", label: "System View policy", order: 70 }, + { id: "policy.admin.tenant-view-policy", moduleId: "policy", kind: "section", label: "Tenant View policy", order: 70 }, + { id: "policy.admin.group-view-policy", moduleId: "policy", kind: "section", label: "Group View policy", order: 70 }, + { id: "policy.admin.user-view-policy", moduleId: "policy", kind: "section", label: "User View policy", order: 70 }, { id: "policy.admin.system-retention", moduleId: "policy", kind: "section", label: "System retention", order: 80 }, { id: "policy.admin.tenant-retention", moduleId: "policy", kind: "section", label: "Tenant retention", order: 80 }, { id: "policy.admin.group-retention", moduleId: "policy", kind: "section", label: "Group retention", order: 80 },