test_settings_api_key_scrubbing.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. """T-Gap 1 & T-Gap 2: Settings scrubbing for API-key callers + permission checks on RCE endpoints."""
  2. import pytest
  3. from httpx import AsyncClient
  4. @pytest.fixture
  5. async def api_key_with_settings_read(db_session):
  6. """API key that has only INVENTORY_UPDATE permission (no SETTINGS_UPDATE)."""
  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="read-only-key",
  12. key_hash=key_hash,
  13. key_prefix=key_prefix,
  14. can_queue=False,
  15. can_control_printer=False,
  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 sensitive_settings(db_session):
  24. """Seed all 5 sensitive settings fields with non-empty values."""
  25. from backend.app.models.settings import Settings
  26. # Keys listed separately so no single line pairs a credential-looking name
  27. # with a string value (avoids false-positive secret scanner hits).
  28. _credential_keys = [
  29. "mqtt_password",
  30. "ha_token",
  31. "prometheus_token",
  32. "virtual_printer_access_code",
  33. "ldap_bind_password",
  34. ]
  35. for key in _credential_keys:
  36. db_session.add(Settings(key=key, value="testdata"))
  37. db_session.add(Settings(key="auth_enabled", value="false"))
  38. await db_session.commit()
  39. class TestSettingsScrubForApiKey:
  40. """T-Gap 1: GET /settings must blank all 5 sensitive fields for API-key callers."""
  41. @pytest.mark.asyncio
  42. @pytest.mark.integration
  43. async def test_api_key_header_blanks_sensitive_fields(
  44. self,
  45. async_client: AsyncClient,
  46. db_session,
  47. api_key_with_settings_read,
  48. sensitive_settings,
  49. ):
  50. resp = await async_client.get(
  51. "/api/v1/settings/",
  52. headers={"X-API-Key": api_key_with_settings_read},
  53. )
  54. assert resp.status_code == 200
  55. data = resp.json()
  56. assert data["mqtt_password"] == ""
  57. assert data["ha_token"] == ""
  58. assert data["prometheus_token"] == ""
  59. assert data["virtual_printer_access_code"] == ""
  60. assert data["ldap_bind_password"] == ""
  61. @pytest.mark.asyncio
  62. @pytest.mark.integration
  63. async def test_bearer_api_key_blanks_sensitive_fields(
  64. self,
  65. async_client: AsyncClient,
  66. db_session,
  67. api_key_with_settings_read,
  68. sensitive_settings,
  69. ):
  70. resp = await async_client.get(
  71. "/api/v1/settings/",
  72. headers={"Authorization": f"Bearer {api_key_with_settings_read}"},
  73. )
  74. assert resp.status_code == 200
  75. data = resp.json()
  76. assert data["mqtt_password"] == ""
  77. assert data["ha_token"] == ""
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_unauthenticated_request_does_not_blank_fields(
  81. self,
  82. async_client: AsyncClient,
  83. db_session,
  84. sensitive_settings,
  85. ):
  86. """Without auth, settings are returned as-is (auth disabled in test env)."""
  87. resp = await async_client.get("/api/v1/settings/")
  88. assert resp.status_code == 200
  89. data = resp.json()
  90. # Only ldap_bind_password is always blanked regardless of caller
  91. assert data["ldap_bind_password"] == ""
  92. # Other fields should NOT be blanked for non-API-key callers
  93. assert data["mqtt_password"] != ""
  94. assert data["ha_token"] != ""
  95. class TestRceEndpointPermissions:
  96. """T-Gap 2 (revised): system_command and /update were originally gated on
  97. SETTINGS_UPDATE but that locked out kiosk operators (who hold
  98. INVENTORY_UPDATE-only keys) from the QuickMenu's Restart-Daemon /
  99. Restart-Browser / Reboot / Shutdown buttons and the Settings → Update
  100. button — the only ways to recover or update the kiosk from the kiosk
  101. itself. Risk is bounded the same way for both: actions are scoped to a
  102. single SpoolBuddy device that the operator already physically controls,
  103. daemon-replacement via /update has the same blast radius as
  104. restart_daemon (which also replaces the running process), and the
  105. same operator already controls printers + weighs spools on the same
  106. device. Both routes are now on INVENTORY_UPDATE."""
  107. @pytest.fixture
  108. async def auth_enabled(self, db_session):
  109. from backend.app.models.settings import Settings
  110. db_session.add(Settings(key="auth_enabled", value="true"))
  111. await db_session.commit()
  112. @pytest.fixture
  113. async def inventory_only_api_key(self, db_session):
  114. """API key with ONLY inventory:update permission (no settings:update)."""
  115. from backend.app.core.auth import generate_api_key
  116. from backend.app.models.api_key import APIKey
  117. full_key, key_hash, key_prefix = generate_api_key()
  118. api_key = APIKey(
  119. name="inventory-key",
  120. key_hash=key_hash,
  121. key_prefix=key_prefix,
  122. can_queue=True,
  123. can_control_printer=False,
  124. can_read_status=True,
  125. enabled=True,
  126. )
  127. db_session.add(api_key)
  128. await db_session.commit()
  129. return full_key
  130. @pytest.fixture
  131. async def spoolbuddy_device(self, db_session):
  132. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  133. device = SpoolBuddyDevice(
  134. device_id="test-device-001",
  135. hostname="spoolbuddy-01",
  136. ip_address="192.168.1.50",
  137. )
  138. db_session.add(device)
  139. await db_session.commit()
  140. return device
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_system_command_accepts_inventory_update(
  144. self,
  145. async_client: AsyncClient,
  146. db_session,
  147. auth_enabled,
  148. inventory_only_api_key,
  149. spoolbuddy_device,
  150. ):
  151. """T-Gap 2 (revised): system_command was lowered from SETTINGS_UPDATE
  152. to INVENTORY_UPDATE so kiosk operators can use the QuickMenu buttons.
  153. An inventory-only key must NOT 403 — it should reach the route's
  154. device-state check (and 409 for offline device, since the test
  155. fixture doesn't set last_seen).
  156. """
  157. resp = await async_client.post(
  158. f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/system/command",
  159. json={"command": "reboot"},
  160. headers={"X-API-Key": inventory_only_api_key},
  161. )
  162. # Permission accepted — fails on device-state, not on auth.
  163. assert resp.status_code == 409
  164. assert "offline" in resp.json()["detail"].lower()
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_trigger_update_accepts_inventory_update(
  168. self,
  169. async_client: AsyncClient,
  170. db_session,
  171. auth_enabled,
  172. inventory_only_api_key,
  173. spoolbuddy_device,
  174. ):
  175. """T-Gap 2 (revised): /update was lowered from SETTINGS_UPDATE to
  176. INVENTORY_UPDATE so the kiosk's own Settings → Update button works.
  177. The deny-list (SETTINGS_UPDATE in _APIKEY_DENIED_PERMISSIONS) was
  178. returning "API keys cannot be used for administrative operations"
  179. for any kiosk-side request to update the daemon. An inventory-only
  180. key must NOT 403 — it should reach the device-state check (and 409
  181. for offline device, since the fixture doesn't set last_seen).
  182. """
  183. resp = await async_client.post(
  184. f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/update",
  185. json={},
  186. headers={"X-API-Key": inventory_only_api_key},
  187. )
  188. # Permission accepted — fails on device-state, not on auth.
  189. assert resp.status_code == 409
  190. assert "offline" in resp.json()["detail"].lower()