389 lines
12 KiB
Python
389 lines
12 KiB
Python
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",
|
|
]
|