Files
govoplan-reporting/src/govoplan_reporting/backend/provider_reports.py
T

836 lines
28 KiB
Python

"""Governed execution boundary for module-contributed reports."""
from __future__ import annotations
from collections.abc import Mapping
import csv
from datetime import UTC, date, datetime, timedelta
from hashlib import sha256
import io
import json
import uuid
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.reporting import (
ReportDescriptor,
ReportParameterOption,
ReportProvider,
ReportProviderRequest,
ReportingGovernanceDecision,
ReportingGovernanceRequest,
report_providers,
reporting_governance_provider,
)
from govoplan_reporting.backend.db.models import (
ReportingProviderExecution,
ReportingProviderExport,
)
class ProviderReportError(RuntimeError):
pass
def list_provider_reports(
session: Session,
principal: object,
*,
registry: object | None,
) -> dict[str, object]:
reports: list[dict[str, object]] = []
diagnostics: list[dict[str, str]] = []
for provider_id, provider in report_providers(registry):
try:
for descriptor in provider.list_reports(session, principal):
_validate_descriptor(provider_id, descriptor)
decision = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="catalogue",
purpose=None,
audience_scope={},
)
reports.append(
{
**descriptor.to_dict(),
"available": decision.allowed,
"unavailable_reason": decision.reason,
"governance": _decision_payload(decision),
}
)
except Exception as exc: # noqa: BLE001 - isolate optional providers.
diagnostics.append(
{
"provider_id": provider_id,
"code": "provider_catalogue_failed",
"message": "The provider catalogue could not be loaded.",
"error_type": type(exc).__name__,
}
)
return {
"reports": sorted(
reports,
key=lambda item: (str(item["title"]), str(item["provider_id"])),
),
"diagnostics": diagnostics,
}
def provider_parameter_options(
session: Session,
principal: object,
*,
registry: object | None,
provider_id: str,
report_id: str,
parameter_key: str,
query: str,
limit: int,
) -> tuple[ReportParameterOption, ...]:
provider, descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=provider_id,
report_id=report_id,
)
parameter = next(
(item for item in descriptor.parameters if item.key == parameter_key),
None,
)
if parameter is None or not parameter.options_from_provider:
raise ProviderReportError("This report parameter has no provider options")
return provider.parameter_options(
session,
principal,
report_id=report_id,
parameter_key=parameter_key,
query=query,
limit=max(1, min(limit, 200)),
)
def execute_provider_report(
session: Session,
principal: object,
*,
registry: object | None,
provider_id: str,
report_id: str,
parameters: Mapping[str, object],
purpose: str,
audience_scope: Mapping[str, object],
idempotency_key: str,
) -> dict[str, object]:
provider, descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=provider_id,
report_id=report_id,
)
clean_purpose = purpose.strip()
clean_idempotency_key = idempotency_key.strip()
clean_parameters = _validated_parameters(descriptor, parameters)
clean_audience = _json_mapping(audience_scope, "audience scope")
if descriptor.purpose_required and not clean_purpose:
raise ProviderReportError("A report purpose is required")
if descriptor.audience_scope_required and not clean_audience:
raise ProviderReportError("An effective audience scope is required")
request_hash = _hash(
{
"provider_id": provider_id,
"report_id": report_id,
"revision": descriptor.revision,
"parameters": clean_parameters,
"purpose": clean_purpose,
"audience_scope": clean_audience,
}
)
replay = (
session.query(ReportingProviderExecution)
.filter(
ReportingProviderExecution.tenant_id == _tenant(principal),
ReportingProviderExecution.idempotency_key == clean_idempotency_key,
)
.one_or_none()
)
if replay is not None:
if replay.request_sha256 != request_hash:
raise ProviderReportError(
"The provider-report idempotency key belongs to another request"
)
if _expired(replay) or replay.retention_redacted_at is not None:
raise ProviderReportError(
"The replayed provider-report result has expired; use a new idempotency key"
)
_require_result_access(provider, session, principal, replay)
return provider_execution_payload(replay)
preflight = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="execute",
purpose=clean_purpose,
audience_scope=clean_audience,
)
_require_allowed(preflight)
result = provider.execute_report(
session,
principal,
request=ReportProviderRequest(
report_id=report_id,
parameters=clean_parameters,
purpose=clean_purpose,
audience_scope=clean_audience,
),
)
_validate_result(descriptor, result, tenant_id=_tenant(principal))
decision = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="execute",
purpose=clean_purpose,
audience_scope=clean_audience,
applied_privacy_transforms=result.applied_privacy_transforms,
)
_require_allowed(decision)
missing_policy_transforms = set(decision.required_privacy_transforms) - set(
result.applied_privacy_transforms
)
if missing_policy_transforms:
raise ProviderReportError(
"The report omitted Policy-required privacy transformations: "
+ ", ".join(sorted(missing_policy_transforms))
)
generated_at = _aware(result.generated_at)
result_payload = _json_mapping(result.payload, "provider report payload")
expires_at = (
generated_at + timedelta(days=decision.retention_days)
if decision.retention_days is not None
else None
)
row = ReportingProviderExecution(
tenant_id=_tenant(principal),
execution_id=str(uuid.uuid4()),
provider_id=provider_id,
report_id=report_id,
report_revision=descriptor.revision,
contract_version=descriptor.contract_version,
idempotency_key=clean_idempotency_key,
request_sha256=request_hash,
purpose=clean_purpose,
audience_scope=clean_audience,
parameters=clean_parameters,
result_schema=[item.to_dict() for item in descriptor.result_schema],
result_payload=result_payload,
source_revisions=[dict(item) for item in result.source_revisions],
effective_scope=dict(result.effective_scope),
privacy_transforms=list(result.applied_privacy_transforms),
provenance=dict(result.provenance),
governance_provenance=dict(decision.provenance),
retention_class=descriptor.retention_class,
retention_days=decision.retention_days,
expires_at=expires_at,
output_hash=_hash(result_payload),
generated_at=generated_at,
actor_id=_actor(principal),
)
session.add(row)
session.flush()
audit_from_principal(
session,
principal,
action="reporting.provider_report.executed",
object_type="reporting_provider_execution",
object_id=row.execution_id,
details={
"provider_id": provider_id,
"report_id": report_id,
"report_revision": descriptor.revision,
"purpose": clean_purpose,
"audience_scope": clean_audience,
"source_revision_count": len(row.source_revisions),
"privacy_transforms": row.privacy_transforms,
"retention_class": row.retention_class,
"retention_days": row.retention_days,
"output_hash": row.output_hash,
},
)
return provider_execution_payload(row)
def get_provider_execution(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
) -> dict[str, object] | None:
row = _provider_execution(session, principal, execution_id=execution_id)
if row is None or _expired(row):
return None
provider, _descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=row.provider_id,
report_id=row.report_id,
)
_require_result_access(provider, session, principal, row)
return provider_execution_payload(row)
def export_provider_execution(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
format: str,
purpose: str,
audience_scope: Mapping[str, object],
) -> tuple[bytes, str, str]:
row = _provider_execution(session, principal, execution_id=execution_id)
if row is None or _expired(row):
raise LookupError("Provider report execution not found")
provider, descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=row.provider_id,
report_id=row.report_id,
)
_require_result_access(provider, session, principal, row)
clean_format = format.strip().lower()
if clean_format not in descriptor.export_formats:
raise ProviderReportError("This report does not support the export format")
clean_purpose = purpose.strip()
clean_audience = _json_mapping(audience_scope, "audience scope")
decision = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="export",
purpose=clean_purpose,
audience_scope=clean_audience,
export_format=clean_format,
applied_privacy_transforms=tuple(row.privacy_transforms or ()),
)
_require_allowed(decision)
if clean_format not in decision.export_formats:
raise ProviderReportError("Policy does not allow this export format")
content, media_type, extension = _serialize_export(
clean_format,
row.result_payload,
row.result_schema,
)
output_hash = sha256(content).hexdigest()
export = ReportingProviderExport(
tenant_id=row.tenant_id,
export_id=str(uuid.uuid4()),
provider_execution_id=row.id,
execution_id=row.execution_id,
format=clean_format,
purpose=clean_purpose,
audience_scope=clean_audience,
output_hash=output_hash,
exported_at=datetime.now(UTC),
actor_id=_actor(principal),
)
session.add(export)
session.flush()
audit_from_principal(
session,
principal,
action="reporting.provider_report.exported",
object_type="reporting_provider_execution",
object_id=row.execution_id,
details={
"export_id": export.export_id,
"format": clean_format,
"purpose": clean_purpose,
"audience_scope": clean_audience,
"output_hash": output_hash,
},
)
filename = f"{row.provider_id}-{row.report_id}-{row.execution_id}.{extension}"
return content, media_type, filename
def list_provider_exports(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
) -> tuple[dict[str, object], ...]:
row = _provider_execution(session, principal, execution_id=execution_id)
if row is None or _expired(row):
return ()
provider, _descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=row.provider_id,
report_id=row.report_id,
)
_require_result_access(provider, session, principal, row)
exports = (
session.query(ReportingProviderExport)
.filter(
ReportingProviderExport.tenant_id == row.tenant_id,
ReportingProviderExport.provider_execution_id == row.id,
)
.order_by(ReportingProviderExport.exported_at.desc())
.all()
)
return tuple(
{
"export_id": item.export_id,
"execution_id": item.execution_id,
"format": item.format,
"purpose": item.purpose,
"audience_scope": dict(item.audience_scope or {}),
"output_hash": item.output_hash,
"exported_at": item.exported_at.isoformat(),
"actor_id": item.actor_id,
}
for item in exports
)
def provider_execution_payload(row: ReportingProviderExecution) -> dict[str, object]:
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,
"purpose": row.purpose,
"audience_scope": dict(row.audience_scope or {}),
"parameters": dict(row.parameters or {}),
"result_schema": list(row.result_schema or []),
"result": dict(row.result_payload or {}),
"source_revisions": list(row.source_revisions or []),
"effective_scope": dict(row.effective_scope or {}),
"privacy_transforms": list(row.privacy_transforms or []),
"provenance": dict(row.provenance or {}),
"governance_provenance": dict(row.governance_provenance or {}),
"retention_class": row.retention_class,
"retention_days": row.retention_days,
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
"output_hash": row.output_hash,
"generated_at": row.generated_at.isoformat(),
"actor_id": row.actor_id,
}
def _provider_report(
session: Session,
principal: object,
*,
registry: object | None,
provider_id: str,
report_id: str,
) -> tuple[ReportProvider, ReportDescriptor]:
provider = dict(report_providers(registry)).get(provider_id)
if provider is None:
raise LookupError("Report provider is unavailable")
descriptor = next(
(
item
for item in provider.list_reports(session, principal)
if item.report_id == report_id
),
None,
)
if descriptor is None:
raise LookupError("Provider report is unavailable")
_validate_descriptor(provider_id, descriptor)
return provider, descriptor
def _validate_descriptor(provider_id: str, descriptor: ReportDescriptor) -> None:
if descriptor.provider_id != provider_id:
raise ProviderReportError("Report descriptor provider id differs")
if not descriptor.report_id or not descriptor.revision:
raise ProviderReportError("Report descriptors require stable ids and revisions")
parameter_keys = [item.key for item in descriptor.parameters]
field_paths = [item.path for item in descriptor.result_schema]
transform_ids = [item.id for item in descriptor.privacy_transforms]
if len(parameter_keys) != len(set(parameter_keys)):
raise ProviderReportError("Report parameter keys must be unique")
if not field_paths or len(field_paths) != len(set(field_paths)):
raise ProviderReportError("Report result paths must be non-empty and unique")
if len(transform_ids) != len(set(transform_ids)):
raise ProviderReportError("Report privacy transforms must be unique")
if any(item.sensitive for item in descriptor.result_schema) and (
descriptor.reidentification_risk != "high"
):
raise ProviderReportError(
"Sensitive result fields require high risk classification"
)
def _validate_result(
descriptor: ReportDescriptor,
result: object,
*,
tenant_id: str,
) -> None:
if not hasattr(result, "report_id") or result.report_id != descriptor.report_id:
raise ProviderReportError("Provider result report id differs")
if not result.source_revisions:
raise ProviderReportError("Provider result has no source revision provenance")
if str(result.effective_scope.get("tenant_id") or "") != tenant_id:
raise ProviderReportError(
"Provider result effective scope differs from the tenant"
)
required = {item.id for item in descriptor.privacy_transforms if item.required}
missing = required - set(result.applied_privacy_transforms)
if missing:
raise ProviderReportError(
"Provider result omitted required privacy transformations: "
+ ", ".join(sorted(missing))
)
declared = {item.path: item for item in descriptor.result_schema}
for field in descriptor.result_schema:
if field.type != "suppressed_count":
continue
value = _path_value(result.payload, field.path)
if not isinstance(value, Mapping) or set(value) != {"value", "suppressed"}:
raise ProviderReportError(
f"Provider result has an invalid suppressed count: {field.path}"
)
if value["value"] is not None and (
not isinstance(value["value"], int) or isinstance(value["value"], bool)
):
raise ProviderReportError(
f"Provider result has an invalid suppressed-count value: {field.path}"
)
if not isinstance(value["suppressed"], bool):
raise ProviderReportError(
f"Provider result has an invalid suppression flag: {field.path}"
)
for path in _leaf_paths(result.payload):
if path in declared:
continue
if any(
path.startswith(declared_path + ".")
and field.type in {"object", "suppressed_count"}
for declared_path, field in declared.items()
):
continue
raise ProviderReportError(f"Provider result contains undeclared field: {path}")
def _validated_parameters(
descriptor: ReportDescriptor,
parameters: Mapping[str, object],
) -> dict[str, object]:
clean = _json_mapping(parameters, "report parameters")
declared = {item.key: item for item in descriptor.parameters}
unknown = set(clean) - set(declared)
if unknown:
raise ProviderReportError(
"Unknown report parameters: " + ", ".join(sorted(unknown))
)
missing = [
item.key
for item in descriptor.parameters
if item.required and clean.get(item.key) in (None, "")
]
if missing:
raise ProviderReportError(
"Missing report parameters: " + ", ".join(sorted(missing))
)
invalid = [
item.key
for item in descriptor.parameters
if item.key in clean
and clean[item.key] is not None
and not _valid_parameter_value(item.type, clean[item.key])
]
if invalid:
raise ProviderReportError(
"Invalid report parameter types: " + ", ".join(sorted(invalid))
)
return clean
def _valid_parameter_value(parameter_type: str, value: object) -> bool:
if parameter_type in {"string", "reference"}:
return isinstance(value, str)
if parameter_type == "boolean":
return isinstance(value, bool)
if parameter_type == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if parameter_type == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if parameter_type == "date":
if not isinstance(value, str):
return False
try:
date.fromisoformat(value)
except ValueError:
return False
return True
if parameter_type == "datetime":
if not isinstance(value, str):
return False
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return False
return True
return False
def _governance_decision(
session: Session,
principal: object,
*,
registry: object | None,
descriptor: ReportDescriptor,
action: str,
purpose: str | None,
audience_scope: Mapping[str, object],
export_format: str | None = None,
applied_privacy_transforms: tuple[str, ...] = (),
) -> ReportingGovernanceDecision:
required = tuple(item.id for item in descriptor.privacy_transforms if item.required)
provider = reporting_governance_provider(registry)
if provider is None:
allowed = descriptor.reidentification_risk != "high"
if descriptor.purpose_required and action != "catalogue" and not purpose:
allowed = False
if (
descriptor.audience_scope_required
and action != "catalogue"
and not audience_scope
):
allowed = False
if action == "export" and export_format not in descriptor.export_formats:
allowed = False
return ReportingGovernanceDecision(
allowed=allowed,
reason=None
if allowed
else "The built-in Reporting privacy baseline denied this action.",
retention_days=30,
export_formats=descriptor.export_formats,
required_privacy_transforms=required,
provenance={"provider": "reporting.built_in_baseline", "version": "1"},
)
decision = provider.decide_reporting_action(
session,
principal,
request=ReportingGovernanceRequest(
action=action, # type: ignore[arg-type]
tenant_id=_tenant(principal),
provider_id=descriptor.provider_id,
report_id=descriptor.report_id,
purpose=purpose,
audience_scope=audience_scope,
retention_class=descriptor.retention_class,
export_format=export_format,
reidentification_risk=descriptor.reidentification_risk,
declared_privacy_transforms=required,
applied_privacy_transforms=applied_privacy_transforms,
),
)
unsupported = set(decision.required_privacy_transforms) - {
item.id for item in descriptor.privacy_transforms
}
if not unsupported:
return decision
return ReportingGovernanceDecision(
allowed=False,
reason=(
"Policy requires privacy transformations this report does not support: "
+ ", ".join(sorted(unsupported))
),
retention_days=decision.retention_days,
export_formats=decision.export_formats,
required_privacy_transforms=decision.required_privacy_transforms,
provenance={
**dict(decision.provenance),
"unsupported_required_privacy_transforms": sorted(unsupported),
},
)
def _serialize_export(
format: str,
payload: Mapping[str, object],
schema: list[dict[str, object]],
) -> tuple[bytes, str, str]:
if format == "json":
return (
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True).encode(
"utf-8"
),
"application/json",
"json",
)
if format != "csv":
raise ProviderReportError("Unsupported provider report export format")
output = io.StringIO(newline="")
writer = csv.writer(output)
writer.writerow([str(item["label"]) for item in schema])
writer.writerow(
[_csv_value(_path_value(payload, str(item["path"]))) for item in schema]
)
return output.getvalue().encode("utf-8-sig"), "text/csv; charset=utf-8", "csv"
def _csv_value(value: object) -> object:
if isinstance(value, Mapping):
if value.get("suppressed") is True:
return "suppressed"
if set(value).issuperset({"value", "suppressed"}):
value = value.get("value")
else:
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
if isinstance(value, (list, tuple)):
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")):
return "'" + value
return "" if value is None else value
def _path_value(payload: Mapping[str, object], path: str) -> object:
value: object = payload
for part in path.split("."):
if not isinstance(value, Mapping):
return None
value = value.get(part)
return value
def _leaf_paths(value: object, prefix: str = "") -> tuple[str, ...]:
if isinstance(value, Mapping):
rows: list[str] = []
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
rows.extend(_leaf_paths(item, path))
return tuple(rows)
if isinstance(value, (list, tuple)):
return (prefix,)
return (prefix,)
def _provider_execution(
session: Session,
principal: object,
*,
execution_id: str,
) -> ReportingProviderExecution | None:
return (
session.query(ReportingProviderExecution)
.filter(
ReportingProviderExecution.tenant_id == _tenant(principal),
ReportingProviderExecution.execution_id == execution_id,
)
.one_or_none()
)
def _require_result_access(
provider: ReportProvider,
session: Session,
principal: object,
row: ReportingProviderExecution,
) -> None:
if not provider.authorize_result(
session,
principal,
report_id=row.report_id,
source_revisions=tuple(row.source_revisions or ()),
effective_scope=dict(row.effective_scope or {}),
):
raise PermissionError(
"The source module no longer permits access to this report result"
)
def _expired(row: ReportingProviderExecution) -> bool:
if row.expires_at is None:
return False
return _aware(row.expires_at) <= datetime.now(UTC)
def _require_allowed(decision: ReportingGovernanceDecision) -> None:
if not decision.allowed:
raise PermissionError(decision.reason or "Reporting Policy denied this action")
def _decision_payload(decision: ReportingGovernanceDecision) -> dict[str, object]:
return {
"allowed": decision.allowed,
"reason": decision.reason,
"retention_days": decision.retention_days,
"export_formats": list(decision.export_formats),
"required_privacy_transforms": list(decision.required_privacy_transforms),
"provenance": dict(decision.provenance),
}
def _json_mapping(value: Mapping[str, object], label: str) -> dict[str, object]:
try:
payload = json.loads(json.dumps(value, sort_keys=True, separators=(",", ":")))
except (TypeError, ValueError) as exc:
raise ProviderReportError(f"The {label} must contain JSON values") from exc
if not isinstance(payload, dict):
raise ProviderReportError(f"The {label} must be an object")
return payload
def _hash(value: object) -> str:
return sha256(
json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def _tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise PermissionError("A tenant principal is required")
return tenant_id
def _actor(principal: object) -> str | None:
user = getattr(principal, "user", None)
actor_id = getattr(user, "id", None)
return str(actor_id) if actor_id else None
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
__all__ = [
"ProviderReportError",
"execute_provider_report",
"export_provider_execution",
"get_provider_execution",
"list_provider_exports",
"list_provider_reports",
"provider_parameter_options",
]