|
|
@@ -0,0 +1,196 @@
|
|
|
+"""Regression tests for LDAP user group sync behavior (#1292).
|
|
|
+
|
|
|
+Reporter @Fuechslein: when an admin manually assigned a BamBuddy group to an
|
|
|
+LDAP user, the assignment was silently wiped on the user's next login. Cause
|
|
|
+was that _sync_ldap_user used to replace `user.groups` entirely on every login,
|
|
|
+overwriting anything not derived from LDAP state.
|
|
|
+
|
|
|
+The fix partitions the user's groups into "LDAP-managed" (anything in the
|
|
|
+ldap_group_mapping config values + the default_group) and "manual". Only the
|
|
|
+LDAP-managed slice is rebuilt from LDAP truth; manual assignments survive.
|
|
|
+"""
|
|
|
+
|
|
|
+from dataclasses import dataclass
|
|
|
+
|
|
|
+import pytest
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
+
|
|
|
+from backend.app.api.routes.auth import _sync_ldap_user
|
|
|
+from backend.app.models.group import Group
|
|
|
+from backend.app.models.user import User
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class _FakeLdapUser:
|
|
|
+ """Stand-in for backend.app.services.ldap_service.LDAPUserInfo."""
|
|
|
+
|
|
|
+ username: str
|
|
|
+ email: str | None
|
|
|
+ groups: list[str]
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class _FakeLdapConfig:
|
|
|
+ """Stand-in for backend.app.services.ldap_service.LDAPConfig — only the
|
|
|
+ fields _sync_ldap_user actually reads."""
|
|
|
+
|
|
|
+ group_mapping: dict[str, str]
|
|
|
+ default_group: str = ""
|
|
|
+
|
|
|
+
|
|
|
+async def _make_group(db: AsyncSession, name: str) -> Group:
|
|
|
+ group = Group(name=name, description=f"Test group {name}")
|
|
|
+ db.add(group)
|
|
|
+ await db.commit()
|
|
|
+ await db.refresh(group)
|
|
|
+ return group
|
|
|
+
|
|
|
+
|
|
|
+async def _make_ldap_user(db: AsyncSession, username: str, groups: list[Group]) -> User:
|
|
|
+ user = User(
|
|
|
+ username=username,
|
|
|
+ email=f"{username}@example.com",
|
|
|
+ password_hash=None,
|
|
|
+ role="user",
|
|
|
+ auth_source="ldap",
|
|
|
+ is_active=True,
|
|
|
+ )
|
|
|
+ user.groups = groups
|
|
|
+ db.add(user)
|
|
|
+ await db.commit()
|
|
|
+ await db.refresh(user, attribute_names=["groups"])
|
|
|
+ return user
|
|
|
+
|
|
|
+
|
|
|
+class TestLdapGroupSyncPreservesManualAssignments:
|
|
|
+ """The #1292 fix: groups outside the LDAP-managed set must survive logins."""
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_manual_group_survives_login(self, db_session: AsyncSession):
|
|
|
+ """Admin assigns 'Administrators' to an LDAP user. 'Administrators' is
|
|
|
+ NOT in the LDAP group_mapping. Next login must keep it."""
|
|
|
+ admins = await _make_group(db_session, "Administrators")
|
|
|
+ users = await _make_group(db_session, "Users")
|
|
|
+
|
|
|
+ user = await _make_ldap_user(db_session, "alice", [admins])
|
|
|
+ assert {g.name for g in user.groups} == {"Administrators"}
|
|
|
+
|
|
|
+ ldap_user = _FakeLdapUser(
|
|
|
+ username="alice", email="alice@example.com", groups=["cn=staff,ou=groups,dc=example,dc=com"]
|
|
|
+ )
|
|
|
+ ldap_config = _FakeLdapConfig(
|
|
|
+ group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
|
|
|
+ default_group="",
|
|
|
+ )
|
|
|
+
|
|
|
+ await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
|
|
|
+ await db_session.refresh(user, attribute_names=["groups"])
|
|
|
+
|
|
|
+ assert {g.name for g in user.groups} == {"Administrators", "Users"}, (
|
|
|
+ "Manual 'Administrators' assignment must be preserved; LDAP-mapped 'Users' must be added"
|
|
|
+ )
|
|
|
+ # Use the local refs to silence linters about unused locals
|
|
|
+ assert admins.id != users.id
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_default_group_not_treated_as_manual(self, db_session: AsyncSession):
|
|
|
+ """The default_group is LDAP-managed even though it's not in the mapping
|
|
|
+ values — it gets added when no mapped groups resolve. So if LDAP later
|
|
|
+ revokes all group memberships, the default group stays; if a different
|
|
|
+ default_group is configured, the old one is dropped from the user."""
|
|
|
+ guest = await _make_group(db_session, "Guests")
|
|
|
+ await _make_group(db_session, "Users")
|
|
|
+
|
|
|
+ # User has the (LDAP-managed) Guests group as their default — no manual groups.
|
|
|
+ user = await _make_ldap_user(db_session, "bob", [guest])
|
|
|
+
|
|
|
+ ldap_user = _FakeLdapUser(username="bob", email="bob@example.com", groups=[])
|
|
|
+ ldap_config = _FakeLdapConfig(group_mapping={}, default_group="Guests")
|
|
|
+
|
|
|
+ await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
|
|
|
+ await db_session.refresh(user, attribute_names=["groups"])
|
|
|
+ assert {g.name for g in user.groups} == {"Guests"}, "Default group should persist"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_revocation_in_ldap_still_propagates(self, db_session: AsyncSession):
|
|
|
+ """The original design intent — revocation in LDAP must flow through — must
|
|
|
+ still work for LDAP-managed groups. User was in 'Users' (LDAP-mapped); LDAP
|
|
|
+ no longer reports the mapped group; sync must remove 'Users'."""
|
|
|
+ users = await _make_group(db_session, "Users")
|
|
|
+
|
|
|
+ user = await _make_ldap_user(db_session, "charlie", [users])
|
|
|
+ assert {g.name for g in user.groups} == {"Users"}
|
|
|
+
|
|
|
+ ldap_user = _FakeLdapUser(username="charlie", email="charlie@example.com", groups=[])
|
|
|
+ ldap_config = _FakeLdapConfig(
|
|
|
+ group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
|
|
|
+ default_group="",
|
|
|
+ )
|
|
|
+
|
|
|
+ await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
|
|
|
+ await db_session.refresh(user, attribute_names=["groups"])
|
|
|
+ assert {g.name for g in user.groups} == set(), (
|
|
|
+ "LDAP-managed groups must be removed when LDAP no longer reports the user in them"
|
|
|
+ )
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_manual_assignment_to_managed_group_still_overridden(self, db_session: AsyncSession):
|
|
|
+ """If an admin manually assigns a group that IS in the LDAP mapping, LDAP
|
|
|
+ truth still wins — otherwise revoking access in LDAP wouldn't work for
|
|
|
+ users who happened to have manual assignments to the same group. Cannot
|
|
|
+ distinguish manual-but-mapped from LDAP-derived once the assignment is
|
|
|
+ in the DB; resolved by treating any group in the LDAP-managed set as
|
|
|
+ authoritative-by-LDAP."""
|
|
|
+ users = await _make_group(db_session, "Users")
|
|
|
+
|
|
|
+ # Manually assign 'Users' (which IS in the LDAP mapping) to an LDAP user.
|
|
|
+ user = await _make_ldap_user(db_session, "dave", [users])
|
|
|
+
|
|
|
+ # LDAP says the user is in no mapped groups.
|
|
|
+ ldap_user = _FakeLdapUser(username="dave", email="dave@example.com", groups=[])
|
|
|
+ ldap_config = _FakeLdapConfig(
|
|
|
+ group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
|
|
|
+ default_group="",
|
|
|
+ )
|
|
|
+
|
|
|
+ await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
|
|
|
+ await db_session.refresh(user, attribute_names=["groups"])
|
|
|
+ assert {g.name for g in user.groups} == set(), (
|
|
|
+ "Manual assignment to an LDAP-managed group is overridden by LDAP state"
|
|
|
+ )
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_mixed_manual_and_ldap_groups(self, db_session: AsyncSession):
|
|
|
+ """Most realistic scenario: user has multiple manual assignments AND LDAP
|
|
|
+ mapped groups. Manual groups survive; LDAP-managed slice gets rebuilt."""
|
|
|
+ admins = await _make_group(db_session, "Administrators")
|
|
|
+ ops = await _make_group(db_session, "PrintOps")
|
|
|
+ users = await _make_group(db_session, "Users")
|
|
|
+ await _make_group(db_session, "Power Users")
|
|
|
+
|
|
|
+ # User has two manual groups (Administrators, PrintOps) plus one LDAP
|
|
|
+ # group (Users) at the start.
|
|
|
+ user = await _make_ldap_user(db_session, "eve", [admins, ops, users])
|
|
|
+
|
|
|
+ # LDAP login: user is now in two LDAP-mapped groups.
|
|
|
+ ldap_user = _FakeLdapUser(
|
|
|
+ username="eve",
|
|
|
+ email="eve@example.com",
|
|
|
+ groups=["cn=staff,ou=groups,dc=example,dc=com", "cn=power,ou=groups,dc=example,dc=com"],
|
|
|
+ )
|
|
|
+ ldap_config = _FakeLdapConfig(
|
|
|
+ group_mapping={
|
|
|
+ "cn=staff,ou=groups,dc=example,dc=com": "Users",
|
|
|
+ "cn=power,ou=groups,dc=example,dc=com": "Power Users",
|
|
|
+ },
|
|
|
+ default_group="",
|
|
|
+ )
|
|
|
+
|
|
|
+ await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
|
|
|
+ await db_session.refresh(user, attribute_names=["groups"])
|
|
|
+ assert {g.name for g in user.groups} == {
|
|
|
+ "Administrators", # manual, preserved
|
|
|
+ "PrintOps", # manual, preserved
|
|
|
+ "Users", # LDAP-managed, retained from LDAP
|
|
|
+ "Power Users", # LDAP-managed, newly added from LDAP
|
|
|
+ }
|