authorization_service.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. """主案、子方案及共享附件的集中读取授权。"""
  2. from __future__ import annotations
  3. import logging
  4. from dataclasses import dataclass
  5. from sqlalchemy import func, select
  6. from dms.common.enums import (
  7. AllowedAction,
  8. DocumentStatus,
  9. DocumentType,
  10. EnabledStatus,
  11. RoleCode,
  12. SECURITY_LEVEL_VALUES,
  13. SubjectType,
  14. VisibilityType,
  15. )
  16. from dms.extensions import db
  17. from dms.models import AttachmentBinding, Document, Organization, Permission
  18. from dms.security.auth_context import AuthContext
  19. from dms.common.errors import (
  20. DocumentDownloadForbiddenError,
  21. DocumentViewForbiddenError,
  22. SecurityLevelForbiddenError,
  23. )
  24. logger = logging.getLogger(__name__)
  25. @dataclass(frozen=True, slots=True)
  26. class PlanAccess:
  27. allowed: bool
  28. reason: str | None
  29. source: Document | None
  30. permissions: tuple[Permission, ...]
  31. can_download: bool
  32. def _security_allowed(document: Document, context: AuthContext) -> bool:
  33. return (
  34. SECURITY_LEVEL_VALUES[context.security_level]
  35. >= SECURITY_LEVEL_VALUES[type(context.security_level)(document.security_level)]
  36. )
  37. def require_security_clearance(
  38. document: Document,
  39. context: AuthContext,
  40. ) -> None:
  41. """集中执行文档密级前置检查,ADMIN也不能绕过。"""
  42. if not _security_allowed(document, context):
  43. raise SecurityLevelForbiddenError()
  44. def _active_organization_ancestor_ids(
  45. organization_id: int | None,
  46. ) -> set[int]:
  47. if organization_id is None:
  48. return set()
  49. organizations = db.session.scalars(
  50. select(Organization).where(
  51. Organization.is_deleted.is_(False),
  52. Organization.status == EnabledStatus.ENABLED.value,
  53. )
  54. ).all()
  55. by_id = {organization.id: organization for organization in organizations}
  56. result: set[int] = set()
  57. current_id: int | None = organization_id
  58. while current_id is not None and current_id not in result:
  59. current = by_id.get(current_id)
  60. if current is None:
  61. return set()
  62. result.add(current.id)
  63. current_id = current.parent_id
  64. return result
  65. def _permission_source(document: Document) -> Document | None:
  66. if document.document_type == DocumentType.MAIN.value:
  67. return document
  68. if document.document_type != DocumentType.SUB_PLAN.value:
  69. return None
  70. root = db.session.scalar(
  71. select(Document).where(
  72. Document.id == document.root_document_id,
  73. Document.document_type == DocumentType.MAIN.value,
  74. Document.is_deleted.is_(False),
  75. )
  76. )
  77. if root is None:
  78. logger.error(
  79. "子方案数据一致性异常:document_id=%s root_document_id=%s",
  80. document.id,
  81. document.root_document_id,
  82. )
  83. return root
  84. def evaluate_plan_access(
  85. document: Document,
  86. context: AuthContext,
  87. ) -> PlanAccess:
  88. """按固定顺序判断方案读取权限并返回有效权限快照。"""
  89. source = _permission_source(document)
  90. if source is None:
  91. return PlanAccess(False, "INVALID_ROOT", None, (), False)
  92. if (
  93. context.role_code == RoleCode.USER
  94. and document.document_status != DocumentStatus.PUBLISHED.value
  95. ):
  96. return PlanAccess(False, "STATUS", source, (), False)
  97. if not _security_allowed(document, context):
  98. return PlanAccess(False, "SECURITY", source, (), False)
  99. if context.role_code == RoleCode.ADMIN:
  100. return PlanAccess(True, None, source, (), True)
  101. visibility = VisibilityType(source.visibility_type)
  102. if visibility == VisibilityType.ALL_AUTHENTICATED:
  103. return PlanAccess(True, None, source, (), True)
  104. permissions = tuple(
  105. db.session.scalars(
  106. select(Permission).where(
  107. Permission.document_id == source.id,
  108. Permission.is_deleted.is_(False),
  109. )
  110. ).all()
  111. )
  112. ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
  113. matched: list[Permission] = []
  114. for permission in permissions:
  115. if (
  116. permission.subject_type == SubjectType.ORG.value
  117. and permission.subject_id in ancestor_ids
  118. ):
  119. matched.append(permission)
  120. elif (
  121. visibility == VisibilityType.CUSTOM
  122. and permission.subject_type == SubjectType.USER.value
  123. and permission.subject_id == context.user_id
  124. ):
  125. matched.append(permission)
  126. if visibility == VisibilityType.ORGANIZATION:
  127. matched = [
  128. permission
  129. for permission in matched
  130. if permission.subject_type == SubjectType.ORG.value
  131. ]
  132. can_view = any(permission.can_view for permission in matched)
  133. return PlanAccess(
  134. can_view,
  135. None if can_view else "ACL",
  136. source,
  137. tuple(matched),
  138. can_view and any(permission.can_download for permission in matched),
  139. )
  140. def plan_allowed_actions(
  141. document: Document,
  142. context: AuthContext,
  143. access: PlanAccess,
  144. ) -> list[str]:
  145. if not access.allowed:
  146. return []
  147. if context.role_code == RoleCode.USER:
  148. actions = [AllowedAction.VIEW.value]
  149. if access.can_download:
  150. actions.append(AllowedAction.DOWNLOAD.value)
  151. return actions
  152. if context.role_code != RoleCode.ADMIN:
  153. return []
  154. if document.document_type == DocumentType.SUB_PLAN.value:
  155. return [
  156. AllowedAction.VIEW.value,
  157. AllowedAction.DOWNLOAD.value,
  158. AllowedAction.EDIT.value,
  159. AllowedAction.DELETE.value,
  160. ]
  161. actions = [
  162. AllowedAction.VIEW.value,
  163. AllowedAction.DOWNLOAD.value,
  164. AllowedAction.EDIT.value,
  165. AllowedAction.CONFIG_PERMISSION.value,
  166. AllowedAction.DELETE.value,
  167. AllowedAction.BIND_ATTACHMENT.value,
  168. ]
  169. binding_count = db.session.scalar(
  170. select(func.count(AttachmentBinding.id)).where(
  171. AttachmentBinding.main_document_id == document.id,
  172. AttachmentBinding.is_deleted.is_(False),
  173. )
  174. )
  175. if binding_count:
  176. actions.append(AllowedAction.UNBIND_ATTACHMENT.value)
  177. return actions
  178. def attachment_allowed_actions(
  179. context: AuthContext,
  180. access: PlanAccess | None = None,
  181. ) -> list[str]:
  182. if context.role_code == RoleCode.ADMIN:
  183. return [
  184. AllowedAction.VIEW.value,
  185. AllowedAction.DOWNLOAD.value,
  186. AllowedAction.EDIT.value,
  187. AllowedAction.DELETE.value,
  188. ]
  189. actions: list[str] = []
  190. if access is not None and access.allowed:
  191. actions.append(AllowedAction.VIEW.value)
  192. if access.can_download:
  193. actions.append(AllowedAction.DOWNLOAD.value)
  194. return actions
  195. def evaluate_attachment_access(
  196. attachment: Document,
  197. context: AuthContext,
  198. ) -> tuple[bool, bool]:
  199. """USER只能通过至少一个有权有效主案访问共享附件。"""
  200. if context.role_code == RoleCode.ADMIN:
  201. return True, True
  202. mains = db.session.scalars(
  203. select(Document)
  204. .join(
  205. AttachmentBinding,
  206. AttachmentBinding.main_document_id == Document.id,
  207. )
  208. .where(
  209. AttachmentBinding.attachment_document_id == attachment.id,
  210. AttachmentBinding.is_deleted.is_(False),
  211. Document.document_type == DocumentType.MAIN.value,
  212. Document.is_deleted.is_(False),
  213. )
  214. .order_by(Document.id.asc())
  215. ).all()
  216. can_view = False
  217. can_download = False
  218. for main in mains:
  219. access = evaluate_plan_access(main, context)
  220. can_view = can_view or access.allowed
  221. can_download = can_download or (access.allowed and access.can_download)
  222. return can_view, can_download
  223. def require_attachment_access(
  224. attachment: Document,
  225. context: AuthContext,
  226. *,
  227. download: bool,
  228. ) -> tuple[bool, bool]:
  229. can_view, can_download = evaluate_attachment_access(attachment, context)
  230. if download and not can_download:
  231. raise DocumentDownloadForbiddenError()
  232. if not download and not can_view:
  233. raise DocumentViewForbiddenError()
  234. return can_view, can_download