diff --git a/CHANGELOG.md b/CHANGELOG.md index 040bb05c..ae3afae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.32 — 2026-06-19 + +**Spring Security parity — Tier 3: method-security depth.** Expression-based +method security and domain-object permissions, the SpEL-equivalent layer over +the existing `#[pre_authorize]` / `#[post_authorize]` macros. All additive (no +behaviour change to existing code). Adversarially reviewed before release. + +### Added + +- **Expression-based `#[pre_authorize]`** — a non-keyword argument is now a + boolean Rust expression evaluated *before* the body with the method's + parameters and `auth` (a `&Authentication`) in scope (Spring's + `@PreAuthorize("#id == authentication.name")`), e.g. + `#[pre_authorize(auth.has_role("ADMIN") || auth.principal == owner)]`. The + keyword rules (`authenticated`, `role`, `any_role`, `authority`, + `any_authority`) are unchanged and fully backward-compatible. Fail-closed: no + ambient context denies with `Unauthenticated`, a false expression with + `Forbidden`. +- **`PermissionEvaluator` + `has_permission`** — the Rust analog of Spring's + `PermissionEvaluator` / `hasPermission(target, permission)`. Register one + process-wide with `set_permission_evaluator`; call + `has_permission(auth, target, permission)` inside any pre/post expression. The + target is erased to `Any` so one evaluator serves every domain type by + downcasting. **Secure default: with no evaluator registered, every permission + is denied.** +- **`#[pre_filter]` / `#[post_filter]`** — collection filtering (Spring's + `@PreFilter` / `@PostFilter`). `#[post_filter(element.owner == auth.principal)]` + retains only the elements of the returned collection the predicate accepts; + `#[pre_filter(items, …)]` filters a named owned `mut` collection argument + before the body. `element` is the per-element `&T` (Spring's `filterObject`); + no ambient context denies the call with `Unauthenticated`. + +### Known limitations (roadmap) + +- `PermissionEvaluator` is a process-global set-once registry (one evaluator per + process, like Spring's single bean); there is no per-scope override. +- `#[pre_filter]` requires the targeted parameter to be an owned `mut` + collection with `retain` (e.g. `mut items: Vec`). + ## v26.6.31 — 2026-06-19 **Spring Security parity — Tier 2: the web authentication mechanisms.** The diff --git a/Cargo.lock b/Cargo.lock index 9a5b4f10..8ba3e788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,7 +1495,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "firefly-actuator", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1585,7 +1585,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "inventory", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-observability", @@ -1626,7 +1626,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-cache", @@ -1650,7 +1650,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "chrono", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-stream", "axum", @@ -1719,7 +1719,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "regex", @@ -1734,7 +1734,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.31" +version = "26.6.32" dependencies = [ "futures", "inventory", @@ -1761,7 +1761,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -1784,7 +1784,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-stream", "async-trait", @@ -1801,7 +1801,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-stream", "async-trait", @@ -1819,7 +1819,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-stream", "async-trait", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -1857,7 +1857,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1874,7 +1874,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1891,7 +1891,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1908,7 +1908,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1928,7 +1928,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "base64 0.22.1", @@ -1970,7 +1970,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-eda", @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -2003,7 +2003,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-eda", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-eda", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "base64 0.22.1", @@ -2052,7 +2052,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "http", @@ -2067,7 +2067,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2083,7 +2083,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2121,7 +2121,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2143,7 +2143,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2160,7 +2160,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2195,7 +2195,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.31" +version = "26.6.32" dependencies = [ "chrono", "serde", @@ -2207,7 +2207,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.31" +version = "26.6.32" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.31" +version = "26.6.32" dependencies = [ "chrono", "hex", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -2268,7 +2268,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2284,7 +2284,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2301,7 +2301,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2318,7 +2318,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "base64 0.22.1", @@ -2335,7 +2335,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2351,7 +2351,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -2375,7 +2375,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "chrono", @@ -2391,7 +2391,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2415,7 +2415,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -2425,7 +2425,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-stream", "firefly-kernel", @@ -2437,7 +2437,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-config", @@ -2449,7 +2449,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2469,7 +2469,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2486,7 +2486,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -2501,7 +2501,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.31" +version = "26.6.32" dependencies = [ "chrono", "firefly", @@ -2512,7 +2512,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.31" +version = "26.6.32" dependencies = [ "chrono", "firefly", @@ -2525,7 +2525,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.31" +version = "26.6.32" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2537,7 +2537,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "firefly", @@ -2555,7 +2555,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "firefly", @@ -2568,7 +2568,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2591,7 +2591,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-stream", "async-trait", @@ -2631,7 +2631,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "chrono", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.31" +version = "26.6.32" dependencies = [ "argon2", "async-trait", @@ -2683,7 +2683,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2708,7 +2708,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-session", @@ -2721,7 +2721,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-session", @@ -2733,7 +2733,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-session", @@ -2745,7 +2745,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "futures", @@ -2755,7 +2755,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "firefly", @@ -2763,7 +2763,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "firefly", @@ -2775,7 +2775,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "bytes", @@ -2791,7 +2791,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-cqrs", @@ -2805,7 +2805,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2831,7 +2831,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "firefly-cqrs", @@ -2844,7 +2844,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2856,7 +2856,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2877,7 +2877,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "firefly-kernel", @@ -2892,7 +2892,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.31" +version = "26.6.32" dependencies = [ "axum", "base64 0.22.1", @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "inventory", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.31" +version = "26.6.32" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -2938,7 +2938,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.31" +version = "26.6.32" dependencies = [ "chrono", "firefly-kernel", @@ -2949,7 +2949,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -2988,7 +2988,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", @@ -3016,7 +3016,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.31" +version = "26.6.32" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index b51b66dc..08a5c9b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.31" +version = "26.6.32" 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.31" } -firefly-kernel = { path = "crates/kernel", version = "26.6.31" } -firefly-utils = { path = "crates/utils", version = "26.6.31" } -firefly-validators = { path = "crates/validators", version = "26.6.31" } -firefly-web = { path = "crates/web", version = "26.6.31" } -firefly-config = { path = "crates/config", version = "26.6.31" } -firefly-i18n = { path = "crates/i18n", version = "26.6.31" } -firefly-cache = { path = "crates/cache", version = "26.6.31" } -firefly-observability = { path = "crates/observability", version = "26.6.31" } -firefly-data = { path = "crates/data", version = "26.6.31" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.31" } -firefly-eda = { path = "crates/eda", version = "26.6.31" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.31" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.31" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.31" } -firefly-plugins = { path = "crates/plugins", version = "26.6.31" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.31" } -firefly-actuator = { path = "crates/actuator", version = "26.6.31" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.31" } -firefly-resilience = { path = "crates/resilience", version = "26.6.31" } -firefly-security = { path = "crates/security", version = "26.6.31" } -firefly-migrations = { path = "crates/migrations", version = "26.6.31" } -firefly-openapi = { path = "crates/openapi", version = "26.6.31" } -firefly-sse = { path = "crates/sse", version = "26.6.31" } -firefly-transactional = { path = "crates/transactional", version = "26.6.31" } -firefly-testkit = { path = "crates/testkit", version = "26.6.31" } -firefly-client = { path = "crates/client", version = "26.6.31" } -firefly-config-server = { path = "crates/config-server", version = "26.6.31" } -firefly-idp = { path = "crates/idp", version = "26.6.31" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.31" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.31" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.31" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.31" } -firefly-ecm = { path = "crates/ecm", version = "26.6.31" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.31" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.31" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.31" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.31" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.31" } -firefly-notifications = { path = "crates/notifications", version = "26.6.31" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.31" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.31" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.31" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.31" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.31" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.31" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.31" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.31" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.31" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.31" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.31" } -firefly-admin = { path = "crates/admin", version = "26.6.31" } -firefly-aop = { path = "crates/aop", version = "26.6.31" } -firefly-cli = { path = "crates/cli", version = "26.6.31" } -firefly-container = { path = "crates/container", version = "26.6.31" } -firefly-session = { path = "crates/session", version = "26.6.31" } -firefly-shell = { path = "crates/shell", version = "26.6.31" } -firefly-websocket = { path = "crates/websocket", version = "26.6.31" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.31" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.31" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.31" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.31" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.31" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.31" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.31" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.31" } -firefly = { path = "crates/firefly", version = "26.6.31" } -firefly-macros = { path = "crates/macros", version = "26.6.31" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.31" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.31" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.31" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.31" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.31" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.31" } +firefly-reactive = { path = "crates/reactive", version = "26.6.32" } +firefly-kernel = { path = "crates/kernel", version = "26.6.32" } +firefly-utils = { path = "crates/utils", version = "26.6.32" } +firefly-validators = { path = "crates/validators", version = "26.6.32" } +firefly-web = { path = "crates/web", version = "26.6.32" } +firefly-config = { path = "crates/config", version = "26.6.32" } +firefly-i18n = { path = "crates/i18n", version = "26.6.32" } +firefly-cache = { path = "crates/cache", version = "26.6.32" } +firefly-observability = { path = "crates/observability", version = "26.6.32" } +firefly-data = { path = "crates/data", version = "26.6.32" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.32" } +firefly-eda = { path = "crates/eda", version = "26.6.32" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.32" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.32" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.32" } +firefly-plugins = { path = "crates/plugins", version = "26.6.32" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.32" } +firefly-actuator = { path = "crates/actuator", version = "26.6.32" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.32" } +firefly-resilience = { path = "crates/resilience", version = "26.6.32" } +firefly-security = { path = "crates/security", version = "26.6.32" } +firefly-migrations = { path = "crates/migrations", version = "26.6.32" } +firefly-openapi = { path = "crates/openapi", version = "26.6.32" } +firefly-sse = { path = "crates/sse", version = "26.6.32" } +firefly-transactional = { path = "crates/transactional", version = "26.6.32" } +firefly-testkit = { path = "crates/testkit", version = "26.6.32" } +firefly-client = { path = "crates/client", version = "26.6.32" } +firefly-config-server = { path = "crates/config-server", version = "26.6.32" } +firefly-idp = { path = "crates/idp", version = "26.6.32" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.32" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.32" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.32" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.32" } +firefly-ecm = { path = "crates/ecm", version = "26.6.32" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.32" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.32" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.32" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.32" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.32" } +firefly-notifications = { path = "crates/notifications", version = "26.6.32" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.32" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.32" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.32" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.32" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.32" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.32" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.32" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.32" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.32" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.32" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.32" } +firefly-admin = { path = "crates/admin", version = "26.6.32" } +firefly-aop = { path = "crates/aop", version = "26.6.32" } +firefly-cli = { path = "crates/cli", version = "26.6.32" } +firefly-container = { path = "crates/container", version = "26.6.32" } +firefly-session = { path = "crates/session", version = "26.6.32" } +firefly-shell = { path = "crates/shell", version = "26.6.32" } +firefly-websocket = { path = "crates/websocket", version = "26.6.32" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.32" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.32" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.32" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.32" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.32" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.32" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.32" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.32" } +firefly = { path = "crates/firefly", version = "26.6.32" } +firefly-macros = { path = "crates/macros", version = "26.6.32" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.32" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.32" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.32" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.32" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.32" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.32" } # ---- 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 d2303f63..94ef2199 100644 --- a/MODULES.md +++ b/MODULES.md @@ -17,7 +17,7 @@ declarative macros instead of hand-rolled builder wiring. | Crate | What it provides | |-------|------------------| | [`firefly`](crates/firefly/README.md) | The **one-dependency facade**: `use firefly::prelude::*;` brings the whole framework into scope — `Bus`, `Container`, `Scheduler`, `Saga`/`Step`, `Application`/`ShutdownHandle`, `Core`/`CoreConfig`, `WebResult`/`WebError`/`problem_response`, `FireflyError`/`FireflyResult`, `Mono`/`Flux` — plus every macro. Ergonomic per-crate aliases (`firefly::cqrs`, `firefly::web`, …), the hidden `__rt` macro-contract path, and feature-gated heavy adapters (`data-sqlx`, `data-mongodb`, `eda-*`, `cache-*`, `admin`, `full`) | -| [`firefly-macros`](crates/macros/README.md) | The **declarative service layer** (Spring annotations / pyfly decorators): `#[derive(Command)]` / `#[derive(Query)]` (→ `impl Message`), `#[command_handler]` / `#[query_handler]` (→ `register_(bus)`), `#[derive(Component)]` / `#[derive(Service)]` / `#[derive(Repository)]` + `register_all!` (→ DI registration), `#[scheduled]` (→ `schedule_(scheduler)`), `#[rest_controller]` + `#[get/post/put/delete/patch]` (→ `routes(state) -> axum::Router`), `#[derive(DomainEvent)]` / `#[derive(AggregateRoot)]`, `#[event_listener]` (→ `subscribe_(broker)`). Generated code targets the `firefly` facade's `__rt` path | +| [`firefly-macros`](crates/macros/README.md) | The **declarative service layer** (Spring annotations / pyfly decorators): `#[derive(Command)]` / `#[derive(Query)]` (→ `impl Message`), `#[command_handler]` / `#[query_handler]` (→ `register_(bus)`), `#[derive(Component)]` / `#[derive(Service)]` / `#[derive(Repository)]` + `register_all!` (→ DI registration), `#[scheduled]` (→ `schedule_(scheduler)`), `#[rest_controller]` + `#[get/post/put/delete/patch]` (→ `routes(state) -> axum::Router`), `#[derive(DomainEvent)]` / `#[derive(AggregateRoot)]`, `#[event_listener]` (→ `subscribe_(broker)`), method security `#[pre_authorize]` / `#[post_authorize]` (keyword rules **or** SpEL-style expressions over arguments + principal) and `#[pre_filter]` / `#[post_filter]` collection filtering. Generated code targets the `firefly` facade's `__rt` path | ## 01 — Foundational @@ -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`), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + authorization server), **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 + authorization server), **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-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/macros/src/lib.rs b/crates/macros/src/lib.rs index 0ceb875b..4baa2f08 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -629,6 +629,45 @@ pub fn post_authorize(args: TokenStream, item: TokenStream) -> TokenStream { emit(method_security::post_authorize_impl(args.into(), item)) } +/// Filters a method's *returned collection* — Spring Security's `@PostFilter`. +/// +/// The `async fn` body runs first; then each element of the returned collection +/// (a `Vec` or any type with `retain(impl FnMut(&T) -> bool)`) is kept only +/// when the boolean expression is true, with `element` (a `&T`, the +/// `filterObject`) and `auth` (a `&Authentication`) in scope. The function must +/// return `Result` with `E: From`; no +/// caller present denies with `Unauthenticated`. +/// +/// ```ignore +/// #[firefly::post_filter(element.owner == auth.principal)] +/// async fn list(&self) -> Result, MyError> { /* ... */ } +/// ``` +#[proc_macro_attribute] +pub fn post_filter(args: TokenStream, item: TokenStream) -> TokenStream { + let item = parse_macro_input!(item as ItemFn); + emit(method_security::post_filter_impl(args.into(), item)) +} + +/// Filters a method's *collection argument* before it runs — Spring Security's +/// `@PreFilter`. +/// +/// Names an owned `mut` collection parameter and a predicate; before the body +/// runs, the parameter retains only the elements for which the expression is +/// true, with `element` (a `&T`) and `auth` (a `&Authentication`) in scope. The +/// function must return `Result<_, E>` with +/// `E: From`; no caller present denies with +/// `Unauthenticated`. +/// +/// ```ignore +/// #[firefly::pre_filter(orders, element.owner == auth.principal)] +/// async fn ingest(&self, mut orders: Vec) -> Result<(), MyError> { /* ... */ } +/// ``` +#[proc_macro_attribute] +pub fn pre_filter(args: TokenStream, item: TokenStream) -> TokenStream { + let item = parse_macro_input!(item as ItemFn); + emit(method_security::pre_filter_impl(args.into(), item)) +} + /// Derives a fluent builder — Lombok's `@Builder`. /// /// `T::builder().field(v)…​.build()` returns `Result`; required diff --git a/crates/macros/src/method_security.rs b/crates/macros/src/method_security.rs index 38acf4fa..6a168d75 100644 --- a/crates/macros/src/method_security.rs +++ b/crates/macros/src/method_security.rs @@ -24,19 +24,30 @@ use proc_macro2::TokenStream; use quote::quote; -use syn::{Expr, ItemFn, Lit, Meta, ReturnType}; +use syn::parse::Parser; +use syn::punctuated::Punctuated; +use syn::{Expr, ItemFn, Lit, Meta, ReturnType, Token}; use crate::common::Facade; -/// Expands `#[pre_authorize()]` on a function that returns +/// Expands `#[pre_authorize()]` on a function that returns /// `Result` where `E: From`. /// -/// The rule is one of: `authenticated` (the default when the attribute is -/// empty), `role = "X"`, `any_role = ["A", "B"]`, `authority = "X"`, or -/// `any_authority = ["A", "B"]`. +/// The argument is either a **keyword rule** — `authenticated` (the default +/// when the attribute is empty), `role = "X"`, `any_role = ["A", "B"]`, +/// `authority = "X"`, or `any_authority = ["A", "B"]` — or, for anything else, a +/// boolean **Rust expression** evaluated *before* the body with the function's +/// parameters and `auth` (a `&Authentication`) in scope (Spring's SpEL +/// `@PreAuthorize("#id == authentication.name")`). A false expression denies +/// with `Forbidden`; no ambient context denies with `Unauthenticated`. +/// +/// The expression must **borrow**, not move, its parameters (it runs before the +/// body, which still needs them) — write reference comparisons +/// (`auth.principal == *id`), not by-value calls that consume an argument. A +/// parameter named `auth` is rejected at compile time (it would shadow the +/// injected principal binding); rename it. pub(crate) fn pre_authorize_impl(args: TokenStream, mut func: ItemFn) -> syn::Result { let sec = Facade::default().security(); - let rule = parse_rule(&sec, args)?; if matches!(func.sig.output, ReturnType::Default) { return Err(syn::Error::new_spanned( @@ -47,12 +58,39 @@ pub(crate) fn pre_authorize_impl(args: TokenStream, mut func: ItemFn) -> syn::Re } let block = &func.block; - // The `?` propagates the denial through `From`; the granted - // `Authentication` it returns is intentionally discarded. - let new_block = quote! { - { - #sec::check_access(&#rule)?; - #block + let new_block = match classify_rule(&sec, args.clone())? { + // Keyword rule: `?` propagates the denial through `From`; + // the granted `Authentication` it returns is intentionally discarded. + Some(rule) => quote! { + { + #sec::check_access(&#rule)?; + #block + } + }, + // Expression form: bind the caller (deny-closed if absent), then + // evaluate the boolean expression with the parameters and `auth` bound. + None => { + // The expression sees a framework-injected `auth` binding; reject a + // colliding parameter rather than silently shadowing it (which would + // make the rule read the principal instead of the argument). + reject_reserved_params(&func, &["auth", "__auth", "__granted"], "#[pre_authorize]")?; + let expr: Expr = syn::parse2(args)?; + quote! { + { + let __auth = #sec::current_authentication() + .ok_or(#sec::SecurityError::Unauthenticated)?; + let __granted: bool = { + let auth = &__auth; + #expr + }; + if !__granted { + return ::core::result::Result::Err(::core::convert::From::from( + #sec::SecurityError::Forbidden, + )); + } + #block + } + } } }; func.block = syn::parse2(new_block)?; @@ -67,6 +105,12 @@ pub(crate) fn pre_authorize_impl(args: TokenStream, mut func: ItemFn) -> syn::Re /// Spring `returnObject`) and `auth` (a `&Authentication` of the caller). When /// it is `false` the call is denied with `Forbidden` and the value discarded; /// when no security context is active the call is `Unauthenticated`. +/// +/// The body runs inside an `async move` block, so the predicate should reference +/// only `result` and `auth`. Referencing another method parameter the body also +/// uses works only if it is `Copy` (a non-`Copy` parameter is moved into the +/// body and cannot be borrowed afterwards). Parameters named `result` or `auth` +/// are rejected at compile time to prevent silent shadowing. pub(crate) fn post_authorize_impl(args: TokenStream, mut func: ItemFn) -> syn::Result { let sec = Facade::default().security(); @@ -91,6 +135,13 @@ pub(crate) fn post_authorize_impl(args: TokenStream, mut func: ItemFn) -> syn::R e.g. #[post_authorize(result.owner == auth.principal)]", )); } + // The expression sees framework-injected `result` / `auth` bindings; reject + // colliding parameters rather than silently authorizing the wrong value. + reject_reserved_params( + &func, + &["result", "auth", "__value", "__granted", "__err"], + "#[post_authorize]", + )?; let check: Expr = syn::parse2(args)?; let block = &func.block; @@ -126,24 +177,191 @@ pub(crate) fn post_authorize_impl(args: TokenStream, mut func: ItemFn) -> syn::R Ok(quote!(#func)) } -/// Parses the `#[pre_authorize(...)]` attribute into an `AccessRule` -/// constructor expression rooted at the security facade path `#sec`. -fn parse_rule(sec: &TokenStream, args: TokenStream) -> syn::Result { +/// Expands `#[post_filter()]` on an `async fn` returning `Result` +/// where `C` is a collection with `retain(impl FnMut(&T) -> bool)` (e.g. +/// `Vec`) and `E: From` — Spring's +/// `@PostFilter("filterObject.owner == authentication.name")`. +/// +/// After a successful call, each element is retained only when `` (a +/// boolean over `element`, a `&T`, and `auth`, a `&Authentication`) is true; the +/// rest are dropped from the returned collection. No ambient context denies the +/// whole call with `Unauthenticated`. +/// +/// The body runs inside an `async move` block, so the predicate should reference +/// only `element` and `auth` (a non-`Copy` parameter the body also uses is moved +/// and cannot be borrowed in the predicate). Parameters named `element` or +/// `auth` are rejected at compile time to prevent silent shadowing. +pub(crate) fn post_filter_impl(args: TokenStream, mut func: ItemFn) -> syn::Result { + let sec = Facade::default().security(); + + if func.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + &func.sig, + "#[post_filter] requires an `async fn` — the body is awaited before its result is \ + filtered", + )); + } + if matches!(func.sig.output, ReturnType::Default) { + return Err(syn::Error::new_spanned( + &func.sig, + "#[post_filter] requires an `async fn` returning `Result` where `C` is a \ + collection (e.g. `Vec`) and `E: From`", + )); + } if args.is_empty() { - return Ok(quote!(#sec::AccessRule::Authenticated)); + return Err(syn::Error::new_spanned( + &func.sig, + "#[post_filter()] needs a boolean expression over `element` (a `&T`) and `auth`, \ + e.g. #[post_filter(element.owner == auth.principal)]", + )); } - let meta: Meta = syn::parse2(args)?; + // The predicate sees framework-injected `element` / `auth` bindings; reject + // colliding parameters rather than silently shadowing them. + reject_reserved_params( + &func, + &["element", "auth", "__collection", "__auth", "__err"], + "#[post_filter]", + )?; + let predicate: Expr = syn::parse2(args)?; + + let block = &func.block; + let new_block = quote! { + { + match (async move #block).await { + ::core::result::Result::Ok(mut __collection) => { + match #sec::current_authentication() { + ::core::option::Option::Some(__auth) => { + { + let auth = &__auth; + __collection.retain(|element| { #predicate }); + } + ::core::result::Result::Ok(__collection) + } + ::core::option::Option::None => ::core::result::Result::Err( + ::core::convert::From::from(#sec::SecurityError::Unauthenticated), + ), + } + } + __err => __err, + } + } + }; + func.block = syn::parse2(new_block)?; + Ok(quote!(#func)) +} + +/// Expands `#[pre_filter(, )]` — Spring's +/// `@PreFilter("filterObject...")`. Filters the named **owned `mut`** +/// collection parameter `` *before* the body runs, retaining only the +/// elements for which `` (over `element`, a `&T`, and `auth`) is true. +/// +/// The function must return `Result<_, E>` with +/// `E: From`; no ambient context denies with +/// `Unauthenticated`. Declare the parameter `mut`, e.g. +/// `async fn ingest(&self, mut items: Vec) -> Result<(), E>`. +pub(crate) fn pre_filter_impl(args: TokenStream, mut func: ItemFn) -> syn::Result { + let sec = Facade::default().security(); + + if matches!(func.sig.output, ReturnType::Default) { + return Err(syn::Error::new_spanned( + &func.sig, + "#[pre_filter] requires a function returning `Result<_, E>` where \ + `E: From`", + )); + } + + // Parse `, `: exactly two comma-separated expressions, the + // first a bare parameter identifier. + let parsed = Punctuated::::parse_terminated.parse2(args)?; + if parsed.len() != 2 { + return Err(syn::Error::new_spanned( + &func.sig, + "#[pre_filter(, )] needs the collection parameter to filter and a \ + boolean expression over `element` and `auth`, e.g. \ + #[pre_filter(items, element.owner == auth.principal)]", + )); + } + let param = + match &parsed[0] { + Expr::Path(p) if p.path.get_ident().is_some() => p.path.get_ident().unwrap().clone(), + other => return Err(syn::Error::new_spanned( + other, + "the first argument to #[pre_filter] must be a parameter name (a bare identifier)", + )), + }; + let predicate = &parsed[1]; + + // The predicate sees framework-injected `element` / `auth` bindings (and the + // generated `__auth`); reject colliding parameters — including the filtered + // one — rather than silently shadowing them. + reject_reserved_params(&func, &["element", "auth", "__auth"], "#[pre_filter]")?; + + let block = &func.block; + let new_block = quote! { + { + let __auth = #sec::current_authentication() + .ok_or(#sec::SecurityError::Unauthenticated)?; + { + let auth = &__auth; + #param.retain(|element| { #predicate }); + } + #block + } + }; + func.block = syn::parse2(new_block)?; + Ok(quote!(#func)) +} + +/// Rejects a function parameter whose name collides with a binding the macro +/// injects into the rule/predicate scope (e.g. `auth`, `result`, `element`, or +/// an internal `__`-temporary). Without this, ordinary lexical shadowing would +/// let the generated check read the framework value instead of the parameter +/// the author intended — a silent authorization trap. The fix is to fail loudly +/// at expansion time and ask the author to rename. +fn reject_reserved_params(func: &ItemFn, reserved: &[&str], ctx: &str) -> syn::Result<()> { + for input in &func.sig.inputs { + let syn::FnArg::Typed(typed) = input else { + continue; + }; + if let syn::Pat::Ident(pat_ident) = &*typed.pat { + let name = pat_ident.ident.to_string(); + if reserved.contains(&name.as_str()) { + return Err(syn::Error::new_spanned( + &pat_ident.ident, + format!( + "{ctx} injects a binding named `{name}` into the authorization \ + expression; rename this parameter so the rule cannot silently read the \ + framework value instead of your argument", + ), + )); + } + } + } + Ok(()) +} + +/// Classifies the `#[pre_authorize(...)]` argument: `Some(rule)` for a known +/// keyword rule (an `AccessRule` constructor rooted at `#sec`), or `None` when +/// the argument is not a keyword rule and should be treated as a boolean +/// expression. Errors only for a *malformed* keyword rule (e.g. `role = 42`). +fn classify_rule(sec: &TokenStream, args: TokenStream) -> syn::Result> { + if args.is_empty() { + return Ok(Some(quote!(#sec::AccessRule::Authenticated))); + } + // A keyword rule parses as a `Meta`; anything that does not (e.g. + // `auth.principal == id`) falls through to the expression form. + let Ok(meta) = syn::parse2::(args) else { + return Ok(None); + }; match &meta { - // `#[pre_authorize(authenticated)]` + // `#[pre_authorize(authenticated)]` — any other bare path is an + // expression (e.g. a boolean variable/const in scope). Meta::Path(path) if path.is_ident("authenticated") => { - Ok(quote!(#sec::AccessRule::Authenticated)) + Ok(Some(quote!(#sec::AccessRule::Authenticated))) } - Meta::Path(path) => Err(syn::Error::new_spanned( - path, - "unknown rule; use `authenticated`, `role = \"..\"`, `any_role = [..]`, \ - `authority = \"..\"`, or `any_authority = [..]`", - )), - // `key = value` forms. + Meta::Path(_) => Ok(None), + // `key = value` forms. An unknown key is treated as an expression + // (so e.g. `a == b` — already not a Meta — never reaches here). Meta::NameValue(nv) => { let key = nv .path @@ -153,33 +371,26 @@ fn parse_rule(sec: &TokenStream, args: TokenStream) -> syn::Result match key.as_str() { "role" => { let s = expect_str(&nv.value)?; - Ok(quote!(#sec::AccessRule::Role(#s))) + Ok(Some(quote!(#sec::AccessRule::Role(#s)))) } "authority" => { let s = expect_str(&nv.value)?; - Ok(quote!(#sec::AccessRule::Authority(#s))) + Ok(Some(quote!(#sec::AccessRule::Authority(#s)))) } "any_role" => { let items = expect_str_array(&nv.value)?; - Ok(quote!(#sec::AccessRule::AnyRole(&[#(#items),*]))) + Ok(Some(quote!(#sec::AccessRule::AnyRole(&[#(#items),*])))) } "any_authority" => { let items = expect_str_array(&nv.value)?; - Ok(quote!(#sec::AccessRule::AnyAuthority(&[#(#items),*]))) + Ok(Some(quote!(#sec::AccessRule::AnyAuthority(&[#(#items),*])))) } - other => Err(syn::Error::new_spanned( - &nv.path, - format!( - "unknown rule key `{other}`; use `role`, `any_role`, `authority`, \ - or `any_authority`" - ), - )), + _ => Ok(None), } } - Meta::List(list) => Err(syn::Error::new_spanned( - list, - "expected `authenticated` or `key = value`, not a nested list", - )), + // A nested list is not a keyword rule (e.g. a call expression like + // `has_permission(...)`); treat as an expression. + Meta::List(_) => Ok(None), } } diff --git a/crates/macros/tests/method_security.rs b/crates/macros/tests/method_security.rs index 5c2c7d69..5ee29225 100644 --- a/crates/macros/tests/method_security.rs +++ b/crates/macros/tests/method_security.rs @@ -114,6 +114,95 @@ async fn pre_authorize_on_method_uses_authority() { assert_eq!(denied, Err(SvcErr::Denied(SecurityError::Forbidden))); } +// --- pre_authorize: SpEL-style expression over arguments + principal ------- + +/// Spring's `@PreAuthorize("#id == authentication.name")` — the caller may act +/// only on their own id. A non-keyword argument is a boolean Rust expression +/// evaluated with the method's parameters and `auth` (`&Authentication`) bound. +#[firefly::pre_authorize(auth.principal == id)] +async fn read_account(id: &str) -> Result { + Ok(format!("account:{id}")) +} + +/// Combines a role check with an ownership check over an argument. +#[firefly::pre_authorize(auth.has_role("ADMIN") || auth.principal == owner)] +async fn edit_doc(owner: &str) -> Result<&'static str, SvcErr> { + Ok("edited") +} + +#[tokio::test] +async fn pre_authorize_expression_binds_arguments_and_principal() { + // alice reading her own account → allowed. + let ok = with_authentication_scope(principal("alice", &[], &[]), read_account("alice")).await; + assert_eq!(ok.unwrap(), "account:alice"); + // alice reading bob's account → forbidden. + let denied = with_authentication_scope(principal("alice", &[], &[]), read_account("bob")).await; + assert_eq!(denied, Err(SvcErr::Denied(SecurityError::Forbidden))); + // No ambient context → unauthenticated (the principal binding fails closed). + assert_eq!( + read_account("alice").await, + Err(SvcErr::Denied(SecurityError::Unauthenticated)) + ); +} + +#[tokio::test] +async fn pre_authorize_expression_combines_role_and_ownership() { + // The owner may edit their own doc. + let own = with_authentication_scope(principal("alice", &[], &[]), edit_doc("alice")).await; + assert_eq!(own, Ok("edited")); + // An ADMIN may edit anyone's doc. + let admin = + with_authentication_scope(principal("root", &["ADMIN"], &[]), edit_doc("bob")).await; + assert_eq!(admin, Ok("edited")); + // A non-owner non-admin is forbidden. + let denied = with_authentication_scope(principal("eve", &[], &[]), edit_doc("bob")).await; + assert_eq!(denied, Err(SvcErr::Denied(SecurityError::Forbidden))); +} + +// --- pre_authorize: hasPermission via a PermissionEvaluator ---------------- + +struct Account { + owner: String, +} + +/// Grants `read` on an `Account` to its owner — Spring's `PermissionEvaluator`. +struct AccountPermissions; +impl firefly::security::PermissionEvaluator for AccountPermissions { + fn has_permission( + &self, + auth: &Authentication, + target: &dyn std::any::Any, + permission: &str, + ) -> bool { + target + .downcast_ref::() + .is_some_and(|a| permission == "read" && a.owner == auth.principal) + } +} + +/// Spring's `@PreAuthorize("hasPermission(#account, 'read')")` — the expression +/// form calls the registered evaluator with the bound `auth` and an argument. +#[firefly::pre_authorize(firefly::security::has_permission(auth, account, "read"))] +async fn read_statement(account: &Account) -> Result<&'static str, SvcErr> { + Ok("statement") +} + +#[tokio::test] +async fn pre_authorize_has_permission_consults_the_evaluator() { + // This is the only test in this binary that registers the evaluator. + let _ = firefly::security::set_permission_evaluator(std::sync::Arc::new(AccountPermissions)); + + let acct = Account { + owner: "alice".into(), + }; + // The owner may read. + let ok = with_authentication_scope(principal("alice", &[], &[]), read_statement(&acct)).await; + assert_eq!(ok, Ok("statement")); + // A non-owner is forbidden. + let denied = with_authentication_scope(principal("bob", &[], &[]), read_statement(&acct)).await; + assert_eq!(denied, Err(SvcErr::Denied(SecurityError::Forbidden))); +} + // --- post_authorize: returnObject ownership check -------------------------- #[derive(Debug, Clone, PartialEq)] @@ -146,3 +235,64 @@ async fn post_authorize_filters_on_return_value() { Err(SvcErr::Denied(SecurityError::Unauthenticated)) ); } + +// --- post_filter / pre_filter: collection filtering ------------------------ + +#[derive(Debug, Clone, PartialEq)] +struct Owned { + owner: String, +} + +fn owned(owner: &str) -> Owned { + Owned { + owner: owner.into(), + } +} + +/// Spring's `@PostFilter("filterObject.owner == authentication.name")` — keep +/// only the elements the caller owns. +#[firefly::post_filter(element.owner == auth.principal)] +async fn list_docs() -> Result, SvcErr> { + Ok(vec![owned("alice"), owned("bob"), owned("alice")]) +} + +#[tokio::test] +async fn post_filter_retains_only_owned_elements() { + // alice sees only her own rows. + let mine = with_authentication_scope(principal("alice", &[], &[]), list_docs()) + .await + .unwrap(); + assert_eq!(mine, vec![owned("alice"), owned("alice")]); + // bob sees only his (one). + let bobs = with_authentication_scope(principal("bob", &[], &[]), list_docs()) + .await + .unwrap(); + assert_eq!(bobs, vec![owned("bob")]); + // No ambient context → unauthenticated (the whole call fails closed). + assert_eq!( + list_docs().await, + Err(SvcErr::Denied(SecurityError::Unauthenticated)) + ); +} + +/// Spring's `@PreFilter` — drop the elements the caller does not own from the +/// `mut` argument before the body runs. +#[firefly::pre_filter(items, element.owner == auth.principal)] +async fn ingest(mut items: Vec) -> Result, SvcErr> { + Ok(items) +} + +#[tokio::test] +async fn pre_filter_drops_unowned_arguments_before_body() { + let input = vec![owned("alice"), owned("bob"), owned("carol")]; + // The body only ever sees alice's elements. + let kept = with_authentication_scope(principal("alice", &[], &[]), ingest(input.clone())) + .await + .unwrap(); + assert_eq!(kept, vec![owned("alice")]); + // No ambient context → unauthenticated, body never runs. + assert_eq!( + ingest(input).await, + Err(SvcErr::Denied(SecurityError::Unauthenticated)) + ); +} diff --git a/crates/macros/tests/ui/fail/post_authorize_param_collision.rs b/crates/macros/tests/ui/fail/post_authorize_param_collision.rs new file mode 100644 index 00000000..6022013f --- /dev/null +++ b/crates/macros/tests/ui/fail/post_authorize_param_collision.rs @@ -0,0 +1,19 @@ +// #[post_authorize] must reject a parameter named `result`: it would silently +// shadow the framework-injected return-value binding, authorizing against the +// wrong value (a fail-open trap). + +use firefly::security::SecurityError; + +struct E; +impl From for E { + fn from(_: SecurityError) -> Self { + E + } +} + +#[firefly::post_authorize(result == 1)] +async fn f(result: i32) -> Result { + Ok(result) +} + +fn main() {} diff --git a/crates/macros/tests/ui/fail/post_authorize_param_collision.stderr b/crates/macros/tests/ui/fail/post_authorize_param_collision.stderr new file mode 100644 index 00000000..9c8ff49a --- /dev/null +++ b/crates/macros/tests/ui/fail/post_authorize_param_collision.stderr @@ -0,0 +1,5 @@ +error: #[post_authorize] injects a binding named `result` into the authorization expression; rename this parameter so the rule cannot silently read the framework value instead of your argument + --> tests/ui/fail/post_authorize_param_collision.rs:15:12 + | +15 | async fn f(result: i32) -> Result { + | ^^^^^^ diff --git a/crates/macros/tests/ui/fail/pre_authorize_param_collision.rs b/crates/macros/tests/ui/fail/pre_authorize_param_collision.rs new file mode 100644 index 00000000..149e2bd0 --- /dev/null +++ b/crates/macros/tests/ui/fail/pre_authorize_param_collision.rs @@ -0,0 +1,20 @@ +// #[pre_authorize] must reject a parameter named `auth`: it would silently +// shadow the framework-injected principal binding, so the rule would read the +// principal instead of the argument. + +use firefly::security::SecurityError; + +struct E; +impl From for E { + fn from(_: SecurityError) -> Self { + E + } +} + +#[firefly::pre_authorize(auth.principal == owner)] +async fn f(auth: &str, owner: &str) -> Result<(), E> { + let _ = (auth, owner); + Ok(()) +} + +fn main() {} diff --git a/crates/macros/tests/ui/fail/pre_authorize_param_collision.stderr b/crates/macros/tests/ui/fail/pre_authorize_param_collision.stderr new file mode 100644 index 00000000..3b52dab3 --- /dev/null +++ b/crates/macros/tests/ui/fail/pre_authorize_param_collision.stderr @@ -0,0 +1,5 @@ +error: #[pre_authorize] injects a binding named `auth` into the authorization expression; rename this parameter so the rule cannot silently read the framework value instead of your argument + --> tests/ui/fail/pre_authorize_param_collision.rs:15:12 + | +15 | async fn f(auth: &str, owner: &str) -> Result<(), E> { + | ^^^^ diff --git a/crates/macros/tests/ui/fail/pre_authorize_role_not_string.rs b/crates/macros/tests/ui/fail/pre_authorize_role_not_string.rs new file mode 100644 index 00000000..6e475171 --- /dev/null +++ b/crates/macros/tests/ui/fail/pre_authorize_role_not_string.rs @@ -0,0 +1,18 @@ +// A malformed keyword rule (`role` with a non-string value) must fail closed +// with a clear diagnostic rather than being reinterpreted as an expression. + +use firefly::security::SecurityError; + +struct E; +impl From for E { + fn from(_: SecurityError) -> Self { + E + } +} + +#[firefly::pre_authorize(role = 42)] +async fn f() -> Result<(), E> { + Ok(()) +} + +fn main() {} diff --git a/crates/macros/tests/ui/fail/pre_authorize_role_not_string.stderr b/crates/macros/tests/ui/fail/pre_authorize_role_not_string.stderr new file mode 100644 index 00000000..51483fea --- /dev/null +++ b/crates/macros/tests/ui/fail/pre_authorize_role_not_string.stderr @@ -0,0 +1,5 @@ +error: expected a string literal, e.g. role = "ADMIN" + --> tests/ui/fail/pre_authorize_role_not_string.rs:13:33 + | +13 | #[firefly::pre_authorize(role = 42)] + | ^^ diff --git a/crates/security/src/lib.rs b/crates/security/src/lib.rs index 5adc0839..3dd1b5e4 100644 --- a/crates/security/src/lib.rs +++ b/crates/security/src/lib.rs @@ -152,6 +152,7 @@ mod jwt; pub mod oauth2; mod ott; mod password; +mod permission; mod problem; mod remember_me; mod request_cache; @@ -207,6 +208,7 @@ 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 remember_me::{ RememberMeServices, TokenBasedRememberMeServices, DEFAULT_REMEMBER_ME_SECONDS, }; diff --git a/crates/security/src/permission.rs b/crates/security/src/permission.rs new file mode 100644 index 00000000..62c17dae --- /dev/null +++ b/crates/security/src/permission.rs @@ -0,0 +1,172 @@ +// 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. + +//! Domain-object permissions — the Rust analog of Spring Security's +//! `PermissionEvaluator` and the SpEL `hasPermission(target, permission)`. +//! +//! A [`PermissionEvaluator`] answers "may this principal perform `permission` on +//! this domain object?". Register one process-wide with +//! [`set_permission_evaluator`]; method-security expressions then call +//! [`has_permission`] — usable directly inside `#[pre_authorize]` / +//! `#[post_authorize]` since they bind `auth`: +//! +//! ```rust,ignore +//! #[pre_authorize(firefly_security::has_permission(auth, account, "read"))] +//! async fn read(&self, account: &Account) -> Result { /* … */ } +//! ``` +//! +//! The default — **no evaluator registered** — denies every permission +//! (fail-closed), so wiring an evaluator is a deliberate opt-in. The target is +//! erased to [`std::any::Any`] so a single registered evaluator can serve every +//! domain type by downcasting, mirroring Spring's reflective contract. The +//! erasure is sound (distinct types have distinct [`std::any::TypeId`]s, so +//! there is no type confusion), but it is *not* a compile-time guarantee that +//! the evaluator recognises the target: an unrecognised type — including the +//! accidental `&&T` shape — simply **denies** ([`has_permission`] documents the +//! footgun). Every miss fails closed. + +use std::any::Any; +use std::sync::{Arc, OnceLock}; + +use crate::authentication::Authentication; + +/// Decides whether a principal holds a `permission` on a domain object — the +/// Rust analog of Spring's `PermissionEvaluator`. +/// +/// The `target` is type-erased; an implementation downcasts it (via +/// [`Any::downcast_ref`]) to the domain types it understands and denies +/// (returns `false`) for any type or permission it does not recognise. +pub trait PermissionEvaluator: Send + Sync { + /// Whether `auth` may perform `permission` on `target`. + fn has_permission(&self, auth: &Authentication, target: &dyn Any, permission: &str) -> bool; +} + +/// The process-wide evaluator, set once at startup (Spring's single +/// `PermissionEvaluator` bean). +static EVALUATOR: OnceLock> = OnceLock::new(); + +/// Registers the process-wide [`PermissionEvaluator`]. Returns `Err` (handing +/// the rejected evaluator back) if one was already set — it is a set-once +/// startup hook, not a runtime switch. +/// +/// # Errors +/// +/// Returns the passed-in `evaluator` unchanged if an evaluator is already +/// registered. Because exactly one evaluator authorizes the whole process, an +/// `Err` means a *different* evaluator is already authoritative — handle it +/// (log/abort startup) rather than ignoring it, or two components will disagree +/// about who decides `has_permission`. +pub fn set_permission_evaluator( + evaluator: Arc, +) -> Result<(), Arc> { + EVALUATOR.set(evaluator) +} + +/// Whether `auth` may perform `permission` on `target`, per the registered +/// [`PermissionEvaluator`] — Spring's `hasPermission(target, permission)`. +/// +/// Returns `false` (deny) when no evaluator is registered, so an unconfigured +/// application fails closed. The `target` is taken by reference and erased to +/// [`Any`] for the evaluator to downcast. +/// +/// Pass a reference to the **owned** domain value (`&Account`), not a +/// reference-to-a-reference. `T` is inferred from the argument, so an accidental +/// extra `&` infers `T = &Account`, whose [`std::any::TypeId`] differs from +/// `Account`'s — the evaluator's `downcast_ref::()` then misses and the +/// call **silently denies** (fail-closed, but easy to misdiagnose). Inside a +/// `#[pre_authorize]` / `#[post_authorize]` expression, pass the parameter +/// directly (`has_permission(auth, account, "read")` where `account: &Account`). +#[must_use] +pub fn has_permission(auth: &Authentication, target: &T, permission: &str) -> bool { + EVALUATOR + .get() + .is_some_and(|e| e.has_permission(auth, target, permission)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct Account { + owner: String, + } + + /// Grants `read`/`write` on an `Account` to its owner; denies everything + /// else (unknown target type, unknown permission, non-owner). + struct OwnerPermissionEvaluator; + impl PermissionEvaluator for OwnerPermissionEvaluator { + fn has_permission( + &self, + auth: &Authentication, + target: &dyn Any, + permission: &str, + ) -> bool { + match target.downcast_ref::() { + Some(account) => { + matches!(permission, "read" | "write") && account.owner == auth.principal + } + None => false, + } + } + } + + fn principal(name: &str) -> Authentication { + Authentication { + principal: name.into(), + username: name.into(), + ..Default::default() + } + } + + #[test] + fn evaluator_grants_owner_and_denies_others() { + let eval = OwnerPermissionEvaluator; + let alice = principal("alice"); + let acct = Account { + owner: "alice".into(), + }; + + // Owner + known permission → granted. + assert!(eval.has_permission(&alice, &acct, "read")); + assert!(eval.has_permission(&alice, &acct, "write")); + // Non-owner → denied. + assert!(!eval.has_permission(&principal("bob"), &acct, "read")); + // Unknown permission → denied even for the owner. + assert!(!eval.has_permission(&alice, &acct, "delete")); + // Unknown target type → denied. + assert!(!eval.has_permission(&alice, &"some string", "read")); + } + + // The global registry is set-once per process; this is the only test that + // touches it, so ordering/parallelism cannot make it flaky. + #[test] + fn global_registry_defaults_to_deny_then_delegates() { + let alice = principal("alice"); + let acct = Account { + owner: "alice".into(), + }; + + // No evaluator registered yet → fail closed. + assert!(!has_permission(&alice, &acct, "read")); + + // Register, then the same call delegates and grants. + assert!(set_permission_evaluator(Arc::new(OwnerPermissionEvaluator)).is_ok()); + assert!(has_permission(&alice, &acct, "read")); + assert!(!has_permission(&principal("bob"), &acct, "read")); + + // A second set is rejected (set-once), handing the evaluator back. + assert!(set_permission_evaluator(Arc::new(OwnerPermissionEvaluator)).is_err()); + } +} diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub index 0568ec0f..5743d35c 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 3a10561d..643f191d 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 c785a2c3..cc4bf71c 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 4757d869..31558fb9 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 07571e86..d903d4aa 100644 --- a/docs/book/src-es/14a-spring-security-parity.md +++ b/docs/book/src-es/14a-spring-security-parity.md @@ -22,7 +22,8 @@ En la columna **Estado**, :status-supported: indica una función soportada, | Autorización de peticiones HTTP (`FilterChain`, RBAC, jerarquía de roles) | :status-supported: | Coincidencia por segmentos de ruta, denegar por defecto, gana la primera regla | | Servidor de recursos Bearer / OAuth2 (JWT) | :status-supported: | JWKS con RSA + **EC (ES256/384)** + **EdDSA**; validación de `iss`/`aud`/`exp`/`nbf`; tolerancia de reloj de 60 s; *challenge* `WWW-Authenticate` (RFC 6750) | | JWT simétrico (`JwtService`) | :status-supported: | HS256/384/512, `exp` obligatorio, tolerancia de reloj | -| Seguridad de método (`#[pre_authorize]` / `#[post_authorize]`) | :status-supported: | Funciona igual con autenticación **bearer *y* de sesión/OAuth2-login** | +| Seguridad de método (`#[pre_authorize]` / `#[post_authorize]`) | :status-supported: | Funciona igual con autenticación **bearer *y* de sesión/OAuth2-login**; reglas por palabra clave **y** expresiones estilo SpEL sobre argumentos + principal | +| Profundidad de seguridad de método (`@PreFilter`/`@PostFilter`, `PermissionEvaluator`) | :status-supported: | Filtrado de colecciones `#[pre_filter]` / `#[post_filter]`; `PermissionEvaluator` + `has_permission` (`hasPermission(...)`), utilizable dentro de las expresiones | | Comprobación de roles (`hasRole`) | :status-supported: | Acepta el prefijo `ROLE_` de Spring *y* nombres de rol sin prefijo | | CORS | :status-supported: | Rechaza la combinación insegura de origen comodín + credenciales | | Cabeceras de respuesta de seguridad | :status-supported: | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS solo en peticiones seguras** por defecto | @@ -110,6 +111,33 @@ de Spring: `PathRequestMatcher::new("/api")`) coincide, de modo que un `/api/**` blindado y una superficie web permisiva coexisten — el `FilterChainProxy` de Spring. +## Seguridad de método + +`#[pre_authorize]` / `#[post_authorize]` protegen un método de servicio frente +al principal ambiente — sin `Request` en la firma. Además de las reglas por +palabra clave (`role = "ADMIN"`, `any_authority = [..]`), aceptan +**expresiones**, el análogo en Rust del SpEL de Spring: + +- **Enlace de argumentos + principal** — un `#[pre_authorize(...)]` que no es una + palabra clave es una expresión booleana de Rust evaluada *antes* del cuerpo con + los parámetros del método y `auth` (un `&Authentication`) a la vista: + `#[pre_authorize(auth.has_role("ADMIN") || auth.principal == owner)]` + (el `@PreAuthorize("#owner == authentication.name")` de Spring). + `#[post_authorize]` enlaza `result` + `auth` sobre el valor de retorno. +- **`PermissionEvaluator`** — registra uno a nivel de proceso con + `set_permission_evaluator` y luego llama a + `has_permission(auth, target, permission)` dentro de cualquier expresión + pre/post (el `hasPermission(#obj, 'read')` de Spring). Sin evaluador + registrado, todo permiso se **deniega** (cierre seguro). +- **`#[pre_filter]` / `#[post_filter]`** — filtran una colección por un predicado + por elemento: `#[post_filter(element.owner == auth.principal)]` descarta del + `Vec` devuelto las filas que el llamante no posee; `#[pre_filter(items, …)]` + hace lo mismo con un argumento `mut` antes del cuerpo (el + `@PreFilter`/`@PostFilter` de Spring, donde `element` es el `filterObject`). + +Las cuatro fallan en cerrado: sin contexto ambiente se deniega con +`Unauthenticated`, y una expresión falsa con `Forbidden`. + ## Login sin contraseña Firefly incluye los dos mecanismos sin contraseña de Spring Security 6.4: @@ -137,8 +165,9 @@ La paridad se entrega por niveles, cada uno un incremento: 3. **Mecanismos web (hecho)** — form login, HTTP Basic, remember-me, `RequestCache` / `SavedRequest`, `SessionCreationPolicy`, múltiples cadenas de filtros. -4. **Profundidad de seguridad de método** — enlace de argumentos/principal estilo - SpEL, `@PreFilter`/`@PostFilter`, `PermissionEvaluator`. +4. **Profundidad de seguridad de método (hecho)** — enlace de + argumentos/principal estilo SpEL, `@PreFilter`/`@PostFilter`, + `PermissionEvaluator`. 5. **Ecosistema OAuth2** — introspección de tokens opacos, gestor de clientes autorizados salientes, logout iniciado por RP, servidor de autorización montado. diff --git a/docs/book/src/14a-spring-security-parity.md b/docs/book/src/14a-spring-security-parity.md index 01ece10d..83f3e24a 100644 --- a/docs/book/src/14a-spring-security-parity.md +++ b/docs/book/src/14a-spring-security-parity.md @@ -21,7 +21,8 @@ In the **Status** column, :status-supported: marks a supported feature, | HTTP request authorization (`FilterChain`, RBAC, role hierarchy) | :status-supported: | Path-segment-aware matching, deny-by-default, first-match-wins | | Bearer / OAuth2 resource server (JWT) | :status-supported: | JWKS with RSA + **EC (ES256/384)** + **EdDSA**; `iss`/`aud`/`exp`/`nbf` validation; 60 s clock-skew leeway; RFC 6750 `WWW-Authenticate` challenge | | Symmetric JWT (`JwtService`) | :status-supported: | HS256/384/512, `exp` required, clock-skew leeway | -| Method security (`#[pre_authorize]` / `#[post_authorize]`) | :status-supported: | Works uniformly across **bearer *and* session/OAuth2-login** auth | +| Method security (`#[pre_authorize]` / `#[post_authorize]`) | :status-supported: | Works uniformly across **bearer *and* session/OAuth2-login** auth; keyword rules **and** SpEL-style expressions over arguments + principal | +| Method-security depth (`@PreFilter`/`@PostFilter`, `PermissionEvaluator`) | :status-supported: | `#[pre_filter]` / `#[post_filter]` collection filtering; `PermissionEvaluator` + `has_permission` (`hasPermission(...)`), usable inside the expression forms | | Role checks (`hasRole`) | :status-supported: | Accepts Spring's `ROLE_` prefix *and* bare role names | | CORS | :status-supported: | Rejects the unsafe wildcard-origin + credentials combination | | Security response headers | :status-supported: | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS is secure-request-only** by default | @@ -104,6 +105,32 @@ The classic web authentication mechanisms, faithful to Spring's defaults: matches, so a locked-down `/api/**` and a permissive web surface coexist — Spring's `FilterChainProxy`. +## Method security + +`#[pre_authorize]` / `#[post_authorize]` guard a service method against the +ambient principal — no `Request` in the signature. Beyond fixed keyword rules +(`role = "ADMIN"`, `any_authority = [..]`), they accept **expressions**, the +Rust analog of Spring's SpEL: + +- **Argument + principal binding** — a non-keyword `#[pre_authorize(...)]` is a + boolean Rust expression evaluated *before* the body with the method's + parameters and `auth` (a `&Authentication`) in scope: + `#[pre_authorize(auth.has_role("ADMIN") || auth.principal == owner)]` + (Spring's `@PreAuthorize("#owner == authentication.name")`). `#[post_authorize]` + binds `result` + `auth` over the return value. +- **`PermissionEvaluator`** — register one process-wide with + `set_permission_evaluator`, then call `has_permission(auth, target, permission)` + inside any pre/post expression (Spring's `hasPermission(#obj, 'read')`). With + no evaluator registered, every permission is **denied** (fail-closed). +- **`#[pre_filter]` / `#[post_filter]`** — filter a collection by a per-element + predicate: `#[post_filter(element.owner == auth.principal)]` drops the rows the + caller doesn't own from the returned `Vec`; `#[pre_filter(items, …)]` does the + same to a `mut` argument before the body (Spring's `@PreFilter`/`@PostFilter`, + where `element` is the `filterObject`). + +All four fail closed: no ambient context denies with `Unauthenticated`, a false +expression with `Forbidden`. + ## Passwordless login Firefly ships the two Spring Security 6.4 passwordless mechanisms: @@ -130,7 +157,7 @@ Parity is delivered in tiers, each its own increment: 3. **Web mechanisms (done)** — form login, HTTP Basic, remember-me, `RequestCache` / `SavedRequest`, `SessionCreationPolicy`, multiple filter chains. -4. **Method-security depth** — SpEL-style argument/principal binding, +4. **Method-security depth (done)** — SpEL-style argument/principal binding, `@PreFilter`/`@PostFilter`, `PermissionEvaluator`. 5. **OAuth2 ecosystem** — opaque-token introspection, the outbound authorized-client manager, RP-initiated logout, a mounted authorization