1014 lines
32 KiB
Python
1014 lines
32 KiB
Python
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.governance import (
|
|
apply_parent_governance,
|
|
normalize_definition_governance,
|
|
scope_visible,
|
|
)
|
|
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))
|
|
validated_payload = validate_definition_payload(
|
|
kind,
|
|
normalize_definition_governance(
|
|
validated_payload,
|
|
principal,
|
|
administrative=_has_scope(principal, ADMIN_SCOPE),
|
|
),
|
|
)
|
|
parent_kind, parent_id, parent_revision = _parent_reference(
|
|
kind,
|
|
validated_payload,
|
|
)
|
|
validated_payload = validate_definition_payload(
|
|
kind,
|
|
_validate_parent(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
child_status=clean_status,
|
|
parent_kind=parent_kind,
|
|
parent_id=parent_id,
|
|
parent_revision=parent_revision,
|
|
child_payload=validated_payload,
|
|
),
|
|
)
|
|
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)),
|
|
)
|
|
next_payload = validate_definition_payload(
|
|
kind,
|
|
normalize_definition_governance(
|
|
next_payload,
|
|
principal,
|
|
administrative=_has_scope(principal, ADMIN_SCOPE),
|
|
),
|
|
)
|
|
parent_kind, parent_id, parent_revision = _parent_reference(kind, next_payload)
|
|
next_payload = validate_definition_payload(
|
|
kind,
|
|
_validate_parent(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
child_status=next_status,
|
|
parent_kind=parent_kind,
|
|
parent_id=parent_id,
|
|
parent_revision=parent_revision,
|
|
child_payload=next_payload,
|
|
),
|
|
)
|
|
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,
|
|
child_payload: Mapping[str, object],
|
|
) -> dict[str, object]:
|
|
if parent_kind is None:
|
|
return dict(child_payload)
|
|
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."
|
|
)
|
|
return apply_parent_governance(child_payload, row.payload)
|
|
|
|
|
|
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 not scope_visible(row.payload, principal):
|
|
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
|
|
governance = ReportingDefinitionRevision.payload["governance"]
|
|
scope_type = governance["scope_type"].as_string()
|
|
scope_id = governance["scope_id"].as_string()
|
|
inherited = governance["inherit_to_lower_scopes"].as_boolean()
|
|
scope_conditions = [
|
|
scope_type.is_(None),
|
|
and_(
|
|
scope_type == "tenant",
|
|
or_(scope_id.is_(None), scope_id == _principal_tenant(principal)),
|
|
),
|
|
and_(scope_type == "system", inherited.is_(True)),
|
|
]
|
|
group_ids = tuple(_string_subject_ids(principal, "group_ids"))
|
|
if group_ids:
|
|
scope_conditions.append(and_(scope_type == "group", scope_id.in_(group_ids)))
|
|
user_ids = _actor_ids(principal)
|
|
if user_ids:
|
|
scope_conditions.append(and_(scope_type == "user", scope_id.in_(user_ids)))
|
|
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(and_(or_(*scope_conditions), or_(*conditions)))
|
|
|
|
|
|
def _string_subject_ids(principal: object, attribute: str) -> tuple[str, ...]:
|
|
raw = getattr(principal, attribute, ()) or ()
|
|
if isinstance(raw, (str, bytes)):
|
|
return (str(raw),) if raw else ()
|
|
return tuple(dict.fromkeys(str(value) for value in raw if str(value or "").strip()))
|
|
|
|
|
|
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",
|
|
]
|