permission_service.py 16 KB

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