permission_service.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. """B6主案借阅授权查询、动态继承及授权全集差异保存。
  2. 借阅语义(0026迁移,契约1.19):授权仅面向用户(subject_id),限时生效
  3. (expires_at,最长7天),操作位收敛为查看/下载两项;每条授权可选挂一份
  4. PDF审批单凭证(voucher_*,凭证读写见 voucher_service)。
  5. """
  6. from __future__ import annotations
  7. from datetime import datetime, timedelta, timezone
  8. from typing import Any
  9. from sqlalchemy import select
  10. from sqlalchemy.exc import IntegrityError
  11. from dms.common.enums import (
  12. AllowedAction,
  13. AuditAction,
  14. AuditTarget,
  15. DocumentType,
  16. EnabledStatus,
  17. )
  18. from dms.common.errors import (
  19. AttachmentHasNoAclError,
  20. ConflictError,
  21. DuplicatePermissionSubjectError,
  22. InvalidArgumentError,
  23. ResourceNotFoundError,
  24. SubPlanPermissionInheritedError,
  25. )
  26. from dms.common.response import serialize_id
  27. from dms.database.transaction import execute_with_deadlock_retry, transaction
  28. from dms.extensions import db
  29. from dms.models import LOCK_DEFER_LOB, Document, Permission, User
  30. from dms.security.auth_context import get_auth_context
  31. from dms.services.audit_service import business_audit
  32. from dms.services.document_password_service import (
  33. DocumentPasswordAction,
  34. get_request_password,
  35. require_document_password,
  36. )
  37. from dms.services.authorization_service import (
  38. require_plan_action,
  39. require_security_clearance,
  40. )
  41. PERMISSION_FIELDS = {"subjectId", "canView", "canDownload", "expiresAt"}
  42. SAVE_FIELDS = {"documentRowVersion", "entries"}
  43. ACTION_FIELDS = ("canView", "canDownload")
  44. # 借阅时限:最长7天;60秒容差覆盖前端分钟粒度的拾取舍入
  45. MAX_BORROW = timedelta(days=7)
  46. _BORROW_TOLERANCE = timedelta(seconds=60)
  47. def _now() -> datetime:
  48. return datetime.now(timezone.utc).replace(tzinfo=None)
  49. def _iso(value: datetime) -> str:
  50. return (
  51. value.replace(tzinfo=timezone.utc)
  52. .isoformat(timespec="milliseconds")
  53. .replace("+00:00", "Z")
  54. )
  55. def _parse_expires(value: Any, index: int, *, now: datetime) -> datetime:
  56. """解析UTC时间字符串(Z结尾)并执行借阅时限校验。"""
  57. if not isinstance(value, str) or not value.endswith("Z"):
  58. raise InvalidArgumentError(
  59. f"entries[{index}].expiresAt必须是UTC时间字符串(Z结尾)"
  60. )
  61. try:
  62. parsed = datetime.fromisoformat(f"{value[:-1]}+00:00")
  63. except ValueError as exc:
  64. raise InvalidArgumentError(
  65. f"entries[{index}].expiresAt不是有效的时间格式"
  66. ) from exc
  67. if parsed.utcoffset() != timezone.utc.utcoffset(parsed):
  68. raise InvalidArgumentError(
  69. f"entries[{index}].expiresAt必须是UTC时间"
  70. )
  71. expires = parsed.astimezone(timezone.utc).replace(tzinfo=None)
  72. if expires <= now:
  73. raise InvalidArgumentError(
  74. f"entries[{index}].expiresAt必须晚于当前时间"
  75. )
  76. if expires > now + MAX_BORROW + _BORROW_TOLERANCE:
  77. raise InvalidArgumentError(
  78. f"entries[{index}].expiresAt借阅时限最长为7天"
  79. )
  80. return expires
  81. def _string_id(value: Any, name: str) -> int:
  82. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  83. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  84. return int(value)
  85. def _version(value: Any) -> int:
  86. if type(value) is not int or value < 0:
  87. raise InvalidArgumentError("documentRowVersion必须是非负整数")
  88. return value
  89. def _document(document_id: int, *, lock: bool = False) -> Document:
  90. statement = select(Document).where(
  91. Document.id == document_id,
  92. Document.is_deleted.is_(False),
  93. )
  94. if lock:
  95. statement = statement.options(*LOCK_DEFER_LOB).with_for_update()
  96. document = db.session.scalar(statement)
  97. if document is None:
  98. raise ResourceNotFoundError("文档不存在")
  99. return document
  100. def _permission_source(document: Document, *, lock: bool = False) -> Document:
  101. if document.document_type == DocumentType.ATTACHMENT.value:
  102. raise AttachmentHasNoAclError()
  103. require_security_clearance(document, get_auth_context())
  104. if document.document_type == DocumentType.MAIN.value:
  105. # 读取与写入同口径:仅授予授权位(或管理员)可见授权清单
  106. require_plan_action(
  107. document, get_auth_context(), AllowedAction.CONFIG_PERMISSION
  108. )
  109. return document
  110. statement = select(Document).where(
  111. Document.id == document.root_document_id,
  112. Document.document_type == DocumentType.MAIN.value,
  113. Document.is_deleted.is_(False),
  114. )
  115. if lock:
  116. statement = statement.options(*LOCK_DEFER_LOB).with_for_update()
  117. source = db.session.scalar(statement)
  118. if source is None:
  119. raise ResourceNotFoundError("子方案所属主案不存在")
  120. require_document_password(
  121. source,
  122. DocumentPasswordAction.CONFIG_PERMISSION.value,
  123. get_request_password(),
  124. get_auth_context(),
  125. )
  126. return source
  127. def _entry(permission: Permission) -> dict[str, object]:
  128. return {
  129. "id": serialize_id(permission.id),
  130. "subjectId": serialize_id(permission.subject_id),
  131. "subjectName": permission.subject_name,
  132. "canView": bool(permission.can_view),
  133. "canDownload": bool(permission.can_download),
  134. "expiresAt": _iso(permission.expires_at),
  135. "voucherFileName": permission.voucher_file_name,
  136. "voucherFileSize": permission.voucher_file_size,
  137. "voucherUploadedAt": (
  138. _iso(permission.voucher_uploaded_at)
  139. if permission.voucher_uploaded_at is not None
  140. else None
  141. ),
  142. }
  143. def _active_permissions(source_id: int, *, lock: bool = False) -> list[Permission]:
  144. statement = (
  145. select(Permission)
  146. .where(
  147. Permission.document_id == source_id,
  148. Permission.is_deleted.is_(False),
  149. )
  150. .order_by(
  151. Permission.subject_id.asc(),
  152. Permission.id.asc(),
  153. )
  154. )
  155. if lock:
  156. statement = statement.with_for_update()
  157. return list(db.session.scalars(statement).all())
  158. def _detail(
  159. document: Document,
  160. source: Document,
  161. permissions: list[Permission],
  162. ) -> dict[str, object]:
  163. return {
  164. "documentId": serialize_id(document.id),
  165. "sourceDocumentId": serialize_id(source.id),
  166. "inherited": document.document_type == DocumentType.SUB_PLAN.value,
  167. "documentRowVersion": source.row_version,
  168. "entries": [_entry(permission) for permission in permissions],
  169. }
  170. def get_permissions(document_id: int) -> dict[str, object]:
  171. document = _document(document_id)
  172. source = _permission_source(document)
  173. return _detail(document, source, _active_permissions(source.id))
  174. def _payload(payload: Any) -> tuple[int, list[dict[str, Any]]]:
  175. if not isinstance(payload, dict) or set(payload) != SAVE_FIELDS:
  176. raise InvalidArgumentError(
  177. "请求字段必须且只能包含documentRowVersion和entries"
  178. )
  179. expected_version = _version(payload["documentRowVersion"])
  180. raw_entries = payload["entries"]
  181. if not isinstance(raw_entries, list):
  182. raise InvalidArgumentError("entries必须是数组")
  183. now = _now()
  184. entries: list[dict[str, Any]] = []
  185. seen: set[int] = set()
  186. for index, raw_entry in enumerate(raw_entries):
  187. if not isinstance(raw_entry, dict) or set(raw_entry) != PERMISSION_FIELDS:
  188. raise InvalidArgumentError(
  189. f"entries[{index}]字段必须完整且不能包含未知字段"
  190. )
  191. subject_id = _string_id(
  192. raw_entry["subjectId"], f"entries[{index}].subjectId"
  193. )
  194. for field in ACTION_FIELDS:
  195. if type(raw_entry[field]) is not bool:
  196. raise InvalidArgumentError(
  197. f"entries[{index}].{field}必须是boolean"
  198. )
  199. expires_at = _parse_expires(
  200. raw_entry["expiresAt"], index, now=now
  201. )
  202. if subject_id in seen:
  203. raise DuplicatePermissionSubjectError(
  204. details={"subjectId": serialize_id(subject_id)}
  205. )
  206. seen.add(subject_id)
  207. entries.append(
  208. {
  209. "subjectId": subject_id,
  210. "canView": raw_entry["canView"],
  211. "canDownload": raw_entry["canDownload"],
  212. "expiresAt": expires_at,
  213. }
  214. )
  215. return expected_version, entries
  216. def _locked_subject_names(
  217. entries: list[dict[str, Any]],
  218. ) -> dict[int, str]:
  219. user_ids = sorted(entry["subjectId"] for entry in entries)
  220. names: dict[int, str] = {}
  221. if user_ids:
  222. users = db.session.scalars(
  223. select(User)
  224. .where(User.id.in_(user_ids))
  225. .order_by(User.id.asc())
  226. .with_for_update()
  227. ).all()
  228. valid = {
  229. item.id: item
  230. for item in users
  231. if not item.is_deleted and item.status == EnabledStatus.ENABLED.value
  232. }
  233. if set(valid) != set(user_ids):
  234. raise ResourceNotFoundError("借阅用户不存在、已删除或已禁用")
  235. names.update({item.id: item.real_name for item in valid.values()})
  236. return names
  237. def _current_version(document_id: int) -> int | None:
  238. return db.session.scalar(
  239. select(Document.row_version).where(
  240. Document.id == document_id,
  241. Document.is_deleted.is_(False),
  242. )
  243. )
  244. def _audit_item(
  245. subject_id: int,
  246. subject_name: str,
  247. entry: dict[str, Any] | None,
  248. ) -> dict[str, object]:
  249. item: dict[str, object] = {
  250. "subjectId": serialize_id(subject_id),
  251. "subjectName": subject_name,
  252. }
  253. if entry is not None:
  254. item.update(
  255. {
  256. "canView": entry["canView"],
  257. "canDownload": entry["canDownload"],
  258. "expiresAt": _iso(entry["expiresAt"]),
  259. }
  260. )
  261. return item
  262. def save_permissions(document_id: int, payload: Any) -> dict[str, object]:
  263. expected_version, entries = _payload(payload)
  264. context = get_auth_context()
  265. def operation() -> dict[str, object]:
  266. with transaction() as session:
  267. document = _document(document_id, lock=True)
  268. if document.document_type == DocumentType.ATTACHMENT.value:
  269. raise AttachmentHasNoAclError()
  270. require_security_clearance(document, context)
  271. if document.document_type == DocumentType.SUB_PLAN.value:
  272. raise SubPlanPermissionInheritedError()
  273. require_plan_action(
  274. document, context, AllowedAction.CONFIG_PERMISSION
  275. )
  276. # 设密主案的授权操作仍受密码锁保护(与前端弹窗口径一致)
  277. require_document_password(
  278. document,
  279. DocumentPasswordAction.CONFIG_PERMISSION.value,
  280. get_request_password(),
  281. context,
  282. )
  283. if document.document_type != DocumentType.MAIN.value:
  284. raise ResourceNotFoundError("主案不存在或类型不匹配")
  285. if document.row_version != expected_version:
  286. raise ConflictError(
  287. "数据已被其他用户修改,请刷新后重试",
  288. details={"currentRowVersion": document.row_version},
  289. )
  290. current = _active_permissions(document.id, lock=True)
  291. subject_names = _locked_subject_names(entries)
  292. current_by_key = {
  293. permission.subject_id: permission for permission in current
  294. }
  295. requested_by_key = {entry["subjectId"]: entry for entry in entries}
  296. added: list[dict[str, object]] = []
  297. updated: list[dict[str, object]] = []
  298. deleted: list[dict[str, object]] = []
  299. now = _now()
  300. for subject_id, permission in current_by_key.items():
  301. requested = requested_by_key.get(subject_id)
  302. if requested is None:
  303. # 条目软删:凭证文件暂留磁盘(清除凭证走独立接口)
  304. permission.is_deleted = True
  305. permission.deleted_at = now
  306. permission.updated_at = now
  307. permission.updated_by = context.user_id
  308. permission.row_version += 1
  309. deleted.append(
  310. _audit_item(
  311. subject_id, permission.subject_name, None
  312. )
  313. )
  314. continue
  315. new_name = subject_names[subject_id]
  316. changed = (
  317. permission.subject_name != new_name
  318. or bool(permission.can_view) != requested["canView"]
  319. or bool(permission.can_download) != requested["canDownload"]
  320. or permission.expires_at != requested["expiresAt"]
  321. )
  322. if changed:
  323. permission.subject_name = new_name
  324. permission.can_view = requested["canView"]
  325. permission.can_download = requested["canDownload"]
  326. permission.expires_at = requested["expiresAt"]
  327. permission.updated_at = now
  328. permission.updated_by = context.user_id
  329. permission.row_version += 1
  330. updated.append(
  331. _audit_item(subject_id, new_name, requested)
  332. )
  333. for subject_id, requested in requested_by_key.items():
  334. if subject_id in current_by_key:
  335. continue
  336. permission = Permission(
  337. document_id=document.id,
  338. subject_id=subject_id,
  339. subject_name=subject_names[subject_id],
  340. can_view=requested["canView"],
  341. can_download=requested["canDownload"],
  342. expires_at=requested["expiresAt"],
  343. created_by=context.user_id,
  344. updated_by=context.user_id,
  345. )
  346. session.add(permission)
  347. added.append(
  348. _audit_item(
  349. subject_id, subject_names[subject_id], requested
  350. )
  351. )
  352. document.updated_by = context.user_id
  353. document.updated_by_name = context.real_name
  354. document.updated_at = now
  355. document.row_version += 1
  356. session.flush()
  357. session.add(
  358. business_audit(
  359. action=AuditAction.CHANGE_PERMISSION,
  360. target=AuditTarget.PERMISSION,
  361. target_id=document.id,
  362. target_name=document.document_name,
  363. detail={
  364. "mainPlanId": serialize_id(document.id),
  365. "added": added,
  366. "updated": updated,
  367. "deleted": deleted,
  368. "documentRowVersion": document.row_version,
  369. },
  370. )
  371. )
  372. return _detail(
  373. document,
  374. document,
  375. _active_permissions(document.id),
  376. )
  377. try:
  378. return execute_with_deadlock_retry(operation)
  379. except IntegrityError as exc:
  380. db.session.rollback()
  381. raise ConflictError(
  382. "权限集合已被并发修改,请刷新后重试",
  383. details={"currentRowVersion": _current_version(document_id)},
  384. ) from exc
  385. __all__ = ["get_permissions", "save_permissions"]