diff --git a/README.md b/README.md index 87538c7..a7e781e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,23 @@ The canonical global route is `/reports`. `/reporting` remains a Reporting-owned compatibility route for saved links. Domain modules may keep their own operational report routes, but do not register `/reports`. +## Data-subject requests + +Reporting publishes `privacy.dsar.reporting` for private saved views, +short-lived drill contexts, subject access grants, minimized staff +attribution, and explicitly identified retained executions, exports, and +publications. DSAR output never copies report rows, parameters, filters, +delivery targets, source payloads, diagnostics, provenance bodies, or hashes. +The source module remains responsible for locating and correcting subject +facts; arbitrary aggregate report output is not searched as if Reporting were +the authoritative owner. + +Private views and drill contexts can be removed, grants revoked, and exact +retained result or publication detail minimized idempotently. Shared views, +definitions, schedules, quality/import evidence, and institutional attribution +require authorized review or retention. Source facts must be corrected before +rerunning or republishing a report. + See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary decision. The behavior-level comparison with the supplied SuperX module set is recorded in diff --git a/src/govoplan_reporting/backend/dsar_provider.py b/src/govoplan_reporting/backend/dsar_provider.py new file mode 100644 index 0000000..048db3c --- /dev/null +++ b/src/govoplan_reporting/backend/dsar_provider.py @@ -0,0 +1,951 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_reporting.backend.db.models import ( + ReportingDefinitionGrant, + ReportingDefinitionIdentity, + ReportingDefinitionRevision, + ReportingDrillContext, + ReportingExecution, + ReportingImportAssessment, + ReportingProviderExecution, + ReportingProviderExport, + ReportingPublication, + ReportingQualityResult, + ReportingSavedView, + ReportingSchedule, +) + + +REPORTING_DSAR_CAPABILITY = dsar_capability_name("reporting") +_MAX_RECORDS = 5_000 +_CONFLICT = object() +_DIRECT_ALIASES = { + "definition_id": ("reporting.definition",), + "revision_id": ( + "reporting.definition_revision", + "reporting.revision", + ), + "execution_id": ("reporting.execution",), + "provider_execution_id": ("reporting.provider_execution",), + "provider_export_id": ("reporting.provider_export",), + "grant_id": ("reporting.definition_grant", "reporting.grant"), + "saved_view_id": ("reporting.saved_view",), + "schedule_id": ("reporting.schedule",), + "publication_id": ("reporting.publication",), + "drill_context_id": ("reporting.drill_context",), + "quality_result_id": ("reporting.quality_result",), + "import_assessment_id": ("reporting.import_assessment",), +} +_RESOURCE_MODELS = { + "reporting_definition": ReportingDefinitionIdentity, + "reporting_definition_revision": ReportingDefinitionRevision, + "reporting_execution": ReportingExecution, + "reporting_provider_execution": ReportingProviderExecution, + "reporting_provider_export": ReportingProviderExport, + "reporting_definition_grant": ReportingDefinitionGrant, + "reporting_saved_view": ReportingSavedView, + "reporting_schedule": ReportingSchedule, + "reporting_publication": ReportingPublication, + "reporting_drill_context": ReportingDrillContext, + "reporting_quality_result": ReportingQualityResult, + "reporting_import_assessment": ReportingImportAssessment, +} +_EXECUTABLE_KINDS = { + "reporting_execution": "anonymize", + "reporting_provider_execution": "anonymize", + "reporting_provider_export": "anonymize", + "reporting_publication": "anonymize", + "reporting_definition_grant": "revoke", + "reporting_saved_view": "delete", + "reporting_drill_context": "delete", +} + + +@dataclass(frozen=True, slots=True) +class _Selectors: + account_id: str | None + identity_id: str | None + membership_id: str | None + direct: dict[str, str] + + @property + def actor_ids(self) -> tuple[str, ...]: + return tuple( + value + for value in (self.account_id, self.identity_id, self.membership_id) + if value + ) + + +@dataclass(frozen=True, slots=True) +class _Match: + resource_type: str + row: Any + category: str + + +class ReportingDsarProvider: + provider_id = "reporting" + module_id = "reporting" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + selectors = _selectors(subject) + if selectors is None or not (selectors.actor_ids or selectors.direct): + return () + + direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors) + if direct is None: + return () + if direct: + if selectors.actor_ids and not all( + _correlates( + db, + tenant_id=tenant_id, + match=match, + actor_ids=selectors.actor_ids, + ) + for match in direct + ): + return () + matches = direct + else: + matches = _canonical_matches( + db, + tenant_id=tenant_id, + actor_ids=selectors.actor_ids, + ) + + records: list[DsarRecordRef] = [] + seen: set[tuple[str, str]] = set() + for match in matches: + key = (match.resource_type, str(match.row.id)) + if key in seen: + continue + if len(records) >= _MAX_RECORDS: + raise ValueError( + "Reporting DSAR result limit exceeded; narrow the selectors." + ) + seen.add(key) + records.append(_record(match)) + return tuple(records) + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + del tenant_id + _session(session) + if _selectors(subject) is None: + raise ValueError("Reporting DSAR subject selectors conflict.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + kind = _planned_kind(record) + executable = kind in {"delete", "anonymize", "revoke"} + actions.append( + DsarErasureActionRef( + action_id=( + f"reporting:{kind}:{record.resource_type}:{record.resource_id}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind=kind, + resource_type=record.resource_type, + resource_id=record.resource_id, + title=( + f"Minimize {record.title}" + if kind == "anonymize" + else f"{kind.replace('_', ' ').title()} {record.title}" + ), + rationale=_rationale(record, kind=kind), + executable=executable, + irreversible=kind in {"delete", "anonymize"}, + metadata={"record_category": record.category}, + ) + ) + return tuple(actions) + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + db = _session(session) + selectors = _selectors(subject) + if selectors is None: + raise ValueError("Reporting DSAR subject selectors conflict.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + if not action.executable: + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Review institutional reporting evidence, shared " + "configuration, retention, and third-party impact." + ), + evidence={"request_id": request_id}, + ) + ) + continue + + model = _RESOURCE_MODELS[action.resource_type] + row = ( + db.query(model) + .filter(model.tenant_id == tenant_id, model.id == action.resource_id) + .with_for_update() + .one_or_none() + ) + if row is None: + status = "unchanged" + summary = "Reporting row was already absent or minimized." + else: + match = _Match(action.resource_type, row, "execution") + if not ( + _directly_targets(selectors, match) + or _correlates( + db, + tenant_id=tenant_id, + match=match, + actor_ids=selectors.actor_ids, + ) + ): + raise ValueError( + "Reporting DSAR action is not corroborated by the subject." + ) + status, summary = _execute_action( + db, + row=row, + resource_type=action.resource_type, + kind=action.kind, + ) + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status=status, + summary=summary, + evidence={"request_id": request_id}, + ) + ) + return tuple(results) + + +def _direct_matches( + session: Session, + *, + tenant_id: str, + selectors: _Selectors, +) -> list[_Match] | None: + matches: list[_Match] = [] + for selector, value in selectors.direct.items(): + current: list[_Match] + if selector == "definition_id": + identities = _rows( + session, + ReportingDefinitionIdentity, + tenant_id=tenant_id, + field="definition_id", + value=value, + ) + revisions = _rows( + session, + ReportingDefinitionRevision, + tenant_id=tenant_id, + field="definition_id", + value=value, + ) + current = [ + *( + _Match("reporting_definition", row, "reporting_configuration") + for row in identities + ), + *( + _Match( + "reporting_definition_revision", + row, + "reporting_configuration", + ) + for row in revisions + ), + ] + else: + model, field, resource_type = { + "revision_id": ( + ReportingDefinitionRevision, + "id", + "reporting_definition_revision", + ), + "execution_id": ( + ReportingExecution, + "execution_id", + "reporting_execution", + ), + "provider_execution_id": ( + ReportingProviderExecution, + "execution_id", + "reporting_provider_execution", + ), + "provider_export_id": ( + ReportingProviderExport, + "export_id", + "reporting_provider_export", + ), + "grant_id": ( + ReportingDefinitionGrant, + "id", + "reporting_definition_grant", + ), + "saved_view_id": ( + ReportingSavedView, + "view_id", + "reporting_saved_view", + ), + "schedule_id": ( + ReportingSchedule, + "schedule_id", + "reporting_schedule", + ), + "publication_id": ( + ReportingPublication, + "publication_id", + "reporting_publication", + ), + "drill_context_id": ( + ReportingDrillContext, + "drill_context_id", + "reporting_drill_context", + ), + "quality_result_id": ( + ReportingQualityResult, + "result_id", + "reporting_quality_result", + ), + "import_assessment_id": ( + ReportingImportAssessment, + "assessment_id", + "reporting_import_assessment", + ), + }[selector] + current = [ + _Match(resource_type, row, _direct_category(resource_type, row)) + for row in _rows( + session, + model, + tenant_id=tenant_id, + field=field, + value=value, + ) + ] + if not current: + return None + matches.extend(current) + if len(matches) > _MAX_RECORDS: + raise ValueError( + "Reporting DSAR result limit exceeded; narrow the selectors." + ) + return matches + + +def _canonical_matches( + session: Session, + *, + tenant_id: str, + actor_ids: tuple[str, ...], +) -> list[_Match]: + if not actor_ids: + return [] + specs = ( + ( + ReportingDefinitionIdentity, + "created_by", + "reporting_definition", + "reporting_operator_attribution", + ), + ( + ReportingDefinitionRevision, + "changed_by", + "reporting_definition_revision", + "reporting_operator_attribution", + ), + ( + ReportingExecution, + "actor_id", + "reporting_execution", + "reporting_operator_attribution", + ), + ( + ReportingProviderExecution, + "actor_id", + "reporting_provider_execution", + "reporting_operator_attribution", + ), + ( + ReportingProviderExport, + "actor_id", + "reporting_provider_export", + "reporting_operator_attribution", + ), + ( + ReportingSavedView, + "owner_id", + "reporting_saved_view", + "subject_owned_reporting_view", + ), + ( + ReportingSchedule, + "created_by", + "reporting_schedule", + "reporting_operator_attribution", + ), + ( + ReportingDrillContext, + "actor_id", + "reporting_drill_context", + "subject_owned_drill_context", + ), + ( + ReportingQualityResult, + "actor_id", + "reporting_quality_result", + "reporting_operator_attribution", + ), + ( + ReportingImportAssessment, + "assessed_by", + "reporting_import_assessment", + "reporting_operator_attribution", + ), + ( + ReportingDefinitionGrant, + "subject_id", + "reporting_definition_grant", + "subject_access_grant", + ), + ) + matches: list[_Match] = [] + for model, field, resource_type, category in specs: + rows = ( + session.query(model) + .filter( + model.tenant_id == tenant_id, + getattr(model, field).in_(actor_ids), + ) + .order_by(model.id) + .limit(_MAX_RECORDS + 1) + .all() + ) + matches.extend( + _Match( + resource_type, + row, + _direct_category(resource_type, row) + if resource_type == "reporting_saved_view" + else category, + ) + for row in rows + ) + if len(matches) > _MAX_RECORDS: + raise ValueError( + "Reporting DSAR result limit exceeded; narrow the selectors." + ) + return matches + + +def _rows( + session: Session, + model: Any, + *, + tenant_id: str, + field: str, + value: str, +) -> list[Any]: + return ( + session.query(model) + .filter(model.tenant_id == tenant_id, getattr(model, field) == value) + .order_by(model.id) + .limit(_MAX_RECORDS + 1) + .all() + ) + + +def _correlates( + session: Session, + *, + tenant_id: str, + match: _Match, + actor_ids: tuple[str, ...], +) -> bool: + if not actor_ids: + return False + row = match.row + field = { + "reporting_definition": "created_by", + "reporting_definition_revision": "changed_by", + "reporting_execution": "actor_id", + "reporting_provider_execution": "actor_id", + "reporting_provider_export": "actor_id", + "reporting_definition_grant": "subject_id", + "reporting_saved_view": "owner_id", + "reporting_schedule": "created_by", + "reporting_drill_context": "actor_id", + "reporting_quality_result": "actor_id", + "reporting_import_assessment": "assessed_by", + }.get(match.resource_type) + if field and str(getattr(row, field, "") or "") in actor_ids: + return True + if match.resource_type == "reporting_definition_revision": + identity = session.get(ReportingDefinitionIdentity, row.identity_id) + return bool( + identity + and identity.tenant_id == tenant_id + and identity.created_by in actor_ids + ) + if match.resource_type == "reporting_provider_export": + execution = session.get( + ReportingProviderExecution, + row.provider_execution_id, + ) + return bool( + execution + and execution.tenant_id == tenant_id + and execution.actor_id in actor_ids + ) + if match.resource_type == "reporting_publication": + execution = ( + session.query(ReportingExecution) + .filter( + ReportingExecution.tenant_id == tenant_id, + ReportingExecution.execution_id == row.execution_id, + ) + .one_or_none() + ) + return bool(execution and execution.actor_id in actor_ids) + return False + + +def _directly_targets(selectors: _Selectors, match: _Match) -> bool: + row = match.row + selector, field = { + "reporting_definition": ("definition_id", "definition_id"), + "reporting_definition_revision": ("revision_id", "id"), + "reporting_execution": ("execution_id", "execution_id"), + "reporting_provider_execution": ( + "provider_execution_id", + "execution_id", + ), + "reporting_provider_export": ("provider_export_id", "export_id"), + "reporting_definition_grant": ("grant_id", "id"), + "reporting_saved_view": ("saved_view_id", "view_id"), + "reporting_schedule": ("schedule_id", "schedule_id"), + "reporting_publication": ("publication_id", "publication_id"), + "reporting_drill_context": ("drill_context_id", "drill_context_id"), + "reporting_quality_result": ("quality_result_id", "result_id"), + "reporting_import_assessment": ( + "import_assessment_id", + "assessment_id", + ), + }[match.resource_type] + value = getattr(row, field) + if selectors.direct.get(selector) == str(value): + return True + return ( + match.resource_type == "reporting_definition_revision" + and selectors.direct.get("definition_id") == row.definition_id + ) + + +def _direct_category(resource_type: str, row: Any) -> str: + if resource_type == "reporting_saved_view": + return "shared_reporting_view" if row.shared else "subject_owned_reporting_view" + return { + "reporting_execution": "derived_report_result", + "reporting_provider_execution": "derived_provider_report_result", + "reporting_provider_export": "derived_provider_report_export", + "reporting_publication": "derived_report_publication", + "reporting_definition_grant": "subject_access_grant", + "reporting_drill_context": "subject_owned_drill_context", + }.get(resource_type, "reporting_configuration") + + +def _record(match: _Match) -> DsarRecordRef: + row = match.row + data = _record_data(match.resource_type, row) + immutable = match.category == "reporting_operator_attribution" + return DsarRecordRef( + provider_id="reporting", + module_id="reporting", + resource_type=match.resource_type, + resource_id=str(row.id), + category=match.category, + title=_title(match.resource_type), + data={key: value for key, value in data.items() if value is not None}, + observed_at=_observed_at(row), + immutable_evidence=immutable, + retention_reason=( + "Institutional reporting activity remains attributable for " + "governance and audit review." + if immutable + else None + ), + source_path="/reports", + ) + + +def _record_data(resource_type: str, row: Any) -> dict[str, object]: + if resource_type == "reporting_definition": + return { + "definition_kind": row.definition_kind, + "definition_id": row.definition_id, + "definition_key": row.definition_key, + "created_at": _iso(row.created_at), + } + if resource_type == "reporting_definition_revision": + return { + "definition_kind": row.definition_kind, + "definition_id": row.definition_id, + "revision": row.revision, + "status": row.status, + "visibility": row.visibility, + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + } + if resource_type == "reporting_execution": + return { + "execution_id": row.execution_id, + "report_id": row.report_id, + "report_revision": row.report_revision, + "semantic_model_id": row.semantic_model_id, + "semantic_model_revision": row.semantic_model_revision, + "dataset_id": row.dataset_id, + "dataset_revision": row.dataset_revision, + "status": row.status, + "total_rows": row.total_rows, + "truncated": row.truncated, + "has_retained_result": bool(row.result_rows), + "started_at": _iso(row.started_at), + "finished_at": _iso(row.finished_at), + } + if resource_type == "reporting_provider_execution": + return { + "execution_id": row.execution_id, + "provider_id": row.provider_id, + "report_id": row.report_id, + "report_revision": row.report_revision, + "contract_version": row.contract_version, + "privacy_transforms": list(row.privacy_transforms or []), + "retention_class": row.retention_class, + "retention_days": row.retention_days, + "expires_at": _iso(row.expires_at), + "retention_redacted_at": _iso(row.retention_redacted_at), + "has_retained_result": bool(row.result_payload), + "generated_at": _iso(row.generated_at), + } + if resource_type == "reporting_provider_export": + return { + "export_id": row.export_id, + "execution_id": row.execution_id, + "format": row.format, + "exported_at": _iso(row.exported_at), + } + if resource_type == "reporting_definition_grant": + return { + "definition_kind": row.definition_kind, + "definition_id": row.definition_id, + "subject_kind": row.subject_kind, + "permissions": list(row.permissions or []), + "active": row.active, + "source_revision": row.source_revision, + } + if resource_type == "reporting_saved_view": + return { + "view_id": row.view_id, + "report_id": row.report_id, + "report_revision": row.report_revision, + "owner_kind": row.owner_kind, + "revision": row.revision, + "shared": row.shared, + "created_at": _iso(row.created_at), + "updated_at": _iso(row.updated_at), + } + if resource_type == "reporting_schedule": + return { + "schedule_id": row.schedule_id, + "report_id": row.report_id, + "report_revision": row.report_revision, + "revision": row.revision, + "trigger_kind": row.trigger_kind, + "enabled": row.enabled, + "next_run_at": _iso(row.next_run_at), + "last_run_at": _iso(row.last_run_at), + } + if resource_type == "reporting_publication": + return { + "publication_id": row.publication_id, + "execution_id": row.execution_id, + "target_capability": row.target_capability, + "format": row.format, + "status": row.status, + "completed_at": _iso(row.completed_at), + "created_at": _iso(row.created_at), + } + if resource_type == "reporting_drill_context": + return { + "drill_context_id": row.drill_context_id, + "execution_id": row.execution_id, + "expires_at": _iso(row.expires_at), + "last_accessed_at": _iso(row.last_accessed_at), + } + if resource_type == "reporting_quality_result": + return { + "result_id": row.result_id, + "quality_plan_id": row.quality_plan_id, + "quality_plan_revision": row.quality_plan_revision, + "dataset_id": row.dataset_id, + "dataset_revision": row.dataset_revision, + "status": row.status, + "evaluated_at": _iso(row.evaluated_at), + } + return { + "assessment_id": row.assessment_id, + "source_system": row.source_system, + "status": row.status, + "created_at": _iso(row.created_at), + } + + +def _planned_kind(record: DsarRecordRef) -> str: + if record.category == "reporting_operator_attribution": + return "retain" + if record.category in {"reporting_configuration", "shared_reporting_view"}: + return "manual_review" + return _EXECUTABLE_KINDS.get(record.resource_type, "manual_review") + + +def _rationale(record: DsarRecordRef, *, kind: str) -> str: + if kind == "delete": + return "Remove subject-owned, non-authoritative Reporting workspace state." + if kind == "anonymize": + return ( + "Clear retained result or delivery detail while preserving hashes and " + "minimal institutional execution evidence." + ) + if kind == "revoke": + return "Disable the subject-specific Reporting access relationship." + if kind == "retain": + return record.retention_reason or "Retain institutional attribution evidence." + return ( + "An authorized Reporting owner must review shared configuration, " + "dependencies, legal retention, and third-party impact." + ) + + +def _execute_action( + session: Session, + *, + row: Any, + resource_type: str, + kind: str, +) -> tuple[str, str]: + expected = _EXECUTABLE_KINDS.get(resource_type) + if expected != kind: + raise ValueError("Reporting DSAR executable action is not supported.") + if resource_type == "reporting_saved_view": + if row.shared: + raise ValueError("Shared Reporting views require manual review.") + session.delete(row) + session.flush() + return "executed", "Subject-owned Reporting view removed." + if resource_type == "reporting_drill_context": + session.delete(row) + session.flush() + return "executed", "Ephemeral Reporting drill context removed." + if resource_type == "reporting_definition_grant": + if not row.active: + return "unchanged", "Reporting access grant was already inactive." + row.active = False + session.flush() + return "executed", "Subject-specific Reporting access grant revoked." + if resource_type == "reporting_execution": + fields = { + "parameters": {}, + "query": {}, + "source_fingerprints": [], + "result_rows": [], + "diagnostics": [], + "provenance": {}, + } + changed = _replace_fields(row, fields) + elif resource_type == "reporting_provider_execution": + fields = { + "purpose": "Redacted by data-subject request.", + "audience_scope": {}, + "parameters": {}, + "result_payload": {}, + "source_revisions": [], + "effective_scope": {}, + "provenance": {}, + "governance_provenance": {}, + } + changed = _replace_fields(row, fields) + if row.retention_redacted_at is None: + row.retention_redacted_at = datetime.now(timezone.utc) + changed = True + elif resource_type == "reporting_provider_export": + changed = _replace_fields( + row, + { + "purpose": "Redacted by data-subject request.", + "audience_scope": {}, + }, + ) + else: + changed = _replace_fields( + row, + {"target_ref": None, "evidence": {}, "error": None}, + ) + if changed: + session.flush() + return "executed", "Retained Reporting detail minimized; hashes remain." + return "unchanged", "Retained Reporting detail was already minimized." + + +def _replace_fields(row: Any, values: dict[str, object]) -> bool: + changed = False + for field, value in values.items(): + if getattr(row, field) != value: + setattr(row, field, value) + changed = True + return changed + + +def _selectors(subject: DsarSubjectRef) -> _Selectors | None: + references = subject.external_references + account_id = _coalesce( + subject.account_id, + references.get("reporting.account"), + references.get("access.account"), + ) + identity_id = _coalesce( + subject.identity_id, + references.get("reporting.identity"), + references.get("identity.id"), + ) + membership_id = _coalesce( + subject.membership_id, + references.get("reporting.membership"), + references.get("tenancy.membership"), + ) + direct: dict[str, str] = {} + for selector, aliases in _DIRECT_ALIASES.items(): + value = _coalesce(*(references.get(alias) for alias in aliases)) + if value is _CONFLICT: + return None + if value: + direct[selector] = str(value) + if _CONFLICT in {account_id, identity_id, membership_id}: + return None + return _Selectors( + account_id=_optional(account_id), + identity_id=_optional(identity_id), + membership_id=_optional(membership_id), + direct=direct, + ) + + +def _coalesce(*values: str | None) -> str | None | object: + normalized = {str(value).strip() for value in values if str(value or "").strip()} + if len(normalized) > 1: + return _CONFLICT + return next(iter(normalized), None) + + +def _optional(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _title(resource_type: str) -> str: + return resource_type.removeprefix("reporting_").replace("_", " ").title() + + +def _observed_at(row: Any) -> datetime | None: + for field in ( + "generated_at", + "exported_at", + "evaluated_at", + "recorded_at", + "started_at", + "completed_at", + "updated_at", + "created_at", + ): + value = getattr(row, field, None) + if isinstance(value, datetime): + return _aware(value) + return None + + +def _iso(value: datetime | None) -> str | None: + aware = _aware(value) + return aware.isoformat() if aware else None + + +def _aware(value: datetime | None) -> datetime | None: + if value is None or value.tzinfo is not None: + return value + return value.replace(tzinfo=timezone.utc) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Reporting DSAR requires a SQLAlchemy Session.") + return value + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "reporting" or record.module_id != "reporting": + raise ValueError("Reporting DSAR cannot plan a foreign provider record.") + if record.resource_type not in _RESOURCE_MODELS or not record.resource_id: + raise ValueError("Reporting DSAR record identity is invalid.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "reporting" or action.module_id != "reporting": + raise ValueError("Reporting DSAR cannot execute a foreign provider action.") + if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith( + "reporting:" + ): + raise ValueError("Reporting DSAR action identity is invalid.") + + +__all__ = ["REPORTING_DSAR_CAPABILITY", "ReportingDsarProvider"] diff --git a/src/govoplan_reporting/backend/manifest.py b/src/govoplan_reporting/backend/manifest.py index ee93f48..dda87ed 100644 --- a/src/govoplan_reporting/backend/manifest.py +++ b/src/govoplan_reporting/backend/manifest.py @@ -51,6 +51,10 @@ from govoplan_reporting.backend.contracts import ( CAPABILITY_REPORTING_SCHEDULER, ) from govoplan_reporting.backend.db import models as reporting_models +from govoplan_reporting.backend.dsar_provider import ( + REPORTING_DSAR_CAPABILITY, + ReportingDsarProvider, +) from govoplan_reporting.backend.definitions import ( ADMIN_SCOPE, READ_SCOPE, @@ -199,6 +203,11 @@ def _retention(context: ModuleContext): return ReportingRetentionService() +def _dsar_provider(context: ModuleContext) -> ReportingDsarProvider: + del context + return ReportingDsarProvider() + + def _tenant_summary(session, tenant_id: str) -> dict[str, int]: definitions = ( session.query(reporting_models.ReportingDefinitionRevision) @@ -315,7 +324,11 @@ manifest = ModuleManifest( label="i18n:govoplan-core.product_area.data_assurance", icon="database-zap", description="i18n:govoplan-core.product_area.data_assurance_description", - surface_ids=("reporting.navigation", "reporting.workspace", "reporting.compatibility"), + surface_ids=( + "reporting.navigation", + "reporting.workspace", + "reporting.compatibility", + ), order=60, ), ), @@ -357,6 +370,7 @@ manifest = ModuleManifest( name=CAPABILITY_REPORTING_PUBLICATION_MAIL, version="1.0.0" ), ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"), + ModuleInterfaceProvider(name=REPORTING_DSAR_CAPABILITY, version="0.1.0"), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -392,6 +406,7 @@ manifest = ModuleManifest( CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication, CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication, CAPABILITY_REPORTING_RETENTION: _retention, + REPORTING_DSAR_CAPABILITY: _dsar_provider, }, capability_documentation={ CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation( @@ -431,6 +446,13 @@ manifest = ModuleManifest( documentation_types=("admin",), audience=("privacy_officer", "operator", "system_admin"), ), + REPORTING_DSAR_CAPABILITY: CapabilityDocumentation( + label="Reporting data-subject request provider", + summary="Finds subject-owned Reporting state and minimizes derived report copies.", + contract_version="0.1.0", + documentation_types=("admin", "user"), + audience=("privacy_officer", "operator", "user"), + ), }, search_sources=( SearchSourceProviderRegistration( @@ -488,6 +510,34 @@ manifest = ModuleManifest( ), tenant_summary_providers=(_tenant_summary,), documentation=( + DocumentationTopic( + id="reporting.data-subject-requests", + title="Reporting data-subject requests", + summary="Review personal workspace state and derived report copies without confusing them with source-owned facts.", + body=( + "Reporting matches exact tenant-scoped artifact references and account, identity, or membership ownership and attribution. Access output is deliberately minimized: report rows, parameters, filters, delivery targets, source payloads, diagnostics, provenance bodies, and hashes are not copied into the DSAR result. Source modules remain responsible for finding and correcting subject facts; Reporting cannot safely infer a person by scanning arbitrary aggregate output. " + "Private saved views and short-lived drill contexts can be deleted, subject grants can be revoked, and explicitly identified retained execution or publication detail can be minimized idempotently while hashes remain. Shared views, definitions, schedules, quality/import evidence, and staff attribution require authorized review or retention. Correct the source before rerunning a report or republishing an output." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + related_modules=("core", "datasources", "dataflow", "policy"), + metadata={ + "kind": "reference", + "help_contexts": [ + "reporting.data-subject-requests", + "reporting.workspace", + ], + }, + links=( + DocumentationLink( + label="Reporting governance and retention", + href="govoplan-reporting/README.md", + kind="repository", + ), + ), + order=9, + ), DocumentationTopic( id="reporting.governed-bi", title="Governed reporting and semantic BI", diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..f134585 --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,644 @@ +from __future__ import annotations + +import json +import unittest +from datetime import UTC, datetime, timedelta + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarProvider, + DsarRecordRef, + DsarSubjectRef, +) +from govoplan_core.db.base import Base +from govoplan_core.privacy.dsar_workflow import ( + create_data_subject_request, + search_data_subject_request, +) +from govoplan_reporting.backend.db.models import ( + ReportingDefinitionGrant, + ReportingDefinitionIdentity, + ReportingDefinitionRevision, + ReportingDrillContext, + ReportingExecution, + ReportingImportAssessment, + ReportingProviderExecution, + ReportingProviderExport, + ReportingPublication, + ReportingQualityResult, + ReportingSavedView, + ReportingSchedule, +) +from govoplan_reporting.backend.dsar_provider import ( + REPORTING_DSAR_CAPABILITY, + ReportingDsarProvider, +) +from govoplan_reporting.backend.manifest import manifest + + +NOW = datetime(2026, 8, 21, 18, 0, tzinfo=UTC) +SECRET = "personal-report-detail-do-not-export" + + +class _Registry: + def __init__(self, provider: ReportingDsarProvider, *, active: bool = True) -> None: + self.provider = provider + self.active = active + + def capability_names(self): + return (REPORTING_DSAR_CAPABILITY,) + + def capability_owner(self, name): + self._assert_capability(name) + return "reporting" + + def tenant_entitlement_resolver(self): + active = self.active + + class _Resolver: + @staticmethod + def resolve(session, tenant_id): + del session, tenant_id + return type( + "State", + (), + {"effective_modules": ("reporting",) if active else ()}, + )() + + return _Resolver() + + def require_tenant_capability(self, name, session, **kwargs): + del session, kwargs + self._assert_capability(name) + return self.provider + + def manifests(self): + return (type("Manifest", (), {"id": "reporting"})(),) + + @staticmethod + def _assert_capability(name: str) -> None: + if name != REPORTING_DSAR_CAPABILITY: + raise KeyError(name) + + +class ReportingDsarProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(self.engine) + self.session = Session(self.engine) + self.provider = ReportingDsarProvider() + self.assertIsInstance(self.provider, DsarProvider) + self._seed() + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def _seed(self) -> None: + identity = ReportingDefinitionIdentity( + id="definition-row-1", + tenant_id="tenant-1", + definition_kind="report", + definition_id="report-1", + definition_key="resident-permits", + created_by="account-1", + ) + self.session.add(identity) + self.session.flush() + self.session.add( + ReportingDefinitionRevision( + id="revision-row-1", + tenant_id="tenant-1", + identity_id=identity.id, + definition_kind="report", + definition_id="report-1", + definition_key="resident-permits", + revision=1, + name=SECRET, + description=SECRET, + status="active", + visibility="restricted", + content_hash="a" * 64, + change_reason=SECRET, + idempotency_key=SECRET, + request_sha256="b" * 64, + event_id="event-1", + recorded_at=NOW, + payload={"secret": SECRET}, + changed_by="account-1", + ) + ) + self.session.add_all( + ( + self._execution( + row_id="execution-row-1", + tenant_id="tenant-1", + execution_id="execution-1", + actor_id="account-1", + ), + self._execution( + row_id="execution-row-2", + tenant_id="tenant-2", + execution_id="execution-1", + actor_id="account-1", + ), + ) + ) + provider_execution = ReportingProviderExecution( + id="provider-row-1", + tenant_id="tenant-1", + execution_id="provider-execution-1", + provider_id="cases.reports", + report_id="resident-permits", + report_revision="1", + contract_version="1.0.0", + idempotency_key=SECRET, + request_sha256="c" * 64, + purpose=SECRET, + audience_scope={"secret": SECRET}, + parameters={"secret": SECRET}, + result_schema=[{"secret": SECRET}], + result_payload={"secret": SECRET}, + source_revisions=[{"secret": SECRET}], + effective_scope={"secret": SECRET}, + privacy_transforms=["small_cell_suppression"], + provenance={"secret": SECRET}, + governance_provenance={"secret": SECRET}, + retention_class="short", + retention_days=30, + expires_at=NOW + timedelta(days=30), + output_hash="d" * 64, + generated_at=NOW, + actor_id="account-1", + ) + self.session.add(provider_execution) + self.session.flush() + self.session.add_all( + ( + ReportingProviderExport( + id="export-row-1", + tenant_id="tenant-1", + export_id="export-1", + provider_execution_id=provider_execution.id, + execution_id=provider_execution.execution_id, + format="json", + purpose=SECRET, + audience_scope={"secret": SECRET}, + output_hash="e" * 64, + exported_at=NOW, + actor_id="account-1", + ), + ReportingDefinitionGrant( + id="grant-row-1", + tenant_id="tenant-1", + definition_kind="report", + definition_id="report-1", + subject_kind="account", + subject_id="account-1", + permissions=["view"], + active=True, + source_revision=1, + ), + ReportingSavedView( + id="view-row-1", + tenant_id="tenant-1", + view_id="view-1", + report_id="report-1", + report_revision=1, + owner_kind="account", + owner_id="account-1", + name=SECRET, + state={"secret": SECRET}, + shared=False, + access={"secret": SECRET}, + ), + ReportingSavedView( + id="view-row-2", + tenant_id="tenant-1", + view_id="view-shared", + report_id="report-1", + report_revision=1, + owner_kind="account", + owner_id="account-1", + name=SECRET, + state={"secret": SECRET}, + shared=True, + access={"secret": SECRET}, + ), + ReportingSchedule( + id="schedule-row-1", + tenant_id="tenant-1", + schedule_id="schedule-1", + report_id="report-1", + report_revision=1, + name=SECRET, + trigger_kind="interval", + trigger_config={"secret": SECRET}, + parameters={"secret": SECRET}, + query={"secret": SECRET}, + publication_target={"secret": SECRET}, + enabled=True, + next_run_at=NOW + timedelta(days=1), + created_by="account-1", + ), + ReportingPublication( + id="publication-row-1", + tenant_id="tenant-1", + publication_id="publication-1", + execution_id="execution-1", + target_capability="files.artifact_store", + target_ref=SECRET, + format="json", + status="succeeded", + idempotency_key=SECRET, + evidence={"secret": SECRET}, + error=SECRET, + completed_at=NOW, + ), + ReportingDrillContext( + id="drill-row-1", + tenant_id="tenant-1", + drill_context_id="drill-1", + execution_id="execution-1", + token_sha256="f" * 64, + context_sha256="0" * 64, + actor_id="account-1", + dimension_path=[{"secret": SECRET}], + source_fingerprints=[{"secret": SECRET}], + policy_provenance={"secret": SECRET}, + expires_at=NOW + timedelta(minutes=10), + ), + ReportingQualityResult( + id="quality-row-1", + tenant_id="tenant-1", + result_id="quality-1", + quality_plan_id="quality-plan-1", + quality_plan_revision=1, + dataset_id="dataset-1", + dataset_revision=1, + status="passed", + output_hash="1" * 64, + assertions=[{"secret": SECRET}], + source_fingerprints=[{"secret": SECRET}], + evaluated_at=NOW, + actor_id="account-1", + ), + ReportingImportAssessment( + id="assessment-row-1", + tenant_id="tenant-1", + assessment_id="assessment-1", + source_system="legacy-bi", + source_id=SECRET, + source_fingerprint="2" * 64, + mapping_report={"secret": SECRET}, + status="blocked", + accepted_approximations=[SECRET], + assessed_by="account-1", + ), + ) + ) + + @staticmethod + def _execution( + *, + row_id: str, + tenant_id: str, + execution_id: str, + actor_id: str, + ) -> ReportingExecution: + return ReportingExecution( + id=row_id, + tenant_id=tenant_id, + execution_id=execution_id, + report_id="report-1", + report_revision=1, + semantic_model_id="semantic-1", + semantic_model_revision=1, + dataset_id="dataset-1", + dataset_revision=1, + status="succeeded", + idempotency_key=SECRET, + request_sha256="3" * 64, + parameters={"secret": SECRET}, + query={"secret": SECRET}, + source_fingerprints=[{"secret": SECRET}], + definition_hashes={"secret": SECRET}, + output_hash="4" * 64, + executor_version="reporting-v1", + result_schema=[{"secret": SECRET}], + result_rows=[{"secret": SECRET}], + total_rows=1, + truncated=False, + diagnostics=[{"secret": SECRET}], + provenance={"secret": SECRET}, + started_at=NOW, + finished_at=NOW, + actor_id=actor_id, + ) + + def test_canonical_selector_returns_minimized_owned_and_attribution_rows( + self, + ) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id="account-1"), + ) + + self.assertEqual(12, len(records)) + self.assertIn( + "subject_owned_reporting_view", + {record.category for record in records}, + ) + self.assertIn( + "reporting_operator_attribution", + {record.category for record in records}, + ) + exported = json.dumps([record.to_dict() for record in records]) + self.assertNotIn(SECRET, exported) + self.assertNotIn("account-1", exported) + self.assertNotIn("execution-row-2", exported) + + def test_direct_references_are_exact_tenant_scoped_and_corroborated(self) -> None: + direct = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={"reporting.execution": "execution-1"}, + ), + ) + mismatch = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-2", + external_references={"reporting.execution": "execution-1"}, + ), + ) + wrong_tenant = self.provider.search_subject( + self.session, + tenant_id="tenant-2", + subject=DsarSubjectRef( + external_references={"reporting.saved_view": "view-1"} + ), + ) + conflict = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + external_references={ + "reporting.definition_revision": "revision-row-1", + "reporting.revision": "different-revision", + } + ), + ) + + self.assertEqual(["execution-row-1"], [item.resource_id for item in direct]) + self.assertEqual("derived_report_result", direct[0].category) + self.assertEqual((), mismatch) + self.assertEqual((), wrong_tenant) + self.assertEqual((), conflict) + + def test_every_exact_artifact_reference_is_supported(self) -> None: + references = { + "reporting.definition": ("report-1", 2), + "reporting.definition_revision": ("revision-row-1", 1), + "reporting.provider_execution": ("provider-execution-1", 1), + "reporting.provider_export": ("export-1", 1), + "reporting.definition_grant": ("grant-row-1", 1), + "reporting.saved_view": ("view-1", 1), + "reporting.schedule": ("schedule-1", 1), + "reporting.publication": ("publication-1", 1), + "reporting.drill_context": ("drill-1", 1), + "reporting.quality_result": ("quality-1", 1), + "reporting.import_assessment": ("assessment-1", 1), + } + for key, (value, expected) in references.items(): + with self.subTest(key=key): + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(external_references={key: value}), + ) + self.assertEqual(expected, len(records)) + + def test_planning_and_execution_preserve_governed_boundaries(self) -> None: + subject = DsarSubjectRef(account_id="account-1") + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=subject, + ) + actions = self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + records=records, + ) + by_resource = {action.resource_id: action for action in actions} + self.assertEqual("delete", by_resource["view-row-1"].kind) + self.assertEqual("manual_review", by_resource["view-row-2"].kind) + self.assertEqual("delete", by_resource["drill-row-1"].kind) + self.assertEqual("revoke", by_resource["grant-row-1"].kind) + self.assertEqual("retain", by_resource["execution-row-1"].kind) + + first = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + actions=actions, + request_id="dsar-1", + ) + second = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + actions=actions, + request_id="dsar-1-retry", + ) + self.assertIn("executed", {result.status for result in first}) + self.assertIn("blocked", {result.status for result in first}) + self.assertIn("unchanged", {result.status for result in second}) + self.assertIsNone(self.session.get(ReportingSavedView, "view-row-1")) + self.assertIsNone(self.session.get(ReportingDrillContext, "drill-row-1")) + self.assertFalse( + self.session.get(ReportingDefinitionGrant, "grant-row-1").active + ) + self.assertEqual( + [{"secret": SECRET}], + self.session.get(ReportingExecution, "execution-row-1").result_rows, + ) + + def test_exact_derived_detail_is_minimized_idempotently(self) -> None: + subject = DsarSubjectRef( + external_references={ + "reporting.execution": "execution-1", + "reporting.provider_execution": "provider-execution-1", + "reporting.provider_export": "export-1", + "reporting.publication": "publication-1", + } + ) + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=subject, + ) + actions = self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + records=records, + ) + self.assertEqual({"anonymize"}, {action.kind for action in actions}) + first = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + actions=actions, + request_id="dsar-2", + ) + second = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + actions=actions, + request_id="dsar-2-retry", + ) + + self.assertTrue(all(result.status == "executed" for result in first)) + self.assertTrue(all(result.status == "unchanged" for result in second)) + execution = self.session.get(ReportingExecution, "execution-row-1") + provider_execution = self.session.get( + ReportingProviderExecution, + "provider-row-1", + ) + publication = self.session.get( + ReportingPublication, + "publication-row-1", + ) + provider_export = self.session.get(ReportingProviderExport, "export-row-1") + self.assertEqual([], execution.result_rows) + self.assertEqual({}, execution.parameters) + self.assertEqual({}, provider_execution.result_payload) + self.assertIsNotNone(provider_execution.retention_redacted_at) + self.assertEqual({}, provider_export.audience_scope) + self.assertEqual( + "Redacted by data-subject request.", + provider_export.purpose, + ) + self.assertIsNone(publication.target_ref) + self.assertEqual({}, publication.evidence) + self.assertIsNone(publication.error) + + def test_foreign_records_and_actions_are_rejected(self) -> None: + subject = DsarSubjectRef(account_id="account-1") + with self.assertRaisesRegex(ValueError, "foreign provider record"): + self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + records=( + DsarRecordRef( + provider_id="cases", + module_id="cases", + resource_type="case", + resource_id="case-1", + category="case", + title="Case", + ), + ), + ) + with self.assertRaisesRegex(ValueError, "foreign provider action"): + self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + actions=( + DsarErasureActionRef( + action_id="cases:delete:case:case-1", + provider_id="cases", + module_id="cases", + kind="delete", + resource_type="case", + resource_id="case-1", + title="Delete case", + rationale="Foreign", + executable=True, + ), + ), + request_id="dsar-3", + ) + + def test_core_workflow_reports_active_and_inactive_provider(self) -> None: + row = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-REPORTING-1", + request_kind="access_and_erasure", + subject=DsarSubjectRef(account_id="account-1"), + purpose="Respond to a verified request.", + legal_basis="Article 15 and 17 GDPR", + due_at=None, + requested_by_account_id="privacy-officer", + ) + self.session.commit() + search_data_subject_request( + self.session, + registry=_Registry(self.provider), + row=row, + expected_revision=1, + ) + self.assertEqual( + [REPORTING_DSAR_CAPABILITY], + row.coverage["provider_capabilities"], + ) + self.assertEqual(12, row.search_result["record_count"]) + + inactive = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-REPORTING-2", + request_kind="access", + subject=DsarSubjectRef(account_id="account-1"), + purpose="Respond to a verified request.", + legal_basis="Article 15 GDPR", + due_at=None, + requested_by_account_id="privacy-officer", + ) + self.session.commit() + search_data_subject_request( + self.session, + registry=_Registry(self.provider, active=False), + row=inactive, + expected_revision=1, + ) + self.assertEqual([], inactive.coverage["provider_capabilities"]) + self.assertEqual( + [REPORTING_DSAR_CAPABILITY], + inactive.coverage["inactive_provider_capabilities"], + ) + self.assertEqual(0, inactive.search_result["record_count"]) + + def test_manifest_registers_and_documents_capability(self) -> None: + self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_factories) + self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_documentation) + self.assertIn( + REPORTING_DSAR_CAPABILITY, + {item.name for item in manifest.provides_interfaces}, + ) + self.assertTrue( + any( + topic.id == "reporting.data-subject-requests" + and {"admin", "user"}.issubset(topic.documentation_types) + for topic in manifest.documentation + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 7113759..a6417bf 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -14,7 +14,7 @@ class ReportingManifestTests(unittest.TestCase): self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name) self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec) - self.assertEqual(7, len(manifest.provides_interfaces)) + self.assertEqual(8, len(manifest.provides_interfaces)) self.assertEqual(1, len(manifest.search_sources)) self.assertIn("dataflow", manifest.optional_dependencies) self.assertIn("policy", manifest.optional_dependencies)