test_users_groups_privilege_escalation.py 15 KB

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