Browse Source

LDAP: POSIX primary group support and default fallback group

  Two related LDAP authentication changes.

  Fix: POSIX primary group membership was ignored. authenticate_ldap_user
  only searched for posixGroup entries via memberUid (supplementary
  groups). A user's primary group — referenced by the gidNumber attribute
  on the user object matching gidNumber on a posixGroup — was never
  resolved, so users whose role came from their primary group landed
  without the expected permissions. The authenticator now runs a second
  search for posixGroup entries whose gidNumber matches the user's
  primary gidNumber, then dedupes DNs case-insensitively before passing
  the list to resolve_group_mapping (LDAP DNs are case-insensitive by
  spec).

  New feature: ldap_default_group setting. Settings → Authentication →
  LDAP → Advanced has a new "Default group" selector. When an LDAP user
  authenticates but is not listed in any mapped LDAP group, they are
  assigned to this fallback group instead of being left with no groups
  (and therefore no permissions). A warning is logged each time the
  fallback is applied so admins can spot missing group assignments.
  Empty setting preserves the old behavior.

  Tests: added 4 mocked authenticate_ldap_user tests covering primary
  gidNumber lookup, dedupe of overlapping memberUid+primary gid matches,
  case-insensitive DN dedupe, and the guard when a user entry has no
  gidNumber attribute. Also extended the existing parse_ldap_config tests
  to cover the new default_group field.

  Backend: ldap_service.py (primary group + dedupe + default_group
  field), schemas/settings.py (schema field), api/routes/auth.py
  (fallback wiring in _provision_ldap_user / _sync_ldap_user).

  Frontend: LDAPSettings.tsx default-group dropdown in the Advanced
  collapsible, api/client.ts type field, new i18n keys in all 7 locales
  (defaultGroup, defaultGroupNone, defaultGroupHint).
maziggy 1 month ago
parent
commit
848f558105

+ 4 - 0
CHANGELOG.md

@@ -4,7 +4,11 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [0.2.3b3] - Unreleased
 ## [0.2.3b3] - Unreleased
 
 
+### New Features
+- **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
+
 ### Fixed
 ### Fixed
+- **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).
 - **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.
 - **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.
 - **"Build Plate Cleared" Button Unclickable After Second Print** ([#912](https://github.com/maziggy/bambuddy/issues/912)) — After completing the first queued print and confirming the plate was cleared, the "Build plate cleared — ready for next print" button became unresponsive after the second print finished. The React Query mutation's `isSuccess` state persisted from the first plate-clear confirmation, causing the component to render the static "Plate Ready" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle.
 - **"Build Plate Cleared" Button Unclickable After Second Print** ([#912](https://github.com/maziggy/bambuddy/issues/912)) — After completing the first queued print and confirming the plate was cleared, the "Build plate cleared — ready for next print" button became unresponsive after the second print finished. The React Query mutation's `isSuccess` state persisted from the first plate-clear confirmation, causing the component to render the static "Plate Ready" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle.
 - **Spoolman Location Not Cleared When Spool Removed from AMS** ([#921](https://github.com/maziggy/bambuddy/issues/921)) — When Spoolman auto-sync was enabled and a spool was removed from an AMS slot, its location in Spoolman was never cleared, causing "double-booked" slots where multiple spools shared the same location. The auto-sync callback set locations for newly inserted spools but skipped the cleanup step that clears stale locations. The location clearing logic now runs after every auto-sync cycle. Also fixed the single-printer manual sync endpoint which didn't track synced spool IDs, risking incorrect location clearing for location-matched (non-RFID) spools.
 - **Spoolman Location Not Cleared When Spool Removed from AMS** ([#921](https://github.com/maziggy/bambuddy/issues/921)) — When Spoolman auto-sync was enabled and a spool was removed from an AMS slot, its location in Spoolman was never cleared, causing "double-booked" slots where multiple spools shared the same location. The auto-sync callback set locations for newly inserted spools but skipped the cleanup step that clears stale locations. The location clearing logic now runs after every auto-sync cycle. Also fixed the single-printer manual sync endpoint which didn't track synced spool IDs, risking incorrect location clearing for location-matched (non-RFID) spools.

+ 20 - 2
backend/app/api/routes/auth.py

@@ -751,6 +751,7 @@ async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
         "ldap_group_mapping",
         "ldap_group_mapping",
         "ldap_auto_provision",
         "ldap_auto_provision",
         "ldap_ca_cert_path",
         "ldap_ca_cert_path",
+        "ldap_default_group",
     ]
     ]
     result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
     result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
     settings = {s.key: s.value for s in result.scalars().all()}
     settings = {s.key: s.value for s in result.scalars().all()}
@@ -776,8 +777,16 @@ async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User
         is_active=True,
         is_active=True,
     )
     )
 
 
-    # Map LDAP groups to BamBuddy groups
+    # Map LDAP groups to BamBuddy groups, falling back to the configured default group
+    # when the user is authenticated but has no matching group mapping (#921-follow-up).
     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:
+        mapped_group_names = [ldap_config.default_group]
+        logger.warning(
+            "LDAP user %s has no mapped groups — assigning configured default group '%s'",
+            ldap_user.username,
+            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_user.groups = list(groups_result.scalars().all())
         new_user.groups = list(groups_result.scalars().all())
@@ -804,8 +813,17 @@ 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)
+    # 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.
     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:
+        mapped_group_names = [ldap_config.default_group]
+        logger.warning(
+            "LDAP user %s has no mapped groups — assigning configured default group '%s'",
+            user.username,
+            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_groups = list(groups_result.scalars().all())

+ 5 - 0
backend/app/schemas/settings.py

@@ -244,6 +244,10 @@ class AppSettings(BaseModel):
         default=False,
         default=False,
         description="Auto-create BamBuddy user on first successful LDAP login",
         description="Auto-create BamBuddy user on first successful LDAP login",
     )
     )
+    ldap_default_group: str = Field(
+        default="",
+        description="Fallback BamBuddy group name assigned when an LDAP user authenticates but has no mapped groups. Empty = no fallback.",
+    )
 
 
     # Default sidebar order (admin-set for all users)
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
     default_sidebar_order: str = Field(
@@ -339,6 +343,7 @@ class AppSettingsUpdate(BaseModel):
     ldap_security: str | None = None
     ldap_security: str | None = None
     ldap_group_mapping: str | None = None
     ldap_group_mapping: str | None = None
     ldap_auto_provision: bool | None = None
     ldap_auto_provision: bool | None = None
+    ldap_default_group: str | None = None
     default_sidebar_order: str | None = None
     default_sidebar_order: str | None = None
 
 
     @field_validator("gcode_snippets")
     @field_validator("gcode_snippets")

+ 29 - 1
backend/app/services/ldap_service.py

@@ -41,6 +41,7 @@ class LDAPConfig:
     group_mapping: dict[str, str]  # LDAP group DN -> BamBuddy group name
     group_mapping: dict[str, str]  # LDAP group DN -> BamBuddy group name
     auto_provision: bool
     auto_provision: bool
     ca_cert_path: str  # Path to CA certificate file (empty = skip verification)
     ca_cert_path: str  # Path to CA certificate file (empty = skip verification)
+    default_group: str  # Fallback BamBuddy group assigned when user has no mapped groups (empty = no fallback)
 
 
 
 
 def parse_ldap_config(settings: dict[str, str]) -> LDAPConfig | None:
 def parse_ldap_config(settings: dict[str, str]) -> LDAPConfig | None:
@@ -68,6 +69,7 @@ def parse_ldap_config(settings: dict[str, str]) -> LDAPConfig | None:
         group_mapping=group_mapping if isinstance(group_mapping, dict) else {},
         group_mapping=group_mapping if isinstance(group_mapping, dict) else {},
         auto_provision=settings.get("ldap_auto_provision", "false").lower() == "true",
         auto_provision=settings.get("ldap_auto_provision", "false").lower() == "true",
         ca_cert_path=settings.get("ldap_ca_cert_path", "").strip(),
         ca_cert_path=settings.get("ldap_ca_cert_path", "").strip(),
+        default_group=settings.get("ldap_default_group", "").strip(),
     )
     )
 
 
 
 
@@ -168,7 +170,7 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
             [str(g) for g in user_entry.memberOf] if hasattr(user_entry, "memberOf") and user_entry.memberOf else []
             [str(g) for g in user_entry.memberOf] if hasattr(user_entry, "memberOf") and user_entry.memberOf else []
         )
         )
 
 
-        # Also search for Posix groups (memberUid-based) using the service account
+        # Also search for POSIX groups (memberUid-based) using the service account
         canonical_username = username
         canonical_username = username
         if hasattr(user_entry, "sAMAccountName") and user_entry.sAMAccountName:
         if hasattr(user_entry, "sAMAccountName") and user_entry.sAMAccountName:
             canonical_username = str(user_entry.sAMAccountName)
             canonical_username = str(user_entry.sAMAccountName)
@@ -185,6 +187,32 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
         for entry in service_conn.entries:
         for entry in service_conn.entries:
             groups.append(str(entry.entry_dn))
             groups.append(str(entry.entry_dn))
 
 
+        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
+        # Standard Unix semantics treat this as full group membership, so we need
+        # to resolve it to a group DN alongside the memberUid results.
+        if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
+            primary_gid = str(user_entry.gidNumber)
+            primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+            service_conn.search(
+                search_base=config.search_base,
+                search_filter=primary_filter,
+                search_scope=SUBTREE,
+                attributes=["cn"],
+            )
+            for entry in service_conn.entries:
+                groups.append(str(entry.entry_dn))
+
+        # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
+        # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
+        seen_lower: set[str] = set()
+        deduped_groups: list[str] = []
+        for g in groups:
+            key = g.lower()
+            if key not in seen_lower:
+                seen_lower.add(key)
+                deduped_groups.append(g)
+        groups = deduped_groups
+
         logger.info(
         logger.info(
             "LDAP authentication successful for user: %s (DN: %s, groups: %d)", canonical_username, user_dn, len(groups)
             "LDAP authentication successful for user: %s (DN: %s, groups: %d)", canonical_username, user_dn, len(groups)
         )
         )

+ 197 - 0
backend/tests/unit/services/test_ldap_service.py

@@ -16,6 +16,7 @@ from backend.app.services.ldap_service import (
     LDAPConfig,
     LDAPConfig,
     LDAPUserInfo,
     LDAPUserInfo,
     _ldap_escape,
     _ldap_escape,
+    authenticate_ldap_user,
     parse_ldap_config,
     parse_ldap_config,
     resolve_group_mapping,
     resolve_group_mapping,
 )
 )
@@ -55,6 +56,7 @@ class TestParseConfig:
         assert config.group_mapping == {}
         assert config.group_mapping == {}
         assert config.auto_provision is False
         assert config.auto_provision is False
         assert config.ca_cert_path == ""
         assert config.ca_cert_path == ""
+        assert config.default_group == ""
 
 
     def test_parses_full_config(self):
     def test_parses_full_config(self):
         settings = {
         settings = {
@@ -68,6 +70,7 @@ class TestParseConfig:
             "ldap_group_mapping": '{"cn=admins,dc=example,dc=com": "Administrators"}',
             "ldap_group_mapping": '{"cn=admins,dc=example,dc=com": "Administrators"}',
             "ldap_auto_provision": "true",
             "ldap_auto_provision": "true",
             "ldap_ca_cert_path": "/path/to/ca.pem",
             "ldap_ca_cert_path": "/path/to/ca.pem",
+            "ldap_default_group": "Viewers",
         }
         }
         config = parse_ldap_config(settings)
         config = parse_ldap_config(settings)
         assert config is not None
         assert config is not None
@@ -79,6 +82,7 @@ class TestParseConfig:
         assert config.group_mapping == {"cn=admins,dc=example,dc=com": "Administrators"}
         assert config.group_mapping == {"cn=admins,dc=example,dc=com": "Administrators"}
         assert config.auto_provision is True
         assert config.auto_provision is True
         assert config.ca_cert_path == "/path/to/ca.pem"
         assert config.ca_cert_path == "/path/to/ca.pem"
+        assert config.default_group == "Viewers"
 
 
     def test_handles_invalid_group_mapping_json(self):
     def test_handles_invalid_group_mapping_json(self):
         settings = {
         settings = {
@@ -113,11 +117,13 @@ class TestParseConfig:
             "ldap_server_url": "  ldaps://ldap.example.com  ",
             "ldap_server_url": "  ldaps://ldap.example.com  ",
             "ldap_bind_dn": "  cn=admin,dc=example,dc=com  ",
             "ldap_bind_dn": "  cn=admin,dc=example,dc=com  ",
             "ldap_search_base": "  dc=example,dc=com  ",
             "ldap_search_base": "  dc=example,dc=com  ",
+            "ldap_default_group": "  Viewers  ",
         }
         }
         config = parse_ldap_config(settings)
         config = parse_ldap_config(settings)
         assert config.server_url == "ldaps://ldap.example.com"
         assert config.server_url == "ldaps://ldap.example.com"
         assert config.bind_dn == "cn=admin,dc=example,dc=com"
         assert config.bind_dn == "cn=admin,dc=example,dc=com"
         assert config.search_base == "dc=example,dc=com"
         assert config.search_base == "dc=example,dc=com"
+        assert config.default_group == "Viewers"
 
 
 
 
 class TestLDAPEscape:
 class TestLDAPEscape:
@@ -225,6 +231,197 @@ class TestDataclasses:
             group_mapping={"cn=admins": "Administrators"},
             group_mapping={"cn=admins": "Administrators"},
             auto_provision=True,
             auto_provision=True,
             ca_cert_path="",
             ca_cert_path="",
+            default_group="Viewers",
         )
         )
         assert config.server_url == "ldaps://ldap.example.com:636"
         assert config.server_url == "ldaps://ldap.example.com:636"
         assert config.auto_provision is True
         assert config.auto_provision is True
+        assert config.default_group == "Viewers"
+
+
+# ---------------------------------------------------------------------------
+# Mocked authenticate_ldap_user group-discovery tests
+# ---------------------------------------------------------------------------
+# These tests mock ldap3.Connection to exercise the group-discovery logic in
+# authenticate_ldap_user without a live LDAP server. Added after a bug where
+# POSIX primary-group membership (via gidNumber) was ignored — see CHANGELOG.
+
+
+class _MockAttr:
+    """Minimal stand-in for ldap3 Attribute objects.
+
+    Supports str(), bool(), .value, .values, and iteration — the operations
+    used by ldap_service against user entry attributes.
+    """
+
+    def __init__(self, value):
+        self._value = value
+
+    @property
+    def value(self):
+        return self._value
+
+    @property
+    def values(self):
+        return self._value if isinstance(self._value, list) else [self._value]
+
+    def __str__(self):
+        return str(self._value)
+
+    def __bool__(self):
+        return bool(self._value)
+
+    def __iter__(self):
+        if isinstance(self._value, list):
+            return iter(self._value)
+        return iter([self._value])
+
+
+class _MockEntry:
+    """Minimal stand-in for ldap3 Entry. Only attributes passed at construction exist."""
+
+    def __init__(self, dn, **attrs):
+        self.entry_dn = dn
+        for key, val in attrs.items():
+            setattr(self, key, _MockAttr(val))
+
+
+class _MockConnection:
+    """Mock ldap3 Connection that returns pre-configured entries based on filter substring match.
+
+    Every Connection() instance shares a class-level fixture dict so the service-account
+    connection and the user-bind connection both see the same fake directory.
+    """
+
+    _search_fixture: dict[str, list] = {}
+    _instances: list["_MockConnection"] = []
+
+    def __init__(self, *args, **kwargs):
+        self.entries: list = []
+        self.search_calls: list[str] = []
+        _MockConnection._instances.append(self)
+
+    def open(self):
+        pass
+
+    def start_tls(self):
+        pass
+
+    def bind(self):
+        return True
+
+    def unbind(self):
+        pass
+
+    def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None):
+        self.search_calls.append(search_filter or "")
+        for needle, entries in _MockConnection._search_fixture.items():
+            if needle in (search_filter or ""):
+                self.entries = entries
+                return True
+        self.entries = []
+        return True
+
+
+@pytest.fixture
+def mock_ldap(monkeypatch):
+    """Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline."""
+    _MockConnection._search_fixture = {}
+    _MockConnection._instances = []
+    monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection)
+    monkeypatch.setattr("backend.app.services.ldap_service._create_server", lambda config: None)
+    return _MockConnection
+
+
+def _base_config(**overrides):
+    """Build a minimal LDAPConfig for mocked tests."""
+    defaults = {
+        "server_url": "ldaps://test.example.com:636",
+        "bind_dn": "cn=admin,dc=test,dc=com",
+        "bind_password": "x",
+        "search_base": "dc=test,dc=com",
+        "user_filter": "(uid={username})",
+        "security": "ldaps",
+        "group_mapping": {},
+        "auto_provision": False,
+        "ca_cert_path": "",
+        "default_group": "",
+    }
+    defaults.update(overrides)
+    return LDAPConfig(**defaults)
+
+
+class TestAuthenticateLdapUserGroups:
+    """Group-discovery behaviour in authenticate_ldap_user.
+
+    Covers the POSIX primary gidNumber lookup and case-insensitive dedupe added
+    to fix a bug where users whose role came from their primary group were
+    authenticated without the correct group membership.
+    """
+
+    def test_primary_gidnumber_group_found(self, mock_ldap):
+        """Regression: POSIX primary group (gidNumber match) must be included in the result."""
+        user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
+        operators_group = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
+
+        mock_ldap._search_fixture = {
+            "(uid=mz)": [user_entry],
+            "memberUid=mz": [],  # no supplementary memberships
+            "gidNumber=10002": [operators_group],
+        }
+
+        info = authenticate_ldap_user(_base_config(), "mz", "password")
+
+        assert info is not None
+        assert info.groups == ["cn=bambuddy-operators,ou=groups,dc=test,dc=com"]
+
+    def test_dedupes_group_found_via_both_memberuid_and_primary_gid(self, mock_ldap):
+        """A user in the same group via BOTH memberUid and primary gidNumber should appear once."""
+        user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
+        group_entry = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
+
+        mock_ldap._search_fixture = {
+            "(uid=mz)": [user_entry],
+            "memberUid=mz": [group_entry],  # supplementary membership
+            "gidNumber=10002": [group_entry],  # primary group — same DN
+        }
+
+        info = authenticate_ldap_user(_base_config(), "mz", "password")
+
+        assert info.groups == ["cn=bambuddy-operators,ou=groups,dc=test,dc=com"]
+
+    def test_case_insensitive_dedupe(self, mock_ldap):
+        """DNs differing only in case should collapse to a single entry (LDAP DNs are case-insensitive)."""
+        user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
+        upper_dn = _MockEntry("CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com")
+        lower_dn = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
+
+        mock_ldap._search_fixture = {
+            "(uid=mz)": [user_entry],
+            "memberUid=mz": [upper_dn],
+            "gidNumber=10002": [lower_dn],
+        }
+
+        info = authenticate_ldap_user(_base_config(), "mz", "password")
+
+        assert len(info.groups) == 1
+        # The first-seen casing (memberUid result) is kept.
+        assert info.groups[0] == "CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com"
+
+    def test_no_gidnumber_skips_primary_search(self, mock_ldap):
+        """User entries without a gidNumber attribute should not crash and should not issue the primary-gid query."""
+        user_entry = _MockEntry("cn=tester,dc=test,dc=com", uid="tester")  # no gidNumber
+        viewers_group = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com")
+
+        mock_ldap._search_fixture = {
+            "(uid=tester)": [user_entry],
+            "memberUid=tester": [viewers_group],
+        }
+
+        info = authenticate_ldap_user(_base_config(), "tester", "password")
+
+        assert info is not None
+        assert info.groups == ["cn=bambuddy-viewers,ou=groups,dc=test,dc=com"]
+        # Ensure the primary-gidNumber search was never issued — verifying the guard works.
+        service_conn = _MockConnection._instances[0]
+        gidnumber_searches = [call for call in service_conn.search_calls if "gidNumber=" in call]
+        assert gidnumber_searches == []

+ 1 - 0
frontend/src/api/client.ts

@@ -913,6 +913,7 @@ export interface AppSettings {
   ldap_security: string;
   ldap_security: string;
   ldap_group_mapping: string;
   ldap_group_mapping: string;
   ldap_auto_provision: boolean;
   ldap_auto_provision: boolean;
+  ldap_default_group: string;
 }
 }
 
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 24 - 0
frontend/src/components/LDAPSettings.tsx

@@ -24,6 +24,7 @@ interface LDAPFormState {
   ldap_security: string;
   ldap_security: string;
   ldap_group_mapping: string;
   ldap_group_mapping: string;
   ldap_auto_provision: boolean;
   ldap_auto_provision: boolean;
+  ldap_default_group: string;
 }
 }
 
 
 export function LDAPSettings() {
 export function LDAPSettings() {
@@ -41,6 +42,7 @@ export function LDAPSettings() {
     ldap_security: 'starttls',
     ldap_security: 'starttls',
     ldap_group_mapping: '',
     ldap_group_mapping: '',
     ldap_auto_provision: false,
     ldap_auto_provision: false,
+    ldap_default_group: '',
   });
   });
 
 
   // Fetch settings
   // Fetch settings
@@ -73,6 +75,7 @@ export function LDAPSettings() {
         ldap_security: settings.ldap_security || 'starttls',
         ldap_security: settings.ldap_security || 'starttls',
         ldap_group_mapping: settings.ldap_group_mapping || '',
         ldap_group_mapping: settings.ldap_group_mapping || '',
         ldap_auto_provision: settings.ldap_auto_provision ?? false,
         ldap_auto_provision: settings.ldap_auto_provision ?? false,
+        ldap_default_group: settings.ldap_default_group || '',
       });
       });
     }
     }
   }, [settings]);
   }, [settings]);
@@ -138,6 +141,7 @@ export function LDAPSettings() {
       ldap_security: form.ldap_security,
       ldap_security: form.ldap_security,
       ldap_group_mapping: form.ldap_group_mapping,
       ldap_group_mapping: form.ldap_group_mapping,
       ldap_auto_provision: form.ldap_auto_provision,
       ldap_auto_provision: form.ldap_auto_provision,
+      ldap_default_group: form.ldap_default_group,
     };
     };
     if (form.ldap_bind_password) {
     if (form.ldap_bind_password) {
       update.ldap_bind_password = form.ldap_bind_password;
       update.ldap_bind_password = form.ldap_bind_password;
@@ -376,6 +380,26 @@ export function LDAPSettings() {
                   </button>
                   </button>
                 </div>
                 </div>
 
 
+                {/* Default Group (fallback for users with no mapped groups) */}
+                <div>
+                  <label className="block text-sm font-medium text-bambu-gray mb-1">
+                    {t('settings.ldap.defaultGroup') || 'Default group'}
+                  </label>
+                  <select
+                    className={inputClasses}
+                    value={form.ldap_default_group}
+                    onChange={e => setForm({ ...form, ldap_default_group: e.target.value })}
+                  >
+                    <option value="">{t('settings.ldap.defaultGroupNone') || '— None (reject login) —'}</option>
+                    {groups.map(g => (
+                      <option key={g.id} value={g.name}>{g.name}</option>
+                    ))}
+                  </select>
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.ldap.defaultGroupHint') || 'Fallback group assigned when an LDAP user authenticates but is not listed in any mapped group. Leave empty to leave unmapped users without permissions.'}
+                  </p>
+                </div>
+
                 {/* Group Mapping */}
                 {/* Group Mapping */}
                 <div>
                 <div>
                   <label className="block text-sm font-medium text-bambu-gray mb-1">
                   <label className="block text-sm font-medium text-bambu-gray mb-1">

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -1327,6 +1327,9 @@ export default {
       userFilterHint: '{username} wird durch den Anmeldenamen ersetzt. Verwenden Sie (uid={username}) für OpenLDAP.',
       userFilterHint: '{username} wird durch den Anmeldenamen ersetzt. Verwenden Sie (uid={username}) für OpenLDAP.',
       autoProvision: 'Benutzer automatisch anlegen',
       autoProvision: 'Benutzer automatisch anlegen',
       autoProvisionHint: 'Automatisch ein BamBuddy-Konto bei der ersten LDAP-Anmeldung erstellen',
       autoProvisionHint: 'Automatisch ein BamBuddy-Konto bei der ersten LDAP-Anmeldung erstellen',
+      defaultGroup: 'Standardgruppe',
+      defaultGroupNone: '— Keine (kein Fallback) —',
+      defaultGroupHint: 'Fallback-Gruppe, die zugewiesen wird, wenn sich ein LDAP-Benutzer authentifiziert, aber in keiner zugeordneten LDAP-Gruppe enthalten ist. Leer lassen, um nicht zugeordnete Benutzer ohne Berechtigungen zu belassen.',
       groupMapping: 'Gruppenzuordnung (JSON)',
       groupMapping: 'Gruppenzuordnung (JSON)',
       groupMappingHint: 'LDAP-Gruppen-DNs BamBuddy-Gruppen zuordnen. Verfügbare Gruppen: ',
       groupMappingHint: 'LDAP-Gruppen-DNs BamBuddy-Gruppen zuordnen. Verfügbare Gruppen: ',
       testConnection: 'Verbindung testen',
       testConnection: 'Verbindung testen',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -1329,6 +1329,9 @@ export default {
       userFilterHint: '{username} is replaced with the login username. Use (uid={username}) for OpenLDAP.',
       userFilterHint: '{username} is replaced with the login username. Use (uid={username}) for OpenLDAP.',
       autoProvision: 'Auto-provision users',
       autoProvision: 'Auto-provision users',
       autoProvisionHint: 'Automatically create a BamBuddy account on first LDAP login',
       autoProvisionHint: 'Automatically create a BamBuddy account on first LDAP login',
+      defaultGroup: 'Default group',
+      defaultGroupNone: '— None (no fallback) —',
+      defaultGroupHint: 'Fallback group assigned when an LDAP user authenticates but is not listed in any mapped LDAP group. Leave empty to leave unmapped users without permissions.',
       groupMapping: 'Group Mapping (JSON)',
       groupMapping: 'Group Mapping (JSON)',
       groupMappingHint: 'Map LDAP group DNs to BamBuddy groups. Available groups: ',
       groupMappingHint: 'Map LDAP group DNs to BamBuddy groups. Available groups: ',
       testConnection: 'Test Connection',
       testConnection: 'Test Connection',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -1327,6 +1327,9 @@ export default {
       userFilterHint: "{username} est remplacé par le nom d'utilisateur. Utilisez (uid={username}) pour OpenLDAP.",
       userFilterHint: "{username} est remplacé par le nom d'utilisateur. Utilisez (uid={username}) pour OpenLDAP.",
       autoProvision: 'Provisionnement automatique',
       autoProvision: 'Provisionnement automatique',
       autoProvisionHint: 'Créer automatiquement un compte BamBuddy lors de la première connexion LDAP',
       autoProvisionHint: 'Créer automatiquement un compte BamBuddy lors de la première connexion LDAP',
+      defaultGroup: 'Groupe par défaut',
+      defaultGroupNone: '— Aucun (pas de repli) —',
+      defaultGroupHint: "Groupe de repli attribué lorsqu'un utilisateur LDAP s'authentifie mais n'est dans aucun groupe LDAP mappé. Laissez vide pour laisser les utilisateurs non mappés sans autorisations.",
       groupMapping: 'Mappage de groupes (JSON)',
       groupMapping: 'Mappage de groupes (JSON)',
       groupMappingHint: 'Associer les DN de groupes LDAP aux groupes BamBuddy. Groupes disponibles : ',
       groupMappingHint: 'Associer les DN de groupes LDAP aux groupes BamBuddy. Groupes disponibles : ',
       testConnection: 'Tester la connexion',
       testConnection: 'Tester la connexion',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -1327,6 +1327,9 @@ export default {
       userFilterHint: '{username} viene sostituito con il nome utente. Usa (uid={username}) per OpenLDAP.',
       userFilterHint: '{username} viene sostituito con il nome utente. Usa (uid={username}) per OpenLDAP.',
       autoProvision: 'Provisioning automatico utenti',
       autoProvision: 'Provisioning automatico utenti',
       autoProvisionHint: 'Crea automaticamente un account BamBuddy al primo accesso LDAP',
       autoProvisionHint: 'Crea automaticamente un account BamBuddy al primo accesso LDAP',
+      defaultGroup: 'Gruppo predefinito',
+      defaultGroupNone: '— Nessuno (nessun fallback) —',
+      defaultGroupHint: 'Gruppo di fallback assegnato quando un utente LDAP si autentica ma non è presente in nessun gruppo LDAP mappato. Lascia vuoto per lasciare gli utenti non mappati senza autorizzazioni.',
       groupMapping: 'Mappatura gruppi (JSON)',
       groupMapping: 'Mappatura gruppi (JSON)',
       groupMappingHint: 'Mappa i DN dei gruppi LDAP ai gruppi BamBuddy. Gruppi disponibili: ',
       groupMappingHint: 'Mappa i DN dei gruppi LDAP ai gruppi BamBuddy. Gruppi disponibili: ',
       testConnection: 'Test connessione',
       testConnection: 'Test connessione',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -1326,6 +1326,9 @@ export default {
       userFilterHint: '{username}はログインユーザー名に置き換えられます。OpenLDAPの場合は(uid={username})を使用。',
       userFilterHint: '{username}はログインユーザー名に置き換えられます。OpenLDAPの場合は(uid={username})を使用。',
       autoProvision: 'ユーザー自動作成',
       autoProvision: 'ユーザー自動作成',
       autoProvisionHint: '初回LDAPログイン時にBamBuddyアカウントを自動作成',
       autoProvisionHint: '初回LDAPログイン時にBamBuddyアカウントを自動作成',
+      defaultGroup: 'デフォルトグループ',
+      defaultGroupNone: '— なし(フォールバックなし)—',
+      defaultGroupHint: 'LDAPユーザーが認証されたがマッピングされたLDAPグループに属していない場合に割り当てられるフォールバックグループ。空欄の場合、マッピングされていないユーザーは権限なしのままになります。',
       groupMapping: 'グループマッピング(JSON)',
       groupMapping: 'グループマッピング(JSON)',
       groupMappingHint: 'LDAPグループDNをBamBuddyグループにマッピング。利用可能なグループ: ',
       groupMappingHint: 'LDAPグループDNをBamBuddyグループにマッピング。利用可能なグループ: ',
       testConnection: '接続テスト',
       testConnection: '接続テスト',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1327,6 +1327,9 @@ export default {
       userFilterHint: '{username} é substituído pelo nome de usuário. Use (uid={username}) para OpenLDAP.',
       userFilterHint: '{username} é substituído pelo nome de usuário. Use (uid={username}) para OpenLDAP.',
       autoProvision: 'Provisionamento automático de usuários',
       autoProvision: 'Provisionamento automático de usuários',
       autoProvisionHint: 'Criar automaticamente uma conta BamBuddy no primeiro login LDAP',
       autoProvisionHint: 'Criar automaticamente uma conta BamBuddy no primeiro login LDAP',
+      defaultGroup: 'Grupo padrão',
+      defaultGroupNone: '— Nenhum (sem fallback) —',
+      defaultGroupHint: 'Grupo de fallback atribuído quando um usuário LDAP se autentica mas não está em nenhum grupo LDAP mapeado. Deixe vazio para manter usuários não mapeados sem permissões.',
       groupMapping: 'Mapeamento de grupos (JSON)',
       groupMapping: 'Mapeamento de grupos (JSON)',
       groupMappingHint: 'Mapear DNs de grupos LDAP para grupos BamBuddy. Grupos disponíveis: ',
       groupMappingHint: 'Mapear DNs de grupos LDAP para grupos BamBuddy. Grupos disponíveis: ',
       testConnection: 'Testar conexão',
       testConnection: 'Testar conexão',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1327,6 +1327,9 @@ export default {
       userFilterHint: '{username} 替换为登录用户名。OpenLDAP 使用 (uid={username})。',
       userFilterHint: '{username} 替换为登录用户名。OpenLDAP 使用 (uid={username})。',
       autoProvision: '自动创建用户',
       autoProvision: '自动创建用户',
       autoProvisionHint: '首次 LDAP 登录时自动创建 BamBuddy 帐户',
       autoProvisionHint: '首次 LDAP 登录时自动创建 BamBuddy 帐户',
+      defaultGroup: '默认组',
+      defaultGroupNone: '— 无(无回退)—',
+      defaultGroupHint: '当 LDAP 用户通过身份验证但不在任何已映射的 LDAP 组中时分配的回退组。留空以使未映射的用户没有权限。',
       groupMapping: '组映射(JSON)',
       groupMapping: '组映射(JSON)',
       groupMappingHint: '将 LDAP 组 DN 映射到 BamBuddy 组。可用组:',
       groupMappingHint: '将 LDAP 组 DN 映射到 BamBuddy 组。可用组:',
       testConnection: '测试连接',
       testConnection: '测试连接',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-LfULlGD1.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-OM4EoHsE.js"></script>
+    <script type="module" crossorigin src="/assets/index-LfULlGD1.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Caj-77TJ.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Caj-77TJ.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff