feat: implement governed reporting vertical
This commit is contained in:
@@ -0,0 +1,955 @@
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user