Files
bambuddy/backend/tests/integration/test_ldap_group_sync.py
T
maziggy 5fea138f5d fix(auth): preserve manually-assigned groups across LDAP logins (#1292)
_sync_ldap_user used to replace user.groups entirely on every login,
  wiping manual admin assignments to groups outside the LDAP mapping.
  Now partitions on LDAP-managed group names (mapping values + default
  group) and only rebuilds that slice from LDAP truth. Manual assignments
  to non-managed groups are preserved; revocation in LDAP still
  propagates for managed groups.
2026-05-12 16:12:58 +02:00

197 lines
8.2 KiB
Python

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