Complete governed reporting execution and publication

This commit is contained in:
2026-08-04 02:23:24 +02:00
parent a81151391b
commit 8337aec19a
29 changed files with 3611 additions and 81 deletions
+388
View File
@@ -0,0 +1,388 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime, timedelta
import hashlib
import hmac
import json
import secrets
from typing import Any
import uuid
from sqlalchemy.orm import Session
from govoplan_core.security.time import utc_now
from govoplan_reporting.backend.db.models import (
ReportingDrillContext,
ReportingExecution,
)
from govoplan_reporting.backend.definitions import get_definition
from govoplan_reporting.backend.execution import (
ReportingExecutionError,
_apply_row_policy,
_read_dataset,
_validate_schema,
get_execution,
)
from govoplan_reporting.backend.postgres_planner import execute_postgres_query
from govoplan_reporting.backend.query_engine import execute_semantic_query
from govoplan_reporting.backend.schemas import (
DatasetDefinition,
FilterClause,
ReportDefinition,
ReportQuery,
SemanticModelDefinition,
)
DRILL_CONTEXT_TTL = timedelta(minutes=20)
class ReportingDrillError(ValueError):
pass
def create_drill_context(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
aggregate_row: Mapping[str, object],
limit: int,
) -> dict[str, object]:
execution_payload = get_execution(
session,
principal,
execution_id=execution_id,
registry=registry,
)
if execution_payload is None or execution_payload.get("status") != "succeeded":
raise LookupError("Successful Reporting execution not found.")
row = _execution(session, _tenant(principal), execution_id)
normalized_aggregate = _json_value(dict(aggregate_row))
if normalized_aggregate not in [
_json_value(dict(item)) for item in row.result_rows or []
]:
raise ReportingDrillError(
"The selected aggregate row does not belong to this execution."
)
semantic_record = get_definition(
session,
principal,
definition_kind="semantic_model",
definition_id=row.semantic_model_id,
revision=row.semantic_model_revision,
)
if semantic_record is None:
raise PermissionError("The report semantic model is no longer accessible.")
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
query = ReportQuery.model_validate(row.query or {})
dimension_keys = _drill_dimensions(query, semantic)
dimension_map = {item.key: item for item in semantic.dimensions}
path = [
{
"dimension": key,
"label": dimension_map[key].label,
"value": normalized_aggregate.get(key),
}
for key in dimension_keys
if key in normalized_aggregate
]
if not path:
raise ReportingDrillError(
"This aggregate has no dimension path to drill through."
)
bounded_limit = max(1, min(int(limit), 500))
actor_id = _actor(principal)
if not actor_id:
raise ReportingDrillError("Drill-through requires an accountable actor.")
drill_context_id = str(uuid.uuid4())
secret = secrets.token_urlsafe(32)
token = f"{drill_context_id}.{secret}"
context = {
"execution_id": execution_id,
"output_hash": row.output_hash,
"actor_id": actor_id,
"dimension_path": path,
"source_fingerprints": list(row.source_fingerprints or []),
"limit": bounded_limit,
}
item = ReportingDrillContext(
tenant_id=row.tenant_id,
drill_context_id=drill_context_id,
execution_id=execution_id,
token_sha256=_sha256(token),
context_sha256=_sha256(context),
actor_id=actor_id,
dimension_path=path,
source_fingerprints=list(row.source_fingerprints or []),
policy_provenance=dict(
execution_payload.get("delivery_authorization") or {}
),
expires_at=utc_now() + DRILL_CONTEXT_TTL,
)
item.policy_provenance["limit"] = bounded_limit
session.add(item)
session.flush()
return {
"token": token,
"drill_context_id": drill_context_id,
"execution_id": execution_id,
"dimension_path": path,
"expires_at": _datetime_text(item.expires_at),
}
def resolve_drill_context(
session: Session,
principal: object,
*,
registry: object | None,
token: str,
) -> dict[str, object]:
context_id, separator, _secret = token.partition(".")
if not separator or not context_id:
raise ReportingDrillError("The drill-through context token is invalid.")
item = (
session.query(ReportingDrillContext)
.filter(
ReportingDrillContext.tenant_id == _tenant(principal),
ReportingDrillContext.drill_context_id == context_id,
)
.one_or_none()
)
if item is None or not hmac.compare_digest(item.token_sha256, _sha256(token)):
raise LookupError("Reporting drill-through context not found.")
if item.actor_id != _actor(principal):
raise PermissionError(
"This drill-through context belongs to another account."
)
if _aware(item.expires_at) <= utc_now():
raise ReportingDrillError("The drill-through context has expired.")
row = _execution(session, item.tenant_id, item.execution_id)
expected_context = {
"execution_id": row.execution_id,
"output_hash": row.output_hash,
"actor_id": item.actor_id,
"dimension_path": list(item.dimension_path or []),
"source_fingerprints": list(item.source_fingerprints or []),
"limit": int((item.policy_provenance or {}).get("limit", 200)),
}
if not hmac.compare_digest(item.context_sha256, _sha256(expected_context)):
raise ReportingDrillError(
"The persisted drill-through context failed its integrity check."
)
execution_payload = get_execution(
session,
principal,
execution_id=row.execution_id,
registry=registry,
)
if execution_payload is None:
raise LookupError("Reporting execution not found.")
report_record = get_definition(
session,
principal,
definition_kind="report",
definition_id=row.report_id,
revision=row.report_revision,
)
semantic_record = get_definition(
session,
principal,
definition_kind="semantic_model",
definition_id=row.semantic_model_id,
revision=row.semantic_model_revision,
)
dataset_record = get_definition(
session,
principal,
definition_kind="dataset",
definition_id=row.dataset_id,
revision=row.dataset_revision,
)
if report_record is None or semantic_record is None or dataset_record is None:
raise PermissionError(
"The report source graph is no longer accessible for drill-through."
)
report = ReportDefinition.model_validate(report_record.payload)
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
dataset = DatasetDefinition.model_validate(dataset_record.payload)
source = _read_dataset(
session,
principal,
registry=registry,
dataset=dataset,
parameters=dict(row.parameters or {}),
)
if not _fingerprints_equal(
item.source_fingerprints or [], source.source_fingerprints
):
raise ReportingExecutionError(
"The source fingerprint changed after the aggregate execution; run the report again before drilling through."
)
normalized_rows = tuple(_json_value(dict(source_row)) for source_row in source.rows)
_validate_schema(dataset, normalized_rows)
authorized_rows, row_policy = _apply_row_policy(
session,
principal,
registry=registry,
dataset_id=dataset_record.definition_id,
dataset_revision=dataset_record.revision,
dataset=dataset,
rows=normalized_rows,
)
original = ReportQuery.model_validate(row.query or {})
hidden_dimensions = _strings(report.access_policy.get("hidden_dimensions"))
visible_dimensions = [
dimension.key
for dimension in semantic.dimensions
if dimension.key not in hidden_dimensions
]
filters = list(original.filters)
filters.extend(
FilterClause(
dimension=str(path_item["dimension"]),
operator="eq",
value=path_item.get("value"),
)
for path_item in item.dimension_path or []
)
detail_query = ReportQuery(
mode="detail",
dimensions=visible_dimensions,
filters=filters,
limit=int((item.policy_provenance or {}).get("limit", 200)),
)
result = execute_postgres_query(
session,
rows=authorized_rows,
dataset=dataset,
semantic_model=semantic,
query=detail_query,
) or execute_semantic_query(authorized_rows, semantic, detail_query)
item.last_accessed_at = utc_now()
item.policy_provenance = {
**dict(item.policy_provenance or {}),
"resolved_row_policy": dict(row_policy),
"delivery_authorization": dict(
execution_payload.get("delivery_authorization") or {}
),
}
session.flush()
return {
"drill_context_id": item.drill_context_id,
"execution_id": item.execution_id,
"dimension_path": list(item.dimension_path or []),
"rows": list(result.rows),
"schema": list(result.schema),
"total_rows": result.total_rows,
"truncated": result.truncated or source.truncated,
"source_fingerprints": list(source.source_fingerprints),
"policy_provenance": dict(item.policy_provenance or {}),
"expires_at": _datetime_text(item.expires_at),
}
def _drill_dimensions(
query: ReportQuery,
semantic: SemanticModelDefinition,
) -> tuple[str, ...]:
if query.mode == "pivot" and query.pivot is not None:
return tuple(dict.fromkeys((*query.pivot.rows, *query.pivot.columns)))
return tuple(query.dimensions or semantic.default_dimensions)
def _execution(
session: Session,
tenant_id: str,
execution_id: str,
) -> ReportingExecution:
row = (
session.query(ReportingExecution)
.filter(
ReportingExecution.tenant_id == tenant_id,
ReportingExecution.execution_id == execution_id,
)
.one_or_none()
)
if row is None:
raise LookupError("Reporting execution not found.")
return row
def _fingerprints_equal(
expected: Sequence[Mapping[str, object]],
actual: Sequence[Mapping[str, object]],
) -> bool:
normalize = lambda values: sorted( # noqa: E731 - compact canonicalizer
json.dumps(
_json_value(dict(item)),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
for item in values
)
return normalize(expected) == normalize(actual)
def _tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise ReportingDrillError("Drill-through requires a tenant-bound principal.")
return tenant_id
def _actor(principal: object) -> str | None:
for value in (
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
):
if str(value or "").strip():
return str(value)
return None
def _strings(value: object) -> set[str]:
if not isinstance(value, (list, tuple, set, frozenset)):
return set()
return {str(item) for item in value if str(item).strip()}
def _sha256(value: object) -> str:
payload = value if isinstance(value, str) else json.dumps(
_json_value(value),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _json_value(value: object) -> Any:
if isinstance(value, datetime):
return _aware(value).isoformat()
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_value(item) for item in value]
return value
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _datetime_text(value: datetime) -> str:
return _aware(value).isoformat()
__all__ = [
"DRILL_CONTEXT_TTL",
"ReportingDrillError",
"create_drill_context",
"resolve_drill_context",
]