test_auth_apikey_rbac.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. """Integration tests for API key RBAC enforcement (security fix C1)."""
  2. import pytest
  3. from httpx import AsyncClient
  4. @pytest.fixture
  5. async def api_key_data(async_client: AsyncClient, db_session):
  6. """Create an API key and return its full key value."""
  7. from backend.app.core.auth import generate_api_key
  8. from backend.app.models.api_key import APIKey
  9. full_key, key_hash, key_prefix = generate_api_key()
  10. api_key = APIKey(
  11. name="test-key",
  12. key_hash=key_hash,
  13. key_prefix=key_prefix,
  14. can_queue=True,
  15. can_control_printer=True,
  16. can_read_status=True,
  17. enabled=True,
  18. )
  19. db_session.add(api_key)
  20. await db_session.commit()
  21. return full_key
  22. @pytest.fixture
  23. async def spoolman_settings(db_session):
  24. from backend.app.models.settings import Settings
  25. db_session.add(Settings(key="spoolman_enabled", value="true"))
  26. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  27. await db_session.commit()
  28. class TestApiKeyRbacDenied:
  29. """API keys must be refused for admin-only endpoints."""
  30. @pytest.mark.asyncio
  31. @pytest.mark.integration
  32. async def test_api_key_cannot_access_settings_update_endpoint(
  33. self, async_client: AsyncClient, db_session, api_key_data
  34. ):
  35. """API key must not be usable for settings:update endpoints (C1)."""
  36. from backend.app.models.settings import Settings
  37. db_session.add(Settings(key="auth_enabled", value="true"))
  38. await db_session.commit()
  39. resp = await async_client.put(
  40. "/api/v1/settings/",
  41. json={},
  42. headers={"X-API-Key": api_key_data},
  43. )
  44. assert resp.status_code == 403
  45. assert "administrative operations" in resp.json()["detail"]
  46. @pytest.mark.asyncio
  47. @pytest.mark.integration
  48. async def test_api_key_bearer_cannot_access_settings_update(
  49. self, async_client: AsyncClient, db_session, api_key_data
  50. ):
  51. """Bearer bb_ API key must also be refused for settings:update (C1)."""
  52. from backend.app.models.settings import Settings
  53. db_session.add(Settings(key="auth_enabled", value="true"))
  54. await db_session.commit()
  55. resp = await async_client.put(
  56. "/api/v1/settings/",
  57. json={},
  58. headers={"Authorization": f"Bearer {api_key_data}"},
  59. )
  60. assert resp.status_code == 403
  61. assert "administrative operations" in resp.json()["detail"]
  62. class TestApiKeyRbacAllowed:
  63. """API keys must still work for non-admin endpoints."""
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_api_key_can_access_inventory_read(
  67. self, async_client: AsyncClient, db_session, api_key_data, spoolman_settings
  68. ):
  69. """API key must be accepted for inventory:read endpoints (C1)."""
  70. from unittest.mock import AsyncMock, MagicMock, patch
  71. from backend.app.models.settings import Settings
  72. db_session.add(Settings(key="auth_enabled", value="true"))
  73. await db_session.commit()
  74. mock_client = MagicMock()
  75. mock_client.base_url = "http://localhost:7912"
  76. mock_client.health_check = AsyncMock(return_value=True)
  77. mock_client.get_all_spools = AsyncMock(return_value=[])
  78. mock_client.get_distinct_locations = AsyncMock(return_value=[])
  79. with patch(
  80. "backend.app.api.routes.spoolman_inventory._get_client",
  81. AsyncMock(return_value=mock_client),
  82. ):
  83. resp = await async_client.get(
  84. "/api/v1/spoolman/inventory/spools",
  85. headers={"X-API-Key": api_key_data},
  86. )
  87. assert resp.status_code == 200
  88. class TestApiKeyDenylistIntegrity:
  89. """Drift-detection: assert that admin-tier permissions remain in the denylist."""
  90. def test_admin_permissions_are_denied_for_api_keys(self):
  91. """All known admin-tier permissions must be in _APIKEY_DENIED_PERMISSIONS (H1 guard)."""
  92. from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
  93. from backend.app.core.permissions import Permission
  94. expected_denied = {
  95. # SETTINGS_READ is intentionally NOT denied — SpoolBuddy kiosk reads
  96. # settings via API key (e.g. to sync the UI language).
  97. Permission.SETTINGS_UPDATE,
  98. Permission.SETTINGS_BACKUP,
  99. Permission.SETTINGS_RESTORE,
  100. Permission.USERS_READ,
  101. Permission.USERS_CREATE,
  102. Permission.USERS_UPDATE,
  103. Permission.USERS_DELETE,
  104. Permission.GROUPS_READ,
  105. Permission.GROUPS_CREATE,
  106. Permission.GROUPS_UPDATE,
  107. Permission.GROUPS_DELETE,
  108. Permission.API_KEYS_READ,
  109. Permission.API_KEYS_CREATE,
  110. Permission.API_KEYS_UPDATE,
  111. Permission.API_KEYS_DELETE,
  112. Permission.GITHUB_BACKUP,
  113. Permission.GITHUB_RESTORE,
  114. Permission.FIRMWARE_UPDATE,
  115. }
  116. missing = expected_denied - _APIKEY_DENIED_PERMISSIONS
  117. assert not missing, (
  118. f"Admin-tier permissions not in API key denylist (add them to _APIKEY_DENIED_PERMISSIONS): {missing}"
  119. )
  120. def test_operational_permissions_are_allowed_for_api_keys(self):
  121. """Core operational permissions must NOT be in the denylist."""
  122. from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
  123. from backend.app.core.permissions import Permission
  124. # NOTE: under the GHSA-r2qv-8222-hqg3 allowlist model, INVENTORY_CREATE
  125. # and INVENTORY_UPDATE are administrative (not in the allowlist) and
  126. # therefore denied for API keys regardless of denylist membership.
  127. # This test still guards the small denylist-redundancy set of read-y
  128. # permissions that the SpoolBuddy kiosk + status integrations rely on.
  129. expected_allowed = {
  130. Permission.INVENTORY_READ,
  131. Permission.PRINTERS_READ,
  132. Permission.PRINTERS_CONTROL,
  133. Permission.ARCHIVES_READ,
  134. # SpoolBuddy kiosk reads settings (e.g. language) via API key — must stay allowed.
  135. Permission.SETTINGS_READ,
  136. }
  137. incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
  138. assert not incorrectly_denied, f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"
  139. class TestApiKeyScopeAllowlist:
  140. """GHSA-r2qv-8222-hqg3 (CVSS 9.9) — allowlist-based scope enforcement.
  141. Verifies that ``_check_apikey_permissions`` (and the higher-level
  142. dependencies that call it) honour the per-permission scope mapping rather
  143. than the legacy denylist-only model. Failures here would re-open the
  144. "Read Status / Manage Queue / Control Printer / Manage Library checkboxes
  145. are decorative" class of bug.
  146. """
  147. def test_every_permission_has_a_classification(self):
  148. """Structural: every Permission must be either allowlisted or admin-denied.
  149. This is the load-bearing drift-detection test for the allowlist model.
  150. A new Permission added to ``core/permissions.py`` without a matching
  151. entry in ``_APIKEY_SCOPE_BY_PERMISSION`` or ``_APIKEY_DENIED_PERMISSIONS``
  152. is functionally admin-only (allowlist failure → 403) — that's the safe
  153. default, but it should be an explicit choice rather than an oversight.
  154. """
  155. from backend.app.core.auth import (
  156. _APIKEY_DENIED_PERMISSIONS,
  157. _APIKEY_SCOPE_BY_PERMISSION,
  158. )
  159. from backend.app.core.permissions import Permission
  160. unclassified = {
  161. perm
  162. for perm in Permission
  163. if perm not in _APIKEY_SCOPE_BY_PERMISSION and perm not in _APIKEY_DENIED_PERMISSIONS
  164. }
  165. assert not unclassified, (
  166. "Every Permission must be classified for API-key access. "
  167. "Either add to _APIKEY_SCOPE_BY_PERMISSION (with scope flag) or "
  168. f"_APIKEY_DENIED_PERMISSIONS (admin-only). Unclassified: {unclassified}"
  169. )
  170. def test_allowlist_uses_only_valid_scope_flags(self):
  171. """Every value in the scope mapping must be a real bool field on APIKey."""
  172. from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
  173. from backend.app.models.api_key import APIKey
  174. # can_access_cloud / can_update_energy_cost are narrow opt-in scopes;
  175. # the latter routes through its own ``require_energy_cost_update`` dep
  176. # rather than the central allowlist, so it doesn't appear here.
  177. valid_flags = {
  178. "can_read_status",
  179. "can_queue",
  180. "can_control_printer",
  181. "can_manage_library",
  182. "can_manage_inventory",
  183. "can_manage_maintenance",
  184. "can_access_cloud",
  185. }
  186. used_flags = set(_APIKEY_SCOPE_BY_PERMISSION.values())
  187. assert used_flags <= valid_flags, f"Unknown scope flags in mapping: {used_flags - valid_flags}"
  188. # And every flag must actually exist on the model.
  189. for flag in valid_flags:
  190. assert hasattr(APIKey, flag), f"APIKey model missing column referenced by allowlist: {flag}"
  191. def test_allowlist_and_denylist_are_disjoint(self):
  192. """A permission classified as allowlisted must not also be in the denylist (and v/v)."""
  193. from backend.app.core.auth import (
  194. _APIKEY_DENIED_PERMISSIONS,
  195. _APIKEY_SCOPE_BY_PERMISSION,
  196. )
  197. overlap = set(_APIKEY_SCOPE_BY_PERMISSION) & _APIKEY_DENIED_PERMISSIONS
  198. assert not overlap, f"Permissions in both allowlist and denylist: {overlap}"
  199. @pytest.mark.parametrize(
  200. "scope_flag",
  201. [
  202. "can_read_status",
  203. "can_queue",
  204. "can_control_printer",
  205. "can_manage_library",
  206. "can_manage_inventory",
  207. "can_manage_maintenance",
  208. "can_access_cloud",
  209. ],
  210. )
  211. def test_each_scope_flag_has_at_least_one_permission(self, scope_flag):
  212. """If a scope flag has no permissions, it's dead code — fail loudly."""
  213. from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
  214. assert scope_flag in _APIKEY_SCOPE_BY_PERMISSION.values(), (
  215. f"No permission maps to {scope_flag} — either remove the flag or classify a permission under it."
  216. )
  217. class _FakeApiKey:
  218. """Bool-attribute stand-in for APIKey used by the scope matrix tests.
  219. The ``_check_apikey_permissions`` function only inspects the four scope
  220. booleans, so a lightweight stub is enough; instantiating the real model
  221. requires a DB session which is overkill for pure-logic verification.
  222. """
  223. def __init__(
  224. self,
  225. can_read_status=False,
  226. can_queue=False,
  227. can_control_printer=False,
  228. can_manage_library=False,
  229. can_manage_inventory=False,
  230. can_manage_maintenance=False,
  231. ):
  232. self.can_read_status = can_read_status
  233. self.can_queue = can_queue
  234. self.can_control_printer = can_control_printer
  235. self.can_manage_library = can_manage_library
  236. self.can_manage_inventory = can_manage_inventory
  237. self.can_manage_maintenance = can_manage_maintenance
  238. class TestCheckApiKeyPermissionsMatrix:
  239. """Pure-logic matrix: every (scope flag combo × representative permission) outcome.
  240. These are the tests that would have caught GHSA-r2qv-8222-hqg3 — they prove
  241. the actual gate function honours the scope flags, not just that some
  242. helper called by webhook.py does.
  243. """
  244. # (Permission, expected scope flag attribute, category description)
  245. _SCOPE_CASES = [
  246. # can_read_status
  247. ("PRINTERS_READ", "can_read_status", "read printer status"),
  248. ("ARCHIVES_READ", "can_read_status", "read archives"),
  249. ("QUEUE_READ", "can_read_status", "read queue"),
  250. ("SETTINGS_READ", "can_read_status", "SpoolBuddy kiosk settings read"),
  251. ("WEBSOCKET_CONNECT", "can_read_status", "websocket subscribe"),
  252. # can_queue
  253. ("QUEUE_CREATE", "can_queue", "add queue item"),
  254. ("QUEUE_DELETE_ALL", "can_queue", "delete any queue item"),
  255. ("ARCHIVES_REPRINT_ALL", "can_queue", "reprint an archive"),
  256. # can_control_printer
  257. ("PRINTERS_CONTROL", "can_control_printer", "start/stop print"),
  258. ("PRINTERS_FILES", "can_control_printer", "send file to printer"),
  259. ("SMART_PLUGS_CONTROL", "can_control_printer", "smart plug on/off"),
  260. # can_manage_library — OWN and ALL ownership variants both fold into
  261. # the same scope (#1832): API keys have no per-row ownership identity,
  262. # so splitting OWN/ALL across allowlist/denylist made the curation
  263. # surface unreachable. PURGE stays admin-only.
  264. ("LIBRARY_UPLOAD", "can_manage_library", "upload library file"),
  265. ("LIBRARY_UPDATE_OWN", "can_manage_library", "rename own library file"),
  266. ("LIBRARY_UPDATE_ALL", "can_manage_library", "rename any library file"),
  267. ("LIBRARY_DELETE_OWN", "can_manage_library", "delete own library file"),
  268. ("LIBRARY_DELETE_ALL", "can_manage_library", "delete any library file"),
  269. ("MAKERWORLD_IMPORT", "can_manage_library", "import from MakerWorld"),
  270. # can_manage_inventory
  271. ("INVENTORY_CREATE", "can_manage_inventory", "create spool record"),
  272. ("INVENTORY_UPDATE", "can_manage_inventory", "update spool / SpoolBuddy kiosk write"),
  273. ("INVENTORY_DELETE", "can_manage_inventory", "delete spool record"),
  274. ("INVENTORY_FORECAST_WRITE", "can_manage_inventory", "update forecast SKU settings"),
  275. # can_manage_maintenance (#1832 follow-up) — HA "cleaned nozzle" / reset counter
  276. # is the load-bearing use case; MAINTENANCE_UPDATE gates POST /maintenance/items/{id}/perform.
  277. ("MAINTENANCE_CREATE", "can_manage_maintenance", "assign maintenance type to printer"),
  278. ("MAINTENANCE_UPDATE", "can_manage_maintenance", "log maintenance / edit interval"),
  279. ("MAINTENANCE_DELETE", "can_manage_maintenance", "remove custom maintenance item"),
  280. ]
  281. _ADMIN_CASES = [
  282. # Documented denylist
  283. "SETTINGS_UPDATE",
  284. "USERS_CREATE",
  285. "GROUPS_DELETE",
  286. "API_KEYS_CREATE",
  287. "GITHUB_BACKUP",
  288. "FIRMWARE_UPDATE",
  289. # Unmapped administrative (allowlist fail-closed catches these too)
  290. "PRINTERS_CREATE",
  291. # LIBRARY_DELETE_ALL / LIBRARY_UPDATE_ALL moved to can_manage_library
  292. # under #1832 — covered by the _SCOPE_CASES matrix above.
  293. "LIBRARY_PURGE",
  294. "DISCOVERY_SCAN",
  295. ]
  296. @pytest.mark.parametrize("perm_name,required_flag,_descr", _SCOPE_CASES)
  297. def test_permission_allowed_only_when_scope_flag_is_set(self, perm_name, required_flag, _descr):
  298. """For each (Permission, scope) case, true→allow and false→403."""
  299. from fastapi import HTTPException
  300. from backend.app.core.auth import _check_apikey_permissions
  301. from backend.app.core.permissions import Permission
  302. perm = Permission[perm_name].value
  303. # Flag set → passes
  304. _check_apikey_permissions(_FakeApiKey(**{required_flag: True}), [perm])
  305. # All flags off → 403
  306. with pytest.raises(HTTPException) as exc:
  307. _check_apikey_permissions(_FakeApiKey(), [perm])
  308. assert exc.value.status_code == 403
  309. # Wrong flag set, required flag off → 403 (no cross-scope leakage)
  310. other_flags = {
  311. f
  312. for f in (
  313. "can_read_status",
  314. "can_queue",
  315. "can_control_printer",
  316. "can_manage_library",
  317. "can_manage_inventory",
  318. "can_manage_maintenance",
  319. )
  320. if f != required_flag
  321. }
  322. for other in other_flags:
  323. with pytest.raises(HTTPException) as exc:
  324. _check_apikey_permissions(_FakeApiKey(**{other: True}), [perm])
  325. assert exc.value.status_code == 403
  326. @pytest.mark.parametrize("perm_name", _ADMIN_CASES)
  327. def test_admin_permissions_are_403_regardless_of_flags(self, perm_name):
  328. """A fully-flagged API key still cannot use administrative permissions."""
  329. from fastapi import HTTPException
  330. from backend.app.core.auth import _check_apikey_permissions
  331. from backend.app.core.permissions import Permission
  332. perm = Permission[perm_name].value
  333. all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
  334. with pytest.raises(HTTPException) as exc:
  335. _check_apikey_permissions(all_flags, [perm])
  336. assert exc.value.status_code == 403
  337. assert "administrative" in exc.value.detail.lower() or "does not have" in exc.value.detail.lower()
  338. def test_unknown_permission_string_is_admin_denied(self):
  339. """An unrecognised permission string must fail closed, not silently pass."""
  340. from fastapi import HTTPException
  341. from backend.app.core.auth import _check_apikey_permissions
  342. all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
  343. with pytest.raises(HTTPException) as exc:
  344. _check_apikey_permissions(all_flags, ["bogus:nonexistent"])
  345. assert exc.value.status_code == 403
  346. def test_empty_perm_list_is_403(self):
  347. """Defence-in-depth: an empty perm list must not silently allow."""
  348. from fastapi import HTTPException
  349. from backend.app.core.auth import _check_apikey_permissions
  350. all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
  351. with pytest.raises(HTTPException) as exc:
  352. _check_apikey_permissions(all_flags, [])
  353. assert exc.value.status_code == 403
  354. def test_require_any_at_least_one_must_pass(self):
  355. """``require_any=True`` matches any-of semantics, but still respects scopes."""
  356. from fastapi import HTTPException
  357. from backend.app.core.auth import _check_apikey_permissions
  358. from backend.app.core.permissions import Permission
  359. # can_read_status only: any-of (PRINTERS_READ, QUEUE_CREATE) passes because the read flag is set.
  360. _check_apikey_permissions(
  361. _FakeApiKey(can_read_status=True),
  362. [Permission.PRINTERS_READ.value, Permission.QUEUE_CREATE.value],
  363. require_any=True,
  364. )
  365. # No flags: any-of fails.
  366. with pytest.raises(HTTPException):
  367. _check_apikey_permissions(
  368. _FakeApiKey(),
  369. [Permission.PRINTERS_READ.value, Permission.QUEUE_CREATE.value],
  370. require_any=True,
  371. )
  372. # All admin perms: any-of fails even with every flag set.
  373. with pytest.raises(HTTPException):
  374. _check_apikey_permissions(
  375. _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True),
  376. [Permission.USERS_CREATE.value, Permission.GROUPS_DELETE.value],
  377. require_any=True,
  378. )
  379. def test_require_all_every_perm_must_pass(self):
  380. """Default ``require_any=False``: every permission must pass — single failure → 403."""
  381. from fastapi import HTTPException
  382. from backend.app.core.auth import _check_apikey_permissions
  383. from backend.app.core.permissions import Permission
  384. # Read+queue set, queue+control required → fails because control flag is off.
  385. with pytest.raises(HTTPException) as exc:
  386. _check_apikey_permissions(
  387. _FakeApiKey(can_read_status=True, can_queue=True),
  388. [Permission.QUEUE_CREATE.value, Permission.PRINTERS_CONTROL.value],
  389. )
  390. assert exc.value.status_code == 403