feat: govern campaign archive encryption

This commit is contained in:
2026-08-20 12:12:14 +02:00
parent 8fcc12dbb2
commit be5e3a7d72
11 changed files with 1070 additions and 5 deletions
@@ -0,0 +1,56 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type ArchiveEncryptionPolicyScope = "system" | "tenant" | "group" | "user";
export type ArchiveEncryptionMethod = "aes" | "zip_standard";
export type PasswordDeliveryChannel = "separate_mail" | "sms" | "letter" | "phone" | "in_person";
export type ArchiveEncryptionPolicyItem = {
allowed_password_encryption_methods?: ArchiveEncryptionMethod[];
allowed_password_delivery_channels?: PasswordDeliveryChannel[];
};
export type EffectiveArchiveEncryptionPolicy = {
allowed_password_encryption_methods: ArchiveEncryptionMethod[];
allowed_password_delivery_channels: PasswordDeliveryChannel[];
policy_hash: string;
source_path: Array<{ path: string; label: string }>;
reason: string;
diagnostics: Array<Record<string, unknown>>;
};
export type ArchiveEncryptionPolicyResponse = {
scope_type: ArchiveEncryptionPolicyScope;
scope_id?: string | null;
id?: string | null;
revision?: number | null;
policy: ArchiveEncryptionPolicyItem;
effective_policy: EffectiveArchiveEncryptionPolicy;
parent_policy: EffectiveArchiveEncryptionPolicy;
};
function policyPath(scope: ArchiveEncryptionPolicyScope, scopeId?: string | null): string {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString();
return `/api/v1/admin/campaign-archive-encryption/policies/${scope}${suffix ? `?${suffix}` : ""}`;
}
export function fetchArchiveEncryptionPolicy(
settings: ApiSettings,
scope: ArchiveEncryptionPolicyScope,
scopeId?: string | null
): Promise<ArchiveEncryptionPolicyResponse> {
return apiFetch(settings, policyPath(scope, scopeId));
}
export function updateArchiveEncryptionPolicy(
settings: ApiSettings,
scope: ArchiveEncryptionPolicyScope,
scopeId: string | null,
policy: ArchiveEncryptionPolicyItem
): Promise<ArchiveEncryptionPolicyResponse> {
return apiFetch(settings, policyPath(scope, scopeId), {
method: "PUT",
body: JSON.stringify({ policy })
});
}
@@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import {
AdminPageLayout,
adminErrorMessage,
Button,
Card,
DescriptionItem,
DescriptionList,
DismissibleAlert,
FormField,
SearchableSelect,
ToggleSwitch,
useUnsavedDraftGuard,
type ApiSettings,
type SearchableSelectOption
} from "@govoplan/core-webui";
import { RefreshCw, Save, Undo2 } from "lucide-react";
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
import {
fetchArchiveEncryptionPolicy,
updateArchiveEncryptionPolicy,
type ArchiveEncryptionMethod,
type ArchiveEncryptionPolicyItem,
type ArchiveEncryptionPolicyResponse,
type ArchiveEncryptionPolicyScope,
type PasswordDeliveryChannel
} from "../../api/archiveEncryptionPolicies";
type Props = {
settings: ApiSettings;
scopeType: ArchiveEncryptionPolicyScope;
canWrite: boolean;
};
type Draft = {
inheritMethods: boolean;
methods: ArchiveEncryptionMethod[];
inheritChannels: boolean;
channels: PasswordDeliveryChannel[];
};
const METHODS: Array<{ id: ArchiveEncryptionMethod; label: string; description: string }> = [
{ id: "aes", label: "AES (strong, default)", description: "Modern AES encryption for compatible ZIP clients." },
{ id: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption", description: "Requires a separate Campaign permission and reasoned acknowledgement." }
];
const CHANNELS: Array<{ id: PasswordDeliveryChannel; label: string }> = [
{ id: "separate_mail", label: "Separate email (never the campaign message)" },
{ id: "sms", label: "SMS" },
{ id: "letter", label: "Letter" },
{ id: "phone", label: "Telephone" },
{ id: "in_person", label: "In person" }
];
export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
const [targets, setTargets] = useState<SearchableSelectOption[]>([]);
const [targetId, setTargetId] = useState("");
const [state, setState] = useState<ArchiveEncryptionPolicyResponse | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const needsTarget = scopeType === "group" || scopeType === "user";
const dirty = Boolean(state && draft && stable(buildPolicy(draft)) !== stable(state.policy));
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
useEffect(() => { void initialize(); }, [scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function initialize() {
setLoading(true);
setError("");
try {
const loadedTargets = await loadTargets(settings, scopeType);
setTargets(loadedTargets);
const next = needsTarget ? loadedTargets[0]?.value ?? "" : "";
setTargetId(next);
if (!needsTarget || next) await load(next, false);
} catch (cause) {
setError(adminErrorMessage(cause));
} finally {
setLoading(false);
}
}
async function load(nextTarget = targetId, manageLoading = true) {
if (needsTarget && !nextTarget) return;
if (manageLoading) setLoading(true);
setError("");
setSuccess("");
try {
const loaded = await fetchArchiveEncryptionPolicy(settings, scopeType, nextTarget || null);
setState(loaded);
setDraft(draftFromPolicy(loaded.policy, loaded.parent_policy));
} catch (cause) {
setError(adminErrorMessage(cause));
} finally {
if (manageLoading) setLoading(false);
}
}
async function selectTarget(value: string) {
if (!value || value === targetId) return;
setTargetId(value);
await load(value);
}
function discard() {
if (state) setDraft(draftFromPolicy(state.policy, state.parent_policy));
setError("");
setSuccess("");
}
async function save(): Promise<boolean> {
if (!draft || !dirty) return true;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await updateArchiveEncryptionPolicy(settings, scopeType, targetId || null, buildPolicy(draft));
setState(loaded);
setDraft(draftFromPolicy(loaded.policy, loaded.parent_policy));
setSuccess("Campaign archive-encryption policy saved.");
return true;
} catch (cause) {
setError(adminErrorMessage(cause));
return false;
} finally {
setBusy(false);
}
}
const scopeLabel = scopeType[0].toUpperCase() + scopeType.slice(1);
const parentMethods = state?.parent_policy.allowed_password_encryption_methods ?? ["aes"];
const parentChannels = state?.parent_policy.allowed_password_delivery_channels ?? [];
return <AdminPageLayout
title={`${scopeLabel} Campaign archive encryption`}
description="Restrict password-protected ZIP methods and the separate channel used to convey passwords. Lower scopes can only narrow inherited choices."
loading={loading}
error={error}
success={success}
actions={<>
<Button title="Reload saved archive policy" aria-label="Reload saved archive 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 variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}><Save size={16} /> {busy ? "Saving..." : "Save"}</Button>
</>}>
{needsTarget && <Card title={`${scopeLabel} target`}>
<FormField label={`Select ${scopeType}`}>
<SearchableSelect value={targetId} options={targets} onChange={(value) => void selectTarget(value)} disabled={busy} />
</FormField>
</Card>}
{draft && state && <>
<DismissibleAlert tone={state.effective_policy.allowed_password_encryption_methods.includes("zip_standard") ? "warning" : "info"} dismissible={false}>
{state.effective_policy.reason} Legacy ZipCrypto remains a weak compatibility exception and is never an automatic fallback.
</DismissibleAlert>
<Card title="Allowed password-encryption methods">
{scopeType !== "system" && <ToggleSwitch label="Inherit methods from the parent scope" checked={draft.inheritMethods} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />}
{METHODS.map((method) => <ToggleSwitch key={method.id} label={method.label} help={method.description} checked={draft.methods.includes(method.id)} disabled={!canWrite || busy || draft.inheritMethods || (scopeType !== "system" && !parentMethods.includes(method.id))} onChange={(checked) => setDraft({ ...draft, methods: toggle(draft.methods, method.id, checked) })} />)}
</Card>
<Card title="Allowed separate password-delivery channels">
{scopeType !== "system" && <ToggleSwitch label="Inherit channels from the parent scope" checked={draft.inheritChannels} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />}
{CHANNELS.map((channel) => <ToggleSwitch key={channel.id} label={channel.label} checked={draft.channels.includes(channel.id)} disabled={!canWrite || busy || draft.inheritChannels || (scopeType !== "system" && !parentChannels.includes(channel.id))} onChange={(checked) => setDraft({ ...draft, channels: toggle(draft.channels, channel.id, checked) })} />)}
</Card>
<Card title="Effective policy evidence">
<DescriptionList>
<DescriptionItem term="Policy hash"><code>{state.effective_policy.policy_hash}</code></DescriptionItem>
<DescriptionItem term="Source path">{state.effective_policy.source_path.map((step) => step.label).join(" → ")}</DescriptionItem>
</DescriptionList>
</Card>
</>}
</AdminPageLayout>;
}
function draftFromPolicy(policy: ArchiveEncryptionPolicyItem, parent: ArchiveEncryptionPolicyResponse["parent_policy"]): Draft {
return {
inheritMethods: policy.allowed_password_encryption_methods === undefined,
methods: [...(policy.allowed_password_encryption_methods ?? parent.allowed_password_encryption_methods)],
inheritChannels: policy.allowed_password_delivery_channels === undefined,
channels: [...(policy.allowed_password_delivery_channels ?? parent.allowed_password_delivery_channels)]
};
}
function buildPolicy(draft: Draft): ArchiveEncryptionPolicyItem {
return {
...(draft.inheritMethods ? {} : { allowed_password_encryption_methods: draft.methods }),
...(draft.inheritChannels ? {} : { allowed_password_delivery_channels: draft.channels })
};
}
function stable(value: ArchiveEncryptionPolicyItem): string {
return JSON.stringify({
methods: value.allowed_password_encryption_methods ? [...value.allowed_password_encryption_methods].sort() : null,
channels: value.allowed_password_delivery_channels ? [...value.allowed_password_delivery_channels].sort() : null
});
}
function toggle<T extends string>(values: T[], value: T, checked: boolean): T[] {
return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value);
}
async function loadTargets(settings: ApiSettings, scope: ArchiveEncryptionPolicyScope): Promise<SearchableSelectOption[]> {
if (scope === "group") {
const response = await fetchGroupsDelta(settings, { limit: 1000 });
return response.groups.map((group) => ({ value: group.id, label: group.name, description: group.slug }));
}
if (scope === "user") {
const response = await fetchUsersDelta(settings, { limit: 1000 });
return response.users.map((user) => ({ value: user.id, label: user.display_name || user.email, description: user.email }));
}
return [];
}
+1
View File
@@ -3,4 +3,5 @@ export { default as ViewPoliciesPanel } from "./features/policy/ViewPoliciesPane
export * from "./module";
export * from "./api/adminTargets";
export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel";
export { default as ArchiveEncryptionPoliciesPanel } from "./features/policy/ArchiveEncryptionPoliciesPanel";
export type { PlatformWebModule } from "@govoplan/core-webui";
+65
View File
@@ -3,6 +3,7 @@ import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from
const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel"));
const ViewPoliciesPanel = lazy(() => import("./features/policy/ViewPoliciesPanel"));
const ArchiveEncryptionPoliciesPanel = lazy(() => import("./features/policy/ArchiveEncryptionPoliciesPanel"));
const policyAdminSections: AdminSectionsUiCapability = {
sections: [
@@ -66,6 +67,66 @@ const policyAdminSections: AdminSectionsUiCapability = {
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "system-campaign-archive-encryption",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.system-campaign-archive-encryption",
label: "Campaign archive encryption",
group: "SYSTEM",
order: 75,
allOf: ["admin:policies:read"],
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
settings,
scopeType: "system",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "tenant-campaign-archive-encryption",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.tenant-campaign-archive-encryption",
label: "Campaign archive encryption",
group: "TENANT",
order: 75,
allOf: ["admin:policies:read"],
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
settings,
scopeType: "tenant",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "group-campaign-archive-encryption",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.group-campaign-archive-encryption",
label: "Campaign archive encryption",
group: "GROUP",
order: 25,
allOf: ["admin:policies:read", "admin:groups:read"],
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
settings,
scopeType: "group",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "user-campaign-archive-encryption",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.user-campaign-archive-encryption",
label: "Campaign archive encryption",
group: "USER",
order: 25,
allOf: ["admin:policies:read", "admin:users:read"],
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
settings,
scopeType: "user",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "system-retention",
moduleId: "policy",
@@ -139,6 +200,10 @@ export const policyModule: PlatformWebModule = {
{ 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-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "System Campaign archive encryption", order: 75 },
{ id: "policy.admin.tenant-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "Tenant Campaign archive encryption", order: 75 },
{ id: "policy.admin.group-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "Group Campaign archive encryption", order: 75 },
{ id: "policy.admin.user-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "User Campaign archive encryption", order: 75 },
{ 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 },