test_users_groups_privilege_escalation.py 16 KB

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