Files
govoplan-datasources/src/govoplan_datasources/backend/dsar_provider.py
T

744 lines
25 KiB
Python

from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_datasources.backend.db.models import (
DatasourceGovernanceReferenceRecord,
DatasourceMaterializationRecord,
DatasourcePayloadRecord,
DatasourcePublicationRecord,
DatasourceRecord,
DatasourceStageRecord,
)
DATASOURCES_DSAR_CAPABILITY = dsar_capability_name("datasources")
_MAX_RECORDS = 5_000
_CONFLICT = object()
_DIRECT_ALIASES = {
"datasource_id": ("datasources.datasource", "datasources.catalogue"),
"governance_reference_id": ("datasources.governance_reference",),
"materialization_id": ("datasources.materialization",),
"payload_id": ("datasources.payload",),
"stage_id": ("datasources.stage",),
"publication_id": ("datasources.publication",),
}
_RESOURCE_MODELS = {
"datasource": DatasourceRecord,
"datasource_governance_reference": DatasourceGovernanceReferenceRecord,
"datasource_materialization": DatasourceMaterializationRecord,
"datasource_payload": DatasourcePayloadRecord,
"datasource_stage": DatasourceStageRecord,
"datasource_publication": DatasourcePublicationRecord,
}
@dataclass(frozen=True, slots=True)
class _Selectors:
account_id: str | None
identity_id: str | None
membership_id: str | None
direct: dict[str, str]
@property
def actor_ids(self) -> tuple[str, ...]:
return tuple(
value
for value in (self.account_id, self.identity_id, self.membership_id)
if value
)
@dataclass(frozen=True, slots=True)
class _Match:
resource_type: str
row: Any
category: str
class DatasourcesDsarProvider:
provider_id = "datasources"
module_id = "datasources"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None or not (selectors.actor_ids or selectors.direct):
return ()
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
if direct is None:
return ()
if direct:
if selectors.actor_ids and not any(
_correlates(match, selectors.actor_ids) for match in direct
):
return ()
matches = direct
else:
matches = _canonical_matches(
db,
tenant_id=tenant_id,
actor_ids=selectors.actor_ids,
)
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
for match in matches:
key = (match.resource_type, str(match.row.id))
if key in seen:
continue
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Datasources DSAR result limit exceeded; narrow the selectors."
)
seen.add(key)
records.append(_record(match))
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Datasources DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.category in {
"unpromoted_datasource_stage",
"unreferenced_datasource_payload",
}:
kind = "delete"
executable = True
rationale = (
"Remove transient Datasources content that has not become "
"immutable or referenced lifecycle evidence."
)
elif record.category == "datasource_operator_attribution":
kind = "retain"
executable = False
rationale = record.retention_reason or (
"Institutional data operations remain attributable."
)
else:
kind = "manual_review"
executable = False
rationale = (
"A data steward must correct the authoritative source and "
"review immutable revisions, holds, consumers, and evidence."
)
actions.append(
DsarErasureActionRef(
action_id=(
f"datasources:{kind}:{record.resource_type}:"
f"{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind=kind,
resource_type=record.resource_type,
resource_id=record.resource_id,
title=(
f"Delete {record.title}"
if executable
else f"Review {record.title}"
),
rationale=rationale,
executable=executable,
irreversible=executable,
metadata={"record_category": record.category},
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None:
raise ValueError("Datasources DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Use governed source correction and Datasources "
"retention review before changing published state."
),
evidence={"request_id": request_id},
)
)
continue
if action.kind != "delete" or action.resource_type not in {
"datasource_stage",
"datasource_payload",
}:
raise ValueError("Datasources DSAR executable action is unsupported.")
model = _RESOURCE_MODELS[action.resource_type]
row = (
db.query(model)
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
.with_for_update()
.one_or_none()
)
if row is None:
status = "unchanged"
summary = "Transient Datasources row was already absent."
else:
match = _Match(action.resource_type, row, "execution")
if not (
_directly_targets(selectors, match)
or _correlates(match, selectors.actor_ids)
):
raise ValueError(
"Datasources DSAR action is not corroborated by the subject."
)
_assert_deletable(db, resource_type=action.resource_type, row=row)
db.delete(row)
db.flush()
status = "executed"
summary = "Transient, unreferenced Datasources row removed."
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status=status,
summary=summary,
evidence={"request_id": request_id},
)
)
return tuple(results)
def _direct_matches(
session: Session,
*,
tenant_id: str,
selectors: _Selectors,
) -> list[_Match] | None:
matches: list[_Match] = []
for selector, value in selectors.direct.items():
if selector == "datasource_id":
datasource = _one(
session,
DatasourceRecord,
tenant_id=tenant_id,
field="id",
value=value.removeprefix("datasource:"),
)
if datasource is None:
return None
current = _datasource_package(
session,
tenant_id=tenant_id,
datasource=datasource,
)
else:
model, resource_type = {
"governance_reference_id": (
DatasourceGovernanceReferenceRecord,
"datasource_governance_reference",
),
"materialization_id": (
DatasourceMaterializationRecord,
"datasource_materialization",
),
"payload_id": (DatasourcePayloadRecord, "datasource_payload"),
"stage_id": (DatasourceStageRecord, "datasource_stage"),
"publication_id": (
DatasourcePublicationRecord,
"datasource_publication",
),
}[selector]
row = _one(
session,
model,
tenant_id=tenant_id,
field="id",
value=_strip_prefix(value),
)
if row is None:
return None
current = [
_Match(
resource_type, row, _direct_category(session, resource_type, row)
)
]
matches.extend(current)
if len(matches) > _MAX_RECORDS:
raise ValueError(
"Datasources DSAR result limit exceeded; narrow the selectors."
)
roots = {
root
for match in matches
for root in _root_datasource_ids(session, match)
if root
}
if len(roots) > 1:
return None
return matches
def _datasource_package(
session: Session,
*,
tenant_id: str,
datasource: DatasourceRecord,
) -> list[_Match]:
matches = [_Match("datasource", datasource, "datasource_configuration")]
specs = (
(
DatasourceGovernanceReferenceRecord,
"datasource_governance_reference",
),
(DatasourceMaterializationRecord, "datasource_materialization"),
(DatasourceStageRecord, "datasource_stage"),
(DatasourcePublicationRecord, "datasource_publication"),
)
materializations: list[DatasourceMaterializationRecord] = []
for model, resource_type in specs:
rows = (
session.query(model)
.filter(
model.tenant_id == tenant_id,
(
model.target_datasource_id == datasource.id
if model is DatasourceStageRecord
else model.datasource_id == datasource.id
),
)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
if model is DatasourceMaterializationRecord:
materializations = rows
matches.extend(
_Match(
resource_type,
row,
(
"datasource_related_stage"
if resource_type == "datasource_stage"
else _direct_category(session, resource_type, row)
),
)
for row in rows
)
payload_ids = {row.payload_id for row in materializations if row.payload_id}
if payload_ids:
payloads = (
session.query(DatasourcePayloadRecord)
.filter(
DatasourcePayloadRecord.tenant_id == tenant_id,
DatasourcePayloadRecord.id.in_(payload_ids),
)
.order_by(DatasourcePayloadRecord.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches.extend(
_Match(
"datasource_payload",
row,
_direct_category(session, "datasource_payload", row),
)
for row in payloads
)
return matches
def _canonical_matches(
session: Session,
*,
tenant_id: str,
actor_ids: tuple[str, ...],
) -> list[_Match]:
if not actor_ids:
return []
specs = (
(
DatasourceRecord,
or_(
DatasourceRecord.created_by.in_(actor_ids),
DatasourceRecord.updated_by.in_(actor_ids),
),
"datasource",
),
(
DatasourceMaterializationRecord,
DatasourceMaterializationRecord.created_by.in_(actor_ids),
"datasource_materialization",
),
(
DatasourcePayloadRecord,
DatasourcePayloadRecord.created_by.in_(actor_ids),
"datasource_payload",
),
(
DatasourceStageRecord,
DatasourceStageRecord.created_by.in_(actor_ids),
"datasource_stage",
),
(
DatasourcePublicationRecord,
DatasourcePublicationRecord.created_by.in_(actor_ids),
"datasource_publication",
),
)
matches: list[_Match] = []
for model, condition, resource_type in specs:
rows = (
session.query(model)
.filter(model.tenant_id == tenant_id, condition)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches.extend(
_Match(resource_type, row, "datasource_operator_attribution")
for row in rows
)
if len(matches) > _MAX_RECORDS:
raise ValueError(
"Datasources DSAR result limit exceeded; narrow the selectors."
)
return matches
def _one(
session: Session,
model: Any,
*,
tenant_id: str,
field: str,
value: str,
) -> Any | None:
return (
session.query(model)
.filter(model.tenant_id == tenant_id, getattr(model, field) == value)
.one_or_none()
)
def _direct_category(session: Session, resource_type: str, row: Any) -> str:
if resource_type == "datasource_stage" and row.promoted_at is None:
return "unpromoted_datasource_stage"
if resource_type == "datasource_payload":
referenced = (
session.query(DatasourceMaterializationRecord.id)
.filter(
DatasourceMaterializationRecord.tenant_id == row.tenant_id,
DatasourceMaterializationRecord.payload_id == row.id,
)
.limit(1)
.count()
)
if not referenced:
return "unreferenced_datasource_payload"
return {
"datasource": "datasource_configuration",
"datasource_governance_reference": "datasource_governance_configuration",
"datasource_materialization": "immutable_datasource_materialization",
"datasource_payload": "referenced_datasource_payload",
"datasource_stage": "promoted_datasource_stage",
"datasource_publication": "immutable_datasource_publication",
}[resource_type]
def _root_datasource_ids(session: Session, match: _Match) -> set[str]:
row = match.row
if match.resource_type == "datasource":
return {row.id}
if match.resource_type == "datasource_stage":
return {row.target_datasource_id} if row.target_datasource_id else set()
if match.resource_type == "datasource_payload":
return {
value
for (value,) in session.query(DatasourceMaterializationRecord.datasource_id)
.filter(
DatasourceMaterializationRecord.tenant_id == row.tenant_id,
DatasourceMaterializationRecord.payload_id == row.id,
)
.all()
}
return {row.datasource_id}
def _correlates(match: _Match, actor_ids: tuple[str, ...]) -> bool:
row = match.row
return any(
str(getattr(row, field, "") or "") in actor_ids
for field in ("created_by", "updated_by")
)
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
row = match.row
selector, field = {
"datasource": ("datasource_id", "id"),
"datasource_governance_reference": (
"governance_reference_id",
"id",
),
"datasource_materialization": ("materialization_id", "id"),
"datasource_payload": ("payload_id", "id"),
"datasource_stage": ("stage_id", "id"),
"datasource_publication": ("publication_id", "id"),
}[match.resource_type]
value = _strip_prefix(selectors.direct.get(selector, ""))
if value == str(getattr(row, field)):
return True
datasource_selector = _strip_prefix(selectors.direct.get("datasource_id", ""))
return bool(
datasource_selector
and datasource_selector in _root_datasource_ids_for_row(match)
)
def _root_datasource_ids_for_row(match: _Match) -> set[str]:
row = match.row
if match.resource_type == "datasource":
return {row.id}
if match.resource_type == "datasource_stage":
return {row.target_datasource_id} if row.target_datasource_id else set()
if match.resource_type == "datasource_payload":
return set()
return {row.datasource_id}
def _assert_deletable(session: Session, *, resource_type: str, row: Any) -> None:
if resource_type == "datasource_stage":
if row.promoted_at is not None or row.promoted_materialization_id is not None:
raise ValueError("Promoted Datasource stages require manual review.")
return
referenced = (
session.query(DatasourceMaterializationRecord.id)
.filter(
DatasourceMaterializationRecord.tenant_id == row.tenant_id,
DatasourceMaterializationRecord.payload_id == row.id,
)
.limit(1)
.count()
)
if referenced:
raise ValueError("Referenced Datasource payloads require manual review.")
def _record(match: _Match) -> DsarRecordRef:
row = match.row
immutable = match.category == "datasource_operator_attribution"
return DsarRecordRef(
provider_id="datasources",
module_id="datasources",
resource_type=match.resource_type,
resource_id=str(row.id),
category=match.category,
title=_title(match.resource_type),
data={
key: value
for key, value in _record_data(match.resource_type, row).items()
if value is not None
},
observed_at=_observed_at(row),
immutable_evidence=immutable,
retention_reason=(
"Institutional datasource creation, publication, and revision "
"activity remains attributable for governance and audit."
if immutable
else None
),
source_path="/datasources",
)
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
if resource_type == "datasource":
return {
"kind": row.kind,
"mode": row.mode,
"shape": row.shape,
"status": row.status,
"schema_version": row.schema_version,
"row_count": row.row_count,
"byte_count": row.byte_count,
"authority_mode": row.authority_mode,
"classification": row.classification,
"publication_state": row.publication_state,
"deleted_at": _iso(row.deleted_at),
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
if resource_type == "datasource_governance_reference":
return {"relation": row.relation}
if resource_type == "datasource_materialization":
return {
"revision": row.revision,
"state": row.state,
"schema_version": row.schema_version,
"row_count": row.row_count,
"byte_count": row.byte_count,
"frozen_at": _iso(row.frozen_at),
"source_timestamp": _iso(row.source_timestamp),
"created_at": _iso(row.created_at),
}
if resource_type == "datasource_payload":
return {
"backend": row.backend,
"state": row.state,
"media_type": row.media_type,
"row_count": row.row_count,
"byte_count": row.byte_count,
"created_at": _iso(row.created_at),
}
if resource_type == "datasource_stage":
return {
"kind": row.kind,
"mode": row.mode,
"shape": row.shape,
"state": row.state,
"row_count": row.row_count,
"byte_count": row.byte_count,
"promoted": row.promoted_at is not None,
"promoted_at": _iso(row.promoted_at),
"created_at": _iso(row.created_at),
}
return {
"producer_module": row.producer_module,
"status": row.status,
"created_at": _iso(row.created_at),
}
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
references = subject.external_references
account_id = _coalesce(
subject.account_id,
references.get("datasources.account"),
references.get("access.account"),
)
identity_id = _coalesce(
subject.identity_id,
references.get("datasources.identity"),
references.get("identity.id"),
)
membership_id = _coalesce(
subject.membership_id,
references.get("datasources.membership"),
references.get("tenancy.membership"),
)
if any(value is _CONFLICT for value in (account_id, identity_id, membership_id)):
return None
direct: dict[str, str] = {}
for selector, aliases in _DIRECT_ALIASES.items():
value = _coalesce(*(references.get(alias) for alias in aliases))
if value is _CONFLICT:
return None
if value:
direct[selector] = str(value)
return _Selectors(
account_id=_optional(account_id),
identity_id=_optional(identity_id),
membership_id=_optional(membership_id),
direct=direct,
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _strip_prefix(value: str) -> str:
return value.partition(":")[2] if ":" in value else value
def _title(resource_type: str) -> str:
return resource_type.replace("_", " ").title()
def _observed_at(row: Any) -> datetime | None:
for field in ("promoted_at", "source_timestamp", "updated_at", "created_at"):
value = getattr(row, field, None)
if isinstance(value, datetime):
return _aware(value)
return None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Datasources DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "datasources" or record.module_id != "datasources":
raise ValueError("Datasources DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
raise ValueError("Datasources DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "datasources" or action.module_id != "datasources":
raise ValueError("Datasources DSAR cannot execute a foreign provider action.")
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
"datasources:"
):
raise ValueError("Datasources DSAR action identity is invalid.")
__all__ = ["DATASOURCES_DSAR_CAPABILITY", "DatasourcesDsarProvider"]