|
|
@@ -0,0 +1,386 @@
|
|
|
+"""Versioned RACI bindings for governed resources and device accountability."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+import uuid
|
|
|
+from dataclasses import asdict, dataclass
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from sqlalchemy import text
|
|
|
+
|
|
|
+from app.core.common.identifiers import new_governance_uid
|
|
|
+
|
|
|
+
|
|
|
+RESOURCE_TYPES = frozenset(
|
|
|
+ {
|
|
|
+ "business_domain",
|
|
|
+ "device_asset",
|
|
|
+ "device_ontology",
|
|
|
+ "device_mapping",
|
|
|
+ "fault_classification",
|
|
|
+ "quality_issue",
|
|
|
+ }
|
|
|
+)
|
|
|
+DEVICE_RESOURCE_TYPES = frozenset(
|
|
|
+ {
|
|
|
+ "device_asset",
|
|
|
+ "device_ontology",
|
|
|
+ "device_mapping",
|
|
|
+ "fault_classification",
|
|
|
+ }
|
|
|
+)
|
|
|
+RESPONSIBILITY_ROLES = frozenset(
|
|
|
+ {"domain_owner", "data_steward", "data_architect", "asset_manager"}
|
|
|
+)
|
|
|
+RACI_ROLES = frozenset({"responsible", "accountable", "consulted", "informed"})
|
|
|
+
|
|
|
+
|
|
|
+class ResponsibilityError(RuntimeError):
|
|
|
+ """Base error for responsibility-matrix operations."""
|
|
|
+
|
|
|
+
|
|
|
+class ResponsibilityValidationError(ResponsibilityError):
|
|
|
+ """The requested matrix violates its governed contract."""
|
|
|
+
|
|
|
+
|
|
|
+class ResponsibilityConflict(ResponsibilityError):
|
|
|
+ """The matrix changed after the caller loaded it."""
|
|
|
+
|
|
|
+
|
|
|
+class ResponsibilityUserUnavailable(ResponsibilityError):
|
|
|
+ """An assignment targets an unknown or disabled user."""
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class ResponsibilityAssignment:
|
|
|
+ user_id: str
|
|
|
+ responsibility_role: str
|
|
|
+ raci_role: str
|
|
|
+
|
|
|
+
|
|
|
+def _valid_uuid(value: Any, field: str) -> str:
|
|
|
+ try:
|
|
|
+ return str(uuid.UUID(str(value)))
|
|
|
+ except (ValueError, TypeError, AttributeError) as exc:
|
|
|
+ raise ResponsibilityValidationError(f"{field} must be a UUID") from exc
|
|
|
+
|
|
|
+
|
|
|
+def validate_resource(resource_type: str, resource_uid: str | None = None) -> None:
|
|
|
+ if resource_type not in RESOURCE_TYPES:
|
|
|
+ raise ResponsibilityValidationError("unsupported resource type")
|
|
|
+ if resource_uid is None:
|
|
|
+ return
|
|
|
+ value = str(resource_uid).strip()
|
|
|
+ if not value or len(value) > 120:
|
|
|
+ raise ResponsibilityValidationError("resource uid is invalid")
|
|
|
+
|
|
|
+
|
|
|
+def validate_matrix(
|
|
|
+ resource_type: str,
|
|
|
+ assignments: list[dict[str, Any]],
|
|
|
+) -> tuple[ResponsibilityAssignment, ...]:
|
|
|
+ validate_resource(resource_type)
|
|
|
+ if not isinstance(assignments, list) or not assignments:
|
|
|
+ raise ResponsibilityValidationError("responsibility matrix cannot be empty")
|
|
|
+
|
|
|
+ validated: list[ResponsibilityAssignment] = []
|
|
|
+ identities: set[tuple[str, str, str]] = set()
|
|
|
+ for raw in assignments:
|
|
|
+ if not isinstance(raw, dict):
|
|
|
+ raise ResponsibilityValidationError("assignment must be an object")
|
|
|
+ user_id = _valid_uuid(raw.get("user_id"), "user id")
|
|
|
+ responsibility_role = str(raw.get("responsibility_role") or "").strip()
|
|
|
+ raci_role = str(raw.get("raci_role") or "").strip()
|
|
|
+ if responsibility_role not in RESPONSIBILITY_ROLES:
|
|
|
+ raise ResponsibilityValidationError("unsupported responsibility role")
|
|
|
+ if raci_role not in RACI_ROLES:
|
|
|
+ raise ResponsibilityValidationError("unsupported RACI role")
|
|
|
+ identity = (user_id, responsibility_role, raci_role)
|
|
|
+ if identity in identities:
|
|
|
+ raise ResponsibilityValidationError("duplicate responsibility assignment")
|
|
|
+ identities.add(identity)
|
|
|
+ validated.append(
|
|
|
+ ResponsibilityAssignment(
|
|
|
+ user_id=user_id,
|
|
|
+ responsibility_role=responsibility_role,
|
|
|
+ raci_role=raci_role,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ if resource_type in DEVICE_RESOURCE_TYPES:
|
|
|
+ accountable_asset_managers = [
|
|
|
+ item
|
|
|
+ for item in validated
|
|
|
+ if item.responsibility_role == "asset_manager"
|
|
|
+ and item.raci_role == "accountable"
|
|
|
+ ]
|
|
|
+ if len(accountable_asset_managers) != 1:
|
|
|
+ raise ResponsibilityValidationError(
|
|
|
+ "device scope requires exactly one accountable asset manager"
|
|
|
+ )
|
|
|
+ return tuple(validated)
|
|
|
+
|
|
|
+
|
|
|
+class ResponsibilityService:
|
|
|
+ def __init__(self, repository):
|
|
|
+ self.repository = repository
|
|
|
+
|
|
|
+ def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
|
|
|
+ validate_resource(resource_type, resource_uid)
|
|
|
+ return self.repository.get(resource_type, resource_uid)
|
|
|
+
|
|
|
+ def replace(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ resource_type: str,
|
|
|
+ resource_uid: str,
|
|
|
+ assignments: list[dict[str, Any]],
|
|
|
+ expected_revision: int,
|
|
|
+ actor_uid: str,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ validate_resource(resource_type, resource_uid)
|
|
|
+ actor_uid = _valid_uuid(actor_uid, "actor uid")
|
|
|
+ try:
|
|
|
+ revision = int(expected_revision)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise ResponsibilityValidationError("revision is invalid") from exc
|
|
|
+ if revision < 0:
|
|
|
+ raise ResponsibilityValidationError("revision is invalid")
|
|
|
+ return self.repository.replace(
|
|
|
+ resource_type=resource_type,
|
|
|
+ resource_uid=str(resource_uid).strip(),
|
|
|
+ assignments=validate_matrix(resource_type, assignments),
|
|
|
+ expected_revision=revision,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class SqlAlchemyResponsibilityRepository:
|
|
|
+ def __init__(self, session):
|
|
|
+ self.session = session
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _assignment_dict(row) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "user_id": str(row["user_id"]),
|
|
|
+ "username": row["username"],
|
|
|
+ "display_name": row["display_name"],
|
|
|
+ "responsibility_role": row["responsibility_role"],
|
|
|
+ "raci_role": row["raci_role"],
|
|
|
+ }
|
|
|
+
|
|
|
+ def _assignments(self, scope_id: str) -> list[dict[str, Any]]:
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ SELECT a.user_id::text AS user_id, u.username,
|
|
|
+ u.display_name, a.responsibility_role, a.raci_role
|
|
|
+ FROM public.governance_responsibility_assignments a
|
|
|
+ JOIN public.users u ON u.id = a.user_id
|
|
|
+ WHERE a.scope_id = CAST(:scope_id AS uuid)
|
|
|
+ ORDER BY a.raci_role, a.responsibility_role, u.username
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {"scope_id": scope_id},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ return [self._assignment_dict(row) for row in rows]
|
|
|
+
|
|
|
+ def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
|
|
|
+ scope = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ SELECT id::text AS id, revision
|
|
|
+ FROM public.governance_responsibility_scopes
|
|
|
+ WHERE resource_type = :resource_type
|
|
|
+ AND resource_uid = :resource_uid
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "resource_type": resource_type,
|
|
|
+ "resource_uid": resource_uid,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if scope is None:
|
|
|
+ return {
|
|
|
+ "resource_type": resource_type,
|
|
|
+ "resource_uid": resource_uid,
|
|
|
+ "revision": 0,
|
|
|
+ "assignments": [],
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ "resource_type": resource_type,
|
|
|
+ "resource_uid": resource_uid,
|
|
|
+ "revision": int(scope["revision"]),
|
|
|
+ "assignments": self._assignments(scope["id"]),
|
|
|
+ }
|
|
|
+
|
|
|
+ def replace(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ resource_type: str,
|
|
|
+ resource_uid: str,
|
|
|
+ assignments: tuple[ResponsibilityAssignment, ...],
|
|
|
+ expected_revision: int,
|
|
|
+ actor_uid: str,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ lock_key = f"responsibility:{resource_type}:{resource_uid}"
|
|
|
+ self.session.execute(
|
|
|
+ text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
|
|
|
+ {"key": lock_key},
|
|
|
+ )
|
|
|
+ scope = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ SELECT id::text AS id, revision
|
|
|
+ FROM public.governance_responsibility_scopes
|
|
|
+ WHERE resource_type = :resource_type
|
|
|
+ AND resource_uid = :resource_uid
|
|
|
+ FOR UPDATE
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "resource_type": resource_type,
|
|
|
+ "resource_uid": resource_uid,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if scope is None:
|
|
|
+ if expected_revision != 0:
|
|
|
+ raise ResponsibilityConflict("responsibility revision conflict")
|
|
|
+ scope_id = new_governance_uid()
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ INSERT INTO public.governance_responsibility_scopes (
|
|
|
+ id, resource_type, resource_uid, revision,
|
|
|
+ updated_by
|
|
|
+ ) VALUES (
|
|
|
+ CAST(:id AS uuid), :resource_type, :resource_uid, 0,
|
|
|
+ CAST(:actor_uid AS uuid)
|
|
|
+ )
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": scope_id,
|
|
|
+ "resource_type": resource_type,
|
|
|
+ "resource_uid": resource_uid,
|
|
|
+ "actor_uid": actor_uid,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ current_revision = 0
|
|
|
+ before: list[dict[str, Any]] = []
|
|
|
+ else:
|
|
|
+ scope_id = str(scope["id"])
|
|
|
+ current_revision = int(scope["revision"])
|
|
|
+ if current_revision != expected_revision:
|
|
|
+ raise ResponsibilityConflict("responsibility revision conflict")
|
|
|
+ before = self._assignments(scope_id)
|
|
|
+
|
|
|
+ user_ids = sorted({item.user_id for item in assignments})
|
|
|
+ active_user_ids = {
|
|
|
+ str(row[0])
|
|
|
+ for row in self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ SELECT id::text FROM public.users
|
|
|
+ WHERE status = 'active' AND id::text = ANY(:user_ids)
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {"user_ids": user_ids},
|
|
|
+ )
|
|
|
+ }
|
|
|
+ if active_user_ids != set(user_ids):
|
|
|
+ raise ResponsibilityUserUnavailable(
|
|
|
+ "responsibility user is unknown or disabled"
|
|
|
+ )
|
|
|
+
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ DELETE FROM public.governance_responsibility_assignments
|
|
|
+ WHERE scope_id = CAST(:scope_id AS uuid)
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {"scope_id": scope_id},
|
|
|
+ )
|
|
|
+ for assignment in assignments:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ INSERT INTO public.governance_responsibility_assignments (
|
|
|
+ id, scope_id, user_id, responsibility_role,
|
|
|
+ raci_role, assigned_by
|
|
|
+ ) VALUES (
|
|
|
+ CAST(:id AS uuid), CAST(:scope_id AS uuid),
|
|
|
+ CAST(:user_id AS uuid), :responsibility_role,
|
|
|
+ :raci_role, CAST(:actor_uid AS uuid)
|
|
|
+ )
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "scope_id": scope_id,
|
|
|
+ "user_id": assignment.user_id,
|
|
|
+ "responsibility_role": assignment.responsibility_role,
|
|
|
+ "raci_role": assignment.raci_role,
|
|
|
+ "actor_uid": actor_uid,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ new_revision = current_revision + 1
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ UPDATE public.governance_responsibility_scopes
|
|
|
+ SET revision = :revision, updated_by = CAST(:actor_uid AS uuid),
|
|
|
+ updated_at = CURRENT_TIMESTAMP
|
|
|
+ WHERE id = CAST(:scope_id AS uuid)
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "scope_id": scope_id,
|
|
|
+ "revision": new_revision,
|
|
|
+ "actor_uid": actor_uid,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ requested = [asdict(item) for item in assignments]
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ """
|
|
|
+ INSERT INTO public.governance_responsibility_audit_events (
|
|
|
+ scope_id, resource_type, resource_uid, actor_uid,
|
|
|
+ action, before_state, after_state
|
|
|
+ ) VALUES (
|
|
|
+ CAST(:scope_id AS uuid), :resource_type, :resource_uid,
|
|
|
+ CAST(:actor_uid AS uuid), 'matrix_replaced',
|
|
|
+ CAST(:before_state AS jsonb), CAST(:after_state AS jsonb)
|
|
|
+ )
|
|
|
+ """
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "scope_id": scope_id,
|
|
|
+ "resource_type": resource_type,
|
|
|
+ "resource_uid": resource_uid,
|
|
|
+ "actor_uid": actor_uid,
|
|
|
+ "before_state": json.dumps(before, ensure_ascii=False),
|
|
|
+ "after_state": json.dumps(requested, ensure_ascii=False),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "resource_type": resource_type,
|
|
|
+ "resource_uid": resource_uid,
|
|
|
+ "revision": new_revision,
|
|
|
+ "assignments": self._assignments(scope_id),
|
|
|
+ }
|