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
66 changes: 66 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ tower = "0.5"
hyper = "1"
actix-web = "4"
criterion = { version = "0.8" }
tracing-subscriber = "0.3"

[[bench]]
name = "permission_checker"
Expand Down
133 changes: 119 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,53 +11,130 @@ A flexible authorization library that combines role-based (RBAC), attribute-base
## Features
- **Multi-paradigm Authorization**: Support for RBAC, ABAC, and ReBAC patterns
- **Policy Composition**: Combine policies with logical operators (`AND`, `OR`, `NOT`)
- **Detailed Evaluation Tracing**: Complete decision trace for debugging and auditing
- **Detailed Evaluation Tracing**: Decision trace for the policies and branches that were actually evaluated
- **Fluent Builder API**: Construct custom policies with a PolicyBuilder.
- **Type Safety**: Strongly typed resources/actions/contexts
- **Async Ready**: Built with async/await support

## Quick Start

```rust
use gatehouse::*;

#[derive(Debug, Clone)]
struct User {
id: u64,
roles: Vec<String>,
}

#[derive(Debug, Clone)]
struct Document {
owner_id: u64,
}

#[derive(Debug, Clone)]
struct Action;

#[derive(Debug, Clone)]
struct Context;

let admin_policy = PolicyBuilder::<User, Document, Action, Context>::new("AdminOnly")
.subjects(|user| user.roles.iter().any(|role| role == "admin"))
.build();

let owner_policy = PolicyBuilder::<User, Document, Action, Context>::new("OwnerOnly")
.when(|user, _action, resource, _ctx| resource.owner_id == user.id)
.build();

let mut checker = PermissionChecker::new();
checker.add_policy(admin_policy);
checker.add_policy(owner_policy);

# tokio_test::block_on(async {
let user = User {
id: 1,
roles: vec!["admin".to_string()],
};
let document = Document { owner_id: 1 };

let evaluation = checker
.evaluate_access(&user, &Action, &document, &Context)
.await;

assert!(evaluation.is_granted());
println!("{}", evaluation.display_trace());

let outcome: Result<(), String> = evaluation.to_result(|reason| reason.to_string());
assert!(outcome.is_ok());
# });
Comment on lines +41 to +69
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Quick Start snippet uses rustdoc-style hidden lines (# tokio_test::block_on(async { ... # });). In a README code fence these # prefixes are not hidden, so the example is harder to copy/paste and also depends on tokio_test. Consider rewriting the snippet as a normal #[tokio::main] async fn main() (or similar) without #-prefixed lines so it works as a standalone example.

Suggested change
let admin_policy = PolicyBuilder::<User, Document, Action, Context>::new("AdminOnly")
.subjects(|user| user.roles.iter().any(|role| role == "admin"))
.build();
let owner_policy = PolicyBuilder::<User, Document, Action, Context>::new("OwnerOnly")
.when(|user, _action, resource, _ctx| resource.owner_id == user.id)
.build();
let mut checker = PermissionChecker::new();
checker.add_policy(admin_policy);
checker.add_policy(owner_policy);
# tokio_test::block_on(async {
let user = User {
id: 1,
roles: vec!["admin".to_string()],
};
let document = Document { owner_id: 1 };
let evaluation = checker
.evaluate_access(&user, &Action, &document, &Context)
.await;
assert!(evaluation.is_granted());
println!("{}", evaluation.display_trace());
let outcome: Result<(), String> = evaluation.to_result(|reason| reason.to_string());
assert!(outcome.is_ok());
# });
#[tokio::main]
async fn main() {
let admin_policy = PolicyBuilder::<User, Document, Action, Context>::new("AdminOnly")
.subjects(|user| user.roles.iter().any(|role| role == "admin"))
.build();
let owner_policy = PolicyBuilder::<User, Document, Action, Context>::new("OwnerOnly")
.when(|user, _action, resource, _ctx| resource.owner_id == user.id)
.build();
let mut checker = PermissionChecker::new();
checker.add_policy(admin_policy);
checker.add_policy(owner_policy);
let user = User {
id: 1,
roles: vec!["admin".to_string()],
};
let document = Document { owner_id: 1 };
let evaluation = checker
.evaluate_access(&user, &Action, &document, &Context)
.await;
assert!(evaluation.is_granted());
println!("{}", evaluation.display_trace());
let outcome: Result<(), String> = evaluation.to_result(|reason| reason.to_string());
assert!(outcome.is_ok());
}

Copilot uses AI. Check for mistakes.
```

## Decision Semantics

- `PermissionChecker` evaluates policies sequentially with `OR` semantics and short-circuits on the first grant.
- An empty `PermissionChecker` always denies with the reason `"No policies configured"`.
- `AndPolicy` short-circuits on the first denial; `OrPolicy` short-circuits on the first grant.
- `NotPolicy` inverts the result of its inner policy.
- `PolicyBuilder` combines all configured predicates with `AND` logic.
- `PolicyBuilder::effect(Effect::Deny)` changes a matching policy result from allow to deny; a non-match is still treated as denied/non-applicable. It does not create global "deny overrides allow" behavior when used inside `PermissionChecker`.
- `AccessEvaluation::Denied.reason` is a summary string such as `"All policies denied access"`. Inspect the trace tree for individual policy reasons.
- Evaluation traces only contain policies and branches that were actually evaluated before short-circuiting.

## Core Components

### `Policy` Trait

The foundation of the authorization system:

```rust
use async_trait::async_trait;
use gatehouse::{PolicyEvalResult, SecurityRuleMetadata};

#[async_trait]
trait Policy<Subject, Resource, Action, Context> {
trait Policy<Subject, Resource, Action, Context>: Send + Sync {
async fn evaluate_access(
&self,
subject: &Subject,
action: &Action,
resource: &Resource,
context: &Context,
) -> PolicyEvalResult;

fn policy_type(&self) -> String;

fn security_rule(&self) -> SecurityRuleMetadata {
SecurityRuleMetadata::default()
}
}
```

### `PermissionChecker`

Aggregates multiple policies (e.g. RBAC, ABAC) with `OR` logic by default: if any policy grants access, permission is granted.
Aggregates multiple policies (e.g. RBAC, ABAC) with `OR` logic by default: if any policy grants access, permission is granted. The returned `AccessEvaluation` contains both the final decision and a trace tree of the evaluated policies.

```rust
```rust,ignore
let mut checker = PermissionChecker::new();
checker.add_policy(rbac_policy);
checker.add_policy(owner_policy);

// Check if access is granted
let result = checker.evaluate_access(&user, &action, &resource, &context).await;
if result.is_granted() {
let evaluation = checker.evaluate_access(&user, &action, &resource, &context).await;
if evaluation.is_granted() {
// Access allowed
} else {
// Access denied
}

println!("{}", evaluation.display_trace());
```

### PolicyBuilder
The `PolicyBuilder` provides a fluent API to construct custom policies by chaining predicate functions for
subjects, actions, resources, and context. Once built, the policy can be added to a [`PermissionChecker`].
subjects, actions, resources, and context. Every configured predicate must pass for the built policy to grant access. Once built, the policy can be added to a `PermissionChecker`.

```rust
Use `PolicyBuilder` for synchronous predicate logic. If your policy needs async I/O or external lookups, implement `Policy` directly.

```rust,ignore
let custom_policy = PolicyBuilder::<MySubject, MyResource, MyAction, MyContext>::new("CustomPolicy")
.subjects(|s| /* ... */)
.actions(|a| /* ... */)
Expand All @@ -68,21 +145,49 @@ let custom_policy = PolicyBuilder::<MySubject, MyResource, MyAction, MyContext>:
```

### Built-in Policies
- RbacPolicy: Role-based access control
- AbacPolicy: Attribute-based access control
- RebacPolicy: Relationship-based access control
- `RbacPolicy`: Role-based access control. Grants when at least one required role for `(resource, action)` is present in the subject's roles.
- `AbacPolicy`: Attribute-based access control. Grants when its boolean condition closure returns `true`.
- `RebacPolicy`: Relationship-based access control. Grants when its `RelationshipResolver` returns `true` for the configured relationship.

`RelationshipResolver` returns `bool`, so resolver errors and timeouts need to be handled by the resolver implementation and mapped to `false` or to your own surrounding telemetry/logging strategy.

### Combinators

AndPolicy: Grants access only if all inner policies allow access
OrPolicy: Grants access if any inner policy allows access
NotPolicy: Inverts the decision of an inner policy
- `AndPolicy`: Grants access only if all inner policies allow access. Must be created with at least one policy.
- `OrPolicy`: Grants access if any inner policy allows access. Must be created with at least one policy.
- `NotPolicy`: Inverts the decision of an inner policy.

## Tracing And Telemetry

When trace-level events are enabled, `PermissionChecker::evaluate_access` creates an instrumented span and every evaluated policy records a `trace!` event on the `gatehouse::security` target.

Emitted fields:

- `security_rule.name`
- `security_rule.category`
- `security_rule.description`
- `security_rule.reference`
- `security_rule.ruleset.name`
- `security_rule.uuid`
- `security_rule.version`
- `security_rule.license`
- `event.outcome`
- `policy.type`
- `policy.result.reason`

Fallback behavior when `security_rule()` is not overridden:

- `security_rule.name` falls back to `policy_type()`
- `security_rule.category` falls back to `"Access Control"`
- `security_rule.ruleset.name` falls back to `"PermissionChecker"`

## Examples

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`)
- Group authorization with trace output (`groups_policy`)
- Policy combinators (`combinator_policy`)
- Axum integration with shared policies (`axum`)
- Actix Web integration with shared policies (`actix_web`)
Expand Down
2 changes: 1 addition & 1 deletion examples/groups_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,5 +138,5 @@ async fn main() {
// Evaluation Trace:
// ✔ PermissionChecker (OR)
// ✘ OrgAdminPolicy DENIED: User is not organization admin
// ✔ PartlyStaffPolicy GRANTED: User has staff permission
// ✔ StaffPolicy GRANTED: User has staff permission
}
5 changes: 3 additions & 2 deletions examples/rebac_policy.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! # Relationship-Based Access Control Policy Example
//!
//! This example demonstrates how to use the built-in ReBAC policy
//! for relationship-based permissions management, including error handling
//! during relationship resolution.
//! for relationship-based permissions management, including how resolver
//! failures such as database errors or timeouts must be flattened into
//! denial because `RelationshipResolver` returns `bool`.
//!
//! To run this example:
//! ```
Expand Down
Loading
Loading