authorization_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  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. DeptScope,
  9. DocumentStatus,
  10. DocumentType,
  11. EnabledStatus,
  12. RoleCode,
  13. SECURITY_LEVEL_VALUES,
  14. SubjectType,
  15. VisibilityType,
  16. )
  17. from dms.extensions import db
  18. from dms.models import (
  19. AttachmentBinding,
  20. Document,
  21. Organization,
  22. Permission,
  23. Role,
  24. RoleDepartment,
  25. RolePermission,
  26. User,
  27. )
  28. from dms.security.auth_context import AuthContext
  29. from dms.common.errors import (
  30. DocumentDownloadForbiddenError,
  31. DocumentViewForbiddenError,
  32. SecurityLevelForbiddenError,
  33. )
  34. logger = logging.getLogger(__name__)
  35. # 矩阵布尔位与 AllowedAction 的一一对应
  36. MATRIX_ACTION_COLUMNS: tuple[tuple[str, AllowedAction], ...] = (
  37. ("can_view", AllowedAction.VIEW),
  38. ("can_download", AllowedAction.DOWNLOAD),
  39. ("can_edit", AllowedAction.EDIT),
  40. ("can_delete", AllowedAction.DELETE),
  41. ("can_config_permission", AllowedAction.CONFIG_PERMISSION),
  42. ("can_bind_attachment", AllowedAction.BIND_ATTACHMENT),
  43. ("can_unbind_attachment", AllowedAction.UNBIND_ATTACHMENT),
  44. )
  45. @dataclass(frozen=True, slots=True)
  46. class PlanAccess:
  47. allowed: bool
  48. reason: str | None
  49. source: Document | None
  50. permissions: tuple[Permission, ...]
  51. can_download: bool
  52. matrix_actions: frozenset[str] = frozenset()
  53. def _security_allowed(document: Document, context: AuthContext) -> bool:
  54. return (
  55. SECURITY_LEVEL_VALUES[context.security_level]
  56. >= SECURITY_LEVEL_VALUES[type(context.security_level)(document.security_level)]
  57. )
  58. def require_security_clearance(
  59. document: Document,
  60. context: AuthContext,
  61. ) -> None:
  62. """集中执行文档密级前置检查,ADMIN也不能绕过。"""
  63. if not _security_allowed(document, context):
  64. raise SecurityLevelForbiddenError()
  65. def _active_organization_ancestor_ids(
  66. organization_id: int | None,
  67. ) -> set[int]:
  68. if organization_id is None:
  69. return set()
  70. organizations = db.session.scalars(
  71. select(Organization).where(
  72. Organization.is_deleted.is_(False),
  73. Organization.status == EnabledStatus.ENABLED.value,
  74. )
  75. ).all()
  76. by_id = {organization.id: organization for organization in organizations}
  77. result: set[int] = set()
  78. current_id: int | None = organization_id
  79. while current_id is not None and current_id not in result:
  80. current = by_id.get(current_id)
  81. if current is None:
  82. return set()
  83. result.add(current.id)
  84. current_id = current.parent_id
  85. return result
  86. def _permission_source(document: Document) -> Document | None:
  87. if document.document_type == DocumentType.MAIN.value:
  88. return document
  89. if document.document_type != DocumentType.SUB_PLAN.value:
  90. return None
  91. root = db.session.scalar(
  92. select(Document).where(
  93. Document.id == document.root_document_id,
  94. Document.document_type == DocumentType.MAIN.value,
  95. Document.is_deleted.is_(False),
  96. )
  97. )
  98. if root is None:
  99. logger.error(
  100. "子方案数据一致性异常:document_id=%s root_document_id=%s",
  101. document.id,
  102. document.root_document_id,
  103. )
  104. return root
  105. def _effective_role(context: AuthContext) -> Role | None:
  106. """实时解析 USER 体系的生效角色:显式角色优先,未挂角色回退默认角色。
  107. 显式角色被停用或软删时不回退默认角色(矩阵贡献为空)。
  108. """
  109. role_id = db.session.scalar(
  110. select(User.role_id).where(
  111. User.id == context.user_id,
  112. User.is_deleted.is_(False),
  113. )
  114. )
  115. if role_id is not None:
  116. return db.session.scalar(
  117. select(Role).where(
  118. Role.id == role_id,
  119. Role.is_deleted.is_(False),
  120. Role.status == EnabledStatus.ENABLED.value,
  121. )
  122. )
  123. return db.session.scalar(
  124. select(Role).where(
  125. Role.is_default.is_(True),
  126. Role.is_deleted.is_(False),
  127. Role.status == EnabledStatus.ENABLED.value,
  128. )
  129. )
  130. def _dept_scope(
  131. source: Document, context: AuthContext, role: Role | None
  132. ) -> DeptScope:
  133. """按文档归属机构划分 OWN / ASSIGNED / OTHER。"""
  134. if source.organization_id is None:
  135. return DeptScope.OTHER
  136. if context.organization_id is not None:
  137. paths = db.session.execute(
  138. select(Organization.id, Organization.org_path).where(
  139. Organization.id.in_(
  140. {source.organization_id, context.organization_id}
  141. ),
  142. Organization.is_deleted.is_(False),
  143. )
  144. ).all()
  145. by_id = {organization_id: path for organization_id, path in paths}
  146. doc_path = by_id.get(source.organization_id)
  147. user_path = by_id.get(context.organization_id)
  148. if doc_path and user_path:
  149. own_prefix = user_path if user_path.endswith("/") else user_path + "/"
  150. if doc_path == user_path or doc_path.startswith(own_prefix):
  151. return DeptScope.OWN
  152. if role is not None:
  153. assigned = db.session.scalar(
  154. select(RoleDepartment.id).where(
  155. RoleDepartment.role_id == role.id,
  156. RoleDepartment.organization_id == source.organization_id,
  157. RoleDepartment.is_deleted.is_(False),
  158. )
  159. )
  160. if assigned is not None:
  161. return DeptScope.ASSIGNED
  162. return DeptScope.OTHER
  163. def _matrix_actions(source: Document, context: AuthContext) -> frozenset[str]:
  164. """取生效角色矩阵中命中的操作位集合(密级×部门范围,实时查库)。"""
  165. role = _effective_role(context)
  166. if role is None:
  167. return frozenset()
  168. scope = _dept_scope(source, context, role)
  169. row = db.session.scalar(
  170. select(RolePermission).where(
  171. RolePermission.role_id == role.id,
  172. RolePermission.security_level == source.security_level,
  173. RolePermission.dept_scope == scope.value,
  174. RolePermission.is_deleted.is_(False),
  175. )
  176. )
  177. if row is None:
  178. return frozenset()
  179. return frozenset(
  180. action.value
  181. for column, action in MATRIX_ACTION_COLUMNS
  182. if getattr(row, column)
  183. )
  184. def evaluate_plan_access(
  185. document: Document,
  186. context: AuthContext,
  187. ) -> PlanAccess:
  188. """按固定顺序判断方案读取权限并返回有效权限快照。"""
  189. source = _permission_source(document)
  190. if source is None:
  191. return PlanAccess(False, "INVALID_ROOT", None, (), False)
  192. if (
  193. context.role_code == RoleCode.USER
  194. and document.document_status != DocumentStatus.PUBLISHED.value
  195. ):
  196. return PlanAccess(False, "STATUS", source, (), False)
  197. if not _security_allowed(document, context):
  198. return PlanAccess(False, "SECURITY", source, (), False)
  199. if context.role_code == RoleCode.ADMIN:
  200. return PlanAccess(True, None, source, (), True)
  201. matrix_actions = _matrix_actions(source, context)
  202. visibility = VisibilityType(source.visibility_type)
  203. if visibility == VisibilityType.ALL_AUTHENTICATED:
  204. return PlanAccess(
  205. True, None, source, (), True, matrix_actions
  206. )
  207. permissions = tuple(
  208. db.session.scalars(
  209. select(Permission).where(
  210. Permission.document_id == source.id,
  211. Permission.is_deleted.is_(False),
  212. )
  213. ).all()
  214. )
  215. ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
  216. matched: list[Permission] = []
  217. for permission in permissions:
  218. if (
  219. permission.subject_type == SubjectType.ORG.value
  220. and permission.subject_id in ancestor_ids
  221. ):
  222. matched.append(permission)
  223. elif (
  224. visibility == VisibilityType.CUSTOM
  225. and permission.subject_type == SubjectType.USER.value
  226. and permission.subject_id == context.user_id
  227. ):
  228. matched.append(permission)
  229. if visibility == VisibilityType.ORGANIZATION:
  230. matched = [
  231. permission
  232. for permission in matched
  233. if permission.subject_type == SubjectType.ORG.value
  234. ]
  235. can_view = any(permission.can_view for permission in matched)
  236. # 矩阵与单文档 ACL 为 OR 叠加
  237. can_view = can_view or AllowedAction.VIEW.value in matrix_actions
  238. can_download = (can_view and any(
  239. permission.can_download for permission in matched
  240. )) or AllowedAction.DOWNLOAD.value in matrix_actions
  241. return PlanAccess(
  242. can_view,
  243. None if can_view else "ACL",
  244. source,
  245. tuple(matched),
  246. can_view and can_download,
  247. matrix_actions,
  248. )
  249. def plan_allowed_actions(
  250. document: Document,
  251. context: AuthContext,
  252. access: PlanAccess,
  253. ) -> list[str]:
  254. if not access.allowed:
  255. return []
  256. if context.role_code == RoleCode.USER:
  257. actions = [AllowedAction.VIEW.value]
  258. if access.can_download:
  259. actions.append(AllowedAction.DOWNLOAD.value)
  260. # 矩阵操作位 OR 叠加;子方案不适用授权/挂载/解除挂载
  261. applicable = None
  262. if document.document_type == DocumentType.SUB_PLAN.value:
  263. applicable = {
  264. AllowedAction.VIEW.value,
  265. AllowedAction.DOWNLOAD.value,
  266. AllowedAction.EDIT.value,
  267. AllowedAction.DELETE.value,
  268. }
  269. for action in access.matrix_actions:
  270. if applicable is not None and action not in applicable:
  271. continue
  272. if action not in actions:
  273. actions.append(action)
  274. return actions
  275. if context.role_code != RoleCode.ADMIN:
  276. return []
  277. if document.document_type == DocumentType.SUB_PLAN.value:
  278. return [
  279. AllowedAction.VIEW.value,
  280. AllowedAction.DOWNLOAD.value,
  281. AllowedAction.EDIT.value,
  282. AllowedAction.DELETE.value,
  283. ]
  284. actions = [
  285. AllowedAction.VIEW.value,
  286. AllowedAction.DOWNLOAD.value,
  287. AllowedAction.EDIT.value,
  288. AllowedAction.CONFIG_PERMISSION.value,
  289. AllowedAction.DELETE.value,
  290. AllowedAction.BIND_ATTACHMENT.value,
  291. ]
  292. binding_count = db.session.scalar(
  293. select(func.count(AttachmentBinding.id)).where(
  294. AttachmentBinding.main_document_id == document.id,
  295. AttachmentBinding.is_deleted.is_(False),
  296. )
  297. )
  298. if binding_count:
  299. actions.append(AllowedAction.UNBIND_ATTACHMENT.value)
  300. return actions
  301. def attachment_allowed_actions(
  302. context: AuthContext,
  303. access: PlanAccess | None = None,
  304. ) -> list[str]:
  305. if context.role_code == RoleCode.ADMIN:
  306. return [
  307. AllowedAction.VIEW.value,
  308. AllowedAction.DOWNLOAD.value,
  309. AllowedAction.EDIT.value,
  310. AllowedAction.DELETE.value,
  311. ]
  312. actions: list[str] = []
  313. if access is not None and access.allowed:
  314. actions.append(AllowedAction.VIEW.value)
  315. if access.can_download:
  316. actions.append(AllowedAction.DOWNLOAD.value)
  317. return actions
  318. def evaluate_attachment_access(
  319. attachment: Document,
  320. context: AuthContext,
  321. ) -> tuple[bool, bool]:
  322. """USER只能通过至少一个有权有效主案访问共享附件。"""
  323. if context.role_code == RoleCode.ADMIN:
  324. return True, True
  325. mains = db.session.scalars(
  326. select(Document)
  327. .join(
  328. AttachmentBinding,
  329. AttachmentBinding.main_document_id == Document.id,
  330. )
  331. .where(
  332. AttachmentBinding.attachment_document_id == attachment.id,
  333. AttachmentBinding.is_deleted.is_(False),
  334. Document.document_type == DocumentType.MAIN.value,
  335. Document.is_deleted.is_(False),
  336. )
  337. .order_by(Document.id.asc())
  338. ).all()
  339. can_view = False
  340. can_download = False
  341. for main in mains:
  342. access = evaluate_plan_access(main, context)
  343. can_view = can_view or access.allowed
  344. can_download = can_download or (access.allowed and access.can_download)
  345. return can_view, can_download
  346. def require_attachment_access(
  347. attachment: Document,
  348. context: AuthContext,
  349. *,
  350. download: bool,
  351. ) -> tuple[bool, bool]:
  352. can_view, can_download = evaluate_attachment_access(attachment, context)
  353. if download and not can_download:
  354. raise DocumentDownloadForbiddenError()
  355. if not download and not can_view:
  356. raise DocumentViewForbiddenError()
  357. return can_view, can_download