test_oidc_group_sync.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. """Integration tests for OIDC group sync (#3107).
  2. Same regression contract as the LDAP group sync (test_ldap_group_sync.py,
  3. #1292): the sync manages only the Bambuddy groups named in the provider's
  4. group_mapping values, and every login replaces exactly that slice. Manual
  5. assignments to groups outside the mapping survive; revocation at the IdP
  6. propagates on the next login.
  7. One deliberate difference from LDAP: there is no default-group fallback in
  8. the OIDC sync. The provider's default_group_id is applied once at account
  9. creation (routes/mfa.py) and never re-asserted, so promoting an auto-created
  10. user out of Viewers is a manual action that sticks.
  11. """
  12. import logging
  13. from typing import NoReturn
  14. import pytest
  15. from pydantic import ValidationError
  16. from sqlalchemy import select
  17. from sqlalchemy.ext.asyncio import AsyncSession
  18. from sqlalchemy.orm import selectinload
  19. from backend.app.models.group import Group
  20. from backend.app.models.oidc_provider import OIDCProvider
  21. from backend.app.models.user import User
  22. from backend.app.schemas.auth import OIDCProviderCreate
  23. from backend.app.services.oidc_group_sync import (
  24. _MAX_CLAIM_ITEMS,
  25. extract_idp_groups,
  26. resolve_oidc_group_mapping,
  27. sync_oidc_user_groups,
  28. )
  29. async def _make_group(db: AsyncSession, name: str) -> Group:
  30. group = Group(name=name, description=f"Test group {name}")
  31. db.add(group)
  32. await db.commit()
  33. await db.refresh(group)
  34. return group
  35. async def _make_user(db: AsyncSession, username: str, groups: list[Group]) -> User:
  36. user = User(
  37. username=username,
  38. email=f"{username}@example.com",
  39. password_hash=None,
  40. role="user",
  41. auth_source="oidc",
  42. is_active=True,
  43. )
  44. user.groups = groups
  45. db.add(user)
  46. await db.commit()
  47. await db.refresh(user, attribute_names=["groups"])
  48. return user
  49. # ─── claim-shape helpers ──────────────────────────────────────────────────────
  50. class TestExtractIdpGroups:
  51. """Providers disagree on the groups-claim shape (#3107). All accepted."""
  52. def test_json_array(self):
  53. assert extract_idp_groups(["fablab-staff", "students"]) == ["fablab-staff", "students"]
  54. def test_space_separated_string(self):
  55. assert extract_idp_groups("fablab-staff students") == ["fablab-staff", "students"]
  56. def test_comma_separated_string(self):
  57. assert extract_idp_groups("fablab-staff, students") == ["fablab-staff", "students"]
  58. def test_single_group_string(self):
  59. assert extract_idp_groups("fablab-staff") == ["fablab-staff"]
  60. def test_duplicates_removed(self):
  61. assert extract_idp_groups(["a", "b", "a", "b "]) == ["a", "b"]
  62. def test_none_and_non_group_payloads(self):
  63. assert extract_idp_groups(None) == []
  64. assert extract_idp_groups(42) == []
  65. assert extract_idp_groups({"odd": "shape"}) == []
  66. assert extract_idp_groups(["ok", 7, None, ""]) == ["ok"]
  67. def test_list_claim_is_bounded(self):
  68. # Review on #3122: the bound is the only defence against a hostile
  69. # oversized token, and it must hold for every accepted shape, not
  70. # just the list one.
  71. oversized = [f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100)]
  72. result = extract_idp_groups(oversized)
  73. assert len(result) == _MAX_CLAIM_ITEMS
  74. assert result[0] == "g0"
  75. assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}"
  76. def test_space_separated_claim_is_bounded(self):
  77. # The shape the original bound missed: a string claim splits into
  78. # arbitrarily many fragments, so the slice has to apply after
  79. # splitting, not only on the list path.
  80. oversized = " ".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100))
  81. result = extract_idp_groups(oversized)
  82. assert len(result) == _MAX_CLAIM_ITEMS
  83. assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}"
  84. def test_comma_separated_claim_is_bounded(self):
  85. # No spaces around the commas: the split happens on raw fragments,
  86. # so ", "-joined input would spend half the budget on empty
  87. # fragments. The contract being pinned is the upper bound.
  88. oversized = ",".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100))
  89. result = extract_idp_groups(oversized)
  90. assert len(result) == _MAX_CLAIM_ITEMS
  91. class TestResolveMapping:
  92. def test_case_insensitive_on_idp_side(self):
  93. assert resolve_oidc_group_mapping(["IDP-STAFF"], {"idp-staff": "Operators"}) == ["Operators"]
  94. def test_unmapped_groups_ignored(self):
  95. assert resolve_oidc_group_mapping(["nope", "idp-staff"], {"idp-staff": "Operators"}) == ["Operators"]
  96. def test_empty_mapping_disables(self):
  97. assert resolve_oidc_group_mapping(["idp-staff"], {}) == []
  98. def test_two_idp_groups_to_one_bambuddy_group(self):
  99. mapping = {"staff": "Operators", "admins": "Operators"}
  100. assert resolve_oidc_group_mapping(["admins", "staff"], mapping) == ["Operators"]
  101. # ─── sync semantics ───────────────────────────────────────────────────────────
  102. class TestSyncOidcUserGroups:
  103. @pytest.mark.asyncio
  104. async def test_adds_mapped_group_on_login(self, db_session: AsyncSession):
  105. operators = await _make_group(db_session, "Operators")
  106. user = await _make_user(db_session, "alice", [])
  107. await sync_oidc_user_groups(
  108. db_session,
  109. user,
  110. group_claim="groups",
  111. group_mapping={"idp-staff": "Operators"},
  112. claims={"groups": ["idp-staff"]},
  113. )
  114. await db_session.refresh(user, attribute_names=["groups"])
  115. assert {g.id for g in user.groups} == {operators.id}
  116. @pytest.mark.asyncio
  117. async def test_manual_group_survives_login(self, db_session: AsyncSession):
  118. """The #1292 contract: a group outside the mapping is a manual
  119. assignment and must never be touched by the sync."""
  120. admins = await _make_group(db_session, "Administrators")
  121. await _make_group(db_session, "Operators")
  122. user = await _make_user(db_session, "alice", [admins])
  123. await sync_oidc_user_groups(
  124. db_session,
  125. user,
  126. group_claim="groups",
  127. group_mapping={"idp-staff": "Operators"},
  128. claims={"groups": ["idp-staff"]},
  129. )
  130. await db_session.refresh(user, attribute_names=["groups"])
  131. assert {g.name for g in user.groups} == {"Administrators", "Operators"}
  132. @pytest.mark.asyncio
  133. async def test_revocation_at_idp_propagates(self, db_session: AsyncSession):
  134. """Losing the IdP group must remove the mapped Bambuddy group on the
  135. next login — otherwise IdP-side revocation would be decorative."""
  136. operators = await _make_group(db_session, "Operators")
  137. user = await _make_user(db_session, "bob", [operators])
  138. await sync_oidc_user_groups(
  139. db_session,
  140. user,
  141. group_claim="groups",
  142. group_mapping={"idp-staff": "Operators"},
  143. claims={"groups": []},
  144. )
  145. await db_session.refresh(user, attribute_names=["groups"])
  146. assert {g.name for g in user.groups} == set()
  147. @pytest.mark.asyncio
  148. async def test_revocation_keeps_manual_groups(self, db_session: AsyncSession):
  149. admins = await _make_group(db_session, "Administrators")
  150. operators = await _make_group(db_session, "Operators")
  151. user = await _make_user(db_session, "carol", [admins, operators])
  152. await sync_oidc_user_groups(
  153. db_session,
  154. user,
  155. group_claim="groups",
  156. group_mapping={"idp-staff": "Operators"},
  157. claims={"groups": []},
  158. )
  159. await db_session.refresh(user, attribute_names=["groups"])
  160. assert {g.name for g in user.groups} == {"Administrators"}
  161. @pytest.mark.asyncio
  162. async def test_manual_assignment_to_managed_group_overridden(self, db_session: AsyncSession):
  163. """An admin who manually grants a mapped group is overridden by IdP
  164. truth, same as LDAP: revocation must work for those users too."""
  165. operators = await _make_group(db_session, "Operators")
  166. user = await _make_user(db_session, "dave", [operators])
  167. await sync_oidc_user_groups(
  168. db_session,
  169. user,
  170. group_claim="groups",
  171. group_mapping={"idp-staff": "Operators"},
  172. claims={"groups": []},
  173. )
  174. await db_session.refresh(user, attribute_names=["groups"])
  175. assert {g.name for g in user.groups} == set()
  176. @pytest.mark.asyncio
  177. async def test_no_mapping_is_a_noop(self, db_session: AsyncSession):
  178. """Default state for every upgraded install: nothing configured, so
  179. nothing changes — including groups that would have matched a mapping
  180. if one existed."""
  181. admins = await _make_group(db_session, "Administrators")
  182. user = await _make_user(db_session, "eve", [admins])
  183. await sync_oidc_user_groups(
  184. db_session,
  185. user,
  186. group_claim="groups",
  187. group_mapping={},
  188. claims={"groups": ["idp-staff"]},
  189. )
  190. await db_session.refresh(user, attribute_names=["groups"])
  191. assert {g.name for g in user.groups} == {"Administrators"}
  192. @pytest.mark.asyncio
  193. async def test_missing_claim_is_not_fatal(self, db_session: AsyncSession):
  194. """A provider that never sends the claim means 'no mapped groups',
  195. not an error: the login must proceed and the managed slice clears."""
  196. operators = await _make_group(db_session, "Operators")
  197. user = await _make_user(db_session, "frank", [operators])
  198. await sync_oidc_user_groups(
  199. db_session,
  200. user,
  201. group_claim="groups",
  202. group_mapping={"idp-staff": "Operators"},
  203. claims={}, # claim absent entirely
  204. )
  205. await db_session.refresh(user, attribute_names=["groups"])
  206. assert {g.name for g in user.groups} == set()
  207. @pytest.mark.asyncio
  208. async def test_mapping_to_deleted_group_skipped(self, db_session: AsyncSession):
  209. """A dangling mapping value (group deleted after the mapping was saved)
  210. is skipped at sync time, mirroring default_group_id's SQLite story."""
  211. await _make_group(db_session, "Operators")
  212. user = await _make_user(db_session, "grace", [])
  213. await sync_oidc_user_groups(
  214. db_session,
  215. user,
  216. group_claim="groups",
  217. group_mapping={"idp-staff": "Operators", "idp-ghost": "DeletedGroup"},
  218. claims={"groups": ["idp-staff", "idp-ghost"]},
  219. )
  220. await db_session.refresh(user, attribute_names=["groups"])
  221. assert {g.name for g in user.groups} == {"Operators"}
  222. @pytest.mark.asyncio
  223. async def test_custom_claim_name(self, db_session: AsyncSession):
  224. """group_claim='roles' reads the roles claim and ignores a groups
  225. claim that happens to be present."""
  226. operators = await _make_group(db_session, "Operators")
  227. user = await _make_user(db_session, "heidi", [])
  228. await sync_oidc_user_groups(
  229. db_session,
  230. user,
  231. group_claim="roles",
  232. group_mapping={"op": "Operators"},
  233. claims={"roles": ["op"], "groups": ["unrelated"]},
  234. )
  235. await db_session.refresh(user, attribute_names=["groups"])
  236. assert {g.id for g in user.groups} == {operators.id}
  237. @pytest.mark.asyncio
  238. async def test_sync_failure_never_blocks_login(self, db_session: AsyncSession, monkeypatch, caplog):
  239. """The service's contract with oidc_callback: never raise. A failure
  240. mid-sync is logged and the user keeps the groups they had — the login
  241. already authenticated, so the sync must not take it down with it.
  242. The commit is the interesting failure point: by then the user object
  243. is dirty, so the except path's rollback has real work to do and the
  244. in-memory relationship is post-rollback state. Database truth is
  245. re-selected rather than read off the expired instance."""
  246. admins = await _make_group(db_session, "Administrators")
  247. await _make_group(db_session, "Operators")
  248. user = await _make_user(db_session, "ivan", [admins])
  249. user_id = user.id # captured pre-sync: the rollback expires the whole instance, PK included
  250. async def _failing_commit() -> NoReturn:
  251. raise RuntimeError("simulated commit failure")
  252. monkeypatch.setattr(db_session, "commit", _failing_commit)
  253. with caplog.at_level(logging.ERROR):
  254. await sync_oidc_user_groups( # must not raise
  255. db_session,
  256. user,
  257. group_claim="groups",
  258. group_mapping={"idp-staff": "Operators"},
  259. claims={"groups": ["idp-staff"]},
  260. )
  261. fresh = (
  262. await db_session.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  263. ).scalar_one()
  264. assert {g.name for g in fresh.groups} == {"Administrators"}
  265. assert "OIDC group sync failed for user ivan" in caplog.text
  266. # ─── schema validation ────────────────────────────────────────────────────────
  267. class TestProviderSchema:
  268. def test_create_defaults(self):
  269. provider = OIDCProviderCreate(name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b")
  270. assert provider.group_claim == "groups"
  271. assert provider.group_mapping == {}
  272. def test_create_with_mapping(self):
  273. provider = OIDCProviderCreate(
  274. name="t",
  275. issuer_url="https://id.example.com",
  276. client_id="a",
  277. client_secret="b",
  278. group_claim="roles",
  279. group_mapping={"op": "Operators"},
  280. )
  281. assert provider.group_claim == "roles"
  282. assert provider.group_mapping == {"op": "Operators"}
  283. def test_invalid_group_claim_rejected(self):
  284. with pytest.raises(ValidationError):
  285. OIDCProviderCreate(
  286. name="t",
  287. issuer_url="https://id.example.com",
  288. client_id="a",
  289. client_secret="b",
  290. group_claim="not a claim!",
  291. )
  292. def test_non_object_mapping_rejected(self):
  293. with pytest.raises(ValidationError):
  294. OIDCProviderCreate(
  295. name="t",
  296. issuer_url="https://id.example.com",
  297. client_id="a",
  298. client_secret="b",
  299. group_mapping=["not", "an", "object"],
  300. )
  301. def test_empty_mapping_values_rejected(self):
  302. with pytest.raises(ValidationError):
  303. OIDCProviderCreate(
  304. name="t",
  305. issuer_url="https://id.example.com",
  306. client_id="a",
  307. client_secret="b",
  308. group_mapping={"op": " "},
  309. )
  310. def test_case_colliding_mapping_keys_rejected(self):
  311. """Review round 2: {"Admins": ..., "admins": ...} would silently
  312. collapse in the sync's case-insensitive lookup — rejected at save."""
  313. with pytest.raises(ValidationError):
  314. OIDCProviderCreate(
  315. name="t",
  316. issuer_url="https://id.example.com",
  317. client_id="a",
  318. client_secret="b",
  319. group_mapping={"Admins": "Administrators", "admins": "Viewers"},
  320. )
  321. def test_case_colliding_keys_same_target_also_rejected(self):
  322. with pytest.raises(ValidationError):
  323. OIDCProviderCreate(
  324. name="t",
  325. issuer_url="https://id.example.com",
  326. client_id="a",
  327. client_secret="b",
  328. group_mapping={"Admins": "Operators", "admins": "Operators"},
  329. )
  330. def test_update_none_leaves_unchanged(self):
  331. from backend.app.schemas.auth import OIDCProviderUpdate
  332. update = OIDCProviderUpdate()
  333. assert update.group_claim is None
  334. assert update.group_mapping is None
  335. # ─── model column round-trip ──────────────────────────────────────────────────
  336. class TestProviderModelRoundTrip:
  337. @pytest.mark.asyncio
  338. async def test_columns_persist(self, db_session: AsyncSession):
  339. provider = OIDCProvider(
  340. name="idp-test",
  341. issuer_url="https://id.example.com",
  342. client_id="a",
  343. client_secret="b",
  344. group_claim="roles",
  345. group_mapping={"op": "Operators"},
  346. )
  347. db_session.add(provider)
  348. await db_session.commit()
  349. await db_session.refresh(provider)
  350. assert provider.group_claim == "roles"
  351. assert provider.group_mapping == {"op": "Operators"}