test_admin_permissions.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. """管理类权限扩展测试(管理位挂角色,方案B)。
  2. 覆盖:
  3. - 角色管理位(分类/机构/用户编辑)对相应写接口的放行与拒绝;
  4. - 管理范围锚点(角色 managedOrgId)的子树收敛(机构/用户增删改与用户列表);
  5. - 锚点管理员不可将用户迁出管理子树;
  6. - 用户接口不再接受管理位字段、角色锚点无效与权限位非布尔的契约拒绝;
  7. - ADMIN 降级 USER 后角色关联保留、管理位字段跟随生效角色;
  8. - 锚点 "SELF"(落库哨兵 0)跟随管理员自身部门,无部门时无管理范围;
  9. - 内置 admin 不受权限位与范围约束(由全量测试套以 perm 全 0 的 admin 隐含验证)。
  10. """
  11. from __future__ import annotations
  12. import pytest
  13. from dms.extensions import db
  14. from dms.models import Organization, User
  15. ADMIN_INITIAL_PASSWORD = "Initial@2026"
  16. def _auth(token: str) -> dict[str, str]:
  17. return {"Authorization": f"Bearer {token}"}
  18. def _login(client, password: str, *, username: str) -> str:
  19. response = client.post(
  20. "/api/v1/auth/login",
  21. json={"username": username, "password": password, "keepSignedIn": False},
  22. )
  23. assert response.status_code == 200, response.get_json()
  24. return response.get_json()["data"]["accessToken"]
  25. def _org_id(app, code: str) -> str:
  26. with app.app_context():
  27. org = db.session.scalar(
  28. db.select(Organization).where(Organization.org_code == code)
  29. )
  30. assert org is not None, f"organization {code} not found"
  31. return str(org.id)
  32. def _user_id(app, username: str) -> str:
  33. with app.app_context():
  34. user = db.session.scalar(db.select(User).where(User.username == username))
  35. assert user is not None, f"user {username} not found"
  36. return str(user.id)
  37. def _role_payload(suffix: str) -> dict:
  38. return {
  39. "roleName": f"管理测试-{suffix}",
  40. "sortNo": 90,
  41. "status": "ENABLED",
  42. "remark": None,
  43. "isDefault": False,
  44. "manageCategoryEdit": False,
  45. "manageOrgEdit": False,
  46. "manageUserEdit": False,
  47. "managedOrgId": None,
  48. }
  49. def _create_admin(
  50. client,
  51. token: str,
  52. *,
  53. username: str,
  54. organization_id: str | None,
  55. perm_category: bool = False,
  56. perm_org: bool = False,
  57. perm_user: bool = False,
  58. managed_org_id: str | None = None,
  59. ):
  60. """由内置 admin 先建带管理位的角色,再建 ADMIN 类别账户挂该角色。"""
  61. role_id = None
  62. if perm_category or perm_org or perm_user or managed_org_id is not None:
  63. role_payload = _role_payload(username)
  64. role_payload.update(
  65. {
  66. "manageCategoryEdit": perm_category,
  67. "manageOrgEdit": perm_org,
  68. "manageUserEdit": perm_user,
  69. "managedOrgId": managed_org_id,
  70. }
  71. )
  72. resp = client.post(
  73. "/api/v1/roles", json=role_payload, headers=_auth(token)
  74. )
  75. assert resp.status_code == 201, resp.get_json()
  76. role_id = resp.get_json()["data"]["id"]
  77. response = client.post(
  78. "/api/v1/users",
  79. json={
  80. "username": username,
  81. "realName": username,
  82. "initialPassword": ADMIN_INITIAL_PASSWORD,
  83. "organizationId": organization_id,
  84. "roleCode": "ADMIN",
  85. "roleId": role_id,
  86. "securityLevel": "TOP_SECRET",
  87. "status": "ENABLED",
  88. },
  89. headers=_auth(token),
  90. )
  91. assert response.status_code == 201, response.get_json()
  92. return response.get_json()["data"]
  93. def _category_payload(code: str) -> dict:
  94. return {
  95. "categoryCode": code,
  96. "categoryName": f"权限测试{code}",
  97. "categoryType": "SCENE",
  98. "parentId": None,
  99. "sortNo": 10,
  100. "flatMode": False,
  101. "typeDisplayName": None,
  102. }
  103. def _org_payload(code: str, *, parent_id: str | None) -> dict:
  104. return {
  105. "orgCode": code,
  106. "orgName": f"权限测试{code}",
  107. "parentId": parent_id,
  108. "sortNo": 10,
  109. "status": "ENABLED",
  110. }
  111. def _user_payload(username: str, *, organization_id: str | None) -> dict:
  112. return {
  113. "username": username,
  114. "realName": username,
  115. "initialPassword": ADMIN_INITIAL_PASSWORD,
  116. "organizationId": organization_id,
  117. "roleCode": "USER",
  118. "roleId": None,
  119. "securityLevel": "INTERNAL",
  120. "status": "ENABLED",
  121. }
  122. # =====================================================================
  123. # 权限位开关
  124. # =====================================================================
  125. @pytest.mark.integration
  126. def test_plain_admin_without_flags_is_readonly_for_directory_writes(
  127. b2_app, b2_client, login_token, b2_password
  128. ):
  129. """未挂管理位角色的管理员(默认角色管理位全 0)不能写分类/机构/用户。"""
  130. _create_admin(
  131. b2_client,
  132. login_token,
  133. username="plain_admin",
  134. organization_id=_org_id(b2_app, "ORG_ROOT"),
  135. )
  136. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="plain_admin")
  137. resp = b2_client.post(
  138. "/api/v1/categories",
  139. json=_category_payload("PERM_CAT"),
  140. headers=_auth(token),
  141. )
  142. assert resp.status_code == 403
  143. assert resp.get_json()["code"] == "FORBIDDEN"
  144. resp = b2_client.post(
  145. "/api/v1/organizations",
  146. json=_org_payload("ORG_PERM_TOP", parent_id=None),
  147. headers=_auth(token),
  148. )
  149. assert resp.status_code == 403
  150. resp = b2_client.post(
  151. "/api/v1/users",
  152. json=_user_payload("perm_target_user", organization_id=None),
  153. headers=_auth(token),
  154. )
  155. assert resp.status_code == 403
  156. @pytest.mark.integration
  157. def test_category_edit_flag_grants_global_category_writes(
  158. b2_app, b2_client, login_token, b2_password
  159. ):
  160. """分类编辑权限不受锚点范围约束:有权限位即可编辑所有分类。"""
  161. # 锚定OPS,但分类编辑仍是全局能力
  162. _create_admin(
  163. b2_client,
  164. login_token,
  165. username="cat_admin",
  166. organization_id=_org_id(b2_app, "ORG_OPS"),
  167. perm_category=True,
  168. managed_org_id=_org_id(b2_app, "ORG_OPS"),
  169. )
  170. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="cat_admin")
  171. resp = b2_client.post(
  172. "/api/v1/categories",
  173. json=_category_payload("PERM_CAT_OK"),
  174. headers=_auth(token),
  175. )
  176. assert resp.status_code == 201, resp.get_json()
  177. assert resp.get_json()["data"]["categoryCode"] == "PERM_CAT_OK"
  178. # =====================================================================
  179. # 机构编辑:锚点子树收敛
  180. # =====================================================================
  181. @pytest.mark.integration
  182. def test_anchored_org_editor_scope(
  183. b2_app, b2_client, login_token, b2_password
  184. ):
  185. """处于 ROOT 但角色锚定 OPS 的机构编辑员:可写 OPS 子树,越范围与顶级一律拒绝。"""
  186. ops = _org_id(b2_app, "ORG_OPS")
  187. comms = _org_id(b2_app, "ORG_COMMS")
  188. root = _org_id(b2_app, "ORG_ROOT")
  189. _create_admin(
  190. b2_client,
  191. login_token,
  192. username="ops_admin",
  193. organization_id=root,
  194. perm_org=True,
  195. managed_org_id=ops,
  196. )
  197. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="ops_admin")
  198. # 子机构创建:范围内 → 201
  199. resp = b2_client.post(
  200. "/api/v1/organizations",
  201. json=_org_payload("ORG_OPS_CHILD", parent_id=ops),
  202. headers=_auth(token),
  203. )
  204. assert resp.status_code == 201, resp.get_json()
  205. # 编辑范围外机构(COMMS)→ 403
  206. resp = b2_client.put(
  207. f"/api/v1/organizations/{comms}",
  208. json={
  209. "orgCode": "ORG_COMMS",
  210. "orgName": "通信部",
  211. "parentId": root,
  212. "sortNo": 10,
  213. "status": "ENABLED",
  214. "rowVersion": 0,
  215. },
  216. headers=_auth(token),
  217. )
  218. assert resp.status_code == 403
  219. assert resp.get_json()["code"] == "FORBIDDEN"
  220. # 创建顶级机构(parentId 为 null)→ 403(仅全局管理员可建顶级)
  221. resp = b2_client.post(
  222. "/api/v1/organizations",
  223. json=_org_payload("ORG_PERM_TOP", parent_id=None),
  224. headers=_auth(token),
  225. )
  226. assert resp.status_code == 403
  227. @pytest.mark.integration
  228. def test_global_org_editor_can_create_top_level(
  229. b2_app, b2_client, login_token, b2_password
  230. ):
  231. """角色无锚点(managedOrgId 为 null)的机构编辑员为全局范围,可建顶级机构。"""
  232. _create_admin(
  233. b2_client,
  234. login_token,
  235. username="global_org_admin",
  236. organization_id=_org_id(b2_app, "ORG_ROOT"),
  237. perm_org=True,
  238. )
  239. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="global_org_admin")
  240. resp = b2_client.post(
  241. "/api/v1/organizations",
  242. json=_org_payload("ORG_PERM_TOP", parent_id=None),
  243. headers=_auth(token),
  244. )
  245. assert resp.status_code == 201, resp.get_json()
  246. # =====================================================================
  247. # 用户编辑:锚点子树收敛 + 列表收敛
  248. # =====================================================================
  249. @pytest.mark.integration
  250. def test_anchored_user_editor_scope(
  251. b2_app, b2_client, login_token, b2_password
  252. ):
  253. """角色锚定 OPS 的用户编辑员:可增改 OPS 子树用户,越范围一律拒绝。"""
  254. ops = _org_id(b2_app, "ORG_OPS")
  255. comms = _org_id(b2_app, "ORG_COMMS")
  256. _create_admin(
  257. b2_client,
  258. login_token,
  259. username="user_editor",
  260. organization_id=_org_id(b2_app, "ORG_OPS"),
  261. perm_user=True,
  262. managed_org_id=ops,
  263. )
  264. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="user_editor")
  265. # 编辑范围内用户(user 在 OPS)→ 200,且详情管理位字段跟随其生效角色
  266. resp = b2_client.put(
  267. f"/api/v1/users/{_user_id(b2_app, 'user')}",
  268. json={
  269. "realName": "普通用户",
  270. "organizationId": ops,
  271. "roleCode": "USER",
  272. "roleId": None,
  273. "securityLevel": "INTERNAL",
  274. "status": "ENABLED",
  275. "rowVersion": 0,
  276. },
  277. headers=_auth(token),
  278. )
  279. assert resp.status_code == 200, resp.get_json()
  280. data = resp.get_json()["data"]
  281. assert data["permCategoryEdit"] is False
  282. assert data["permOrgEdit"] is False
  283. assert data["permUserEdit"] is False
  284. assert data["managedOrgId"] is None
  285. # 编辑范围外用户(disabled 在 COMMS)→ 403
  286. resp = b2_client.put(
  287. f"/api/v1/users/{_user_id(b2_app, 'disabled')}",
  288. json={
  289. "realName": "停用用户",
  290. "organizationId": comms,
  291. "roleCode": "USER",
  292. "roleId": None,
  293. "securityLevel": "INTERNAL",
  294. "status": "DISABLED",
  295. "rowVersion": 0,
  296. },
  297. headers=_auth(token),
  298. )
  299. assert resp.status_code == 403
  300. # 创建范围内用户 → 201;创建范围外用户 → 403
  301. resp = b2_client.post(
  302. "/api/v1/users",
  303. json=_user_payload("ops_new_user", organization_id=ops),
  304. headers=_auth(token),
  305. )
  306. assert resp.status_code == 201, resp.get_json()
  307. resp = b2_client.post(
  308. "/api/v1/users",
  309. json=_user_payload("comms_new_user", organization_id=comms),
  310. headers=_auth(token),
  311. )
  312. assert resp.status_code == 403
  313. @pytest.mark.integration
  314. def test_anchored_user_editor_cannot_move_user_out_of_scope(
  315. b2_app, b2_client, login_token, b2_password
  316. ):
  317. """锚点管理员不可将范围内用户迁出到范围外部门。"""
  318. ops = _org_id(b2_app, "ORG_OPS")
  319. comms = _org_id(b2_app, "ORG_COMMS")
  320. _create_admin(
  321. b2_client,
  322. login_token,
  323. username="user_editor",
  324. organization_id=ops,
  325. perm_user=True,
  326. managed_org_id=ops,
  327. )
  328. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="user_editor")
  329. resp = b2_client.put(
  330. f"/api/v1/users/{_user_id(b2_app, 'user')}",
  331. json={
  332. "realName": "普通用户",
  333. "organizationId": comms,
  334. "roleCode": "USER",
  335. "roleId": None,
  336. "securityLevel": "INTERNAL",
  337. "status": "ENABLED",
  338. "rowVersion": 0,
  339. },
  340. headers=_auth(token),
  341. )
  342. assert resp.status_code == 403
  343. assert resp.get_json()["code"] == "FORBIDDEN"
  344. @pytest.mark.integration
  345. def test_user_list_converges_to_managed_scope(
  346. b2_app, b2_client, login_token, b2_password
  347. ):
  348. """用户列表按锚点子树收敛;全局管理员与内置 admin 不受限。"""
  349. ops = _org_id(b2_app, "ORG_OPS")
  350. # 管理员身属 ROOT、角色锚定 OPS(身处 A 部门管理 B 部门):
  351. # 其本人不在 OPS 子树内,列表应仅见被管子树用户
  352. _create_admin(
  353. b2_client,
  354. login_token,
  355. username="ops_viewer",
  356. organization_id=_org_id(b2_app, "ORG_ROOT"),
  357. perm_user=True,
  358. managed_org_id=ops,
  359. )
  360. scoped_token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="ops_viewer")
  361. resp = b2_client.get(
  362. "/api/v1/users?page=1&pageSize=50", headers=_auth(scoped_token)
  363. )
  364. page = resp.get_json()["data"]
  365. # OPS 子树内仅有 conftest 的 user(admin 在 ROOT、disabled 在 COMMS 均不可见)
  366. assert page["total"] == 1
  367. assert [item["username"] for item in page["items"]] == ["user"]
  368. # 内置 admin(超管)不受范围约束,可见全部用户
  369. resp = b2_client.get(
  370. "/api/v1/users?page=1&pageSize=50", headers=_auth(login_token)
  371. )
  372. usernames = {item["username"] for item in resp.get_json()["data"]["items"]}
  373. assert {"admin", "user", "disabled", "ops_viewer"} <= usernames
  374. # =====================================================================
  375. # 契约校验
  376. # =====================================================================
  377. @pytest.mark.integration
  378. def test_admin_perm_contract_rejections(b2_app, b2_client, login_token):
  379. """用户接口不接受管理位字段;角色锚点无效、权限位非布尔均 400。"""
  380. root = _org_id(b2_app, "ORG_ROOT")
  381. # 用户 payload 携带管理位字段(管理位已移至角色)→ 400(字段集外)
  382. payload = _user_payload("perm_user_cat", organization_id=root)
  383. payload["permCategoryEdit"] = True
  384. resp = b2_client.post("/api/v1/users", json=payload, headers=_auth(login_token))
  385. assert resp.status_code == 400
  386. assert resp.get_json()["code"] == "INVALID_ARGUMENT"
  387. # 角色锚点指向不存在机构 → 400
  388. role_payload = _role_payload("BAD_ANCHOR")
  389. role_payload["managedOrgId"] = "999999"
  390. resp = b2_client.post(
  391. "/api/v1/roles", json=role_payload, headers=_auth(login_token)
  392. )
  393. assert resp.status_code == 400
  394. # 角色权限位非布尔 → 400
  395. role_payload = _role_payload("BAD_FLAG")
  396. role_payload["manageUserEdit"] = "yes"
  397. resp = b2_client.post(
  398. "/api/v1/roles", json=role_payload, headers=_auth(login_token)
  399. )
  400. assert resp.status_code == 400
  401. @pytest.mark.integration
  402. def test_downgrade_admin_keeps_role_binding(
  403. b2_app, b2_client, login_token
  404. ):
  405. """ADMIN 降级为 USER 后角色关联保留,管理位字段继续跟随生效角色。"""
  406. root = _org_id(b2_app, "ORG_ROOT")
  407. ops = _org_id(b2_app, "ORG_OPS")
  408. created = _create_admin(
  409. b2_client,
  410. login_token,
  411. username="downgrade_admin",
  412. organization_id=root,
  413. perm_category=True,
  414. perm_user=True,
  415. managed_org_id=ops,
  416. )
  417. user_id = created["id"]
  418. resp = b2_client.put(
  419. f"/api/v1/users/{user_id}",
  420. json={
  421. "realName": "downgrade_admin",
  422. "organizationId": root,
  423. "roleCode": "USER",
  424. "roleId": created["roleId"],
  425. "securityLevel": "TOP_SECRET",
  426. "status": "ENABLED",
  427. "rowVersion": 0,
  428. },
  429. headers=_auth(login_token),
  430. )
  431. assert resp.status_code == 200, resp.get_json()
  432. data = resp.get_json()["data"]
  433. assert data["roleCode"] == "USER"
  434. # 管理位挂角色:降级不清零,字段继续反映生效角色
  435. assert data["permCategoryEdit"] is True
  436. assert data["permUserEdit"] is True
  437. assert data["managedOrgId"] == ops
  438. @pytest.mark.integration
  439. def test_self_anchor_follows_admin_own_org(
  440. b2_app, b2_client, login_token, b2_password
  441. ):
  442. """锚点 "SELF"(落库哨兵 0)跟随管理员自身部门:身在 OPS 即管 OPS 子树。"""
  443. ops = _org_id(b2_app, "ORG_OPS")
  444. comms = _org_id(b2_app, "ORG_COMMS")
  445. root = _org_id(b2_app, "ORG_ROOT")
  446. _create_admin(
  447. b2_client,
  448. login_token,
  449. username="self_admin",
  450. organization_id=ops,
  451. perm_org=True,
  452. managed_org_id="SELF",
  453. )
  454. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="self_admin")
  455. # 角色列表与用户详情均按 "SELF" 哨兵序列化
  456. resp = b2_client.get(
  457. "/api/v1/roles?keyword=管理测试-self_admin",
  458. headers=_auth(login_token),
  459. )
  460. assert resp.status_code == 200, resp.get_json()
  461. items = resp.get_json()["data"]["items"]
  462. assert [item["managedOrgId"] for item in items] == ["SELF"]
  463. resp = b2_client.get("/api/v1/users/me", headers=_auth(token))
  464. assert resp.status_code == 200, resp.get_json()
  465. assert resp.get_json()["data"]["managedOrgId"] == "SELF"
  466. # 身在 OPS:子机构创建(范围内)→ 201
  467. resp = b2_client.post(
  468. "/api/v1/organizations",
  469. json=_org_payload("ORG_OPS_SELF_CHILD", parent_id=ops),
  470. headers=_auth(token),
  471. )
  472. assert resp.status_code == 201, resp.get_json()
  473. # 编辑范围外机构(COMMS)→ 403
  474. resp = b2_client.put(
  475. f"/api/v1/organizations/{comms}",
  476. json={
  477. "orgCode": "ORG_COMMS",
  478. "orgName": "通信部",
  479. "parentId": root,
  480. "sortNo": 10,
  481. "status": "ENABLED",
  482. "rowVersion": 0,
  483. },
  484. headers=_auth(token),
  485. )
  486. assert resp.status_code == 403
  487. # 创建顶级机构 → 403(仅全局管理员可建顶级)
  488. resp = b2_client.post(
  489. "/api/v1/organizations",
  490. json=_org_payload("ORG_SELF_TOP", parent_id=None),
  491. headers=_auth(token),
  492. )
  493. assert resp.status_code == 403
  494. @pytest.mark.integration
  495. def test_self_anchor_without_org_has_no_scope(
  496. b2_app, b2_client, login_token, b2_password
  497. ):
  498. """锚点 "SELF" 且管理员自身无部门:管理范围为空,任何机构写操作拒绝。"""
  499. ops = _org_id(b2_app, "ORG_OPS")
  500. _create_admin(
  501. b2_client,
  502. login_token,
  503. username="orgless_admin",
  504. organization_id=None,
  505. perm_org=True,
  506. managed_org_id="SELF",
  507. )
  508. token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="orgless_admin")
  509. resp = b2_client.post(
  510. "/api/v1/organizations",
  511. json=_org_payload("ORG_ORGLESS_CHILD", parent_id=ops),
  512. headers=_auth(token),
  513. )
  514. assert resp.status_code == 403