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 })} /> +