Skip to content
Merged
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ If the item itself is the resource and the context type is `()`, use `evaluate_b

Policies can override `Policy::evaluate_batch` to collapse backend work. `RebacPolicy` builds `RelationshipQuery` fact keys and loads them through the request-scoped `EvaluationSession`, so deduplication, chunking, caching, and fail-closed source errors live in one `FactSource` layer. Combinator policies (`AndPolicy`, `OrPolicy`, and `NotPolicy`) preserve batching for their inner policies.

Checker and combinator batch evaluation remain sequential by design: they preserve policy order, per-item short-circuiting, and trace shape. Parallel work belongs inside policy implementations or fact sources today; any future checker-level parallel batch API should be explicit. `EvaluationSession` is safe to share across those parallel loaders, and unrelated fact keys do not contend on one global cache or in-flight lock.

`PermissionChecker::with_max_batch_size` caps the number of still-pending items passed to each policy batch call. Fact-backed policies can also set `FactSource::max_batch_size`, which caps source-level loads after session deduplication.

```rust
Expand Down Expand Up @@ -308,14 +310,16 @@ Fallback behavior when `security_rule()` is not overridden:
See the `examples` directory for complete demonstrations of:
- Role-based access control (`rbac_policy`)
- Attribute-style custom policies with `PolicyBuilder` (`policy_builder`)
- Relationship-based access control (`rebac_policy`)
- Relationship-based access control with request-scoped fact sources (`rebac_policy`)
- In-RAM relationship facts shared across request sessions (`in_ram_rebac`)
- PostgreSQL-backed batched relationship facts (`pg18_bulk_rebac`)
- PostgreSQL-backed batched relationship facts for list endpoints (`pg18_bulk_rebac`)
- Group authorization with trace output (`groups_policy`)
- Policy combinators (`combinator_policy`)
- Axum integration with shared policies, app state, request-scoped sessions, and a bulk invoice listing endpoint (`axum`)
- Actix Web integration with shared policies (`actix_web`)

For the v0.3 request-scoped design, start with `rebac_policy`, then read `in_ram_rebac` for a small list endpoint and `pg18_bulk_rebac` for the same boundary backed by SQL.

Run with:

```shell
Expand Down
118 changes: 116 additions & 2 deletions benches/permission_checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ use gatehouse::{
Effect, EvaluationSession, FactLoadResult, FactSource, PermissionChecker, PolicyBuilder,
RebacPolicy, RelationshipQuery,
};
use std::collections::HashMap;
use std::hint::black_box;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::runtime::Runtime;
use tokio::sync::Mutex as AsyncMutex;
Expand Down Expand Up @@ -180,6 +181,30 @@ impl FactSource<BenchRelationship> for LatencyFoundSource {
}
}

struct CoarseReferenceSession {
cache: Mutex<HashMap<BenchRelationship, FactLoadResult<bool>>>,
}

impl CoarseReferenceSession {
fn cached(keys: &[BenchRelationship]) -> Self {
Self {
cache: Mutex::new(
keys.iter()
.cloned()
.map(|key| (key, FactLoadResult::Found(true)))
.collect(),
),
}
}

fn get_many(&self, keys: &[BenchRelationship]) -> Vec<FactLoadResult<bool>> {
let cache = self.cache.lock().unwrap();
keys.iter()
.map(|key| cache.get(key).cloned().unwrap_or(FactLoadResult::Missing))
.collect()
}
}

fn build_rebac_checker() -> PermissionChecker<BenchUser, BenchResource, BenchAction, BenchContext> {
let mut checker = PermissionChecker::new();
checker.add_policy(RebacPolicy::new(
Expand Down Expand Up @@ -414,10 +439,99 @@ fn bench_latency_fact_source(c: &mut Criterion) {
group.finish();
}

fn bench_parallel_fact_state(c: &mut Criterion) {
let runtime = Runtime::new().expect("failed to create Tokio runtime");
let subject = BenchUser { id: Uuid::new_v4() };
let source: Arc<dyn FactSource<BenchRelationship>> = Arc::new(AlwaysFoundSource);
let mut group = c.benchmark_group("parallel_in_ram_fact_state");

for &item_count in &[100usize, 1_000, 10_000] {
let resources = (0..item_count)
.map(|_| BenchResource { id: Uuid::new_v4() })
.collect::<Vec<_>>();
let keys = resources
.iter()
.map(|resource| BenchRelationship {
subject_id: subject.id,
resource_id: resource.id,
relation: BenchRelation::Viewer,
})
.collect::<Vec<_>>();
let chunk_len = keys.len().div_ceil(4);
let chunks = keys
.chunks(chunk_len)
.map(|chunk| chunk.to_vec())
.collect::<Vec<_>>();
assert_eq!(chunks.len(), 4);

let coarse = Arc::new(CoarseReferenceSession::cached(&keys));
group.bench_with_input(
BenchmarkId::new("coarse_reference_cached_4_tasks", item_count),
&chunks,
|b, chunks| {
b.iter(|| {
let coarse = Arc::clone(&coarse);
runtime.block_on(async {
let chunk_a = black_box(chunks[0].clone());
let chunk_b = black_box(chunks[1].clone());
let chunk_c = black_box(chunks[2].clone());
let chunk_d = black_box(chunks[3].clone());
let coarse_a = Arc::clone(&coarse);
let coarse_b = Arc::clone(&coarse);
let coarse_c = Arc::clone(&coarse);
let coarse_d = Arc::clone(&coarse);
let a = tokio::spawn(async move { coarse_a.get_many(&chunk_a) });
let b = tokio::spawn(async move { coarse_b.get_many(&chunk_b) });
let c = tokio::spawn(async move { coarse_c.get_many(&chunk_c) });
let d = tokio::spawn(async move { coarse_d.get_many(&chunk_d) });
let (a, b, c, d) = tokio::join!(a, b, c, d);
black_box((a.unwrap(), b.unwrap(), c.unwrap(), d.unwrap()))
})
});
},
);

let sharded = Arc::new(
EvaluationSession::builder()
.with_arc::<BenchRelationship>(Arc::clone(&source))
.build(),
);
runtime.block_on(sharded.get_many(&keys));
group.bench_with_input(
BenchmarkId::new("sharded_session_cached_4_tasks", item_count),
&chunks,
|b, chunks| {
b.iter(|| {
let sharded = Arc::clone(&sharded);
runtime.block_on(async {
let chunk_a = black_box(chunks[0].clone());
let chunk_b = black_box(chunks[1].clone());
let chunk_c = black_box(chunks[2].clone());
let chunk_d = black_box(chunks[3].clone());
let sharded_a = Arc::clone(&sharded);
let sharded_b = Arc::clone(&sharded);
let sharded_c = Arc::clone(&sharded);
let sharded_d = Arc::clone(&sharded);
let a = tokio::spawn(async move { sharded_a.get_many(&chunk_a).await });
let b = tokio::spawn(async move { sharded_b.get_many(&chunk_b).await });
let c = tokio::spawn(async move { sharded_c.get_many(&chunk_c).await });
let d = tokio::spawn(async move { sharded_d.get_many(&chunk_d).await });
let (a, b, c, d) = tokio::join!(a, b, c, d);
black_box((a.unwrap(), b.unwrap(), c.unwrap(), d.unwrap()))
})
});
},
);
}

group.finish();
}

criterion_group!(
benches,
bench_permission_checker,
bench_in_ram_fact_source,
bench_latency_fact_source
bench_latency_fact_source,
bench_parallel_fact_state
);
criterion_main!(benches);
63 changes: 34 additions & 29 deletions examples/rebac_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
//!
//! This example demonstrates ReBAC in the v0.3 shape: `RebacPolicy` extracts
//! flat IDs and loads relationship facts through a request-scoped
//! `EvaluationSession`. Relationship store failures are returned as
//! `EvaluationSession`. The happy path declares sources with
//! `EvaluationSession::builder()` so all request-scoped dependencies are
//! visible in one place. Relationship store failures are returned as
//! `FactLoadResult::Error` and fail closed to denial.
//!
//! To run this example:
Expand Down Expand Up @@ -122,10 +124,11 @@ async fn main() {
},
]);

let session = EvaluationSession::new();
session.register::<RelationshipQuery<Uuid, Uuid, String>, _>(ProjectRelationshipSource::new(
relationships.clone(),
));
let session = EvaluationSession::builder()
.with::<RelationshipQuery<Uuid, Uuid, String>, _>(ProjectRelationshipSource::new(
relationships.clone(),
))
.build();

let mut checker = PermissionChecker::<User, Project, EditAction, EmptyContext>::new();
checker.add_policy(RebacPolicy::new(
Expand All @@ -146,10 +149,11 @@ async fn main() {
test_access(&checker, &session, &unauthorized, &project).await;

println!("\n=== Error During Relationship Loading ===\n");
let error_session = EvaluationSession::new();
error_session.register::<RelationshipQuery<Uuid, Uuid, String>, _>(
ProjectRelationshipSource::new(relationships).with_error(),
);
let error_session = EvaluationSession::builder()
.with::<RelationshipQuery<Uuid, Uuid, String>, _>(
ProjectRelationshipSource::new(relationships).with_error(),
)
.build();
test_access(&checker, &error_session, &owner, &project).await;

enum_relationship_example().await;
Expand Down Expand Up @@ -240,26 +244,27 @@ async fn enum_relationship_example() {
name: "Typed Project".to_string(),
};

let session = EvaluationSession::new();
session.register::<RelationshipQuery<Uuid, Uuid, Relation>, _>(EnumRelationshipSource {
relationships: HashSet::from([
RelationshipQuery {
subject_id: alice.id,
resource_id: project.id,
relation: Relation::Owner,
},
RelationshipQuery {
subject_id: bob.id,
resource_id: project.id,
relation: Relation::Contributor,
},
RelationshipQuery {
subject_id: charlie.id,
resource_id: project.id,
relation: Relation::Viewer,
},
]),
});
let session = EvaluationSession::builder()
.with::<RelationshipQuery<Uuid, Uuid, Relation>, _>(EnumRelationshipSource {
relationships: HashSet::from([
RelationshipQuery {
subject_id: alice.id,
resource_id: project.id,
relation: Relation::Owner,
},
RelationshipQuery {
subject_id: bob.id,
resource_id: project.id,
relation: Relation::Contributor,
},
RelationshipQuery {
subject_id: charlie.id,
resource_id: project.id,
relation: Relation::Viewer,
},
]),
})
.build();

let mut checker = PermissionChecker::<User, Project, EditAction, EmptyContext>::new();
checker.add_policy(RebacPolicy::new(
Expand Down
Loading
Loading