Skip to content

feat(security): Tier 5a — LDAP / Active Directory authentication (opt-in ldap) [26.6.34]#42

Merged
ancongui merged 4 commits into
mainfrom
feat/spring-security-tier5a-ldap
Jun 19, 2026
Merged

feat(security): Tier 5a — LDAP / Active Directory authentication (opt-in ldap) [26.6.34]#42
ancongui merged 4 commits into
mainfrom
feat/spring-security-tier5a-ldap

Conversation

@ancongui

Copy link
Copy Markdown
Contributor

Tier 5a — LDAP / Active Directory authentication

First of the Tier 5 "big subsystems", delivered as an opt-in ldap feature. Fully additive: the default build does not compile the new module, so there is no behaviour change to existing code.

What's new

  • LdapAuthenticationProvider — bind authentication as an AuthenticationProvider (plugs into the Tier 1 ProviderManager): search the user DN under a base+filter ((uid={0}), username RFC 4515-escaped), bind as that DN with the password (the directory verifies it), then map group membership ((member={0})) to ROLE_<GROUP> authorities — Spring's BindAuthenticator + DefaultLdapAuthoritiesPopulator.
  • ActiveDirectoryLdapAuthenticationProvider — binds as the userPrincipalName (user@domain) and maps the user's memberOf groups to roles.
  • LdapOperations port (+ escape_filter_value, cn_from_dn, LdapEntry) with the production Ldap3Operations adapter over ldap3. The port makes the provider logic unit-testable without a live directory.

Spring-faithful safety behaviours

  • Empty password rejected before binding (anonymous-bind bypass guard).
  • Username/DN RFC 4515-escaped in every filter (LDAP-injection safe); unknown-user and wrong-password return the same error value.
  • Ambiguous user search rejected rather than binding an arbitrary first match (Spring's IncorrectResultSizeDataAccessException).
  • Directory error while populating authorities fails the login instead of silently authenticating with no roles.
  • Malformed directory entry caught and turned into a clean error rather than aborting the auth task.
  • A non-zero LDAP bind result code is always an error (never a silent success).

Verification

  • 12/12 LDAP unit tests pass (mock-LdapOperations); fmt + clippy -D warnings clean with --features ldap.
  • Live Ldap3Operations adapter exercised by an integration test gated on FIREFLY_TEST_LDAP_URL (skipped when unset).
  • Full-workspace cargo check clean at 26.6.34.
  • Adversarially reviewed before release (13-agent multi-dimension review): 7 findings confirmed real — the medium (ambiguous-bind) plus three lows (authorities error-swallowing, construct panic, over-claimed enumeration docs) are all fixed here; the rest were positive confirmations / a benign wire-cleanliness note.

Docs

  • Parity appendix (EN + ES) updated with the LDAP/AD row + section; CHANGELOG v26.6.34; MODULES.md; rebuilt book PDF + EPUB (EN + ES).

Next Tier 5 subsystems (separate releases): SAML2, then ACL / domain-object security.

Andres Contreras added 4 commits June 19, 2026 22:33
Begin Tier 5a (LDAP/AD) — Spring's ldapAuthentication(). Feature-gated `ldap`
module (optional ldap3 dep, mirroring the webauthn opt-in), the default build
untouched:

- LdapOperations port (search + bind) + LdapEntry — the seam that makes the
  provider logic unit-testable without a live directory.
- escape_filter_value: RFC 4515 §3 filter-value escaping, preventing LDAP
  injection via the username/DN.
- LdapAuthenticationProvider implements the Tier 1 AuthenticationProvider
  (plugs into ProviderManager) via bind authentication: search the user DN
  under a base+filter ({0} = escaped username), bind as that DN with the
  password (the directory verifies it), then populate ROLE_<GROUP> authorities
  from a group search ({0} = user DN). An empty password is rejected before
  binding (a simple bind with an empty password is an anonymous bind = bypass).
  Unknown user and wrong password both fail as "Bad credentials"
  (enumeration-safe).

5 unit tests against a scriptable mock LdapOperations (bind + group→role,
wrong-password/unknown-user reject, empty-password reject, filter-injection
escaped, escape_filter_value specials). fmt + clippy clean (--features ldap).
Complete the Tier 5a LDAP surface:

- ActiveDirectoryLdapAuthenticationProvider (Spring's
  ActiveDirectoryLdapAuthenticationProvider): binds as the userPrincipalName
  (username@domain — or a full UPN if supplied), then reads the user's memberOf
  group DNs and maps each leading CN to a ROLE_<CN> authority. Empty password
  rejected; bad credential fails uniformly.
- cn_from_dn(): extracts the leading CN RDN of a DN (CN=Admins,... → Admins).
- Ldap3Operations: the production LdapOperations adapter over ldap3
  (LdapConnAsync, search with optional manager bind, simple_bind whose
  .success() turns a non-zero result code into a clean Err). Each op uses a
  fresh connection so a failed user bind never disturbs searches.

4 new tests (AD bind+memberOf→roles, AD wrong-password reject, AD empty-password
reject, cn_from_dn) + an env-gated live-adapter smoke test (skipped without
FIREFLY_TEST_LDAP_URL). 9 ldap tests green; fmt + clippy clean (--features ldap).
Applies the four actionable findings from the multi-agent adversarial
review of the LDAP / Active Directory providers (7 confirmed, 3 were
positive confirmations, 1 benign wire-cleanliness note):

- Reject ambiguous user searches. Both providers took the first entry
  via `.into_iter().next()`, silently binding against / reading
  authorities from an arbitrary match when the filter matched more than
  one entry. A new single_entry() helper fails closed on >1 result,
  mirroring Spring's IncorrectResultSizeDataAccessException.

- Propagate directory errors during authorities population. authorities_for
  (and the AD memberOf search) used unwrap_or_default(), authenticating
  the user with an empty role set on a transient directory error (silent
  privilege loss). Errors now propagate and fail the login, matching
  Spring's DefaultLdapAuthoritiesPopulator.

- Guard SearchEntry::construct against panics. ldap3 0.11 has no fallible
  variant and panics on a malformed / non-schema-conformant entry; a
  compromised or MITM'd directory could send one. The construct call is
  now wrapped in catch_unwind so a bad entry becomes a clean Err rather
  than aborting the in-flight authentication task.

- Correct the over-claimed "enumeration-safe" docs. The unknown-user and
  wrong-password paths return the same error value but, as in Spring's
  BindAuthenticator, the unknown-user path skips the bind, leaving a
  residual timing channel. The comment now states this honestly.

Adds three tests: ambiguous_user_search_is_rejected,
group_search_error_fails_the_login_not_silent_role_loss, and
active_directory_rejects_ambiguous_member_of_search. 12/12 LDAP tests
pass; fmt + clippy clean with --features ldap.
Bumps the workspace 26.6.33 -> 26.6.34 and ships the Tier 5a docs:

- Parity appendix (EN + ES): LDAP / Active Directory row + dedicated
  section, with the Spring-faithful safety behaviours.
- CHANGELOG v26.6.34 entry.
- MODULES.md firefly-security LDAP note.
- Rebuilt book PDF + EPUB (EN + ES).

The docs reflect the hardening from the pre-release adversarial review:
ambiguous-search rejection (IncorrectResultSizeDataAccessException),
authorities-error propagation (no silent role loss), the malformed-entry
panic guard, and the corrected (honest) enumeration-timing note.
@ancongui ancongui merged commit 416312c into main Jun 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant