diff --git a/CHANGELOG.md b/CHANGELOG.md index e518e8d..5c60cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.35 — 2026-06-20 + +**Spring Security parity — Tier 5c: ACL / domain-object security.** The Rust +analog of `spring-security-acl`, answering `hasPermission(object, permission)` +from per-object access-control lists. Pure Rust — no new dependencies. All +additive. Adversarially reviewed before release. + +### Added + +- **ACL core** (`spring-security-acl` parity): + - **`Permission`** — the `BasePermission` bitmask (`READ`=1, `WRITE`=2, + `CREATE`=4, `DELETE`=8, `ADMINISTRATION`=16), with cumulative `union`, + bit-`contains`, and case-insensitive name parsing. + - **`Sid`** (`Principal` / `Authority` — Spring's `PrincipalSid` / + `GrantedAuthoritySid`), **`ObjectIdentity`** (`type` + `identifier`), + **`AccessControlEntry`** (sid + permission + granting), and **`Acl`** (owner + + ordered ACEs + optional parent for inheritance). + - **`AclService`** + **`InMemoryAclService`** (Spring's `MutableAclService`), + and the free **`is_granted`** resolver. +- **`AclPermissionEvaluator`** — bridges an `AclService` to the Tier 3 + `PermissionEvaluator`, resolving `hasPermission(...)` against per-object ACLs + by object reference *or* `(type, id)`. The principal and its roles/authorities + map to `PrincipalSid` / `GrantedAuthoritySid` (each role matched both bare and + `ROLE_`-prefixed). +- **`PermissionEvaluator::has_permission_for_id`** + the free + **`has_permission_for_id`** — Spring's id-based `hasPermission` overload + (default-deny, backward compatible). + +### Security + +- ACL evaluation is **default-deny**: a permission is granted only when an + applicable *granting* entry is found (locally or up the inheritance chain); + the **first entry matching a `(sid, permission)` wins**, so a deny placed + before a grant takes precedence (Spring's `DefaultPermissionGrantingStrategy`). + The inheritance walk is **bounded**, so a cyclic or pathologically deep parent + chain terminates and denies rather than looping. + ## v26.6.34 — 2026-06-19 **Spring Security parity — Tier 5a: LDAP / Active Directory authentication.** diff --git a/Cargo.lock b/Cargo.lock index c1dc007..db243de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,7 +1548,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "firefly-actuator", @@ -1592,7 +1592,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1609,7 +1609,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "inventory", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1667,7 +1667,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-observability", @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -1691,7 +1691,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-cache", @@ -1703,7 +1703,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1729,7 +1729,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "chrono", @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-stream", "axum", @@ -1772,7 +1772,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "regex", @@ -1787,7 +1787,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1803,7 +1803,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.34" +version = "26.6.35" dependencies = [ "futures", "inventory", @@ -1814,7 +1814,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -1837,7 +1837,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-stream", "async-trait", @@ -1854,7 +1854,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-stream", "async-trait", @@ -1872,7 +1872,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-stream", "async-trait", @@ -1894,7 +1894,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -1910,7 +1910,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1961,7 +1961,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -1981,7 +1981,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2001,7 +2001,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "base64 0.22.1", @@ -2023,7 +2023,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-eda", @@ -2038,7 +2038,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-eda", @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-eda", @@ -2087,7 +2087,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "base64 0.22.1", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "http", @@ -2120,7 +2120,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2157,7 +2157,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2174,7 +2174,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2196,7 +2196,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2248,7 +2248,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.34" +version = "26.6.35" dependencies = [ "chrono", "serde", @@ -2260,7 +2260,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.34" +version = "26.6.35" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2269,7 +2269,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2293,7 +2293,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.34" +version = "26.6.35" dependencies = [ "chrono", "hex", @@ -2306,7 +2306,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -2321,7 +2321,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2337,7 +2337,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2354,7 +2354,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2371,7 +2371,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "base64 0.22.1", @@ -2388,7 +2388,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2404,7 +2404,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -2428,7 +2428,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "chrono", @@ -2444,7 +2444,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2468,7 +2468,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -2478,7 +2478,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-stream", "firefly-kernel", @@ -2490,7 +2490,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-config", @@ -2502,7 +2502,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2522,7 +2522,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2539,7 +2539,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -2554,7 +2554,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.34" +version = "26.6.35" dependencies = [ "chrono", "firefly", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.34" +version = "26.6.35" dependencies = [ "chrono", "firefly", @@ -2578,7 +2578,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.34" +version = "26.6.35" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2590,7 +2590,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "firefly", @@ -2608,7 +2608,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "firefly", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2644,7 +2644,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-stream", "async-trait", @@ -2684,7 +2684,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "chrono", @@ -2705,7 +2705,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.34" +version = "26.6.35" dependencies = [ "argon2", "async-trait", @@ -2737,7 +2737,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2762,7 +2762,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-session", @@ -2775,7 +2775,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-session", @@ -2787,7 +2787,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-session", @@ -2799,7 +2799,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "futures", @@ -2809,7 +2809,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "firefly", @@ -2817,7 +2817,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "firefly", @@ -2829,7 +2829,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "bytes", @@ -2845,7 +2845,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-cqrs", @@ -2859,7 +2859,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "firefly-cqrs", @@ -2898,7 +2898,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2910,7 +2910,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -2931,7 +2931,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "firefly-kernel", @@ -2946,7 +2946,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.34" +version = "26.6.35" dependencies = [ "axum", "base64 0.22.1", @@ -2965,7 +2965,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "inventory", @@ -2976,7 +2976,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.34" +version = "26.6.35" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -2992,7 +2992,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.34" +version = "26.6.35" dependencies = [ "chrono", "firefly-kernel", @@ -3003,7 +3003,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -3042,7 +3042,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", @@ -3070,7 +3070,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.34" +version = "26.6.35" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index d0ae410..76bcb8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.34" +version = "26.6.35" 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.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" } +firefly-reactive = { path = "crates/reactive", version = "26.6.35" } +firefly-kernel = { path = "crates/kernel", version = "26.6.35" } +firefly-utils = { path = "crates/utils", version = "26.6.35" } +firefly-validators = { path = "crates/validators", version = "26.6.35" } +firefly-web = { path = "crates/web", version = "26.6.35" } +firefly-config = { path = "crates/config", version = "26.6.35" } +firefly-i18n = { path = "crates/i18n", version = "26.6.35" } +firefly-cache = { path = "crates/cache", version = "26.6.35" } +firefly-observability = { path = "crates/observability", version = "26.6.35" } +firefly-data = { path = "crates/data", version = "26.6.35" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.35" } +firefly-eda = { path = "crates/eda", version = "26.6.35" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.35" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.35" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.35" } +firefly-plugins = { path = "crates/plugins", version = "26.6.35" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.35" } +firefly-actuator = { path = "crates/actuator", version = "26.6.35" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.35" } +firefly-resilience = { path = "crates/resilience", version = "26.6.35" } +firefly-security = { path = "crates/security", version = "26.6.35" } +firefly-migrations = { path = "crates/migrations", version = "26.6.35" } +firefly-openapi = { path = "crates/openapi", version = "26.6.35" } +firefly-sse = { path = "crates/sse", version = "26.6.35" } +firefly-transactional = { path = "crates/transactional", version = "26.6.35" } +firefly-testkit = { path = "crates/testkit", version = "26.6.35" } +firefly-client = { path = "crates/client", version = "26.6.35" } +firefly-config-server = { path = "crates/config-server", version = "26.6.35" } +firefly-idp = { path = "crates/idp", version = "26.6.35" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.35" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.35" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.35" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.35" } +firefly-ecm = { path = "crates/ecm", version = "26.6.35" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.35" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.35" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.35" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.35" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.35" } +firefly-notifications = { path = "crates/notifications", version = "26.6.35" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.35" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.35" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.35" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.35" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.35" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.35" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.35" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.35" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.35" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.35" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.35" } +firefly-admin = { path = "crates/admin", version = "26.6.35" } +firefly-aop = { path = "crates/aop", version = "26.6.35" } +firefly-cli = { path = "crates/cli", version = "26.6.35" } +firefly-container = { path = "crates/container", version = "26.6.35" } +firefly-session = { path = "crates/session", version = "26.6.35" } +firefly-shell = { path = "crates/shell", version = "26.6.35" } +firefly-websocket = { path = "crates/websocket", version = "26.6.35" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.35" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.35" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.35" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.35" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.35" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.35" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.35" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.35" } +firefly = { path = "crates/firefly", version = "26.6.35" } +firefly-macros = { path = "crates/macros", version = "26.6.35" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.35" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.35" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.35" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.35" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.35" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.35" } # ---- 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 3ace1e4..a77ac4b 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, 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-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`), domain-object **ACL** (`Acl`/`AclService`/`AclPermissionEvaluator`, `spring-security-acl` parity), `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/src/acl.rs b/crates/security/src/acl.rs new file mode 100644 index 0000000..8f3c3ac --- /dev/null +++ b/crates/security/src/acl.rs @@ -0,0 +1,646 @@ +// 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. + +//! ACL / domain-object security — the Rust analog of Spring Security's +//! `spring-security-acl`. +//! +//! Where the Tier 3 [`PermissionEvaluator`](crate::PermissionEvaluator) answers +//! "may this principal do X to this object?" with arbitrary logic, an **ACL** +//! answers it from per-object access-control lists: each domain object +//! ([`ObjectIdentity`]) has an [`Acl`] of [`AccessControlEntry`]s granting (or +//! denying) [`Permission`]s to [`Sid`]s (a principal or an authority). +//! +//! The pieces mirror Spring: +//! +//! - [`Permission`] — the `BasePermission` bitmask (`READ`, `WRITE`, `CREATE`, +//! `DELETE`, `ADMINISTRATION`), combinable into a cumulative mask. +//! - [`Sid`] — a security identity: a [`Sid::Principal`] (username) or a +//! [`Sid::Authority`] (a granted authority / role), Spring's `PrincipalSid` / +//! `GrantedAuthoritySid`. +//! - [`ObjectIdentity`] — a domain object's `(type, identifier)` key. +//! - [`Acl`] — owner + ordered [`AccessControlEntry`]s + optional parent for +//! **inheritance**. +//! - [`AclService`] — looks an [`Acl`] up by identity; [`InMemoryAclService`] is +//! the built-in mutable store (Spring's `MutableAclService`). +//! - [`AclPermissionEvaluator`] — bridges an [`AclService`] to the +//! [`PermissionEvaluator`](crate::PermissionEvaluator) port, so +//! `hasPermission(...)` in method security resolves against the ACLs. +//! +//! Evaluation is **default-deny**: a permission is granted only if an applicable +//! granting ACE is found (locally or via the inheritance chain); the first ACE +//! matching a `(sid, permission)` wins, so a deny ACE placed before a grant +//! takes precedence (Spring's `DefaultPermissionGrantingStrategy`). +//! +//! An ACE matches by **bit-containment** ([`Permission::contains`]): a granting +//! entry for a cumulative mask satisfies a request for any permission it +//! includes (a grant of `READ|WRITE` satisfies a `READ` check). This is a +//! deliberate divergence from Spring's *exact-mask-equality* default — it is the +//! behaviour of Spring's documented bitwise `PermissionGrantingStrategy` override +//! — chosen so a cumulative grant means "holds each of these permissions". + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::authentication::{Authentication, ROLE_PREFIX}; +use crate::permission::PermissionEvaluator; + +/// The maximum number of parent hops followed during inheritance resolution — a +/// guard against a cyclic or pathologically deep parent chain. +const MAX_INHERITANCE_DEPTH: usize = 32; + +/// A permission as a bitmask — the Rust analog of Spring's `BasePermission`. +/// +/// The five base permissions occupy single bits and can be combined with +/// [`union`](Permission::union) into a cumulative mask. An ACE's permission +/// *contains* a requested permission when every requested bit is set in the +/// ACE's mask. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Permission(i32); + +impl Permission { + /// Read access (mask `1`). + pub const READ: Permission = Permission(1); + /// Write / modify access (mask `2`). + pub const WRITE: Permission = Permission(2); + /// Create access (mask `4`). + pub const CREATE: Permission = Permission(4); + /// Delete access (mask `8`). + pub const DELETE: Permission = Permission(8); + /// Administrative / take-ownership access (mask `16`). + pub const ADMINISTRATION: Permission = Permission(16); + + /// Builds a permission from a raw bitmask. + #[must_use] + pub const fn from_mask(mask: i32) -> Permission { + Permission(mask) + } + + /// The raw bitmask. + #[must_use] + pub const fn mask(self) -> i32 { + self.0 + } + + /// The cumulative permission carrying both sets of bits. + #[must_use] + pub const fn union(self, other: Permission) -> Permission { + Permission(self.0 | other.0) + } + + /// Whether this (cumulative) permission contains every bit of `required`. + /// + /// A `required` mask of `0` (no bits — a meaningless permission, only + /// reachable via [`from_mask`](Permission::from_mask)) is **never** contained: + /// it would otherwise be satisfied by any entry, defeating default-deny. + #[must_use] + pub const fn contains(self, required: Permission) -> bool { + required.0 != 0 && (self.0 & required.0) == required.0 + } + + /// Parses a base-permission name (case-insensitive), the bridge from a + /// method-security `hasPermission(obj, "read")` string to a [`Permission`]. + /// Recognises `read`, `write`, `create`, `delete`, and + /// `administration` (or `admin`). + #[must_use] + pub fn from_name(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "read" => Some(Permission::READ), + "write" => Some(Permission::WRITE), + "create" => Some(Permission::CREATE), + "delete" => Some(Permission::DELETE), + "administration" | "admin" => Some(Permission::ADMINISTRATION), + _ => None, + } + } +} + +/// A security identity an [`AccessControlEntry`] is granted to — Spring's `Sid`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Sid { + /// A specific principal, by username (Spring's `PrincipalSid`). + Principal(String), + /// A granted authority / role (Spring's `GrantedAuthoritySid`), e.g. + /// `ROLE_ADMIN`. + Authority(String), +} + +/// A domain object's identity — its `(type, identifier)` key, Spring's +/// `ObjectIdentity`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ObjectIdentity { + /// The object's type (e.g. a fully-qualified domain-class name or table). + pub object_type: String, + /// The object's identifier within its type (e.g. a primary key). + pub identifier: String, +} + +impl ObjectIdentity { + /// Builds an object identity from a type and identifier. + #[must_use] + pub fn new(object_type: impl Into, identifier: impl Into) -> Self { + Self { + object_type: object_type.into(), + identifier: identifier.into(), + } + } + + fn key(&self) -> (String, String) { + (self.object_type.clone(), self.identifier.clone()) + } +} + +/// One entry of an [`Acl`] — grants (or denies) a [`Permission`] to a [`Sid`]. +/// Spring's `AccessControlEntry`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccessControlEntry { + /// The identity this entry applies to. + pub sid: Sid, + /// The permission (possibly cumulative) this entry concerns. + pub permission: Permission, + /// `true` grants the permission; `false` explicitly denies it. + pub granting: bool, +} + +/// An access-control list for one domain object — Spring's `Acl`. +/// +/// Entries are evaluated in order; the first entry matching a `(sid, permission)` +/// decides. When no local entry applies and [`entries_inheriting`](Acl::entries_inheriting) +/// is set, evaluation continues at the [`parent`](Acl::parent). +#[derive(Debug, Clone)] +pub struct Acl { + /// The object this ACL governs. + pub object_identity: ObjectIdentity, + /// The object's owner (always granted, implicitly, administrative control in + /// Spring; here exposed via [`owner`](Acl::owner) for callers that honour it). + pub owner: Sid, + /// The ordered access-control entries. + pub entries: Vec, + /// The parent object whose ACL is consulted when inheriting. + pub parent: Option, + /// Whether unmatched permissions fall through to the [`parent`](Acl::parent). + pub entries_inheriting: bool, +} + +impl Acl { + /// Builds an empty, non-inheriting ACL for `object_identity` owned by `owner`. + #[must_use] + pub fn new(object_identity: ObjectIdentity, owner: Sid) -> Self { + Self { + object_identity, + owner, + entries: Vec::new(), + parent: None, + entries_inheriting: false, + } + } + + /// Appends a granting entry for `sid` / `permission`. + #[must_use] + pub fn grant(mut self, sid: Sid, permission: Permission) -> Self { + self.entries.push(AccessControlEntry { + sid, + permission, + granting: true, + }); + self + } + + /// Appends a denying entry for `sid` / `permission` (takes precedence over a + /// later grant for the same pair). + #[must_use] + pub fn deny(mut self, sid: Sid, permission: Permission) -> Self { + self.entries.push(AccessControlEntry { + sid, + permission, + granting: false, + }); + self + } + + /// Sets a parent object for inheritance. + #[must_use] + pub fn with_parent(mut self, parent: ObjectIdentity, inheriting: bool) -> Self { + self.parent = Some(parent); + self.entries_inheriting = inheriting; + self + } + + /// The object's owner. + #[must_use] + pub fn owner(&self) -> &Sid { + &self.owner + } + + /// The local decision for `permission`/`sids`, ignoring inheritance: + /// `Some(true)` granted, `Some(false)` denied, `None` no applicable entry. + fn local_decision(&self, permission: Permission, sids: &[Sid]) -> Option { + for ace in &self.entries { + if sids.contains(&ace.sid) && ace.permission.contains(permission) { + return Some(ace.granting); + } + } + None + } +} + +/// Looks up [`Acl`]s by [`ObjectIdentity`] — Spring's `AclService`. +/// +/// ACLs are handed out behind an [`Arc`] so a lookup (and each inheritance hop) +/// is a cheap reference-count bump rather than a deep copy, keeping a store's +/// lock held only briefly. +pub trait AclService: Send + Sync { + /// Reads the ACL for `object_identity`, if one exists. + fn read_acl(&self, object_identity: &ObjectIdentity) -> Option>; +} + +/// Resolves whether `sids` are granted `permission` on `object_identity`, +/// following the inheritance chain. **Default-deny**: returns `false` when no +/// applicable entry exists anywhere in the chain, when there is no ACL, or when +/// the chain is cyclic / deeper than [`MAX_INHERITANCE_DEPTH`]. +#[must_use] +pub fn is_granted( + service: &dyn AclService, + object_identity: &ObjectIdentity, + permission: Permission, + sids: &[Sid], +) -> bool { + let mut current = service.read_acl(object_identity); + for _ in 0..MAX_INHERITANCE_DEPTH { + let Some(acl) = current else { + return false; + }; + if let Some(decision) = acl.local_decision(permission, sids) { + return decision; + } + match (acl.entries_inheriting, &acl.parent) { + (true, Some(parent)) => current = service.read_acl(parent), + _ => return false, + } + } + false +} + +/// An in-memory, mutable [`AclService`] — Spring's `MutableAclService`. +#[derive(Default)] +pub struct InMemoryAclService { + acls: Mutex>>, +} + +impl InMemoryAclService { + /// Builds an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Inserts or replaces the ACL for its object identity. + pub fn save(&self, acl: Acl) { + let key = acl.object_identity.key(); + self.acls + .lock() + .expect("acl store poisoned") + .insert(key, Arc::new(acl)); + } + + /// Removes the ACL for `object_identity`, returning whether one existed. + pub fn delete(&self, object_identity: &ObjectIdentity) -> bool { + self.acls + .lock() + .expect("acl store poisoned") + .remove(&object_identity.key()) + .is_some() + } +} + +impl AclService for InMemoryAclService { + fn read_acl(&self, object_identity: &ObjectIdentity) -> Option> { + self.acls + .lock() + .expect("acl store poisoned") + .get(&object_identity.key()) + .cloned() + } +} + +/// A [`PermissionEvaluator`] backed by an [`AclService`] — Spring's +/// `AclPermissionEvaluator`. It turns the principal's identity and authorities +/// into [`Sid`]s and resolves the requested permission against the object's ACL. +/// +/// Both `PermissionEvaluator` forms are supported: +/// - **id-based** ([`has_permission_for_id`](PermissionEvaluator::has_permission_for_id)) — +/// pass the object's `(type, identifier)`; this is the natural ACL entry point. +/// - **object-based** ([`has_permission`](PermissionEvaluator::has_permission)) — +/// pass a reference to an [`ObjectIdentity`] as the target (other target types +/// deny). +pub struct AclPermissionEvaluator { + service: Arc, +} + +impl AclPermissionEvaluator { + /// Builds an evaluator over `service`. + #[must_use] + pub fn new(service: Arc) -> Self { + Self { service } + } + + /// The SIDs an authentication presents: its principal (only when + /// [authenticated](Authentication::is_authenticated)), plus a + /// [`Sid::Authority`] for every role and authority. Each bare role also + /// yields its `ROLE_`-prefixed form (and vice versa) so an ACE configured + /// either way matches. + /// + /// An unauthenticated / anonymous caller contributes **no** principal SID, so + /// it can never match a `Sid::Principal("")` / `Sid::Principal("anonymous")` + /// ACE (fail-closed); deliberate anonymous access is granted via a + /// [`Sid::Authority`] entry instead. + fn sids_for(auth: &Authentication) -> Vec { + let mut sids = Vec::new(); + if auth.is_authenticated() { + sids.push(Sid::Principal(auth.principal.clone())); + } + let mut push_authority = |value: &str| { + let sid = Sid::Authority(value.to_string()); + if !sids.contains(&sid) { + sids.push(sid); + } + }; + for role in &auth.roles { + push_authority(role); + if let Some(bare) = role.strip_prefix(ROLE_PREFIX) { + push_authority(bare); + } else { + push_authority(&format!("{ROLE_PREFIX}{role}")); + } + } + for authority in &auth.authorities { + push_authority(authority); + } + sids + } + + fn decide(&self, auth: &Authentication, oid: &ObjectIdentity, permission: &str) -> bool { + let Some(permission) = Permission::from_name(permission) else { + return false; + }; + is_granted(&*self.service, oid, permission, &Self::sids_for(auth)) + } +} + +impl PermissionEvaluator for AclPermissionEvaluator { + fn has_permission( + &self, + auth: &Authentication, + target: &dyn std::any::Any, + permission: &str, + ) -> bool { + match target.downcast_ref::() { + Some(oid) => self.decide(auth, oid, permission), + None => false, + } + } + + fn has_permission_for_id( + &self, + auth: &Authentication, + object_type: &str, + identifier: &str, + permission: &str, + ) -> bool { + let oid = ObjectIdentity::new(object_type, identifier); + self.decide(auth, &oid, permission) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ACCOUNT: &str = "com.example.BankAccount"; + + fn principal(name: &str, roles: &[&str]) -> Authentication { + Authentication { + principal: name.into(), + username: name.into(), + roles: roles.iter().map(|r| (*r).to_string()).collect(), + ..Default::default() + } + } + + #[test] + fn permission_masks_and_names() { + assert_eq!(Permission::READ.mask(), 1); + assert_eq!(Permission::ADMINISTRATION.mask(), 16); + // Cumulative mask contains its parts but not unrelated bits. + let rw = Permission::READ.union(Permission::WRITE); + assert!(rw.contains(Permission::READ)); + assert!(rw.contains(Permission::WRITE)); + assert!(!rw.contains(Permission::DELETE)); + // Name parsing is case-insensitive. + assert_eq!(Permission::from_name("READ"), Some(Permission::READ)); + assert_eq!( + Permission::from_name("admin"), + Some(Permission::ADMINISTRATION) + ); + assert_eq!(Permission::from_name("nope"), None); + } + + #[test] + fn first_matching_ace_decides_and_deny_precedes_grant() { + let oid = ObjectIdentity::new(ACCOUNT, "1"); + let alice = Sid::Principal("alice".into()); + // A deny placed before a grant for the same (sid, permission) wins. + let acl = Acl::new(oid.clone(), alice.clone()) + .deny(alice.clone(), Permission::WRITE) + .grant(alice.clone(), Permission::WRITE) + .grant(alice.clone(), Permission::READ); + assert_eq!( + acl.local_decision(Permission::WRITE, std::slice::from_ref(&alice)), + Some(false) + ); + assert_eq!(acl.local_decision(Permission::READ, &[alice]), Some(true)); + // A sid with no entry → no local decision. + assert_eq!( + acl.local_decision(Permission::READ, &[Sid::Principal("bob".into())]), + None + ); + } + + #[test] + fn service_is_granted_resolves_direct_entries() { + let svc = InMemoryAclService::new(); + let oid = ObjectIdentity::new(ACCOUNT, "1"); + let alice = Sid::Principal("alice".into()); + svc.save(Acl::new(oid.clone(), alice.clone()).grant(alice.clone(), Permission::READ)); + + assert!(is_granted( + &svc, + &oid, + Permission::READ, + std::slice::from_ref(&alice) + )); + // Permission not granted → deny. + assert!(!is_granted(&svc, &oid, Permission::WRITE, &[alice])); + // No ACL for the object → default-deny. + let other = ObjectIdentity::new(ACCOUNT, "999"); + assert!(!is_granted( + &svc, + &other, + Permission::READ, + &[Sid::Principal("alice".into())] + )); + } + + #[test] + fn inheritance_consults_the_parent_only_when_inheriting() { + let svc = InMemoryAclService::new(); + let parent = ObjectIdentity::new(ACCOUNT, "parent"); + let child = ObjectIdentity::new(ACCOUNT, "child"); + let admins = Sid::Authority("ROLE_ADMIN".into()); + + // Parent grants ROLE_ADMIN read; child has no local entry. + svc.save(Acl::new(parent.clone(), admins.clone()).grant(admins.clone(), Permission::READ)); + svc.save(Acl::new(child.clone(), admins.clone()).with_parent(parent.clone(), true)); + assert!(is_granted( + &svc, + &child, + Permission::READ, + std::slice::from_ref(&admins) + )); + + // Non-inheriting child does NOT see the parent's grant. + svc.save(Acl::new(child.clone(), admins.clone()).with_parent(parent.clone(), false)); + assert!(!is_granted(&svc, &child, Permission::READ, &[admins])); + } + + #[test] + fn inheritance_terminates_on_a_cycle() { + let svc = InMemoryAclService::new(); + let a = ObjectIdentity::new(ACCOUNT, "a"); + let b = ObjectIdentity::new(ACCOUNT, "b"); + let alice = Sid::Principal("alice".into()); + // a -> b -> a, no granting entries: resolution must terminate and deny. + svc.save(Acl::new(a.clone(), alice.clone()).with_parent(b.clone(), true)); + svc.save(Acl::new(b.clone(), alice.clone()).with_parent(a.clone(), true)); + assert!(!is_granted(&svc, &a, Permission::READ, &[alice])); + } + + #[test] + fn evaluator_grants_by_principal_and_by_authority() { + let svc = Arc::new(InMemoryAclService::new()); + let oid = ObjectIdentity::new(ACCOUNT, "1"); + svc.save( + Acl::new(oid.clone(), Sid::Principal("alice".into())) + .grant(Sid::Principal("alice".into()), Permission::READ) + .grant(Sid::Authority("ROLE_ADMIN".into()), Permission::WRITE), + ); + let eval = AclPermissionEvaluator::new(svc); + + let alice = principal("alice", &[]); + // id-based form: alice (principal) may read. + assert!(eval.has_permission_for_id(&alice, ACCOUNT, "1", "read")); + // alice has no write grant. + assert!(!eval.has_permission_for_id(&alice, ACCOUNT, "1", "write")); + + // An admin (bare role "ADMIN" → matched as ROLE_ADMIN authority) may write. + let admin = principal("carol", &["ADMIN"]); + assert!(eval.has_permission_for_id(&admin, ACCOUNT, "1", "write")); + // …but the admin is not granted read (only ROLE_ADMIN write + alice read). + assert!(!eval.has_permission_for_id(&admin, ACCOUNT, "1", "read")); + + // Object-based form with an ObjectIdentity target works too. + assert!(eval.has_permission(&alice, &oid, "read")); + // Unknown target type denies. + assert!(!eval.has_permission(&alice, &"a string", "read")); + // Unknown permission name denies. + assert!(!eval.has_permission_for_id(&alice, ACCOUNT, "1", "teleport")); + } + + #[test] + fn zero_mask_permission_is_never_granted() { + let svc = InMemoryAclService::new(); + let oid = ObjectIdentity::new(ACCOUNT, "1"); + let alice = Sid::Principal("alice".into()); + let acl = Acl::new(oid.clone(), alice.clone()).grant(alice.clone(), Permission::READ); + // A zero (no-bits) required permission matches no ACE… + assert_eq!( + acl.local_decision(Permission::from_mask(0), std::slice::from_ref(&alice)), + None + ); + svc.save(acl); + // …so it is default-denied even with a granting ACE for the sid. + assert!(!is_granted( + &svc, + &oid, + Permission::from_mask(0), + std::slice::from_ref(&alice) + )); + } + + #[test] + fn unauthenticated_and_anonymous_principals_are_denied() { + let oid = ObjectIdentity::new(ACCOUNT, "1"); + // A misconfigured ACL that grants the blank and anonymous principal SIDs. + let svc = Arc::new(InMemoryAclService::new()); + svc.save( + Acl::new(oid.clone(), Sid::Principal(String::new())) + .grant(Sid::Principal(String::new()), Permission::READ) + .grant(Sid::Principal("anonymous".into()), Permission::READ), + ); + let eval = AclPermissionEvaluator::new(svc); + // A default (empty-principal) and an explicit anonymous Authentication + // contribute no principal SID, so neither matches the misconfigured ACEs. + assert!(!eval.has_permission_for_id(&Authentication::default(), ACCOUNT, "1", "read")); + assert!(!eval.has_permission_for_id(&Authentication::anonymous(), ACCOUNT, "1", "read")); + // A real principal with a matching ACE is still granted. + let svc2 = Arc::new(InMemoryAclService::new()); + svc2.save( + Acl::new(oid, Sid::Principal("alice".into())) + .grant(Sid::Principal("alice".into()), Permission::READ), + ); + let eval2 = AclPermissionEvaluator::new(svc2); + assert!(eval2.has_permission_for_id(&principal("alice", &[]), ACCOUNT, "1", "read")); + } + + #[test] + fn cumulative_grant_satisfies_a_component_request() { + // Deliberate divergence from Spring's exact-mask default: a cumulative + // grant satisfies a request for any permission it includes. + let svc = InMemoryAclService::new(); + let oid = ObjectIdentity::new(ACCOUNT, "1"); + let alice = Sid::Principal("alice".into()); + let all = Permission::READ + .union(Permission::WRITE) + .union(Permission::ADMINISTRATION); + svc.save(Acl::new(oid.clone(), alice.clone()).grant(alice.clone(), all)); + assert!(is_granted( + &svc, + &oid, + Permission::READ, + std::slice::from_ref(&alice) + )); + assert!(is_granted( + &svc, + &oid, + Permission::ADMINISTRATION, + std::slice::from_ref(&alice) + )); + // A permission not in the cumulative mask is still denied. + assert!(!is_granted( + &svc, + &oid, + Permission::DELETE, + std::slice::from_ref(&alice) + )); + } +} diff --git a/crates/security/src/lib.rs b/crates/security/src/lib.rs index 5d87b8c..90af9aa 100644 --- a/crates/security/src/lib.rs +++ b/crates/security/src/lib.rs @@ -136,6 +136,7 @@ //! .layer(BearerLayer::new(BearerConfig::new(verifier))); //! ``` +mod acl; mod authentication; mod authentication_manager; mod bearer; @@ -167,6 +168,10 @@ mod userdetails; #[cfg(feature = "webauthn")] mod webauthn; +pub use acl::{ + is_granted, AccessControlEntry, Acl, AclPermissionEvaluator, AclService, InMemoryAclService, + ObjectIdentity, Permission, Sid, +}; pub use authentication::{ authentication_from, must_auth_from, with_authentication, Authentication, SecurityError, Verifier, VerifierFn, ANONYMOUS_ID, ROLE_PREFIX, @@ -215,7 +220,9 @@ pub use password::{ Argon2PasswordEncoder, BcryptPasswordEncoder, DelegatingPasswordEncoder, NoOpPasswordEncoder, PasswordEncoder, DEFAULT_PASSWORD_ENCODER_ID, DEFAULT_ROUNDS, }; -pub use permission::{has_permission, set_permission_evaluator, PermissionEvaluator}; +pub use permission::{ + has_permission, has_permission_for_id, set_permission_evaluator, PermissionEvaluator, +}; pub use remember_me::{ RememberMeServices, TokenBasedRememberMeServices, DEFAULT_REMEMBER_ME_SECONDS, }; diff --git a/crates/security/src/permission.rs b/crates/security/src/permission.rs index 62c17da..ab1b56c 100644 --- a/crates/security/src/permission.rs +++ b/crates/security/src/permission.rs @@ -50,6 +50,22 @@ use crate::authentication::Authentication; pub trait PermissionEvaluator: Send + Sync { /// Whether `auth` may perform `permission` on `target`. fn has_permission(&self, auth: &Authentication, target: &dyn Any, permission: &str) -> bool; + + /// Whether `auth` may perform `permission` on the domain object identified by + /// (`object_type`, `identifier`) — Spring's id-based + /// `hasPermission(authentication, targetId, targetType, permission)` overload, + /// used when the object is referenced by identity rather than loaded (the + /// natural entry point for ACL-backed evaluators). Defaults to deny + /// (fail-closed); ACL-style evaluators override it. + fn has_permission_for_id( + &self, + _auth: &Authentication, + _object_type: &str, + _identifier: &str, + _permission: &str, + ) -> bool { + false + } } /// The process-wide evaluator, set once at startup (Spring's single @@ -94,6 +110,25 @@ pub fn has_permission(auth: &Authentication, target: &T, permission: &st .is_some_and(|e| e.has_permission(auth, target, permission)) } +/// Whether `auth` may perform `permission` on the domain object identified by +/// (`object_type`, `identifier`), per the registered [`PermissionEvaluator`] — +/// Spring's id-based `hasPermission(targetId, targetType, permission)`. +/// +/// Returns `false` (deny) when no evaluator is registered. This is the form to +/// use when you hold an object's identity (type + id) rather than the loaded +/// object — e.g. an ACL-backed [`AclPermissionEvaluator`](crate::AclPermissionEvaluator). +#[must_use] +pub fn has_permission_for_id( + auth: &Authentication, + object_type: &str, + identifier: &str, + permission: &str, +) -> bool { + EVALUATOR + .get() + .is_some_and(|e| e.has_permission_for_id(auth, object_type, identifier, permission)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub index a9b873a..83b04a7 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 3d7e5e5..3fd34d4 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 194a5e3..9329bd9 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 631223e..5251a73 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 d893492..35ce16a 100644 --- a/docs/book/src-es/14a-spring-security-parity.md +++ b/docs/book/src-es/14a-spring-security-parity.md @@ -47,7 +47,8 @@ En la columna **Estado**, :status-supported: indica una función soportada, | 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 | | 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) | +| ACL / seguridad de objetos de dominio | :status-supported: | `Acl` / `AccessControlEntry` / `Permission` / `Sid` / `ObjectIdentity`, `AclService` + `InMemoryAclService`, y `AclPermissionEvaluator` que conecta `hasPermission(...)` con ACLs por objeto (`spring-security-acl`) | +| SAML2 (`saml2Login()`) | :status-planned: | Implementación del lado SP (`RelyingPartyRegistration` + verificación de respuestas firmadas) tras una característica opcional; el *release* depende de una pila XML-Security de Rust estable | ## Comportamientos fieles a Spring que conviene conocer @@ -218,6 +219,33 @@ adversarial previa al *release*: 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. +## Seguridad de objetos de dominio (ACL) + +Donde el [`PermissionEvaluator`](#) responde "¿puede este principal hacer X +sobre este objeto?" con código arbitrario, una **ACL** lo responde a partir de +listas de control de acceso por objeto — el análogo en Rust de +`spring-security-acl`. Es Rust puro (sin dependencias extra): + +- **`Permission`** — la máscara de bits `BasePermission` (`READ`, `WRITE`, + `CREATE`, `DELETE`, `ADMINISTRATION`), combinable en una máscara acumulativa. +- **`Sid`** — una identidad de seguridad: un `Principal` (usuario) o una + `Authority` (rol), el `PrincipalSid` / `GrantedAuthoritySid` de Spring. +- **`ObjectIdentity`** — la clave `(tipo, identificador)` de un objeto de dominio. +- **`Acl`** — un propietario, más **`AccessControlEntry`s** ordenadas (conceden o + deniegan un permiso a un sid), más un padre opcional para **herencia**. +- **`AclService`** / **`InMemoryAclService`** — buscan una ACL por identidad + (el `MutableAclService` de Spring). +- **`AclPermissionEvaluator`** — conecta un `AclService` con la expresión + `hasPermission(...)` de seguridad de métodos, por referencia y por `(tipo, id)`. + +La evaluación es **denegar por defecto**: un permiso se concede solo cuando se +encuentra una entrada *concedente* aplicable, local o subiendo por la cadena de +herencia; **gana la primera entrada que coincide con `(sid, permiso)`**, de modo +que un *deny* colocado antes de un *grant* tiene prioridad (la +`DefaultPermissionGrantingStrategy` de Spring). El recorrido de herencia está +acotado, así que una cadena de padres cíclica o demasiado profunda termina (y +deniega) en vez de quedar en bucle. + ## Hoja de ruta La paridad se entrega por niveles, cada uno un incremento: @@ -238,5 +266,8 @@ La paridad se entrega por niveles, cada uno un incremento: servidor de autorización montado sobre HTTP con metadatos RFC 8414. (El grant authorization_code del lado servidor queda como seguimiento.) 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**. + Active Directory (hecho)** — el módulo `ldap` opcional. **ACL / seguridad de + objetos de dominio (hecho)** — paridad con `spring-security-acl`, en Rust puro. + **SAML2** tiene una implementación del lado SP (registro + verificación de + respuestas firmadas) tras una característica opcional, con *release* pendiente + de una pila XML-Security de Rust estable. diff --git a/docs/book/src/14a-spring-security-parity.md b/docs/book/src/14a-spring-security-parity.md index d514fad..9cf2726 100644 --- a/docs/book/src/14a-spring-security-parity.md +++ b/docs/book/src/14a-spring-security-parity.md @@ -46,7 +46,8 @@ In the **Status** column, :status-supported: marks a supported feature, | 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 | | 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) | +| ACL / domain-object security | :status-supported: | `Acl` / `AccessControlEntry` / `Permission` / `Sid` / `ObjectIdentity`, `AclService` + `InMemoryAclService`, and `AclPermissionEvaluator` wiring `hasPermission(...)` to per-object ACLs (`spring-security-acl`) | +| SAML2 (`saml2Login()`) | :status-planned: | SP-side `RelyingPartyRegistration` + signed-response verification implemented behind an opt-in feature; release pending a stable Rust XML-Security stack | ## Spring-faithful behaviours to know @@ -207,6 +208,32 @@ adversarial review: silently authenticating with no roles, and a **malformed directory entry** is turned into a clean error rather than aborting the request. +## Domain-object security (ACL) + +Where the [`PermissionEvaluator`](#) answers "may this principal do X to this +object?" with arbitrary code, an **ACL** answers it from per-object +access-control lists — the Rust analog of `spring-security-acl`. It is pure Rust +(no extra dependencies): + +- **`Permission`** — the `BasePermission` bitmask (`READ`, `WRITE`, `CREATE`, + `DELETE`, `ADMINISTRATION`), combinable into a cumulative mask. +- **`Sid`** — a security identity: a `Principal` (username) or an `Authority` + (role), Spring's `PrincipalSid` / `GrantedAuthoritySid`. +- **`ObjectIdentity`** — a domain object's `(type, identifier)` key. +- **`Acl`** — an owner plus ordered **`AccessControlEntry`s** (grant or deny a + permission to a sid) plus an optional parent for **inheritance**. +- **`AclService`** / **`InMemoryAclService`** — look an ACL up by identity + (Spring's `MutableAclService`). +- **`AclPermissionEvaluator`** — wires an `AclService` into the method-security + `hasPermission(...)` expression, by both object reference and `(type, id)`. + +Evaluation is **default-deny**: a permission is granted only when an applicable +*granting* entry is found locally or up the inheritance chain; the **first entry +matching a `(sid, permission)` wins**, so a deny placed before a grant takes +precedence (Spring's `DefaultPermissionGrantingStrategy`). The inheritance walk +is bounded, so a cyclic or pathologically deep parent chain terminates (and +denies) rather than looping. + ## Roadmap Parity is delivered in tiers, each its own increment: @@ -226,5 +253,8 @@ Parity is delivered in tiers, each its own increment: authorization server mounted over HTTP with RFC 8414 metadata. (The server-side authorization_code grant remains a follow-up.) 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. + Active Directory (done)** — the feature-gated `ldap` module. **ACL / + domain-object security (done)** — `spring-security-acl` parity, pure Rust. + **SAML2** has an SP-side implementation (registration + signed-response + verification) behind an opt-in feature, with release pending a stable Rust + XML-Security stack.