responsibilities.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. """Versioned RACI bindings for governed resources and device accountability."""
  2. from __future__ import annotations
  3. import json
  4. import uuid
  5. from dataclasses import asdict, dataclass
  6. from typing import Any
  7. from sqlalchemy import text
  8. from app.core.common.identifiers import new_governance_uid
  9. RESOURCE_TYPES = frozenset(
  10. {
  11. "business_domain",
  12. "device_asset",
  13. "device_ontology",
  14. "device_mapping",
  15. "device_quality",
  16. "fault_classification",
  17. "quality_issue",
  18. }
  19. )
  20. DEVICE_RESOURCE_TYPES = frozenset(
  21. {
  22. "device_asset",
  23. "device_ontology",
  24. "device_mapping",
  25. "device_quality",
  26. "fault_classification",
  27. }
  28. )
  29. RESPONSIBILITY_ROLES = frozenset(
  30. {"domain_owner", "data_steward", "data_architect", "asset_manager"}
  31. )
  32. RACI_ROLES = frozenset({"responsible", "accountable", "consulted", "informed"})
  33. class ResponsibilityError(RuntimeError):
  34. """Base error for responsibility-matrix operations."""
  35. class ResponsibilityValidationError(ResponsibilityError):
  36. """The requested matrix violates its governed contract."""
  37. class ResponsibilityConflict(ResponsibilityError):
  38. """The matrix changed after the caller loaded it."""
  39. class ResponsibilityUserUnavailable(ResponsibilityError):
  40. """An assignment targets an unknown or disabled user."""
  41. @dataclass(frozen=True)
  42. class ResponsibilityAssignment:
  43. user_id: str
  44. responsibility_role: str
  45. raci_role: str
  46. def _valid_uuid(value: Any, field: str) -> str:
  47. try:
  48. return str(uuid.UUID(str(value)))
  49. except (ValueError, TypeError, AttributeError) as exc:
  50. raise ResponsibilityValidationError(f"{field} must be a UUID") from exc
  51. def validate_resource(resource_type: str, resource_uid: str | None = None) -> None:
  52. if resource_type not in RESOURCE_TYPES:
  53. raise ResponsibilityValidationError("unsupported resource type")
  54. if resource_uid is None:
  55. return
  56. value = str(resource_uid).strip()
  57. if not value or len(value) > 120:
  58. raise ResponsibilityValidationError("resource uid is invalid")
  59. def validate_matrix(
  60. resource_type: str,
  61. assignments: list[dict[str, Any]],
  62. ) -> tuple[ResponsibilityAssignment, ...]:
  63. validate_resource(resource_type)
  64. if not isinstance(assignments, list) or not assignments:
  65. raise ResponsibilityValidationError("responsibility matrix cannot be empty")
  66. validated: list[ResponsibilityAssignment] = []
  67. identities: set[tuple[str, str, str]] = set()
  68. for raw in assignments:
  69. if not isinstance(raw, dict):
  70. raise ResponsibilityValidationError("assignment must be an object")
  71. user_id = _valid_uuid(raw.get("user_id"), "user id")
  72. responsibility_role = str(raw.get("responsibility_role") or "").strip()
  73. raci_role = str(raw.get("raci_role") or "").strip()
  74. if responsibility_role not in RESPONSIBILITY_ROLES:
  75. raise ResponsibilityValidationError("unsupported responsibility role")
  76. if raci_role not in RACI_ROLES:
  77. raise ResponsibilityValidationError("unsupported RACI role")
  78. identity = (user_id, responsibility_role, raci_role)
  79. if identity in identities:
  80. raise ResponsibilityValidationError("duplicate responsibility assignment")
  81. identities.add(identity)
  82. validated.append(
  83. ResponsibilityAssignment(
  84. user_id=user_id,
  85. responsibility_role=responsibility_role,
  86. raci_role=raci_role,
  87. )
  88. )
  89. if resource_type in DEVICE_RESOURCE_TYPES:
  90. accountable_asset_managers = [
  91. item
  92. for item in validated
  93. if item.responsibility_role == "asset_manager"
  94. and item.raci_role == "accountable"
  95. ]
  96. if len(accountable_asset_managers) != 1:
  97. raise ResponsibilityValidationError(
  98. "device scope requires exactly one accountable asset manager"
  99. )
  100. return tuple(validated)
  101. class ResponsibilityService:
  102. def __init__(self, repository):
  103. self.repository = repository
  104. def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
  105. validate_resource(resource_type, resource_uid)
  106. return self.repository.get(resource_type, resource_uid)
  107. def replace(
  108. self,
  109. *,
  110. resource_type: str,
  111. resource_uid: str,
  112. assignments: list[dict[str, Any]],
  113. expected_revision: int,
  114. actor_uid: str,
  115. ) -> dict[str, Any]:
  116. validate_resource(resource_type, resource_uid)
  117. actor_uid = _valid_uuid(actor_uid, "actor uid")
  118. try:
  119. revision = int(expected_revision)
  120. except (TypeError, ValueError) as exc:
  121. raise ResponsibilityValidationError("revision is invalid") from exc
  122. if revision < 0:
  123. raise ResponsibilityValidationError("revision is invalid")
  124. return self.repository.replace(
  125. resource_type=resource_type,
  126. resource_uid=str(resource_uid).strip(),
  127. assignments=validate_matrix(resource_type, assignments),
  128. expected_revision=revision,
  129. actor_uid=actor_uid,
  130. )
  131. class SqlAlchemyResponsibilityRepository:
  132. def __init__(self, session):
  133. self.session = session
  134. @staticmethod
  135. def _assignment_dict(row) -> dict[str, Any]:
  136. return {
  137. "user_id": str(row["user_id"]),
  138. "username": row["username"],
  139. "display_name": row["display_name"],
  140. "responsibility_role": row["responsibility_role"],
  141. "raci_role": row["raci_role"],
  142. }
  143. def _assignments(self, scope_id: str) -> list[dict[str, Any]]:
  144. rows = (
  145. self.session.execute(
  146. text(
  147. """
  148. SELECT a.user_id::text AS user_id, u.username,
  149. u.display_name, a.responsibility_role, a.raci_role
  150. FROM public.governance_responsibility_assignments a
  151. JOIN public.users u ON u.id = a.user_id
  152. WHERE a.scope_id = CAST(:scope_id AS uuid)
  153. ORDER BY a.raci_role, a.responsibility_role, u.username
  154. """
  155. ),
  156. {"scope_id": scope_id},
  157. )
  158. .mappings()
  159. .all()
  160. )
  161. return [self._assignment_dict(row) for row in rows]
  162. def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
  163. scope = (
  164. self.session.execute(
  165. text(
  166. """
  167. SELECT id::text AS id, revision
  168. FROM public.governance_responsibility_scopes
  169. WHERE resource_type = :resource_type
  170. AND resource_uid = :resource_uid
  171. """
  172. ),
  173. {
  174. "resource_type": resource_type,
  175. "resource_uid": resource_uid,
  176. },
  177. )
  178. .mappings()
  179. .one_or_none()
  180. )
  181. if scope is None:
  182. return {
  183. "resource_type": resource_type,
  184. "resource_uid": resource_uid,
  185. "revision": 0,
  186. "assignments": [],
  187. }
  188. return {
  189. "resource_type": resource_type,
  190. "resource_uid": resource_uid,
  191. "revision": int(scope["revision"]),
  192. "assignments": self._assignments(scope["id"]),
  193. }
  194. def replace(
  195. self,
  196. *,
  197. resource_type: str,
  198. resource_uid: str,
  199. assignments: tuple[ResponsibilityAssignment, ...],
  200. expected_revision: int,
  201. actor_uid: str,
  202. ) -> dict[str, Any]:
  203. lock_key = f"responsibility:{resource_type}:{resource_uid}"
  204. self.session.execute(
  205. text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
  206. {"key": lock_key},
  207. )
  208. scope = (
  209. self.session.execute(
  210. text(
  211. """
  212. SELECT id::text AS id, revision
  213. FROM public.governance_responsibility_scopes
  214. WHERE resource_type = :resource_type
  215. AND resource_uid = :resource_uid
  216. FOR UPDATE
  217. """
  218. ),
  219. {
  220. "resource_type": resource_type,
  221. "resource_uid": resource_uid,
  222. },
  223. )
  224. .mappings()
  225. .one_or_none()
  226. )
  227. if scope is None:
  228. if expected_revision != 0:
  229. raise ResponsibilityConflict("responsibility revision conflict")
  230. scope_id = new_governance_uid()
  231. self.session.execute(
  232. text(
  233. """
  234. INSERT INTO public.governance_responsibility_scopes (
  235. id, resource_type, resource_uid, revision,
  236. updated_by
  237. ) VALUES (
  238. CAST(:id AS uuid), :resource_type, :resource_uid, 0,
  239. CAST(:actor_uid AS uuid)
  240. )
  241. """
  242. ),
  243. {
  244. "id": scope_id,
  245. "resource_type": resource_type,
  246. "resource_uid": resource_uid,
  247. "actor_uid": actor_uid,
  248. },
  249. )
  250. current_revision = 0
  251. before: list[dict[str, Any]] = []
  252. else:
  253. scope_id = str(scope["id"])
  254. current_revision = int(scope["revision"])
  255. if current_revision != expected_revision:
  256. raise ResponsibilityConflict("responsibility revision conflict")
  257. before = self._assignments(scope_id)
  258. user_ids = sorted({item.user_id for item in assignments})
  259. active_user_ids = {
  260. str(row[0])
  261. for row in self.session.execute(
  262. text(
  263. """
  264. SELECT id::text FROM public.users
  265. WHERE status = 'active' AND id::text = ANY(:user_ids)
  266. """
  267. ),
  268. {"user_ids": user_ids},
  269. )
  270. }
  271. if active_user_ids != set(user_ids):
  272. raise ResponsibilityUserUnavailable(
  273. "responsibility user is unknown or disabled"
  274. )
  275. self.session.execute(
  276. text(
  277. """
  278. DELETE FROM public.governance_responsibility_assignments
  279. WHERE scope_id = CAST(:scope_id AS uuid)
  280. """
  281. ),
  282. {"scope_id": scope_id},
  283. )
  284. for assignment in assignments:
  285. self.session.execute(
  286. text(
  287. """
  288. INSERT INTO public.governance_responsibility_assignments (
  289. id, scope_id, user_id, responsibility_role,
  290. raci_role, assigned_by
  291. ) VALUES (
  292. CAST(:id AS uuid), CAST(:scope_id AS uuid),
  293. CAST(:user_id AS uuid), :responsibility_role,
  294. :raci_role, CAST(:actor_uid AS uuid)
  295. )
  296. """
  297. ),
  298. {
  299. "id": new_governance_uid(),
  300. "scope_id": scope_id,
  301. "user_id": assignment.user_id,
  302. "responsibility_role": assignment.responsibility_role,
  303. "raci_role": assignment.raci_role,
  304. "actor_uid": actor_uid,
  305. },
  306. )
  307. new_revision = current_revision + 1
  308. self.session.execute(
  309. text(
  310. """
  311. UPDATE public.governance_responsibility_scopes
  312. SET revision = :revision, updated_by = CAST(:actor_uid AS uuid),
  313. updated_at = CURRENT_TIMESTAMP
  314. WHERE id = CAST(:scope_id AS uuid)
  315. """
  316. ),
  317. {
  318. "scope_id": scope_id,
  319. "revision": new_revision,
  320. "actor_uid": actor_uid,
  321. },
  322. )
  323. requested = [asdict(item) for item in assignments]
  324. self.session.execute(
  325. text(
  326. """
  327. INSERT INTO public.governance_responsibility_audit_events (
  328. scope_id, resource_type, resource_uid, actor_uid,
  329. action, before_state, after_state
  330. ) VALUES (
  331. CAST(:scope_id AS uuid), :resource_type, :resource_uid,
  332. CAST(:actor_uid AS uuid), 'matrix_replaced',
  333. CAST(:before_state AS jsonb), CAST(:after_state AS jsonb)
  334. )
  335. """
  336. ),
  337. {
  338. "scope_id": scope_id,
  339. "resource_type": resource_type,
  340. "resource_uid": resource_uid,
  341. "actor_uid": actor_uid,
  342. "before_state": json.dumps(before, ensure_ascii=False),
  343. "after_state": json.dumps(requested, ensure_ascii=False),
  344. },
  345. )
  346. return {
  347. "resource_type": resource_type,
  348. "resource_uid": resource_uid,
  349. "revision": new_revision,
  350. "assignments": self._assignments(scope_id),
  351. }