diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c157b..e518e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.34 — 2026-06-19 + +**Spring Security parity — Tier 5a: LDAP / Active Directory authentication.** +The first of the Tier 5 "big subsystems", delivered as an opt-in feature. All +additive (no behaviour change to existing code; the default build does not +compile the new module). Adversarially reviewed before release. + +### Added + +- **`ldap` feature** (opt-in, pulls in `ldap3`) — Spring's + `ldapAuthentication()`: + - **`LdapAuthenticationProvider`** — bind authentication as an + `AuthenticationProvider` (plugs into `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_` 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. +- Security defaults: an **empty password is rejected before binding** (a simple + bind with an empty password is an anonymous bind that most directories accept + — an authentication bypass); the username/DN are RFC 4515-escaped in search + filters (LDAP-injection safe); unknown-user and wrong-password fail with the + same error value; a non-zero LDAP bind result code is an error (never a silent + success). +- Hardened from the pre-release adversarial review: an **ambiguous user search** + (more than one matching entry) is rejected rather than binding against an + arbitrary first match (Spring's `IncorrectResultSizeDataAccessException`); a + **directory error while populating authorities** propagates and fails the + login instead of silently authenticating with no roles (Spring's + `DefaultLdapAuthoritiesPopulator` semantics); and a **malformed directory + entry** is caught and turned into a clean error rather than aborting the + authentication task. + +### Notes + +- The live `Ldap3Operations` adapter is exercised by an integration test gated + on `FIREFLY_TEST_LDAP_URL` (skipped when unset); the provider logic is fully + covered by mock-`LdapOperations` unit tests. + ## v26.6.33 — 2026-06-19 **Spring Security parity — Tier 4: the OAuth2 ecosystem.** The wider OAuth2 diff --git a/Cargo.lock b/Cargo.lock index 89f9784..c1dc007 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,22 @@ dependencies = [ "password-hash", ] +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive 0.4.0", + "asn1-rs-impl 0.1.0", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -206,7 +222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ "asn1-rs-derive 0.5.1", - "asn1-rs-impl", + "asn1-rs-impl 0.2.0", "displaydoc", "nom 7.1.3", "num-traits", @@ -222,7 +238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive 0.6.0", - "asn1-rs-impl", + "asn1-rs-impl 0.2.0", "displaydoc", "nom 7.1.3", "num-traits", @@ -231,6 +247,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "asn1-rs-derive" version = "0.5.1" @@ -239,8 +267,8 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.117", + "synstructure 0.13.2", ] [[package]] @@ -251,8 +279,19 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.117", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -263,7 +302,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -406,7 +445,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -423,7 +462,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -542,11 +581,11 @@ dependencies = [ "hyper", "hyper-util", "pin-project-lite", - "rustls", - "rustls-pemfile", + "rustls 0.23.40", + "rustls-pemfile 2.2.0", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -850,7 +889,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1145,7 +1184,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -1158,7 +1197,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -1169,7 +1208,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1180,7 +1219,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1202,6 +1241,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" +dependencies = [ + "asn1-rs 0.5.2", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1238,7 +1291,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1258,7 +1311,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1269,7 +1322,7 @@ checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1291,7 +1344,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -1336,7 +1389,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1495,7 +1548,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "firefly-actuator", @@ -1539,7 +1592,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1556,7 +1609,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1585,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "inventory", @@ -1595,7 +1648,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1614,7 +1667,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-observability", @@ -1626,7 +1679,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -1638,7 +1691,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-cache", @@ -1650,7 +1703,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1676,7 +1729,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "chrono", @@ -1697,7 +1750,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-stream", "axum", @@ -1719,7 +1772,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "regex", @@ -1734,7 +1787,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1750,7 +1803,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.33" +version = "26.6.34" dependencies = [ "futures", "inventory", @@ -1761,7 +1814,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -1784,7 +1837,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-stream", "async-trait", @@ -1801,7 +1854,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-stream", "async-trait", @@ -1819,7 +1872,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-stream", "async-trait", @@ -1841,7 +1894,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -1857,7 +1910,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1874,7 +1927,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1891,7 +1944,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1908,7 +1961,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1928,7 +1981,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -1948,7 +2001,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "base64 0.22.1", @@ -1970,7 +2023,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-eda", @@ -1985,7 +2038,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -2003,7 +2056,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-eda", @@ -2018,7 +2071,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-eda", @@ -2034,7 +2087,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "base64 0.22.1", @@ -2052,7 +2105,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "http", @@ -2067,7 +2120,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2083,7 +2136,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2104,7 +2157,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2121,7 +2174,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2143,7 +2196,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2160,7 +2213,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2195,7 +2248,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.33" +version = "26.6.34" dependencies = [ "chrono", "serde", @@ -2207,7 +2260,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.33" +version = "26.6.34" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2216,7 +2269,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2232,7 +2285,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "syn", + "syn 2.0.117", "tokio", "tower 0.5.3", "trybuild", @@ -2240,7 +2293,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.33" +version = "26.6.34" dependencies = [ "chrono", "hex", @@ -2253,7 +2306,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -2268,7 +2321,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2284,7 +2337,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2301,7 +2354,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2318,7 +2371,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "base64 0.22.1", @@ -2335,7 +2388,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2351,7 +2404,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -2375,7 +2428,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "chrono", @@ -2391,7 +2444,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2415,7 +2468,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -2425,7 +2478,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-stream", "firefly-kernel", @@ -2437,7 +2490,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-config", @@ -2449,7 +2502,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2469,7 +2522,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2486,7 +2539,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -2501,7 +2554,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.33" +version = "26.6.34" dependencies = [ "chrono", "firefly", @@ -2512,7 +2565,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.33" +version = "26.6.34" dependencies = [ "chrono", "firefly", @@ -2525,7 +2578,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.33" +version = "26.6.34" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2537,7 +2590,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "firefly", @@ -2555,7 +2608,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "firefly", @@ -2568,7 +2621,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2591,7 +2644,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-stream", "async-trait", @@ -2631,7 +2684,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "chrono", @@ -2652,7 +2705,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.33" +version = "26.6.34" dependencies = [ "argon2", "async-trait", @@ -2665,6 +2718,7 @@ dependencies = [ "http", "http-body-util", "jsonwebtoken", + "ldap3", "rand 0.8.6", "redis", "reqwest", @@ -2683,7 +2737,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2708,7 +2762,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-session", @@ -2721,7 +2775,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-session", @@ -2733,7 +2787,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-session", @@ -2745,7 +2799,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "futures", @@ -2755,7 +2809,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "firefly", @@ -2763,7 +2817,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "firefly", @@ -2775,7 +2829,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "bytes", @@ -2791,7 +2845,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-cqrs", @@ -2805,7 +2859,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2831,7 +2885,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "firefly-cqrs", @@ -2844,7 +2898,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2856,7 +2910,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2877,7 +2931,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "firefly-kernel", @@ -2892,7 +2946,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.33" +version = "26.6.34" dependencies = [ "axum", "base64 0.22.1", @@ -2911,7 +2965,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "inventory", @@ -2922,7 +2976,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.33" +version = "26.6.34" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -2938,7 +2992,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.33" +version = "26.6.34" dependencies = [ "chrono", "firefly-kernel", @@ -2949,7 +3003,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -2974,7 +3028,7 @@ dependencies = [ "quick-xml", "rand 0.8.6", "regex", - "rustls", + "rustls 0.23.40", "serde", "serde_json", "sha2 0.10.9", @@ -2988,7 +3042,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -3016,7 +3070,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.33" +version = "26.6.34" dependencies = [ "async-trait", "axum", @@ -3048,7 +3102,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -3204,7 +3258,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3457,7 +3511,7 @@ dependencies = [ "once_cell", "prefix-trie", "rand 0.10.1", - "ring", + "ring 0.17.14", "thiserror 2.0.18", "tinyvec", "tracing", @@ -3628,9 +3682,9 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.40", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -3977,7 +4031,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -3996,7 +4050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4029,7 +4083,7 @@ dependencies = [ "base64 0.22.1", "js-sys", "pem", - "ring", + "ring 0.17.14", "serde", "serde_json", "simple_asn1", @@ -4063,7 +4117,44 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", +] + +[[package]] +name = "lber" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df7f9fd9f64cf8f59e1a4a0753fe7d575a5b38d3d7ac5758dcee9357d83ef0a" +dependencies = [ + "bytes", + "nom 7.1.3", +] + +[[package]] +name = "ldap3" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166199a8207874a275144c8a94ff6eed5fcbf5c52303e4d9b4d53a0c7ac76554" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lazy_static", + "lber", + "log", + "nom 7.1.3", + "percent-encoding", + "ring 0.16.20", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tokio-stream", + "tokio-util", + "url", + "x509-parser 0.15.1", ] [[package]] @@ -4092,10 +4183,10 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls", + "rustls 0.23.40", "socket2 0.6.4", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "url", "webpki-roots 1.0.7", ] @@ -4189,7 +4280,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4203,7 +4294,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4214,7 +4305,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4225,7 +4316,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4370,7 +4461,7 @@ dependencies = [ "percent-encoding", "rand 0.9.4", "rustc_version_runtime", - "rustls", + "rustls 0.23.40", "serde", "serde_bytes", "serde_with", @@ -4382,7 +4473,7 @@ dependencies = [ "take_mut", "thiserror 2.0.18", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "typed-builder", "uuid", @@ -4398,7 +4489,7 @@ dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4414,7 +4505,7 @@ dependencies = [ "httparse", "memchr", "mime", - "spin", + "spin 0.9.8", "version_check", ] @@ -4518,7 +4609,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4570,7 +4661,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4591,6 +4682,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" +dependencies = [ + "asn1-rs 0.5.2", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -4653,7 +4753,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4837,7 +4937,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5052,7 +5152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -5394,6 +5494,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.14" @@ -5404,7 +5519,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -5497,6 +5612,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.40" @@ -5506,9 +5633,9 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", + "ring 0.17.14", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -5520,10 +5647,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" dependencies = [ "log", - "rustls", + "rustls 0.23.40", "rustls-native-certs 0.7.3", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework 2.11.1", ] [[package]] @@ -5533,7 +5672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ "openssl-probe 0.1.6", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", "security-framework 2.11.1", @@ -5551,6 +5690,15 @@ dependencies = [ "security-framework 3.7.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -5569,6 +5717,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -5576,9 +5734,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", - "ring", + "ring 0.17.14", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5637,6 +5795,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5726,7 +5894,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5794,7 +5962,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5963,6 +6131,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.8" @@ -6019,7 +6193,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.40", "serde", "serde_json", "sha2 0.10.9", @@ -6043,7 +6217,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -6066,7 +6240,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -6209,6 +6383,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -6229,6 +6414,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -6237,7 +6434,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6308,7 +6505,7 @@ dependencies = [ "cfg-if", "p12-keystore", "rustls-connector", - "rustls-pemfile", + "rustls-pemfile 2.2.0", ] [[package]] @@ -6359,7 +6556,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6370,7 +6567,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6471,7 +6668,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6510,13 +6707,23 @@ dependencies = [ "whoami 2.1.2", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -6632,10 +6839,10 @@ dependencies = [ "pin-project", "prost", "rustls-native-certs 0.8.4", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "socket2 0.5.10", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-stream", "tower 0.4.13", "tower-layer", @@ -6730,7 +6937,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6841,7 +7048,7 @@ checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6905,6 +7112,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -7087,7 +7300,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -7379,7 +7592,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7390,7 +7603,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7401,7 +7614,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7412,7 +7625,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7652,7 +7865,7 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -7668,7 +7881,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -7736,6 +7949,23 @@ dependencies = [ "spki", ] +[[package]] +name = "x509-parser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7069fba5b66b9193bd2c5d3d4ff12b839118f6bcbef5328efafafb5395cf63da" +dependencies = [ + "asn1-rs 0.5.2", + "data-encoding", + "der-parser 8.2.0", + "lazy_static", + "nom 7.1.3", + "oid-registry 0.6.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -7789,8 +8019,8 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.117", + "synstructure 0.13.2", ] [[package]] @@ -7810,7 +8040,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7830,8 +8060,8 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.117", + "synstructure 0.13.2", ] [[package]] @@ -7870,7 +8100,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 079b268..d0ae410 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.33" +version = "26.6.34" edition = "2021" license = "Apache-2.0" repository = "https://github.com/fireflyframework/fireflyframework-rust" @@ -99,80 +99,80 @@ rust-version = "1.88" [workspace.dependencies] # ---- internal crates ---- -firefly-reactive = { path = "crates/reactive", version = "26.6.33" } -firefly-kernel = { path = "crates/kernel", version = "26.6.33" } -firefly-utils = { path = "crates/utils", version = "26.6.33" } -firefly-validators = { path = "crates/validators", version = "26.6.33" } -firefly-web = { path = "crates/web", version = "26.6.33" } -firefly-config = { path = "crates/config", version = "26.6.33" } -firefly-i18n = { path = "crates/i18n", version = "26.6.33" } -firefly-cache = { path = "crates/cache", version = "26.6.33" } -firefly-observability = { path = "crates/observability", version = "26.6.33" } -firefly-data = { path = "crates/data", version = "26.6.33" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.33" } -firefly-eda = { path = "crates/eda", version = "26.6.33" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.33" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.33" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.33" } -firefly-plugins = { path = "crates/plugins", version = "26.6.33" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.33" } -firefly-actuator = { path = "crates/actuator", version = "26.6.33" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.33" } -firefly-resilience = { path = "crates/resilience", version = "26.6.33" } -firefly-security = { path = "crates/security", version = "26.6.33" } -firefly-migrations = { path = "crates/migrations", version = "26.6.33" } -firefly-openapi = { path = "crates/openapi", version = "26.6.33" } -firefly-sse = { path = "crates/sse", version = "26.6.33" } -firefly-transactional = { path = "crates/transactional", version = "26.6.33" } -firefly-testkit = { path = "crates/testkit", version = "26.6.33" } -firefly-client = { path = "crates/client", version = "26.6.33" } -firefly-config-server = { path = "crates/config-server", version = "26.6.33" } -firefly-idp = { path = "crates/idp", version = "26.6.33" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.33" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.33" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.33" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.33" } -firefly-ecm = { path = "crates/ecm", version = "26.6.33" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.33" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.33" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.33" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.33" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.33" } -firefly-notifications = { path = "crates/notifications", version = "26.6.33" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.33" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.33" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.33" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.33" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.33" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.33" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.33" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.33" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.33" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.33" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.33" } -firefly-admin = { path = "crates/admin", version = "26.6.33" } -firefly-aop = { path = "crates/aop", version = "26.6.33" } -firefly-cli = { path = "crates/cli", version = "26.6.33" } -firefly-container = { path = "crates/container", version = "26.6.33" } -firefly-session = { path = "crates/session", version = "26.6.33" } -firefly-shell = { path = "crates/shell", version = "26.6.33" } -firefly-websocket = { path = "crates/websocket", version = "26.6.33" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.33" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.33" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.33" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.33" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.33" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.33" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.33" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.33" } -firefly = { path = "crates/firefly", version = "26.6.33" } -firefly-macros = { path = "crates/macros", version = "26.6.33" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.33" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.33" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.33" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.33" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.33" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.33" } +firefly-reactive = { path = "crates/reactive", version = "26.6.34" } +firefly-kernel = { path = "crates/kernel", version = "26.6.34" } +firefly-utils = { path = "crates/utils", version = "26.6.34" } +firefly-validators = { path = "crates/validators", version = "26.6.34" } +firefly-web = { path = "crates/web", version = "26.6.34" } +firefly-config = { path = "crates/config", version = "26.6.34" } +firefly-i18n = { path = "crates/i18n", version = "26.6.34" } +firefly-cache = { path = "crates/cache", version = "26.6.34" } +firefly-observability = { path = "crates/observability", version = "26.6.34" } +firefly-data = { path = "crates/data", version = "26.6.34" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.34" } +firefly-eda = { path = "crates/eda", version = "26.6.34" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.34" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.34" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.34" } +firefly-plugins = { path = "crates/plugins", version = "26.6.34" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.34" } +firefly-actuator = { path = "crates/actuator", version = "26.6.34" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.34" } +firefly-resilience = { path = "crates/resilience", version = "26.6.34" } +firefly-security = { path = "crates/security", version = "26.6.34" } +firefly-migrations = { path = "crates/migrations", version = "26.6.34" } +firefly-openapi = { path = "crates/openapi", version = "26.6.34" } +firefly-sse = { path = "crates/sse", version = "26.6.34" } +firefly-transactional = { path = "crates/transactional", version = "26.6.34" } +firefly-testkit = { path = "crates/testkit", version = "26.6.34" } +firefly-client = { path = "crates/client", version = "26.6.34" } +firefly-config-server = { path = "crates/config-server", version = "26.6.34" } +firefly-idp = { path = "crates/idp", version = "26.6.34" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.34" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.34" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.34" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.34" } +firefly-ecm = { path = "crates/ecm", version = "26.6.34" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.34" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.34" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.34" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.34" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.34" } +firefly-notifications = { path = "crates/notifications", version = "26.6.34" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.34" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.34" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.34" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.34" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.34" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.34" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.34" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.34" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.34" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.34" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.34" } +firefly-admin = { path = "crates/admin", version = "26.6.34" } +firefly-aop = { path = "crates/aop", version = "26.6.34" } +firefly-cli = { path = "crates/cli", version = "26.6.34" } +firefly-container = { path = "crates/container", version = "26.6.34" } +firefly-session = { path = "crates/session", version = "26.6.34" } +firefly-shell = { path = "crates/shell", version = "26.6.34" } +firefly-websocket = { path = "crates/websocket", version = "26.6.34" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.34" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.34" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.34" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.34" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.34" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.34" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.34" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.34" } +firefly = { path = "crates/firefly", version = "26.6.34" } +firefly-macros = { path = "crates/macros", version = "26.6.34" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.34" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.34" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.34" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.34" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.34" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.34" } # ---- async runtime + web ---- tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal", "io-util", "net", "fs"] } diff --git a/MODULES.md b/MODULES.md index 7c7dcdb..3ace1e4 100644 --- a/MODULES.md +++ b/MODULES.md @@ -48,7 +48,7 @@ declarative macros instead of hand-rolled builder wiring. | [`firefly-actuator`](crates/actuator/README.md) | `/actuator/{health,info,metrics,env,tasks,version}` + liveness/readiness probes, runtime loggers, `httpexchanges`, `threaddump`, labeled Micrometer metrics, `refresh`, `management.endpoints.web` exposure | | [`firefly-scheduling`](crates/scheduling/README.md) | Cron + FixedRate + FixedDelay `Scheduler` | | [`firefly-resilience`](crates/resilience/README.md) | `CircuitBreaker`, `RateLimiter`, `Bulkhead`, `Timeout`, composable `Chain` | -| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), the authentication spine (`AuthenticationManager`/`ProviderManager`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `DelegatingPasswordEncoder`), web mechanisms (`httpBasic`, `formLogin`, `TokenBasedRememberMeServices`, `RequestCache`, `SessionCreationPolicy`, `SecurityFilterChains`), method-security depth (`PermissionEvaluator` + `has_permission`, consumed by the expression `#[pre_authorize]`/`#[post_authorize]`/`#[pre_filter]`/`#[post_filter]` macros), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + RP-initiated logout, outbound `AuthorizedClientManager`, RFC 7662 opaque-token introspection, authorization server + RFC 8414 `AuthorizationServerRouter`), **one-time-token + WebAuthn/passkey** passwordless login, `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt + Argon2id) — Spring Security 6-faithful (see the book's *Spring Security Parity* appendix) | +| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), the authentication spine (`AuthenticationManager`/`ProviderManager`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `DelegatingPasswordEncoder`), web mechanisms (`httpBasic`, `formLogin`, `TokenBasedRememberMeServices`, `RequestCache`, `SessionCreationPolicy`, `SecurityFilterChains`), method-security depth (`PermissionEvaluator` + `has_permission`, consumed by the expression `#[pre_authorize]`/`#[post_authorize]`/`#[pre_filter]`/`#[post_filter]` macros), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + RP-initiated logout, outbound `AuthorizedClientManager`, RFC 7662 opaque-token introspection, authorization server + RFC 8414 `AuthorizationServerRouter`), **one-time-token + WebAuthn/passkey** passwordless login, feature-gated **LDAP/Active-Directory** auth (`LdapAuthenticationProvider`/`ActiveDirectoryLdapAuthenticationProvider`), `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt + Argon2id) — Spring Security 6-faithful (see the book's *Spring Security Parity* appendix) | | [`firefly-migrations`](crates/migrations/README.md) | Versioned SQL migrations (`V001__init.sql`) over a `Database` port | | [`firefly-openapi`](crates/openapi/README.md) | OpenAPI 3.1 generator + Swagger-UI shim | | [`firefly-sse`](crates/sse/README.md) | Server-Sent Events writer w/ heartbeat + Last-Event-Id | diff --git a/crates/security/Cargo.toml b/crates/security/Cargo.toml index b08977d..c754c01 100644 --- a/crates/security/Cargo.toml +++ b/crates/security/Cargo.toml @@ -14,6 +14,11 @@ description = "Firefly Framework for Rust — Authentication context, bearer mid # The default build does not compile the `webauthn` module at all. webauthn = ["dep:webauthn-rs", "dep:uuid"] +# LDAP / Active Directory authentication (Spring Security `ldapAuthentication()` +# parity). Opt-in because it pulls in the `ldap3` client. The default build does +# not compile the `ldap` module at all. +ldap = ["dep:ldap3"] + [dependencies] axum = { workspace = true } tower = { workspace = true } @@ -47,6 +52,13 @@ webauthn-rs = { version = "0.5.5", optional = true } # are random UUIDs; `serde` lets the in-memory handle map round-trip if needed. uuid = { version = "1", features = ["v4", "serde"], optional = true } +# --- `ldap` feature -------------------------------------------------------- +# Async LDAP/AD client (search + bind) for `LdapAuthenticationProvider`. +# Optional so the default build is untouched; enabled by the `ldap` feature. +ldap3 = { version = "0.11", optional = true, default-features = false, features = [ + "tls-rustls", +] } + [dev-dependencies] tokio = { workspace = true } http-body-util = { workspace = true } diff --git a/crates/security/src/ldap.rs b/crates/security/src/ldap.rs new file mode 100644 index 0000000..fbcf945 --- /dev/null +++ b/crates/security/src/ldap.rs @@ -0,0 +1,818 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! LDAP / Active Directory authentication — the Rust analog of Spring +//! Security's `ldapAuthentication()` (`LdapAuthenticationProvider` / +//! `BindAuthenticator` / `DefaultLdapAuthoritiesPopulator`). +//! +//! [`LdapAuthenticationProvider`] is an +//! [`AuthenticationProvider`](crate::AuthenticationProvider) — it plugs into the +//! Tier 1 [`ProviderManager`](crate::ProviderManager) spine — that authenticates +//! username/password credentials by **bind authentication**: +//! +//! 1. Search the directory for the user's DN (under a base, by a filter like +//! `(uid={0})`, with the username RFC 4515-escaped). +//! 2. **Bind** to the directory as that DN with the supplied password — the +//! directory itself verifies the credential. +//! 3. Populate authorities from group membership (a group search like +//! `(member={0})`, mapping each group's name to `ROLE_`). +//! +//! The LDAP wire operations are abstracted behind the [`LdapOperations`] port, +//! so the provider logic is unit-tested without a live directory; the real +//! [`ldap3`]-backed adapter ([`Ldap3Operations`]) is the production +//! implementation. +//! +//! An **empty password is rejected** before binding: most directories treat a +//! simple bind with an empty password as an *anonymous* bind that succeeds, +//! which would be an authentication bypass. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use ldap3::{LdapConnAsync, Scope, SearchEntry}; + +use crate::authentication::{Authentication, SecurityError, ROLE_PREFIX}; +use crate::authentication_manager::{AuthenticationProvider, AuthenticationRequest}; + +/// A directory entry returned by [`LdapOperations::search`]. +#[derive(Debug, Clone, Default)] +pub struct LdapEntry { + /// The entry's distinguished name. + pub dn: String, + /// Requested attributes, each possibly multi-valued. + pub attrs: HashMap>, +} + +impl LdapEntry { + /// The first value of attribute `name`, if present. + #[must_use] + pub fn first(&self, name: &str) -> Option<&str> { + self.attrs + .get(name) + .and_then(|v| v.first()) + .map(String::as_str) + } +} + +/// The LDAP operations the authentication providers need — the seam that lets +/// the provider logic be unit-tested without a live directory. +#[async_trait] +pub trait LdapOperations: Send + Sync { + /// Searches under `base` with `filter`, returning each match's DN and the + /// requested `attrs`. + async fn search( + &self, + base: &str, + filter: &str, + attrs: &[&str], + ) -> Result, SecurityError>; + + /// Attempts a simple bind as `dn` with `password`; `Ok(())` on success, + /// `Err` on invalid credentials or a bind error. + async fn bind(&self, dn: &str, password: &str) -> Result<(), SecurityError>; +} + +/// Escapes a value for safe inclusion in an LDAP search filter (RFC 4515 §3), +/// preventing LDAP-filter injection through the username/DN. +#[must_use] +pub fn escape_filter_value(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\5c"), + '*' => out.push_str("\\2a"), + '(' => out.push_str("\\28"), + ')' => out.push_str("\\29"), + '\0' => out.push_str("\\00"), + other => out.push(other), + } + } + out +} + +/// Collapses a search result to its single entry, mirroring Spring's +/// `IncorrectResultSizeDataAccessException`: `Ok(None)` for no match, +/// `Ok(Some(entry))` for exactly one, and `Err` when the search is **ambiguous** +/// (more than one entry). +/// +/// An ambiguous user search must never silently bind against — or read +/// authorities from — an arbitrary first match (RFC 4511 leaves result ordering +/// unspecified), so the providers fail closed instead. +fn single_entry(entries: Vec) -> Result, SecurityError> { + let mut it = entries.into_iter(); + let first = it.next(); + if it.next().is_some() { + return Err(SecurityError::verification( + "ambiguous directory search: more than one entry matched", + )); + } + Ok(first) +} + +/// Bind-authentication provider over a directory — Spring's +/// `LdapAuthenticationProvider` with a `BindAuthenticator` + +/// `DefaultLdapAuthoritiesPopulator`. +pub struct LdapAuthenticationProvider { + ops: Arc, + user_search_base: String, + user_search_filter: String, + group_search_base: Option, + group_search_filter: Option, + group_role_attribute: String, + role_prefix: String, +} + +impl LdapAuthenticationProvider { + /// Builds a provider that finds users under `user_search_base` with + /// `user_search_filter` (where `{0}` is replaced by the escaped username), + /// e.g. base `"ou=people,dc=example,dc=com"`, filter `"(uid={0})"`. Group + /// authorities are off until [`with_group_search`](Self::with_group_search). + #[must_use] + pub fn new( + ops: Arc, + user_search_base: impl Into, + user_search_filter: impl Into, + ) -> Self { + Self { + ops, + user_search_base: user_search_base.into(), + user_search_filter: user_search_filter.into(), + group_search_base: None, + group_search_filter: None, + group_role_attribute: "cn".to_string(), + role_prefix: ROLE_PREFIX.to_string(), + } + } + + /// Enables group-membership authorities: searches `group_search_base` with + /// `group_search_filter` (`{0}` = the escaped user DN, `{1}` = the escaped + /// username), reading [`group_role_attribute`](Self::group_role_attribute) + /// (default `"cn"`) from each group. E.g. base `"ou=groups,dc=example,dc=com"`, + /// filter `"(member={0})"`. + #[must_use] + pub fn with_group_search( + mut self, + group_search_base: impl Into, + group_search_filter: impl Into, + ) -> Self { + self.group_search_base = Some(group_search_base.into()); + self.group_search_filter = Some(group_search_filter.into()); + self + } + + /// Overrides the group attribute mapped to a role (default `"cn"`). + #[must_use] + pub fn group_role_attribute(mut self, attribute: impl Into) -> Self { + self.group_role_attribute = attribute.into(); + self + } + + /// Overrides the role prefix prepended to each group name (default `ROLE_`). + #[must_use] + pub fn role_prefix(mut self, prefix: impl Into) -> Self { + self.role_prefix = prefix.into(); + self + } + + /// Collects `ROLE_` authorities for `user_dn` / `username` from the + /// configured group search (empty when group search is disabled). + /// + /// A directory error is **propagated**, not swallowed: Spring's + /// `DefaultLdapAuthoritiesPopulator` surfaces the search exception rather + /// than silently authenticating with zero roles, which would be a hard-to- + /// diagnose privilege loss on a transient directory hiccup. + async fn authorities_for( + &self, + user_dn: &str, + username: &str, + ) -> Result, SecurityError> { + let (Some(base), Some(filter)) = (&self.group_search_base, &self.group_search_filter) + else { + return Ok(Vec::new()); + }; + let filter = filter + .replace("{0}", &escape_filter_value(user_dn)) + .replace("{1}", &escape_filter_value(username)); + let groups = self + .ops + .search(base, &filter, &[self.group_role_attribute.as_str()]) + .await?; + Ok(groups + .iter() + .filter_map(|g| g.first(&self.group_role_attribute)) + .map(|name| format!("{}{}", self.role_prefix, name.to_uppercase())) + .collect()) + } +} + +#[async_trait] +impl AuthenticationProvider for LdapAuthenticationProvider { + fn supports(&self, request: &AuthenticationRequest) -> bool { + matches!(request, AuthenticationRequest::UsernamePassword { .. }) + } + + async fn authenticate( + &self, + request: &AuthenticationRequest, + ) -> Result { + let AuthenticationRequest::UsernamePassword { username, password } = request else { + return Err(SecurityError::verification("unsupported credential kind")); + }; + // Reject an empty password: a simple bind with one is an *anonymous* + // bind that most directories accept — an authentication bypass. + if password.is_empty() { + return Err(SecurityError::verification("Bad credentials")); + } + + // 1. Resolve the user DN. An unknown user fails with the same error + // *value* as a wrong password (no text-based enumeration). Note: as + // in Spring's `BindAuthenticator`, the unknown-user path skips the + // bind, so a residual *timing* channel remains — search-then-bind + // authentication cannot bind a DN it never found. + let filter = self + .user_search_filter + .replace("{0}", &escape_filter_value(username)); + let user_dn = single_entry( + self.ops + .search(&self.user_search_base, &filter, &[]) + .await?, + )? + .map(|e| e.dn) + .ok_or_else(|| SecurityError::verification("Bad credentials"))?; + + // 2. Bind as the user — the directory verifies the password. + self.ops + .bind(&user_dn, password) + .await + .map_err(|_| SecurityError::verification("Bad credentials"))?; + + // 3. Group authorities (a directory error here fails the login rather + // than silently dropping the user's roles). + let roles = self.authorities_for(&user_dn, username).await?; + + Ok(Authentication { + principal: user_dn, + username: username.clone(), + roles, + ..Default::default() + }) + } +} + +/// The leading `CN` (relative-DN) value of a distinguished name, e.g. +/// `"CN=Admins,OU=Groups,DC=ex,DC=com"` → `"Admins"`. Used to turn an Active +/// Directory `memberOf` group DN into a role name. +#[must_use] +pub fn cn_from_dn(dn: &str) -> Option<&str> { + let rdn = dn.split(',').next()?.trim(); + let (key, value) = rdn.split_once('=')?; + key.trim().eq_ignore_ascii_case("cn").then(|| value.trim()) +} + +/// Active Directory authentication — the Rust analog of Spring's +/// `ActiveDirectoryLdapAuthenticationProvider`. +/// +/// AD authenticates by binding as the user's `userPrincipalName` +/// (`username@domain`); the directory verifies the password. The provider then +/// reads the user's `memberOf` group DNs and maps each leading `CN` to a +/// `ROLE_` authority. As with [`LdapAuthenticationProvider`], an empty +/// password is rejected (anonymous-bind bypass) and a bad credential fails +/// uniformly. +pub struct ActiveDirectoryLdapAuthenticationProvider { + ops: Arc, + domain: String, + root_dn: String, + role_prefix: String, +} + +impl ActiveDirectoryLdapAuthenticationProvider { + /// Builds the provider for AD `domain` (e.g. `"example.com"`), searching + /// under `root_dn` (e.g. `"dc=example,dc=com"`) for the authenticated user's + /// `memberOf` groups. + #[must_use] + pub fn new( + ops: Arc, + domain: impl Into, + root_dn: impl Into, + ) -> Self { + Self { + ops, + domain: domain.into(), + root_dn: root_dn.into(), + role_prefix: ROLE_PREFIX.to_string(), + } + } + + /// Overrides the role prefix prepended to each group name (default `ROLE_`). + #[must_use] + pub fn role_prefix(mut self, prefix: impl Into) -> Self { + self.role_prefix = prefix.into(); + self + } +} + +#[async_trait] +impl AuthenticationProvider for ActiveDirectoryLdapAuthenticationProvider { + fn supports(&self, request: &AuthenticationRequest) -> bool { + matches!(request, AuthenticationRequest::UsernamePassword { .. }) + } + + async fn authenticate( + &self, + request: &AuthenticationRequest, + ) -> Result { + let AuthenticationRequest::UsernamePassword { username, password } = request else { + return Err(SecurityError::verification("unsupported credential kind")); + }; + if password.is_empty() { + return Err(SecurityError::verification("Bad credentials")); + } + + // The bind principal is the userPrincipalName (username@domain), unless + // the caller already supplied a full UPN. + let upn = if username.contains('@') { + username.clone() + } else { + format!("{username}@{}", self.domain) + }; + + // Bind as the user — AD verifies the password. + self.ops + .bind(&upn, password) + .await + .map_err(|_| SecurityError::verification("Bad credentials"))?; + + // Read the user's DN + memberOf groups. A directory error propagates + // (no silent role loss) and an ambiguous result is rejected rather than + // reading authorities from an arbitrary first entry. + let filter = format!( + "(&(objectClass=user)(userPrincipalName={}))", + escape_filter_value(&upn) + ); + let entry = single_entry( + self.ops + .search(&self.root_dn, &filter, &["memberOf"]) + .await?, + )?; + + let (principal, roles) = match entry { + Some(entry) => { + let roles = entry + .attrs + .get("memberOf") + .map(|dns| { + dns.iter() + .filter_map(|dn| cn_from_dn(dn)) + .map(|cn| format!("{}{}", self.role_prefix, cn.to_uppercase())) + .collect() + }) + .unwrap_or_default(); + ( + if entry.dn.is_empty() { + upn.clone() + } else { + entry.dn + }, + roles, + ) + } + None => (upn.clone(), Vec::new()), + }; + + Ok(Authentication { + principal, + username: username.clone(), + roles, + ..Default::default() + }) + } +} + +/// The production [`LdapOperations`] adapter, backed by [`ldap3`]. +/// +/// Each operation opens a fresh async connection to the directory `url`. +/// Searches bind first with the configured manager DN/password (set via +/// [`with_manager`](Self::with_manager)) when present, else search anonymously; +/// [`bind`](LdapOperations::bind) opens its own connection to test the user's +/// credentials, so a failed user bind never disturbs the search binding. +pub struct Ldap3Operations { + url: String, + manager_dn: Option, + manager_password: Option, +} + +impl Ldap3Operations { + /// Builds the adapter for the directory at `url` (e.g. + /// `"ldaps://ad.example.com:636"`), searching anonymously until + /// [`with_manager`](Self::with_manager) sets a search binding. + #[must_use] + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + manager_dn: None, + manager_password: None, + } + } + + /// Sets the manager DN/password used to bind before searches. + #[must_use] + pub fn with_manager(mut self, dn: impl Into, password: impl Into) -> Self { + self.manager_dn = Some(dn.into()); + self.manager_password = Some(password.into()); + self + } +} + +/// Maps an `ldap3` error to a [`SecurityError`]. +fn ldap_err(e: impl std::fmt::Display) -> SecurityError { + SecurityError::verification(format!("ldap: {e}")) +} + +#[async_trait] +impl LdapOperations for Ldap3Operations { + async fn search( + &self, + base: &str, + filter: &str, + attrs: &[&str], + ) -> Result, SecurityError> { + let (conn, mut ldap) = LdapConnAsync::new(&self.url).await.map_err(ldap_err)?; + ldap3::drive!(conn); + if let (Some(dn), Some(pw)) = (&self.manager_dn, &self.manager_password) { + ldap.simple_bind(dn, pw) + .await + .map_err(ldap_err)? + .success() + .map_err(ldap_err)?; + } + let (rs, _res) = ldap + .search(base, Scope::Subtree, filter, attrs.to_vec()) + .await + .map_err(ldap_err)? + .success() + .map_err(ldap_err)?; + // `SearchEntry::construct` panics on a malformed / non-schema-conformant + // entry and `ldap3` 0.11 offers no fallible variant; a compromised or + // MITM'd directory could send one. Catch the unwind so a bad entry + // becomes a clean `Err` (fail closed) rather than aborting the in-flight + // authentication task. + let entries: Result, SecurityError> = rs + .into_iter() + .map(|e| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| SearchEntry::construct(e))) + .map(|se| LdapEntry { + dn: se.dn, + attrs: se.attrs, + }) + .map_err(|_| SecurityError::verification("ldap: malformed directory entry")) + }) + .collect(); + let _ = ldap.unbind().await; + entries + } + + async fn bind(&self, dn: &str, password: &str) -> Result<(), SecurityError> { + let (conn, mut ldap) = LdapConnAsync::new(&self.url).await.map_err(ldap_err)?; + ldap3::drive!(conn); + // `success()` turns a non-zero LDAP result code (e.g. invalidCredentials) + // into an error, so a failed bind is a clean `Err`. + let result = ldap + .simple_bind(dn, password) + .await + .map_err(ldap_err)? + .success(); + let _ = ldap.unbind().await; + result.map(|_| ()).map_err(ldap_err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// A scriptable [`LdapOperations`] for the provider unit tests. + #[derive(Default)] + struct MockLdap { + user_search_base: String, + group_search_base: String, + /// The DN the user search resolves to (`None` → no such user). + user_dn: Option, + /// The single `(dn, password)` pair whose bind succeeds. + valid_bind: Option<(String, String)>, + /// Group `cn`s the group search returns. + group_cns: Vec, + /// `memberOf` group DNs attached to the user entry (Active Directory). + member_of: Vec, + /// When set, the user search returns a SECOND entry (ambiguous result). + duplicate_user: bool, + /// When set, the group search fails with a directory error. + fail_group_search: bool, + /// Every filter passed to `search`, for injection-escaping assertions. + seen_filters: Mutex>, + } + + #[async_trait] + impl LdapOperations for MockLdap { + async fn search( + &self, + base: &str, + filter: &str, + _attrs: &[&str], + ) -> Result, SecurityError> { + self.seen_filters.lock().unwrap().push(filter.to_string()); + if base == self.user_search_base { + let mut attrs = HashMap::new(); + if !self.member_of.is_empty() { + attrs.insert("memberOf".to_string(), self.member_of.clone()); + } + let Some(dn) = self.user_dn.clone() else { + return Ok(Vec::new()); + }; + let mut entries = vec![LdapEntry { + dn, + attrs: attrs.clone(), + }]; + if self.duplicate_user { + // A second, attacker-controlled entry matching the same filter. + entries.push(LdapEntry { + dn: "uid=evil,ou=people,dc=ex,dc=com".into(), + attrs, + }); + } + Ok(entries) + } else if base == self.group_search_base { + if self.fail_group_search { + return Err(SecurityError::verification("group search failed")); + } + Ok(self + .group_cns + .iter() + .map(|cn| LdapEntry { + dn: format!("cn={cn},{}", self.group_search_base), + attrs: HashMap::from([("cn".to_string(), vec![cn.clone()])]), + }) + .collect()) + } else { + Ok(Vec::new()) + } + } + + async fn bind(&self, dn: &str, password: &str) -> Result<(), SecurityError> { + match &self.valid_bind { + Some((vd, vp)) if vd == dn && vp == password => Ok(()), + _ => Err(SecurityError::verification("invalid credentials")), + } + } + } + + fn up(username: &str, password: &str) -> AuthenticationRequest { + AuthenticationRequest::username_password(username, password) + } + + fn provider(mock: Arc) -> LdapAuthenticationProvider { + LdapAuthenticationProvider::new(mock, "ou=people,dc=ex,dc=com", "(uid={0})") + .with_group_search("ou=groups,dc=ex,dc=com", "(member={0})") + } + + #[tokio::test] + async fn binds_the_user_and_populates_group_roles() { + let mock = Arc::new(MockLdap { + user_search_base: "ou=people,dc=ex,dc=com".into(), + group_search_base: "ou=groups,dc=ex,dc=com".into(), + user_dn: Some("uid=alice,ou=people,dc=ex,dc=com".into()), + valid_bind: Some(("uid=alice,ou=people,dc=ex,dc=com".into(), "pw".into())), + group_cns: vec!["admins".into(), "users".into()], + ..MockLdap::default() + }); + let auth = provider(mock) + .authenticate(&up("alice", "pw")) + .await + .expect("authenticated"); + assert_eq!(auth.principal, "uid=alice,ou=people,dc=ex,dc=com"); + assert_eq!(auth.username, "alice"); + // Group cns become ROLE_. + assert!(auth.has_role("ADMINS")); + assert!(auth.has_role("USERS")); + } + + #[tokio::test] + async fn wrong_password_and_unknown_user_both_fail_as_bad_credentials() { + // Wrong password → bind fails. + let known = Arc::new(MockLdap { + user_search_base: "ou=people,dc=ex,dc=com".into(), + user_dn: Some("uid=alice,ou=people,dc=ex,dc=com".into()), + valid_bind: Some(("uid=alice,ou=people,dc=ex,dc=com".into(), "pw".into())), + ..MockLdap::default() + }); + assert!(provider(known) + .authenticate(&up("alice", "wrong")) + .await + .is_err()); + + // Unknown user → no search hit, fails with the same error value. + let unknown = Arc::new(MockLdap { + user_search_base: "ou=people,dc=ex,dc=com".into(), + user_dn: None, + ..MockLdap::default() + }); + assert!(provider(unknown) + .authenticate(&up("ghost", "pw")) + .await + .is_err()); + } + + #[tokio::test] + async fn empty_password_is_rejected_before_binding() { + // Even a "valid" empty-password bind must be refused (anonymous-bind bypass). + let mock = Arc::new(MockLdap { + user_search_base: "ou=people,dc=ex,dc=com".into(), + user_dn: Some("uid=alice,ou=people,dc=ex,dc=com".into()), + valid_bind: Some(("uid=alice,ou=people,dc=ex,dc=com".into(), String::new())), + ..MockLdap::default() + }); + assert!(provider(mock).authenticate(&up("alice", "")).await.is_err()); + } + + #[tokio::test] + async fn ambiguous_user_search_is_rejected() { + // Two entries match the user filter → fail closed (Spring's + // IncorrectResultSizeDataAccessException), never bind an arbitrary first + // match even when its password would succeed. + let mock = Arc::new(MockLdap { + user_search_base: "ou=people,dc=ex,dc=com".into(), + user_dn: Some("uid=alice,ou=people,dc=ex,dc=com".into()), + valid_bind: Some(("uid=alice,ou=people,dc=ex,dc=com".into(), "pw".into())), + duplicate_user: true, + ..MockLdap::default() + }); + assert!(provider(mock) + .authenticate(&up("alice", "pw")) + .await + .is_err()); + } + + #[tokio::test] + async fn group_search_error_fails_the_login_not_silent_role_loss() { + // A directory error during authorities population must propagate, not + // authenticate the user with an empty (under-privileged) role set. + let mock = Arc::new(MockLdap { + user_search_base: "ou=people,dc=ex,dc=com".into(), + group_search_base: "ou=groups,dc=ex,dc=com".into(), + user_dn: Some("uid=alice,ou=people,dc=ex,dc=com".into()), + valid_bind: Some(("uid=alice,ou=people,dc=ex,dc=com".into(), "pw".into())), + fail_group_search: true, + ..MockLdap::default() + }); + assert!(provider(mock) + .authenticate(&up("alice", "pw")) + .await + .is_err()); + } + + #[tokio::test] + async fn username_is_escaped_in_the_search_filter() { + let mock = Arc::new(MockLdap { + user_search_base: "ou=people,dc=ex,dc=com".into(), + user_dn: None, + ..MockLdap::default() + }); + let shared = mock.clone(); + // A wildcard-injection username must be neutralized in the filter. + let _ = provider(mock).authenticate(&up("a*)(uid=*", "pw")).await; + let filters = shared.seen_filters.lock().unwrap(); + let user_filter = &filters[0]; + assert!( + user_filter.contains("a\\2a\\29\\28uid=\\2a"), + "filter not escaped: {user_filter}" + ); + assert!(!user_filter.contains("a*)(uid=*")); + } + + #[test] + fn escape_filter_value_covers_rfc4515_specials() { + assert_eq!(escape_filter_value("a*b(c)d\\e"), "a\\2ab\\28c\\29d\\5ce"); + assert_eq!(escape_filter_value("plain"), "plain"); + } + + #[test] + fn cn_from_dn_extracts_leading_cn() { + assert_eq!( + cn_from_dn("CN=Admins,OU=Groups,DC=ex,DC=com"), + Some("Admins") + ); + assert_eq!(cn_from_dn("cn=Ops Team, ou=g"), Some("Ops Team")); + // A non-CN leading RDN yields nothing. + assert_eq!(cn_from_dn("OU=Groups,DC=ex"), None); + assert_eq!(cn_from_dn("garbage"), None); + } + + // --- Active Directory provider ----------------------------------------- + + #[tokio::test] + async fn active_directory_binds_upn_and_maps_member_of_to_roles() { + let mock = Arc::new(MockLdap { + // AD searches under the root DN for the user's memberOf. + user_search_base: "dc=example,dc=com".into(), + user_dn: Some("CN=Alice,OU=People,DC=example,DC=com".into()), + // The bind principal is the userPrincipalName (alice@example.com). + valid_bind: Some(("alice@example.com".into(), "pw".into())), + member_of: vec![ + "CN=Admins,OU=Groups,DC=example,DC=com".into(), + "CN=Users,OU=Groups,DC=example,DC=com".into(), + ], + ..MockLdap::default() + }); + let provider = ActiveDirectoryLdapAuthenticationProvider::new( + mock, + "example.com", + "dc=example,dc=com", + ); + + let auth = provider + .authenticate(&up("alice", "pw")) + .await + .expect("authenticated"); + assert_eq!(auth.principal, "CN=Alice,OU=People,DC=example,DC=com"); + assert!(auth.has_role("ADMINS")); + assert!(auth.has_role("USERS")); + + // Wrong password → the UPN bind fails → Bad credentials. + let mock2 = Arc::new(MockLdap { + valid_bind: Some(("alice@example.com".into(), "pw".into())), + ..MockLdap::default() + }); + let provider2 = ActiveDirectoryLdapAuthenticationProvider::new( + mock2, + "example.com", + "dc=example,dc=com", + ); + assert!(provider2.authenticate(&up("alice", "nope")).await.is_err()); + } + + #[tokio::test] + async fn active_directory_rejects_empty_password() { + let mock = Arc::new(MockLdap { + valid_bind: Some(("alice@example.com".into(), String::new())), + ..MockLdap::default() + }); + let provider = ActiveDirectoryLdapAuthenticationProvider::new( + mock, + "example.com", + "dc=example,dc=com", + ); + assert!(provider.authenticate(&up("alice", "")).await.is_err()); + } + + #[tokio::test] + async fn active_directory_rejects_ambiguous_member_of_search() { + // The UPN bind succeeds, but the post-bind directory search is ambiguous + // → refuse to read authorities from an arbitrary entry; fail the login. + let mock = Arc::new(MockLdap { + user_search_base: "dc=example,dc=com".into(), + user_dn: Some("CN=Alice,OU=People,DC=example,DC=com".into()), + valid_bind: Some(("alice@example.com".into(), "pw".into())), + duplicate_user: true, + ..MockLdap::default() + }); + let provider = ActiveDirectoryLdapAuthenticationProvider::new( + mock, + "example.com", + "dc=example,dc=com", + ); + assert!(provider.authenticate(&up("alice", "pw")).await.is_err()); + } + + // Live `ldap3` adapter smoke test — skipped unless FIREFLY_TEST_LDAP_URL is + // set (e.g. a test OpenLDAP/AD), mirroring the env-gated Postgres tests. + #[tokio::test] + async fn ldap3_adapter_binds_against_a_live_directory() { + let Ok(url) = std::env::var("FIREFLY_TEST_LDAP_URL") else { + eprintln!("skipping ldap3 adapter test: set FIREFLY_TEST_LDAP_URL to run"); + return; + }; + let (Ok(dn), Ok(pw)) = ( + std::env::var("FIREFLY_TEST_LDAP_BIND_DN"), + std::env::var("FIREFLY_TEST_LDAP_BIND_PW"), + ) else { + eprintln!("skipping: set FIREFLY_TEST_LDAP_BIND_DN / _PW to run"); + return; + }; + let ops = Ldap3Operations::new(url); + // A correct bind succeeds; a wrong password fails closed. + ops.bind(&dn, &pw).await.expect("valid bind succeeds"); + assert!(ops.bind(&dn, "definitely-wrong").await.is_err()); + } +} diff --git a/crates/security/src/lib.rs b/crates/security/src/lib.rs index c412448..5d87b8c 100644 --- a/crates/security/src/lib.rs +++ b/crates/security/src/lib.rs @@ -149,6 +149,8 @@ pub mod guards; mod http_basic; mod jwks; mod jwt; +#[cfg(feature = "ldap")] +mod ldap; pub mod oauth2; mod ott; mod password; @@ -199,6 +201,11 @@ pub use guards::{require, AuthorizationGuard}; pub use http_basic::{HttpBasicLayer, HttpBasicService}; pub use jwks::{claims_to_authentication, Algorithm, JwksVerifier, DEFAULT_CLOCK_SKEW_SECONDS}; pub use jwt::{authentication_from_claims, JwtService, DEFAULT_EXPIRATION_SECONDS}; +#[cfg(feature = "ldap")] +pub use ldap::{ + cn_from_dn, escape_filter_value, ActiveDirectoryLdapAuthenticationProvider, Ldap3Operations, + LdapAuthenticationProvider, LdapEntry, LdapOperations, +}; pub use ott::{ ott_login_routes, InMemoryOneTimeTokenService, LoggingOttHandler, OneTimeToken, OneTimeTokenGenerationSuccessHandler, OneTimeTokenService, OttLoginState, diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub index de27722..a9b873a 100644 Binary files a/docs/book/dist/firefly-rust-by-example-es.epub and b/docs/book/dist/firefly-rust-by-example-es.epub differ diff --git a/docs/book/dist/firefly-rust-by-example-es.pdf b/docs/book/dist/firefly-rust-by-example-es.pdf index 95d6ee3..3d7e5e5 100644 Binary files a/docs/book/dist/firefly-rust-by-example-es.pdf and b/docs/book/dist/firefly-rust-by-example-es.pdf differ diff --git a/docs/book/dist/firefly-rust-by-example.epub b/docs/book/dist/firefly-rust-by-example.epub index 83db3ff..194a5e3 100644 Binary files a/docs/book/dist/firefly-rust-by-example.epub and b/docs/book/dist/firefly-rust-by-example.epub differ diff --git a/docs/book/dist/firefly-rust-by-example.pdf b/docs/book/dist/firefly-rust-by-example.pdf index 519b2e6..631223e 100644 Binary files a/docs/book/dist/firefly-rust-by-example.pdf and b/docs/book/dist/firefly-rust-by-example.pdf differ diff --git a/docs/book/src-es/14a-spring-security-parity.md b/docs/book/src-es/14a-spring-security-parity.md index dd44f53..d893492 100644 --- a/docs/book/src-es/14a-spring-security-parity.md +++ b/docs/book/src-es/14a-spring-security-parity.md @@ -46,7 +46,8 @@ En la columna **Estado**, :status-supported: indica una función soportada, | Introspección de tokens opacos (RFC 7662) | :status-supported: | `RemoteTokenIntrospector` (`OpaqueTokenIntrospector`) — un `Verifier` de servidor de recursos intercambiable | | Logout iniciado por RP (OIDC) | :status-supported: | `oidc_logout_url` — el logout redirige al `end_session_endpoint` del proveedor (`OidcClientInitiatedLogoutSuccessHandler`) | | Servidor de autorización | :status-partial: | `AuthorizationServer` (client-credentials + refresh-token) montado vía `AuthorizationServerRouter` (`/oauth2/token`, metadatos RFC 8414); el grant authorization_code del lado servidor en la hoja de ruta | -| ACL / seguridad de objetos de dominio · SAML2 · LDAP/AD | :status-planned: | Hoja de ruta (crates opcionales) | +| Autenticación LDAP / Active Directory | :status-partial: | Módulo `ldap` opcional: `LdapAuthenticationProvider` (bind auth + autoridades de grupo) + `ActiveDirectoryLdapAuthenticationProvider`, sobre `ldap3` (`ldapAuthentication()`) | +| ACL / seguridad de objetos de dominio · SAML2 | :status-planned: | Hoja de ruta (opcional) | ## Comportamientos fieles a Spring que conviene conocer @@ -182,6 +183,41 @@ Firefly incluye los dos mecanismos sin contraseña de Spring Security 6.4: sobre `webauthn-rs`, almacenando credenciales mediante un repositorio conectable. +## LDAP / Active Directory + +El módulo `ldap` opcional (`--features ldap`, trae `ldap3`) autentica +credenciales usuario/contraseña contra un directorio — el `ldapAuthentication()` +de Spring. Ambos proveedores son `AuthenticationProvider`, así que se conectan +al `ProviderManager` del Nivel 1: + +- **`LdapAuthenticationProvider`** — **autenticación por bind**: busca el DN del + usuario bajo una base con un filtro (`(uid={0})`, el usuario escapado según + RFC 4515), hace bind como ese DN con la contraseña (el directorio la verifica), + y luego mapea la pertenencia a grupos (`(member={0})`) a autoridades + `ROLE_` (el `BindAuthenticator` + `DefaultLdapAuthoritiesPopulator` de + Spring). +- **`ActiveDirectoryLdapAuthenticationProvider`** — hace bind como el + `userPrincipalName` (`usuario@dominio`) y mapea los grupos `memberOf` del + usuario a roles. + +Las operaciones LDAP están tras un puerto `LdapOperations` (adaptador real: +`Ldap3Operations`), de modo que la lógica se prueba sin un directorio real. +Comportamientos de seguridad, fieles a Spring y verificados por una revisión +adversarial previa al *release*: + +- Una **contraseña vacía se rechaza antes del bind** — un bind simple con + contraseña vacía es un bind anónimo que la mayoría de directorios aceptan (un + *bypass* de autenticación). +- El usuario/DN se **escapa según RFC 4515** en cada filtro (a salvo de + inyección LDAP), y usuario-desconocido / contraseña-incorrecta devuelven el + **mismo valor de error**. +- Una **búsqueda de usuario ambigua** (más de una entrada coincidente) se rechaza + en vez de hacer bind contra una primera coincidencia arbitraria — la + `IncorrectResultSizeDataAccessException` de Spring. +- Un **error de directorio al poblar autoridades** falla el inicio de sesión en + lugar de autenticar en silencio sin roles, y una **entrada de directorio + malformada** se convierte en un error limpio en vez de abortar la petición. + ## Hoja de ruta La paridad se entrega por niveles, cada uno un incremento: @@ -201,5 +237,6 @@ La paridad se entrega por niveles, cada uno un incremento: el gestor de clientes autorizados salientes, logout iniciado por RP, y el servidor de autorización montado sobre HTTP con metadatos RFC 8414. (El grant authorization_code del lado servidor queda como seguimiento.) -6. **Subsistemas grandes** — ACL / seguridad de objetos de dominio, LDAP / - Active Directory, SAML2. +6. **Subsistemas grandes** — entregados de uno en uno (opcional). **LDAP / + Active Directory (hecho)** — el módulo `ldap` opcional. Quedan **SAML2** y + **ACL / seguridad de objetos de dominio**. diff --git a/docs/book/src/14a-spring-security-parity.md b/docs/book/src/14a-spring-security-parity.md index 10f9d43..d514fad 100644 --- a/docs/book/src/14a-spring-security-parity.md +++ b/docs/book/src/14a-spring-security-parity.md @@ -45,7 +45,8 @@ In the **Status** column, :status-supported: marks a supported feature, | Opaque-token introspection (RFC 7662) | :status-supported: | `RemoteTokenIntrospector` (`OpaqueTokenIntrospector`) — a drop-in resource-server `Verifier` | | RP-initiated logout (OIDC) | :status-supported: | `oidc_logout_url` — logout redirects to the provider `end_session_endpoint` (`OidcClientInitiatedLogoutSuccessHandler`) | | Authorization server | :status-partial: | `AuthorizationServer` (client-credentials + refresh-token) mounted via `AuthorizationServerRouter` (`/oauth2/token`, RFC 8414 metadata); server-side authorization_code grant on the roadmap | -| ACL / domain-object security · SAML2 · LDAP/AD | :status-planned: | Roadmap (opt-in crates) | +| LDAP / Active Directory authentication | :status-partial: | Feature-gated `ldap`: `LdapAuthenticationProvider` (bind auth + group authorities) + `ActiveDirectoryLdapAuthenticationProvider`, over `ldap3` (`ldapAuthentication()`) | +| ACL / domain-object security · SAML2 | :status-planned: | Roadmap (opt-in) | ## Spring-faithful behaviours to know @@ -172,6 +173,40 @@ Firefly ships the two Spring Security 6.4 passwordless mechanisms: `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn`) built on `webauthn-rs`, storing credentials through a pluggable repository. +## LDAP / Active Directory + +The feature-gated `ldap` module (opt-in: `--features ldap`, pulls in `ldap3`) +authenticates username/password credentials against a directory — Spring's +`ldapAuthentication()`. Both providers are +[`AuthenticationProvider`](#)s, so they plug straight into the Tier 1 +`ProviderManager`: + +- **`LdapAuthenticationProvider`** — **bind authentication**: search the user's + DN under a base with a filter (`(uid={0})`, the username RFC 4515-escaped), + bind as that DN with the password (the directory verifies it), then map group + membership (`(member={0})`) to `ROLE_` authorities (Spring's + `BindAuthenticator` + `DefaultLdapAuthoritiesPopulator`). +- **`ActiveDirectoryLdapAuthenticationProvider`** — binds as the + `userPrincipalName` (`user@domain`) and maps the user's `memberOf` groups to + roles (Spring's `ActiveDirectoryLdapAuthenticationProvider`). + +The LDAP wire operations sit behind an `LdapOperations` port (real adapter: +`Ldap3Operations`), so the provider logic is unit-tested without a live +directory. Safety behaviours, Spring-faithful and verified by a pre-release +adversarial review: + +- An **empty password is rejected before binding** — a simple bind with an empty + password is an anonymous bind that most directories accept (an authentication + bypass). +- The username/DN is **RFC 4515-escaped** in every filter (LDAP-injection safe), + and unknown-user / wrong-password return the **same error value**. +- An **ambiguous user search** (more than one matching entry) is rejected rather + than binding against an arbitrary first match — Spring's + `IncorrectResultSizeDataAccessException`. +- A **directory error while populating authorities** fails the login instead of + silently authenticating with no roles, and a **malformed directory entry** is + turned into a clean error rather than aborting the request. + ## Roadmap Parity is delivered in tiers, each its own increment: @@ -190,5 +225,6 @@ Parity is delivered in tiers, each its own increment: outbound authorized-client manager, RP-initiated logout, and the authorization server mounted over HTTP with RFC 8414 metadata. (The server-side authorization_code grant remains a follow-up.) -6. **Big subsystems** — ACL / domain-object security, LDAP / Active Directory, - SAML2. +6. **Big subsystems** — delivered one opt-in subsystem at a time. **LDAP / + Active Directory (done)** — the feature-gated `ldap` module. **SAML2** and + **ACL / domain-object security** remain.