From 5efbd353ed54e572e8ec51c8b57a7622c939df02 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 5 Aug 2026 11:14:57 +0200 Subject: [PATCH] Survive a directory that defines no posixGroup class (#2769) Every LDAP user on an lldap directory was rejected with "Incorrect username or password", on an install where Test Connection passed and where the same bind DN, filter and group membership all checked out under ldapsearch. The directory never saw the request. _extract_user_info searches for POSIX groups alongside the memberOf ones, and both of those filters name the posixGroup object class. ldap3 fetches the schema at connect time (get_info=ALL) and validates class names in a filter against it while building the request, raising LDAPObjectClassError before anything is sent. lldap marks every account it creates as posixAccount -- which is what makes us look for POSIX groups at all -- but defines no group class beyond groupOfNames. The exception escaped authenticate_ldap_user, and the login route reports any LDAP failure as bad credentials. A directory with no posixGroup class has no posixGroup entries, which is exactly the answer those searches would have returned. Catch it, log it once, and carry on with the memberOf groups collected above. Both searches sit inside the one try: they name the same class, so once one is rejected the other cannot succeed, and attempting it would only produce a second identical exception to swallow. Not a regression from 848f55810. The memberUid filter has named the class since b6599dd41 and runs for every user whether or not they have a gidNumber, so a directory of this shape has never been able to log in; the primary-group lookup only added a second trigger. Test Connection was unaffected throughout because (objectClass=*) is a presence filter and never reaches the value validator. The mock connection gained a hook that raises on a filter substring, standing in for that client-side validation. Reset in the fixture and default None, so existing tests are unchanged. --- CHANGELOG.md | 1 + backend/app/services/ldap_service.py | 60 +++++++++---- .../tests/unit/services/test_ldap_service.py | 88 +++++++++++++++++++ 3 files changed, 131 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf941264..7a66cbfc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed +- **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests. - **Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768)** — A sliced file numbers its filaments 1, 2, 3, 4, and which AMS tray each of those came from is a separate decision made when the job is sent. Bambuddy learns that decision one of two ways: it made the choice itself, for a print started from Bambuddy, or it read the print command as it crossed the local network, for a print sent from a slicer. A job dispatched from Bambu Studio while the printer is signed in to Bambu's cloud satisfies neither — the command travels through Bambu's own broker and never appears on the network Bambuddy is listening to. With nothing recorded, the Spoolman writer fell back to assuming the AMS was loaded in slicer order: filament 1 from the first loaded tray, filament 2 from the second. The reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so every one of the four was deducted from the wrong spool. It also changed what the print looked like afterwards: on completion Bambuddy stamps the archive with the material and colour of the spools it charged, so the print showed the right filament while it ran and switched to a different one the moment it finished — which is how the reporter noticed. The printer knew the answer all along. It publishes the running job's slot-to-tray assignment in its own status, and Bambuddy's built-in filament inventory has read that field for as long as it has resolved mappings at completion; only the Spoolman writer, which resolves at print start instead, never learned to. It now consults the same two fallbacks at the same moment: the printer's report first, and failing that a colour match of the sliced filaments against the loaded trays, which covers the A1, A1 Mini, P1S and P2S — those models publish no such field, so their owners were on the positional guess no matter how the print was sent. Reading the field at completion rather than at print start is deliberate: a printer keeps publishing the last job's mapping while it sits idle, so consulting it early risks stamping the previous print's mapping onto this one. A mapping Bambuddy or the slicer actually recorded is never second-guessed, so nothing changes for prints started from Bambuddy, from the queue, or over LAN. Cancelled and failed prints take the same correction, since partial usage is charged through the same mapping. The resolved mapping and where it came from are now logged at both print start and completion, so the next report of a wrong deduction can be read straight out of a support bundle. Wiki updated. Covered by backend tests. - **A drying cycle no longer reports itself finished a minute after it starts (#2759)** — Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute, then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target Bambuddy cached when it sent the command — and that cache had been thrown away. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase; the reporter's log caught 720 minutes, then 0, then 719. Bambuddy read the zero as the cycle ending. Losing the cached target left the badge to guess the filament from the first loaded slot, which happened to be PETG, and its RFID-recommended 65°C — a confident wrong answer for a cycle running PLA at 45. The same false ending also armed smart-plug auto-off-after-drying, so anyone with that switched on had power scheduled to cut one minute into a twelve-hour dry. A remaining time of zero is now only treated as the end of a cycle when the AMS also reports an idle phase, which the firmware already publishes alongside it; stopping a dry early still ends it immediately, and a unit that reports no phase at all still ends its cycles as before. The fallback guess has been tightened to match, in both directions. It names a filament only when every loaded spool agrees on one — on a mixed unit the badge shows the countdown alone rather than naming a spool the cycle isn't drying — and it no longer guesses a temperature at all. A unit loaded entirely with PLA does tell you what is being dried, but not at what temperature: that is picked freely when the cycle is started, so the spools' RFID-recommended value is never evidence of it, and a second AMS loaded only with PLA and drying at 45°C still read "PLA @ 55°C" whenever the cached target went missing. The badge now names a temperature only when Bambuddy sent it, and shows the filament and countdown without one otherwise. Covered by backend and frontend tests. - **A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758)** — Sending a job to an X2D with two AMS units mid-drying failed silently: the file uploaded, the printer accepted it and then simply stayed idle. Bambuddy waited out the start watchdog, re-uploaded the whole 3MF, waited again, and after three attempts gave up with advice to check the printer's screen and the SD card — while Bambu Studio, asked directly, said it could not start the job because of the drying. Bambuddy now watches the AMS drying telemetry it already receives across the dispatch window and, when a job never starts while a unit was drying, names the units in the failure message and records the correlation in the log from the first attempt rather than only after the retries are spent. This is deliberately a diagnosis and not a rule: the printers concerned support drying *continuing* through a print, so drying and printing are not in conflict as such, and the report also involved one AMS drying without its external power supply — which would make the start-of-print calibration a power problem rather than a drying one. Stopping the cycle automatically would therefore be acting on a guess, and could tear down drying the hardware was happy to continue. Until it is known which of the two is the real obstacle, Bambuddy tells you what it saw and leaves the call to you. The message for a stalled dispatch with no drying involved is unchanged. Wiki updated. Covered by backend tests. diff --git a/backend/app/services/ldap_service.py b/backend/app/services/ldap_service.py index 8f45fef84..710338c36 100644 --- a/backend/app/services/ldap_service.py +++ b/backend/app/services/ldap_service.py @@ -14,6 +14,7 @@ import logging from dataclasses import dataclass from ldap3 import ALL, SUBTREE, Connection, Server, Tls +from ldap3.core.exceptions import LDAPObjectClassError logger = logging.getLogger(__name__) @@ -155,32 +156,55 @@ def _extract_user_info( canonical_username = _pick_canonical_username(user_entry, fallback_username) - # Also search for POSIX groups (memberUid-based) using the service account - posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))" - service_conn.search( - search_base=config.search_base, - search_filter=posix_filter, - search_scope=SUBTREE, - attributes=["cn"], - ) - for entry in service_conn.entries: - 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)}))" + # Also search for POSIX groups, both the memberUid kind and the primary + # gidNumber kind. Both filters name the posixGroup object class, and ldap3 + # validates that name against the schema it fetched at connect time + # (get_info=ALL) before it builds the request — so on a directory that + # publishes a schema without posixGroup it raises client-side and nothing is + # ever sent. A directory with no posixGroup class has no posixGroup entries, + # which is exactly the answer the searches would have returned, so the + # correct response is to carry on with the memberOf groups collected above. + # + # Left uncaught, that exception escaped authenticate_ldap_user, and the login + # route reports any LDAP error as "Incorrect username or password" — so an + # lldap user, whose accounts carry posixAccount but whose directory defines + # no group classes beyond groupOfNames, could never log in and had nothing + # but a wrong-password message to go on (#2769). This predates the primary + # gidNumber lookup: the memberUid filter has named the class since #794. + try: + posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))" service_conn.search( search_base=config.search_base, - search_filter=primary_filter, + search_filter=posix_filter, search_scope=SUBTREE, attributes=["cn"], ) for entry in service_conn.entries: 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)) + except LDAPObjectClassError: + # Logged once per authentication, at info: it is the explanation for a + # user's POSIX groups being absent from their mapping, and it is not an + # error the operator can or should act on. + logger.info( + "Directory publishes no posixGroup object class; skipping POSIX group lookup " + "(memberOf groups are unaffected)" + ) + # 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() diff --git a/backend/tests/unit/services/test_ldap_service.py b/backend/tests/unit/services/test_ldap_service.py index 33ad167a8..f6b85ee8e 100644 --- a/backend/tests/unit/services/test_ldap_service.py +++ b/backend/tests/unit/services/test_ldap_service.py @@ -11,6 +11,7 @@ are not tested here — they require a live LDAP server. """ import pytest +from ldap3.core.exceptions import LDAPObjectClassError from backend.app.services.ldap_service import ( LDAPConfig, @@ -297,6 +298,11 @@ class _MockConnection: _search_fixture: dict[str, list] = {} _instances: list["_MockConnection"] = [] + # Filter substring that should raise LDAPObjectClassError instead of + # searching, standing in for ldap3's client-side schema validation — it + # rejects an object class the server's published schema doesn't define + # before the request is ever built (#2769). + _raise_object_class_error_on: str | None = None def __init__(self, *args, **kwargs): self.entries: list = [] @@ -320,6 +326,9 @@ class _MockConnection: # **kwargs absorbs ldap3 options like size_limit that the real client supports self.search_calls.append(search_filter or "") self.last_attrs = list(attributes) if attributes is not None else None + needle = _MockConnection._raise_object_class_error_on + if needle and needle in (search_filter or ""): + raise LDAPObjectClassError(f"invalid class in objectClass attribute: {needle}") for needle, entries in _MockConnection._search_fixture.items(): if needle in (search_filter or ""): self.entries = entries @@ -333,6 +342,7 @@ def mock_ldap(monkeypatch): """Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline.""" _MockConnection._search_fixture = {} _MockConnection._instances = [] + _MockConnection._raise_object_class_error_on = None monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection) monkeypatch.setattr("backend.app.services.ldap_service._create_server", lambda config: None) return _MockConnection @@ -433,6 +443,84 @@ class TestAuthenticateLdapUserGroups: assert gidnumber_searches == [] +class TestDirectoryWithoutPosixGroupClass: + """A directory whose published schema defines no posixGroup class (#2769). + + ldap3 fetches the schema at connect time (get_info=ALL) and validates object + class names in a filter against it before building the request, so both POSIX + group searches raise client-side and nothing reaches the server. lldap is the + case in the wild: it puts posixAccount on every account it creates, which + gives each user a gidNumber, but defines no group class beyond groupOfNames. + Left uncaught the exception escaped authenticate_ldap_user and the login route + reported it as "Incorrect username or password", so LDAP login was impossible. + """ + + def test_authenticates_and_keeps_memberof_groups(self, mock_ldap): + """The reporter's setup: the mapped membership comes from memberOf, which + is read off the user entry and never touches a posixGroup filter.""" + user_entry = _MockEntry( + "uid=peter,ou=people,dc=fablab,dc=test", + uid="peter", + gidNumber=1001, # lldap gives every account one + memberOf=["cn=AAUStudents,ou=groups,dc=fablab,dc=test"], + ) + mock_ldap._search_fixture = {"(uid=peter)": [user_entry]} + mock_ldap._raise_object_class_error_on = "objectClass=posixGroup" + + info = authenticate_ldap_user(_base_config(), "peter", "password") + + assert info is not None + assert info.groups == ["cn=AAUStudents,ou=groups,dc=fablab,dc=test"] + + def test_authenticates_with_no_groups_at_all(self, mock_ldap): + """No memberOf either. The user still gets in — auto-provisioning assigns + the configured default group, which is the whole point of that setting.""" + user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001) + mock_ldap._search_fixture = {"(uid=peter)": [user_entry]} + mock_ldap._raise_object_class_error_on = "objectClass=posixGroup" + + info = authenticate_ldap_user(_base_config(), "peter", "password") + + assert info is not None + assert info.username == "peter" + assert info.groups == [] + + def test_abandons_the_primary_gid_search_after_the_first_rejection(self, mock_ldap): + """Both filters name the same class, so once one is rejected the other + cannot succeed. Attempting it would only produce a second identical + exception to swallow.""" + user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001) + mock_ldap._search_fixture = {"(uid=peter)": [user_entry]} + mock_ldap._raise_object_class_error_on = "objectClass=posixGroup" + + authenticate_ldap_user(_base_config(), "peter", "password") + + service_conn = _MockConnection._instances[0] + posix_searches = [call for call in service_conn.search_calls if "posixGroup" in call] + assert len(posix_searches) == 1 + assert "memberUid=peter" in posix_searches[0] + + def test_a_directory_that_defines_the_class_is_untouched(self, mock_ldap): + """The guard must not cost a normal directory its POSIX groups — both + searches still run and both results still land.""" + user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002) + supplementary = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com") + primary = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com") + + mock_ldap._search_fixture = { + "(uid=mz)": [user_entry], + "memberUid=mz": [supplementary], + "gidNumber=10002": [primary], + } + + info = authenticate_ldap_user(_base_config(), "mz", "password") + + assert info.groups == [ + "cn=bambuddy-viewers,ou=groups,dc=test,dc=com", + "cn=bambuddy-operators,ou=groups,dc=test,dc=com", + ] + + # --------------------------------------------------------------------------- # Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298) # ---------------------------------------------------------------------------