test_oidc_env_apply.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. """Upserting the env-managed OIDC provider (#2593).
  2. Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
  3. never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
  4. recreating the provider would silently unlink every account bound to it.
  5. """
  6. from __future__ import annotations
  7. import logging
  8. import pytest
  9. from sqlalchemy import select
  10. from backend.app.core.oidc_env import apply_env_oidc_provider
  11. from backend.app.models.oidc_provider import OIDCProvider
  12. REQUIRED = {
  13. "BAMBUDDY_OIDC_NAME": "Keycloak",
  14. "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
  15. "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
  16. "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
  17. }
  18. ALL_VARS = (
  19. *REQUIRED,
  20. "BAMBUDDY_OIDC_SCOPES",
  21. "BAMBUDDY_OIDC_ENABLED",
  22. "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
  23. "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
  24. "BAMBUDDY_OIDC_EMAIL_CLAIM",
  25. "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
  26. "BAMBUDDY_OIDC_ICON_URL",
  27. "BAMBUDDY_OIDC_AUTOLOGIN",
  28. )
  29. @pytest.fixture(autouse=True)
  30. def clean_env(monkeypatch):
  31. for key in ALL_VARS:
  32. monkeypatch.delenv(key, raising=False)
  33. def _configure(monkeypatch, **overrides):
  34. for key, value in REQUIRED.items():
  35. monkeypatch.setenv(key, value)
  36. for key, value in overrides.items():
  37. monkeypatch.setenv(key, value)
  38. async def _env_provider(db_session) -> OIDCProvider | None:
  39. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  40. return result.scalar_one_or_none()
  41. @pytest.mark.asyncio
  42. async def test_creates_the_provider_from_env(db_session, monkeypatch):
  43. _configure(monkeypatch)
  44. await apply_env_oidc_provider(db_session)
  45. provider = await _env_provider(db_session)
  46. assert provider is not None
  47. assert provider.name == "Keycloak"
  48. assert provider.client_id == "bambuddy"
  49. assert provider.is_env_managed is True
  50. assert provider.client_secret == "s3cr3t" # property decrypts
  51. @pytest.mark.asyncio
  52. async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
  53. """The id must survive: user_oidc_links references it with ON DELETE
  54. CASCADE, so a delete-recreate would unlink every bound account."""
  55. _configure(monkeypatch)
  56. await apply_env_oidc_provider(db_session)
  57. original_id = (await _env_provider(db_session)).id
  58. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
  59. await apply_env_oidc_provider(db_session)
  60. provider = await _env_provider(db_session)
  61. assert provider.id == original_id
  62. assert provider.client_id == "rotated"
  63. @pytest.mark.asyncio
  64. async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
  65. _configure(monkeypatch)
  66. await apply_env_oidc_provider(db_session)
  67. original_id = (await _env_provider(db_session)).id
  68. for key in ALL_VARS:
  69. monkeypatch.delenv(key, raising=False)
  70. await apply_env_oidc_provider(db_session)
  71. # Looked up by name, not by the flag: releasing the provider clears the flag,
  72. # and the point of this test is that the ROW survives either way.
  73. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  74. provider = result.scalar_one_or_none()
  75. assert provider is not None, "deleting would cascade away every account link"
  76. assert provider.id == original_id
  77. assert provider.is_enabled is False
  78. @pytest.mark.asyncio
  79. async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
  80. """Only one provider may be the autologin target; the env one wins."""
  81. ui_provider = OIDCProvider(
  82. name="UI provider",
  83. issuer_url="https://other.example.com",
  84. client_id="ui",
  85. is_autologin=True,
  86. )
  87. ui_provider.client_secret = "ui-secret"
  88. db_session.add(ui_provider)
  89. await db_session.commit()
  90. _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
  91. await apply_env_oidc_provider(db_session)
  92. await db_session.refresh(ui_provider)
  93. assert (await _env_provider(db_session)).is_autologin is True
  94. assert ui_provider.is_autologin is False
  95. @pytest.mark.asyncio
  96. async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
  97. ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
  98. ui_provider.client_secret = "ui-secret"
  99. db_session.add(ui_provider)
  100. await db_session.commit()
  101. _configure(monkeypatch)
  102. await apply_env_oidc_provider(db_session)
  103. await db_session.refresh(ui_provider)
  104. assert ui_provider.is_env_managed is False
  105. assert ui_provider.is_enabled is True
  106. assert ui_provider.client_id == "ui"
  107. @pytest.mark.asyncio
  108. async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
  109. """auto-link + unverified email is the SEC-1 account-takeover shape. The
  110. schema rejects it for the UI, and env config must not be a way around that
  111. -- but a bad variable must not stop the app from booting either."""
  112. _configure(
  113. monkeypatch,
  114. BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
  115. BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
  116. )
  117. await apply_env_oidc_provider(db_session)
  118. assert await _env_provider(db_session) is None
  119. @pytest.mark.asyncio
  120. async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
  121. """client_secret has max_length=512, so an over-long value raises
  122. string_too_long. The rejection must be logged without the value: str(exc)
  123. embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
  124. secret = "S3CR3T" * 100 # > 512 chars -> ValidationError on client_secret
  125. _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
  126. with caplog.at_level(logging.ERROR):
  127. await apply_env_oidc_provider(db_session)
  128. assert await _env_provider(db_session) is None # rejected, not booted-through
  129. assert "rejected" in caplog.text # the rejection was actually logged
  130. assert secret not in caplog.text
  131. assert "S3CR3T" not in caplog.text # not even a fragment of the value
  132. @pytest.mark.asyncio
  133. async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
  134. """The generic except branch handles anything that isn't a ValidationError
  135. (e.g. a library call raising mid-construction). It must not stop boot and,
  136. since such a message could carry a configured value, must log only the
  137. exception class -- never str(exc)."""
  138. # oidc_env imports OIDCProviderCreate inside the function (to avoid an
  139. # import cycle), so patch it at its source module, not on oidc_env.
  140. import backend.app.schemas.auth as auth_schemas
  141. def _raise(**_kwargs):
  142. raise RuntimeError("boom leaked-secret")
  143. monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
  144. _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
  145. with caplog.at_level(logging.ERROR):
  146. await apply_env_oidc_provider(db_session) # must not raise
  147. assert await _env_provider(db_session) is None
  148. assert "could not be applied" in caplog.text
  149. assert "RuntimeError" in caplog.text # class is logged...
  150. assert "leaked-secret" not in caplog.text # ...but nothing from the message
  151. @pytest.mark.asyncio
  152. async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
  153. """Every boot re-applies; the second run must not create a second row."""
  154. _configure(monkeypatch)
  155. await apply_env_oidc_provider(db_session)
  156. await apply_env_oidc_provider(db_session)
  157. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  158. assert len(result.scalars().all()) == 1
  159. # --- identity is the name, not the flag ---------------------------------------
  160. # The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
  161. # Matching on is_env_managed instead made three things impossible: adopting a
  162. # provider that already carries the name (the insert hit the unique constraint
  163. # and took startup down with it), releasing the provider when the config goes
  164. # away, and finding it again afterwards.
  165. @pytest.mark.asyncio
  166. async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
  167. """An operator who names the env provider after one they created in the UI
  168. must not end up with an app that refuses to boot."""
  169. ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
  170. ui_provider.client_secret = "ui-secret"
  171. db_session.add(ui_provider)
  172. await db_session.commit()
  173. original_id = ui_provider.id
  174. _configure(monkeypatch)
  175. await apply_env_oidc_provider(db_session)
  176. provider = await _env_provider(db_session)
  177. assert provider is not None
  178. assert provider.id == original_id, "adopted, not duplicated"
  179. assert provider.client_id == "bambuddy"
  180. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  181. assert len(result.scalars().all()) == 1
  182. @pytest.mark.asyncio
  183. async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
  184. """Nothing manages it any more, so the API must stop refusing edits and
  185. deletes -- otherwise the row is a dead end only reachable via the database."""
  186. _configure(monkeypatch)
  187. await apply_env_oidc_provider(db_session)
  188. for key in ALL_VARS:
  189. monkeypatch.delenv(key, raising=False)
  190. await apply_env_oidc_provider(db_session)
  191. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  192. provider = result.scalar_one()
  193. assert provider.is_enabled is False
  194. assert provider.is_env_managed is False
  195. @pytest.mark.asyncio
  196. async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
  197. """The account links hang off this row; a second provider would orphan them."""
  198. _configure(monkeypatch)
  199. await apply_env_oidc_provider(db_session)
  200. original_id = (await _env_provider(db_session)).id
  201. for key in ALL_VARS:
  202. monkeypatch.delenv(key, raising=False)
  203. await apply_env_oidc_provider(db_session)
  204. _configure(monkeypatch)
  205. await apply_env_oidc_provider(db_session)
  206. provider = await _env_provider(db_session)
  207. assert provider.id == original_id
  208. assert provider.is_enabled is True
  209. @pytest.mark.asyncio
  210. async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
  211. _configure(monkeypatch)
  212. await apply_env_oidc_provider(db_session)
  213. original_id = (await _env_provider(db_session)).id
  214. monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
  215. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
  216. await apply_env_oidc_provider(db_session)
  217. provider = await _env_provider(db_session)
  218. assert provider.id == original_id
  219. assert provider.issuer_url == "https://sso.example.com/realms/other"
  220. assert provider.client_id == "rotated"