Add hierarchical View policy administration

This commit is contained in:
2026-08-04 08:21:49 +02:00
parent 86c95f85bb
commit fc6d333a64
7 changed files with 682 additions and 0 deletions
+7
View File
@@ -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`.
+50
View File
@@ -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",
+21
View File
@@ -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()
+98
View File
@@ -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<Record<string, unknown>>;
};
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<ViewPolicyScopeResponse> {
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<ViewPolicyScopeResponse> {
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<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}), { method: "DELETE" });
}
export async function fetchViewPolicyReferences(settings: ApiSettings): Promise<ViewPolicyReferenceData> {
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 : []
};
}
@@ -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<Draft, "allow_view" | "allow_select" | "allow_assign" | "allow_edit" | "allow_derive" | "allow_workflow_activate">;
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<Target[]>([]);
const [targetId, setTargetId] = useState("");
const [state, setState] = useState<ViewPolicyScopeResponse | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [viewOptions, setViewOptions] = useState<ReferenceOption[]>([]);
const [surfaceOptions, setSurfaceOptions] = useState<ReferenceOption[]>([]);
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<boolean> {
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 (
<>
<AdminPageLayout
title={`${scopeLabel} View policy`}
description="Control which Views and surfaces are available, forced by assignment, selectable, editable, derivable, or workflow-activatable at this scope."
loading={loading}
error={error}
success={success}
actions={
<>
<Button title="Reload saved View policy" aria-label="Reload saved View policy" onClick={() => void load()} disabled={loading || busy || (needsTarget && !targetId)}>
<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>
<DocumentationHelpLink reference={DOCUMENTATION} label="Open View policy documentation" />
</>
}
>
{needsTarget && (
<FormField label={scopeType === "group" ? "Group" : "User"} documentation={DOCUMENTATION}>
<SearchableSelect
value={targetId}
options={targets}
onChange={(value) => void selectTarget(value)}
placeholder={`Select ${scopeType}`}
searchPlaceholder={`Search ${scopeType}s...`}
disabled={loading || busy || targets.length === 0}
/>
</FormField>
)}
{state && draft && (
<>
<Card title="Actions">
<div className="settings-list">
{BOOLEAN_FIELDS.map((field) => (
<div className="admin-tenant-assignment-row" key={field.id}>
<span>
<strong>{field.label}</strong>
<small>{field.description} Effective: {effectiveLabel(state.effective_policy, field.id)}.</small>
</span>
<SegmentedControl
options={DECISION_OPTIONS.map((option) => (
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`}
/>
</div>
))}
</div>
</Card>
<Card title="Availability ceilings">
<div className="settings-list">
<ToggleSwitch
label="Limit available Views"
checked={draft.limitViews}
onChange={(checked) => 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 && (
<FormField label="Available Views" documentation={DOCUMENTATION}>
<ReferenceMultiSelect
values={draft.allowedViewIds}
onChange={(values) => setDraft({ ...draft, allowedViewIds: values })}
provider={viewProvider}
createCustomOption={(value) => customReference(value, parentViewIds)}
placeholder="Add View"
searchPlaceholder="Search Views or enter an ID..."
disabled={!canWrite || busy}
/>
</FormField>
)}
<ToggleSwitch
label="Limit visible surfaces"
checked={draft.limitSurfaces}
onChange={(checked) => 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 && (
<FormField label="Visible surfaces" documentation={DOCUMENTATION}>
<ReferenceMultiSelect
values={draft.visibleSurfaceIds}
onChange={(values) => setDraft({ ...draft, visibleSurfaceIds: values })}
provider={surfaceProvider}
createCustomOption={(value) => customReference(value, parentSurfaceIds)}
placeholder="Add surface"
searchPlaceholder="Search surfaces or enter an ID..."
disabled={!canWrite || busy}
/>
</FormField>
)}
</div>
</Card>
<Card title="Effective policy and provenance">
<dl className="admin-details-grid">
<div><dt>Local override</dt><dd><StatusBadge status={state.id ? "info" : "neutral"} label={state.id ? `Revision ${state.revision}` : "Inherited"} /></dd></div>
<div><dt>Allowed Views</dt><dd>{ceilingLabel(state.effective_policy.allowed_view_ids)}</dd></div>
<div><dt>Visible surfaces</dt><dd>{ceilingLabel(state.effective_policy.visible_surface_ids)}</dd></div>
<div><dt>Policy path</dt><dd>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}</dd></div>
</dl>
</Card>
</>
)}
</AdminPageLayout>
<ConfirmDialog
open={confirmReset}
title="Use inherited View policy?"
message="The local override will be removed. All restrictions inherited from higher scopes continue to apply."
confirmLabel="Use inherited policy"
busy={busy}
onConfirm={() => void resetPolicy()}
onCancel={() => setConfirmReset(false)}
/>
</>
);
}
async function loadTargets(settings: ApiSettings, scope: ViewPolicyScope): Promise<Target[]> {
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 };
}
+1
View File
@@ -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";
+65
View File
@@ -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 },