Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 276 additions & 21 deletions Cargo.lock

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion crates/builtins/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/builtins/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 4 additions & 0 deletions crates/builtins/src/plugins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,3 +27,6 @@ pub mod elicitation_ciba;

#[cfg(feature = "experimental-quota")]
pub mod quota;

#[cfg(feature = "experimental-ratelimit")]
pub mod ratelimit;
178 changes: 178 additions & 0 deletions crates/builtins/src/plugins/ratelimit/README.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 83 additions & 0 deletions crates/builtins/src/plugins/ratelimit/config.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>,
pub limits: Vec<LimitConfig>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct LimitConfig {
pub max: u64,
pub seconds: u64,
#[serde(default)]
pub conditions: Vec<String>,
#[serde(default)]
pub variables: Vec<String>,
}

const fn default_counter_capacity() -> u64 {
10_000
}

fn default_bindings() -> BTreeMap<String, String> {
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(())
}
}
31 changes: 31 additions & 0 deletions crates/builtins/src/plugins/ratelimit/factory.rs
Original file line number Diff line number Diff line change
@@ -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<PluginInstance, Box<PluginError>> {
let core = Arc::new(RateLimit::new(config.clone())?);
let handler: Arc<dyn AnyHookHandler> =
Arc::new(TypedHandlerAdapter::<HttpHook, _>::new(Arc::clone(&core)));
Ok(PluginInstance {
plugin: core,
handlers: vec![(HOOK_HTTP_REQUEST, handler)],
})
}
}
Loading