test_role_authorization.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. """需求16 矩阵判定融合测试(authorization_service)。
  2. 覆盖:
  3. - OWN/OTHER 部门两分类矩阵判定(OTHER 涵盖所属部门子树外全部部门)
  4. - 矩阵与单文档 ACL 的 OR 叠加
  5. - 密级前置与状态前置不可被矩阵绕过
  6. - 无角色→默认角色→无默认则贡献为空
  7. - 显式角色停用后矩阵贡献为空且不回退默认
  8. - plan_allowed_actions 的矩阵操作位叠加(子方案过滤授权/挂载位)
  9. """
  10. from __future__ import annotations
  11. import pytest
  12. from dms.common.enums import AllowedAction, RoleCode, SecurityLevel
  13. from dms.common.errors import (
  14. DocumentActionForbiddenError,
  15. SecurityLevelForbiddenError,
  16. )
  17. from dms.extensions import db
  18. from dms.models import Document, Organization, Role, RolePermission, User
  19. from dms.security.auth_context import AuthContext
  20. from dms.services.authorization_service import (
  21. evaluate_plan_access,
  22. has_any_manage_action,
  23. plan_allowed_actions,
  24. require_plan_action,
  25. )
  26. def _make_document(name: str, *, org: Organization, security: str = "SECRET",
  27. visibility: str = "ORGANIZATION", status: str = "PUBLISHED",
  28. document_type: str = "MAIN") -> Document:
  29. item = Document(
  30. document_name=name,
  31. summary=f"{name}摘要",
  32. document_type=document_type,
  33. document_status=status,
  34. security_level=security,
  35. visibility_type=visibility,
  36. organization_id=org.id,
  37. organization_name=org.org_name,
  38. tags=["矩阵测试"],
  39. original_file_name=f"{name}.docx",
  40. file_relative_path=f"original/MATRIX_{name}.docx",
  41. file_extension="docx",
  42. mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  43. file_size=128,
  44. file_hash="b" * 64,
  45. search_text=f"{name} 矩阵测试",
  46. created_by_name="系统管理员",
  47. updated_by_name="系统管理员",
  48. )
  49. db.session.add(item)
  50. db.session.flush()
  51. return item
  52. def _make_role(name: str, *, is_default: bool = False,
  53. status: str = "ENABLED", actor_id: int | None = None) -> Role:
  54. role = Role(
  55. role_name=name,
  56. sort_no=10,
  57. status=status,
  58. is_default=is_default,
  59. created_by=actor_id,
  60. updated_by=actor_id,
  61. )
  62. db.session.add(role)
  63. db.session.flush()
  64. return role
  65. def _matrix_row(role_id: int, level: str, scope: str, **flags) -> None:
  66. db.session.add(
  67. RolePermission(
  68. role_id=role_id,
  69. security_level=level,
  70. dept_scope=scope,
  71. **flags,
  72. )
  73. )
  74. def _user_context(user: User) -> AuthContext:
  75. return AuthContext(
  76. user_id=user.id,
  77. username=user.username,
  78. real_name=user.real_name,
  79. organization_id=user.organization_id,
  80. organization_name=user.organization_name,
  81. role_code=RoleCode(user.role_code),
  82. security_level=SecurityLevel(user.security_level),
  83. auth_version=user.auth_version,
  84. )
  85. @pytest.fixture()
  86. def matrix_env(b2_app, role_cleanup):
  87. """在 b2_app 基础上构造矩阵判定数据:机构、用户、文档。"""
  88. with b2_app.app_context():
  89. ops = db.session.scalar(
  90. db.select(Organization).where(Organization.org_code == "ORG_OPS")
  91. )
  92. comms = db.session.scalar(
  93. db.select(Organization).where(Organization.org_code == "ORG_COMMS")
  94. )
  95. user = db.session.scalar(
  96. db.select(User).where(User.username == "user")
  97. )
  98. assert ops and comms and user
  99. db.session.commit()
  100. ids = {"ops": ops.id, "comms": comms.id, "user": user.id}
  101. return b2_app, ids
  102. @pytest.mark.integration
  103. def test_own_scope_matrix_grants_view_when_acl_denies(matrix_env):
  104. app, ids = matrix_env
  105. with app.app_context():
  106. ops = db.session.get(Organization, ids["ops"])
  107. user = db.session.get(User, ids["user"])
  108. role = _make_role("作战部角色", actor_id=user.id)
  109. _matrix_row(
  110. role.id, "SECRET", "OWN", can_view=True, can_download=True
  111. )
  112. # ORGANIZATION 可见性且无匹配 ACL 行:纯矩阵放行
  113. doc = _make_document("矩阵本部门主案", org=ops, visibility="ORGANIZATION")
  114. db.session.commit()
  115. context = _user_context(user)
  116. access = evaluate_plan_access(doc, context)
  117. assert access.allowed is True
  118. assert access.can_download is True
  119. assert "VIEW" in access.matrix_actions
  120. assert "DOWNLOAD" in access.matrix_actions
  121. @pytest.mark.integration
  122. def test_other_scope_without_matrix_denies(matrix_env):
  123. app, ids = matrix_env
  124. with app.app_context():
  125. comms = db.session.get(Organization, ids["comms"])
  126. user = db.session.get(User, ids["user"])
  127. # 文档挂通信部:对作战部用户是 OTHER,且未配置部门
  128. doc = _make_document("矩阵其他部门主案", org=comms, visibility="ORGANIZATION")
  129. db.session.commit()
  130. context = _user_context(user)
  131. access = evaluate_plan_access(doc, context)
  132. assert access.allowed is False
  133. assert access.reason == "ACL"
  134. @pytest.mark.integration
  135. def test_other_scope_matrix_grants_cross_department_view(matrix_env):
  136. app, ids = matrix_env
  137. with app.app_context():
  138. comms = db.session.get(Organization, ids["comms"])
  139. user = db.session.get(User, ids["user"])
  140. role = _make_role("跨部门角色", actor_id=user.id)
  141. _matrix_row(role.id, "SECRET", "OTHER", can_view=True)
  142. doc = _make_document("矩阵其他部门主案", org=comms, visibility="ORGANIZATION")
  143. db.session.commit()
  144. context = _user_context(user)
  145. access = evaluate_plan_access(doc, context)
  146. assert access.allowed is True
  147. assert "VIEW" in access.matrix_actions
  148. @pytest.mark.integration
  149. def test_security_and_status_precedence_over_matrix(matrix_env):
  150. app, ids = matrix_env
  151. with app.app_context():
  152. ops = db.session.get(Organization, ids["ops"])
  153. user = db.session.get(User, ids["user"]) # 密级 SECRET
  154. role = _make_role("越权角色", actor_id=user.id)
  155. _matrix_row(role.id, "TOP_SECRET", "OWN", can_view=True)
  156. top_secret_doc = _make_document(
  157. "矩阵绝密主案", org=ops, security="TOP_SECRET"
  158. )
  159. draft_doc = _make_document(
  160. "矩阵草稿主案", org=ops, security="SECRET", status="DRAFT"
  161. )
  162. db.session.commit()
  163. context = _user_context(user)
  164. assert evaluate_plan_access(top_secret_doc, context).reason == "SECURITY"
  165. assert evaluate_plan_access(draft_doc, context).reason == "STATUS"
  166. @pytest.mark.integration
  167. def test_default_role_applies_and_disabled_role_yields_nothing(matrix_env):
  168. app, ids = matrix_env
  169. with app.app_context():
  170. ops = db.session.get(Organization, ids["ops"])
  171. user = db.session.get(User, ids["user"]) # 未挂角色
  172. default_role = _make_role("默认角色", is_default=True, actor_id=user.id)
  173. _matrix_row(default_role.id, "SECRET", "OWN", can_view=True)
  174. doc = _make_document("默认角色主案", org=ops, visibility="ORGANIZATION")
  175. db.session.commit()
  176. context = _user_context(user)
  177. assert evaluate_plan_access(doc, context).allowed is True
  178. # 默认角色停用:无显式角色且无默认 → 矩阵贡献为空
  179. default_role.status = "DISABLED"
  180. db.session.commit()
  181. denied = evaluate_plan_access(doc, context)
  182. assert denied.allowed is False
  183. # 显式挂停用角色:不回退默认角色
  184. disabled_role = _make_role(
  185. "停用角色", status="DISABLED", actor_id=user.id
  186. )
  187. _matrix_row(disabled_role.id, "SECRET", "OWN", can_view=True)
  188. user.role_id = disabled_role.id
  189. db.session.commit()
  190. assert evaluate_plan_access(doc, context).allowed is False
  191. @pytest.mark.integration
  192. def test_matrix_actions_expand_allowed_actions(matrix_env):
  193. app, ids = matrix_env
  194. with app.app_context():
  195. ops = db.session.get(Organization, ids["ops"])
  196. user = db.session.get(User, ids["user"])
  197. role = _make_role("完整权限角色", actor_id=user.id)
  198. _matrix_row(
  199. role.id,
  200. "SECRET",
  201. "OWN",
  202. can_view=True,
  203. can_download=True,
  204. can_edit=True,
  205. can_delete=True,
  206. can_config_permission=True,
  207. can_bind_attachment=True,
  208. can_unbind_attachment=True,
  209. )
  210. main_doc = _make_document("矩阵操作位主案", org=ops)
  211. sub_doc = _make_document(
  212. "矩阵操作位子案",
  213. org=ops,
  214. document_type="SUB_PLAN",
  215. )
  216. sub_doc.root_document_id = main_doc.id
  217. sub_doc.parent_document_id = main_doc.id
  218. db.session.commit()
  219. context = _user_context(user)
  220. main_actions = plan_allowed_actions(
  221. main_doc, context, evaluate_plan_access(main_doc, context)
  222. )
  223. assert set(main_actions) >= {
  224. "VIEW",
  225. "DOWNLOAD",
  226. "EDIT",
  227. "DELETE",
  228. "CONFIG_PERMISSION",
  229. }
  230. sub_actions = plan_allowed_actions(
  231. sub_doc, context, evaluate_plan_access(sub_doc, context)
  232. )
  233. # 子方案过滤授权/挂载/解除挂载
  234. assert "CONFIG_PERMISSION" not in sub_actions
  235. assert "BIND_ATTACHMENT" not in sub_actions
  236. assert "UNBIND_ATTACHMENT" not in sub_actions
  237. assert {"VIEW", "DOWNLOAD", "EDIT", "DELETE"} <= set(sub_actions)
  238. # ===== 动态管理菜单(allowedModules)判定 =====================================
  239. from dms.services.user_service import allowed_modules_for_user
  240. @pytest.mark.integration
  241. def test_admin_role_always_has_manage_capability(matrix_env):
  242. app, ids = matrix_env
  243. with app.app_context():
  244. admin = db.session.scalar(
  245. db.select(User).where(User.username == "admin")
  246. )
  247. context = _user_context(admin)
  248. assert has_any_manage_action(context) is True
  249. assert allowed_modules_for_user(admin) == [
  250. "DOCUMENT_BROWSER",
  251. "BACKEND_MANAGEMENT",
  252. "AUDIT_LOG",
  253. "ORGANIZATION_USER_MANAGEMENT",
  254. ]
  255. @pytest.mark.integration
  256. def test_user_without_manage_bits_gets_browser_only(matrix_env):
  257. app, ids = matrix_env
  258. with app.app_context():
  259. ops = db.session.get(Organization, ids["ops"])
  260. user = db.session.get(User, ids["user"])
  261. role = _make_role("只读角色", is_default=True, actor_id=user.id)
  262. # 矩阵只有查看/下载位,无任何管理位
  263. for scope in ("OWN", "OTHER"):
  264. _matrix_row(
  265. role.id, "SECRET", scope, can_view=True, can_download=True
  266. )
  267. _make_document("只读可看主案", org=ops, visibility="ALL_AUTHENTICATED")
  268. db.session.commit()
  269. assert has_any_manage_action(_user_context(user)) is False
  270. assert allowed_modules_for_user(user) == ["DOCUMENT_BROWSER"]
  271. @pytest.mark.integration
  272. def test_user_with_manage_bit_and_matching_document_gets_admin_menu(matrix_env):
  273. app, ids = matrix_env
  274. with app.app_context():
  275. ops = db.session.get(Organization, ids["ops"])
  276. user = db.session.get(User, ids["user"])
  277. role = _make_role("编辑角色", is_default=True, actor_id=user.id)
  278. # OWN × SECRET 勾选编辑:作战部存在已发布 SECRET 主案即命中
  279. _matrix_row(
  280. role.id, "SECRET", "OWN", can_view=True, can_edit=True
  281. )
  282. _make_document("本部门可编辑主案", org=ops)
  283. db.session.commit()
  284. assert has_any_manage_action(_user_context(user)) is True
  285. assert allowed_modules_for_user(user) == [
  286. "DOCUMENT_BROWSER",
  287. "BACKEND_MANAGEMENT",
  288. ]
  289. @pytest.mark.integration
  290. def test_manage_bit_without_matching_document_or_view_denies(matrix_env):
  291. app, ids = matrix_env
  292. with app.app_context():
  293. ops = db.session.get(Organization, ids["ops"])
  294. user = db.session.get(User, ids["user"])
  295. role = _make_role("空转编辑角色", is_default=True, actor_id=user.id)
  296. # 只有 OTHER × TOP_SECRET 勾编辑,但文档在本部门且密级只有 SECRET:
  297. # 部门范围不命中 → 不出管理菜单
  298. _matrix_row(
  299. role.id, "TOP_SECRET", "OTHER", can_view=True, can_edit=True
  300. )
  301. _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_edit=True)
  302. _make_document("本部门SECRET主案", org=ops)
  303. db.session.commit()
  304. # 修正:OWN×SECRET 也勾了编辑且文档命中,应当可见 —— 先验证 OTHER 行不误伤
  305. assert has_any_manage_action(_user_context(user)) is True
  306. # 去掉 OWN 行的编辑位后只剩 OTHER 行:不再命中
  307. row = db.session.scalar(
  308. db.select(RolePermission).where(
  309. RolePermission.role_id == role.id,
  310. RolePermission.dept_scope == "OWN",
  311. )
  312. )
  313. row.can_edit = False
  314. db.session.commit()
  315. assert has_any_manage_action(_user_context(user)) is False
  316. @pytest.mark.integration
  317. def test_manage_bit_without_view_source_denies(matrix_env):
  318. app, ids = matrix_env
  319. with app.app_context():
  320. ops = db.session.get(Organization, ids["ops"])
  321. user = db.session.get(User, ids["user"])
  322. role = _make_role("无查看编辑角色", is_default=True, actor_id=user.id)
  323. # 编辑位开启但查看位全关且文档非全员可见:access 不成立 → 管理位无效
  324. _matrix_row(role.id, "SECRET", "OWN", can_edit=True)
  325. _make_document("ORGANIZATION可见主案", org=ops, visibility="ORGANIZATION")
  326. db.session.commit()
  327. assert has_any_manage_action(_user_context(user)) is False
  328. # ===== 写路径集中鉴权 require_plan_action ====================================
  329. from dms.common.errors import (
  330. DocumentActionForbiddenError,
  331. SecurityLevelForbiddenError,
  332. )
  333. @pytest.mark.integration
  334. def test_require_plan_action_allows_matrix_edit(matrix_env):
  335. app, ids = matrix_env
  336. with app.app_context():
  337. ops = db.session.get(Organization, ids["ops"])
  338. user = db.session.get(User, ids["user"])
  339. role = _make_role("写路径编辑角色", is_default=True, actor_id=user.id)
  340. _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_edit=True)
  341. doc = _make_document("写路径可编辑主案", org=ops)
  342. db.session.commit()
  343. require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
  344. @pytest.mark.integration
  345. def test_require_plan_action_denies_without_bit(matrix_env):
  346. app, ids = matrix_env
  347. with app.app_context():
  348. ops = db.session.get(Organization, ids["ops"])
  349. user = db.session.get(User, ids["user"])
  350. role = _make_role("写路径只读角色", is_default=True, actor_id=user.id)
  351. _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_download=True)
  352. doc = _make_document("写路径只读主案", org=ops)
  353. db.session.commit()
  354. with pytest.raises(DocumentActionForbiddenError):
  355. require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
  356. with pytest.raises(DocumentActionForbiddenError):
  357. require_plan_action(doc, _user_context(user), AllowedAction.DELETE)
  358. with pytest.raises(DocumentActionForbiddenError):
  359. require_plan_action(
  360. doc, _user_context(user), AllowedAction.CONFIG_PERMISSION
  361. )
  362. @pytest.mark.integration
  363. def test_require_plan_action_security_gate_precedes_bits(matrix_env):
  364. app, ids = matrix_env
  365. with app.app_context():
  366. ops = db.session.get(Organization, ids["ops"])
  367. user = db.session.get(User, ids["user"])
  368. role = _make_role("写路径高密角色", is_default=True, actor_id=user.id)
  369. _matrix_row(role.id, "TOP_SECRET", "OWN", can_view=True, can_edit=True)
  370. # 用户密级 SECRET:即使矩阵在 TOP_SECRET 行勾了编辑,密级前置直接拒绝
  371. doc = _make_document("绝密主案", org=ops, security="TOP_SECRET")
  372. db.session.commit()
  373. with pytest.raises(SecurityLevelForbiddenError):
  374. require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
  375. @pytest.mark.integration
  376. def test_require_plan_action_admin_passes_all(matrix_env):
  377. app, ids = matrix_env
  378. with app.app_context():
  379. ops = db.session.get(Organization, ids["ops"])
  380. admin = db.session.scalar(
  381. db.select(User).where(User.username == "admin")
  382. )
  383. doc = _make_document("管理员主案", org=ops, security="SECRET")
  384. db.session.commit()
  385. for action in (
  386. AllowedAction.EDIT,
  387. AllowedAction.DELETE,
  388. AllowedAction.CONFIG_PERMISSION,
  389. AllowedAction.BIND_ATTACHMENT,
  390. AllowedAction.UNBIND_ATTACHMENT,
  391. ):
  392. require_plan_action(doc, _user_context(admin), action)
  393. @pytest.mark.integration
  394. def test_require_plan_action_attachment_admin_only(matrix_env):
  395. app, ids = matrix_env
  396. with app.app_context():
  397. ops = db.session.get(Organization, ids["ops"])
  398. user = db.session.get(User, ids["user"])
  399. admin = db.session.scalar(
  400. db.select(User).where(User.username == "admin")
  401. )
  402. role = _make_role("附件全权角色", is_default=True, actor_id=user.id)
  403. for scope in ("OWN", "OTHER"):
  404. _matrix_row(
  405. role.id, "PUBLIC", scope,
  406. can_view=True, can_download=True, can_edit=True, can_delete=True,
  407. )
  408. att = _make_document(
  409. "共享附件", org=ops, security="PUBLIC",
  410. visibility="ALL_AUTHENTICATED", document_type="ATTACHMENT",
  411. )
  412. db.session.commit()
  413. with pytest.raises(DocumentActionForbiddenError):
  414. require_plan_action(att, _user_context(user), AllowedAction.EDIT)
  415. require_plan_action(att, _user_context(admin), AllowedAction.DELETE)