authorization_service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. """主案、子方案及共享附件的集中读取授权。"""
  2. from __future__ import annotations
  3. import logging
  4. from dataclasses import dataclass
  5. from sqlalchemy import and_, exists, false, func, or_, select, true
  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. RolePermission,
  25. User,
  26. )
  27. from dms.security.auth_context import AuthContext
  28. from dms.common.errors import (
  29. DocumentActionForbiddenError,
  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. # 写路径操作码 → 中文动词(拒绝提示与前端口径一致)
  66. ACTION_LABELS: dict[str, str] = {
  67. AllowedAction.VIEW.value: "查看",
  68. AllowedAction.DOWNLOAD.value: "下载",
  69. AllowedAction.EDIT.value: "编辑",
  70. AllowedAction.DELETE.value: "删除",
  71. AllowedAction.CONFIG_PERMISSION.value: "授权",
  72. AllowedAction.BIND_ATTACHMENT.value: "挂载",
  73. AllowedAction.UNBIND_ATTACHMENT.value: "解除挂载",
  74. }
  75. def require_plan_action(
  76. document: Document,
  77. context: AuthContext,
  78. action: AllowedAction,
  79. ) -> PlanAccess | None:
  80. """写路径集中鉴权:密级前置 + 文档访问 + 操作位校验。
  81. 与读路径/前端按钮同源(evaluate_plan_access + plan_allowed_actions),
  82. 保证"按钮可点 ⟺ 后端放行":
  83. - 密级不足:任何角色(含 ADMIN)一律拒绝;
  84. - 共享附件:矩阵不授予编辑/删除/授权/挂载位,仅 ADMIN 可维护;
  85. - 主案/子方案:ADMIN 恒过(密级通过后);USER 由矩阵操作位判定,
  86. 子方案自动过滤授权/挂载/解除挂载位(继承自主案)。
  87. """
  88. if not _security_allowed(document, context):
  89. raise SecurityLevelForbiddenError()
  90. label = ACTION_LABELS.get(action.value, "执行该操作")
  91. if document.document_type == DocumentType.ATTACHMENT.value:
  92. if context.role_code != RoleCode.ADMIN:
  93. raise DocumentActionForbiddenError(
  94. f"无权{label}该共享附件:仅管理员可维护共享附件"
  95. )
  96. return None
  97. access = evaluate_plan_access(document, context)
  98. if not access.allowed:
  99. raise DocumentActionForbiddenError(f"无权{label}该文档")
  100. actions = plan_allowed_actions(document, context, access)
  101. if action.value not in actions:
  102. raise DocumentActionForbiddenError(
  103. f"无权{label}该文档:角色权限未授予该操作"
  104. )
  105. return access
  106. def _active_organization_ancestor_ids(
  107. organization_id: int | None,
  108. ) -> set[int]:
  109. if organization_id is None:
  110. return set()
  111. organizations = db.session.scalars(
  112. select(Organization).where(
  113. Organization.is_deleted.is_(False),
  114. Organization.status == EnabledStatus.ENABLED.value,
  115. )
  116. ).all()
  117. by_id = {organization.id: organization for organization in organizations}
  118. result: set[int] = set()
  119. current_id: int | None = organization_id
  120. while current_id is not None and current_id not in result:
  121. current = by_id.get(current_id)
  122. if current is None:
  123. return set()
  124. result.add(current.id)
  125. current_id = current.parent_id
  126. return result
  127. def _permission_source(document: Document) -> Document | None:
  128. if document.document_type == DocumentType.MAIN.value:
  129. return document
  130. if document.document_type != DocumentType.SUB_PLAN.value:
  131. return None
  132. root = db.session.scalar(
  133. select(Document).where(
  134. Document.id == document.root_document_id,
  135. Document.document_type == DocumentType.MAIN.value,
  136. Document.is_deleted.is_(False),
  137. )
  138. )
  139. if root is None:
  140. logger.error(
  141. "子方案数据一致性异常:document_id=%s root_document_id=%s",
  142. document.id,
  143. document.root_document_id,
  144. )
  145. return root
  146. def _effective_role(context: AuthContext) -> Role | None:
  147. """实时解析 USER 体系的生效角色:显式角色优先,未挂角色回退默认角色。
  148. 显式角色被停用或软删时不回退默认角色(矩阵贡献为空)。
  149. """
  150. role_id = db.session.scalar(
  151. select(User.role_id).where(
  152. User.id == context.user_id,
  153. User.is_deleted.is_(False),
  154. )
  155. )
  156. if role_id is not None:
  157. return db.session.scalar(
  158. select(Role).where(
  159. Role.id == role_id,
  160. Role.is_deleted.is_(False),
  161. Role.status == EnabledStatus.ENABLED.value,
  162. )
  163. )
  164. return db.session.scalar(
  165. select(Role).where(
  166. Role.is_default.is_(True),
  167. Role.is_deleted.is_(False),
  168. Role.status == EnabledStatus.ENABLED.value,
  169. )
  170. )
  171. def _dept_scope(source: Document, context: AuthContext) -> DeptScope:
  172. """按文档归属机构划分 OWN / OTHER(OTHER 涵盖所属部门子树外全部部门)。"""
  173. if source.organization_id is None:
  174. return DeptScope.OTHER
  175. if context.organization_id is not None:
  176. paths = db.session.execute(
  177. select(Organization.id, Organization.org_path).where(
  178. Organization.id.in_(
  179. {source.organization_id, context.organization_id}
  180. ),
  181. Organization.is_deleted.is_(False),
  182. )
  183. ).all()
  184. by_id = {organization_id: path for organization_id, path in paths}
  185. doc_path = by_id.get(source.organization_id)
  186. user_path = by_id.get(context.organization_id)
  187. if doc_path and user_path:
  188. own_prefix = user_path if user_path.endswith("/") else user_path + "/"
  189. if doc_path == user_path or doc_path.startswith(own_prefix):
  190. return DeptScope.OWN
  191. return DeptScope.OTHER
  192. def _matrix_actions(source: Document, context: AuthContext) -> frozenset[str]:
  193. """取生效角色矩阵中命中的操作位集合(密级×部门范围,实时查库)。"""
  194. role = _effective_role(context)
  195. if role is None:
  196. return frozenset()
  197. scope = _dept_scope(source, context)
  198. row = db.session.scalar(
  199. select(RolePermission).where(
  200. RolePermission.role_id == role.id,
  201. RolePermission.security_level == source.security_level,
  202. RolePermission.dept_scope == scope.value,
  203. RolePermission.is_deleted.is_(False),
  204. )
  205. )
  206. if row is None:
  207. return frozenset()
  208. return frozenset(
  209. action.value
  210. for column, action in MATRIX_ACTION_COLUMNS
  211. if getattr(row, column)
  212. )
  213. def evaluate_plan_access(
  214. document: Document,
  215. context: AuthContext,
  216. ) -> PlanAccess:
  217. """按固定顺序判断方案读取权限并返回有效权限快照。"""
  218. source = _permission_source(document)
  219. if source is None:
  220. return PlanAccess(False, "INVALID_ROOT", None, (), False)
  221. if (
  222. context.role_code == RoleCode.USER
  223. and document.document_status != DocumentStatus.PUBLISHED.value
  224. ):
  225. return PlanAccess(False, "STATUS", source, (), False)
  226. if not _security_allowed(document, context):
  227. return PlanAccess(False, "SECURITY", source, (), False)
  228. if context.role_code == RoleCode.ADMIN:
  229. return PlanAccess(True, None, source, (), True)
  230. matrix_actions = _matrix_actions(source, context)
  231. visibility = VisibilityType(source.visibility_type)
  232. if visibility == VisibilityType.ALL_AUTHENTICATED:
  233. return PlanAccess(
  234. True, None, source, (), True, matrix_actions
  235. )
  236. permissions = tuple(
  237. db.session.scalars(
  238. select(Permission).where(
  239. Permission.document_id == source.id,
  240. Permission.is_deleted.is_(False),
  241. )
  242. ).all()
  243. )
  244. ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
  245. matched: list[Permission] = []
  246. for permission in permissions:
  247. if (
  248. permission.subject_type == SubjectType.ORG.value
  249. and permission.subject_id in ancestor_ids
  250. ):
  251. matched.append(permission)
  252. elif (
  253. visibility == VisibilityType.CUSTOM
  254. and permission.subject_type == SubjectType.USER.value
  255. and permission.subject_id == context.user_id
  256. ):
  257. matched.append(permission)
  258. if visibility == VisibilityType.ORGANIZATION:
  259. matched = [
  260. permission
  261. for permission in matched
  262. if permission.subject_type == SubjectType.ORG.value
  263. ]
  264. can_view = any(permission.can_view for permission in matched)
  265. # 矩阵与单文档 ACL 为 OR 叠加
  266. can_view = can_view or AllowedAction.VIEW.value in matrix_actions
  267. can_download = (can_view and any(
  268. permission.can_download for permission in matched
  269. )) or AllowedAction.DOWNLOAD.value in matrix_actions
  270. return PlanAccess(
  271. can_view,
  272. None if can_view else "ACL",
  273. source,
  274. tuple(matched),
  275. can_view and can_download,
  276. matrix_actions,
  277. )
  278. def plan_allowed_actions(
  279. document: Document,
  280. context: AuthContext,
  281. access: PlanAccess,
  282. ) -> list[str]:
  283. if not access.allowed:
  284. return []
  285. if context.role_code == RoleCode.USER:
  286. actions = [AllowedAction.VIEW.value]
  287. if access.can_download:
  288. actions.append(AllowedAction.DOWNLOAD.value)
  289. # 矩阵操作位 OR 叠加;子方案不适用授权/挂载/解除挂载
  290. applicable = None
  291. if document.document_type == DocumentType.SUB_PLAN.value:
  292. applicable = {
  293. AllowedAction.VIEW.value,
  294. AllowedAction.DOWNLOAD.value,
  295. AllowedAction.EDIT.value,
  296. AllowedAction.DELETE.value,
  297. }
  298. for action in access.matrix_actions:
  299. if applicable is not None and action not in applicable:
  300. continue
  301. if action not in actions:
  302. actions.append(action)
  303. return actions
  304. if context.role_code != RoleCode.ADMIN:
  305. return []
  306. if document.document_type == DocumentType.SUB_PLAN.value:
  307. return [
  308. AllowedAction.VIEW.value,
  309. AllowedAction.DOWNLOAD.value,
  310. AllowedAction.EDIT.value,
  311. AllowedAction.DELETE.value,
  312. ]
  313. # 阶段11方案B:ADMIN恒含UNBIND(与BIND对称)。原"存在附件挂载才有位"
  314. # 是UI显影残留——附件表零挂载不渲染、子案挂载(0018)复用该位后反而出现
  315. # "仅有子案挂载的主案管理员无法解挂"的倒挂,故移除条件(顺带消除
  316. # 主案列表每行一次的count查询)。
  317. return [
  318. AllowedAction.VIEW.value,
  319. AllowedAction.DOWNLOAD.value,
  320. AllowedAction.EDIT.value,
  321. AllowedAction.CONFIG_PERMISSION.value,
  322. AllowedAction.DELETE.value,
  323. AllowedAction.BIND_ATTACHMENT.value,
  324. AllowedAction.UNBIND_ATTACHMENT.value,
  325. ]
  326. def attachment_allowed_actions(
  327. context: AuthContext,
  328. access: PlanAccess | None = None,
  329. ) -> list[str]:
  330. if context.role_code == RoleCode.ADMIN:
  331. return [
  332. AllowedAction.VIEW.value,
  333. AllowedAction.DOWNLOAD.value,
  334. AllowedAction.EDIT.value,
  335. AllowedAction.DELETE.value,
  336. ]
  337. actions: list[str] = []
  338. if access is not None and access.allowed:
  339. actions.append(AllowedAction.VIEW.value)
  340. if access.can_download:
  341. actions.append(AllowedAction.DOWNLOAD.value)
  342. return actions
  343. def evaluate_attachment_access(
  344. attachment: Document,
  345. context: AuthContext,
  346. ) -> tuple[bool, bool]:
  347. """USER只能通过至少一个有权有效主案访问共享附件。
  348. 阶段11:附件允许携带任意密级(0020放宽shape约束,存量子案保留原密级),
  349. 读路径密级前置与主案/子方案同口径——任何角色(含ADMIN)密级不足即拒绝。
  350. """
  351. if not _security_allowed(attachment, context):
  352. return False, False
  353. if context.role_code == RoleCode.ADMIN:
  354. return True, True
  355. mains = db.session.scalars(
  356. select(Document)
  357. .join(
  358. AttachmentBinding,
  359. AttachmentBinding.main_document_id == Document.id,
  360. )
  361. .where(
  362. AttachmentBinding.attachment_document_id == attachment.id,
  363. AttachmentBinding.is_deleted.is_(False),
  364. Document.document_type == DocumentType.MAIN.value,
  365. Document.is_deleted.is_(False),
  366. )
  367. .order_by(Document.id.asc())
  368. ).all()
  369. can_view = False
  370. can_download = False
  371. for main in mains:
  372. access = evaluate_plan_access(main, context)
  373. can_view = can_view or access.allowed
  374. can_download = can_download or (access.allowed and access.can_download)
  375. return can_view, can_download
  376. def require_attachment_access(
  377. attachment: Document,
  378. context: AuthContext,
  379. *,
  380. download: bool,
  381. ) -> tuple[bool, bool]:
  382. # 密级不足优先按密级拒绝(前端据此提示"密级不足"而非"权限不足")
  383. if not _security_allowed(attachment, context):
  384. raise SecurityLevelForbiddenError()
  385. can_view, can_download = evaluate_attachment_access(attachment, context)
  386. if download and not can_download:
  387. raise DocumentDownloadForbiddenError()
  388. if not download and not can_view:
  389. raise DocumentViewForbiddenError()
  390. return can_view, can_download
  391. # 管理类操作位:命中任一即视为“对该文档有管理能力”,用于动态展示后台管理菜单。
  392. MANAGE_MATRIX_COLUMNS: tuple[str, ...] = (
  393. "can_edit",
  394. "can_delete",
  395. "can_config_permission",
  396. "can_bind_attachment",
  397. "can_unbind_attachment",
  398. )
  399. def _organization_subtree_ids(organization_id: int | None) -> set[int]:
  400. """用户机构的下属子树(含自身),与 _dept_scope 的 OWN 判定同口径。"""
  401. if organization_id is None:
  402. return set()
  403. organizations = db.session.scalars(
  404. select(Organization).where(
  405. Organization.is_deleted.is_(False),
  406. Organization.status == EnabledStatus.ENABLED.value,
  407. )
  408. ).all()
  409. by_id = {organization.id: organization for organization in organizations}
  410. root = by_id.get(organization_id)
  411. if root is None:
  412. return set()
  413. prefix = root.org_path if root.org_path.endswith("/") else root.org_path + "/"
  414. return {
  415. organization.id
  416. for organization in organizations
  417. if organization.id == organization_id
  418. or (organization.org_path or "").startswith(prefix)
  419. }
  420. def has_any_manage_action(context: AuthContext) -> bool:
  421. """用户是否对任一已发布主案具备管理类操作(编辑/删除/授权/挂载/解除挂载)。
  422. 与 plan_allowed_actions 同源:ADMIN 恒真;USER 由角色权限矩阵推导——
  423. 矩阵行命中(密级×部门范围)且存在“可查看”的对应主案时管理位才生效。
  424. 只看主案即可完整覆盖子案:子案矩阵取自其根主案的同一行,主案命中
  425. 子案必命中。用于登录/会话接口动态计算 allowedModules 中的
  426. BACKEND_MANAGEMENT,替代“管理员固定可见”的角色硬编码。
  427. """
  428. if context.role_code == RoleCode.ADMIN:
  429. return True
  430. role = _effective_role(context)
  431. if role is None:
  432. return False
  433. rows = db.session.scalars(
  434. select(RolePermission).where(
  435. RolePermission.role_id == role.id,
  436. RolePermission.is_deleted.is_(False),
  437. )
  438. ).all()
  439. candidates = [
  440. row
  441. for row in rows
  442. if any(getattr(row, column) for column in MANAGE_MATRIX_COLUMNS)
  443. ]
  444. if not candidates:
  445. return False
  446. # 部门范围两区集合:OWN 子树 / 其余(含无归属)。
  447. own_ids = _organization_subtree_ids(context.organization_id)
  448. ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
  449. # ORG 主体授权对机构/自定义可见均生效;USER 主体仅自定义可见生效。
  450. org_subject = and_(
  451. Permission.subject_type == SubjectType.ORG.value,
  452. (
  453. Permission.subject_id.in_(ancestor_ids)
  454. if ancestor_ids
  455. else false()
  456. ),
  457. )
  458. user_subject = and_(
  459. Permission.subject_type == SubjectType.USER.value,
  460. Permission.subject_id == context.user_id,
  461. Document.visibility_type == VisibilityType.CUSTOM.value,
  462. )
  463. acl_view_granted = exists(
  464. select(Permission.id).where(
  465. Permission.document_id == Document.id,
  466. Permission.is_deleted.is_(False),
  467. Permission.can_view.is_(True),
  468. or_(org_subject, user_subject),
  469. )
  470. )
  471. for row in candidates:
  472. scope = DeptScope(row.dept_scope)
  473. if scope == DeptScope.OWN:
  474. scope_filter = Document.organization_id.in_(own_ids)
  475. else:
  476. scope_filter = or_(
  477. Document.organization_id.is_(None),
  478. Document.organization_id.not_in(own_ids),
  479. )
  480. # 查看权三来源(与 evaluate_plan_access 同口径):
  481. # ① 本矩阵行勾选查看;② 全员可见;③ 文档级 ACL 授权查看。
  482. view_sources: list = [
  483. Document.visibility_type == VisibilityType.ALL_AUTHENTICATED.value,
  484. acl_view_granted,
  485. ]
  486. if row.can_view:
  487. view_sources.append(true())
  488. hit = db.session.scalar(
  489. select(Document.id).where(
  490. Document.is_deleted.is_(False),
  491. Document.document_status == DocumentStatus.PUBLISHED.value,
  492. Document.document_type == DocumentType.MAIN.value,
  493. Document.security_level == row.security_level,
  494. scope_filter,
  495. or_(*view_sources),
  496. ).limit(1)
  497. )
  498. if hit is not None:
  499. return True
  500. return False