feat: add governed DSAR workflow

This commit is contained in:
2026-08-07 14:53:07 +02:00
parent eb0c01c5d2
commit e6457b3f6b
19 changed files with 2239 additions and 2 deletions
+2
View File
@@ -10,6 +10,7 @@ from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.bootstrap import create_bootstrap_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.dsar import router as dsar_router
from govoplan_core.server.ownership import router as ownership_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions
@@ -72,6 +73,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(create_bootstrap_router(server_config.settings))
api_router.include_router(credential_router)
api_router.include_router(dsar_router)
api_router.include_router(ownership_router)
for router in server_config.post_module_routers:
api_router.include_router(router)
+406
View File
@@ -0,0 +1,406 @@
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.dsar import DsarSubjectRef
from govoplan_core.db.session import get_session
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
data_subject_export,
data_subject_request_dict,
execute_data_subject_erasure,
get_data_subject_request,
list_data_subject_requests,
plan_data_subject_erasure,
search_data_subject_request,
)
READ_SCOPE = "access:privacy:read"
MANAGE_SCOPE = "access:privacy:manage"
EXPORT_SCOPE = "access:privacy:export"
ERASE_SCOPE = "access:privacy:erase"
class DataSubjectSelectorRequest(BaseModel):
account_id: str | None = Field(default=None, max_length=36)
identity_id: str | None = Field(default=None, max_length=36)
membership_id: str | None = Field(default=None, max_length=36)
email: str | None = Field(default=None, max_length=320)
external_references: dict[str, str] = Field(default_factory=dict)
@field_validator("external_references")
@classmethod
def validate_external_references(
cls,
value: dict[str, str],
) -> dict[str, str]:
if len(value) > 50:
raise ValueError("At most 50 external subject references are allowed.")
normalized: dict[str, str] = {}
for raw_key, raw_value in value.items():
key = str(raw_key).strip()
item = str(raw_value).strip()
if not key or not item:
continue
if len(key) > 120 or len(item) > 500:
raise ValueError(
"External subject-reference namespaces are limited to 120 "
"characters and values to 500 characters."
)
normalized[key] = item
return normalized
class DataSubjectRequestCreate(BaseModel):
reference: str = Field(min_length=1, max_length=120)
request_kind: Literal["access", "erasure", "access_and_erasure"]
subject: DataSubjectSelectorRequest
purpose: str = Field(min_length=1, max_length=1000)
legal_basis: str | None = Field(default=None, max_length=1000)
due_at: datetime | None = None
notes: str | None = Field(default=None, max_length=10_000)
class RevisionMutationRequest(BaseModel):
base_revision: int = Field(ge=1)
class DataSubjectExecutionRequest(RevisionMutationRequest):
action_ids: list[str] = Field(min_length=1, max_length=10_000)
confirmation: str = Field(min_length=1, max_length=100)
class DataSubjectRequestResponse(BaseModel):
request: dict[str, Any]
search: dict[str, Any] = Field(default_factory=dict)
erasure_plan: dict[str, Any] = Field(default_factory=dict)
execution: dict[str, Any] = Field(default_factory=dict)
class DataSubjectRequestListResponse(BaseModel):
items: list[dict[str, Any]] = Field(default_factory=list)
router = APIRouter(
prefix="/admin/privacy/data-subject-requests",
tags=["data-subject-requests"],
)
@router.get("", response_model=DataSubjectRequestListResponse)
def list_requests(
limit: int = Query(default=200, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestListResponse:
_require(principal, READ_SCOPE)
rows = list_data_subject_requests(
session,
tenant_id=principal.tenant_id,
limit=limit,
)
return DataSubjectRequestListResponse(
items=[
data_subject_request_dict(row, include_subject=True)
for row in rows
]
)
@router.post(
"",
response_model=DataSubjectRequestResponse,
status_code=status.HTTP_201_CREATED,
)
def create_request(
payload: DataSubjectRequestCreate,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, MANAGE_SCOPE)
try:
row = create_data_subject_request(
session,
tenant_id=principal.tenant_id,
reference=payload.reference,
request_kind=payload.request_kind,
subject=_subject(payload.subject),
purpose=payload.purpose,
legal_basis=payload.legal_basis,
due_at=payload.due_at,
requested_by_account_id=principal.account_id,
notes=payload.notes,
)
_audit(session, principal, row, "privacy.dsar.created")
session.commit()
session.refresh(row)
return _detail(row)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{request_id}", response_model=DataSubjectRequestResponse)
def get_request(
request_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, READ_SCOPE)
return _detail(_row(session, principal, request_id))
@router.post("/{request_id}/search", response_model=DataSubjectRequestResponse)
def search_request(
request_id: str,
payload: RevisionMutationRequest,
request: Request,
if_match: str | None = Header(default=None, alias="If-Match"),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, MANAGE_SCOPE)
return _mutate(
session,
principal,
request_id,
payload.base_revision,
if_match,
lambda row: search_data_subject_request(
session,
registry=_registry(request),
row=row,
expected_revision=payload.base_revision,
),
"privacy.dsar.searched",
)
@router.post("/{request_id}/erasure-plan", response_model=DataSubjectRequestResponse)
def plan_erasure(
request_id: str,
payload: RevisionMutationRequest,
request: Request,
if_match: str | None = Header(default=None, alias="If-Match"),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, MANAGE_SCOPE)
return _mutate(
session,
principal,
request_id,
payload.base_revision,
if_match,
lambda row: plan_data_subject_erasure(
session,
registry=_registry(request),
row=row,
expected_revision=payload.base_revision,
),
"privacy.dsar.erasure_planned",
)
@router.post("/{request_id}/execute", response_model=DataSubjectRequestResponse)
def execute_erasure(
request_id: str,
payload: DataSubjectExecutionRequest,
request: Request,
if_match: str | None = Header(default=None, alias="If-Match"),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, ERASE_SCOPE)
if payload.confirmation != f"ERASE {request_id}":
raise HTTPException(
status_code=400,
detail=f'Type "ERASE {request_id}" to confirm the selected actions.',
)
return _mutate(
session,
principal,
request_id,
payload.base_revision,
if_match,
lambda row: execute_data_subject_erasure(
session,
registry=_registry(request),
row=row,
expected_revision=payload.base_revision,
action_ids=payload.action_ids,
),
"privacy.dsar.erasure_executed",
details={"action_ids": payload.action_ids},
)
@router.get("/{request_id}/export")
def export_request(
request_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> Response:
_require(principal, EXPORT_SCOPE)
row = _row(session, principal, request_id)
content = data_subject_export(row)
_audit(
session,
principal,
row,
"privacy.dsar.exported",
details={"export_bytes": len(content)},
)
session.commit()
safe_reference = re.sub(r"[^A-Za-z0-9._-]+", "-", row.reference).strip("-")
filename = f"dsar-{safe_reference or row.id}.json"
return Response(
content=content,
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _mutate(
session: Session,
principal: ApiPrincipal,
request_id: str,
base_revision: int,
if_match: str | None,
operation: Any,
audit_action: str,
*,
details: dict[str, Any] | None = None,
) -> DataSubjectRequestResponse:
try:
assert_revision_precondition(
if_match,
resource_type="data_subject_request",
resource_id=request_id,
submitted_base_revision=base_revision,
)
row = get_data_subject_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
for_update=True,
)
operation(row)
_audit(session, principal, row, audit_action, details=details)
session.commit()
session.refresh(row)
return _detail(row)
except LookupError as exc:
session.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except MissingPreconditionError as exc:
session.rollback()
raise HTTPException(status_code=428, detail=exc.as_dict()) from exc
except RevisionConflictError as exc:
session.rollback()
raise HTTPException(status_code=409, detail=exc.as_dict()) from exc
except ConcurrencyError as exc:
session.rollback()
raise HTTPException(status_code=412, detail=str(exc)) from exc
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _row(
session: Session,
principal: ApiPrincipal,
request_id: str,
) -> DataSubjectRequest:
try:
return get_data_subject_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
def _detail(row: DataSubjectRequest) -> DataSubjectRequestResponse:
return DataSubjectRequestResponse(
request=data_subject_request_dict(row, include_subject=True),
search=dict(row.search_result),
erasure_plan=dict(row.erasure_plan),
execution=dict(row.execution_result),
)
def _subject(payload: DataSubjectSelectorRequest) -> DsarSubjectRef:
return DsarSubjectRef(
account_id=_text(payload.account_id),
identity_id=_text(payload.identity_id),
membership_id=_text(payload.membership_id),
email=_text(payload.email),
external_references={
str(key).strip(): str(value).strip()
for key, value in payload.external_references.items()
if str(key).strip() and str(value).strip()
},
)
def _registry(request: Request) -> object:
registry = getattr(request.app.state, "govoplan_registry", None)
if registry is None:
raise HTTPException(status_code=503, detail="Module registry is unavailable.")
return registry
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _audit(
session: Session,
principal: ApiPrincipal,
row: DataSubjectRequest,
action: str,
*,
details: dict[str, Any] | None = None,
) -> None:
audit_event(
session,
tenant_id=principal.tenant_id,
action=action,
user_id=principal.membership_id,
api_key_id=principal.api_key_id,
object_type="data_subject_request",
object_id=row.id,
details={
"reference": row.reference,
"status": row.status,
"resource_revision": row.resource_revision,
**(details or {}),
},
)
def _text(value: str | None) -> str | None:
normalized = (value or "").strip()
return normalized or None
__all__ = ["router"]