Skip to content

Commit e56a8d4

Browse files
authored
Merge pull request #31 from barbacane-dev/feat/cel-middleware
feat: add cel middleware plugin
2 parents 173e583 + 53fa7c7 commit e56a8d4

14 files changed

Lines changed: 1657 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- Auth context headers: `x-auth-sub`, `x-auth-scope`, `x-auth-claims`
2323
- `issuer_override` config option for split-network environments (e.g., Docker)
2424

25+
#### CEL Policy Evaluation
26+
- `cel` middleware plugin — inline expression-based access control via [CEL](https://cel.dev/)
27+
- Pre-compiled expressions for microsecond-latency evaluation
28+
- Full request context: method, path, headers, query, body, client IP, path params
29+
- Auth integration: `request.consumer` and `request.claims` from upstream auth plugins
30+
- CEL standard library: `startsWith`, `endsWith`, `contains`, `exists`, `has`, `in`, `matches`
31+
- Problem+json error responses (RFC 9457)
32+
2533
#### ACL & Consumer Headers
2634
- `acl` middleware plugin — group and consumer-based access control
2735
- Allow/deny lists for consumer groups

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
<a href="https://github.com/barbacane-dev/barbacane/actions/workflows/ci.yml"><img src="https://github.com/barbacane-dev/barbacane/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
1111
<a href="https://docs.barbacane.dev"><img src="https://img.shields.io/badge/docs-docs.barbacane.dev-blue" alt="Documentation"></a>
1212
<img src="https://img.shields.io/badge/unit%20tests-288%20passing-brightgreen" alt="Unit Tests">
13-
<img src="https://img.shields.io/badge/plugin%20tests-410%20passing-brightgreen" alt="Plugin Tests">
14-
<img src="https://img.shields.io/badge/integration%20tests-166%20passing-brightgreen" alt="Integration Tests">
13+
<img src="https://img.shields.io/badge/plugin%20tests-444%20passing-brightgreen" alt="Plugin Tests">
14+
<img src="https://img.shields.io/badge/integration%20tests-171%20passing-brightgreen" alt="Integration Tests">
1515
<img src="https://img.shields.io/badge/rust-1.75%2B-orange" alt="Rust Version">
1616
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue" alt="License"></a>
1717
</p>
@@ -84,6 +84,9 @@ The playground includes a Train Travel API demo with WireMock backend, full obse
8484
| `basic-auth` | Middleware | HTTP Basic authentication (RFC 7617) |
8585
| `oauth2-auth` | Middleware | OAuth2 token introspection |
8686
| `oidc-auth` | Middleware | OpenID Connect (OIDC) authentication |
87+
| `acl` | Middleware | Consumer-based access control lists |
88+
| `opa-authz` | Middleware | Open Policy Agent authorization |
89+
| `cel` | Middleware | Inline CEL expression policy evaluation |
8790
| `rate-limit` | Middleware | Sliding window rate limiting |
8891
| `cache` | Middleware | Response caching |
8992
| `cors` | Middleware | CORS header management |

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Near-term items ready to be picked up:
5656
| ~~`acl`~~ | ~~Middleware~~ | ~~Access control by consumer/group after auth~~**done** |
5757
| ~~`request-size-limit`~~ | ~~Middleware~~ | ~~Reject requests exceeding size (per-route)~~**done** |
5858
| ~~`oidc-auth`~~ | ~~Middleware~~ | ~~OpenID Connect discovery + JWKS validation~~**done** |
59+
| ~~`cel`~~ | ~~Middleware~~ | ~~CEL expression-based inline policy evaluation~~**done** |
5960

6061
### P2 — Nice to Have
6162

crates/barbacane-test/src/gateway.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,9 @@ impl TestGateway {
213213
/// Wait for the gateway to be ready by polling the health endpoint.
214214
async fn wait_for_ready(&mut self) -> Result<(), TestError> {
215215
let health_url = format!("{}/__barbacane/health", self.base_url());
216-
// Increase timeout for CI environments (15 seconds instead of 5)
217-
let max_attempts = 150;
216+
// 60-second timeout — larger WASM plugins (e.g. CEL ~1.3 MB) need
217+
// more JIT compile time, especially under heavy parallel test load.
218+
let max_attempts = 600;
218219
let delay = Duration::from_millis(100);
219220

220221
for _ in 0..max_attempts {
@@ -4223,4 +4224,73 @@ paths:
42234224
let resp = gateway.get("/public").await.unwrap();
42244225
assert_eq!(resp.status(), 200);
42254226
}
4227+
4228+
// -----------------------------------------------------------------------
4229+
// CEL policy evaluation tests
4230+
// -----------------------------------------------------------------------
4231+
4232+
#[tokio::test]
4233+
async fn test_cel_method_check_get_allowed() {
4234+
let gateway = TestGateway::from_spec("../../tests/fixtures/cel.yaml")
4235+
.await
4236+
.expect("failed to start gateway");
4237+
let resp = gateway.get("/cel-method-check").await.unwrap();
4238+
assert_eq!(resp.status(), 200);
4239+
}
4240+
4241+
#[tokio::test]
4242+
async fn test_cel_method_check_post_denied() {
4243+
let gateway = TestGateway::from_spec("../../tests/fixtures/cel.yaml")
4244+
.await
4245+
.expect("failed to start gateway");
4246+
let resp = gateway
4247+
.request_builder(reqwest::Method::POST, "/cel-method-check")
4248+
.send()
4249+
.await
4250+
.unwrap();
4251+
assert_eq!(resp.status(), 403);
4252+
}
4253+
4254+
fn cel_basic_auth(username: &str, password: &str) -> String {
4255+
use base64::{engine::general_purpose::STANDARD, Engine};
4256+
let encoded = STANDARD.encode(format!("{}:{}", username, password));
4257+
format!("Basic {}", encoded)
4258+
}
4259+
4260+
#[tokio::test]
4261+
async fn test_cel_with_auth_admin_allowed() {
4262+
let gateway = TestGateway::from_spec("../../tests/fixtures/cel.yaml")
4263+
.await
4264+
.expect("failed to start gateway");
4265+
let resp = gateway
4266+
.request_builder(reqwest::Method::GET, "/cel-with-auth")
4267+
.header("Authorization", cel_basic_auth("admin", "admin123"))
4268+
.send()
4269+
.await
4270+
.unwrap();
4271+
assert_eq!(resp.status(), 200);
4272+
}
4273+
4274+
#[tokio::test]
4275+
async fn test_cel_with_auth_viewer_denied() {
4276+
let gateway = TestGateway::from_spec("../../tests/fixtures/cel.yaml")
4277+
.await
4278+
.expect("failed to start gateway");
4279+
let resp = gateway
4280+
.request_builder(reqwest::Method::GET, "/cel-with-auth")
4281+
.header("Authorization", cel_basic_auth("viewer", "viewer123"))
4282+
.send()
4283+
.await
4284+
.unwrap();
4285+
assert_eq!(resp.status(), 403);
4286+
}
4287+
4288+
#[tokio::test]
4289+
async fn test_cel_public_endpoint_bypasses() {
4290+
let gateway = TestGateway::from_spec("../../tests/fixtures/cel.yaml")
4291+
.await
4292+
.expect("failed to start gateway");
4293+
let resp = gateway.get("/public").await.unwrap();
4294+
assert_eq!(resp.status(), 200);
4295+
}
42264296
}

crates/barbacane-wasm/src/instance.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,6 +1597,150 @@ fn add_host_functions(linker: &mut Linker<PluginState>) -> Result<(), WasmError>
15971597
state.last_broker_result.take()
15981598
})?;
15991599

1600+
// ── Minimal WASI stubs ──────────────────────────────────────────────
1601+
// Some plugins (e.g. cel) compile with wasm32-wasip1 and import WASI
1602+
// functions even though Barbacane provides its own host ABI. We add
1603+
// lightweight stubs so the linker can resolve these imports.
1604+
// Full WASI support (wasmtime-wasi) can replace these if needed.
1605+
1606+
// random_get — deterministic bytes for HashMap seed initialisation.
1607+
// Acceptable in a sandboxed single-request context (no HashDoS risk).
1608+
linker
1609+
.func_wrap(
1610+
"wasi_snapshot_preview1",
1611+
"random_get",
1612+
|mut caller: Caller<'_, PluginState>, buf_ptr: i32, buf_len: i32| -> i32 {
1613+
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
1614+
Some(m) => m,
1615+
None => return 1,
1616+
};
1617+
let start = buf_ptr as usize;
1618+
let end = start + buf_len as usize;
1619+
let data = memory.data_mut(&mut caller);
1620+
if end > data.len() {
1621+
return 1;
1622+
}
1623+
for (i, byte) in data[start..end].iter_mut().enumerate() {
1624+
*byte = (i.wrapping_mul(0x9E3779B9) >> 24) as u8;
1625+
}
1626+
0
1627+
},
1628+
)
1629+
.map_err(|e| WasmError::Instantiation(format!("wasi stub: {e}")))?;
1630+
1631+
// sched_yield — no-op, always succeeds.
1632+
linker
1633+
.func_wrap("wasi_snapshot_preview1", "sched_yield", || -> i32 { 0 })
1634+
.map_err(|e| WasmError::Instantiation(format!("wasi stub: {e}")))?;
1635+
1636+
// clock_time_get — returns current time in nanoseconds.
1637+
linker
1638+
.func_wrap(
1639+
"wasi_snapshot_preview1",
1640+
"clock_time_get",
1641+
|mut caller: Caller<'_, PluginState>,
1642+
_clock_id: i32,
1643+
_precision: i64,
1644+
time_ptr: i32|
1645+
-> i32 {
1646+
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
1647+
Some(m) => m,
1648+
None => return 1,
1649+
};
1650+
let nanos = std::time::SystemTime::now()
1651+
.duration_since(std::time::UNIX_EPOCH)
1652+
.map(|d| d.as_nanos() as u64)
1653+
.unwrap_or(0);
1654+
let ptr = time_ptr as usize;
1655+
let data = memory.data_mut(&mut caller);
1656+
if ptr + 8 > data.len() {
1657+
return 1;
1658+
}
1659+
data[ptr..ptr + 8].copy_from_slice(&nanos.to_le_bytes());
1660+
0
1661+
},
1662+
)
1663+
.map_err(|e| WasmError::Instantiation(format!("wasi stub: {e}")))?;
1664+
1665+
// fd_write — silently discards output (plugins use host_log instead).
1666+
linker
1667+
.func_wrap(
1668+
"wasi_snapshot_preview1",
1669+
"fd_write",
1670+
|mut caller: Caller<'_, PluginState>,
1671+
_fd: i32,
1672+
iovs_ptr: i32,
1673+
iovs_len: i32,
1674+
nwritten_ptr: i32|
1675+
-> i32 {
1676+
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
1677+
Some(m) => m,
1678+
None => return 1,
1679+
};
1680+
let data = memory.data_mut(&mut caller);
1681+
let mut total: u32 = 0;
1682+
for i in 0..iovs_len as usize {
1683+
let base = (iovs_ptr as usize) + i * 8;
1684+
if base + 8 > data.len() {
1685+
return 1;
1686+
}
1687+
let len =
1688+
u32::from_le_bytes(data[base + 4..base + 8].try_into().unwrap_or([0; 4]));
1689+
total = total.saturating_add(len);
1690+
}
1691+
let ptr = nwritten_ptr as usize;
1692+
if ptr + 4 > data.len() {
1693+
return 1;
1694+
}
1695+
data[ptr..ptr + 4].copy_from_slice(&total.to_le_bytes());
1696+
0
1697+
},
1698+
)
1699+
.map_err(|e| WasmError::Instantiation(format!("wasi stub: {e}")))?;
1700+
1701+
// environ_get — no environment variables exposed.
1702+
linker
1703+
.func_wrap(
1704+
"wasi_snapshot_preview1",
1705+
"environ_get",
1706+
|_caller: Caller<'_, PluginState>, _environ: i32, _environ_buf: i32| -> i32 { 0 },
1707+
)
1708+
.map_err(|e| WasmError::Instantiation(format!("wasi stub: {e}")))?;
1709+
1710+
// environ_sizes_get — reports 0 vars, 0 bytes.
1711+
linker
1712+
.func_wrap(
1713+
"wasi_snapshot_preview1",
1714+
"environ_sizes_get",
1715+
|mut caller: Caller<'_, PluginState>, num_ptr: i32, buf_size_ptr: i32| -> i32 {
1716+
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
1717+
Some(m) => m,
1718+
None => return 1,
1719+
};
1720+
let data = memory.data_mut(&mut caller);
1721+
let np = num_ptr as usize;
1722+
let bp = buf_size_ptr as usize;
1723+
if np + 4 > data.len() || bp + 4 > data.len() {
1724+
return 1;
1725+
}
1726+
data[np..np + 4].copy_from_slice(&0u32.to_le_bytes());
1727+
data[bp..bp + 4].copy_from_slice(&0u32.to_le_bytes());
1728+
0
1729+
},
1730+
)
1731+
.map_err(|e| WasmError::Instantiation(format!("wasi stub: {e}")))?;
1732+
1733+
// proc_exit — traps the module (should never be reached).
1734+
linker
1735+
.func_wrap(
1736+
"wasi_snapshot_preview1",
1737+
"proc_exit",
1738+
|_caller: Caller<'_, PluginState>, _code: i32| {
1739+
// Intentional trap — WASM execution stops here
1740+
},
1741+
)
1742+
.map_err(|e| WasmError::Instantiation(format!("wasi stub: {e}")))?;
1743+
16001744
Ok(())
16011745
}
16021746

docs/guide/middlewares.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,111 @@ allow if {
569569
}
570570
```
571571

572+
### cel
573+
574+
Inline policy evaluation using [CEL (Common Expression Language)](https://cel.dev/). Evaluates expressions directly in-process — no external service needed. CEL is the same language used by Envoy, Kubernetes, and Firebase for policy rules.
575+
576+
```yaml
577+
x-barbacane-middlewares:
578+
- name: jwt-auth
579+
config:
580+
issuer: "https://auth.example.com"
581+
jwks_url: "https://auth.example.com/.well-known/jwks.json"
582+
- name: cel
583+
config:
584+
expression: >
585+
'admin' in request.claims.roles
586+
|| (request.method == 'GET' && request.path.startsWith('/public/'))
587+
```
588+
589+
#### Configuration
590+
591+
| Property | Type | Default | Description |
592+
|----------|------|---------|-------------|
593+
| `expression` | string | *(required)* | CEL expression that must evaluate to a boolean |
594+
| `deny_message` | string | `Access denied by policy` | Custom message returned in the 403 response body |
595+
596+
#### Request Context
597+
598+
The expression has access to a `request` object with these fields:
599+
600+
| Variable | Type | Description |
601+
|----------|------|-------------|
602+
| `request.method` | string | HTTP method (`GET`, `POST`, etc.) |
603+
| `request.path` | string | Request path (e.g., `/api/users`) |
604+
| `request.query` | string | Query string (empty string if none) |
605+
| `request.headers` | map | Request headers (e.g., `request.headers.authorization`) |
606+
| `request.body` | string | Request body (empty string if none) |
607+
| `request.client_ip` | string | Client IP address |
608+
| `request.path_params` | map | Path parameters (e.g., `request.path_params.id`) |
609+
| `request.consumer` | string | Consumer identity from `x-auth-consumer` header (empty if absent) |
610+
| `request.claims` | map | Parsed JSON from `x-auth-claims` header (empty map if absent/invalid) |
611+
612+
#### CEL Features
613+
614+
CEL supports a rich expression language:
615+
616+
```cel
617+
// String operations
618+
request.path.startsWith('/api/')
619+
request.path.endsWith('.json')
620+
request.headers.host.contains('example')
621+
622+
// List operations
623+
'admin' in request.claims.roles
624+
request.claims.roles.exists(r, r == 'editor')
625+
626+
// Field presence
627+
has(request.claims.email)
628+
629+
// Logical operators
630+
request.method == 'GET' && request.consumer != ''
631+
request.method in ['GET', 'HEAD', 'OPTIONS']
632+
!(request.client_ip.startsWith('192.168.'))
633+
```
634+
635+
#### Decision Logic
636+
637+
| Expression Result | HTTP Response |
638+
|------------------|---------------|
639+
| `true` | Request continues to next middleware/dispatcher |
640+
| `false` | **403** Forbidden |
641+
| Non-boolean | **500** Internal Server Error |
642+
| Parse/evaluation error | **500** Internal Server Error |
643+
644+
#### Error Responses
645+
646+
**403 Forbidden** — expression evaluates to `false`:
647+
648+
```json
649+
{
650+
"type": "urn:barbacane:error:cel-denied",
651+
"title": "Forbidden",
652+
"status": 403,
653+
"detail": "Access denied by policy"
654+
}
655+
```
656+
657+
**500 Internal Server Error** — invalid expression or non-boolean result:
658+
659+
```json
660+
{
661+
"type": "urn:barbacane:error:cel-evaluation",
662+
"title": "Internal Server Error",
663+
"status": 500,
664+
"detail": "expression returned string, expected bool"
665+
}
666+
```
667+
668+
#### CEL vs OPA
669+
670+
| | `cel` | `opa-authz` |
671+
|---|---|---|
672+
| Deployment | Embedded (no sidecar) | External OPA server |
673+
| Language | CEL | Rego |
674+
| Latency | Microseconds (in-process) | HTTP round-trip |
675+
| Best for | Inline route-level rules | Complex policy repos, audit trails |
676+
572677
---
573678

574679
## Rate Limiting

0 commit comments

Comments
 (0)