feat: govern campaign archive encryption
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user