test_settings_ui_flags_3023.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """The app shell can read install configuration without settings:read (#3023).
  2. Reporter @lonix: a user holding `cost_centers:read_own` never saw the Finance
  3. entry in the sidebar. The permission map was right and the route guard was
  4. right -- navigating to /finance directly worked and showed their balance. What
  5. hid it was an extra condition, `billing_enabled !== true`, read from
  6. GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so the
  7. value arrived undefined and the entry was hidden from exactly the users the
  8. permission exists to serve.
  9. SETTINGS_READ cannot be the price of knowing whether billing is on: it also
  10. grants sight of the SMTP, LDAP and MQTT credentials. Hence /settings/ui-flags,
  11. which asks only that the caller be signed in.
  12. It is deliberately not more fields on /settings/ui-preferences. That endpoint is
  13. served to anyone at all, on the recorded grounds that its contents are "public
  14. defaults that ship with the app" (test_route_auth_coverage.py), and its field
  15. set is pinned by a test written to stop exactly this kind of addition. These
  16. fields are not defaults -- they say how this deployment is configured -- so the
  17. last test here pins that they did not leak into it.
  18. """
  19. import secrets
  20. import pytest
  21. from httpx import AsyncClient
  22. from backend.app.models.settings import Settings
  23. FLAGS_URL = "/api/v1/settings/ui-flags"
  24. _FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12) # pragma: allowlist secret
  25. async def _setup_admin(async_client: AsyncClient, username: str) -> str:
  26. await async_client.post(
  27. "/api/v1/auth/setup",
  28. json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
  29. )
  30. login = await async_client.post(
  31. "/api/v1/auth/login",
  32. json={"username": username, "password": _FIXTURE_PW},
  33. )
  34. assert login.status_code == 200, login.text
  35. return login.json()["access_token"]
  36. async def _create_operator(
  37. async_client: AsyncClient,
  38. admin_token: str,
  39. *,
  40. username: str,
  41. permissions: list[str],
  42. ) -> str:
  43. """A non-admin holding exactly `permissions` -- never settings:read."""
  44. headers = {"Authorization": f"Bearer {admin_token}"}
  45. grp = await async_client.post(
  46. "/api/v1/groups/",
  47. headers=headers,
  48. json={"name": f"ui_flags_test_{username}", "permissions": permissions},
  49. )
  50. assert grp.status_code == 201, grp.text
  51. user = await async_client.post(
  52. "/api/v1/users/",
  53. headers=headers,
  54. json={
  55. "username": username,
  56. "password": _FIXTURE_PW,
  57. "role": "user",
  58. "group_ids": [grp.json()["id"]],
  59. },
  60. )
  61. assert user.status_code == 201, user.text
  62. assert user.json()["is_admin"] is False
  63. login = await async_client.post(
  64. "/api/v1/auth/login",
  65. json={"username": username, "password": _FIXTURE_PW},
  66. )
  67. assert login.status_code == 200, login.text
  68. return login.json()["access_token"]
  69. @pytest.mark.integration
  70. class TestTheUserTheEndpointExistsFor:
  71. """A non-admin with cost_centers:read_own and nothing else."""
  72. @pytest.mark.asyncio
  73. async def test_they_can_read_the_flags(self, async_client: AsyncClient):
  74. admin = await _setup_admin(async_client, "flagadmin1")
  75. op = await _create_operator(async_client, admin, username="flagop1", permissions=["cost_centers:read_own"])
  76. resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
  77. assert resp.status_code == 200, resp.text
  78. assert "billing_enabled" in resp.json()
  79. @pytest.mark.asyncio
  80. async def test_they_still_cannot_read_settings(self, async_client: AsyncClient):
  81. """The fix must not have widened SETTINGS_READ to get there."""
  82. admin = await _setup_admin(async_client, "flagadmin2")
  83. op = await _create_operator(async_client, admin, username="flagop2", permissions=["cost_centers:read_own"])
  84. resp = await async_client.get("/api/v1/settings/", headers={"Authorization": f"Bearer {op}"})
  85. assert resp.status_code == 403, resp.text
  86. @pytest.mark.asyncio
  87. async def test_billing_enabled_carries_the_configured_value(self, async_client: AsyncClient, db_session):
  88. """The whole point: the sidebar tests this for `true`, so it has to be
  89. the real value and a real bool, not a truthy string."""
  90. admin = await _setup_admin(async_client, "flagadmin3")
  91. op = await _create_operator(async_client, admin, username="flagop3", permissions=["cost_centers:read_own"])
  92. db_session.add(Settings(key="billing_enabled", value="true"))
  93. await db_session.commit()
  94. resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
  95. assert resp.json()["billing_enabled"] is True
  96. @pytest.mark.integration
  97. class TestTheBoundaryItDraws:
  98. """Signed in is required; settings:read is not."""
  99. @pytest.mark.asyncio
  100. async def test_an_anonymous_caller_is_refused_when_auth_is_on(self, async_client: AsyncClient):
  101. """This is the reason it is a separate endpoint rather than four more
  102. fields on the public one."""
  103. await _setup_admin(async_client, "flagadmin4")
  104. resp = await async_client.get(FLAGS_URL)
  105. assert resp.status_code in (401, 403), resp.text
  106. @pytest.mark.asyncio
  107. async def test_it_answers_when_auth_is_switched_off(self, async_client: AsyncClient):
  108. """An install with no auth has no user to authenticate, and the shell
  109. still has to render. require_auth_if_enabled returns None there."""
  110. resp = await async_client.get(FLAGS_URL)
  111. assert resp.status_code == 200, resp.text
  112. @pytest.mark.integration
  113. class TestWhatItExposes:
  114. @pytest.mark.asyncio
  115. async def test_the_field_set_is_exactly_these_four(self, async_client: AsyncClient):
  116. """Pinned like the /ui-preferences set: anything added here is readable
  117. by every signed-in user, so adding one should require editing this."""
  118. resp = await async_client.get(FLAGS_URL)
  119. assert set(resp.json().keys()) == {
  120. "billing_enabled",
  121. "user_notifications_enabled",
  122. "currency",
  123. "check_updates",
  124. }
  125. @pytest.mark.asyncio
  126. async def test_no_credential_ever_appears(self, async_client: AsyncClient, db_session):
  127. for i, key in enumerate(
  128. ("smtp_password", "ldap_bind_password", "mqtt_password", "ha_token", "prometheus_token")
  129. ):
  130. db_session.add(Settings(key=key, value=f"SECRET_VALUE_{i}_DO_NOT_LEAK"))
  131. await db_session.commit()
  132. body = (await async_client.get(FLAGS_URL)).text
  133. assert "DO_NOT_LEAK" not in body
  134. @pytest.mark.asyncio
  135. async def test_the_public_endpoint_did_not_gain_them(self, async_client: AsyncClient):
  136. """These describe the deployment, not app defaults, so they must not
  137. have been added to the endpoint that serves anyone at all."""
  138. public = (await async_client.get("/api/v1/settings/ui-preferences")).json()
  139. assert "billing_enabled" not in public
  140. assert "user_notifications_enabled" not in public