mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
_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.
197 lines
8.2 KiB
Python
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
|
|
}
|