Procházet zdrojové kódy

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.
maziggy před 2 týdny
rodič
revize
5fea138f5d

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 25 - 6
backend/app/api/routes/auth.py

@@ -1185,7 +1185,15 @@ async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User
 
 
 
 
 async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:
 async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:
-    """Sync LDAP user attributes (email, groups) on each login."""
+    """Sync LDAP user attributes (email, groups) on each login.
+
+    Group sync only touches BamBuddy groups that LDAP is configured to manage —
+    that is, the values of `group_mapping` plus `default_group`. Any group
+    outside that set is assumed to be a manual admin assignment and is
+    preserved across logins (#1292). Manual assignments to a BamBuddy group
+    that IS LDAP-managed are still overridden by LDAP truth, because revoking
+    access in LDAP must propagate to BamBuddy on next login.
+    """
     import logging
     import logging
 
 
     from backend.app.services.ldap_service import resolve_group_mapping
     from backend.app.services.ldap_service import resolve_group_mapping
@@ -1199,9 +1207,13 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
         user.email = ldap_user.email
         user.email = ldap_user.email
         changed = True
         changed = True
 
 
-    # Sync group mappings — always update to match LDAP state (including revocation).
-    # Fall back to the configured default group when the user has no mapped groups,
-    # so authenticated LDAP users are never left permission-less.
+    # Compute the set of BamBuddy groups LDAP is allowed to manage. Anything
+    # outside this set is left alone so manual admin assignments survive logins.
+    ldap_managed_names: set[str] = set(ldap_config.group_mapping.values())
+    if ldap_config.default_group:
+        ldap_managed_names.add(ldap_config.default_group)
+
+    # Resolve what LDAP says the user should currently be in.
     mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
     mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
     if not mapped_group_names and ldap_config.default_group:
     if not mapped_group_names and ldap_config.default_group:
         mapped_group_names = [ldap_config.default_group]
         mapped_group_names = [ldap_config.default_group]
@@ -1210,11 +1222,18 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
             user.username,
             user.username,
             ldap_config.default_group,
             ldap_config.default_group,
         )
         )
+
     if mapped_group_names:
     if mapped_group_names:
         groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
         groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
-        new_groups = list(groups_result.scalars().all())
+        new_ldap_groups = list(groups_result.scalars().all())
     else:
     else:
-        new_groups = []
+        new_ldap_groups = []
+
+    # Preserve manual assignments to non-LDAP-managed groups; replace only
+    # the LDAP-managed slice with the resolved set.
+    preserved_manual_groups = [g for g in user.groups if g.name not in ldap_managed_names]
+    new_groups = preserved_manual_groups + new_ldap_groups
+
     current_group_ids = {g.id for g in user.groups}
     current_group_ids = {g.id for g in user.groups}
     new_group_ids = {g.id for g in new_groups}
     new_group_ids = {g.id for g in new_groups}
     if current_group_ids != new_group_ids:
     if current_group_ids != new_group_ids:

+ 196 - 0
backend/tests/integration/test_ldap_group_sync.py

@@ -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
+        }

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů