responsibilities.py 13 KB

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