permission_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. """B6主案ACL查询、动态继承及权限全集差异保存。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. from typing import Any
  5. from sqlalchemy import select
  6. from sqlalchemy.exc import IntegrityError
  7. from dms.common.enums import (
  8. AuditAction,
  9. AuditTarget,
  10. DocumentType,
  11. EnabledStatus,
  12. SubjectType,
  13. VisibilityType,
  14. )
  15. from dms.common.errors import (
  16. AttachmentHasNoAclError,
  17. ConflictError,
  18. DuplicatePermissionSubjectError,
  19. InvalidArgumentError,
  20. ResourceNotFoundError,
  21. SubPlanPermissionInheritedError,
  22. )
  23. from dms.common.response import serialize_id
  24. from dms.database.transaction import execute_with_deadlock_retry, transaction
  25. from dms.extensions import db
  26. from dms.models import Document, Organization, Permission, User
  27. from dms.security.auth_context import get_auth_context
  28. from dms.services.audit_service import business_audit
  29. from dms.services.authorization_service import require_security_clearance
  30. PERMISSION_FIELDS = {
  31. "subjectType",
  32. "subjectId",
  33. "canView",
  34. "canDownload",
  35. "canEdit",
  36. "canManagePermission",
  37. "canDelete",
  38. }
  39. SAVE_FIELDS = {"visibilityType", "documentRowVersion", "entries"}
  40. ACTION_FIELDS = (
  41. "canView",
  42. "canDownload",
  43. "canEdit",
  44. "canManagePermission",
  45. "canDelete",
  46. )
  47. def _now() -> datetime:
  48. return datetime.now(timezone.utc).replace(tzinfo=None)
  49. def _string_id(value: Any, name: str) -> int:
  50. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  51. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  52. return int(value)
  53. def _version(value: Any) -> int:
  54. if type(value) is not int or value < 0:
  55. raise InvalidArgumentError("documentRowVersion必须是非负整数")
  56. return value
  57. def _document(document_id: int, *, lock: bool = False) -> Document:
  58. statement = select(Document).where(
  59. Document.id == document_id,
  60. Document.is_deleted.is_(False),
  61. )
  62. if lock:
  63. statement = statement.with_for_update()
  64. document = db.session.scalar(statement)
  65. if document is None:
  66. raise ResourceNotFoundError("文档不存在")
  67. return document
  68. def _permission_source(document: Document, *, lock: bool = False) -> Document:
  69. if document.document_type == DocumentType.ATTACHMENT.value:
  70. raise AttachmentHasNoAclError()
  71. require_security_clearance(document, get_auth_context())
  72. if document.document_type == DocumentType.MAIN.value:
  73. return document
  74. statement = select(Document).where(
  75. Document.id == document.root_document_id,
  76. Document.document_type == DocumentType.MAIN.value,
  77. Document.is_deleted.is_(False),
  78. )
  79. if lock:
  80. statement = statement.with_for_update()
  81. source = db.session.scalar(statement)
  82. if source is None:
  83. raise ResourceNotFoundError("子方案所属主案不存在")
  84. return source
  85. def _entry(permission: Permission) -> dict[str, object]:
  86. return {
  87. "id": serialize_id(permission.id),
  88. "subjectType": permission.subject_type,
  89. "subjectId": serialize_id(permission.subject_id),
  90. "subjectName": permission.subject_name,
  91. "canView": bool(permission.can_view),
  92. "canDownload": bool(permission.can_download),
  93. "canEdit": bool(permission.can_edit),
  94. "canManagePermission": bool(permission.can_manage_permission),
  95. "canDelete": bool(permission.can_delete),
  96. }
  97. def _active_permissions(source_id: int, *, lock: bool = False) -> list[Permission]:
  98. statement = (
  99. select(Permission)
  100. .where(
  101. Permission.document_id == source_id,
  102. Permission.is_deleted.is_(False),
  103. )
  104. .order_by(
  105. Permission.subject_type.asc(),
  106. Permission.subject_id.asc(),
  107. Permission.id.asc(),
  108. )
  109. )
  110. if lock:
  111. statement = statement.with_for_update()
  112. return list(db.session.scalars(statement).all())
  113. def _detail(
  114. document: Document,
  115. source: Document,
  116. permissions: list[Permission],
  117. ) -> dict[str, object]:
  118. return {
  119. "documentId": serialize_id(document.id),
  120. "sourceDocumentId": serialize_id(source.id),
  121. "inherited": document.document_type == DocumentType.SUB_PLAN.value,
  122. "visibilityType": source.visibility_type,
  123. "documentRowVersion": source.row_version,
  124. "entries": [_entry(permission) for permission in permissions],
  125. }
  126. def get_permissions(document_id: int) -> dict[str, object]:
  127. document = _document(document_id)
  128. source = _permission_source(document)
  129. return _detail(document, source, _active_permissions(source.id))
  130. def _payload(payload: Any) -> tuple[str, int, list[dict[str, Any]]]:
  131. if not isinstance(payload, dict) or set(payload) != SAVE_FIELDS:
  132. raise InvalidArgumentError(
  133. "请求字段必须且只能包含visibilityType、documentRowVersion和entries"
  134. )
  135. try:
  136. visibility = VisibilityType(payload["visibilityType"]).value
  137. except (TypeError, ValueError) as exc:
  138. raise InvalidArgumentError("visibilityType不是有效枚举值") from exc
  139. expected_version = _version(payload["documentRowVersion"])
  140. raw_entries = payload["entries"]
  141. if not isinstance(raw_entries, list):
  142. raise InvalidArgumentError("entries必须是数组")
  143. entries: list[dict[str, Any]] = []
  144. seen: set[tuple[str, int]] = set()
  145. for index, raw_entry in enumerate(raw_entries):
  146. if not isinstance(raw_entry, dict) or set(raw_entry) != PERMISSION_FIELDS:
  147. raise InvalidArgumentError(
  148. f"entries[{index}]字段必须完整且不能包含未知字段"
  149. )
  150. try:
  151. subject_type = SubjectType(raw_entry["subjectType"]).value
  152. except (TypeError, ValueError) as exc:
  153. raise InvalidArgumentError(
  154. f"entries[{index}].subjectType不是有效枚举值"
  155. ) from exc
  156. subject_id = _string_id(
  157. raw_entry["subjectId"], f"entries[{index}].subjectId"
  158. )
  159. for field in ACTION_FIELDS:
  160. if type(raw_entry[field]) is not bool:
  161. raise InvalidArgumentError(
  162. f"entries[{index}].{field}必须是boolean"
  163. )
  164. key = (subject_type, subject_id)
  165. if key in seen:
  166. raise DuplicatePermissionSubjectError(
  167. details={
  168. "subjectType": subject_type,
  169. "subjectId": serialize_id(subject_id),
  170. }
  171. )
  172. seen.add(key)
  173. entries.append(
  174. {
  175. "subjectType": subject_type,
  176. "subjectId": subject_id,
  177. **{field: raw_entry[field] for field in ACTION_FIELDS},
  178. }
  179. )
  180. if visibility == VisibilityType.ALL_AUTHENTICATED.value and entries:
  181. raise InvalidArgumentError("ALL_AUTHENTICATED的entries必须为空")
  182. if visibility == VisibilityType.ORGANIZATION.value:
  183. if not entries:
  184. raise InvalidArgumentError("ORGANIZATION至少需要一个ORG权限主体")
  185. if any(
  186. entry["subjectType"] != SubjectType.ORG.value
  187. for entry in entries
  188. ):
  189. raise InvalidArgumentError("ORGANIZATION只允许ORG权限主体")
  190. return visibility, expected_version, entries
  191. def _locked_subject_names(
  192. entries: list[dict[str, Any]],
  193. ) -> dict[tuple[str, int], str]:
  194. organization_ids = sorted(
  195. entry["subjectId"]
  196. for entry in entries
  197. if entry["subjectType"] == SubjectType.ORG.value
  198. )
  199. user_ids = sorted(
  200. entry["subjectId"]
  201. for entry in entries
  202. if entry["subjectType"] == SubjectType.USER.value
  203. )
  204. names: dict[tuple[str, int], str] = {}
  205. if organization_ids:
  206. organizations = db.session.scalars(
  207. select(Organization)
  208. .where(Organization.id.in_(organization_ids))
  209. .order_by(Organization.id.asc())
  210. .with_for_update()
  211. ).all()
  212. valid = {
  213. item.id: item
  214. for item in organizations
  215. if not item.is_deleted and item.status == EnabledStatus.ENABLED.value
  216. }
  217. if set(valid) != set(organization_ids):
  218. raise ResourceNotFoundError("权限组织不存在、已删除或已禁用")
  219. names.update(
  220. {
  221. (SubjectType.ORG.value, item.id): item.org_name
  222. for item in valid.values()
  223. }
  224. )
  225. if user_ids:
  226. users = db.session.scalars(
  227. select(User)
  228. .where(User.id.in_(user_ids))
  229. .order_by(User.id.asc())
  230. .with_for_update()
  231. ).all()
  232. valid = {
  233. item.id: item
  234. for item in users
  235. if not item.is_deleted and item.status == EnabledStatus.ENABLED.value
  236. }
  237. if set(valid) != set(user_ids):
  238. raise ResourceNotFoundError("权限用户不存在、已删除或已禁用")
  239. names.update(
  240. {
  241. (SubjectType.USER.value, item.id): item.real_name
  242. for item in valid.values()
  243. }
  244. )
  245. return names
  246. def _current_version(document_id: int) -> int | None:
  247. return db.session.scalar(
  248. select(Document.row_version).where(
  249. Document.id == document_id,
  250. Document.is_deleted.is_(False),
  251. )
  252. )
  253. def save_permissions(document_id: int, payload: Any) -> dict[str, object]:
  254. visibility, expected_version, entries = _payload(payload)
  255. context = get_auth_context()
  256. def operation() -> dict[str, object]:
  257. with transaction() as session:
  258. document = _document(document_id, lock=True)
  259. if document.document_type == DocumentType.ATTACHMENT.value:
  260. raise AttachmentHasNoAclError()
  261. require_security_clearance(document, context)
  262. if document.document_type == DocumentType.SUB_PLAN.value:
  263. raise SubPlanPermissionInheritedError()
  264. if document.document_type != DocumentType.MAIN.value:
  265. raise ResourceNotFoundError("主案不存在或类型不匹配")
  266. if document.row_version != expected_version:
  267. raise ConflictError(
  268. "数据已被其他用户修改,请刷新后重试",
  269. details={"currentRowVersion": document.row_version},
  270. )
  271. current = _active_permissions(document.id, lock=True)
  272. subject_names = _locked_subject_names(entries)
  273. current_by_key = {
  274. (permission.subject_type, permission.subject_id): permission
  275. for permission in current
  276. }
  277. requested_by_key = {
  278. (entry["subjectType"], entry["subjectId"]): entry
  279. for entry in entries
  280. }
  281. added: list[dict[str, str]] = []
  282. updated: list[dict[str, str]] = []
  283. deleted: list[dict[str, str]] = []
  284. now = _now()
  285. for key, permission in current_by_key.items():
  286. requested = requested_by_key.get(key)
  287. if requested is None:
  288. permission.is_deleted = True
  289. permission.deleted_at = now
  290. permission.updated_at = now
  291. permission.updated_by = context.user_id
  292. permission.row_version += 1
  293. deleted.append(
  294. {
  295. "subjectType": key[0],
  296. "subjectId": serialize_id(key[1]),
  297. }
  298. )
  299. continue
  300. new_name = subject_names[key]
  301. changed = permission.subject_name != new_name or any(
  302. bool(getattr(permission, _snake_action(field)))
  303. != requested[field]
  304. for field in ACTION_FIELDS
  305. )
  306. if changed:
  307. permission.subject_name = new_name
  308. for field in ACTION_FIELDS:
  309. setattr(
  310. permission,
  311. _snake_action(field),
  312. requested[field],
  313. )
  314. permission.updated_at = now
  315. permission.updated_by = context.user_id
  316. permission.row_version += 1
  317. updated.append(
  318. {
  319. "subjectType": key[0],
  320. "subjectId": serialize_id(key[1]),
  321. }
  322. )
  323. for key, requested in requested_by_key.items():
  324. if key in current_by_key:
  325. continue
  326. permission = Permission(
  327. document_id=document.id,
  328. subject_type=key[0],
  329. subject_id=key[1],
  330. subject_name=subject_names[key],
  331. created_by=context.user_id,
  332. updated_by=context.user_id,
  333. **{
  334. _snake_action(field): requested[field]
  335. for field in ACTION_FIELDS
  336. },
  337. )
  338. session.add(permission)
  339. added.append(
  340. {
  341. "subjectType": key[0],
  342. "subjectId": serialize_id(key[1]),
  343. }
  344. )
  345. previous_visibility = document.visibility_type
  346. document.visibility_type = visibility
  347. document.updated_by = context.user_id
  348. document.updated_by_name = context.real_name
  349. document.updated_at = now
  350. document.row_version += 1
  351. session.flush()
  352. session.add(
  353. business_audit(
  354. action=AuditAction.CHANGE_PERMISSION,
  355. target=AuditTarget.PERMISSION,
  356. target_id=document.id,
  357. target_name=document.document_name,
  358. detail={
  359. "mainPlanId": serialize_id(document.id),
  360. "visibilityTypeBefore": previous_visibility,
  361. "visibilityTypeAfter": visibility,
  362. "added": added,
  363. "updated": updated,
  364. "deleted": deleted,
  365. "documentRowVersion": document.row_version,
  366. },
  367. )
  368. )
  369. return _detail(
  370. document,
  371. document,
  372. _active_permissions(document.id),
  373. )
  374. try:
  375. return execute_with_deadlock_retry(operation)
  376. except IntegrityError as exc:
  377. db.session.rollback()
  378. raise ConflictError(
  379. "权限集合已被并发修改,请刷新后重试",
  380. details={"currentRowVersion": _current_version(document_id)},
  381. ) from exc
  382. def _snake_action(field: str) -> str:
  383. return {
  384. "canView": "can_view",
  385. "canDownload": "can_download",
  386. "canEdit": "can_edit",
  387. "canManagePermission": "can_manage_permission",
  388. "canDelete": "can_delete",
  389. }[field]
  390. __all__ = ["get_permissions", "save_permissions"]