permission_service.py 15 KB

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