test_users_groups_privilege_escalation.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. """Privilege-escalation regression suite for the users/groups admin boundary.
  2. The intent declared in ``permissions.py`` is that USERS_* / GROUPS_* are
  3. admin-level capabilities — the comments literally say "(admin-level)".
  4. The original implementation enforced ONLY the permission, not admin role.
  5. Any user holding USERS_UPDATE (or USERS_CREATE / GROUPS_UPDATE /
  6. GROUPS_CREATE) could grant themselves admin via the management routes.
  7. This suite reproduces every attack vector from the disclosure and pins
  8. the fail-closed behaviour. Each negative test grants the operator the
  9. minimum permission needed to *reach* the route gate, then asserts the
  10. admin gate blocks them. A companion positive test verifies the same
  11. operation succeeds with an admin token (so the admin gate doesn't
  12. over-block real flows).
  13. Default-install operators do NOT have USERS_* / GROUPS_* (see
  14. ``DEFAULT_GROUPS``), so default deployments were never vulnerable
  15. unless an admin had explicitly granted the permission to a custom
  16. group — but anyone in that position would expect the boundary the
  17. comments described.
  18. """
  19. import pytest
  20. from httpx import AsyncClient
  21. from sqlalchemy import select
  22. from backend.app.models.group import Group
  23. async def _setup_admin(async_client: AsyncClient, username: str = "secadmin") -> str:
  24. await async_client.post(
  25. "/api/v1/auth/setup",
  26. json={"auth_enabled": True, "admin_username": username, "admin_password": "AdminPass1!"},
  27. )
  28. login = await async_client.post(
  29. "/api/v1/auth/login",
  30. json={"username": username, "password": "AdminPass1!"},
  31. )
  32. return login.json()["access_token"]
  33. async def _create_operator_with_perms(
  34. async_client: AsyncClient,
  35. admin_token: str,
  36. db_session,
  37. *,
  38. username: str,
  39. permissions: list[str],
  40. ) -> tuple[str, int]:
  41. """Create a non-admin user, drop them in a custom group with exactly
  42. the requested permissions, return (token, user_id).
  43. The operator is intentionally NOT an admin and NOT in the Administrators
  44. group — they hold ONLY the listed permission strings. Mirrors the exact
  45. deployment shape the security engineer described: an operator gifted
  46. one admin-level permission via a custom group ends up able to escalate
  47. to full admin without the gate.
  48. """
  49. headers = {"Authorization": f"Bearer {admin_token}"}
  50. # Create a custom group carrying just the requested permissions.
  51. grp_resp = await async_client.post(
  52. "/api/v1/groups/",
  53. headers=headers,
  54. json={"name": f"escalation_test_{username}", "permissions": permissions},
  55. )
  56. assert grp_resp.status_code == 201, grp_resp.text
  57. gid = grp_resp.json()["id"]
  58. # Create a regular (role="user") user.
  59. user_resp = await async_client.post(
  60. "/api/v1/users/",
  61. headers=headers,
  62. json={"username": username, "password": "OpPass1234!", "role": "user", "group_ids": [gid]},
  63. )
  64. assert user_resp.status_code == 201, user_resp.text
  65. uid = user_resp.json()["id"]
  66. # Confirm the operator is NOT admin in the response shape.
  67. assert user_resp.json()["is_admin"] is False
  68. login = await async_client.post(
  69. "/api/v1/auth/login",
  70. json={"username": username, "password": "OpPass1234!"},
  71. )
  72. assert login.status_code == 200
  73. return login.json()["access_token"], uid
  74. async def _admin_group_id(db_session) -> int:
  75. result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
  76. return result.scalar_one().id
  77. # ---------------------------------------------------------------------------
  78. # 1. PATCH /users/{id} {role: "admin"} — USERS_UPDATE holder cannot
  79. # self-promote
  80. # ---------------------------------------------------------------------------
  81. @pytest.mark.asyncio
  82. @pytest.mark.integration
  83. async def test_users_update_holder_cannot_set_role_to_admin(async_client: AsyncClient, db_session):
  84. admin_token = await _setup_admin(async_client)
  85. op_token, op_id = await _create_operator_with_perms(
  86. async_client, admin_token, db_session, username="op1", permissions=["users:update"]
  87. )
  88. resp = await async_client.patch(
  89. f"/api/v1/users/{op_id}",
  90. headers={"Authorization": f"Bearer {op_token}"},
  91. json={"role": "admin"},
  92. )
  93. assert resp.status_code == 403
  94. # And the operator is not admin in the DB after the attempted patch.
  95. from backend.app.models.user import User
  96. result = await db_session.execute(select(User).where(User.id == op_id))
  97. user = result.scalar_one()
  98. assert user.role == "user"
  99. @pytest.mark.asyncio
  100. @pytest.mark.integration
  101. async def test_users_update_holder_cannot_target_other_user(async_client: AsyncClient, db_session):
  102. admin_token = await _setup_admin(async_client)
  103. op_token, _ = await _create_operator_with_perms(
  104. async_client, admin_token, db_session, username="op2", permissions=["users:update"]
  105. )
  106. # Create a separate target user.
  107. headers = {"Authorization": f"Bearer {admin_token}"}
  108. target = await async_client.post(
  109. "/api/v1/users/",
  110. headers=headers,
  111. json={"username": "target", "password": "TargetPass1!", "role": "user"},
  112. )
  113. target_id = target.json()["id"]
  114. # Operator attempts to elevate target to admin.
  115. resp = await async_client.patch(
  116. f"/api/v1/users/{target_id}",
  117. headers={"Authorization": f"Bearer {op_token}"},
  118. json={"role": "admin"},
  119. )
  120. assert resp.status_code == 403
  121. # ---------------------------------------------------------------------------
  122. # 2. POST /users/ {role: "admin"} — USERS_CREATE holder cannot create admin
  123. # ---------------------------------------------------------------------------
  124. @pytest.mark.asyncio
  125. @pytest.mark.integration
  126. async def test_users_create_holder_cannot_create_admin(async_client: AsyncClient, db_session):
  127. admin_token = await _setup_admin(async_client)
  128. op_token, _ = await _create_operator_with_perms(
  129. async_client, admin_token, db_session, username="op3", permissions=["users:create"]
  130. )
  131. resp = await async_client.post(
  132. "/api/v1/users/",
  133. headers={"Authorization": f"Bearer {op_token}"},
  134. json={"username": "newadmin", "password": "NewAdmin1!", "role": "admin"},
  135. )
  136. assert resp.status_code == 403
  137. # ---------------------------------------------------------------------------
  138. # 3. PATCH /groups/{id} {permissions: [...]} — GROUPS_UPDATE holder cannot
  139. # rewrite a group to admin-equivalent
  140. # ---------------------------------------------------------------------------
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_groups_update_holder_cannot_rewrite_permissions(async_client: AsyncClient, db_session):
  144. admin_token = await _setup_admin(async_client)
  145. op_token, _ = await _create_operator_with_perms(
  146. async_client, admin_token, db_session, username="op4", permissions=["groups:update"]
  147. )
  148. # Admin creates a target group; operator tries to grant it everything.
  149. headers = {"Authorization": f"Bearer {admin_token}"}
  150. create = await async_client.post(
  151. "/api/v1/groups/",
  152. headers=headers,
  153. json={"name": "innocent", "permissions": ["printers:read"]},
  154. )
  155. gid = create.json()["id"]
  156. from backend.app.core.permissions import ALL_PERMISSIONS
  157. resp = await async_client.patch(
  158. f"/api/v1/groups/{gid}",
  159. headers={"Authorization": f"Bearer {op_token}"},
  160. json={"permissions": ALL_PERMISSIONS},
  161. )
  162. assert resp.status_code == 403
  163. # And the group still has its original (narrow) permissions.
  164. result = await db_session.execute(select(Group).where(Group.id == gid))
  165. assert result.scalar_one().permissions == ["printers:read"]
  166. # ---------------------------------------------------------------------------
  167. # 4. POST /groups/ {permissions: [...]} — GROUPS_CREATE holder cannot create
  168. # an admin-equivalent group
  169. # ---------------------------------------------------------------------------
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_groups_create_holder_cannot_create_admin_equivalent(async_client: AsyncClient, db_session):
  173. admin_token = await _setup_admin(async_client)
  174. op_token, _ = await _create_operator_with_perms(
  175. async_client, admin_token, db_session, username="op5", permissions=["groups:create"]
  176. )
  177. from backend.app.core.permissions import ALL_PERMISSIONS
  178. resp = await async_client.post(
  179. "/api/v1/groups/",
  180. headers={"Authorization": f"Bearer {op_token}"},
  181. json={"name": "shadowadmins", "permissions": ALL_PERMISSIONS},
  182. )
  183. assert resp.status_code == 403
  184. # ---------------------------------------------------------------------------
  185. # 5. POST /groups/{admin_gid}/users/{my_id} — GROUPS_UPDATE holder cannot
  186. # self-add to Administrators
  187. # ---------------------------------------------------------------------------
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_groups_update_holder_cannot_self_add_to_administrators(async_client: AsyncClient, db_session):
  191. admin_token = await _setup_admin(async_client)
  192. op_token, op_id = await _create_operator_with_perms(
  193. async_client, admin_token, db_session, username="op6", permissions=["groups:update"]
  194. )
  195. admin_gid = await _admin_group_id(db_session)
  196. resp = await async_client.post(
  197. f"/api/v1/groups/{admin_gid}/users/{op_id}",
  198. headers={"Authorization": f"Bearer {op_token}"},
  199. )
  200. assert resp.status_code == 403
  201. # ---------------------------------------------------------------------------
  202. # 6. PATCH /groups/{system_gid} — even an admin must not be able to strip
  203. # the Administrators group's permissions (DoS guard).
  204. # ---------------------------------------------------------------------------
  205. @pytest.mark.asyncio
  206. @pytest.mark.integration
  207. async def test_admin_cannot_strip_administrators_group_permissions(async_client: AsyncClient, db_session):
  208. admin_token = await _setup_admin(async_client)
  209. headers = {"Authorization": f"Bearer {admin_token}"}
  210. admin_gid = await _admin_group_id(db_session)
  211. resp = await async_client.patch(
  212. f"/api/v1/groups/{admin_gid}",
  213. headers=headers,
  214. json={"permissions": []},
  215. )
  216. assert resp.status_code == 400
  217. assert "system groups" in resp.json()["detail"].lower()
  218. # Untouched in DB.
  219. result = await db_session.execute(select(Group).where(Group.id == admin_gid))
  220. grp = result.scalar_one()
  221. assert len(grp.permissions or []) > 0
  222. # ---------------------------------------------------------------------------
  223. # Positive companions — admin should succeed on each route (the admin gate
  224. # must not over-block normal admin flows).
  225. # ---------------------------------------------------------------------------
  226. @pytest.mark.asyncio
  227. @pytest.mark.integration
  228. async def test_admin_can_still_perform_user_role_change(async_client: AsyncClient, db_session):
  229. admin_token = await _setup_admin(async_client)
  230. headers = {"Authorization": f"Bearer {admin_token}"}
  231. target = await async_client.post(
  232. "/api/v1/users/",
  233. headers=headers,
  234. json={"username": "promoteme", "password": "Promote1!", "role": "user"},
  235. )
  236. tid = target.json()["id"]
  237. resp = await async_client.patch(
  238. f"/api/v1/users/{tid}",
  239. headers=headers,
  240. json={"role": "admin"},
  241. )
  242. assert resp.status_code == 200
  243. assert resp.json()["role"] == "admin"
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_administrators_group_member_passes_admin_gate(async_client: AsyncClient, db_session):
  247. """A user whose admin status comes from Administrators-group membership
  248. rather than the legacy ``role`` column must pass the admin gate. The
  249. canonical signal is ``User.is_admin``, not ``role == 'admin'``.
  250. Uses a write endpoint (PATCH /users/{id} {role}) since the admin gate
  251. lives on writes only — reads stay at ``USERS_READ`` so operator UIs
  252. (Stats filter-by-user, Archives Print Log, File Manager username
  253. autocomplete) keep working for non-admin operators who hold the
  254. read permission via a custom group."""
  255. admin_token = await _setup_admin(async_client)
  256. headers = {"Authorization": f"Bearer {admin_token}"}
  257. admin_gid = await _admin_group_id(db_session)
  258. # Create a regular user, then add them to Administrators.
  259. user_resp = await async_client.post(
  260. "/api/v1/users/",
  261. headers=headers,
  262. json={"username": "groupadmin", "password": "GroupAdmin1!", "role": "user"},
  263. )
  264. uid = user_resp.json()["id"]
  265. add = await async_client.post(f"/api/v1/groups/{admin_gid}/users/{uid}", headers=headers)
  266. assert add.status_code == 204
  267. # Also create a separate target user to mutate (cleaner than self-modify).
  268. target_resp = await async_client.post(
  269. "/api/v1/users/",
  270. headers=headers,
  271. json={"username": "target_member", "password": "Target1234!", "role": "user"},
  272. )
  273. target_id = target_resp.json()["id"]
  274. login = await async_client.post("/api/v1/auth/login", json={"username": "groupadmin", "password": "GroupAdmin1!"})
  275. group_admin_token = login.json()["access_token"]
  276. # Through an admin-gated write route — must succeed.
  277. resp = await async_client.patch(
  278. f"/api/v1/users/{target_id}",
  279. headers={"Authorization": f"Bearer {group_admin_token}"},
  280. json={"is_active": False},
  281. )
  282. assert resp.status_code == 200
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_users_read_remains_delegable_to_non_admin(async_client: AsyncClient, db_session):
  286. """Operator-visible UIs (Stats filter-by-user, Archives Print Log
  287. username column, File Manager username autocomplete) reach
  288. ``GET /users/`` for non-admin operators when a deployment granted
  289. them ``users:read`` via a custom group. The admin gate must NOT
  290. apply to read endpoints — only to writes."""
  291. admin_token = await _setup_admin(async_client)
  292. op_token, _ = await _create_operator_with_perms(
  293. async_client, admin_token, db_session, username="reader", permissions=["users:read"]
  294. )
  295. resp = await async_client.get("/api/v1/users/", headers={"Authorization": f"Bearer {op_token}"})
  296. assert resp.status_code == 200
  297. # Operator is in the list with is_admin=False — confirms the read is
  298. # working AND the operator hasn't escalated.
  299. me = next(u for u in resp.json() if u["username"] == "reader")
  300. assert me["is_admin"] is False
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_groups_read_remains_delegable_to_non_admin(async_client: AsyncClient, db_session):
  304. """Companion to ``users:read``. ``GET /groups/`` + ``GET /groups/
  305. permissions`` stay reachable to non-admin operators with the read
  306. permission. Used by setup wizards / informational lookups."""
  307. admin_token = await _setup_admin(async_client)
  308. op_token, _ = await _create_operator_with_perms(
  309. async_client, admin_token, db_session, username="greader", permissions=["groups:read"]
  310. )
  311. headers = {"Authorization": f"Bearer {op_token}"}
  312. list_resp = await async_client.get("/api/v1/groups/", headers=headers)
  313. assert list_resp.status_code == 200
  314. perms_resp = await async_client.get("/api/v1/groups/permissions", headers=headers)
  315. assert perms_resp.status_code == 200