397 lines
14 KiB
Python
397 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_committee.backend.db.models import (
|
|
CommitteeDecisionProjection,
|
|
CommitteeWorkspaceEvent,
|
|
CommitteeWorkspaceRevision,
|
|
)
|
|
from govoplan_core.core.dsar import (
|
|
DsarErasureActionRef,
|
|
DsarExecutionResultRef,
|
|
DsarRecordRef,
|
|
DsarSubjectRef,
|
|
dsar_capability_name,
|
|
)
|
|
|
|
|
|
COMMITTEE_DSAR_CAPABILITY = dsar_capability_name("committee")
|
|
_MAX_RECORDS = 5_000
|
|
_CONFLICT = object()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _SubjectSelectors:
|
|
actor_ids: tuple[str, ...]
|
|
object_kind: str | None
|
|
object_id: str | None
|
|
decision_id: str | None
|
|
|
|
|
|
class CommitteeDsarProvider:
|
|
provider_id = "committee"
|
|
module_id = "committee"
|
|
|
|
def search_subject(
|
|
self,
|
|
session: object,
|
|
*,
|
|
tenant_id: str,
|
|
subject: DsarSubjectRef,
|
|
) -> Sequence[DsarRecordRef]:
|
|
db = _session(session)
|
|
selectors = _subject_selectors(subject)
|
|
if selectors is None:
|
|
return ()
|
|
records: list[DsarRecordRef] = []
|
|
revisions = db.query(CommitteeWorkspaceRevision).filter(
|
|
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
|
CommitteeWorkspaceRevision.changed_by.in_(selectors.actor_ids),
|
|
)
|
|
events = db.query(CommitteeWorkspaceEvent).filter(
|
|
CommitteeWorkspaceEvent.tenant_id == tenant_id,
|
|
CommitteeWorkspaceEvent.actor_id.in_(selectors.actor_ids),
|
|
)
|
|
decisions = db.query(CommitteeDecisionProjection).filter(
|
|
CommitteeDecisionProjection.tenant_id == tenant_id,
|
|
CommitteeDecisionProjection.changed_by.in_(selectors.actor_ids),
|
|
)
|
|
if selectors.object_kind:
|
|
revisions = revisions.filter(
|
|
CommitteeWorkspaceRevision.object_kind == selectors.object_kind
|
|
)
|
|
events = events.filter(
|
|
CommitteeWorkspaceEvent.object_kind == selectors.object_kind
|
|
)
|
|
if selectors.object_id:
|
|
revisions = revisions.filter(
|
|
CommitteeWorkspaceRevision.object_id == selectors.object_id
|
|
)
|
|
events = events.filter(
|
|
CommitteeWorkspaceEvent.object_id == selectors.object_id
|
|
)
|
|
if selectors.object_kind == "meeting":
|
|
decisions = decisions.filter(
|
|
CommitteeDecisionProjection.meeting_id == selectors.object_id
|
|
)
|
|
elif selectors.object_kind in {"agenda", "agenda_item"}:
|
|
decisions = decisions.filter(
|
|
CommitteeDecisionProjection.agenda_item_id == selectors.object_id
|
|
)
|
|
elif selectors.object_kind == "decision":
|
|
decisions = decisions.filter(
|
|
CommitteeDecisionProjection.decision_id == selectors.object_id
|
|
)
|
|
if selectors.decision_id:
|
|
decisions = decisions.filter(
|
|
CommitteeDecisionProjection.decision_id == selectors.decision_id
|
|
)
|
|
|
|
records.extend(
|
|
_revision_attribution(row)
|
|
for row in _limited(
|
|
revisions,
|
|
CommitteeWorkspaceRevision.recorded_at,
|
|
CommitteeWorkspaceRevision.id,
|
|
label="workspace revision attribution",
|
|
)
|
|
)
|
|
records.extend(
|
|
_event_attribution(row)
|
|
for row in _limited(
|
|
events,
|
|
CommitteeWorkspaceEvent.occurred_at,
|
|
CommitteeWorkspaceEvent.id,
|
|
label="workspace event attribution",
|
|
)
|
|
)
|
|
records.extend(
|
|
_decision_attribution(row)
|
|
for row in _limited(
|
|
decisions,
|
|
CommitteeDecisionProjection.recorded_at,
|
|
CommitteeDecisionProjection.id,
|
|
label="Decision projection attribution",
|
|
)
|
|
)
|
|
if len(records) > _MAX_RECORDS:
|
|
raise ValueError(
|
|
"Committee DSAR combined result limit exceeded; narrow the selectors."
|
|
)
|
|
return tuple(
|
|
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
|
)
|
|
|
|
def plan_erasure(
|
|
self,
|
|
session: object,
|
|
*,
|
|
tenant_id: str,
|
|
subject: DsarSubjectRef,
|
|
records: Sequence[DsarRecordRef],
|
|
) -> Sequence[DsarErasureActionRef]:
|
|
del tenant_id
|
|
_session(session)
|
|
if _subject_selectors(subject) is None:
|
|
raise ValueError("Committee DSAR subject selectors conflict.")
|
|
actions: list[DsarErasureActionRef] = []
|
|
for record in records:
|
|
_validate_record(record)
|
|
actions.append(
|
|
DsarErasureActionRef(
|
|
action_id=(
|
|
f"committee:retain:{record.resource_type}:{record.resource_id}"
|
|
),
|
|
provider_id=self.provider_id,
|
|
module_id=self.module_id,
|
|
kind="retain",
|
|
resource_type=record.resource_type,
|
|
resource_id=record.resource_id,
|
|
title=f"Retain {record.title}",
|
|
rationale=record.retention_reason
|
|
or "Committee attribution is immutable evidence.",
|
|
executable=False,
|
|
)
|
|
)
|
|
return tuple(actions)
|
|
|
|
def execute_erasure(
|
|
self,
|
|
session: object,
|
|
*,
|
|
tenant_id: str,
|
|
subject: DsarSubjectRef,
|
|
actions: Sequence[DsarErasureActionRef],
|
|
request_id: str,
|
|
) -> Sequence[DsarExecutionResultRef]:
|
|
del tenant_id
|
|
_session(session)
|
|
if _subject_selectors(subject) is None:
|
|
raise ValueError("Committee DSAR subject selectors conflict.")
|
|
results: list[DsarExecutionResultRef] = []
|
|
for action in actions:
|
|
_validate_action(action)
|
|
if action.executable or action.kind != "retain":
|
|
raise ValueError("Committee DSAR publishes retain-only actions.")
|
|
results.append(
|
|
DsarExecutionResultRef(
|
|
action_id=action.action_id,
|
|
status="blocked",
|
|
summary=(
|
|
"Committee workspace, event, and Decision attribution remains "
|
|
"immutable institutional evidence."
|
|
),
|
|
evidence={"request_id": request_id},
|
|
)
|
|
)
|
|
return tuple(results)
|
|
|
|
|
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
|
references = subject.external_references
|
|
values = {
|
|
"account_id": _coalesce(
|
|
subject.account_id,
|
|
references.get("committee.account"),
|
|
references.get("access.account"),
|
|
),
|
|
"membership_id": _coalesce(
|
|
subject.membership_id,
|
|
references.get("committee.membership"),
|
|
references.get("tenancy.membership"),
|
|
),
|
|
"identity_id": _coalesce(
|
|
subject.identity_id,
|
|
references.get("committee.identity"),
|
|
references.get("identity.id"),
|
|
),
|
|
"actor_id": _coalesce(
|
|
references.get("committee.actor"),
|
|
references.get("committee.changed_by"),
|
|
),
|
|
"object_kind": _coalesce(
|
|
references.get("committee.object_kind"),
|
|
references.get("committee.kind"),
|
|
),
|
|
"object_id": _coalesce(
|
|
references.get("committee.object"),
|
|
references.get("committee.object_id"),
|
|
),
|
|
"decision_id": _coalesce(
|
|
references.get("committee.decision"),
|
|
references.get("committee.decision_id"),
|
|
),
|
|
}
|
|
if any(value is _CONFLICT for value in values.values()):
|
|
return None
|
|
actor_ids = tuple(
|
|
dict.fromkeys(
|
|
value
|
|
for value in (
|
|
_optional_string(values["account_id"]),
|
|
_prefixed("account", values["account_id"]),
|
|
_optional_string(values["membership_id"]),
|
|
_prefixed("membership", values["membership_id"]),
|
|
_optional_string(values["identity_id"]),
|
|
_prefixed("identity", values["identity_id"]),
|
|
)
|
|
if value
|
|
)
|
|
)
|
|
direct_actor = _optional_string(values["actor_id"])
|
|
if direct_actor:
|
|
if actor_ids and direct_actor not in actor_ids:
|
|
return None
|
|
if not actor_ids:
|
|
actor_ids = (direct_actor,)
|
|
if not actor_ids:
|
|
return None
|
|
return _SubjectSelectors(
|
|
actor_ids=actor_ids,
|
|
object_kind=_optional_string(values["object_kind"]),
|
|
object_id=_optional_string(values["object_id"]),
|
|
decision_id=_optional_string(values["decision_id"]),
|
|
)
|
|
|
|
|
|
def _revision_attribution(row: CommitteeWorkspaceRevision) -> DsarRecordRef:
|
|
return DsarRecordRef(
|
|
provider_id="committee",
|
|
module_id="committee",
|
|
resource_type="committee_workspace_actor_attribution",
|
|
resource_id=row.id,
|
|
category="committee_workspace_accountability",
|
|
title="Committee workspace revision attribution",
|
|
data={
|
|
"object_kind": row.object_kind,
|
|
"object_id": row.object_id,
|
|
"revision": row.revision,
|
|
"parent_kind": row.parent_kind,
|
|
"parent_id": row.parent_id,
|
|
"state": row.state,
|
|
"recorded_at": _iso(row.recorded_at),
|
|
"superseded_at": _iso(row.superseded_at),
|
|
"activity": "recorded_workspace_revision",
|
|
},
|
|
observed_at=_aware(row.recorded_at),
|
|
immutable_evidence=True,
|
|
retention_reason="Committee workspace attribution is immutable evidence.",
|
|
)
|
|
|
|
|
|
def _event_attribution(row: CommitteeWorkspaceEvent) -> DsarRecordRef:
|
|
return DsarRecordRef(
|
|
provider_id="committee",
|
|
module_id="committee",
|
|
resource_type="committee_event_actor_attribution",
|
|
resource_id=row.id,
|
|
category="committee_workspace_accountability",
|
|
title="Committee lifecycle event attribution",
|
|
data={
|
|
"object_kind": row.object_kind,
|
|
"object_id": row.object_id,
|
|
"object_revision": row.object_revision,
|
|
"event_id": row.event_id,
|
|
"event_type": row.event_type,
|
|
"occurred_at": _iso(row.occurred_at),
|
|
},
|
|
observed_at=_aware(row.occurred_at),
|
|
immutable_evidence=True,
|
|
retention_reason="Committee event attribution is immutable evidence.",
|
|
)
|
|
|
|
|
|
def _decision_attribution(row: CommitteeDecisionProjection) -> DsarRecordRef:
|
|
return DsarRecordRef(
|
|
provider_id="committee",
|
|
module_id="committee",
|
|
resource_type="committee_decision_actor_attribution",
|
|
resource_id=row.id,
|
|
category="committee_decision_accountability",
|
|
title="Committee fallback Decision attribution",
|
|
data={
|
|
"decision_id": row.decision_id,
|
|
"revision": row.revision,
|
|
"meeting_id": row.meeting_id,
|
|
"agenda_item_id": row.agenda_item_id,
|
|
"state": row.state,
|
|
"recorded_at": _iso(row.recorded_at),
|
|
"superseded_at": _iso(row.superseded_at),
|
|
"activity": "recorded_decision_projection",
|
|
},
|
|
observed_at=_aware(row.recorded_at),
|
|
immutable_evidence=True,
|
|
retention_reason=(
|
|
"Committee fallback Decision attribution is immutable evidence."
|
|
),
|
|
)
|
|
|
|
|
|
def _limited(query, first, second, *, label: str):
|
|
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
|
if len(rows) > _MAX_RECORDS:
|
|
raise ValueError(f"Committee DSAR {label} limit exceeded; narrow selectors.")
|
|
return rows
|
|
|
|
|
|
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_string(value: object) -> str | None:
|
|
return value if isinstance(value, str) and value else None
|
|
|
|
|
|
def _prefixed(prefix: str, value: object) -> str | None:
|
|
normalized = _optional_string(value)
|
|
return f"{prefix}:{normalized}" if normalized else 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("Committee DSAR requires a SQLAlchemy Session.")
|
|
return value
|
|
|
|
|
|
_RESOURCE_TYPES = {
|
|
"committee_workspace_actor_attribution",
|
|
"committee_event_actor_attribution",
|
|
"committee_decision_actor_attribution",
|
|
}
|
|
|
|
|
|
def _validate_record(record: DsarRecordRef) -> None:
|
|
if record.provider_id != "committee" or record.module_id != "committee":
|
|
raise ValueError("Committee DSAR cannot plan a foreign provider record.")
|
|
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
|
raise ValueError("Committee DSAR record identity is invalid.")
|
|
|
|
|
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
|
if action.provider_id != "committee" or action.module_id != "committee":
|
|
raise ValueError("Committee DSAR cannot execute a foreign provider action.")
|
|
if not action.action_id.startswith("committee:retain:"):
|
|
raise ValueError("Committee DSAR action identity is invalid.")
|
|
|
|
|
|
__all__ = ["COMMITTEE_DSAR_CAPABILITY", "CommitteeDsarProvider"]
|