| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586 |
- """管理类权限扩展测试(管理位挂角色,方案B)。
- 覆盖:
- - 角色管理位(分类/机构/用户编辑)对相应写接口的放行与拒绝;
- - 管理范围锚点(角色 managedOrgId)的子树收敛(机构/用户增删改与用户列表);
- - 锚点管理员不可将用户迁出管理子树;
- - 用户接口不再接受管理位字段、角色锚点无效与权限位非布尔的契约拒绝;
- - ADMIN 降级 USER 后角色关联保留、管理位字段跟随生效角色;
- - 锚点 "SELF"(落库哨兵 0)跟随管理员自身部门,无部门时无管理范围;
- - 内置 admin 不受权限位与范围约束(由全量测试套以 perm 全 0 的 admin 隐含验证)。
- """
- from __future__ import annotations
- import pytest
- from dms.extensions import db
- from dms.models import Organization, User
- ADMIN_INITIAL_PASSWORD = "Initial@2026"
- def _auth(token: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {token}"}
- def _login(client, password: str, *, username: str) -> str:
- response = client.post(
- "/api/v1/auth/login",
- json={"username": username, "password": password, "keepSignedIn": False},
- )
- assert response.status_code == 200, response.get_json()
- return response.get_json()["data"]["accessToken"]
- def _org_id(app, code: str) -> str:
- with app.app_context():
- org = db.session.scalar(
- db.select(Organization).where(Organization.org_code == code)
- )
- assert org is not None, f"organization {code} not found"
- return str(org.id)
- def _user_id(app, username: str) -> str:
- with app.app_context():
- user = db.session.scalar(db.select(User).where(User.username == username))
- assert user is not None, f"user {username} not found"
- return str(user.id)
- def _role_payload(suffix: str) -> dict:
- return {
- "roleName": f"管理测试-{suffix}",
- "sortNo": 90,
- "status": "ENABLED",
- "remark": None,
- "isDefault": False,
- "manageCategoryEdit": False,
- "manageOrgEdit": False,
- "manageUserEdit": False,
- "managedOrgId": None,
- }
- def _create_admin(
- client,
- token: str,
- *,
- username: str,
- organization_id: str | None,
- perm_category: bool = False,
- perm_org: bool = False,
- perm_user: bool = False,
- managed_org_id: str | None = None,
- ):
- """由内置 admin 先建带管理位的角色,再建 ADMIN 类别账户挂该角色。"""
- role_id = None
- if perm_category or perm_org or perm_user or managed_org_id is not None:
- role_payload = _role_payload(username)
- role_payload.update(
- {
- "manageCategoryEdit": perm_category,
- "manageOrgEdit": perm_org,
- "manageUserEdit": perm_user,
- "managedOrgId": managed_org_id,
- }
- )
- resp = client.post(
- "/api/v1/roles", json=role_payload, headers=_auth(token)
- )
- assert resp.status_code == 201, resp.get_json()
- role_id = resp.get_json()["data"]["id"]
- response = client.post(
- "/api/v1/users",
- json={
- "username": username,
- "realName": username,
- "initialPassword": ADMIN_INITIAL_PASSWORD,
- "organizationId": organization_id,
- "roleCode": "ADMIN",
- "roleId": role_id,
- "securityLevel": "TOP_SECRET",
- "status": "ENABLED",
- },
- headers=_auth(token),
- )
- assert response.status_code == 201, response.get_json()
- return response.get_json()["data"]
- def _category_payload(code: str) -> dict:
- return {
- "categoryCode": code,
- "categoryName": f"权限测试{code}",
- "categoryType": "SCENE",
- "parentId": None,
- "sortNo": 10,
- "flatMode": False,
- "typeDisplayName": None,
- }
- def _org_payload(code: str, *, parent_id: str | None) -> dict:
- return {
- "orgCode": code,
- "orgName": f"权限测试{code}",
- "parentId": parent_id,
- "sortNo": 10,
- "status": "ENABLED",
- }
- def _user_payload(username: str, *, organization_id: str | None) -> dict:
- return {
- "username": username,
- "realName": username,
- "initialPassword": ADMIN_INITIAL_PASSWORD,
- "organizationId": organization_id,
- "roleCode": "USER",
- "roleId": None,
- "securityLevel": "INTERNAL",
- "status": "ENABLED",
- }
- # =====================================================================
- # 权限位开关
- # =====================================================================
- @pytest.mark.integration
- def test_plain_admin_without_flags_is_readonly_for_directory_writes(
- b2_app, b2_client, login_token, b2_password
- ):
- """未挂管理位角色的管理员(默认角色管理位全 0)不能写分类/机构/用户。"""
- _create_admin(
- b2_client,
- login_token,
- username="plain_admin",
- organization_id=_org_id(b2_app, "ORG_ROOT"),
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="plain_admin")
- resp = b2_client.post(
- "/api/v1/categories",
- json=_category_payload("PERM_CAT"),
- headers=_auth(token),
- )
- assert resp.status_code == 403
- assert resp.get_json()["code"] == "FORBIDDEN"
- resp = b2_client.post(
- "/api/v1/organizations",
- json=_org_payload("ORG_PERM_TOP", parent_id=None),
- headers=_auth(token),
- )
- assert resp.status_code == 403
- resp = b2_client.post(
- "/api/v1/users",
- json=_user_payload("perm_target_user", organization_id=None),
- headers=_auth(token),
- )
- assert resp.status_code == 403
- @pytest.mark.integration
- def test_category_edit_flag_grants_global_category_writes(
- b2_app, b2_client, login_token, b2_password
- ):
- """分类编辑权限不受锚点范围约束:有权限位即可编辑所有分类。"""
- # 锚定OPS,但分类编辑仍是全局能力
- _create_admin(
- b2_client,
- login_token,
- username="cat_admin",
- organization_id=_org_id(b2_app, "ORG_OPS"),
- perm_category=True,
- managed_org_id=_org_id(b2_app, "ORG_OPS"),
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="cat_admin")
- resp = b2_client.post(
- "/api/v1/categories",
- json=_category_payload("PERM_CAT_OK"),
- headers=_auth(token),
- )
- assert resp.status_code == 201, resp.get_json()
- assert resp.get_json()["data"]["categoryCode"] == "PERM_CAT_OK"
- # =====================================================================
- # 机构编辑:锚点子树收敛
- # =====================================================================
- @pytest.mark.integration
- def test_anchored_org_editor_scope(
- b2_app, b2_client, login_token, b2_password
- ):
- """处于 ROOT 但角色锚定 OPS 的机构编辑员:可写 OPS 子树,越范围与顶级一律拒绝。"""
- ops = _org_id(b2_app, "ORG_OPS")
- comms = _org_id(b2_app, "ORG_COMMS")
- root = _org_id(b2_app, "ORG_ROOT")
- _create_admin(
- b2_client,
- login_token,
- username="ops_admin",
- organization_id=root,
- perm_org=True,
- managed_org_id=ops,
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="ops_admin")
- # 子机构创建:范围内 → 201
- resp = b2_client.post(
- "/api/v1/organizations",
- json=_org_payload("ORG_OPS_CHILD", parent_id=ops),
- headers=_auth(token),
- )
- assert resp.status_code == 201, resp.get_json()
- # 编辑范围外机构(COMMS)→ 403
- resp = b2_client.put(
- f"/api/v1/organizations/{comms}",
- json={
- "orgCode": "ORG_COMMS",
- "orgName": "通信部",
- "parentId": root,
- "sortNo": 10,
- "status": "ENABLED",
- "rowVersion": 0,
- },
- headers=_auth(token),
- )
- assert resp.status_code == 403
- assert resp.get_json()["code"] == "FORBIDDEN"
- # 创建顶级机构(parentId 为 null)→ 403(仅全局管理员可建顶级)
- resp = b2_client.post(
- "/api/v1/organizations",
- json=_org_payload("ORG_PERM_TOP", parent_id=None),
- headers=_auth(token),
- )
- assert resp.status_code == 403
- @pytest.mark.integration
- def test_global_org_editor_can_create_top_level(
- b2_app, b2_client, login_token, b2_password
- ):
- """角色无锚点(managedOrgId 为 null)的机构编辑员为全局范围,可建顶级机构。"""
- _create_admin(
- b2_client,
- login_token,
- username="global_org_admin",
- organization_id=_org_id(b2_app, "ORG_ROOT"),
- perm_org=True,
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="global_org_admin")
- resp = b2_client.post(
- "/api/v1/organizations",
- json=_org_payload("ORG_PERM_TOP", parent_id=None),
- headers=_auth(token),
- )
- assert resp.status_code == 201, resp.get_json()
- # =====================================================================
- # 用户编辑:锚点子树收敛 + 列表收敛
- # =====================================================================
- @pytest.mark.integration
- def test_anchored_user_editor_scope(
- b2_app, b2_client, login_token, b2_password
- ):
- """角色锚定 OPS 的用户编辑员:可增改 OPS 子树用户,越范围一律拒绝。"""
- ops = _org_id(b2_app, "ORG_OPS")
- comms = _org_id(b2_app, "ORG_COMMS")
- _create_admin(
- b2_client,
- login_token,
- username="user_editor",
- organization_id=_org_id(b2_app, "ORG_OPS"),
- perm_user=True,
- managed_org_id=ops,
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="user_editor")
- # 编辑范围内用户(user 在 OPS)→ 200,且详情管理位字段跟随其生效角色
- resp = b2_client.put(
- f"/api/v1/users/{_user_id(b2_app, 'user')}",
- json={
- "realName": "普通用户",
- "organizationId": ops,
- "roleCode": "USER",
- "roleId": None,
- "securityLevel": "INTERNAL",
- "status": "ENABLED",
- "rowVersion": 0,
- },
- headers=_auth(token),
- )
- assert resp.status_code == 200, resp.get_json()
- data = resp.get_json()["data"]
- assert data["permCategoryEdit"] is False
- assert data["permOrgEdit"] is False
- assert data["permUserEdit"] is False
- assert data["managedOrgId"] is None
- # 编辑范围外用户(disabled 在 COMMS)→ 403
- resp = b2_client.put(
- f"/api/v1/users/{_user_id(b2_app, 'disabled')}",
- json={
- "realName": "停用用户",
- "organizationId": comms,
- "roleCode": "USER",
- "roleId": None,
- "securityLevel": "INTERNAL",
- "status": "DISABLED",
- "rowVersion": 0,
- },
- headers=_auth(token),
- )
- assert resp.status_code == 403
- # 创建范围内用户 → 201;创建范围外用户 → 403
- resp = b2_client.post(
- "/api/v1/users",
- json=_user_payload("ops_new_user", organization_id=ops),
- headers=_auth(token),
- )
- assert resp.status_code == 201, resp.get_json()
- resp = b2_client.post(
- "/api/v1/users",
- json=_user_payload("comms_new_user", organization_id=comms),
- headers=_auth(token),
- )
- assert resp.status_code == 403
- @pytest.mark.integration
- def test_anchored_user_editor_cannot_move_user_out_of_scope(
- b2_app, b2_client, login_token, b2_password
- ):
- """锚点管理员不可将范围内用户迁出到范围外部门。"""
- ops = _org_id(b2_app, "ORG_OPS")
- comms = _org_id(b2_app, "ORG_COMMS")
- _create_admin(
- b2_client,
- login_token,
- username="user_editor",
- organization_id=ops,
- perm_user=True,
- managed_org_id=ops,
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="user_editor")
- resp = b2_client.put(
- f"/api/v1/users/{_user_id(b2_app, 'user')}",
- json={
- "realName": "普通用户",
- "organizationId": comms,
- "roleCode": "USER",
- "roleId": None,
- "securityLevel": "INTERNAL",
- "status": "ENABLED",
- "rowVersion": 0,
- },
- headers=_auth(token),
- )
- assert resp.status_code == 403
- assert resp.get_json()["code"] == "FORBIDDEN"
- @pytest.mark.integration
- def test_user_list_converges_to_managed_scope(
- b2_app, b2_client, login_token, b2_password
- ):
- """用户列表按锚点子树收敛;全局管理员与内置 admin 不受限。"""
- ops = _org_id(b2_app, "ORG_OPS")
- # 管理员身属 ROOT、角色锚定 OPS(身处 A 部门管理 B 部门):
- # 其本人不在 OPS 子树内,列表应仅见被管子树用户
- _create_admin(
- b2_client,
- login_token,
- username="ops_viewer",
- organization_id=_org_id(b2_app, "ORG_ROOT"),
- perm_user=True,
- managed_org_id=ops,
- )
- scoped_token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="ops_viewer")
- resp = b2_client.get(
- "/api/v1/users?page=1&pageSize=50", headers=_auth(scoped_token)
- )
- page = resp.get_json()["data"]
- # OPS 子树内仅有 conftest 的 user(admin 在 ROOT、disabled 在 COMMS 均不可见)
- assert page["total"] == 1
- assert [item["username"] for item in page["items"]] == ["user"]
- # 内置 admin(超管)不受范围约束,可见全部用户
- resp = b2_client.get(
- "/api/v1/users?page=1&pageSize=50", headers=_auth(login_token)
- )
- usernames = {item["username"] for item in resp.get_json()["data"]["items"]}
- assert {"admin", "user", "disabled", "ops_viewer"} <= usernames
- # =====================================================================
- # 契约校验
- # =====================================================================
- @pytest.mark.integration
- def test_admin_perm_contract_rejections(b2_app, b2_client, login_token):
- """用户接口不接受管理位字段;角色锚点无效、权限位非布尔均 400。"""
- root = _org_id(b2_app, "ORG_ROOT")
- # 用户 payload 携带管理位字段(管理位已移至角色)→ 400(字段集外)
- payload = _user_payload("perm_user_cat", organization_id=root)
- payload["permCategoryEdit"] = True
- resp = b2_client.post("/api/v1/users", json=payload, headers=_auth(login_token))
- assert resp.status_code == 400
- assert resp.get_json()["code"] == "INVALID_ARGUMENT"
- # 角色锚点指向不存在机构 → 400
- role_payload = _role_payload("BAD_ANCHOR")
- role_payload["managedOrgId"] = "999999"
- resp = b2_client.post(
- "/api/v1/roles", json=role_payload, headers=_auth(login_token)
- )
- assert resp.status_code == 400
- # 角色权限位非布尔 → 400
- role_payload = _role_payload("BAD_FLAG")
- role_payload["manageUserEdit"] = "yes"
- resp = b2_client.post(
- "/api/v1/roles", json=role_payload, headers=_auth(login_token)
- )
- assert resp.status_code == 400
- @pytest.mark.integration
- def test_downgrade_admin_keeps_role_binding(
- b2_app, b2_client, login_token
- ):
- """ADMIN 降级为 USER 后角色关联保留,管理位字段继续跟随生效角色。"""
- root = _org_id(b2_app, "ORG_ROOT")
- ops = _org_id(b2_app, "ORG_OPS")
- created = _create_admin(
- b2_client,
- login_token,
- username="downgrade_admin",
- organization_id=root,
- perm_category=True,
- perm_user=True,
- managed_org_id=ops,
- )
- user_id = created["id"]
- resp = b2_client.put(
- f"/api/v1/users/{user_id}",
- json={
- "realName": "downgrade_admin",
- "organizationId": root,
- "roleCode": "USER",
- "roleId": created["roleId"],
- "securityLevel": "TOP_SECRET",
- "status": "ENABLED",
- "rowVersion": 0,
- },
- headers=_auth(login_token),
- )
- assert resp.status_code == 200, resp.get_json()
- data = resp.get_json()["data"]
- assert data["roleCode"] == "USER"
- # 管理位挂角色:降级不清零,字段继续反映生效角色
- assert data["permCategoryEdit"] is True
- assert data["permUserEdit"] is True
- assert data["managedOrgId"] == ops
- @pytest.mark.integration
- def test_self_anchor_follows_admin_own_org(
- b2_app, b2_client, login_token, b2_password
- ):
- """锚点 "SELF"(落库哨兵 0)跟随管理员自身部门:身在 OPS 即管 OPS 子树。"""
- ops = _org_id(b2_app, "ORG_OPS")
- comms = _org_id(b2_app, "ORG_COMMS")
- root = _org_id(b2_app, "ORG_ROOT")
- _create_admin(
- b2_client,
- login_token,
- username="self_admin",
- organization_id=ops,
- perm_org=True,
- managed_org_id="SELF",
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="self_admin")
- # 角色列表与用户详情均按 "SELF" 哨兵序列化
- resp = b2_client.get(
- "/api/v1/roles?keyword=管理测试-self_admin",
- headers=_auth(login_token),
- )
- assert resp.status_code == 200, resp.get_json()
- items = resp.get_json()["data"]["items"]
- assert [item["managedOrgId"] for item in items] == ["SELF"]
- resp = b2_client.get("/api/v1/users/me", headers=_auth(token))
- assert resp.status_code == 200, resp.get_json()
- assert resp.get_json()["data"]["managedOrgId"] == "SELF"
- # 身在 OPS:子机构创建(范围内)→ 201
- resp = b2_client.post(
- "/api/v1/organizations",
- json=_org_payload("ORG_OPS_SELF_CHILD", parent_id=ops),
- headers=_auth(token),
- )
- assert resp.status_code == 201, resp.get_json()
- # 编辑范围外机构(COMMS)→ 403
- resp = b2_client.put(
- f"/api/v1/organizations/{comms}",
- json={
- "orgCode": "ORG_COMMS",
- "orgName": "通信部",
- "parentId": root,
- "sortNo": 10,
- "status": "ENABLED",
- "rowVersion": 0,
- },
- headers=_auth(token),
- )
- assert resp.status_code == 403
- # 创建顶级机构 → 403(仅全局管理员可建顶级)
- resp = b2_client.post(
- "/api/v1/organizations",
- json=_org_payload("ORG_SELF_TOP", parent_id=None),
- headers=_auth(token),
- )
- assert resp.status_code == 403
- @pytest.mark.integration
- def test_self_anchor_without_org_has_no_scope(
- b2_app, b2_client, login_token, b2_password
- ):
- """锚点 "SELF" 且管理员自身无部门:管理范围为空,任何机构写操作拒绝。"""
- ops = _org_id(b2_app, "ORG_OPS")
- _create_admin(
- b2_client,
- login_token,
- username="orgless_admin",
- organization_id=None,
- perm_org=True,
- managed_org_id="SELF",
- )
- token = _login(b2_client, ADMIN_INITIAL_PASSWORD, username="orgless_admin")
- resp = b2_client.post(
- "/api/v1/organizations",
- json=_org_payload("ORG_ORGLESS_CHILD", parent_id=ops),
- headers=_auth(token),
- )
- assert resp.status_code == 403
|