from __future__ import annotations from collections.abc import Mapping, Sequence from datetime import datetime import hashlib import json from typing import Any import uuid from sqlalchemy import and_, exists, func, or_ from sqlalchemy.orm import Query, Session from govoplan_core.core.events import ( EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event, ) from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_reporting.backend.db.models import ( ReportingDefinitionGrant, ReportingDefinitionIdentity, ReportingDefinitionRevision, ) from govoplan_reporting.backend.domain import ( ReportingDefinitionRecord, definition_from_row, ) from govoplan_reporting.backend.schemas import validate_definition_payload READ_SCOPE = "reporting:definition:read" WRITE_SCOPE = "reporting:definition:write" ADMIN_SCOPE = "reporting:definition:admin" DEFINITION_KINDS = frozenset({"dataset", "semantic_model", "report", "quality_plan"}) STATUS_TRANSITIONS = { "draft": frozenset({"active", "retired"}), "active": frozenset({"draft", "retired"}), "retired": frozenset({"draft"}), } SUBJECT_KINDS = frozenset( { "account", "identity", "group", "role", "function", "function_assignment", "organization_unit", "service_account", } ) class ReportingDefinitionError(ValueError): pass def create_definition( session: Session, principal: object, *, definition_kind: str, definition_id: str, definition_key: str, name: str, description: str | None, status: str, visibility: str, recorded_at: datetime, change_reason: str, payload: Mapping[str, Any], idempotency_key: str, ) -> ReportingDefinitionRecord: _require_scope(principal, WRITE_SCOPE) tenant_id = _principal_tenant(principal) kind = _definition_kind(definition_kind) clean_id = _required(definition_id, "Reporting definition identifier", 255) clean_key = _key(definition_key, "Reporting definition key") clean_name = _required(name, "Reporting definition name", 500) clean_description = _optional(description, "Reporting description", 100_000) clean_status = _status(status) clean_visibility = _visibility(visibility) clean_reason = _required(change_reason, "Reporting change reason", 1_000) _aware(recorded_at, "Reporting recorded_at") validated_payload = validate_definition_payload(kind, dict(payload)) parent_kind, parent_id, parent_revision = _parent_reference( kind, validated_payload, ) _validate_parent( session, tenant_id=tenant_id, child_status=clean_status, parent_kind=parent_kind, parent_id=parent_id, parent_revision=parent_revision, ) request = { "definition_kind": kind, "definition_id": clean_id, "definition_key": clean_key, "name": clean_name, "description": clean_description, "status": clean_status, "visibility": clean_visibility, "recorded_at": recorded_at, "change_reason": clean_reason, "payload": validated_payload, } request_sha256 = _sha256(request) replay = _replay( session, tenant_id=tenant_id, idempotency_key=idempotency_key, request_sha256=request_sha256, ) if replay is not None: return replay if _identity(session, tenant_id, kind, clean_id) is not None: raise ReportingDefinitionError("Reporting definition already exists.") duplicate = ( session.query(ReportingDefinitionIdentity.id) .filter( ReportingDefinitionIdentity.tenant_id == tenant_id, ReportingDefinitionIdentity.definition_kind == kind, ReportingDefinitionIdentity.definition_key == clean_key, ) .first() ) if duplicate is not None: raise ReportingDefinitionError("Reporting definition key already exists.") identity = ReportingDefinitionIdentity( tenant_id=tenant_id, definition_kind=kind, definition_id=clean_id, definition_key=clean_key, created_by=_actor(principal), ) session.add(identity) session.flush() return _write_revision( session, principal, identity=identity, current=None, revision=1, name=clean_name, description=clean_description, status=clean_status, visibility=clean_visibility, recorded_at=recorded_at, change_reason=clean_reason, payload=validated_payload, parent_kind=parent_kind, parent_id=parent_id, parent_revision=parent_revision, idempotency_key=idempotency_key, request_sha256=request_sha256, operation="created", ) def update_definition( session: Session, principal: object, *, definition_kind: str, definition_id: str, expected_revision: int, recorded_at: datetime, change_reason: str, idempotency_key: str, changes: Mapping[str, object], ) -> ReportingDefinitionRecord: tenant_id = _principal_tenant(principal) kind = _definition_kind(definition_kind) current_row = _current_row( session, tenant_id=tenant_id, definition_kind=kind, definition_id=definition_id, lock=True, ) if current_row is None: raise LookupError("Reporting definition not found.") if not can_write_definition( session, principal, definition_kind=kind, definition_id=definition_id, ): raise PermissionError("Reporting definition write access is denied.") current = definition_from_row(current_row) unknown = set(changes) - { "name", "description", "status", "visibility", "payload", } if unknown: raise ReportingDefinitionError( "Unsupported Reporting definition fields: " + ", ".join(sorted(unknown)) ) _aware(recorded_at, "Reporting recorded_at") clean_reason = _required(change_reason, "Reporting change reason", 1_000) request_sha256 = _sha256( { "definition_kind": kind, "definition_id": definition_id, "expected_revision": expected_revision, "recorded_at": recorded_at, "change_reason": clean_reason, "changes": changes, } ) replay = _replay( session, tenant_id=tenant_id, idempotency_key=idempotency_key, request_sha256=request_sha256, ) if replay is not None: return replay if current.revision != expected_revision: raise ReportingDefinitionError( "Reporting definition revision conflict: the expected revision is stale." ) next_status = _status(str(changes.get("status", current.status))) if ( next_status != current.status and next_status not in STATUS_TRANSITIONS[current.status] ): raise ReportingDefinitionError( f"Cannot move Reporting definition from {current.status!r} to {next_status!r}." ) next_payload = validate_definition_payload( kind, dict(changes.get("payload", current.payload)), ) parent_kind, parent_id, parent_revision = _parent_reference(kind, next_payload) _validate_parent( session, tenant_id=tenant_id, child_status=next_status, parent_kind=parent_kind, parent_id=parent_id, parent_revision=parent_revision, ) identity = _identity(session, tenant_id, kind, definition_id) if identity is None: raise ReportingDefinitionError("Reporting definition identity is missing.") return _write_revision( session, principal, identity=identity, current=current_row, revision=current.revision + 1, name=_required( changes.get("name", current.name), "Reporting definition name", 500 ), description=_optional( changes.get("description", current.description), "Reporting description", 100_000, ), status=next_status, visibility=_visibility(str(changes.get("visibility", current.visibility))), recorded_at=recorded_at, change_reason=clean_reason, payload=next_payload, parent_kind=parent_kind, parent_id=parent_id, parent_revision=parent_revision, idempotency_key=idempotency_key, request_sha256=request_sha256, operation="updated" if next_status == current.status else "state_changed", ) def get_definition( session: Session, principal: object, *, definition_kind: str, definition_id: str, revision: int | None = None, ) -> ReportingDefinitionRecord | None: tenant_id = _principal_tenant(principal) kind = _definition_kind(definition_kind) if not can_read_definition( session, principal, definition_kind=kind, definition_id=definition_id, ): return None query = session.query(ReportingDefinitionRevision).filter( ReportingDefinitionRevision.tenant_id == tenant_id, ReportingDefinitionRevision.definition_kind == kind, ReportingDefinitionRevision.definition_id == definition_id, ) if revision is None: query = query.filter(ReportingDefinitionRevision.superseded_at.is_(None)) else: query = query.filter(ReportingDefinitionRevision.revision == revision) row = query.order_by(ReportingDefinitionRevision.revision.desc()).first() return definition_from_row(row) if row is not None else None def list_definitions( session: Session, principal: object, *, definition_kinds: Sequence[str] | None = None, statuses: Sequence[str] | None = None, query: str = "", offset: int = 0, limit: int = 100, ) -> tuple[tuple[ReportingDefinitionRecord, ...], int]: _require_scope(principal, READ_SCOPE) if offset < 0 or not 1 <= limit <= 200: raise ReportingDefinitionError( "Reporting list offset must be non-negative and limit between 1 and 200." ) kinds = tuple(dict.fromkeys(definition_kinds or DEFINITION_KINDS)) if any(item not in DEFINITION_KINDS for item in kinds): raise ReportingDefinitionError("Unsupported Reporting definition kind filter.") statement = session.query(ReportingDefinitionRevision).filter( ReportingDefinitionRevision.tenant_id == _principal_tenant(principal), ReportingDefinitionRevision.superseded_at.is_(None), ReportingDefinitionRevision.definition_kind.in_(kinds), ) statement = _filter_accessible(statement, principal) if statuses: statement = statement.filter( ReportingDefinitionRevision.status.in_(tuple(dict.fromkeys(statuses))) ) clean_query = query.strip().casefold() if clean_query: pattern = f"%{clean_query}%" statement = statement.filter( or_( func.lower(ReportingDefinitionRevision.name).like(pattern), func.lower(ReportingDefinitionRevision.definition_key).like(pattern), func.lower(ReportingDefinitionRevision.description).like(pattern), ) ) total = int(statement.with_entities(func.count()).scalar() or 0) rows = ( statement.order_by( ReportingDefinitionRevision.definition_kind.asc(), ReportingDefinitionRevision.name.asc(), ) .offset(offset) .limit(limit) .all() ) return tuple(definition_from_row(row) for row in rows), total def definition_history( session: Session, principal: object, *, definition_kind: str, definition_id: str, limit: int = 100, ) -> tuple[ReportingDefinitionRecord, ...]: if not can_read_definition( session, principal, definition_kind=definition_kind, definition_id=definition_id, ): return () rows = ( session.query(ReportingDefinitionRevision) .filter( ReportingDefinitionRevision.tenant_id == _principal_tenant(principal), ReportingDefinitionRevision.definition_kind == definition_kind, ReportingDefinitionRevision.definition_id == definition_id, ) .order_by(ReportingDefinitionRevision.revision.desc()) .limit(max(1, min(limit, 200))) .all() ) return tuple(definition_from_row(row) for row in rows) def can_read_definition( session: Session, principal: object, *, definition_kind: str, definition_id: str, ) -> bool: if not _has_scope(principal, READ_SCOPE): return False return _can_access( session, principal, definition_kind=definition_kind, definition_id=definition_id, permission="read", ) def can_write_definition( session: Session, principal: object, *, definition_kind: str, definition_id: str, ) -> bool: if not _has_scope(principal, WRITE_SCOPE): return False return _can_access( session, principal, definition_kind=definition_kind, definition_id=definition_id, permission="write", ) def _write_revision( session: Session, principal: object, *, identity: ReportingDefinitionIdentity, current: ReportingDefinitionRevision | None, revision: int, name: str, description: str | None, status: str, visibility: str, recorded_at: datetime, change_reason: str, payload: dict[str, Any], parent_kind: str | None, parent_id: str | None, parent_revision: int | None, idempotency_key: str, request_sha256: str, operation: str, ) -> ReportingDefinitionRecord: if current is not None: current.superseded_at = recorded_at content_hash = _sha256( { "name": name, "description": description, "status": status, "visibility": visibility, "parent_kind": parent_kind, "parent_id": parent_id, "parent_revision": parent_revision, "payload": payload, } ) event_id = str(uuid.uuid4()) row = ReportingDefinitionRevision( tenant_id=identity.tenant_id, identity_id=identity.id, definition_kind=identity.definition_kind, definition_id=identity.definition_id, definition_key=identity.definition_key, revision=revision, previous_revision_id=current.id if current else None, parent_kind=parent_kind, parent_id=parent_id, parent_revision=parent_revision, name=name, description=description, status=status, visibility=visibility, content_hash=content_hash, change_reason=change_reason, idempotency_key=_required(idempotency_key, "Reporting idempotency key", 255), request_sha256=request_sha256, event_id=event_id, recorded_at=recorded_at, payload=payload, changed_by=_actor(principal), ) session.add(row) session.flush() _sync_grants(session, row) event_type = f"reporting.{identity.definition_kind}.{operation}" emit_platform_event( session, PlatformEvent( event_id=event_id, type=event_type, module_id="reporting", payload={ "definition_kind": identity.definition_kind, "definition_key": identity.definition_key, "revision": revision, "status": status, "content_hash": content_hash, "change_reason": change_reason, }, occurred_at=recorded_at, actor=EventActorRef(type="account", id=_actor(principal)), tenant=EventTenantRef(id=identity.tenant_id), resource=EventObjectRef( type=f"reporting_{identity.definition_kind}", id=identity.definition_id, label=name, ), classification="restricted" if visibility == "restricted" else "internal", ), ) return definition_from_row(row) def _sync_grants(session: Session, row: ReportingDefinitionRevision) -> None: access_policy = row.payload.get("access_policy") subjects = ( access_policy.get("subjects", []) if isinstance(access_policy, Mapping) else [] ) desired: dict[tuple[str, str], list[str]] = {} if not isinstance(subjects, list): raise ReportingDefinitionError("Report access_policy subjects must be a list.") for subject in subjects: if not isinstance(subject, Mapping): raise ReportingDefinitionError("Report access subjects must be objects.") kind = str(subject.get("kind") or "") subject_id = str(subject.get("id") or "").strip() if kind not in SUBJECT_KINDS or not subject_id: raise ReportingDefinitionError("Report access subject is invalid.") permissions = tuple( dict.fromkeys(str(item) for item in subject.get("permissions", ["read"])) ) if not permissions or set(permissions) - {"read", "write", "publish", "admin"}: raise ReportingDefinitionError("Report access permissions are invalid.") desired[(kind, subject_id)] = list(permissions) rows = ( session.query(ReportingDefinitionGrant) .filter( ReportingDefinitionGrant.tenant_id == row.tenant_id, ReportingDefinitionGrant.definition_kind == row.definition_kind, ReportingDefinitionGrant.definition_id == row.definition_id, ) .all() ) existing = {(item.subject_kind, item.subject_id): item for item in rows} for key, item in existing.items(): if key not in desired: item.active = False item.source_revision = row.revision for key, permissions in desired.items(): item = existing.get(key) if item is None: session.add( ReportingDefinitionGrant( tenant_id=row.tenant_id, definition_kind=row.definition_kind, definition_id=row.definition_id, subject_kind=key[0], subject_id=key[1], permissions=permissions, active=True, source_revision=row.revision, ) ) else: item.permissions = permissions item.active = True item.source_revision = row.revision session.flush() def _validate_parent( session: Session, *, tenant_id: str, child_status: str, parent_kind: str | None, parent_id: str | None, parent_revision: int | None, ) -> None: if parent_kind is None: return row = ( session.query(ReportingDefinitionRevision) .filter( ReportingDefinitionRevision.tenant_id == tenant_id, ReportingDefinitionRevision.definition_kind == parent_kind, ReportingDefinitionRevision.definition_id == parent_id, ReportingDefinitionRevision.revision == parent_revision, ) .one_or_none() ) if row is None: raise ReportingDefinitionError( f"Reporting definition references a missing {parent_kind} revision." ) if child_status == "active" and row.status != "active": raise ReportingDefinitionError( f"An active Reporting definition requires an active {parent_kind} revision." ) def _parent_reference( definition_kind: str, payload: Mapping[str, object], ) -> tuple[str | None, str | None, int | None]: if definition_kind == "semantic_model": return "dataset", str(payload["dataset_id"]), int(payload["dataset_revision"]) if definition_kind == "report": return ( "semantic_model", str(payload["semantic_model_id"]), int(payload["semantic_model_revision"]), ) if definition_kind == "quality_plan": return "dataset", str(payload["dataset_id"]), int(payload["dataset_revision"]) return None, None, None def _replay( session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str, ) -> ReportingDefinitionRecord | None: key = _required(idempotency_key, "Reporting idempotency key", 255) row = ( session.query(ReportingDefinitionRevision) .filter( ReportingDefinitionRevision.tenant_id == tenant_id, ReportingDefinitionRevision.idempotency_key == key, ) .one_or_none() ) if row is None: return None if row.request_sha256 != request_sha256: raise ReportingDefinitionError( "Reporting idempotency conflict: the key belongs to another request." ) return definition_from_row(row) def _can_access( session: Session, principal: object, *, definition_kind: str, definition_id: str, permission: str, ) -> bool: tenant_id = _principal_tenant(principal) row = _current_row( session, tenant_id=tenant_id, definition_kind=definition_kind, definition_id=definition_id, lock=False, ) if row is None: return False if _has_scope(principal, ADMIN_SCOPE): return True identity = _identity(session, tenant_id, definition_kind, definition_id) actor_ids = _actor_ids(principal) if identity is not None and identity.created_by in actor_ids: return True if permission == "read" and row.visibility == "tenant": return True subjects = _principal_subjects(principal) if not subjects: return False clauses = [ and_( ReportingDefinitionGrant.subject_kind == kind, ReportingDefinitionGrant.subject_id == subject_id, ) for kind, subject_id in subjects ] grants = ( session.query(ReportingDefinitionGrant) .filter( ReportingDefinitionGrant.tenant_id == tenant_id, ReportingDefinitionGrant.definition_kind == definition_kind, ReportingDefinitionGrant.definition_id == definition_id, ReportingDefinitionGrant.active.is_(True), or_(*clauses), ) .all() ) return any( permission == "read" or permission in set(item.permissions or ()) or "admin" in set(item.permissions or ()) for item in grants ) def _require_scope(principal: object, scope: str) -> None: if not _has_scope(principal, scope): raise PermissionError(f"Missing scope: {scope}") def _filter_accessible(query: Query, principal: object) -> Query: if _has_scope(principal, ADMIN_SCOPE): return query conditions = [ReportingDefinitionRevision.visibility == "tenant"] actor_ids = _actor_ids(principal) if actor_ids: conditions.append( exists() .where( ReportingDefinitionIdentity.id == ReportingDefinitionRevision.identity_id ) .where(ReportingDefinitionIdentity.created_by.in_(actor_ids)) ) subjects = _principal_subjects(principal) if subjects: conditions.append( exists() .where( ReportingDefinitionGrant.tenant_id == ReportingDefinitionRevision.tenant_id ) .where( ReportingDefinitionGrant.definition_kind == ReportingDefinitionRevision.definition_kind ) .where( ReportingDefinitionGrant.definition_id == ReportingDefinitionRevision.definition_id ) .where(ReportingDefinitionGrant.active.is_(True)) .where( or_( *( and_( ReportingDefinitionGrant.subject_kind == kind, ReportingDefinitionGrant.subject_id == subject_id, ) for kind, subject_id in subjects ) ) ) ) return query.filter(or_(*conditions)) def _current_row( session: Session, *, tenant_id: str, definition_kind: str, definition_id: str, lock: bool, ) -> ReportingDefinitionRevision | None: query = session.query(ReportingDefinitionRevision).filter( ReportingDefinitionRevision.tenant_id == tenant_id, ReportingDefinitionRevision.definition_kind == definition_kind, ReportingDefinitionRevision.definition_id == definition_id, ReportingDefinitionRevision.superseded_at.is_(None), ) if lock: query = query.with_for_update() return query.one_or_none() def _identity( session: Session, tenant_id: str, definition_kind: str, definition_id: str, ) -> ReportingDefinitionIdentity | None: return ( session.query(ReportingDefinitionIdentity) .filter( ReportingDefinitionIdentity.tenant_id == tenant_id, ReportingDefinitionIdentity.definition_kind == definition_kind, ReportingDefinitionIdentity.definition_id == definition_id, ) .one_or_none() ) def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]: values: list[tuple[str, str]] = [] for kind, attribute in ( ("account", "account_id"), ("identity", "identity_id"), ("function_assignment", "acting_assignment_id"), ("service_account", "service_account_id"), ): value = getattr(principal, attribute, None) if str(value or "").strip(): values.append((kind, str(value))) for kind, attribute in ( ("group", "group_ids"), ("role", "role_ids"), ("function_assignment", "function_assignment_ids"), ): values.extend( (kind, str(value)) for value in getattr(principal, attribute, ()) or () if str(value or "").strip() ) return tuple(dict.fromkeys(values)) 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 _actor_ids(principal: object) -> tuple[str, ...]: user = getattr(principal, "user", None) return tuple( dict.fromkeys( str(value) for value in ( getattr(principal, "account_id", None), getattr(principal, "identity_id", None), getattr(principal, "membership_id", None), getattr(user, "id", None), ) if str(value or "").strip() ) ) def _principal_tenant(principal: object) -> str: tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() if not tenant_id: raise ReportingDefinitionError( "Reporting operations require a tenant-bound principal." ) return tenant_id def _has_scope(principal: object, scope: str) -> bool: method = getattr(principal, "has", None) if callable(method): return bool(method(scope)) return scopes_grant_compatible( frozenset(getattr(principal, "scopes", ()) or ()), scope, ) def _definition_kind(value: str) -> str: if value not in DEFINITION_KINDS: raise ReportingDefinitionError( f"Unsupported Reporting definition kind: {value!r}." ) return value def _status(value: str) -> str: if value not in STATUS_TRANSITIONS: raise ReportingDefinitionError(f"Unsupported Reporting status: {value!r}.") return value def _visibility(value: str) -> str: if value not in {"tenant", "restricted"}: raise ReportingDefinitionError(f"Unsupported Reporting visibility: {value!r}.") return value def _key(value: object, label: str) -> str: result = _required(value, label, 120).casefold() if any( character not in "abcdefghijklmnopqrstuvwxyz0123456789._-" for character in result ): raise ReportingDefinitionError( f"{label} may contain only letters, digits, dot, underscore, and hyphen." ) return result def _required(value: object, label: str, maximum: int) -> str: result = str(value or "").strip() if not result: raise ReportingDefinitionError(f"{label} is required.") if len(result) > maximum: raise ReportingDefinitionError(f"{label} is limited to {maximum} characters.") return result def _optional(value: object, label: str, maximum: int) -> str | None: result = str(value or "").strip() if not result: return None if len(result) > maximum: raise ReportingDefinitionError(f"{label} is limited to {maximum} characters.") return result def _aware(value: datetime, label: str) -> None: if value.tzinfo is None or value.utcoffset() is None: raise ReportingDefinitionError(f"{label} must include a timezone.") def _sha256(value: object) -> str: return hashlib.sha256( json.dumps( _json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True, ).encode("utf-8") ).hexdigest() def _json_value(value: object) -> Any: if isinstance(value, datetime): return value.isoformat() if hasattr(value, "model_dump"): return _json_value(value.model_dump(mode="json")) 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 __all__ = [ "ADMIN_SCOPE", "DEFINITION_KINDS", "READ_SCOPE", "ReportingDefinitionError", "WRITE_SCOPE", "can_read_definition", "can_write_definition", "create_definition", "definition_history", "get_definition", "list_definitions", "update_definition", ]