diff --git a/Cargo.lock b/Cargo.lock index 24b0f7dc..f6a53301 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -400,7 +409,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror", + "thiserror 2.0.21", "time", "tokio", "tokio-stream", @@ -417,8 +426,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" dependencies = [ - "prost", - "prost-types", + "prost 0.14.4", + "prost-types 0.14.4", "tonic", "tonic-prost", "ureq", @@ -433,7 +442,7 @@ dependencies = [ "base64 0.22.1", "bollard-buildkit-proto", "bytes", - "prost", + "prost 0.14.4", "serde", "serde_json", "serde_repr", @@ -523,7 +532,7 @@ dependencies = [ "serde_json", "serde_with", "smol_str", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -550,7 +559,7 @@ dependencies = [ "serde_with", "smol_str", "stacker", - "thiserror", + "thiserror 2.0.21", "unicode-security", ] @@ -569,6 +578,22 @@ dependencies = [ "smol_str", ] +[[package]] +name = "cel" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca1e5eda1b0f8476181bed1bfc9232a91d62ff0b9f1bc0e48afff3cbcb5b0b5c" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "paste", + "regex", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "cel" version = "0.14.5" @@ -582,7 +607,7 @@ dependencies = [ "pastey", "regex", "serde", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -705,7 +730,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -819,6 +844,12 @@ dependencies = [ "itertools 0.13.0", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -921,6 +952,20 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -984,7 +1029,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -1437,12 +1482,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1454,6 +1514,26 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.9", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -1984,6 +2064,26 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" +[[package]] +name = "limitador" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3afbd69964c45a37b006f9bda0226d30e37f87e432c05799fee6ea9c0bf75392" +dependencies = [ + "async-trait", + "cel 0.12.0", + "cfg-if", + "dashmap", + "metrics", + "moka", + "postcard", + "serde", + "serde_json", + "tonic-build", + "tracing", + "uuid", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2101,6 +2201,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + [[package]] name = "miette" version = "7.6.0" @@ -2216,6 +2326,12 @@ dependencies = [ "cc", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "murmur3" version = "0.4.1" @@ -2464,6 +2580,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.3" @@ -2643,6 +2765,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", "serde", ] @@ -2733,7 +2856,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "yaml_serde", @@ -2751,7 +2874,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "yaml_serde", @@ -2766,7 +2889,7 @@ dependencies = [ "base64 0.23.1", "bytes", "cedar-policy", - "cel", + "cel 0.14.5", "chrono", "criterion", "deadpool-redis", @@ -2774,7 +2897,9 @@ dependencies = [ "getrandom 0.4.3", "hmac", "jsonwebtoken", + "limitador", "moka", + "praxis-policy-apl-cmf", "praxis-policy-apl-core", "praxis-policy-apl-runtime", "praxis-policy-core", @@ -2788,7 +2913,7 @@ dependencies = [ "stacker", "testcontainers", "testcontainers-modules", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -2815,7 +2940,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -2888,6 +3013,16 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2897,6 +3032,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.4" @@ -2904,7 +3049,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2920,13 +3098,22 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost", + "prost 0.14.4", ] [[package]] @@ -3036,6 +3223,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + [[package]] name = "rayon" version = "1.12.0" @@ -3168,7 +3364,7 @@ dependencies = [ "serde", "serde_json", "spin 0.12.3", - "thiserror", + "thiserror 2.0.21", "url", "uuid", "vstd", @@ -3233,6 +3429,15 @@ version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe6f213fb658c8fb95baabd5420393438cf5a98d707f5dd701d9197c705f71e" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -3558,7 +3763,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint 0.4.8", "num-traits", - "thiserror", + "thiserror 2.0.21", "time", ] @@ -3605,6 +3810,9 @@ name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spin" @@ -3744,6 +3952,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "term" version = "1.2.1" @@ -3777,7 +3998,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -3793,13 +4014,33 @@ dependencies = [ "testcontainers", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3977,6 +4218,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types 0.13.5", + "quote", + "syn 2.0.119", +] + [[package]] name = "tonic-prost" version = "0.14.6" @@ -3984,7 +4239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.4", "tonic", ] diff --git a/crates/builtins/Cargo.toml b/crates/builtins/Cargo.toml index 0621f182..61aa3cba 100644 --- a/crates/builtins/Cargo.toml +++ b/crates/builtins/Cargo.toml @@ -39,8 +39,15 @@ oauth = [ ] elicitation-ciba = ["_engine-core", "_plugins", "dep:base64"] # Experimental per-consumer token quota against a standalone Limitador. Calls go -# through the host transport, so no HTTP dep beyond core, the shared serde, and +# through the host transport, so no direct HTTP dependency is needed here. experimental-quota = ["_engine-core", "_plugins"] +# Embedded request limiter. The PoC uses only Limitador's in-memory store. +experimental-ratelimit = [ + "_engine-core", + "_plugins", + "dep:limitador", + "dep:praxis-policy-apl-cmf", +] cedar = ["_engine-apl-core", "_pdps", "dep:cedar-policy", "dep:stacker"] # The feature and the crate share a name, so `dep:cel` is required: without it # the feature would shadow the implicit optional-dependency feature instead of @@ -78,6 +85,7 @@ _secrets = [] # `apl-runtime` here would silently hand a Cedar-only consumer the runtime. praxis-policy-core = { workspace = true, optional = true } praxis-policy-apl-core = { workspace = true, optional = true } +praxis-policy-apl-cmf = { workspace = true, optional = true } praxis-policy-apl-runtime = { workspace = true, optional = true } # Shared runtime crates, deliberately NOT optional. Every feature enables one @@ -100,6 +108,11 @@ tokio = { workspace = true, features = ["rt", "sync", "time"] } uuid = { workspace = true } zeroize = { workspace = true } +# --- experimental-ratelimit -------------------------------------------------- + +# Limitador's default feature enables Redis; this PoC needs only memory. +limitador = { version = "0.13.0", default-features = false, optional = true } + # --- jwt --------------------------------------------------------------------- # `jsonwebtoken` is the de facto JWT library for Rust. Supports @@ -367,6 +380,11 @@ name = "quota" path = "tests/quota/main.rs" required-features = ["experimental-quota"] +[[test]] +name = "ratelimit" +path = "tests/ratelimit/main.rs" +required-features = ["experimental-ratelimit"] + [[bench]] name = "quota_hot_path" path = "benches/quota_hot_path.rs" diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index 35a22d34..d76b5645 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -23,6 +23,7 @@ //! | `oauth` | `plugins::delegator_oauth` | //! | `elicitation-ciba` | `plugins::elicitation_ciba` | //! | `quota/limitador` | `plugins::quota` | +//! | `ratelimit/limitador` | `plugins::ratelimit` | //! | `cedar` | `pdps::cedar_direct` | //! | `cel` | `pdps::cel` | //! | `opa` | `pdps::opa` | diff --git a/crates/builtins/src/plugins/mod.rs b/crates/builtins/src/plugins/mod.rs index ec076031..db1be84a 100644 --- a/crates/builtins/src/plugins/mod.rs +++ b/crates/builtins/src/plugins/mod.rs @@ -11,6 +11,7 @@ //! - `delegator_oauth` (`oauth`) — kind `delegator/oauth` //! - `elicitation_ciba` (`elicitation-ciba`) — kind `elicitation/ciba` //! - `quota` (`experimental-quota`) — kind `quota/limitador` +//! - `ratelimit` (`experimental-ratelimit`) — kind `ratelimit/limitador` #[cfg(feature = "jwt")] pub mod identity_jwt; @@ -26,3 +27,6 @@ pub mod elicitation_ciba; #[cfg(feature = "experimental-quota")] pub mod quota; + +#[cfg(feature = "experimental-ratelimit")] +pub mod ratelimit; diff --git a/crates/builtins/src/plugins/ratelimit/README.md b/crates/builtins/src/plugins/ratelimit/README.md new file mode 100644 index 00000000..0bd0adb3 --- /dev/null +++ b/crates/builtins/src/plugins/ratelimit/README.md @@ -0,0 +1,178 @@ +# Embedded request rate limit PoC + +`ratelimit/limitador` runs at `http.request` and keeps counters in the +process. It is available only with the `experimental-ratelimit` Cargo feature. +Each plugin instance owns one Limitador limiter, so counters reset on restart +and are not shared across replicas. The plugin serializes each in-memory +check/update across concurrent requests to that instance. + +The plugin builds a PPE attribute bag from the capability-filtered Extensions +it receives. `bindings` select string-valued bag attributes for Limitador's +flat CEL context. These bindings are the defaults: + +| CEL variable | PPE bag attribute | Required capability | +| --- | --- | --- | +| `subject_id` | `subject.id` | `read_subject` | +| `http_method` | `http.method` | `read_headers` | + +An explicit `bindings` map replaces the defaults. For example, add +`plan: claim.plan` along with the two defaults, grant `read_claims`, and use +`plan == 'free'` in a limit condition. The integration test proves this claim +selects a limit without hardcoding `claim.plan` in the handler. A missing or +non-string bound attribute denies the request. + +There is no `auth.identity.*` or `request.*` compatibility mapping yet. +`subject_id` is PPE's resolved subject ID and may differ from a Kuadrant +policy's `auth.identity.userid` claim. +The plugin-local bag contains attributes extracted from its Extensions. APL's +route-only attributes such as `route.key` and `data.*` are not supplied to it. +Missing identity or HTTP method denies the request before updating a counter. +A Limitador evaluation error also denies. An exceeded limit sets both +`proto_error_code: 429` and `details["http.status"]: 429`; the current +gateway policy filter reads the latter for a plain HTTP 429 response. + +```yaml +engine_settings: + dispatch: policy + +plugins: + - name: app-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_subject, read_headers] + config: + namespace: toystore + counter_capacity: 1000 + bindings: + subject_id: subject.id + http_method: http.method + limits: + - max: 5 + seconds: 60 + conditions: ["subject_id == 'alice'", "http_method == 'GET'"] + - max: 2 + seconds: 60 + conditions: ["subject_id == 'bob'", "http_method == 'GET'"] + +global: + authorization: + pre_invocation: + - "run(app-ratelimit)" +``` + +The `run` step is required under policy dispatch. The `hooks` list declares +the hook for config validation; the factory registers the handler in code. +Authentication must resolve the subject before the `http.request` invocation. + +Demo the in-process decision sequence from this worktree with: + +```console +cargo test -p praxis-policy-builtins --features experimental-ratelimit --test ratelimit counts_alice_and_bob_independently_and_returns_429 -- --exact --nocapture +``` + +The output shows Alice's POST allowed without using a GET counter, five Alice +GETs and two Bob GETs allowed, then Alice's sixth and Bob's third GET denied +with `proto_error_code=429`. Each test starts a fresh engine, so rerunning the +command resets the counters. This exercises PPE's route and plugin in process; +it does not start an HTTP server or show an HTTP response on the wire. + +## Global and route policy demo + +The same plugin kind can have separate instances and counters. This policy +applies Alice's limit globally, then adds Bob's limit only on `/toys`. The +root-prefix route catches other HTTP paths and keeps the global policy in +effect there. + +```yaml +engine_settings: + dispatch: policy +plugins: + - name: global-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_subject, read_headers] + config: + namespace: global-demo + limits: + - max: 5 + seconds: 60 + conditions: ["subject_id == 'alice'", "http_method == 'GET'"] + - name: toys-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_subject, read_headers] + config: + namespace: toys-demo + limits: + - max: 2 + seconds: 60 + conditions: ["subject_id == 'bob'", "http_method == 'GET'"] +global: + authorization: + pre_invocation: + - "run(global-ratelimit)" +routes: + - http: /toys + authorization: + pre_invocation: + - "run(toys-ratelimit)" + - http: + path_prefix: / +``` + +```console +cargo test -p praxis-policy-builtins --features experimental-ratelimit --test ratelimit demo_global_and_route_scoped_rate_limits -- --exact --nocapture +``` + +The output shows Alice's sixth GET on `/other` denied by the global policy, +Bob's third GET on `/toys` denied by the route policy, and Bob's GET on +`/other` allowed. The route selector controls which plugin instance runs; +the plugin still reads PPE attributes from its filtered Extensions. + +Run the native attribute binding proof with: + +```console +cargo test -p praxis-policy-builtins --features experimental-ratelimit --test ratelimit a_string_claim_from_the_ppe_bag_selects_a_limit -- --exact +``` + +Run the full in-process proof with: + +```console +cargo test -p praxis-policy-builtins --features experimental-ratelimit --test ratelimit +``` + +## HTTP header binding test + +The in-process test `header_binding_sets_http_429` binds +`http.request_headers.x-demo-user` and `http.method` from PPE's attribute bag. +It checks global and `/toys` route limits and both 429 fields without starting +a gateway. `X-Demo-User` is caller-controlled test input; use a resolved +`subject.id` for authenticated limits. A real HTTP gateway check remains to +be done separately. + +## Using the limiter with MCP or LLM policy + +This plugin has an `http.request` handler only. APL inherits +`global.authorization` steps into every route, including `tool:` and `llm:` +routes. The current gateway switches to CMF dispatch for those routes and does +not run its pure-HTTP authorization path. A single policy document that puts +`run(global-ratelimit)` under `global` and also declares an MCP or LLM route +would invoke the HTTP-only plugin in CMF context. PPE now rejects that policy +at startup with the route, plugin, and registered hooks in the error. + +For the current gateway, put request admission in a **first, HTTP-only policy +filter** and the entity routes in a **second policy filter**. The first +filter runs `http.request` once for each incoming request and the gateway's +per-filter admission marker prevents a second count if the body callback runs. +The second filter can then classify and authorize the MCP or LLM entity; its +policy must not inherit the limiter step. Configure the `mcp` classifier +before the entity policy filter when using MCP routes. The gateway supports +multiple policy filter instances in one chain. + +Authenticated limits must resolve `subject.id` within that first filter before +the limiter runs. A future single-filter integration would need a distinct +ingress HTTP admission stage after identity resolution and before entity +dispatch, with the ingress step excluded from inherited entity route steps. diff --git a/crates/builtins/src/plugins/ratelimit/config.rs b/crates/builtins/src/plugins/ratelimit/config.rs new file mode 100644 index 00000000..53eb8245 --- /dev/null +++ b/crates/builtins/src/plugins/ratelimit/config.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +use std::collections::BTreeMap; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RateLimitConfig { + pub namespace: String, + #[serde(default = "default_counter_capacity")] + pub counter_capacity: u64, + #[serde(default = "default_bindings")] + pub bindings: BTreeMap, + pub limits: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LimitConfig { + pub max: u64, + pub seconds: u64, + #[serde(default)] + pub conditions: Vec, + #[serde(default)] + pub variables: Vec, +} + +const fn default_counter_capacity() -> u64 { + 10_000 +} + +fn default_bindings() -> BTreeMap { + BTreeMap::from([ + ("subject_id".to_owned(), "subject.id".to_owned()), + ("http_method".to_owned(), "http.method".to_owned()), + ]) +} + +impl RateLimitConfig { + pub(super) fn validate(&self) -> Result<(), String> { + if self.namespace.trim().is_empty() { + return Err("ratelimit: namespace must be non-empty".to_owned()); + } + if self.counter_capacity == 0 { + return Err("ratelimit: counter_capacity must be greater than zero".to_owned()); + } + if self.bindings.is_empty() { + return Err("ratelimit: at least one attribute binding is required".to_owned()); + } + for (variable, attribute) in &self.bindings { + let mut chars = variable.chars(); + let valid_start = chars + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()); + if variable == "limit" + || !valid_start + || !chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) + { + return Err(format!( + "ratelimit: binding '{variable}' must be a simple CEL variable other than 'limit'" + )); + } + if attribute.trim().is_empty() { + return Err(format!( + "ratelimit: binding '{variable}' must name a PPE attribute" + )); + } + } + if self.limits.is_empty() { + return Err("ratelimit: at least one limit is required".to_owned()); + } + for (index, limit) in self.limits.iter().enumerate() { + if limit.max == 0 || limit.seconds == 0 { + return Err(format!( + "ratelimit: limits[{index}] max and seconds must be greater than zero" + )); + } + } + Ok(()) + } +} diff --git a/crates/builtins/src/plugins/ratelimit/factory.rs b/crates/builtins/src/plugins/ratelimit/factory.rs new file mode 100644 index 00000000..646ad6de --- /dev/null +++ b/crates/builtins/src/plugins/ratelimit/factory.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +use std::sync::Arc; + +use praxis_policy_core::error::PluginError; +use praxis_policy_core::factory::{PluginFactory, PluginInstance}; +use praxis_policy_core::hooks::TypedHandlerAdapter; +use praxis_policy_core::http_hook::{HOOK_HTTP_REQUEST, HttpHook}; +use praxis_policy_core::plugin::PluginConfig; +use praxis_policy_core::registry::AnyHookHandler; + +use super::handler::RateLimit; + +/// The `kind:` value for the embedded request limiter. +pub const KIND: &str = "ratelimit/limitador"; + +/// Constructs an in-memory Limitador instance for one configured plugin. +pub struct RateLimitFactory; + +impl PluginFactory for RateLimitFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let core = Arc::new(RateLimit::new(config.clone())?); + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&core))); + Ok(PluginInstance { + plugin: core, + handlers: vec![(HOOK_HTTP_REQUEST, handler)], + }) + } +} diff --git a/crates/builtins/src/plugins/ratelimit/handler.rs b/crates/builtins/src/plugins/ratelimit/handler.rs new file mode 100644 index 00000000..09ecc83d --- /dev/null +++ b/crates/builtins/src/plugins/ratelimit/handler.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +use std::collections::{BTreeMap, HashMap}; + +use limitador::RateLimiter; +use limitador::limit::{Context, Expression, Limit, Namespace, Predicate}; +use praxis_policy_apl_cmf::BagBuilder; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::error::{PluginError, PluginViolation}; +use praxis_policy_core::hooks::{Extensions, HookHandler, PluginResult}; +use praxis_policy_core::http_hook::{HttpHook, HttpPayload}; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; +use tokio::sync::Mutex; + +use super::config::RateLimitConfig; + +pub(super) struct RateLimit { + config: PluginConfig, + namespace: Namespace, + limiter: RateLimiter, + bindings: BTreeMap, + check_lock: Mutex<()>, +} + +impl RateLimit { + pub(super) fn new(config: PluginConfig) -> Result> { + let raw = config.config.clone().ok_or_else(|| { + PluginError::Config { + message: format!("plugin '{}': ratelimit config is required", config.name), + } + .boxed() + })?; + let typed: RateLimitConfig = serde_json::from_value(raw).map_err(|error| { + PluginError::Config { + message: format!( + "plugin '{}': invalid ratelimit config: {error}", + config.name + ), + } + .boxed() + })?; + typed + .validate() + .map_err(|message| PluginError::Config { message }.boxed())?; + + let namespace: Namespace = typed.namespace.as_str().into(); + let limiter = RateLimiter::new(typed.counter_capacity); + for (index, entry) in typed.limits.iter().enumerate() { + for source in entry.conditions.iter().chain(&entry.variables) { + let expression: Expression = source.as_str().try_into().map_err(|error| { + PluginError::Config { + message: format!("ratelimit: limits[{index}] invalid CEL: {error}"), + } + .boxed() + })?; + for variable in expression.variables() { + if variable != "limit" && !typed.bindings.contains_key(&variable) { + return Err(PluginError::Config { + message: format!( + "ratelimit: limits[{index}] references unbound CEL variable '{variable}'" + ), + } + .boxed()); + } + } + } + let conditions: Vec = entry + .conditions + .iter() + .map(|condition| condition.as_str().try_into()) + .collect::>() + .map_err(|error| { + PluginError::Config { + message: format!("ratelimit: limits[{index}] invalid condition: {error}"), + } + .boxed() + })?; + let variables: Vec = entry + .variables + .iter() + .map(|variable| variable.as_str().try_into()) + .collect::>() + .map_err(|error| { + PluginError::Config { + message: format!("ratelimit: limits[{index}] invalid variable: {error}"), + } + .boxed() + })?; + if !limiter.add_limit(Limit::new( + typed.namespace.as_str(), + entry.max, + entry.seconds, + conditions, + variables, + )) { + return Err(PluginError::Config { + message: format!("ratelimit: limits[{index}] duplicates an earlier limit"), + } + .boxed()); + } + } + + Ok(Self { + config, + namespace, + limiter, + bindings: typed.bindings, + check_lock: Mutex::new(()), + }) + } +} + +impl Plugin for RateLimit { + fn config(&self) -> &PluginConfig { + &self.config + } +} + +impl HookHandler for RateLimit { + async fn handle( + &self, + _payload: &HttpPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Build the same PPE attribute vocabulary as APL, using this plugin's + // capability-filtered view of Extensions. Limitador's public Context + // accepts flat string variables, so config binds CEL names to bag keys. + let bag = BagBuilder::new().with_extensions(extensions).build(); + let mut values = HashMap::with_capacity(self.bindings.len()); + for (variable, attribute) in &self.bindings { + let Some(value) = bag.get_string(attribute) else { + let code = if bag.contains(attribute) { + "ratelimit.unsupported_attribute" + } else { + match attribute.as_str() { + "subject.id" => "ratelimit.no_identity", + "http.method" => "ratelimit.no_http_method", + _ => "ratelimit.missing_attribute", + } + }; + return PluginResult::deny(PluginViolation::new( + code, + format!("PPE attribute '{attribute}' is unavailable as a string"), + )); + }; + values.insert(variable.clone(), value.to_owned()); + } + let context: Context<'_> = values.into(); + // Limitador's in-memory check and update are separate operations. Serialize them + // across this plugin instance so concurrent requests cannot exceed the limit. + let _guard = self.check_lock.lock().await; + match self + .limiter + .check_rate_limited_and_update(&self.namespace, &context, 1, false) + { + Ok(result) if result.limited => PluginResult::deny( + PluginViolation::new("ratelimit.exceeded", "request rate limit exceeded") + .with_details(HashMap::from([( + "http.status".to_owned(), + serde_json::json!(429), + )])) + .with_proto_error_code(429), + ), + Ok(_) => PluginResult::allow(), + Err(error) => { + tracing::error!(%error, "ratelimit: Limitador check failed"); + PluginResult::deny(PluginViolation::new( + "ratelimit.check_failed", + "request rate limit check failed", + )) + }, + } + } +} diff --git a/crates/builtins/src/plugins/ratelimit/mod.rs b/crates/builtins/src/plugins/ratelimit/mod.rs new file mode 100644 index 00000000..61270277 --- /dev/null +++ b/crates/builtins/src/plugins/ratelimit/mod.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Experimental in-process request rate limiting with Limitador. +//! +//! This plugin evaluates `subject_id` and `http_method` from capability-filtered +//! PPE extensions. It does not implement the Kuadrant attribute vocabulary. +//! Counters are local to this plugin instance and disappear on restart. + +mod config; +mod factory; +mod handler; + +pub use factory::{KIND, RateLimitFactory}; diff --git a/crates/builtins/tests/ratelimit/main.rs b/crates/builtins/tests/ratelimit/main.rs new file mode 100644 index 00000000..b76b4c41 --- /dev/null +++ b/crates/builtins/tests/ratelimit/main.rs @@ -0,0 +1,498 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! In-process proof that an APL route runs the embedded Limitador plugin. + +#![allow( + missing_docs, + clippy::expect_used, + clippy::indexing_slicing, + reason = "integration tests inspect the rate-limit decision" +)] + +use std::collections::HashMap; +use std::sync::Arc; + +use praxis_policy_apl_runtime::{AplOptions, register_apl}; +use praxis_policy_builtins::plugins::ratelimit::{KIND, RateLimitFactory}; +use praxis_policy_core::cmf::constants::{ENTITY_HTTP, ENTITY_NAME_GLOBAL}; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::error::PluginError; +use praxis_policy_core::extensions::{ + Extensions, HttpExtension, MetaExtension, SecurityExtension, SubjectExtension, +}; +use praxis_policy_core::factory::PluginFactory as _; +use praxis_policy_core::http_hook::{HOOK_HTTP_REQUEST, HttpHook, HttpPayload}; +use praxis_policy_core::plugin::PluginConfig; + +const POLICY: &str = r#" +engine_settings: + dispatch: policy +plugins: + - name: app-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_subject, read_headers] + config: + namespace: toystore + counter_capacity: 1000 + limits: + - max: 5 + seconds: 60 + conditions: ["subject_id == 'alice'", "http_method == 'GET'"] + - max: 2 + seconds: 60 + conditions: ["subject_id == 'bob'", "http_method == 'GET'"] +global: + authorization: + pre_invocation: + - "run(app-ratelimit)" +"#; + +const CLAIM_POLICY: &str = r#" +engine_settings: + dispatch: policy +plugins: + - name: app-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_subject, read_claims, read_headers] + config: + namespace: claim-demo + bindings: + subject_id: subject.id + http_method: http.method + plan: claim.plan + limits: + - max: 1 + seconds: 60 + conditions: ["subject_id == 'alice'", "http_method == 'GET'", "plan == 'free'"] +global: + authorization: + pre_invocation: + - "run(app-ratelimit)" +"#; + +const GLOBAL_AND_ROUTE_POLICY: &str = r#" +engine_settings: + dispatch: policy +plugins: + - name: global-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_subject, read_headers] + config: + namespace: global-demo + limits: + - max: 5 + seconds: 60 + conditions: ["subject_id == 'alice'", "http_method == 'GET'"] + - name: toys-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_subject, read_headers] + config: + namespace: toys-demo + limits: + - max: 2 + seconds: 60 + conditions: ["subject_id == 'bob'", "http_method == 'GET'"] +global: + authorization: + pre_invocation: + - "run(global-ratelimit)" +routes: + - http: /toys + authorization: + pre_invocation: + - "run(toys-ratelimit)" + - http: + path_prefix: / +"#; + +const HEADER_POLICY: &str = r#" +engine_settings: + dispatch: policy +plugins: + - name: global-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_headers] + config: + namespace: header-global-test + bindings: + demo_user: http.request_headers.x-demo-user + http_method: http.method + limits: + - max: 5 + seconds: 300 + conditions: ["demo_user == 'alice'", "http_method == 'GET'"] + - name: toys-ratelimit + kind: ratelimit/limitador + hooks: [http.request] + mode: sequential + capabilities: [read_headers] + config: + namespace: header-toys-test + bindings: + demo_user: http.request_headers.x-demo-user + http_method: http.method + limits: + - max: 2 + seconds: 300 + conditions: ["demo_user == 'bob'", "http_method == 'GET'"] +global: + authorization: + pre_invocation: + - "run(global-ratelimit)" +routes: + - http: /toys + authorization: + pre_invocation: + - "run(toys-ratelimit)" + - http: + path_prefix: / +"#; + +async fn engine_with(policy: &str) -> Arc { + let manager = Arc::new(PolicyEngine::default()); + manager.register_factory(KIND, Box::new(RateLimitFactory)); + register_apl(&manager, AplOptions::in_process()); + manager + .load_config_yaml(policy) + .expect("rate-limit config loads"); + manager + .initialize() + .await + .expect("rate limiter initializes"); + manager +} + +async fn engine() -> Arc { + engine_with(POLICY).await +} + +fn request_with_plan( + subject_id: Option<&str>, + method: Option<&str>, + plan: Option<&str>, +) -> Extensions { + request_with_plan_at_path(subject_id, method, plan, "/toys") +} + +fn request_with_plan_at_path( + subject_id: Option<&str>, + method: Option<&str>, + plan: Option<&str>, + path: &str, +) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some(ENTITY_HTTP.to_owned()), + entity_name: Some(ENTITY_NAME_GLOBAL.to_owned()), + ..Default::default() + })), + http: Some(Arc::new(HttpExtension { + method: method.map(str::to_owned), + path: Some(path.to_owned()), + ..Default::default() + })), + security: Some(Arc::new(SecurityExtension { + subject: subject_id.map(|id| SubjectExtension { + id: Some(id.to_owned()), + claims: plan.map_or_else(HashMap::new, |value| { + HashMap::from([("plan".to_owned(), serde_json::json!(value))]) + }), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + } +} + +fn request(subject_id: Option<&str>, method: Option<&str>) -> Extensions { + request_with_plan(subject_id, method, None) +} + +fn request_at_path(subject_id: &str, method: &str, path: &str) -> Extensions { + request_with_plan_at_path(Some(subject_id), Some(method), None, path) +} + +fn gateway_request(user: &str, path: &str) -> Extensions { + let mut extensions = request_with_plan_at_path(None, Some("GET"), None, path); + Arc::make_mut(extensions.http.as_mut().expect("HTTP request fixture")) + .request_headers + .insert("x-demo-user".to_owned(), user.to_owned()); + extensions +} + +async fn verdict_request( + manager: &PolicyEngine, + extensions: Extensions, +) -> (bool, Option<(String, Option)>) { + let (result, _background) = manager + .invoke_named::(HOOK_HTTP_REQUEST, HttpPayload, extensions, None) + .await; + ( + result.continue_processing, + result.violation.map(|v| (v.code, v.proto_error_code)), + ) +} + +async fn verdict( + manager: &PolicyEngine, + subject_id: Option<&str>, + method: Option<&str>, +) -> (bool, Option<(String, Option)>) { + verdict_request(manager, request(subject_id, method)).await +} + +#[allow(clippy::print_stdout, reason = "show decisions in the in-process demo")] +fn show_verdict(label: &str, (allowed, violation): &(bool, Option<(String, Option)>)) { + if *allowed { + println!("{label}: ALLOW"); + } else if let Some((code, Some(status))) = violation { + println!("{label}: DENY {code} (proto_error_code={status})"); + } else { + println!("{label}: DENY {violation:?}"); + } +} + +#[tokio::test] +async fn counts_alice_and_bob_independently_and_returns_429() { + let manager = engine().await; + + // POST does not match either GET limit and must leave Alice's balance alone. + let post = verdict(&manager, Some("alice"), Some("POST")).await; + show_verdict("alice POST", &post); + assert!(post.0); + for number in 1..=5 { + let result = verdict(&manager, Some("alice"), Some("GET")).await; + show_verdict(&format!("alice GET #{number}"), &result); + assert!(result.0); + } + for number in 1..=2 { + let result = verdict(&manager, Some("bob"), Some("GET")).await; + show_verdict(&format!("bob GET #{number}"), &result); + assert!(result.0); + } + + let alice_denied = verdict(&manager, Some("alice"), Some("GET")).await; + show_verdict("alice GET #6", &alice_denied); + assert_eq!( + alice_denied, + (false, Some(("ratelimit.exceeded".to_owned(), Some(429)))) + ); + let bob_denied = verdict(&manager, Some("bob"), Some("GET")).await; + show_verdict("bob GET #3", &bob_denied); + assert_eq!( + bob_denied, + (false, Some(("ratelimit.exceeded".to_owned(), Some(429)))) + ); +} + +#[tokio::test] +async fn a_string_claim_from_the_ppe_bag_selects_a_limit() { + let manager = engine_with(CLAIM_POLICY).await; + let free = || request_with_plan(Some("alice"), Some("GET"), Some("free")); + let paid = || request_with_plan(Some("alice"), Some("GET"), Some("paid")); + + assert!(verdict_request(&manager, free()).await.0); + assert_eq!( + verdict_request(&manager, free()).await, + (false, Some(("ratelimit.exceeded".to_owned(), Some(429)))) + ); + assert!(verdict_request(&manager, paid()).await.0); + assert_eq!( + verdict_request(&manager, request(Some("alice"), Some("GET"))) + .await + .1, + Some(("ratelimit.missing_attribute".to_owned(), None)) + ); +} + +#[tokio::test] +async fn demo_global_and_route_scoped_rate_limits() { + let manager = engine_with(GLOBAL_AND_ROUTE_POLICY).await; + + for number in 1..=5 { + let result = verdict_request(&manager, request_at_path("alice", "GET", "/other")).await; + show_verdict(&format!("global alice /other GET #{number}"), &result); + assert!(result.0); + } + let global_denied = verdict_request(&manager, request_at_path("alice", "GET", "/other")).await; + show_verdict("global alice /other GET #6", &global_denied); + assert_eq!( + global_denied, + (false, Some(("ratelimit.exceeded".to_owned(), Some(429)))) + ); + + for number in 1..=2 { + let result = verdict_request(&manager, request_at_path("bob", "GET", "/toys")).await; + show_verdict(&format!("route bob /toys GET #{number}"), &result); + assert!(result.0); + } + let route_denied = verdict_request(&manager, request_at_path("bob", "GET", "/toys")).await; + show_verdict("route bob /toys GET #3", &route_denied); + assert_eq!( + route_denied, + (false, Some(("ratelimit.exceeded".to_owned(), Some(429)))) + ); + + let outside_route = verdict_request(&manager, request_at_path("bob", "GET", "/other")).await; + show_verdict("bob /other GET outside route", &outside_route); + assert!(outside_route.0); +} + +#[tokio::test] +async fn header_binding_sets_http_429() { + let manager = engine_with(HEADER_POLICY).await; + + for _ in 0..5 { + assert!( + verdict_request(&manager, gateway_request("alice", "/other")) + .await + .0 + ); + } + let (result, _) = manager + .invoke_named::( + HOOK_HTTP_REQUEST, + HttpPayload, + gateway_request("alice", "/other"), + None, + ) + .await; + assert!(!result.continue_processing); + let violation = result + .violation + .expect("Alice exceeds the global rate limit"); + assert_eq!(violation.code, "ratelimit.exceeded"); + assert_eq!(violation.proto_error_code, Some(429)); + assert_eq!( + violation.details.get("http.status"), + Some(&serde_json::json!(429)) + ); + + for _ in 0..2 { + assert!( + verdict_request(&manager, gateway_request("bob", "/toys")) + .await + .0 + ); + } + assert_eq!( + verdict_request(&manager, gateway_request("bob", "/toys")) + .await + .1, + Some(("ratelimit.exceeded".to_owned(), Some(429))) + ); + assert!( + verdict_request(&manager, gateway_request("bob", "/other")) + .await + .0 + ); +} + +#[tokio::test] +async fn missing_identity_or_http_method_denies_before_the_counter() { + let manager = engine().await; + assert_eq!( + verdict(&manager, None, Some("GET")).await.1, + Some(("ratelimit.no_identity".to_owned(), None)) + ); + assert_eq!( + verdict(&manager, Some("alice"), None).await.1, + Some(("ratelimit.no_http_method".to_owned(), None)) + ); + assert!(verdict(&manager, Some("alice"), Some("GET")).await.0); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_requests_do_not_exceed_the_in_memory_limit() { + let manager = engine().await; + let mut tasks = Vec::new(); + for _ in 0..20 { + let manager = Arc::clone(&manager); + tasks.push(tokio::spawn(async move { + verdict(&manager, Some("alice"), Some("GET")).await.0 + })); + } + let mut admitted = 0; + for task in tasks { + admitted += usize::from(task.await.expect("request task completes")); + } + assert_eq!(admitted, 5); +} + +#[test] +fn malformed_condition_fails_during_plugin_construction() { + let config = PluginConfig { + name: "app-ratelimit".to_owned(), + kind: KIND.to_owned(), + config: Some(serde_json::json!({ + "namespace": "toystore", + "limits": [{ + "max": 5, + "seconds": 60, + "conditions": ["subject_id =="], + }], + })), + ..Default::default() + }; + let error = RateLimitFactory + .create(&config) + .err() + .expect("malformed CEL must fail at construction"); + assert!(matches!(*error, PluginError::Config { .. })); +} + +#[test] +fn unbound_cel_variable_fails_during_plugin_construction() { + let config = PluginConfig { + name: "app-ratelimit".to_owned(), + kind: KIND.to_owned(), + config: Some(serde_json::json!({ + "namespace": "toystore", + "limits": [{ + "max": 5, + "seconds": 60, + "conditions": ["plan == 'free'"], + }], + })), + ..Default::default() + }; + let error = RateLimitFactory + .create(&config) + .err() + .expect("unbound CEL variable must fail at construction"); + assert!(matches!(*error, PluginError::Config { .. })); + assert!(error.to_string().contains("unbound CEL variable 'plan'")); +} + +#[test] +fn http_only_global_limiter_rejects_entity_routes_at_startup() { + for (route, route_key) in [ + ("tool: get_toys", "tool:get_toys"), + ("llm: demo-model", "llm:demo-model"), + ] { + let manager = Arc::new(PolicyEngine::default()); + manager.register_factory(KIND, Box::new(RateLimitFactory)); + register_apl(&manager, AplOptions::in_process()); + let policy = format!("{HEADER_POLICY} - {route}\n"); + let error = manager + .load_config_yaml(&policy) + .expect_err("HTTP-only limiter cannot run in an entity route") + .to_string(); + assert!(error.contains(route_key), "{error}"); + assert!(error.contains("global-ratelimit"), "{error}"); + assert!(error.contains("http.request"), "{error}"); + assert!(error.contains("no matching registered handler"), "{error}"); + } +} diff --git a/crates/ppe-apl-runtime/src/cmf_invoker.rs b/crates/ppe-apl-runtime/src/cmf_invoker.rs index 7a8f9dc0..9d2ce318 100644 --- a/crates/ppe-apl-runtime/src/cmf_invoker.rs +++ b/crates/ppe-apl-runtime/src/cmf_invoker.rs @@ -71,6 +71,7 @@ use tokio::sync::Mutex; use praxis_policy_core::cmf::CmfHook; use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::error::PluginViolation; use praxis_policy_core::hooks::HookPhase; use praxis_policy_core::hooks::payload::Extensions; use praxis_policy_core::hooks::trait_def::HookTypeDef; @@ -130,6 +131,10 @@ where /// parts only, so a redaction of a `ToolResult` (or any other /// non-text part) looks identical to no mutation at all. payload_modified: AtomicBool, + /// The original violation from a denying plugin. APL's `Decision::Deny` + /// retains only code and reason, so the route handler reads this to keep + /// protocol status and details when it builds the host-facing result. + denial_violation: Mutex>, /// Pre-resolved per-route plugin lineup. Built (or fetched from a /// shared `DispatchCache`) at request start by the host. plan: Arc, @@ -200,6 +205,7 @@ where extensions: Arc::new(Mutex::new(extensions)), payload: Arc::new(Mutex::new(payload)), payload_modified: AtomicBool::new(false), + denial_violation: Mutex::new(None), plan, session_id, session_store, @@ -214,6 +220,11 @@ where self.payload.lock().await.clone() } + /// The full violation from a plugin that denied this request, if any. + pub async fn denial_violation(&self) -> Option { + self.denial_violation.lock().await.clone() + } + /// Did any plugin in this request hand back a payload? /// /// `true` from the moment a `modified_payload` is accepted into the @@ -413,10 +424,15 @@ where .await; // Map deny: violation reason → APL deny reason; plugin code → - // rule_source for audit attribution. + // rule_source for audit attribution. Keep the original violation + // alongside the decision so the route can preserve protocol status + // and details that `Decision::Deny` does not carry. let decision = if result.is_denied() { let (reason, rule_source) = match result.violation { - Some(v) => (Some(v.reason), v.code), + Some(v) => { + *self.denial_violation.lock().await = Some(v.clone()); + (Some(v.reason), v.code) + }, None => (None, "policy.forbidden".to_owned()), }; Decision::Deny { diff --git a/crates/ppe-apl-runtime/src/route_handler.rs b/crates/ppe-apl-runtime/src/route_handler.rs index e8bb567d..d87cf50b 100644 --- a/crates/ppe-apl-runtime/src/route_handler.rs +++ b/crates/ppe-apl-runtime/src/route_handler.rs @@ -729,7 +729,12 @@ impl AplRouteHandler { rule_source }; let reason = reason.unwrap_or_else(|| "access denied".to_owned()); - let mut v = PluginViolation::new(code, reason); + let mut v = match invoker.denial_violation().await { + Some(plugin_v) if plugin_v.code == code && plugin_v.reason == reason => { + plugin_v + }, + _ => PluginViolation::new(code, reason), + }; decorate_denial_response(&mut v, self.route.response.as_ref()); (false, Some(v)) }, diff --git a/crates/ppe-apl-runtime/src/visitor.rs b/crates/ppe-apl-runtime/src/visitor.rs index d1c73fc4..989b75b0 100644 --- a/crates/ppe-apl-runtime/src/visitor.rs +++ b/crates/ppe-apl-runtime/src/visitor.rs @@ -83,6 +83,7 @@ use praxis_policy_core::config::{ PluginRouteRef, RouteEntry, route_bundle_names, route_entity_identity, }; use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::hooks::{HookMetadata, HookPhase, lookup_hook_metadata}; use praxis_policy_core::http_hook::{HOOK_HTTP_REQUEST, HOOK_HTTP_RESPONSE}; use praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE; use praxis_policy_core::plugin::PluginConfig; @@ -627,6 +628,67 @@ fn default_base_capabilities() -> std::collections::HashSet { } impl AplConfigVisitor { + /// Refuse a `run(name)` step when none of that plugin's hooks can run in + /// this route's entity and phase. Use registered handlers when present; + /// handlerless declarations retain their existing load-time semantics. + /// `HookPluginInvoker` makes the same selection at request time, so this + /// catches an inherited global step before the first MCP or LLM request. + fn validate_run_hook_context( + &self, + mgr: &PolicyEngine, + route: &CompiledRoute, + route_key: &str, + entity_type: &str, + ) -> Result<(), VisitorError> { + for (effects, phase) in [ + (&route.pre_invocation, HookPhase::Pre), + (&route.post_invocation, HookPhase::Post), + ] { + let mut names = HashSet::new(); + walk_effects(effects, &mut |effect| { + if let Effect::Plugin { name } = effect { + names.insert(name.clone()); + } + }); + for name in names { + let mut hooks: Vec = mgr + .find_plugin_entries(&name) + .into_iter() + .map(|(hook, _)| hook) + .collect(); + // A declaration may deliberately have no runtime handlers + // (for example, a load-time policy validation fixture). The + // engine already permits that; retain its declared-hook + // semantics while checking actual handlers whenever present. + if hooks.is_empty() { + hooks = self + .state + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .declared_plugin_hooks + .get(&name) + .cloned() + .unwrap_or_default(); + } + let matches = hooks.iter().any(|hook| { + lookup_hook_metadata(hook) + .unwrap_or_else(HookMetadata::permissive) + .matches(Some(entity_type), phase) + }); + if !matches { + hooks.sort_unstable(); + return Err(format!( + "route '{route_key}' runs plugin '{name}' in {entity_type} {phase:?} \ + context, but it has no matching registered handler (hooks: {hooks:?}); \ + move the step to a compatible route or register a handler for this context" + ) + .into()); + } + } + } + Ok(()) + } + /// Tally the plugins a compiled route reaches, against the hook each half /// installs under. /// @@ -1194,6 +1256,8 @@ impl ConfigVisitor for AplConfigVisitor { continue; } + self.validate_run_hook_context(mgr, &effective, &route_key, entity_type)?; + // Load-time soundness check: a route that delegates the // caller's own credential but resolves no identity for it. // Per entity name rather than once per route, because @@ -2129,10 +2193,10 @@ mod tests { _extensions: &praxis_policy_core::extensions::Extensions, _ctx: &mut praxis_policy_core::context::PluginContext, ) -> PluginResult { - PluginResult::deny(PluginViolation::new( - CHAIN_VIOLATION, - "the route's plugin chain ran", - )) + PluginResult::deny( + PluginViolation::new(CHAIN_VIOLATION, "the route's plugin chain ran") + .with_proto_error_code(429), + ) } } @@ -2333,6 +2397,11 @@ routes: violation.code, CHAIN_VIOLATION, "a `run(name)` step is what activates a plugin in policy mode" ); + assert_eq!( + violation.proto_error_code, + Some(429), + "the APL route must preserve the plugin's wire status" + ); } /// A glob route's annotation is keyed by its pattern, not the request name. diff --git a/crates/ppe/Cargo.toml b/crates/ppe/Cargo.toml index c668e851..43da3bf2 100644 --- a/crates/ppe/Cargo.toml +++ b/crates/ppe/Cargo.toml @@ -54,7 +54,7 @@ builtins = [ "valkey", "secrets-vault", ] -# quota is experimental: opt in via experimental-quota, never in a default build. +# Quota and request rate limiting are experimental and never in a default build. # Marker set by every experimental feature; gates warn_experimental_features. experimental = ["dep:tracing"] @@ -87,6 +87,14 @@ experimental-quota = [ "praxis-policy-builtins/experimental-quota", ] +# Embedded in-memory request rate limiting. Not covered by semver. +experimental-ratelimit = [ + "_builtin", + "experimental", + "dep:praxis-policy-builtins", + "praxis-policy-builtins/experimental-ratelimit", +] + # A default `HttpTransport` for hosts that inject none, built on hyper. # # Deliberately NOT a separate crate. Publishing is serial and rate-limited diff --git a/crates/ppe/src/lib.rs b/crates/ppe/src/lib.rs index 9f10d4f5..e7cd0efc 100644 --- a/crates/ppe/src/lib.rs +++ b/crates/ppe/src/lib.rs @@ -142,6 +142,8 @@ pub use praxis_policy_builtins::plugins::identity_api_key::{ pub use praxis_policy_builtins::plugins::identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; #[cfg(feature = "experimental-quota")] pub use praxis_policy_builtins::plugins::quota::{KIND as QUOTA_KIND, QuotaFactory}; +#[cfg(feature = "experimental-ratelimit")] +pub use praxis_policy_builtins::plugins::ratelimit::{KIND as RATELIMIT_KIND, RateLimitFactory}; #[cfg(feature = "secrets-vault")] pub use praxis_policy_builtins::secrets::vault::{ KIND as VAULT_SECRET_KIND, VaultSecretProviderFactory, @@ -210,6 +212,8 @@ use praxis_policy_builtins::plugins::identity_api_key as api_key_builtin; use praxis_policy_builtins::plugins::identity_jwt as jwt_builtin; #[cfg(feature = "experimental-quota")] use praxis_policy_builtins::plugins::quota as quota_builtin; +#[cfg(feature = "experimental-ratelimit")] +use praxis_policy_builtins::plugins::ratelimit as ratelimit_builtin; #[cfg(feature = "_builtin")] register_builtins! { @@ -219,6 +223,7 @@ register_builtins! { feature "elicitation-ciba" => ciba_builtin::CibaApproverFactory, // Experimental: registers only when `experimental-quota` is named, never via `builtins`. feature "experimental-quota" => quota_builtin::QuotaFactory, + feature "experimental-ratelimit" => ratelimit_builtin::RateLimitFactory, } /// The enabled PDP factories, ready to drop into @@ -492,4 +497,24 @@ mod tests { "experimental-quota is on, so quota must register; got: {err}" ); } + + #[cfg(not(feature = "experimental-ratelimit"))] + #[test] + fn ratelimit_is_not_registered_without_the_experimental_feature() { + let err = load_error_for_kind("ratelimit/limitador"); + assert!( + err.contains("no factory registered"), + "ratelimit must stay opt-in; got: {err}" + ); + } + + #[cfg(feature = "experimental-ratelimit")] + #[test] + fn ratelimit_registers_with_the_experimental_feature() { + let err = load_error_for_kind("ratelimit/limitador"); + assert!( + !err.contains("no factory registered"), + "experimental-ratelimit is on, so its factory must register; got: {err}" + ); + } } diff --git a/docs/proposals/00134_limitador-rate-limit-plugin.md b/docs/proposals/00134_limitador-rate-limit-plugin.md new file mode 100644 index 00000000..f3c74471 --- /dev/null +++ b/docs/proposals/00134_limitador-rate-limit-plugin.md @@ -0,0 +1,340 @@ +--- +issue: https://github.com/praxis-proxy/policy/issues/152 +discussion: >- + Design for embedding the limitador crate as an in-process + request-rate-limiting plugin. PPE config and API claims are cited to this repo; + the crate's API to its published docs (exact signatures confirmed in the PoC). + Related to the Kuadrant compatibility work (00130, 00133). +status: proposed +authors: + - maleck13 +graduation_criteria: + - A net-new `ratelimit/limitador` plugin is specified. Its kind, hook point, config schema, and the attribute-bag → Limitador context mapping, each cited to this repo or the crate. + - The reason request-rate limiting is a standalone plugin (not folded + into existing metering) is stated. + - The storage model is specified in-memory for the spike, host-injected shared storage (redis-like) as the production path, with the plugin constructing no network connection of its own. + - Verification is specified at two tiers. In-process PPE tests cover native + attributes, global and route-level policy placement, limiter logic, and + the deny's `proto_error_code` and HTTP status detail; an end-to-end run against `praxis-ai` + confirms a real request returns a real 429. + - An HTTP-only limiter inherited by an MCP or LLM route is rejected at + startup. Request admission for an entity-aware gateway has one defined + HTTP request boundary. +stakeholders: + - araujof + - terylt +--- + +# Limitador rate-limit plugin (embedded crate) + +## What? + +A net-new PPE plugin, `kind: ratelimit/limitador`, that embeds the +[`limitador`](https://crates.io/crates/limitador) crate (v0.13.0) to enforce +**authenticated, application-level request-rate limits** inside the policy +filter. It runs on the `http.request` hook after identity resolution, builds a +Limitador evaluation context from PPE data, and calls Limitador's +`check_rate_limited_and_update` to admit or refuse the request. The target is +parity with Kuadrant `RateLimitPolicy` semantics (N requests per window, +selected by conditions over identity and request attributes). + +Scope is **application and authenticated** rate limiting — limits keyed on who +the caller is and what they are doing, applied after identity resolution. +Infrastructure / network-layer rate limiting is out of scope (see Non-goals). + +The spike uses Limitador's in-memory storage. The production path is a shared +(redis-like) store whose connection is **injected by the host**, not built by +the plugin. + +The first PoC is deliberately narrower than the compatibility target: it +builds a PPE attribute bag from the plugin's capability-filtered Extensions, +then binds native `subject.id` and `http.method` to Limitador CEL variables. +A test also binds `claim.plan` to prove the mapping is configurable. This +proves the plugin and counter path before Kuadrant attributes are available. +Another test runs separate limiter instances from `global` and an HTTP route, +showing both policy scopes with PPE-native attributes. See +`crates/builtins/src/plugins/ratelimit/README.md` for its runnable config. +The in-process header-binding test uses a caller-controlled header to exercise +the same scopes without an identity plugin; it is not an authenticated identity +source. A real HTTP gateway check remains future work. + +### Why? + +- PPE already resolves identity and builds an attribute bag. Keying rate limits + on authenticated identity is cheap here and avoids a second filter that would + re-resolve well-known attributes and re-handshake identity across a filter + boundary. +- Limitador is embeddable (the crate exposes CEL conditions and pluggable + storage), so this validates reusing an upstream Kuadrant component in-process + rather than as a network dependency. +- Limitador's distributed mode uses a redis-compatible store, which PPE already + supports — the `session::valkey` builtin runs against the same kind of + backend. +- It answers an open architectural question: does a **stateful counter** fit + PPE's plugin/effect model, which otherwise forbids background tasks and + plugin-owned network connections? + +### Goals + +- One plugin that enforces request-rate limits from PPE config. +- Prove alice/bob request limits using PPE-native variables first, then + reproduce a Kuadrant `RateLimitPolicy` through the compatibility mapping. +- Keep the plugin free of background tasks and self-constructed network + connections (storage is host-injected), consistent with the quota plugin's + host-transport rule. + +### Non-goals + +- Token/cost budgets at this stage (future work). That is the existing `quota/limitador` plugin's job; this plugin counts requests, not tokens. +- A production-grade shared-store deployment. The spike proves the mechanism + with in-memory storage; the host-injected store is specified but not built. +- Infrastructure / network-layer rate limiting — per-IP throttling, global + request floods, L3/L4 edge protection, DoS mitigation. Those belong at the + gateway or a dedicated infrastructure limiter. This plugin keys on the authenticated caller, so it runs after identity resolution and does not see unauthenticated edge traffic. + +## Design + +### Plugin shape + +Mirrors the quota plugin's structure +(`crates/builtins/src/plugins/quota/`): + +- `KIND = "ratelimit/limitador"` — namespaced so other rate-limit backends + could register alongside. +- `PluginFactory::create(&PluginConfig) -> Result>` + builds one shared core (holding the `RateLimiter` and parsed limits) and + registers a single handler on `HOOK_HTTP_REQUEST` + (`crates/ppe-core/src/http_hook.rs:40`, `"http.request"`, `HttpHook` + family, `Pre` phase) via `TypedHandlerAdapter::`. +- Gated behind its own cargo feature in `crates/builtins`, `experimental` + stability, consistent with quota. + +### Global and route-level policy placement + +The PoC declares one limiter under `global.authorization.pre_invocation` and +a second under an `http: /toys` route. A root-prefix route catches other HTTP +paths. APL runs the global step on all matching requests and adds the route +step only on `/toys`; each configured plugin instance keeps its own counters. +The in-process test proves a global denial on `/other`, a route denial on +`/toys`, and an allowed request outside the route. Route selection uses APL's +HTTP path matcher; it does not add route-only bag attributes to the plugin's +Limitador context. These examples contain HTTP routes only. + +### Entity-aware gateway admission + +`ratelimit/limitador` registers only on `http.request`. APL inherits a global +`run(name)` step into `tool:` and `llm:` routes, whose policy evaluation uses +CMF hooks. The current gateway runs its HTTP authorization path only for a +pure HTTP policy; with entity routes it gates identity on the request headers +and evaluates the entity policy after classification. Putting a global HTTP +limiter and an entity route in one policy document would dispatch the limiter +under the wrong hook. The APL config visitor now checks the plugin's actual +registered handlers against each effective route at load time and rejects that +combination before serving requests. + +The current gateway can run two policy filters in order: an HTTP-only limiter +policy first, then a separate MCP or LLM policy. The first filter evaluates +`http.request` once per incoming request, and its admission marker prevents a +second count if the body callback runs. The entity policy has no inherited +limiter step. For MCP, the classifier runs before the entity policy filter. +Authenticated limits must resolve the subject in the first filter; the PoC's +`X-Demo-User` header is only a local smoke-test selector. + +A single-filter implementation would need an explicit ingress HTTP admission +stage after identity resolution and before entity dispatch, plus policy +layering that does not copy that ingress step into the entity route. Merely +registering the limiter on CMF hooks would count entity invocations, not +necessarily HTTP requests, and does not provide once-per-request admission. + +### Flow (per request, on `http.request`) + +1. Handler receives an empty `HttpPayload` and capability-filtered + `Extensions`. The latter carries the resolved identity and the HTTP + request line; `read_headers` is required to see the HTTP extension. +2. Build a plugin-local PPE attribute bag using the shared `BagBuilder` on + those filtered Extensions. Configured bindings select string-valued bag + keys such as `subject.id`, `http.method`, and `claim.plan` for Limitador's + flat CEL context. Missing or non-string bound attributes deny the request. +3. Call `rate_limiter.check_rate_limited_and_update(namespace, &ctx, 1, false)` + under an async mutex shared by the plugin instance. Limitador applies its + own CEL `conditions` to pick matching limits. The mutex serializes the + in-memory check and update across concurrent requests to this instance. +4. If limited, return `PluginResult::deny(PluginViolation::new(code, msg))` + (`crates/ppe-core/src/hooks/trait_def.rs`) with `proto_error_code: 429` and + `details["http.status"]: 429`; otherwise `PluginResult::allow()`. + +### Attribute mapping + +Limitador 0.13 evaluates its **own** CEL over a context the plugin supplies. +The PoC uses the same extension-to-bag mapping as APL for PPE-native +attributes, but builds a local bag because APL's route-level bag is not passed +to plugins. Limitador's public context accepts flat string variables, so +`bindings` map dotted PPE bag keys to simple CEL variable names. + +In the compatibility stage, the plugin would populate its context from the +shared compatibility mapping. Translating +raw identity claims and request fields into Kuadrant well-known names +(`auth.identity.*`, `request.*`) is owned by the compatibility layer +(`engine_settings.kuadrant_compat: true`, 00130 Approach A; the mapping itself +fixed by 00133). Reusing it would keep one source of truth and avoid a second +map in this plugin. + +That is the compatibility target. The current APL bag is built inside the +route handler and is not passed to the plugin, so its route-only `route.key` +and `data.*` attributes are absent from the plugin-local bag. A later +integration must reuse the shared compatibility mapping when it lands. + +Consequence: running a Kuadrant `RateLimitPolicy`'s conditions verbatim depends +on that compat layer being enabled, so the bag already carries the WKA-shaped +attributes the conditions reference. What is unresolved is whether those +compat-mapped names are visible to a plugin at the `http.request` hook, or only +the raw identity and request fields are; the later compatibility integration +must answer this (see Open questions). See +[00133](00133_kuadrant-authpolicy-attribute-mapping.md). + +### Storage + +- **Spike:** Limitador in-memory storage (`RateLimiter::new(capacity)`). + Per-replica counters, lost on restart. Acceptable for proving the mechanism; + not shared limiting. A plugin-local async mutex prevents concurrent requests + to one instance from over-admitting during the in-memory check/update. +- **Production:** the `limitador` crate supports a redis-like backend + (`RedisStorage` / `AsyncRedisStorage`). The connection/storage handle is + **injected by the host** through `Extensions` and passed to the plugin, the + same ownership model the quota plugin uses for the host HTTP transport. The + plugin constructs no connection, owns no pool, and runs no background task. + The in-memory vs shared choice is then a host wiring decision, not a plugin + rewrite. + +### Config schema (illustrative compatibility target) + +```yaml +plugins: + - name: app-ratelimit + kind: ratelimit/limitador + hooks: [http.request] # registered in code; declarative only + capabilities: [read_subject, read_claims] + config: + namespace: toystore + limits: + - max: 5 + seconds: 10 + conditions: ["auth.identity.userid == 'alice'"] + variables: [] + - max: 2 + seconds: 10 + conditions: ["auth.identity.userid == 'bob'"] + variables: [] +``` + +Each `limits[]` entry maps to `Limit::new(namespace, max, seconds, conditions, +variables)`. Typed config with +`#[serde(deny_unknown_fields)]` and a `validate()` at construction, matching +`QuotaConfig`. + +### Capabilities + +- `read_subject` — to expose `subject.id` to the plugin-local bag. +- `read_claims` — needed when a binding reads a `claim.*` attribute, as in + the PoC's `claim.plan` test. +- `read_headers` — required to read `HttpExtension`, including method and path. +- No `perform_http`: in-memory storage makes no outbound call. A host-injected + shared store reaches the network through host-owned machinery, so the + capability story there is a host concern, resolved when that path is built. + +## Worked example (the PoC target) + +A Kuadrant `RateLimitPolicy` targeting the `toystore` HTTPRoute: alice gets +5 req / 10 s, bob gets 2 req / 10 s, selected by +`auth.identity.userid == 'alice' | 'bob'`. The first PoC instead uses +PPE-native `subject_id` conditions. A resolved `subject.id` is not guaranteed +to equal an `auth.identity.userid` claim. Its in-process PPE test proves the 6th +alice request and 3rd bob request within the window are refused and that the +denial carries `proto_error_code: 429` and `details["http.status"]: 429`. +The separate in-process header-binding test uses a caller-controlled +`X-Demo-User` header to check the native attribute path. A later gateway test +will check it on real HTTP requests, and the compatibility stage will run the +original Kuadrant conditions. + +## Alternatives considered + +- **Second backend under the quota plugin.** Rejected: request-rate limiting + has no debit phase, uses the `http.request` hook not `cmf.llm_*`, and selects + via Limitador CEL rather than a single descriptor, so it does not fit quota's + `check`+`report` backend trait. The shared asset is the `limitador` crate, + not a plugin surface. +- **Remote-only.** Rejected for the spike: #152 asks whether *embedding* the + crate works. The host-injected shared store keeps a distributed deployment + open without a network client in this plugin. +- **Design a full storage abstraction up front.** Partially adopted: the + host-injected handle *is* the abstraction, but the spike builds only the + in-memory arm. + +## Open questions + +1. **Wire-status rendering of 429.** The current `praxis-proxy-filter` 0.7.3 + generic-HTTP adapter reads `details["http.status"]` when choosing the HTTP + response status; it does not use `proto_error_code` for that path. The PoC + sets both fields, and its in-process test asserts both. A live gateway run + is still needed to confirm a real 429 and + `X-Policy-Violation: ratelimit.exceeded` end to end. +2. **Host storage-injection API.** The exact `Extensions` seam for passing a + `limitador` storage/`RateLimiter` handle from host to plugin is not yet + designed; the spike uses an in-process in-memory instance. +3. **Compat-mapped attribute visibility.** Whether the Kuadrant WKA names the + compat layer produces (`auth.identity.*`, `request.*`) are reachable by a + plugin at the `http.request` hook, or only the raw identity and request + fields are. This decides whether a `RateLimitPolicy`'s conditions run + verbatim or need the bag's native names. + +## PoC plan + +Verification proceeds in three stages. Steps 1–3 are implemented in-process; +steps 4–5 remain future work. + +1. Add the `limitador` crate (in-memory feature) and a minimal + `ratelimit/limitador` plugin behind an experimental feature. +2. Wire the `http.request` handler: capability-filtered Extensions → PPE + attribute bag → configured string bindings → Limitador context → + `check_rate_limited_and_update` → allow/deny, setting + `proto_error_code = 429` and `details["http.status"] = 429` on a limited + deny. +3. **In-process PPE test (in-repo):** fire N `http.request` invocations through + the engine with injected identity; assert the limiter counts, selects the + right limit by CEL, passes a `claim.plan` bag attribute through a configured + binding, and that the deny violation carries `proto_error_code == 429` and + HTTP status detail 429. + Exercise both `global` and route-level `run(name)` placement, including a + request outside the route. Reject inherited HTTP-only limiter steps under + `tool:` and `llm:` routes at startup. No gateway or network. +4. **Real HTTP verification (against `praxis-ai`):** build the gateway with this + PPE worktree and `experimental-ratelimit`, then check allowed requests, + global and route limits, and an on-wire 429. A local smoke policy may bind + `http.request_headers.x-demo-user` solely as a test selector. The live + gateway run is still needed; local demo scripts are outside this PoC. +5. **Kuadrant compatibility:** after the shared mapping is available, run the + original `auth.identity.*` conditions with authenticated requests and + compare the behavior with `RateLimitPolicy`. + +## References + +### Verified (this repo) + +- `crates/builtins/src/plugins/quota/` — factory, config, backend trait, + README; the structural template this plugin mirrors. +- `crates/ppe-core/src/http_hook.rs:40` — `HOOK_HTTP_REQUEST` / `HttpHook`. +- `crates/ppe-core/src/hooks/trait_def.rs` — `PluginResult::{allow,deny}`. +- `crates/ppe-core/src/error.rs` — `PluginViolation`, including + `proto_error_code` and structured `details`. +- `crates/builtins/src/plugins/quota/handlers.rs:343` — precedent: + `.with_proto_error_code(429)` on an over-budget deny. +- `crates/ppe-apl-runtime/src/visitor.rs` — plugin chain-deny from a route step. +- [00130](00130_kuadrant-adapter-compatibility.md), + [00133](00133_kuadrant-authpolicy-attribute-mapping.md) — Kuadrant + compatibility and attribute mapping. + +### Unverified (external) + +- `limitador` v0.13.0 (crates.io / docs.rs) — shared-storage integration + remains unverified by this PoC. +- Kuadrant `RateLimitPolicy` semantics and the toystore example (Kuadrant docs).