authorization_service.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  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 SecurityLevelForbiddenError
  20. logger = logging.getLogger(__name__)
  21. @dataclass(frozen=True, slots=True)
  22. class PlanAccess:
  23. allowed: bool
  24. reason: str | None
  25. source: Document | None
  26. permissions: tuple[Permission, ...]
  27. can_download: bool
  28. def _security_allowed(document: Document, context: AuthContext) -> bool:
  29. return (
  30. SECURITY_LEVEL_VALUES[context.security_level]
  31. >= SECURITY_LEVEL_VALUES[type(context.security_level)(document.security_level)]
  32. )
  33. def require_security_clearance(
  34. document: Document,
  35. context: AuthContext,
  36. ) -> None:
  37. """集中执行文档密级前置检查,ADMIN也不能绕过。"""
  38. if not _security_allowed(document, context):
  39. raise SecurityLevelForbiddenError()
  40. def _active_organization_ancestor_ids(
  41. organization_id: int | None,
  42. ) -> set[int]:
  43. if organization_id is None:
  44. return set()
  45. organizations = db.session.scalars(
  46. select(Organization).where(
  47. Organization.is_deleted.is_(False),
  48. Organization.status == EnabledStatus.ENABLED.value,
  49. )
  50. ).all()
  51. by_id = {organization.id: organization for organization in organizations}
  52. result: set[int] = set()
  53. current_id: int | None = organization_id
  54. while current_id is not None and current_id not in result:
  55. current = by_id.get(current_id)
  56. if current is None:
  57. return set()
  58. result.add(current.id)
  59. current_id = current.parent_id
  60. return result
  61. def _permission_source(document: Document) -> Document | None:
  62. if document.document_type == DocumentType.MAIN.value:
  63. return document
  64. if document.document_type != DocumentType.SUB_PLAN.value:
  65. return None
  66. root = db.session.scalar(
  67. select(Document).where(
  68. Document.id == document.root_document_id,
  69. Document.document_type == DocumentType.MAIN.value,
  70. Document.is_deleted.is_(False),
  71. )
  72. )
  73. if root is None:
  74. logger.error(
  75. "子方案数据一致性异常:document_id=%s root_document_id=%s",
  76. document.id,
  77. document.root_document_id,
  78. )
  79. return root
  80. def evaluate_plan_access(
  81. document: Document,
  82. context: AuthContext,
  83. ) -> PlanAccess:
  84. """按固定顺序判断方案读取权限并返回有效权限快照。"""
  85. source = _permission_source(document)
  86. if source is None:
  87. return PlanAccess(False, "INVALID_ROOT", None, (), False)
  88. if (
  89. context.role_code == RoleCode.USER
  90. and document.document_status != DocumentStatus.PUBLISHED.value
  91. ):
  92. return PlanAccess(False, "STATUS", source, (), False)
  93. if not _security_allowed(document, context):
  94. return PlanAccess(False, "SECURITY", source, (), False)
  95. if context.role_code == RoleCode.AUDITOR:
  96. return PlanAccess(False, "ROLE", source, (), False)
  97. if context.role_code == RoleCode.ADMIN:
  98. return PlanAccess(True, None, source, (), True)
  99. visibility = VisibilityType(source.visibility_type)
  100. if visibility == VisibilityType.ALL_AUTHENTICATED:
  101. return PlanAccess(True, None, source, (), True)
  102. permissions = tuple(
  103. db.session.scalars(
  104. select(Permission).where(
  105. Permission.document_id == source.id,
  106. Permission.is_deleted.is_(False),
  107. )
  108. ).all()
  109. )
  110. ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
  111. matched: list[Permission] = []
  112. for permission in permissions:
  113. if (
  114. permission.subject_type == SubjectType.ORG.value
  115. and permission.subject_id in ancestor_ids
  116. ):
  117. matched.append(permission)
  118. elif (
  119. visibility == VisibilityType.CUSTOM
  120. and permission.subject_type == SubjectType.USER.value
  121. and permission.subject_id == context.user_id
  122. ):
  123. matched.append(permission)
  124. if visibility == VisibilityType.ORGANIZATION:
  125. matched = [
  126. permission
  127. for permission in matched
  128. if permission.subject_type == SubjectType.ORG.value
  129. ]
  130. can_view = any(permission.can_view for permission in matched)
  131. return PlanAccess(
  132. can_view,
  133. None if can_view else "ACL",
  134. source,
  135. tuple(matched),
  136. can_view and any(permission.can_download for permission in matched),
  137. )
  138. def plan_allowed_actions(
  139. document: Document,
  140. context: AuthContext,
  141. access: PlanAccess,
  142. ) -> list[str]:
  143. if not access.allowed:
  144. return []
  145. if context.role_code == RoleCode.USER:
  146. actions = [AllowedAction.VIEW.value]
  147. if access.can_download:
  148. actions.append(AllowedAction.DOWNLOAD.value)
  149. return actions
  150. if context.role_code != RoleCode.ADMIN:
  151. return []
  152. if document.document_type == DocumentType.SUB_PLAN.value:
  153. return [
  154. AllowedAction.VIEW.value,
  155. AllowedAction.DOWNLOAD.value,
  156. AllowedAction.EDIT.value,
  157. AllowedAction.DELETE.value,
  158. ]
  159. actions = [
  160. AllowedAction.VIEW.value,
  161. AllowedAction.DOWNLOAD.value,
  162. AllowedAction.EDIT.value,
  163. AllowedAction.CONFIG_PERMISSION.value,
  164. AllowedAction.DELETE.value,
  165. AllowedAction.BIND_ATTACHMENT.value,
  166. ]
  167. binding_count = db.session.scalar(
  168. select(func.count(AttachmentBinding.id)).where(
  169. AttachmentBinding.main_document_id == document.id,
  170. AttachmentBinding.is_deleted.is_(False),
  171. )
  172. )
  173. if binding_count:
  174. actions.append(AllowedAction.UNBIND_ATTACHMENT.value)
  175. return actions
  176. def attachment_allowed_actions(context: AuthContext) -> list[str]:
  177. actions = [AllowedAction.VIEW.value, AllowedAction.DOWNLOAD.value]
  178. if context.role_code == RoleCode.ADMIN:
  179. actions.extend([AllowedAction.EDIT.value, AllowedAction.DELETE.value])
  180. return actions