test_oidc_env_apply.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  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 os
  9. import pytest
  10. from sqlalchemy import select
  11. from backend.app.core.oidc_env import apply_env_oidc_provider
  12. from backend.app.models.oidc_provider import OIDCProvider
  13. REQUIRED = {
  14. "BAMBUDDY_OIDC_NAME": "Keycloak",
  15. "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
  16. "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
  17. "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
  18. }
  19. ALL_VARS = (
  20. *REQUIRED,
  21. "BAMBUDDY_OIDC_SCOPES",
  22. "BAMBUDDY_OIDC_ENABLED",
  23. "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
  24. "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
  25. "BAMBUDDY_OIDC_EMAIL_CLAIM",
  26. "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
  27. "BAMBUDDY_OIDC_ICON_URL",
  28. "BAMBUDDY_OIDC_AUTOLOGIN",
  29. "BAMBUDDY_OIDC_DEFAULT_GROUP",
  30. )
  31. @pytest.fixture(autouse=True)
  32. def clean_env(monkeypatch):
  33. for key in ALL_VARS:
  34. monkeypatch.delenv(key, raising=False)
  35. def _configure(monkeypatch, **overrides):
  36. for key, value in REQUIRED.items():
  37. monkeypatch.setenv(key, value)
  38. for key, value in overrides.items():
  39. monkeypatch.setenv(key, value)
  40. async def _env_provider(db_session) -> OIDCProvider | None:
  41. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  42. return result.scalar_one_or_none()
  43. @pytest.mark.asyncio
  44. async def test_creates_the_provider_from_env(db_session, monkeypatch):
  45. _configure(monkeypatch)
  46. await apply_env_oidc_provider(db_session)
  47. provider = await _env_provider(db_session)
  48. assert provider is not None
  49. assert provider.name == "Keycloak"
  50. assert provider.client_id == "bambuddy"
  51. assert provider.is_env_managed is True
  52. assert provider.client_secret == "s3cr3t" # property decrypts
  53. @pytest.mark.asyncio
  54. async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
  55. """The id must survive: user_oidc_links references it with ON DELETE
  56. CASCADE, so a delete-recreate would unlink every bound account."""
  57. _configure(monkeypatch)
  58. await apply_env_oidc_provider(db_session)
  59. original_id = (await _env_provider(db_session)).id
  60. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
  61. await apply_env_oidc_provider(db_session)
  62. provider = await _env_provider(db_session)
  63. assert provider.id == original_id
  64. assert provider.client_id == "rotated"
  65. @pytest.mark.asyncio
  66. async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
  67. _configure(monkeypatch)
  68. await apply_env_oidc_provider(db_session)
  69. original_id = (await _env_provider(db_session)).id
  70. for key in ALL_VARS:
  71. monkeypatch.delenv(key, raising=False)
  72. await apply_env_oidc_provider(db_session)
  73. # Looked up by name, not by the flag: releasing the provider clears the flag,
  74. # and the point of this test is that the ROW survives either way.
  75. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  76. provider = result.scalar_one_or_none()
  77. assert provider is not None, "deleting would cascade away every account link"
  78. assert provider.id == original_id
  79. assert provider.is_enabled is False
  80. @pytest.mark.asyncio
  81. async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
  82. """Only one provider may be the autologin target; the env one wins."""
  83. ui_provider = OIDCProvider(
  84. name="UI provider",
  85. issuer_url="https://other.example.com",
  86. client_id="ui",
  87. is_autologin=True,
  88. )
  89. ui_provider.client_secret = "ui-secret"
  90. db_session.add(ui_provider)
  91. await db_session.commit()
  92. _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
  93. await apply_env_oidc_provider(db_session)
  94. await db_session.refresh(ui_provider)
  95. assert (await _env_provider(db_session)).is_autologin is True
  96. assert ui_provider.is_autologin is False
  97. @pytest.mark.asyncio
  98. async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
  99. ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
  100. ui_provider.client_secret = "ui-secret"
  101. db_session.add(ui_provider)
  102. await db_session.commit()
  103. _configure(monkeypatch)
  104. await apply_env_oidc_provider(db_session)
  105. await db_session.refresh(ui_provider)
  106. assert ui_provider.is_env_managed is False
  107. assert ui_provider.is_enabled is True
  108. assert ui_provider.client_id == "ui"
  109. @pytest.mark.asyncio
  110. async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
  111. """auto-link + unverified email is the SEC-1 account-takeover shape. The
  112. schema rejects it for the UI, and env config must not be a way around that
  113. -- but a bad variable must not stop the app from booting either."""
  114. _configure(
  115. monkeypatch,
  116. BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
  117. BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
  118. )
  119. await apply_env_oidc_provider(db_session)
  120. assert await _env_provider(db_session) is None
  121. @pytest.mark.asyncio
  122. async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
  123. """client_secret has max_length=512, so an over-long value raises
  124. string_too_long. The rejection must be logged without the value: str(exc)
  125. embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
  126. secret = "S3CR3T" * 100 # > 512 chars -> ValidationError on client_secret
  127. _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
  128. with caplog.at_level(logging.ERROR):
  129. await apply_env_oidc_provider(db_session)
  130. assert await _env_provider(db_session) is None # rejected, not booted-through
  131. assert "rejected" in caplog.text # the rejection was actually logged
  132. assert secret not in caplog.text
  133. assert "S3CR3T" not in caplog.text # not even a fragment of the value
  134. # --- an unrecognized boolean is rejected, not guessed --------------------------
  135. # `_env_bool` used to return the default for anything outside {true,1,yes}, so
  136. # BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
  137. # BAMBUDDY_OIDC_ENABLED=on silently disabled the provider. Strict parsing
  138. # refuses the config instead -- through the same clean path a bad
  139. # DEFAULT_GROUP or a ValidationError already uses, so a typo never releases a
  140. # provider that was running fine.
  141. @pytest.mark.asyncio
  142. async def test_an_unrecognized_require_email_verified_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
  143. _configure(monkeypatch)
  144. await apply_env_oidc_provider(db_session)
  145. original = await _env_provider(db_session)
  146. original_id, original_enabled = original.id, original.is_enabled
  147. monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "on")
  148. with caplog.at_level(logging.ERROR):
  149. await apply_env_oidc_provider(db_session)
  150. provider = await _env_provider(db_session)
  151. assert provider is not None, "a typo must not release the provider"
  152. assert provider.id == original_id
  153. assert provider.is_enabled == original_enabled
  154. assert provider.is_env_managed is True
  155. assert "rejected" in caplog.text
  156. assert "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED" in caplog.text
  157. @pytest.mark.asyncio
  158. async def test_an_unrecognized_enabled_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
  159. _configure(monkeypatch)
  160. await apply_env_oidc_provider(db_session)
  161. original = await _env_provider(db_session)
  162. original_id, original_enabled = original.id, original.is_enabled
  163. monkeypatch.setenv("BAMBUDDY_OIDC_ENABLED", "on")
  164. with caplog.at_level(logging.ERROR):
  165. await apply_env_oidc_provider(db_session)
  166. provider = await _env_provider(db_session)
  167. assert provider is not None, "a typo must not release the provider"
  168. assert provider.id == original_id
  169. assert provider.is_enabled == original_enabled
  170. assert provider.is_env_managed is True
  171. assert "rejected" in caplog.text
  172. assert "BAMBUDDY_OIDC_ENABLED" in caplog.text
  173. @pytest.mark.asyncio
  174. async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
  175. """The generic except branch handles anything that isn't a ValidationError
  176. (e.g. a library call raising mid-construction). It must not stop boot and,
  177. since such a message could carry a configured value, must log only the
  178. exception class -- never str(exc)."""
  179. # oidc_env imports OIDCProviderCreate inside the function (to avoid an
  180. # import cycle), so patch it at its source module, not on oidc_env.
  181. import backend.app.schemas.auth as auth_schemas
  182. def _raise(**_kwargs):
  183. raise RuntimeError("boom leaked-secret")
  184. monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
  185. _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
  186. with caplog.at_level(logging.ERROR):
  187. await apply_env_oidc_provider(db_session) # must not raise
  188. assert await _env_provider(db_session) is None
  189. assert "could not be applied" in caplog.text
  190. assert "RuntimeError" in caplog.text # class is logged...
  191. assert "leaked-secret" not in caplog.text # ...but nothing from the message
  192. @pytest.mark.asyncio
  193. async def test_a_commit_failure_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
  194. """The upsert's db.execute/db.commit calls sit outside the inner
  195. ValidationError guard -- a Postgres blip or a SQLite WAL lock at startup
  196. must not propagate out of the lifespan either. Only the exception class
  197. may be logged, never str(exc), since a DB error message can echo a
  198. configured value."""
  199. async def _raise_on_commit():
  200. raise RuntimeError("database is locked")
  201. monkeypatch.setattr(db_session, "commit", _raise_on_commit)
  202. _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
  203. with caplog.at_level(logging.ERROR):
  204. await apply_env_oidc_provider(db_session) # must not raise
  205. assert "could not be applied" in caplog.text
  206. assert "RuntimeError" in caplog.text # class is logged...
  207. assert "leaked-secret" not in caplog.text # ...but nothing from the message
  208. @pytest.mark.asyncio
  209. async def test_a_failing_rollback_is_also_survivable(db_session, monkeypatch, caplog):
  210. """The handler rolls back after a failed commit -- but rollback on a wedged
  211. connection can raise too, and 'never raises' has to hold for that as well
  212. or the boot dies on the recovery path. The rollback is suppressed."""
  213. async def _raise_on_commit():
  214. raise RuntimeError("database is locked")
  215. async def _raise_on_rollback():
  216. raise RuntimeError("connection is closed")
  217. monkeypatch.setattr(db_session, "commit", _raise_on_commit)
  218. monkeypatch.setattr(db_session, "rollback", _raise_on_rollback)
  219. _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
  220. with caplog.at_level(logging.ERROR):
  221. await apply_env_oidc_provider(db_session) # must not raise, even here
  222. assert "could not be applied" in caplog.text
  223. assert "leaked-secret" not in caplog.text
  224. @pytest.mark.asyncio
  225. async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
  226. """Every boot re-applies; the second run must not create a second row."""
  227. _configure(monkeypatch)
  228. await apply_env_oidc_provider(db_session)
  229. await apply_env_oidc_provider(db_session)
  230. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  231. assert len(result.scalars().all()) == 1
  232. # --- identity is the name, not the flag ---------------------------------------
  233. # The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
  234. # Matching on is_env_managed instead made three things impossible: adopting a
  235. # provider that already carries the name (the insert hit the unique constraint
  236. # and took startup down with it), releasing the provider when the config goes
  237. # away, and finding it again afterwards.
  238. @pytest.mark.asyncio
  239. async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
  240. """An operator who names the env provider after one they created in the UI
  241. must not end up with an app that refuses to boot."""
  242. ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
  243. ui_provider.client_secret = "ui-secret"
  244. db_session.add(ui_provider)
  245. await db_session.commit()
  246. original_id = ui_provider.id
  247. _configure(monkeypatch)
  248. await apply_env_oidc_provider(db_session)
  249. provider = await _env_provider(db_session)
  250. assert provider is not None
  251. assert provider.id == original_id, "adopted, not duplicated"
  252. assert provider.client_id == "bambuddy"
  253. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  254. assert len(result.scalars().all()) == 1
  255. @pytest.mark.asyncio
  256. async def test_adopting_a_ui_provider_logs_a_distinct_warning(db_session, monkeypatch, caplog):
  257. """Overwriting a UI-created provider in place is a bigger deal than a
  258. routine re-apply -- it must not be silent at the same INFO level."""
  259. ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
  260. ui_provider.client_secret = "ui-secret"
  261. db_session.add(ui_provider)
  262. await db_session.commit()
  263. _configure(monkeypatch)
  264. with caplog.at_level(logging.INFO):
  265. await apply_env_oidc_provider(db_session)
  266. warnings = [r for r in caplog.records if r.levelname == "WARNING"]
  267. assert any("adopted" in r.message for r in warnings)
  268. @pytest.mark.asyncio
  269. async def test_a_routine_reapply_does_not_log_an_adoption_warning(db_session, monkeypatch, caplog):
  270. """The same provider re-applying on the next boot is not an adoption --
  271. it was already env-managed."""
  272. _configure(monkeypatch)
  273. await apply_env_oidc_provider(db_session)
  274. caplog.clear()
  275. with caplog.at_level(logging.INFO):
  276. await apply_env_oidc_provider(db_session)
  277. warnings = [r for r in caplog.records if r.levelname == "WARNING"]
  278. assert not any("adopted" in r.message for r in warnings)
  279. @pytest.mark.asyncio
  280. async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
  281. """Nothing manages it any more, so the API must stop refusing edits and
  282. deletes -- otherwise the row is a dead end only reachable via the database."""
  283. _configure(monkeypatch)
  284. await apply_env_oidc_provider(db_session)
  285. for key in ALL_VARS:
  286. monkeypatch.delenv(key, raising=False)
  287. await apply_env_oidc_provider(db_session)
  288. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  289. provider = result.scalar_one()
  290. assert provider.is_enabled is False
  291. assert provider.is_env_managed is False
  292. @pytest.mark.asyncio
  293. async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
  294. """The account links hang off this row; a second provider would orphan them."""
  295. _configure(monkeypatch)
  296. await apply_env_oidc_provider(db_session)
  297. original_id = (await _env_provider(db_session)).id
  298. for key in ALL_VARS:
  299. monkeypatch.delenv(key, raising=False)
  300. await apply_env_oidc_provider(db_session)
  301. _configure(monkeypatch)
  302. await apply_env_oidc_provider(db_session)
  303. provider = await _env_provider(db_session)
  304. assert provider.id == original_id
  305. assert provider.is_enabled is True
  306. @pytest.mark.asyncio
  307. async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
  308. _configure(monkeypatch)
  309. await apply_env_oidc_provider(db_session)
  310. original_id = (await _env_provider(db_session)).id
  311. monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
  312. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
  313. await apply_env_oidc_provider(db_session)
  314. provider = await _env_provider(db_session)
  315. assert provider.id == original_id
  316. assert provider.issuer_url == "https://sso.example.com/realms/other"
  317. assert provider.client_id == "rotated"
  318. # --- a rename must not leave the old row managed -------------------------------
  319. # Identity is the name, so renaming BAMBUDDY_OIDC_NAME matches nothing and
  320. # creates a second row. Leaving the flag on the first one is what makes that
  321. # fatal: it stays enabled with a stale issuer and secret on the login page, the
  322. # API refuses every edit/disable/delete on it (409), and the release path's
  323. # scalar_one_or_none() then raises MultipleResultsFound out of the lifespan --
  324. # the app stops booting. Both states are reachable by ordinary config edits.
  325. async def _env_managed(db_session) -> list[OIDCProvider]:
  326. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  327. return list(result.scalars().all())
  328. @pytest.mark.asyncio
  329. async def test_renaming_the_provider_releases_the_row_it_managed_before(db_session, monkeypatch):
  330. _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
  331. await apply_env_oidc_provider(db_session)
  332. old_id = (await _env_provider(db_session)).id
  333. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
  334. await apply_env_oidc_provider(db_session)
  335. managed = await _env_managed(db_session)
  336. assert [p.name for p in managed] == ["Authentik"], "exactly one row may carry the flag"
  337. old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
  338. # Released, not deleted -- user_oidc_links.provider_id cascades.
  339. assert old.is_env_managed is False
  340. assert old.is_enabled is False, "a stale issuer must not stay on the login page"
  341. assert old.is_autologin is False
  342. @pytest.mark.asyncio
  343. async def test_boot_survives_removing_the_config_after_a_rename(db_session, monkeypatch):
  344. """The MultipleResultsFound path: rename, then unset. Must not raise."""
  345. _configure(monkeypatch)
  346. await apply_env_oidc_provider(db_session)
  347. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
  348. await apply_env_oidc_provider(db_session)
  349. for key in ALL_VARS:
  350. monkeypatch.delenv(key, raising=False)
  351. await apply_env_oidc_provider(db_session) # must not raise
  352. assert await _env_managed(db_session) == []
  353. names = (await db_session.execute(select(OIDCProvider.name))).scalars().all()
  354. assert sorted(names) == ["Authentik", "Keycloak"], "both rows survive, both released"
  355. @pytest.mark.asyncio
  356. async def test_every_managed_row_is_released_not_just_one(db_session, monkeypatch):
  357. """The upsert's sweep should keep this at one row. Should is not enforced by
  358. the schema, and the cost of being wrong is the whole release path raising
  359. MultipleResultsFound out of the lifespan -- so it releases what it finds."""
  360. for name in ("Keycloak", "Authentik"):
  361. stale = OIDCProvider(
  362. name=name,
  363. issuer_url="https://sso.example.com/realms/main",
  364. client_id="bambuddy",
  365. is_env_managed=True,
  366. )
  367. stale.client_secret = "s3cr3t"
  368. db_session.add(stale)
  369. await db_session.commit()
  370. await apply_env_oidc_provider(db_session) # no vars set -> release path
  371. assert await _env_managed(db_session) == []
  372. @pytest.mark.asyncio
  373. async def test_releasing_the_provider_clears_autologin(db_session, monkeypatch):
  374. """is_enabled and is_env_managed alone leave a UI-editable row carrying a
  375. latent autologin claim: update_oidc_provider only runs the exclusivity
  376. sweep when a request sets is_autologin=True, so merely re-enabling this row
  377. makes it the autologin target again."""
  378. _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
  379. await apply_env_oidc_provider(db_session)
  380. assert (await _env_provider(db_session)).is_autologin is True
  381. for key in ALL_VARS:
  382. monkeypatch.delenv(key, raising=False)
  383. await apply_env_oidc_provider(db_session)
  384. released = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))).scalar_one()
  385. assert released.is_autologin is False
  386. # --- default group by name -----------------------------------------------------
  387. # Group ids are not stable across installs, so a declarative deployment cannot
  388. # name one by id. Without this, every auto-created user falls back to Viewers
  389. # (routes/mfa.py) and the env lock means the UI cannot correct the provider.
  390. async def _group(db_session, name: str):
  391. from backend.app.models.group import Group
  392. group = Group(name=name, description=f"Test group {name}")
  393. db_session.add(group)
  394. await db_session.commit()
  395. return group
  396. @pytest.mark.asyncio
  397. async def test_the_default_group_is_resolved_by_name(db_session, monkeypatch):
  398. group = await _group(db_session, "Operators")
  399. _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
  400. await apply_env_oidc_provider(db_session)
  401. assert (await _env_provider(db_session)).default_group_id == group.id
  402. @pytest.mark.asyncio
  403. async def test_an_unknown_group_name_is_rejected_rather_than_defaulted(db_session, monkeypatch, caplog):
  404. """Silently falling back to Viewers is how a typo mints under-privileged
  405. users for weeks. The API answers 400 for a default_group_id that does not
  406. exist; env config gets the same answer, logged and survivable."""
  407. _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Nope")
  408. with caplog.at_level(logging.ERROR):
  409. await apply_env_oidc_provider(db_session)
  410. assert await _env_provider(db_session) is None
  411. assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
  412. assert "Nope" in caplog.text
  413. @pytest.mark.asyncio
  414. async def test_an_unknown_group_name_leaves_the_previous_provider_intact(db_session, monkeypatch):
  415. """Rejection happens before the upsert, so the running config survives a
  416. bad edit -- the provider keeps working until the operator fixes the name."""
  417. group = await _group(db_session, "Operators")
  418. _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
  419. await apply_env_oidc_provider(db_session)
  420. monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Typo")
  421. await apply_env_oidc_provider(db_session)
  422. provider = await _env_provider(db_session)
  423. assert provider is not None
  424. assert provider.default_group_id == group.id
  425. @pytest.mark.asyncio
  426. async def test_no_group_variable_leaves_the_default_group_unset(db_session, monkeypatch):
  427. _configure(monkeypatch)
  428. await apply_env_oidc_provider(db_session)
  429. assert (await _env_provider(db_session)).default_group_id is None
  430. @pytest.mark.asyncio
  431. async def test_removing_the_group_variable_clears_the_default_group(db_session, monkeypatch):
  432. """The environment is the whole truth for this row; a group that is no
  433. longer declared must not linger, since the lock blocks removing it in the UI."""
  434. await _group(db_session, "Operators")
  435. _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
  436. await apply_env_oidc_provider(db_session)
  437. monkeypatch.delenv("BAMBUDDY_OIDC_DEFAULT_GROUP")
  438. await apply_env_oidc_provider(db_session)
  439. assert (await _env_provider(db_session)).default_group_id is None
  440. @pytest.mark.asyncio
  441. async def test_an_empty_group_variable_counts_as_unset(db_session, monkeypatch):
  442. """Same rule the required vars follow: an empty value in a compose file is
  443. a forgotten value, not a request to reject the config."""
  444. _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="")
  445. await apply_env_oidc_provider(db_session)
  446. provider = await _env_provider(db_session)
  447. assert provider is not None
  448. assert provider.default_group_id is None
  449. # --- blank optional strings count as unset, not a refusal ---------------------
  450. # `.env.example` ships `# BAMBUDDY_OIDC_ICON_URL=` commented out, so uncommenting
  451. # it must not take the provider down -- same rule default_group already follows.
  452. @pytest.mark.asyncio
  453. async def test_a_blank_scopes_still_creates_the_provider(db_session, monkeypatch):
  454. _configure(monkeypatch, BAMBUDDY_OIDC_SCOPES="")
  455. await apply_env_oidc_provider(db_session)
  456. provider = await _env_provider(db_session)
  457. assert provider is not None, "a blank optional var must not refuse the whole provider"
  458. assert provider.scopes == "openid email profile"
  459. @pytest.mark.asyncio
  460. async def test_a_blank_email_claim_still_creates_the_provider(db_session, monkeypatch):
  461. _configure(monkeypatch, BAMBUDDY_OIDC_EMAIL_CLAIM="")
  462. await apply_env_oidc_provider(db_session)
  463. provider = await _env_provider(db_session)
  464. assert provider is not None, "a blank optional var must not refuse the whole provider"
  465. assert provider.email_claim == "email"
  466. @pytest.mark.asyncio
  467. async def test_a_blank_icon_url_still_creates_the_provider(db_session, monkeypatch):
  468. _configure(monkeypatch, BAMBUDDY_OIDC_ICON_URL="")
  469. await apply_env_oidc_provider(db_session)
  470. provider = await _env_provider(db_session)
  471. assert provider is not None, "a blank optional var must not refuse the whole provider"
  472. assert provider.icon_url is None
  473. # --- account links and collision behavior ------------------------------------
  474. @pytest.mark.asyncio
  475. async def test_renaming_to_match_a_ui_provider_adopts_it_and_releases_the_old_row(db_session, monkeypatch):
  476. """New name collides with existing UI provider: env config adopts that row,
  477. old env-managed row is released. Identity is the name, so the collision is
  478. resolved by matching the new name against the table."""
  479. # Start with env-managed "Keycloak"
  480. _configure(monkeypatch)
  481. await apply_env_oidc_provider(db_session)
  482. old_id = (await _env_provider(db_session)).id
  483. # Add a UI provider named "Authentik"
  484. ui_provider = OIDCProvider(name="Authentik", issuer_url="https://auth.example.com", client_id="ui-client")
  485. ui_provider.client_secret = "ui-secret"
  486. db_session.add(ui_provider)
  487. await db_session.commit()
  488. ui_id = ui_provider.id
  489. # Rename env provider to "Authentik" — matches the UI provider
  490. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
  491. await apply_env_oidc_provider(db_session)
  492. # The UI provider is adopted and becomes env-managed
  493. provider = await _env_provider(db_session)
  494. assert provider.id == ui_id, "adopted the UI provider"
  495. assert provider.name == "Authentik"
  496. assert provider.client_id == "bambuddy" # updated from env
  497. assert provider.is_env_managed is True
  498. # The old Keycloak row is released
  499. old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
  500. assert old.name == "Keycloak"
  501. assert old.is_env_managed is False
  502. assert old.is_enabled is False
  503. @pytest.mark.asyncio
  504. async def test_account_links_survive_a_provider_rename(db_session, monkeypatch):
  505. """The provider row is never deleted, only updated: user_oidc_links FK
  506. ON DELETE CASCADE must not be triggered by a rename."""
  507. from backend.app.models.oidc_provider import UserOIDCLink
  508. from backend.app.models.user import User
  509. # Create a user and link it to the env-managed provider
  510. _configure(monkeypatch)
  511. await apply_env_oidc_provider(db_session)
  512. provider_id = (await _env_provider(db_session)).id
  513. user = User(username="testuser", email="test@example.com")
  514. db_session.add(user)
  515. await db_session.flush()
  516. link = UserOIDCLink(
  517. user_id=user.id,
  518. provider_id=provider_id,
  519. provider_user_id="oidc-sub-12345",
  520. provider_email="test@idp.example.com",
  521. )
  522. db_session.add(link)
  523. await db_session.commit()
  524. # Rename the env provider
  525. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
  526. await apply_env_oidc_provider(db_session)
  527. # The link still exists, pointing to the old row (which is now released)
  528. result = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_id == provider_id))
  529. links = result.scalars().all()
  530. assert len(links) == 1
  531. assert links[0].provider_user_id == "oidc-sub-12345"
  532. @pytest.mark.asyncio
  533. async def test_renaming_with_autologin_updates_the_exclusivity_sweep(db_session, monkeypatch):
  534. """When renamed env config has autologin=true, the sweep clears autologin
  535. from other rows. The old row is released (autologin cleared there too)."""
  536. # Setup: env provider "Keycloak" with autologin
  537. _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
  538. await apply_env_oidc_provider(db_session)
  539. old_id = (await _env_provider(db_session)).id
  540. assert (await _env_provider(db_session)).is_autologin is True
  541. # Another UI provider also has autologin
  542. ui_provider = OIDCProvider(name="UI", issuer_url="https://ui.example.com", client_id="ui")
  543. ui_provider.client_secret = "secret"
  544. ui_provider.is_autologin = True
  545. db_session.add(ui_provider)
  546. await db_session.commit()
  547. # Rename env provider to "Authentik" with autologin=true
  548. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
  549. await apply_env_oidc_provider(db_session)
  550. # New row is the autologin target
  551. new_provider = await _env_provider(db_session)
  552. assert new_provider.name == "Authentik"
  553. assert new_provider.is_autologin is True
  554. # Old row is released and autologin cleared
  555. old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
  556. assert old.is_env_managed is False
  557. assert old.is_autologin is False
  558. # UI provider autologin is cleared (only env-managed can be autologin now)
  559. await db_session.refresh(ui_provider)
  560. assert ui_provider.is_autologin is False
  561. @pytest.mark.asyncio
  562. async def test_group_name_matching_is_case_sensitive(db_session, monkeypatch, caplog):
  563. """Group name is resolved by exact match; 'operators' != 'Operators'."""
  564. await _group(db_session, "Operators") # capital O
  565. _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="operators") # lowercase
  566. with caplog.at_level(logging.ERROR):
  567. await apply_env_oidc_provider(db_session)
  568. # Config is rejected
  569. assert await _env_provider(db_session) is None
  570. assert "operators" in caplog.text
  571. assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
  572. @pytest.mark.asyncio
  573. async def test_group_name_rejection_does_not_log_the_secret(db_session, monkeypatch, caplog):
  574. """Group resolution happens before schema validation, so the secret is
  575. not yet in scope, but verify it's not leaked by the error path."""
  576. _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="NonExistent")
  577. secret = os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"]
  578. with caplog.at_level(logging.ERROR):
  579. await apply_env_oidc_provider(db_session)
  580. # Config is rejected but secret is safe
  581. assert await _env_provider(db_session) is None
  582. assert secret not in caplog.text
  583. @pytest.mark.asyncio
  584. async def test_restoring_env_config_after_rename_then_unset_finds_the_original_row(db_session, monkeypatch):
  585. """Rename Keycloak → Authentik, unset everything, restore Keycloak.
  586. Must re-enable the original row, not create a new one."""
  587. _configure(monkeypatch)
  588. await apply_env_oidc_provider(db_session)
  589. original_id = (await _env_provider(db_session)).id
  590. # Rename to Authentik
  591. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
  592. await apply_env_oidc_provider(db_session)
  593. assert (await _env_provider(db_session)).name == "Authentik"
  594. # Unset everything
  595. for key in ALL_VARS:
  596. monkeypatch.delenv(key, raising=False)
  597. await apply_env_oidc_provider(db_session)
  598. # Restore the original Keycloak config
  599. _configure(monkeypatch)
  600. await apply_env_oidc_provider(db_session)
  601. # Same row, re-enabled
  602. provider = await _env_provider(db_session)
  603. assert provider.id == original_id
  604. assert provider.name == "Keycloak"
  605. assert provider.is_enabled is True
  606. assert provider.is_env_managed is True