| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- """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
- }
|