responsibilities.py 14 KB

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