test_auth_apikey_rbac.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  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. with patch(
  79. "backend.app.api.routes.spoolman_inventory._get_client",
  80. AsyncMock(return_value=mock_client),
  81. ):
  82. resp = await async_client.get(
  83. "/api/v1/spoolman/inventory/spools",
  84. headers={"X-API-Key": api_key_data},
  85. )
  86. assert resp.status_code == 200
  87. class TestApiKeyDenylistIntegrity:
  88. """Drift-detection: assert that admin-tier permissions remain in the denylist."""
  89. def test_admin_permissions_are_denied_for_api_keys(self):
  90. """All known admin-tier permissions must be in _APIKEY_DENIED_PERMISSIONS (H1 guard)."""
  91. from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
  92. from backend.app.core.permissions import Permission
  93. expected_denied = {
  94. # SETTINGS_READ is intentionally NOT denied — SpoolBuddy kiosk reads
  95. # settings via API key (e.g. to sync the UI language).
  96. Permission.SETTINGS_UPDATE,
  97. Permission.SETTINGS_BACKUP,
  98. Permission.SETTINGS_RESTORE,
  99. Permission.USERS_READ,
  100. Permission.USERS_CREATE,
  101. Permission.USERS_UPDATE,
  102. Permission.USERS_DELETE,
  103. Permission.GROUPS_READ,
  104. Permission.GROUPS_CREATE,
  105. Permission.GROUPS_UPDATE,
  106. Permission.GROUPS_DELETE,
  107. Permission.API_KEYS_READ,
  108. Permission.API_KEYS_CREATE,
  109. Permission.API_KEYS_UPDATE,
  110. Permission.API_KEYS_DELETE,
  111. Permission.GITHUB_BACKUP,
  112. Permission.GITHUB_RESTORE,
  113. Permission.FIRMWARE_UPDATE,
  114. }
  115. missing = expected_denied - _APIKEY_DENIED_PERMISSIONS
  116. assert not missing, (
  117. f"Admin-tier permissions not in API key denylist (add them to _APIKEY_DENIED_PERMISSIONS): {missing}"
  118. )
  119. def test_operational_permissions_are_allowed_for_api_keys(self):
  120. """Core operational permissions must NOT be in the denylist."""
  121. from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
  122. from backend.app.core.permissions import Permission
  123. expected_allowed = {
  124. Permission.INVENTORY_READ,
  125. Permission.INVENTORY_CREATE,
  126. Permission.INVENTORY_UPDATE,
  127. Permission.PRINTERS_READ,
  128. Permission.PRINTERS_CONTROL,
  129. Permission.ARCHIVES_READ,
  130. # SpoolBuddy kiosk reads settings (e.g. language) via API key — must stay allowed.
  131. Permission.SETTINGS_READ,
  132. }
  133. incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
  134. assert not incorrectly_denied, f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"