test_ldap_group_sync.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """Regression tests for LDAP user group sync behavior (#1292).
  2. Reporter @Fuechslein: when an admin manually assigned a BamBuddy group to an
  3. LDAP user, the assignment was silently wiped on the user's next login. Cause
  4. was that _sync_ldap_user used to replace `user.groups` entirely on every login,
  5. overwriting anything not derived from LDAP state.
  6. The fix partitions the user's groups into "LDAP-managed" (anything in the
  7. ldap_group_mapping config values + the default_group) and "manual". Only the
  8. LDAP-managed slice is rebuilt from LDAP truth; manual assignments survive.
  9. """
  10. from dataclasses import dataclass
  11. import pytest
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.api.routes.auth import _sync_ldap_user
  14. from backend.app.models.group import Group
  15. from backend.app.models.user import User
  16. @dataclass
  17. class _FakeLdapUser:
  18. """Stand-in for backend.app.services.ldap_service.LDAPUserInfo."""
  19. username: str
  20. email: str | None
  21. groups: list[str]
  22. @dataclass
  23. class _FakeLdapConfig:
  24. """Stand-in for backend.app.services.ldap_service.LDAPConfig — only the
  25. fields _sync_ldap_user actually reads."""
  26. group_mapping: dict[str, str]
  27. default_group: str = ""
  28. async def _make_group(db: AsyncSession, name: str) -> Group:
  29. group = Group(name=name, description=f"Test group {name}")
  30. db.add(group)
  31. await db.commit()
  32. await db.refresh(group)
  33. return group
  34. async def _make_ldap_user(db: AsyncSession, username: str, groups: list[Group]) -> User:
  35. user = User(
  36. username=username,
  37. email=f"{username}@example.com",
  38. password_hash=None,
  39. role="user",
  40. auth_source="ldap",
  41. is_active=True,
  42. )
  43. user.groups = groups
  44. db.add(user)
  45. await db.commit()
  46. await db.refresh(user, attribute_names=["groups"])
  47. return user
  48. class TestLdapGroupSyncPreservesManualAssignments:
  49. """The #1292 fix: groups outside the LDAP-managed set must survive logins."""
  50. @pytest.mark.asyncio
  51. async def test_manual_group_survives_login(self, db_session: AsyncSession):
  52. """Admin assigns 'Administrators' to an LDAP user. 'Administrators' is
  53. NOT in the LDAP group_mapping. Next login must keep it."""
  54. admins = await _make_group(db_session, "Administrators")
  55. users = await _make_group(db_session, "Users")
  56. user = await _make_ldap_user(db_session, "alice", [admins])
  57. assert {g.name for g in user.groups} == {"Administrators"}
  58. ldap_user = _FakeLdapUser(
  59. username="alice", email="alice@example.com", groups=["cn=staff,ou=groups,dc=example,dc=com"]
  60. )
  61. ldap_config = _FakeLdapConfig(
  62. group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
  63. default_group="",
  64. )
  65. await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
  66. await db_session.refresh(user, attribute_names=["groups"])
  67. assert {g.name for g in user.groups} == {"Administrators", "Users"}, (
  68. "Manual 'Administrators' assignment must be preserved; LDAP-mapped 'Users' must be added"
  69. )
  70. # Use the local refs to silence linters about unused locals
  71. assert admins.id != users.id
  72. @pytest.mark.asyncio
  73. async def test_default_group_not_treated_as_manual(self, db_session: AsyncSession):
  74. """The default_group is LDAP-managed even though it's not in the mapping
  75. values — it gets added when no mapped groups resolve. So if LDAP later
  76. revokes all group memberships, the default group stays; if a different
  77. default_group is configured, the old one is dropped from the user."""
  78. guest = await _make_group(db_session, "Guests")
  79. await _make_group(db_session, "Users")
  80. # User has the (LDAP-managed) Guests group as their default — no manual groups.
  81. user = await _make_ldap_user(db_session, "bob", [guest])
  82. ldap_user = _FakeLdapUser(username="bob", email="bob@example.com", groups=[])
  83. ldap_config = _FakeLdapConfig(group_mapping={}, default_group="Guests")
  84. await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
  85. await db_session.refresh(user, attribute_names=["groups"])
  86. assert {g.name for g in user.groups} == {"Guests"}, "Default group should persist"
  87. @pytest.mark.asyncio
  88. async def test_revocation_in_ldap_still_propagates(self, db_session: AsyncSession):
  89. """The original design intent — revocation in LDAP must flow through — must
  90. still work for LDAP-managed groups. User was in 'Users' (LDAP-mapped); LDAP
  91. no longer reports the mapped group; sync must remove 'Users'."""
  92. users = await _make_group(db_session, "Users")
  93. user = await _make_ldap_user(db_session, "charlie", [users])
  94. assert {g.name for g in user.groups} == {"Users"}
  95. ldap_user = _FakeLdapUser(username="charlie", email="charlie@example.com", groups=[])
  96. ldap_config = _FakeLdapConfig(
  97. group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
  98. default_group="",
  99. )
  100. await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
  101. await db_session.refresh(user, attribute_names=["groups"])
  102. assert {g.name for g in user.groups} == set(), (
  103. "LDAP-managed groups must be removed when LDAP no longer reports the user in them"
  104. )
  105. @pytest.mark.asyncio
  106. async def test_manual_assignment_to_managed_group_still_overridden(self, db_session: AsyncSession):
  107. """If an admin manually assigns a group that IS in the LDAP mapping, LDAP
  108. truth still wins — otherwise revoking access in LDAP wouldn't work for
  109. users who happened to have manual assignments to the same group. Cannot
  110. distinguish manual-but-mapped from LDAP-derived once the assignment is
  111. in the DB; resolved by treating any group in the LDAP-managed set as
  112. authoritative-by-LDAP."""
  113. users = await _make_group(db_session, "Users")
  114. # Manually assign 'Users' (which IS in the LDAP mapping) to an LDAP user.
  115. user = await _make_ldap_user(db_session, "dave", [users])
  116. # LDAP says the user is in no mapped groups.
  117. ldap_user = _FakeLdapUser(username="dave", email="dave@example.com", groups=[])
  118. ldap_config = _FakeLdapConfig(
  119. group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
  120. default_group="",
  121. )
  122. await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
  123. await db_session.refresh(user, attribute_names=["groups"])
  124. assert {g.name for g in user.groups} == set(), (
  125. "Manual assignment to an LDAP-managed group is overridden by LDAP state"
  126. )
  127. @pytest.mark.asyncio
  128. async def test_mixed_manual_and_ldap_groups(self, db_session: AsyncSession):
  129. """Most realistic scenario: user has multiple manual assignments AND LDAP
  130. mapped groups. Manual groups survive; LDAP-managed slice gets rebuilt."""
  131. admins = await _make_group(db_session, "Administrators")
  132. ops = await _make_group(db_session, "PrintOps")
  133. users = await _make_group(db_session, "Users")
  134. await _make_group(db_session, "Power Users")
  135. # User has two manual groups (Administrators, PrintOps) plus one LDAP
  136. # group (Users) at the start.
  137. user = await _make_ldap_user(db_session, "eve", [admins, ops, users])
  138. # LDAP login: user is now in two LDAP-mapped groups.
  139. ldap_user = _FakeLdapUser(
  140. username="eve",
  141. email="eve@example.com",
  142. groups=["cn=staff,ou=groups,dc=example,dc=com", "cn=power,ou=groups,dc=example,dc=com"],
  143. )
  144. ldap_config = _FakeLdapConfig(
  145. group_mapping={
  146. "cn=staff,ou=groups,dc=example,dc=com": "Users",
  147. "cn=power,ou=groups,dc=example,dc=com": "Power Users",
  148. },
  149. default_group="",
  150. )
  151. await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
  152. await db_session.refresh(user, attribute_names=["groups"])
  153. assert {g.name for g in user.groups} == {
  154. "Administrators", # manual, preserved
  155. "PrintOps", # manual, preserved
  156. "Users", # LDAP-managed, retained from LDAP
  157. "Power Users", # LDAP-managed, newly added from LDAP
  158. }