From 176c1d826f323b1c9354323e92fb2082f16b1148 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 7 Aug 2026 14:53:28 +0200 Subject: [PATCH] feat: add DSAR administration --- src/govoplan_admin/backend/manifest.py | 38 ++ webui/src/api/admin.ts | 160 +++++- .../admin/DataSubjectRequestsPanel.tsx | 481 ++++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 122 +++++ webui/src/module.ts | 18 + 5 files changed, 818 insertions(+), 1 deletion(-) create mode 100644 webui/src/features/admin/DataSubjectRequestsPanel.tsx diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py index d724752..b890dc5 100644 --- a/src/govoplan_admin/backend/manifest.py +++ b/src/govoplan_admin/backend/manifest.py @@ -4,6 +4,7 @@ from govoplan_admin.backend.db import models as admin_models # noqa: F401 - pop from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.modules import ( + DocumentationCondition, DocumentationTopic, FrontendModule, MigrationSpec, @@ -170,6 +171,42 @@ manifest = ModuleManifest( ], }, ), + DocumentationTopic( + id="admin.data-subject-request-workspace", + title="Review data-subject requests", + summary="The privacy workspace makes provider coverage, exports, retained evidence, and selected erasure actions explicit.", + body=( + "The data-subject request section is visible only with the Access privacy read permission. Create and search operations require privacy management authority; exports and erasure execution have separate permissions. Subject selectors support account, membership, identity, email, and namespaced source-system references. The workspace never treats a module without a provider as searched. It lists provider coverage and failures next to collected records. An erasure plan separates executable provider actions from retained or manual-review items. Destructive execution uses the current resource revision, selected actions, and an exact confirmation phrase." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("privacy_officer", "tenant_owner", "operator"), + related_modules=("access", "audit", "policy"), + conditions=( + DocumentationCondition( + required_modules=("admin", "access"), + any_scopes=( + "access:privacy:read", + "access:privacy:manage", + "access:privacy:export", + "access:privacy:erase", + ), + ), + ), + translations={ + "de": { + "title": "Betroffenenanfragen prüfen", + "summary": "Der Datenschutz-Arbeitsbereich weist Anbieterabdeckung, Exporte, aufbewahrte Nachweise und ausgewählte Löschaktionen explizit aus.", + "body": "Der Bereich ist nur mit dem Leserecht für Datenschutzanfragen sichtbar. Anlage und Suche, Export sowie Löschausführung besitzen getrennte Rechte. Betroffene Personen können über Konto, Mitgliedschaft, Identität, E-Mail-Adresse und namensraumgebundene Quellsystemreferenzen gesucht werden. Module ohne Anbieter werden nicht als durchsucht dargestellt. Ein Löschplan trennt ausführbare Aktionen von aufzubewahrenden oder manuell zu prüfenden Einträgen. Die Ausführung verlangt die aktuelle Revision, eine Aktionsauswahl und eine exakte Bestätigung.", + } + }, + metadata={ + "kind": "workflow", + "help_contexts": ["admin.privacy.data-subject-requests"], + "route": "/admin?section=tenant-data-subject-requests", + "api_path": "/api/v1/admin/privacy/data-subject-requests", + }, + ), ), frontend=FrontendModule( module_id="admin", @@ -183,6 +220,7 @@ manifest = ModuleManifest( ViewSurface(id="admin.section.system-groups", module_id="admin", kind="section", label="Group templates", order=50), ViewSurface(id="admin.section.system-modules", module_id="admin", kind="section", label="Modules", order=85), ViewSurface(id="admin.section.system-tenant-modules", module_id="admin", kind="section", label="Tenant modules", order=86), + ViewSurface(id="admin.section.tenant-data-subject-requests", module_id="admin", kind="section", label="Data-subject requests", order=55), ViewSurface(id="admin.section.tenant-modules", module_id="admin", kind="section", label="Modules", order=60), ), ), diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index a91986f..6b11d11 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,5 +1,5 @@ import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui"; -import { apiFetch, apiPath, apiQuery } from "@govoplan/core-webui"; +import { apiDownload, apiFetch, apiPath, apiQuery } from "@govoplan/core-webui"; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; export type { AdminOverview, @@ -565,6 +565,102 @@ export type GovernanceTemplateItem = { updated_at: string; }; +export type DataSubjectSelector = { + account_id?: string | null; + identity_id?: string | null; + membership_id?: string | null; + email?: string | null; + external_references?: Record; +}; + +export type DataSubjectRequestSummary = { + id: string; + tenant_id: string; + reference: string; + request_kind: "access" | "erasure" | "access_and_erasure"; + status: string; + subject: DataSubjectSelector; + purpose: string; + legal_basis?: string | null; + due_at?: string | null; + requested_by_account_id: string; + record_count: number; + executable_action_count: number; + coverage: { + covered_modules?: string[]; + modules_without_provider?: string[]; + provider_discovery_failures?: Array>; + }; + evidence_sha256?: string | null; + resource_revision: number; + etag: string; + created_at: string; + updated_at: string; + completed_at?: string | null; + notes?: string | null; +}; + +export type DataSubjectRecord = { + provider_id: string; + module_id: string; + resource_type: string; + resource_id: string; + category: string; + title: string; + data: Record; + observed_at?: string | null; + immutable_evidence: boolean; + retention_reason?: string | null; + source_path?: string | null; +}; + +export type DataSubjectErasureAction = { + action_id: string; + provider_id: string; + module_id: string; + kind: string; + resource_type: string; + resource_id: string; + title: string; + rationale: string; + executable: boolean; + irreversible: boolean; + metadata: Record; +}; + +export type DataSubjectRequestDetail = { + request: DataSubjectRequestSummary; + search: { + records?: DataSubjectRecord[]; + provider_runs?: Array>; + searched_at?: string; + record_count?: number; + }; + erasure_plan: { + actions?: DataSubjectErasureAction[]; + provider_runs?: Array>; + planned_at?: string; + executable_count?: number; + retained_count?: number; + }; + execution: { + results?: Array<{ action_id: string; status: string; summary: string; evidence?: Record }>; + executed_at?: string; + successful_count?: number; + failed_count?: number; + }; +}; + +export type DataSubjectRequestCreatePayload = { + reference: string; + request_kind: DataSubjectRequestSummary["request_kind"]; + subject: DataSubjectSelector; + purpose: string; + legal_basis?: string | null; + due_at?: string | null; + notes?: string | null; +}; + export function fetchSystemSettings(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/admin/system/settings"); } @@ -713,6 +809,68 @@ export function deleteGovernanceTemplate(settings: ApiSettings, templateId: stri return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" }); } +export async function fetchDataSubjectRequests(settings: ApiSettings): Promise { + const response = await apiFetch<{ items: DataSubjectRequestSummary[] }>(settings, "/api/v1/admin/privacy/data-subject-requests"); + return response.items; +} + +export function fetchDataSubjectRequest(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(requestId)}`); +} + +export function createDataSubjectRequest(settings: ApiSettings, payload: DataSubjectRequestCreatePayload): Promise { + return apiFetch(settings, "/api/v1/admin/privacy/data-subject-requests", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function searchDataSubjectRequest(settings: ApiSettings, request: DataSubjectRequestSummary): Promise { + return mutateDataSubjectRequest(settings, request, "search", {}); +} + +export function planDataSubjectErasure(settings: ApiSettings, request: DataSubjectRequestSummary): Promise { + return mutateDataSubjectRequest(settings, request, "erasure-plan", {}); +} + +export function executeDataSubjectErasure( + settings: ApiSettings, + request: DataSubjectRequestSummary, + actionIds: string[], + confirmation: string +): Promise { + return mutateDataSubjectRequest(settings, request, "execute", { + action_ids: actionIds, + confirmation + }); +} + +export function exportDataSubjectRequest(settings: ApiSettings, request: DataSubjectRequestSummary): Promise { + const safeReference = request.reference.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return apiDownload( + settings, + `/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(request.id)}/export`, + `dsar-${safeReference || request.id}.json` + ); +} + +function mutateDataSubjectRequest( + settings: ApiSettings, + request: DataSubjectRequestSummary, + operation: string, + payload: Record +): Promise { + return apiFetch( + settings, + `/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(request.id)}/${operation}`, + { + method: "POST", + headers: { "If-Match": request.etag }, + body: JSON.stringify({ base_revision: request.resource_revision, ...payload }) + } + ); +} + export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> { return apiFetch(settings, "/api/v1/admin/configuration-changes"); } diff --git a/webui/src/features/admin/DataSubjectRequestsPanel.tsx b/webui/src/features/admin/DataSubjectRequestsPanel.tsx new file mode 100644 index 0000000..9ae3355 --- /dev/null +++ b/webui/src/features/admin/DataSubjectRequestsPanel.tsx @@ -0,0 +1,481 @@ +import { useEffect, useMemo, useState } from "react"; +import { Download, Eye, Play, Plus, RefreshCw, Search, ShieldCheck } from "lucide-react"; +import type { ApiSettings, DataGridColumn } from "@govoplan/core-webui"; +import { + AdminIconButton, + AdminPageLayout, + Button, + Card, + DataGrid, + Dialog, + DismissibleAlert, + FormField, + MetricCard, + StatusBadge, + TableActionGroup, + adminErrorMessage, + formatDateTime +} from "@govoplan/core-webui"; +import { + createDataSubjectRequest, + executeDataSubjectErasure, + exportDataSubjectRequest, + fetchDataSubjectRequest, + fetchDataSubjectRequests, + planDataSubjectErasure, + searchDataSubjectRequest, + type DataSubjectErasureAction, + type DataSubjectRecord, + type DataSubjectRequestCreatePayload, + type DataSubjectRequestDetail, + type DataSubjectRequestSummary +} from "../../api/admin"; + +const I18N = { + title: "i18n:govoplan-admin.data_subject_requests.ds001", + description: "i18n:govoplan-admin.data_subject_requests.ds002", + create: "i18n:govoplan-admin.data_subject_requests.ds003", + reload: "i18n:govoplan-admin.data_subject_requests.ds004", + reference: "i18n:govoplan-admin.data_subject_requests.ds005", + kindLabel: "i18n:govoplan-admin.data_subject_requests.ds006", + statusLabel: "i18n:govoplan-admin.data_subject_requests.ds007", + subject: "i18n:govoplan-admin.data_subject_requests.ds008", + due: "i18n:govoplan-admin.data_subject_requests.ds009", + actions: "i18n:govoplan-admin.data_subject_requests.ds010", + inspect: "i18n:govoplan-admin.data_subject_requests.ds011", + noRequests: "i18n:govoplan-admin.data_subject_requests.ds012", + records: "i18n:govoplan-admin.data_subject_requests.ds013", + executable: "i18n:govoplan-admin.data_subject_requests.ds014", + uncovered: "i18n:govoplan-admin.data_subject_requests.ds015", + runSearch: "i18n:govoplan-admin.data_subject_requests.ds016", + plan: "i18n:govoplan-admin.data_subject_requests.ds017", + export: "i18n:govoplan-admin.data_subject_requests.ds018", + execute: "i18n:govoplan-admin.data_subject_requests.ds019", + collectedRecords: "i18n:govoplan-admin.data_subject_requests.ds020", + erasurePlan: "i18n:govoplan-admin.data_subject_requests.ds021", + coverage: "i18n:govoplan-admin.data_subject_requests.ds022", + noSelection: "i18n:govoplan-admin.data_subject_requests.ds023", + provider: "i18n:govoplan-admin.data_subject_requests.ds024", + category: "i18n:govoplan-admin.data_subject_requests.ds025", + evidence: "i18n:govoplan-admin.data_subject_requests.ds026", + decision: "i18n:govoplan-admin.data_subject_requests.ds027", + rationale: "i18n:govoplan-admin.data_subject_requests.ds028", + select: "i18n:govoplan-admin.data_subject_requests.ds029", + createTitle: "i18n:govoplan-admin.data_subject_requests.ds030", + purpose: "i18n:govoplan-admin.data_subject_requests.ds031", + legalBasis: "i18n:govoplan-admin.data_subject_requests.ds032", + email: "i18n:govoplan-admin.data_subject_requests.ds033", + accountIdLabel: "i18n:govoplan-admin.data_subject_requests.ds034", + membershipIdLabel: "i18n:govoplan-admin.data_subject_requests.ds035", + identityIdLabel: "i18n:govoplan-admin.data_subject_requests.ds036", + notes: "i18n:govoplan-admin.data_subject_requests.ds037", + save: "i18n:govoplan-admin.data_subject_requests.ds038", + cancel: "i18n:govoplan-admin.data_subject_requests.ds039", + executeTitle: "i18n:govoplan-admin.data_subject_requests.ds040", + executeWarning: "i18n:govoplan-admin.data_subject_requests.ds041", + confirmation: "i18n:govoplan-admin.data_subject_requests.ds042", + confirmationHelp: "i18n:govoplan-admin.data_subject_requests.ds043", + missingSelector: "i18n:govoplan-admin.data_subject_requests.ds044", + providerGap: "i18n:govoplan-admin.data_subject_requests.ds045", + created: "i18n:govoplan-admin.data_subject_requests.ds046", + searchComplete: "i18n:govoplan-admin.data_subject_requests.ds047", + planComplete: "i18n:govoplan-admin.data_subject_requests.ds048", + executionComplete: "i18n:govoplan-admin.data_subject_requests.ds049", + exportComplete: "i18n:govoplan-admin.data_subject_requests.ds050", + accessRequest: "i18n:govoplan-admin.data_subject_requests.ds051", + erasureRequest: "i18n:govoplan-admin.data_subject_requests.ds052", + combinedRequest: "i18n:govoplan-admin.data_subject_requests.ds053", + mutable: "i18n:govoplan-admin.data_subject_requests.ds054", + retainedEvidence: "i18n:govoplan-admin.data_subject_requests.ds055", + irreversible: "i18n:govoplan-admin.data_subject_requests.ds056", + executableState: "i18n:govoplan-admin.data_subject_requests.ds057", + retainedReview: "i18n:govoplan-admin.data_subject_requests.ds058", + externalReferences: "i18n:govoplan-admin.data_subject_requests.ds059", + externalReferencesHelp: "i18n:govoplan-admin.data_subject_requests.ds060", + invalidExternalReferences: "i18n:govoplan-admin.data_subject_requests.ds061" +} as const; + +type Draft = { + reference: string; + requestKind: DataSubjectRequestSummary["request_kind"]; + email: string; + accountId: string; + membershipId: string; + identityId: string; + externalReferences: string; + purpose: string; + legalBasis: string; + dueDate: string; + notes: string; +}; + +const emptyDraft: Draft = { + reference: "", + requestKind: "access_and_erasure", + email: "", + accountId: "", + membershipId: "", + identityId: "", + externalReferences: "", + purpose: "", + legalBasis: "", + dueDate: "", + notes: "" +}; + +export default function DataSubjectRequestsPanel({ + settings, + canManage, + canExport, + canErase +}: { + settings: ApiSettings; + canManage: boolean; + canExport: boolean; + canErase: boolean; +}) { + const [items, setItems] = useState([]); + const [selected, setSelected] = useState(null); + const [creating, setCreating] = useState(false); + const [executing, setExecuting] = useState(false); + const [draft, setDraft] = useState(emptyDraft); + const [selectedActions, setSelectedActions] = useState([]); + const [confirmation, setConfirmation] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + async function load(preferredId?: string) { + setLoading(true); + setError(""); + try { + const next = await fetchDataSubjectRequests(settings); + setItems(next); + const requestId = preferredId ?? selected?.request.id; + if (requestId && next.some((item) => item.id === requestId)) { + setSelected(await fetchDataSubjectRequest(settings, requestId)); + } else if (selected && !next.some((item) => item.id === selected.request.id)) { + setSelected(null); + } + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, [settings.accessToken, settings.apiBaseUrl]); + + async function inspect(item: DataSubjectRequestSummary) { + setBusy(true); + setError(""); + try { + const detail = await fetchDataSubjectRequest(settings, item.id); + setSelected(detail); + setSelectedActions(defaultActionSelection(detail)); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + async function create() { + if (![draft.email, draft.accountId, draft.membershipId, draft.identityId, draft.externalReferences].some((value) => value.trim())) { + setError(I18N.missingSelector); + return; + } + if (!validExternalReferenceLines(draft.externalReferences)) { + setError(I18N.invalidExternalReferences); + return; + } + setBusy(true); + setError(""); + try { + const detail = await createDataSubjectRequest(settings, createPayload(draft)); + setCreating(false); + setDraft(emptyDraft); + setSelected(detail); + setSuccess(I18N.created); + await load(detail.request.id); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + async function mutate( + operation: (request: DataSubjectRequestSummary) => Promise, + message: string + ): Promise { + if (!selected) return false; + setBusy(true); + setError(""); + setSuccess(""); + try { + const detail = await operation(selected.request); + setSelected(detail); + setSelectedActions(defaultActionSelection(detail)); + setSuccess(message); + await load(detail.request.id); + return true; + } catch (err) { + setError(adminErrorMessage(err)); + return false; + } finally { + setBusy(false); + } + } + + async function download() { + if (!selected) return; + setBusy(true); + setError(""); + try { + await exportDataSubjectRequest(settings, selected.request); + setSuccess(I18N.exportComplete); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + async function execute() { + if (!selected) return; + const succeeded = await mutate( + (request) => executeDataSubjectErasure(settings, request, selectedActions, confirmation), + I18N.executionComplete + ); + if (succeeded) { + setExecuting(false); + setConfirmation(""); + } + } + + const requestColumns = useMemo[]>(() => [ + { + id: "reference", + header: I18N.reference, + width: "minmax(180px, 1fr)", + minWidth: 160, + resizable: true, + sortable: true, + filterable: true, + value: (row) => `${row.reference} ${subjectLabel(row)}`, + render: (row) =>
{row.reference}{subjectLabel(row)}
+ }, + { id: "kind", header: I18N.kindLabel, width: 170, sortable: true, filterable: true, value: (row) => row.request_kind }, + { id: "status", header: I18N.statusLabel, width: 150, sortable: true, filterable: true, value: (row) => row.status, render: (row) => }, + { id: "due", header: I18N.due, width: 170, sortable: true, value: (row) => row.due_at ?? "", render: (row) => row.due_at ? formatDateTime(row.due_at) : "-" }, + { + id: "actions", + header: I18N.actions, + width: 72, + sticky: "end", + align: "right", + render: (row) => , + disabled: busy, + onClick: () => void inspect(row) + }]} /> + } + ], [busy]); + + const recordColumns = useMemo[]>(() => [ + { id: "title", header: I18N.records, width: "minmax(220px, 1.3fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.resource_type}`, render: (row) =>
{row.title}{row.resource_type}
}, + { id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id }, + { id: "category", header: I18N.category, width: 170, sortable: true, filterable: true, value: (row) => row.category }, + { id: "evidence", header: I18N.evidence, width: 190, filterable: true, value: (row) => row.immutable_evidence ? row.retention_reason || "retained" : "mutable", render: (row) => row.immutable_evidence ? : } + ], []); + + const actionColumns = useMemo[]>(() => [ + { + id: "select", + header: I18N.select, + width: 72, + render: (row) => setSelectedActions((current) => event.target.checked ? [...current, row.action_id] : current.filter((id) => id !== row.action_id))} + /> + }, + { id: "action", header: I18N.decision, width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.kind}`, render: (row) =>
{row.title}{row.kind}
}, + { id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id }, + { id: "rationale", header: I18N.rationale, width: "minmax(260px, 1.4fr)", minWidth: 220, resizable: true, value: (row) => row.rationale }, + { id: "state", header: I18N.statusLabel, width: 150, value: (row) => row.executable ? "executable" : row.kind, render: (row) => } + ], [busy, selectedActions]); + + const coverageGaps = selected?.request.coverage.modules_without_provider ?? []; + const actions = selected?.erasure_plan.actions ?? []; + const records = selected?.search.records ?? []; + + return ( + <> + + + } variant="primary" onClick={() => setCreating(true)} disabled={!canManage || busy} /> + } + > +
+ row.id} + emptyText={I18N.noRequests} + /> +
+ + {selected ? <> +
+ + + +
+ + +
+
{I18N.subject}
{subjectLabel(selected.request)}
+
{I18N.kindLabel}
{selected.request.request_kind}
+
{I18N.statusLabel}
+
{I18N.due}
{selected.request.due_at ? formatDateTime(selected.request.due_at) : "-"}
+
{I18N.purpose}
{selected.request.purpose}
+
{I18N.legalBasis}
{selected.request.legal_basis || "-"}
+
+
+ + {selected.request.request_kind !== "access" && } + + {actions.some((action) => action.executable) && } +
+
+ + {coverageGaps.length > 0 && {I18N.providerGap}
{I18N.coverage}: {coverageGaps.join(", ")}
} + + + `${row.provider_id}:${row.resource_type}:${row.resource_id}`} emptyText={I18N.noSelection} /> + + + {actions.length > 0 && + row.action_id} emptyText={I18N.noSelection} /> + } + : {I18N.noSelection}} +
+ + setCreating(false)} + footer={<>} + helpContextId="admin.privacy.data-subject-requests" + > +
+ setDraft({ ...draft, reference: event.target.value })} /> + + setDraft({ ...draft, email: event.target.value })} /> + setDraft({ ...draft, accountId: event.target.value })} /> + setDraft({ ...draft, membershipId: event.target.value })} /> + setDraft({ ...draft, identityId: event.target.value })} /> +