authorization_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. """主案、子方案及共享附件的集中读取授权。"""
  2. from __future__ import annotations
  3. import logging
  4. from dataclasses import dataclass
  5. from datetime import datetime, timezone
  6. from sqlalchemy import exists, or_, select, true
  7. from dms.common.enums import (
  8. AllowedAction,
  9. DeptScope,
  10. DocumentStatus,
  11. DocumentType,
  12. EnabledStatus,
  13. RoleCode,
  14. SECURITY_LEVEL_VALUES,
  15. )
  16. from dms.extensions import db
  17. from dms.models import (
  18. AttachmentBinding,
  19. Document,
  20. Organization,
  21. Permission,
  22. Role,
  23. RolePermission,
  24. User,
  25. )
  26. from dms.security.auth_context import AuthContext
  27. from dms.common.errors import (
  28. DocumentActionForbiddenError,
  29. DocumentDownloadForbiddenError,
  30. DocumentViewForbiddenError,
  31. SecurityLevelForbiddenError,
  32. )
  33. from dms.services.admin_permission import SUPER_ADMIN_USERNAME
  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. class AccessMemo:
  54. """请求级授权备忘:单次列表批量评估共享,缓存请求内不变量
  55. (生效角色、机构路径、矩阵行、祖先机构、权限源主案、授权列表),
  56. 消除逐行重复查库(对齐 Java 端 AccessMemo)。
  57. 仅限同一请求、同一用户上下文内使用,禁止跨请求复用——
  58. 备忘不感知请求期间的权限数据变更。
  59. """
  60. __slots__ = (
  61. "role_loaded",
  62. "role",
  63. "org_paths_by_id",
  64. "matrix_actions_by_key",
  65. "roots_by_id",
  66. "permissions_by_source_id",
  67. )
  68. def __init__(self) -> None:
  69. # 生效角色是否已加载(role 允许为 None:无生效角色)
  70. self.role_loaded = False
  71. self.role: Role | None = None
  72. # 机构 org_path 按 id 缓存(dept_scope 逐行判定复用)
  73. self.org_paths_by_id: dict[int, str | None] = {}
  74. # 矩阵操作位按 (security_level, dept_scope) 缓存
  75. self.matrix_actions_by_key: dict[tuple[str, str], frozenset[str]] = {}
  76. # SUB_PLAN 权限源主案按 root_id 缓存(同根子案批量评估复用)
  77. self.roots_by_id: dict[int, Document | None] = {}
  78. # 授权列表按权限源文档 id 缓存(evaluate_plan_access 与权限摘要共享)
  79. self.permissions_by_source_id: dict[int, list[Permission]] = {}
  80. def _security_allowed(document: Document, context: AuthContext) -> bool:
  81. return (
  82. SECURITY_LEVEL_VALUES[context.security_level]
  83. >= SECURITY_LEVEL_VALUES[type(context.security_level)(document.security_level)]
  84. )
  85. def _is_super_admin(context: AuthContext) -> bool:
  86. """内置超管(username == admin):文档操作不受角色矩阵限制。
  87. 其余 ADMIN(受限管理员)文档权限与 USER 同口径,按关联角色的
  88. 权限矩阵(密级×部门范围×操作位)判定。
  89. """
  90. return (
  91. context.role_code == RoleCode.ADMIN
  92. and context.username == SUPER_ADMIN_USERNAME
  93. )
  94. def require_security_clearance(
  95. document: Document,
  96. context: AuthContext,
  97. ) -> None:
  98. """集中执行文档密级前置检查,ADMIN也不能绕过。"""
  99. if not _security_allowed(document, context):
  100. raise SecurityLevelForbiddenError()
  101. # 写路径操作码 → 中文动词(拒绝提示与前端口径一致)
  102. ACTION_LABELS: dict[str, str] = {
  103. AllowedAction.VIEW.value: "查看",
  104. AllowedAction.DOWNLOAD.value: "下载",
  105. AllowedAction.EDIT.value: "编辑",
  106. AllowedAction.DELETE.value: "删除",
  107. AllowedAction.CONFIG_PERMISSION.value: "授权",
  108. AllowedAction.BIND_ATTACHMENT.value: "挂载",
  109. AllowedAction.UNBIND_ATTACHMENT.value: "解除挂载",
  110. }
  111. def require_plan_action(
  112. document: Document,
  113. context: AuthContext,
  114. action: AllowedAction,
  115. ) -> PlanAccess | None:
  116. """写路径集中鉴权:密级前置 + 文档访问 + 操作位校验。
  117. 与读路径/前端按钮同源(evaluate_plan_access + plan_allowed_actions),
  118. 保证"按钮可点 ⟺ 后端放行":
  119. - 密级不足:任何角色(含超管)一律拒绝;
  120. - 共享附件:矩阵不授予编辑/删除/授权/挂载位,仅 ADMIN(含超管)
  121. 可维护;
  122. - 主案/子方案:超管恒过(密级通过后);受限 ADMIN 与 USER 由矩阵
  123. 操作位判定,子方案自动过滤授权/挂载/解除挂载位(继承自主案)。
  124. """
  125. if not _security_allowed(document, context):
  126. raise SecurityLevelForbiddenError()
  127. label = ACTION_LABELS.get(action.value, "执行该操作")
  128. if document.document_type == DocumentType.ATTACHMENT.value:
  129. if context.role_code != RoleCode.ADMIN:
  130. raise DocumentActionForbiddenError(
  131. f"无权{label}该共享附件:仅管理员可维护共享附件"
  132. )
  133. return None
  134. access = evaluate_plan_access(document, context)
  135. if not access.allowed:
  136. raise DocumentActionForbiddenError(f"无权{label}该文档")
  137. actions = plan_allowed_actions(document, context, access)
  138. if action.value not in actions:
  139. raise DocumentActionForbiddenError(
  140. f"无权{label}该文档:角色权限未授予该操作"
  141. )
  142. return access
  143. def _permission_source(
  144. document: Document,
  145. memo: AccessMemo | None = None,
  146. ) -> Document | None:
  147. if document.document_type == DocumentType.MAIN.value:
  148. return document
  149. if document.document_type != DocumentType.SUB_PLAN.value:
  150. return None
  151. if memo is not None and document.root_document_id in memo.roots_by_id:
  152. return memo.roots_by_id[document.root_document_id]
  153. root = db.session.scalar(
  154. select(Document).where(
  155. Document.id == document.root_document_id,
  156. Document.document_type == DocumentType.MAIN.value,
  157. Document.is_deleted.is_(False),
  158. )
  159. )
  160. if root is None:
  161. logger.error(
  162. "子方案数据一致性异常:document_id=%s root_document_id=%s",
  163. document.id,
  164. document.root_document_id,
  165. )
  166. if memo is not None:
  167. memo.roots_by_id[document.root_document_id] = root
  168. return root
  169. def _effective_role(
  170. context: AuthContext,
  171. memo: AccessMemo | None = None,
  172. ) -> Role | None:
  173. """实时解析 USER 体系的生效角色:显式角色优先,未挂角色回退默认角色。
  174. 显式角色被停用或软删时不回退默认角色(矩阵贡献为空)。
  175. """
  176. if memo is not None and memo.role_loaded:
  177. return memo.role
  178. role_id = db.session.scalar(
  179. select(User.role_id).where(
  180. User.id == context.user_id,
  181. User.is_deleted.is_(False),
  182. )
  183. )
  184. if role_id is not None:
  185. role = db.session.scalar(
  186. select(Role).where(
  187. Role.id == role_id,
  188. Role.is_deleted.is_(False),
  189. Role.status == EnabledStatus.ENABLED.value,
  190. )
  191. )
  192. else:
  193. role = db.session.scalar(
  194. select(Role).where(
  195. Role.is_default.is_(True),
  196. Role.is_deleted.is_(False),
  197. Role.status == EnabledStatus.ENABLED.value,
  198. )
  199. )
  200. if memo is not None:
  201. memo.role_loaded = True
  202. memo.role = role
  203. return role
  204. def _org_path(organization_id: int, memo: AccessMemo | None) -> str | None:
  205. if memo is not None and organization_id in memo.org_paths_by_id:
  206. return memo.org_paths_by_id[organization_id]
  207. path = db.session.scalar(
  208. select(Organization.org_path).where(
  209. Organization.id == organization_id,
  210. Organization.is_deleted.is_(False),
  211. )
  212. )
  213. if memo is not None:
  214. memo.org_paths_by_id[organization_id] = path
  215. return path
  216. def _dept_scope(
  217. source: Document,
  218. context: AuthContext,
  219. memo: AccessMemo | None = None,
  220. ) -> DeptScope:
  221. """按文档归属机构划分 OWN / OTHER(OTHER 涵盖所属部门子树外全部部门)。"""
  222. if source.organization_id is None:
  223. return DeptScope.OTHER
  224. if context.organization_id is not None:
  225. if memo is not None:
  226. doc_path = _org_path(source.organization_id, memo)
  227. user_path = _org_path(context.organization_id, memo)
  228. else:
  229. paths = db.session.execute(
  230. select(Organization.id, Organization.org_path).where(
  231. Organization.id.in_(
  232. {source.organization_id, context.organization_id}
  233. ),
  234. Organization.is_deleted.is_(False),
  235. )
  236. ).all()
  237. by_id = {organization_id: path for organization_id, path in paths}
  238. doc_path = by_id.get(source.organization_id)
  239. user_path = by_id.get(context.organization_id)
  240. if doc_path and user_path:
  241. own_prefix = user_path if user_path.endswith("/") else user_path + "/"
  242. if doc_path == user_path or doc_path.startswith(own_prefix):
  243. return DeptScope.OWN
  244. return DeptScope.OTHER
  245. def _matrix_actions(
  246. source: Document,
  247. context: AuthContext,
  248. memo: AccessMemo | None = None,
  249. ) -> frozenset[str]:
  250. """取生效角色矩阵中命中的操作位集合(密级×部门范围,实时查库)。"""
  251. role = _effective_role(context, memo)
  252. if role is None:
  253. return frozenset()
  254. scope = _dept_scope(source, context, memo)
  255. cache_key = (source.security_level, scope.value)
  256. if memo is not None and cache_key in memo.matrix_actions_by_key:
  257. return memo.matrix_actions_by_key[cache_key]
  258. row = db.session.scalar(
  259. select(RolePermission).where(
  260. RolePermission.role_id == role.id,
  261. RolePermission.security_level == source.security_level,
  262. RolePermission.dept_scope == scope.value,
  263. RolePermission.is_deleted.is_(False),
  264. )
  265. )
  266. if row is None:
  267. actions = frozenset()
  268. else:
  269. actions = frozenset(
  270. action.value
  271. for column, action in MATRIX_ACTION_COLUMNS
  272. if getattr(row, column)
  273. )
  274. if memo is not None:
  275. memo.matrix_actions_by_key[cache_key] = actions
  276. return actions
  277. def _active_permissions(
  278. source_id: int,
  279. memo: AccessMemo | None = None,
  280. ) -> list[Permission]:
  281. """权限源文档的授权列表(evaluate_plan_access 与权限摘要共享缓存)。"""
  282. if memo is not None and source_id in memo.permissions_by_source_id:
  283. return memo.permissions_by_source_id[source_id]
  284. permissions = list(
  285. db.session.scalars(
  286. select(Permission).where(
  287. Permission.document_id == source_id,
  288. Permission.is_deleted.is_(False),
  289. )
  290. ).all()
  291. )
  292. if memo is not None:
  293. memo.permissions_by_source_id[source_id] = permissions
  294. return permissions
  295. def evaluate_plan_access(
  296. document: Document,
  297. context: AuthContext,
  298. memo: AccessMemo | None = None,
  299. ) -> PlanAccess:
  300. """按固定顺序判断方案读取权限并返回有效权限快照。"""
  301. source = _permission_source(document, memo)
  302. if source is None:
  303. return PlanAccess(False, "INVALID_ROOT", None, (), False)
  304. if (
  305. context.role_code == RoleCode.USER
  306. and document.document_status != DocumentStatus.PUBLISHED.value
  307. ):
  308. return PlanAccess(False, "STATUS", source, (), False)
  309. if not _security_allowed(document, context):
  310. return PlanAccess(False, "SECURITY", source, (), False)
  311. # 超管直接放行;受限 ADMIN 落入下方矩阵+ACL 路径(与 USER 同口径,
  312. # 但不受发布状态限制——状态豁免仍是管理员语义)
  313. if _is_super_admin(context):
  314. return PlanAccess(True, None, source, (), True)
  315. matrix_actions = _matrix_actions(source, context, memo)
  316. # 借阅语义(0026):ACL 仅面向用户且限时生效,到期即失效(惰性判定,
  317. # 无需定时任务);矩阵与单文档借阅 ACL 为 OR 叠加
  318. now = datetime.now(timezone.utc).replace(tzinfo=None)
  319. matched = [
  320. permission
  321. for permission in _active_permissions(source.id, memo)
  322. if permission.subject_id == context.user_id
  323. and permission.expires_at > now
  324. ]
  325. can_view = any(permission.can_view for permission in matched)
  326. can_view = can_view or AllowedAction.VIEW.value in matrix_actions
  327. can_download = (can_view and any(
  328. permission.can_download for permission in matched
  329. )) or AllowedAction.DOWNLOAD.value in matrix_actions
  330. return PlanAccess(
  331. can_view,
  332. None if can_view else "ACL",
  333. source,
  334. tuple(matched),
  335. can_view and can_download,
  336. matrix_actions,
  337. )
  338. def plan_allowed_actions(
  339. document: Document,
  340. context: AuthContext,
  341. access: PlanAccess,
  342. ) -> list[str]:
  343. if not access.allowed:
  344. return []
  345. if not _is_super_admin(context):
  346. # 受限 ADMIN 与 USER 同口径:基础查看/下载 + 矩阵操作位 OR 叠加;
  347. # 子方案不适用授权/挂载/解除挂载
  348. actions = [AllowedAction.VIEW.value]
  349. if access.can_download:
  350. actions.append(AllowedAction.DOWNLOAD.value)
  351. applicable = None
  352. if document.document_type == DocumentType.SUB_PLAN.value:
  353. applicable = {
  354. AllowedAction.VIEW.value,
  355. AllowedAction.DOWNLOAD.value,
  356. AllowedAction.EDIT.value,
  357. AllowedAction.DELETE.value,
  358. }
  359. for action in access.matrix_actions:
  360. if applicable is not None and action not in applicable:
  361. continue
  362. if action not in actions:
  363. actions.append(action)
  364. return actions
  365. if document.document_type == DocumentType.SUB_PLAN.value:
  366. return [
  367. AllowedAction.VIEW.value,
  368. AllowedAction.DOWNLOAD.value,
  369. AllowedAction.EDIT.value,
  370. AllowedAction.DELETE.value,
  371. ]
  372. # 阶段11方案B:ADMIN恒含UNBIND(与BIND对称)。原"存在附件挂载才有位"
  373. # 是UI显影残留——附件表零挂载不渲染、子案挂载(0018)复用该位后反而出现
  374. # "仅有子案挂载的主案管理员无法解挂"的倒挂,故移除条件(顺带消除
  375. # 主案列表每行一次的count查询)。
  376. return [
  377. AllowedAction.VIEW.value,
  378. AllowedAction.DOWNLOAD.value,
  379. AllowedAction.EDIT.value,
  380. AllowedAction.CONFIG_PERMISSION.value,
  381. AllowedAction.DELETE.value,
  382. AllowedAction.BIND_ATTACHMENT.value,
  383. AllowedAction.UNBIND_ATTACHMENT.value,
  384. ]
  385. def attachment_allowed_actions(
  386. context: AuthContext,
  387. access: PlanAccess | None = None,
  388. ) -> list[str]:
  389. if context.role_code == RoleCode.ADMIN:
  390. return [
  391. AllowedAction.VIEW.value,
  392. AllowedAction.DOWNLOAD.value,
  393. AllowedAction.EDIT.value,
  394. AllowedAction.DELETE.value,
  395. ]
  396. actions: list[str] = []
  397. if access is not None and access.allowed:
  398. actions.append(AllowedAction.VIEW.value)
  399. if access.can_download:
  400. actions.append(AllowedAction.DOWNLOAD.value)
  401. return actions
  402. def evaluate_attachment_access(
  403. attachment: Document,
  404. context: AuthContext,
  405. ) -> tuple[bool, bool]:
  406. """USER只能通过至少一个有权有效主案访问共享附件。
  407. 阶段11:附件允许携带任意密级(0020放宽shape约束,存量子案保留原密级),
  408. 读路径密级前置与主案/子方案同口径——任何角色(含ADMIN)密级不足即拒绝。
  409. """
  410. if not _security_allowed(attachment, context):
  411. return False, False
  412. if context.role_code == RoleCode.ADMIN:
  413. return True, True
  414. mains = db.session.scalars(
  415. select(Document)
  416. .join(
  417. AttachmentBinding,
  418. AttachmentBinding.main_document_id == Document.id,
  419. )
  420. .where(
  421. AttachmentBinding.attachment_document_id == attachment.id,
  422. AttachmentBinding.is_deleted.is_(False),
  423. Document.document_type == DocumentType.MAIN.value,
  424. Document.is_deleted.is_(False),
  425. )
  426. .order_by(Document.id.asc())
  427. ).all()
  428. can_view = False
  429. can_download = False
  430. memo = AccessMemo()
  431. for main in mains:
  432. access = evaluate_plan_access(main, context, memo)
  433. can_view = can_view or access.allowed
  434. can_download = can_download or (access.allowed and access.can_download)
  435. return can_view, can_download
  436. def require_attachment_access(
  437. attachment: Document,
  438. context: AuthContext,
  439. *,
  440. download: bool,
  441. ) -> tuple[bool, bool]:
  442. # 密级不足优先按密级拒绝(前端据此提示"密级不足"而非"权限不足")
  443. if not _security_allowed(attachment, context):
  444. raise SecurityLevelForbiddenError()
  445. can_view, can_download = evaluate_attachment_access(attachment, context)
  446. if download and not can_download:
  447. raise DocumentDownloadForbiddenError()
  448. if not download and not can_view:
  449. raise DocumentViewForbiddenError()
  450. return can_view, can_download
  451. # 管理类操作位:命中任一即视为“对该文档有管理能力”,用于动态展示后台管理菜单。
  452. MANAGE_MATRIX_COLUMNS: tuple[str, ...] = (
  453. "can_edit",
  454. "can_delete",
  455. "can_config_permission",
  456. "can_bind_attachment",
  457. "can_unbind_attachment",
  458. )
  459. def _organization_subtree_ids(organization_id: int | None) -> set[int]:
  460. """用户机构的下属子树(含自身),与 _dept_scope 的 OWN 判定同口径。"""
  461. if organization_id is None:
  462. return set()
  463. organizations = db.session.scalars(
  464. select(Organization).where(
  465. Organization.is_deleted.is_(False),
  466. Organization.status == EnabledStatus.ENABLED.value,
  467. )
  468. ).all()
  469. by_id = {organization.id: organization for organization in organizations}
  470. root = by_id.get(organization_id)
  471. if root is None:
  472. return set()
  473. prefix = root.org_path if root.org_path.endswith("/") else root.org_path + "/"
  474. return {
  475. organization.id
  476. for organization in organizations
  477. if organization.id == organization_id
  478. or (organization.org_path or "").startswith(prefix)
  479. }
  480. def has_any_manage_action(context: AuthContext) -> bool:
  481. """用户是否对任一已发布主案具备管理类操作(编辑/删除/授权/挂载/解除挂载)。
  482. 与 plan_allowed_actions 同源:超管恒真;受限 ADMIN 与 USER 由角色
  483. 权限矩阵推导——矩阵行命中(密级×部门范围)且存在“可查看”的对应
  484. 主案时管理位才生效。只看主案即可完整覆盖子案:子案矩阵取自其根
  485. 主案的同一行,主案命中子案必命中。用于登录/会话接口动态计算
  486. allowedModules 中的 BACKEND_MANAGEMENT(ADMIN 因管理位独立、
  487. 审计等模块恒可见,不依赖本函数)。
  488. """
  489. if _is_super_admin(context):
  490. return True
  491. role = _effective_role(context)
  492. if role is None:
  493. return False
  494. rows = db.session.scalars(
  495. select(RolePermission).where(
  496. RolePermission.role_id == role.id,
  497. RolePermission.is_deleted.is_(False),
  498. )
  499. ).all()
  500. candidates = [
  501. row
  502. for row in rows
  503. if any(getattr(row, column) for column in MANAGE_MATRIX_COLUMNS)
  504. ]
  505. if not candidates:
  506. return False
  507. # 部门范围两区集合:OWN 子树 / 其余(含无归属)。
  508. own_ids = _organization_subtree_ids(context.organization_id)
  509. # 借阅语义(0026):ACL 仅面向用户且限时生效(到期即失效)
  510. acl_view_granted = exists(
  511. select(Permission.id).where(
  512. Permission.document_id == Document.id,
  513. Permission.is_deleted.is_(False),
  514. Permission.can_view.is_(True),
  515. Permission.subject_id == context.user_id,
  516. Permission.expires_at
  517. > datetime.now(timezone.utc).replace(tzinfo=None),
  518. )
  519. )
  520. for row in candidates:
  521. scope = DeptScope(row.dept_scope)
  522. if scope == DeptScope.OWN:
  523. scope_filter = Document.organization_id.in_(own_ids)
  524. else:
  525. scope_filter = or_(
  526. Document.organization_id.is_(None),
  527. Document.organization_id.not_in(own_ids),
  528. )
  529. # 查看权两来源(与 evaluate_plan_access 同口径):
  530. # ① 本矩阵行勾选查看;② 文档级借阅 ACL 授权查看。
  531. view_sources: list = [acl_view_granted]
  532. if row.can_view:
  533. view_sources.append(true())
  534. hit = db.session.scalar(
  535. select(Document.id).where(
  536. Document.is_deleted.is_(False),
  537. Document.document_status == DocumentStatus.PUBLISHED.value,
  538. Document.document_type == DocumentType.MAIN.value,
  539. Document.security_level == row.security_level,
  540. scope_filter,
  541. or_(*view_sources),
  542. ).limit(1)
  543. )
  544. if hit is not None:
  545. return True
  546. return False
  547. def can_upload_plan(
  548. context: AuthContext,
  549. security_level: str,
  550. organization_id: int | None,
  551. ) -> bool:
  552. """主案上传权限(契约1.31):方案权限矩阵 can_upload 列。
  553. 超管恒可;文档归属本部门子树(或无归属)按 OWN 行判定,
  554. 归属其他部门按 OTHER 行判定(矩阵校验 OTHER 行 can_upload 恒 0,
  555. 即跨部门上传恒拒绝)。
  556. """
  557. if _is_super_admin(context):
  558. return True
  559. role = _effective_role(context)
  560. if role is None:
  561. return False
  562. own_ids = _organization_subtree_ids(context.organization_id)
  563. scope = (
  564. DeptScope.OWN
  565. if organization_id is None or organization_id in own_ids
  566. else DeptScope.OTHER
  567. )
  568. row = db.session.scalar(
  569. select(RolePermission).where(
  570. RolePermission.role_id == role.id,
  571. RolePermission.security_level == security_level,
  572. RolePermission.dept_scope == scope.value,
  573. RolePermission.is_deleted.is_(False),
  574. )
  575. )
  576. return row is not None and bool(row.can_upload)
  577. def has_any_upload_permission(role_id: int | None) -> bool:
  578. """会话 permUpload 位矩阵查询(契约1.31):任一 OWN 行 can_upload=True。
  579. 仅查生效角色的矩阵行,超管特判由调用方三元式处理(与既有 perm 位
  580. 组装同风格)。
  581. """
  582. if role_id is None:
  583. return False
  584. row = db.session.scalar(
  585. select(RolePermission).where(
  586. RolePermission.role_id == role_id,
  587. RolePermission.dept_scope == DeptScope.OWN.value,
  588. RolePermission.can_upload.is_(True),
  589. RolePermission.is_deleted.is_(False),
  590. )
  591. )
  592. return row is not None