test_role_authorization.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. """需求16 矩阵判定融合测试(authorization_service)。
  2. 覆盖:
  3. - OWN/OTHER 部门两分类矩阵判定(OTHER 涵盖所属部门子树外全部部门)
  4. - 矩阵与单文档 ACL 的 OR 叠加
  5. - 密级前置与状态前置不可被矩阵绕过
  6. - 无角色→默认角色→无默认则贡献为空
  7. - 显式角色停用后矩阵贡献为空且不回退默认
  8. - plan_allowed_actions 的矩阵操作位叠加(子方案过滤授权/挂载位)
  9. - 受限管理员(非内置 ADMIN)文档权限同 USER 按矩阵判定,超管保持全权
  10. """
  11. from __future__ import annotations
  12. from datetime import datetime, timedelta, timezone
  13. import pytest
  14. from dms.common.enums import AllowedAction, RoleCode, SecurityLevel
  15. from dms.common.errors import (
  16. DocumentActionForbiddenError,
  17. SecurityLevelForbiddenError,
  18. )
  19. from dms.extensions import db
  20. from dms.models import (
  21. Category,
  22. Document,
  23. Organization,
  24. Permission,
  25. Role,
  26. RolePermission,
  27. User,
  28. )
  29. from dms.security.auth_context import AuthContext
  30. from dms.services.authorization_service import (
  31. evaluate_plan_access,
  32. plan_allowed_actions,
  33. require_plan_action,
  34. )
  35. def _make_document(name: str, *, org: Organization, security: str = "SECRET",
  36. status: str = "PUBLISHED",
  37. document_type: str = "MAIN",
  38. parent: Document | None = None,
  39. root: Document | None = None,
  40. attachment_type: str | None = None) -> Document:
  41. # shape v3(迁移0026):MAIN/SUB_PLAN 必须挂分类且无附件类型,
  42. # ATTACHMENT 必须有附件类型且不挂分类。懒建专用分类补齐约束字段。
  43. category = db.session.scalar(
  44. db.select(Category).where(Category.category_code == "MATRIX_TEST")
  45. )
  46. if category is None:
  47. category = Category(
  48. category_code="MATRIX_TEST",
  49. category_name="矩阵测试分类",
  50. category_type="OTHER",
  51. category_path="/矩阵测试分类",
  52. sort_no=99,
  53. document_count=0,
  54. status="ENABLED",
  55. )
  56. db.session.add(category)
  57. db.session.flush()
  58. item = Document(
  59. document_name=name,
  60. summary=f"{name}摘要",
  61. document_type=document_type,
  62. document_status=status,
  63. security_level=security,
  64. attachment_type=attachment_type,
  65. organization_id=org.id,
  66. organization_name=org.org_name,
  67. category_id=(
  68. category.id if document_type != "ATTACHMENT" else None
  69. ),
  70. category_name=(
  71. category.category_name if document_type != "ATTACHMENT" else None
  72. ),
  73. category_path=(
  74. category.category_path if document_type != "ATTACHMENT" else None
  75. ),
  76. parent_document_id=parent.id if parent else None,
  77. root_document_id=root.id if root else None,
  78. tags=["矩阵测试"],
  79. original_file_name=f"{name}.docx",
  80. file_relative_path=f"original/MATRIX_{name}.docx",
  81. file_extension="docx",
  82. mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  83. file_size=128,
  84. file_hash="b" * 64,
  85. search_text=f"{name} 矩阵测试",
  86. created_by_name="系统管理员",
  87. updated_by_name="系统管理员",
  88. )
  89. db.session.add(item)
  90. db.session.flush()
  91. return item
  92. def _make_role(name: str, *, is_default: bool = False,
  93. status: str = "ENABLED", actor_id: int | None = None) -> Role:
  94. role = Role(
  95. role_name=name,
  96. sort_no=10,
  97. status=status,
  98. is_default=is_default,
  99. created_by=actor_id,
  100. updated_by=actor_id,
  101. )
  102. db.session.add(role)
  103. db.session.flush()
  104. return role
  105. def _matrix_row(role_id: int, level: str, scope: str, **flags) -> None:
  106. db.session.add(
  107. RolePermission(
  108. role_id=role_id,
  109. security_level=level,
  110. dept_scope=scope,
  111. **flags,
  112. )
  113. )
  114. def _user_context(user: User) -> AuthContext:
  115. return AuthContext(
  116. user_id=user.id,
  117. username=user.username,
  118. real_name=user.real_name,
  119. organization_id=user.organization_id,
  120. organization_name=user.organization_name,
  121. role_code=RoleCode(user.role_code),
  122. security_level=SecurityLevel(user.security_level),
  123. auth_version=user.auth_version,
  124. )
  125. @pytest.fixture()
  126. def matrix_env(b2_app, role_cleanup):
  127. """在 b2_app 基础上构造矩阵判定数据:机构、用户、文档。"""
  128. with b2_app.app_context():
  129. ops = db.session.scalar(
  130. db.select(Organization).where(Organization.org_code == "ORG_OPS")
  131. )
  132. comms = db.session.scalar(
  133. db.select(Organization).where(Organization.org_code == "ORG_COMMS")
  134. )
  135. user = db.session.scalar(
  136. db.select(User).where(User.username == "user")
  137. )
  138. assert ops and comms and user
  139. db.session.commit()
  140. ids = {"ops": ops.id, "comms": comms.id, "user": user.id}
  141. return b2_app, ids
  142. @pytest.mark.integration
  143. def test_own_scope_matrix_grants_view_when_acl_denies(matrix_env):
  144. app, ids = matrix_env
  145. with app.app_context():
  146. ops = db.session.get(Organization, ids["ops"])
  147. user = db.session.get(User, ids["user"])
  148. role = _make_role("作战部角色", actor_id=user.id)
  149. user.role_id = role.id
  150. _matrix_row(
  151. role.id, "SECRET", "OWN", can_view=True, can_download=True
  152. )
  153. # 无借阅 ACL 行:纯矩阵放行
  154. doc = _make_document("矩阵本部门主案", org=ops)
  155. db.session.commit()
  156. context = _user_context(user)
  157. access = evaluate_plan_access(doc, context)
  158. assert access.allowed is True
  159. assert access.can_download is True
  160. assert "VIEW" in access.matrix_actions
  161. assert "DOWNLOAD" in access.matrix_actions
  162. @pytest.mark.integration
  163. def test_other_scope_without_matrix_denies(matrix_env):
  164. app, ids = matrix_env
  165. with app.app_context():
  166. comms = db.session.get(Organization, ids["comms"])
  167. user = db.session.get(User, ids["user"])
  168. # 文档挂通信部:对作战部用户是 OTHER,且未配置部门
  169. doc = _make_document("矩阵其他部门主案", org=comms)
  170. db.session.commit()
  171. context = _user_context(user)
  172. access = evaluate_plan_access(doc, context)
  173. assert access.allowed is False
  174. assert access.reason == "ACL"
  175. @pytest.mark.integration
  176. def test_other_scope_matrix_grants_cross_department_view(matrix_env):
  177. app, ids = matrix_env
  178. with app.app_context():
  179. comms = db.session.get(Organization, ids["comms"])
  180. user = db.session.get(User, ids["user"])
  181. role = _make_role("跨部门角色", actor_id=user.id)
  182. user.role_id = role.id
  183. _matrix_row(role.id, "SECRET", "OTHER", can_view=True)
  184. doc = _make_document("矩阵其他部门主案", org=comms)
  185. db.session.commit()
  186. context = _user_context(user)
  187. access = evaluate_plan_access(doc, context)
  188. assert access.allowed is True
  189. assert "VIEW" in access.matrix_actions
  190. @pytest.mark.integration
  191. def test_security_and_status_precedence_over_matrix(matrix_env):
  192. app, ids = matrix_env
  193. with app.app_context():
  194. ops = db.session.get(Organization, ids["ops"])
  195. user = db.session.get(User, ids["user"]) # 密级 SECRET
  196. role = _make_role("越权角色", actor_id=user.id)
  197. _matrix_row(role.id, "TOP_SECRET", "OWN", can_view=True)
  198. top_secret_doc = _make_document(
  199. "矩阵绝密主案", org=ops, security="TOP_SECRET"
  200. )
  201. draft_doc = _make_document(
  202. "矩阵草稿主案", org=ops, security="SECRET", status="DRAFT"
  203. )
  204. db.session.commit()
  205. context = _user_context(user)
  206. assert evaluate_plan_access(top_secret_doc, context).reason == "SECURITY"
  207. assert evaluate_plan_access(draft_doc, context).reason == "STATUS"
  208. @pytest.mark.integration
  209. def test_default_role_applies_and_disabled_role_yields_nothing(matrix_env):
  210. app, ids = matrix_env
  211. with app.app_context():
  212. ops = db.session.get(Organization, ids["ops"])
  213. user = db.session.get(User, ids["user"]) # 未挂角色
  214. default_role = _make_role("默认角色", is_default=True, actor_id=user.id)
  215. _matrix_row(default_role.id, "SECRET", "OWN", can_view=True)
  216. doc = _make_document("默认角色主案", org=ops)
  217. db.session.commit()
  218. context = _user_context(user)
  219. assert evaluate_plan_access(doc, context).allowed is True
  220. # 默认角色停用:无显式角色且无默认 → 矩阵贡献为空
  221. default_role.status = "DISABLED"
  222. db.session.commit()
  223. denied = evaluate_plan_access(doc, context)
  224. assert denied.allowed is False
  225. # 显式挂停用角色:不回退默认角色
  226. disabled_role = _make_role(
  227. "停用角色", status="DISABLED", actor_id=user.id
  228. )
  229. _matrix_row(disabled_role.id, "SECRET", "OWN", can_view=True)
  230. user.role_id = disabled_role.id
  231. db.session.commit()
  232. assert evaluate_plan_access(doc, context).allowed is False
  233. @pytest.mark.integration
  234. def test_matrix_actions_expand_allowed_actions(matrix_env):
  235. app, ids = matrix_env
  236. with app.app_context():
  237. ops = db.session.get(Organization, ids["ops"])
  238. user = db.session.get(User, ids["user"])
  239. role = _make_role("完整权限角色", actor_id=user.id)
  240. user.role_id = role.id
  241. _matrix_row(
  242. role.id,
  243. "SECRET",
  244. "OWN",
  245. can_view=True,
  246. can_download=True,
  247. can_edit=True,
  248. can_delete=True,
  249. can_config_permission=True,
  250. can_bind_attachment=True,
  251. can_unbind_attachment=True,
  252. )
  253. main_doc = _make_document("矩阵操作位主案", org=ops)
  254. sub_doc = _make_document(
  255. "矩阵操作位子案",
  256. org=ops,
  257. document_type="SUB_PLAN",
  258. parent=main_doc,
  259. root=main_doc,
  260. )
  261. db.session.commit()
  262. context = _user_context(user)
  263. main_actions = plan_allowed_actions(
  264. main_doc, context, evaluate_plan_access(main_doc, context)
  265. )
  266. assert set(main_actions) >= {
  267. "VIEW",
  268. "DOWNLOAD",
  269. "EDIT",
  270. "DELETE",
  271. "CONFIG_PERMISSION",
  272. }
  273. sub_actions = plan_allowed_actions(
  274. sub_doc, context, evaluate_plan_access(sub_doc, context)
  275. )
  276. # 子方案过滤授权/挂载/解除挂载
  277. assert "CONFIG_PERMISSION" not in sub_actions
  278. assert "BIND_ATTACHMENT" not in sub_actions
  279. assert "UNBIND_ATTACHMENT" not in sub_actions
  280. assert {"VIEW", "DOWNLOAD", "EDIT", "DELETE"} <= set(sub_actions)
  281. # ===== 动态管理菜单(allowedModules,契约1.35菜单口径) ========================
  282. # 菜单可见性完全由角色权限决定(纯矩阵/管理位判定,不查文档存在性);
  283. # 页签内按钮与接口鉴权仍按 evaluate_plan_access 实时判定。
  284. from dms.services.user_service import allowed_modules_for_user
  285. @pytest.mark.integration
  286. def test_builtin_admin_keeps_full_modules(matrix_env):
  287. app, ids = matrix_env
  288. with app.app_context():
  289. admin = db.session.scalar(
  290. db.select(User).where(User.username == "admin")
  291. )
  292. assert allowed_modules_for_user(admin) == [
  293. "BACKEND_MANAGEMENT",
  294. "AUDIT_LOG",
  295. "ORGANIZATION_USER_MANAGEMENT",
  296. "SYSTEM_MANAGEMENT",
  297. ]
  298. @pytest.mark.integration
  299. def test_view_only_matrix_renders_browser_only(matrix_env):
  300. app, ids = matrix_env
  301. with app.app_context():
  302. user = db.session.get(User, ids["user"])
  303. role = _make_role("只读角色", is_default=True, actor_id=user.id)
  304. # 矩阵只有查看/下载位,无任何管理类位
  305. for scope in ("OWN", "OTHER"):
  306. _matrix_row(
  307. role.id, "SECRET", scope, can_view=True, can_download=True
  308. )
  309. db.session.commit()
  310. assert allowed_modules_for_user(user) == ["DOCUMENT_BROWSER"]
  311. @pytest.mark.integration
  312. def test_manage_matrix_renders_backend_instead_of_browser(matrix_env):
  313. app, ids = matrix_env
  314. with app.app_context():
  315. user = db.session.get(User, ids["user"])
  316. role = _make_role("编辑角色", is_default=True, actor_id=user.id)
  317. _matrix_row(
  318. role.id, "SECRET", "OWN", can_view=True, can_edit=True
  319. )
  320. db.session.commit()
  321. # 管理类列命中:渲染方案管理,不再渲染方案浏览
  322. assert allowed_modules_for_user(user) == ["BACKEND_MANAGEMENT"]
  323. @pytest.mark.integration
  324. def test_upload_only_matrix_renders_backend(matrix_env):
  325. app, ids = matrix_env
  326. with app.app_context():
  327. user = db.session.get(User, ids["user"])
  328. role = _make_role("仅上传角色", is_default=True, actor_id=user.id)
  329. # 仅勾上传位,无查看/下载/编辑等任何其他位
  330. _matrix_row(role.id, "SECRET", "OWN", can_upload=True)
  331. db.session.commit()
  332. assert allowed_modules_for_user(user) == ["BACKEND_MANAGEMENT"]
  333. @pytest.mark.integration
  334. def test_other_scope_manage_bit_without_documents_renders_backend(matrix_env):
  335. """纯矩阵判定:OTHER 行勾管理位同样命中,与文档归属/存在无关。"""
  336. app, ids = matrix_env
  337. with app.app_context():
  338. user = db.session.get(User, ids["user"])
  339. role = _make_role("跨部门编辑角色", is_default=True, actor_id=user.id)
  340. # 仅 OTHER × TOP_SECRET 勾编辑:不创建任何文档也应命中(空库不丢菜单)
  341. _matrix_row(
  342. role.id, "TOP_SECRET", "OTHER", can_view=True, can_edit=True
  343. )
  344. db.session.commit()
  345. assert allowed_modules_for_user(user) == ["BACKEND_MANAGEMENT"]
  346. @pytest.mark.integration
  347. def test_manage_bit_without_view_still_renders_backend(matrix_env):
  348. """编辑位开启但查看位全关:菜单口径仍按管理类列命中。"""
  349. app, ids = matrix_env
  350. with app.app_context():
  351. user = db.session.get(User, ids["user"])
  352. role = _make_role("无查看编辑角色", is_default=True, actor_id=user.id)
  353. _matrix_row(role.id, "SECRET", "OWN", can_edit=True)
  354. db.session.commit()
  355. assert allowed_modules_for_user(user) == ["BACKEND_MANAGEMENT"]
  356. @pytest.mark.integration
  357. def test_empty_matrix_and_bits_falls_back_to_browser(matrix_env):
  358. app, ids = matrix_env
  359. with app.app_context():
  360. user = db.session.get(User, ids["user"])
  361. # 角色存在但矩阵与管理位全空:兜底渲染方案浏览
  362. _make_role("空角色", is_default=True, actor_id=user.id)
  363. db.session.commit()
  364. assert allowed_modules_for_user(user) == ["DOCUMENT_BROWSER"]
  365. @pytest.mark.integration
  366. def test_user_role_manage_bits_get_no_directory_menu(matrix_env):
  367. """管理位仅 ADMIN 类别生效:USER 类别勾管理位不出机构用户页签。"""
  368. app, ids = matrix_env
  369. with app.app_context():
  370. user = db.session.get(User, ids["user"])
  371. role = _make_role("USER类别管理位角色", is_default=True, actor_id=user.id)
  372. role.manage_org_edit = True
  373. role.manage_user_edit = True
  374. role.manage_role_edit = True
  375. _matrix_row(role.id, "SECRET", "OWN", can_view=True)
  376. db.session.commit()
  377. # 管理位被类别限制拦下:仅方案浏览
  378. assert allowed_modules_for_user(user) == ["DOCUMENT_BROWSER"]
  379. # ===== 写路径集中鉴权 require_plan_action ====================================
  380. @pytest.mark.integration
  381. def test_require_plan_action_allows_matrix_edit(matrix_env):
  382. app, ids = matrix_env
  383. with app.app_context():
  384. ops = db.session.get(Organization, ids["ops"])
  385. user = db.session.get(User, ids["user"])
  386. role = _make_role("写路径编辑角色", is_default=True, actor_id=user.id)
  387. _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_edit=True)
  388. doc = _make_document("写路径可编辑主案", org=ops)
  389. db.session.commit()
  390. require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
  391. @pytest.mark.integration
  392. def test_require_plan_action_denies_without_bit(matrix_env):
  393. app, ids = matrix_env
  394. with app.app_context():
  395. ops = db.session.get(Organization, ids["ops"])
  396. user = db.session.get(User, ids["user"])
  397. role = _make_role("写路径只读角色", is_default=True, actor_id=user.id)
  398. _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_download=True)
  399. doc = _make_document("写路径只读主案", org=ops)
  400. db.session.commit()
  401. with pytest.raises(DocumentActionForbiddenError):
  402. require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
  403. with pytest.raises(DocumentActionForbiddenError):
  404. require_plan_action(doc, _user_context(user), AllowedAction.DELETE)
  405. with pytest.raises(DocumentActionForbiddenError):
  406. require_plan_action(
  407. doc, _user_context(user), AllowedAction.CONFIG_PERMISSION
  408. )
  409. @pytest.mark.integration
  410. def test_require_plan_action_security_gate_precedes_bits(matrix_env):
  411. app, ids = matrix_env
  412. with app.app_context():
  413. ops = db.session.get(Organization, ids["ops"])
  414. user = db.session.get(User, ids["user"])
  415. role = _make_role("写路径高密角色", is_default=True, actor_id=user.id)
  416. _matrix_row(role.id, "TOP_SECRET", "OWN", can_view=True, can_edit=True)
  417. # 用户密级 SECRET:即使矩阵在 TOP_SECRET 行勾了编辑,密级前置直接拒绝
  418. doc = _make_document("绝密主案", org=ops, security="TOP_SECRET")
  419. db.session.commit()
  420. with pytest.raises(SecurityLevelForbiddenError):
  421. require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
  422. @pytest.mark.integration
  423. def test_require_plan_action_admin_passes_all(matrix_env):
  424. app, ids = matrix_env
  425. with app.app_context():
  426. ops = db.session.get(Organization, ids["ops"])
  427. admin = db.session.scalar(
  428. db.select(User).where(User.username == "admin")
  429. )
  430. doc = _make_document("管理员主案", org=ops, security="SECRET")
  431. db.session.commit()
  432. for action in (
  433. AllowedAction.EDIT,
  434. AllowedAction.DELETE,
  435. AllowedAction.CONFIG_PERMISSION,
  436. AllowedAction.BIND_ATTACHMENT,
  437. AllowedAction.UNBIND_ATTACHMENT,
  438. ):
  439. require_plan_action(doc, _user_context(admin), action)
  440. @pytest.mark.integration
  441. def test_require_plan_action_attachment_admin_only(matrix_env):
  442. app, ids = matrix_env
  443. with app.app_context():
  444. ops = db.session.get(Organization, ids["ops"])
  445. user = db.session.get(User, ids["user"])
  446. admin = db.session.scalar(
  447. db.select(User).where(User.username == "admin")
  448. )
  449. role = _make_role("附件全权角色", is_default=True, actor_id=user.id)
  450. for scope in ("OWN", "OTHER"):
  451. _matrix_row(
  452. role.id, "PUBLIC", scope,
  453. can_view=True, can_download=True, can_edit=True, can_delete=True,
  454. )
  455. att = _make_document(
  456. "共享附件", org=ops, security="PUBLIC",
  457. document_type="ATTACHMENT", attachment_type="TRAINING_MATERIAL",
  458. )
  459. db.session.commit()
  460. with pytest.raises(DocumentActionForbiddenError):
  461. require_plan_action(att, _user_context(user), AllowedAction.EDIT)
  462. require_plan_action(att, _user_context(admin), AllowedAction.DELETE)
  463. @pytest.mark.integration
  464. def test_require_plan_action_allows_edit_without_view(matrix_env):
  465. """契约1.36完全拆分:仅勾编辑(未勾查看)时编辑位独立生效。"""
  466. app, ids = matrix_env
  467. with app.app_context():
  468. ops = db.session.get(Organization, ids["ops"])
  469. user = db.session.get(User, ids["user"])
  470. role = _make_role("无查看编辑角色", is_default=True, actor_id=user.id)
  471. _matrix_row(role.id, "SECRET", "OWN", can_edit=True)
  472. doc = _make_document("无查看可编辑主案", org=ops)
  473. db.session.commit()
  474. context = _user_context(user)
  475. require_plan_action(doc, context, AllowedAction.EDIT)
  476. access = evaluate_plan_access(doc, context)
  477. # 查看/下载均未命中:操作位清单只含 EDIT
  478. assert plan_allowed_actions(doc, context, access) == ["EDIT"]
  479. with pytest.raises(DocumentActionForbiddenError):
  480. require_plan_action(doc, context, AllowedAction.DELETE)
  481. @pytest.mark.integration
  482. def test_download_bit_without_view(matrix_env):
  483. """契约1.36完全拆分:仅勾下载(未勾查看)时下载位独立生效。"""
  484. app, ids = matrix_env
  485. with app.app_context():
  486. ops = db.session.get(Organization, ids["ops"])
  487. user = db.session.get(User, ids["user"])
  488. role = _make_role("无查看下载角色", is_default=True, actor_id=user.id)
  489. _matrix_row(role.id, "SECRET", "OWN", can_download=True)
  490. doc = _make_document("无查看可下载主案", org=ops)
  491. db.session.commit()
  492. context = _user_context(user)
  493. access = evaluate_plan_access(doc, context)
  494. assert access.allowed is False
  495. assert access.can_download is True
  496. assert plan_allowed_actions(doc, context, access) == ["DOWNLOAD"]
  497. @pytest.mark.integration
  498. def test_acl_download_without_view(matrix_env):
  499. """契约1.36完全拆分:借阅ACL只勾下载(未勾查看)同样独立生效。"""
  500. app, ids = matrix_env
  501. with app.app_context():
  502. ops = db.session.get(Organization, ids["ops"])
  503. user = db.session.get(User, ids["user"])
  504. _make_role("ACL无矩阵角色", is_default=True, actor_id=user.id)
  505. doc = _make_document("ACL下载主案", org=ops)
  506. db.session.add(
  507. Permission(
  508. document_id=doc.id,
  509. subject_id=user.id,
  510. subject_name=user.real_name,
  511. can_view=False,
  512. can_download=True,
  513. expires_at=(
  514. datetime.now(timezone.utc).replace(tzinfo=None)
  515. + timedelta(days=1)
  516. ),
  517. created_by=user.id,
  518. updated_by=user.id,
  519. )
  520. )
  521. db.session.commit()
  522. context = _user_context(user)
  523. access = evaluate_plan_access(doc, context)
  524. assert access.allowed is False
  525. assert access.can_download is True
  526. assert plan_allowed_actions(doc, context, access) == ["DOWNLOAD"]
  527. # ===== 受限管理员(非内置 ADMIN)文档权限按角色矩阵判定 ========================
  528. from dms.security.passwords import hash_password
  529. def _make_limited_admin(org: Organization, *, username: str = "lim_admin",
  530. role_id: int | None = None) -> User:
  531. """非内置管理员:role_code=ADMIN 且 username != admin,可关联角色。"""
  532. admin = User(
  533. username=username,
  534. password_hash=hash_password("Initial@2026"),
  535. real_name="受限管理员",
  536. organization_id=org.id,
  537. organization_name=org.org_name,
  538. role_code="ADMIN",
  539. security_level="SECRET",
  540. status="ENABLED",
  541. role_id=role_id,
  542. )
  543. db.session.add(admin)
  544. db.session.flush()
  545. return admin
  546. @pytest.mark.integration
  547. def test_limited_admin_actions_follow_matrix(matrix_env):
  548. app, ids = matrix_env
  549. with app.app_context():
  550. ops = db.session.get(Organization, ids["ops"])
  551. role = _make_role("受限管理员角色")
  552. lim_admin = _make_limited_admin(ops, role_id=role.id)
  553. _matrix_row(
  554. role.id, "SECRET", "OWN",
  555. can_view=True, can_download=True, can_edit=True,
  556. )
  557. doc = _make_document("受限管理员矩阵主案", org=ops)
  558. db.session.commit()
  559. context = _user_context(lim_admin)
  560. access = evaluate_plan_access(doc, context)
  561. assert access.allowed is True
  562. # 矩阵授予什么就有什么:未授予的删除/授权/挂载位不再因 ADMIN 身份放行
  563. assert set(plan_allowed_actions(doc, context, access)) == {
  564. "VIEW", "DOWNLOAD", "EDIT",
  565. }
  566. require_plan_action(doc, context, AllowedAction.EDIT)
  567. with pytest.raises(DocumentActionForbiddenError):
  568. require_plan_action(doc, context, AllowedAction.DELETE)
  569. # 管理菜单按纯矩阵推导(契约1.35):勾了 EDIT 即渲染方案管理;
  570. # 管理位独立于文档矩阵(契约1.23):该角色未授 manage_audit_log/
  571. # manage_system,也无机构/用户/角色管理位 → 仅方案管理页签
  572. assert allowed_modules_for_user(lim_admin) == ["BACKEND_MANAGEMENT"]
  573. @pytest.mark.integration
  574. def test_limited_admin_without_role_follows_default(matrix_env):
  575. app, ids = matrix_env
  576. with app.app_context():
  577. ops = db.session.get(Organization, ids["ops"])
  578. # 未关联角色 → 回退默认角色(与 USER 语义一致)
  579. default_role = _make_role("受限管理员默认角色", is_default=True)
  580. lim_admin = _make_limited_admin(ops)
  581. _matrix_row(default_role.id, "SECRET", "OWN", can_view=True)
  582. doc = _make_document("受限管理员默认角色主案", org=ops)
  583. db.session.commit()
  584. context = _user_context(lim_admin)
  585. access = evaluate_plan_access(doc, context)
  586. assert access.allowed is True
  587. # 默认角色只有查看位:编辑拒绝
  588. with pytest.raises(DocumentActionForbiddenError):
  589. require_plan_action(doc, context, AllowedAction.EDIT)
  590. @pytest.mark.integration
  591. def test_limited_admin_keeps_status_exemption_security_gate_and_attachment(matrix_env):
  592. app, ids = matrix_env
  593. with app.app_context():
  594. ops = db.session.get(Organization, ids["ops"])
  595. # 无角色且无默认角色:矩阵贡献为空
  596. lim_admin = _make_limited_admin(ops)
  597. draft_doc = _make_document(
  598. "受限管理员草稿主案", org=ops, status="DRAFT"
  599. )
  600. top_secret_doc = _make_document(
  601. "受限管理员绝密主案", org=ops, security="TOP_SECRET"
  602. )
  603. att = _make_document(
  604. "受限管理员共享附件", org=ops, security="PUBLIC",
  605. document_type="ATTACHMENT", attachment_type="TRAINING_MATERIAL",
  606. )
  607. db.session.commit()
  608. context = _user_context(lim_admin)
  609. # 草稿不按状态拒绝(状态豁免仍是管理员语义),矩阵/ACL 均无 → ACL
  610. draft_access = evaluate_plan_access(draft_doc, context)
  611. assert draft_access.allowed is False
  612. assert draft_access.reason == "ACL"
  613. # 密级前置任何角色不可绕过
  614. assert evaluate_plan_access(top_secret_doc, context).reason == "SECURITY"
  615. # 共享附件维护仍是管理员级语义,不按矩阵限制
  616. require_plan_action(att, context, AllowedAction.EDIT)