├── .github ├── logo.svg └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── axum.rs ├── combinator_policy.rs ├── groups_policy.rs ├── policy_builder.rs ├── rbac_policy.rs └── rebac_policy.rs └── src └── lib.rs /.github/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | release: 9 | types: [ published ] 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | RUST_BACKTRACE: full 14 | 15 | jobs: 16 | build-and-test: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Check out repository 21 | uses: actions/checkout@v4 22 | - name: Cache cargo & target directories 23 | uses: Swatinem/rust-cache@v2 24 | with: 25 | key: "v2" 26 | - name: Set up Rust 27 | uses: dtolnay/rust-toolchain@stable 28 | - name: Build 29 | run: cargo build --verbose 30 | - name: Lint 31 | run: cargo clippy --all-targets --all-features -- -D warnings 32 | - name: Build Docs 33 | run: cargo doc --verbose 34 | - name: Test 35 | run: cargo test --all-targets --all-features 36 | 37 | release: 38 | name: Release 39 | needs: build-and-test 40 | runs-on: ubuntu-latest 41 | if: github.event.release && github.event.action == 'published' 42 | steps: 43 | - name: Check out repository 44 | uses: actions/checkout@v4 45 | - name: Cache cargo & target directories 46 | uses: Swatinem/rust-cache@v2 47 | with: 48 | key: "v2" 49 | - name: Set up Rust 50 | uses: dtolnay/rust-toolchain@stable 51 | - name: Build 52 | run: cargo build --release --verbose 53 | - name: Cargo Login 54 | run: cargo login ${{ secrets.CRATES_IO_API_TOKEN }} 55 | - name: Publish 56 | run: cargo publish 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gatehouse" 3 | version = "0.1.3" 4 | edition = "2021" 5 | license = "MIT" 6 | repository = "https://github.com/thepartly/gatehouse" 7 | description = "A flexible authorization library that combines role-based (RBAC), attribute-based (ABAC), and relationship-based (ReBAC) access control policies." 8 | 9 | [dependencies] 10 | tracing = "0.1" 11 | uuid = {version="1", features = ["serde", "v4"]} 12 | async-trait = "0.1" 13 | 14 | [dev-dependencies] 15 | tokio = { version = "1", features = ["full", "test-util"] } 16 | tokio-test = "0.4" 17 | axum = "0.8" 18 | tower = "0.5" 19 | hyper = "1" 20 | 21 | [[example]] 22 | name = "axum" 23 | doc-scrape-examples = true 24 | 25 | [package.metadata.docs.rs] 26 | # Scrape examples from the documentation. 27 | cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] 28 | 29 | # We want to document all features. 30 | all-features = true 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Partly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gatehouse 2 | 3 | [![Build status](https://github.com/thepartly/gatehouse/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/thepartly/gatehouse/actions/workflows/ci.yml) 4 | [![Crates.io](https://img.shields.io/crates/v/gatehouse)](https://crates.io/crates/gatehouse) 5 | [![Documentation](https://docs.rs/gatehouse/badge.svg)](https://docs.rs/gatehouse) 6 | 7 | A flexible authorization library that combines role-based (RBAC), attribute-based (ABAC), and relationship-based (ReBAC) access control policies. 8 | 9 | ![Gatehouse Logo](https://raw.githubusercontent.com/thepartly/gatehouse/main/.github/logo.svg) 10 | 11 | ## Features 12 | - **Multi-paradigm Authorization**: Support for RBAC, ABAC, and ReBAC patterns 13 | - **Policy Composition**: Combine policies with logical operators (`AND`, `OR`, `NOT`) 14 | - **Detailed Evaluation Tracing**: Complete decision trace for debugging and auditing 15 | - **Fluent Builder API**: Construct custom policies with a PolicyBuilder. 16 | - **Type Safety**: Strongly typed resources/actions/contexts 17 | - **Async Ready**: Built with async/await support 18 | 19 | ## Core Components 20 | 21 | ### `Policy` Trait 22 | 23 | The foundation of the authorization system: 24 | 25 | ```rust 26 | #[async_trait] 27 | trait Policy { 28 | async fn evaluate_access( 29 | &self, 30 | subject: &Subject, 31 | action: &Action, 32 | resource: &Resource, 33 | context: &Context, 34 | ) -> PolicyEvalResult; 35 | } 36 | ``` 37 | 38 | ### `PermissionChecker` 39 | 40 | Aggregates multiple policies (e.g. RBAC, ABAC) with `OR` logic by default: if any policy grants access, permission is granted. 41 | 42 | ```rust 43 | let mut checker = PermissionChecker::new(); 44 | checker.add_policy(rbac_policy); 45 | checker.add_policy(owner_policy); 46 | 47 | // Check if access is granted 48 | let result = checker.evaluate_access(&user, &action, &resource, &context).await; 49 | if result.is_granted() { 50 | // Access allowed 51 | } else { 52 | // Access denied 53 | } 54 | ``` 55 | 56 | ### PolicyBuilder 57 | The `PolicyBuilder` provides a fluent API to construct custom policies by chaining predicate functions for 58 | subjects, actions, resources, and context. Once built, the policy can be added to a [`PermissionChecker`]. 59 | 60 | ```rust 61 | let custom_policy = PolicyBuilder::::new("CustomPolicy") 62 | .subjects(|s| /* ... */) 63 | .actions(|a| /* ... */) 64 | .resources(|r| /* ... */) 65 | .context(|c| /* ... */) 66 | .when(|s, a, r, c| /* ... */) 67 | .build(); 68 | ``` 69 | 70 | ### Built-in Policies 71 | - RbacPolicy: Role-based access control 72 | - AbacPolicy: Attribute-based access control 73 | - RebacPolicy: Relationship-based access control 74 | 75 | ### Combinators 76 | 77 | AndPolicy: Grants access only if all inner policies allow access 78 | OrPolicy: Grants access if any inner policy allows access 79 | NotPolicy: Inverts the decision of an inner policy 80 | 81 | ## Examples 82 | 83 | See the `examples` directory for complete demonstration of: 84 | - Role-based access control (`rbac_policy`) 85 | - Relationship-based access control (`rebac_policy`) 86 | - Policy combinators (`combinator_policy`) 87 | 88 | Run with: 89 | 90 | ```shell 91 | cargo run --example rbac_policy 92 | ``` 93 | -------------------------------------------------------------------------------- /examples/axum.rs: -------------------------------------------------------------------------------- 1 | //! Axum service that authorizes multiple resource types (Invoices, Payments) 2 | //! using a single PermissionChecker. Demonstrates multiple policies and actions. 3 | 4 | use axum::{ 5 | extract::{Extension, Path}, 6 | http::StatusCode, 7 | response::IntoResponse, 8 | routing::{get, post}, 9 | Router, 10 | }; 11 | use gatehouse::*; 12 | use std::sync::Arc; 13 | 14 | use axum::extract::Request; 15 | use std::time::{Duration, SystemTime}; 16 | use uuid::Uuid; 17 | 18 | // -------------------- 19 | // 1) Domain Modeling 20 | // -------------------- 21 | 22 | #[derive(Debug, Clone)] 23 | pub struct User { 24 | pub id: Uuid, 25 | pub roles: Vec, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | pub struct AuthenticatedUser(pub User); 30 | 31 | impl axum::extract::FromRequest for AuthenticatedUser 32 | where 33 | S: Send + Sync, 34 | { 35 | type Rejection = (StatusCode, String); 36 | 37 | async fn from_request(_req: Request, _state: &S) -> Result { 38 | // logic for extracting a user (e.g. decode a token, etc.) 39 | let user = User { 40 | id: Uuid::new_v4(), 41 | roles: vec!["admin".to_string()], 42 | }; 43 | Ok(AuthenticatedUser(user)) 44 | } 45 | } 46 | 47 | /// Main "Action" enum. Your app might have more actions: 48 | /// - Edit an invoice 49 | /// - Approve a payment 50 | /// - Refund a payment 51 | /// - View a resource, etc. 52 | #[derive(Debug, Clone)] 53 | pub enum Action { 54 | Edit, // e.g. editing an invoice 55 | ApprovePayment, // e.g. approving a payment resource 56 | RefundPayment, // e.g. refunding a payment 57 | View, // a generic "view" action 58 | } 59 | 60 | /// Two resource types in our app: invoices and payments. We wrap them 61 | /// in a single enum to share one PermissionChecker across different routes/resources. 62 | #[derive(Debug, Clone)] 63 | pub enum Resource { 64 | Invoice(Invoice), 65 | Payment(Payment), 66 | } 67 | 68 | /// An invoice resource. For example, you can only edit it if it isn't locked and 69 | /// it's within 30 days of creation (unless you're an admin, which overrides). 70 | #[derive(Debug, Clone)] 71 | pub struct Invoice { 72 | pub id: Uuid, 73 | pub owner_id: Uuid, 74 | pub locked: bool, 75 | pub created_at: SystemTime, 76 | } 77 | 78 | /// A payment resource. Let’s suppose we can only approve or refund it if we have 79 | /// certain roles (e.g., "finance_manager" or "admin"). 80 | #[derive(Debug, Clone)] 81 | pub struct Payment { 82 | pub id: Uuid, 83 | pub invoice_id: Uuid, 84 | pub is_refunded: bool, 85 | pub approved: bool, 86 | } 87 | 88 | /// Extra context. Could include "current_time", "feature flags", "organization info", etc. 89 | #[derive(Debug, Clone)] 90 | pub struct RequestContext { 91 | pub current_time: SystemTime, 92 | } 93 | 94 | /// -------------------------- 95 | /// 2) Building Our Policies 96 | /// -------------------------- 97 | /// We'll create multiple policies that each handle a slice of the logic. 98 | /// Then we combine them with OR or AND as needed. 99 | /// 100 | /// (A) `AdminOverridePolicy` 101 | /// Allows any action on any resource if user has the "admin" role. 102 | fn admin_override_policy() -> Box> { 103 | PolicyBuilder::::new("AdminOverridePolicy") 104 | .when(|user, _action, _resource, _ctx| user.roles.contains(&"admin".to_string())) 105 | .build() 106 | } 107 | 108 | /// (B) `InvoiceEditingPolicy` 109 | /// Allows editing an invoice if: 110 | /// - It's an `Invoice` resource and the requested action is `Action::Edit`, 111 | /// - The user is the invoice owner, 112 | /// - The invoice is NOT locked, 113 | /// - The invoice is < 30 days old. 114 | /// (If any of these fail, it denies.) 115 | /// We do this by creating four separate sub-policies, `IsInvoiceAndEdit`, `IsOwnerOfInvoice` 116 | /// `InvoiceNotLocked`, `InvoiceAgeUnder30Days`. 117 | /// Then we AND them together. If any sub-policy fails, you’ll see which one did 118 | /// in the evaluation trace (with its own reason). 119 | fn invoice_editing_policy() -> Box> { 120 | // Sub-policy #1: Check that (resource=Invoice) and (action=Edit). 121 | let is_invoice_and_edit = 122 | PolicyBuilder::::new("IsInvoiceAndEdit") 123 | .when(|_user, action, resource, _ctx| { 124 | matches!(action, Action::Edit) && matches!(resource, Resource::Invoice(_)) 125 | }) 126 | .build(); 127 | 128 | // Sub-policy #2: Must be the owner of the invoice. 129 | // We must ensure we only run the check *if* it's an Invoice; else we treat it as failing. 130 | let is_owner = PolicyBuilder::::new("IsOwnerOfInvoice") 131 | .when(|user, _action, resource, _ctx| match resource { 132 | Resource::Invoice(inv) => user.id == inv.owner_id, 133 | _ => false, 134 | }) 135 | .build(); 136 | 137 | // Sub-policy #3: Invoice must not be locked 138 | let invoice_not_locked = 139 | PolicyBuilder::::new("InvoiceNotLocked") 140 | .when(|_user, _action, resource, _ctx| match resource { 141 | Resource::Invoice(inv) => !inv.locked, 142 | _ => false, 143 | }) 144 | .build(); 145 | 146 | // Sub-policy #4: Invoice must be < 30 days old 147 | const THIRTY_DAYS: u64 = 30 * 24 * 60 * 60; 148 | let invoice_age_under_30_days = 149 | PolicyBuilder::::new("InvoiceAgeUnder30Days") 150 | .when(move |_user, _action, resource, ctx| match resource { 151 | Resource::Invoice(inv) => { 152 | let age_secs = ctx 153 | .current_time 154 | .duration_since(inv.created_at) 155 | .unwrap_or_default() 156 | .as_secs(); 157 | age_secs <= THIRTY_DAYS 158 | } 159 | _ => false, 160 | }) 161 | .build(); 162 | 163 | // Now AND them together: 164 | let and_policy = AndPolicy::try_new(vec![ 165 | Arc::from(is_invoice_and_edit), 166 | Arc::from(is_owner), 167 | Arc::from(invoice_not_locked), 168 | Arc::from(invoice_age_under_30_days), 169 | ]) 170 | .expect("Should have at least one policy in the AND set"); 171 | 172 | // Return as a boxed dyn Policy 173 | Box::new(and_policy) 174 | } 175 | 176 | /// (C) `PaymentApprovePolicy` 177 | /// Allows approving a payment if: 178 | /// - It's a `Payment` resource 179 | /// - Action is `Action::ApprovePayment` 180 | /// - The user has "finance_manager" (or "admin", but we have AdminOverride separately) 181 | fn payment_approve_policy() -> Box> { 182 | PolicyBuilder::::new("PaymentApprovePolicy") 183 | .when(|user, action, resource, _ctx| match resource { 184 | Resource::Payment(_payment) => { 185 | matches!(action, Action::ApprovePayment) 186 | && user.roles.contains(&"finance_manager".to_string()) 187 | } 188 | _ => false, 189 | }) 190 | .build() 191 | } 192 | 193 | /// (D) `PaymentRefundPolicy` 194 | /// Allows refunding a payment if: 195 | /// - It's a `Payment` resource 196 | /// - Action is `Action::RefundPayment` 197 | /// - The user has "finance_manager" 198 | /// OR some other "refund" special role. (We’ll keep it simple.) 199 | fn payment_refund_policy() -> Box> { 200 | // Alternatively, we can just do a single condition for finance_manager, 201 | // or combine them. Here let's say "finance_manager" or "refund_specialist". 202 | Box::new(AbacPolicy::new( 203 | |user: &User, resource: &Resource, action: &Action, _ctx: &RequestContext| { 204 | if let Resource::Payment(_) = resource { 205 | if matches!(action, Action::RefundPayment) { 206 | return user.roles.contains(&"finance_manager".into()) 207 | || user.roles.contains(&"refund_specialist".into()); 208 | } 209 | } 210 | false 211 | }, 212 | )) 213 | } 214 | 215 | /// (E) Combine all relevant policies into a single `PermissionChecker`. 216 | /// The checker uses OR semantics by default: if ANY policy returns Granted, 217 | /// the request is allowed. 218 | fn build_permission_checker() -> PermissionChecker { 219 | let mut checker = PermissionChecker::new(); 220 | 221 | // We add them in the order we want them to be evaluated, 222 | // but note that OR short-circuits on the first Granted. So 223 | // e.g. if "AdminOverridePolicy" passes, we never evaluate the others. 224 | checker.add_policy(admin_override_policy()); 225 | checker.add_policy(invoice_editing_policy()); 226 | checker.add_policy(payment_approve_policy()); 227 | checker.add_policy(payment_refund_policy()); 228 | 229 | checker 230 | } 231 | 232 | // --------------------------------- 233 | // 3) Using in Axum Route Handlers 234 | // --------------------------------- 235 | 236 | async fn view_invoice_handler( 237 | Path(_invoice_id): Path, 238 | Extension(checker): Extension>, 239 | AuthenticatedUser(user): AuthenticatedUser, 240 | ) -> impl IntoResponse { 241 | // Simulate DB fetch 242 | let invoice = Invoice { 243 | id: Uuid::new_v4(), 244 | owner_id: Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(), // Example 245 | locked: false, 246 | created_at: SystemTime::now() - Duration::from_secs(10 * 24 * 60 * 60), // 10 days old 247 | }; 248 | 249 | if checker 250 | .evaluate_access( 251 | &user, 252 | &Action::View, 253 | &Resource::Invoice(invoice.clone()), 254 | &RequestContext { 255 | current_time: SystemTime::now(), 256 | }, 257 | ) 258 | .await 259 | .is_granted() 260 | { 261 | (StatusCode::OK, format!("{:?}", invoice)).into_response() 262 | } else { 263 | ( 264 | StatusCode::FORBIDDEN, 265 | "You are not authorized to edit this invoice", 266 | ) 267 | .into_response() 268 | } 269 | } 270 | 271 | async fn edit_invoice_handler( 272 | Path(invoice_id): Path, 273 | Extension(checker): Extension>, 274 | AuthenticatedUser(user): AuthenticatedUser, 275 | ) -> impl IntoResponse { 276 | // Simulate DB fetch 277 | let invoice = Invoice { 278 | id: invoice_id, 279 | owner_id: Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(), // Example 280 | locked: false, 281 | created_at: SystemTime::now() - Duration::from_secs(10 * 24 * 60 * 60), // 10 days old 282 | }; 283 | 284 | let resource = Resource::Invoice(invoice); 285 | let action = Action::Edit; 286 | let context = RequestContext { 287 | current_time: SystemTime::now(), 288 | }; 289 | 290 | let decision = checker 291 | .evaluate_access(&user, &action, &resource, &context) 292 | .await; 293 | 294 | if decision.is_granted() { 295 | // do the editing... 296 | (StatusCode::OK, "Invoice edited successfully").into_response() 297 | } else { 298 | ( 299 | StatusCode::FORBIDDEN, 300 | "You are not authorized to edit this invoice", 301 | ) 302 | .into_response() 303 | } 304 | } 305 | 306 | async fn approve_payment_handler( 307 | Path(payment_id): Path, 308 | Extension(checker): Extension>, 309 | AuthenticatedUser(user): AuthenticatedUser, 310 | ) -> impl IntoResponse { 311 | // Simulate DB fetch 312 | let payment = Payment { 313 | id: payment_id, 314 | invoice_id: Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap(), // Example 315 | is_refunded: false, 316 | approved: false, 317 | }; 318 | 319 | let resource = Resource::Payment(payment); 320 | let action = Action::ApprovePayment; 321 | let context = RequestContext { 322 | current_time: SystemTime::now(), 323 | }; 324 | 325 | let decision = checker 326 | .evaluate_access(&user, &action, &resource, &context) 327 | .await; 328 | 329 | if decision.is_granted() { 330 | // do the approval... 331 | (StatusCode::OK, "Payment approved").into_response() 332 | } else { 333 | ( 334 | StatusCode::FORBIDDEN, 335 | "You are not authorized to approve this payment", 336 | ) 337 | .into_response() 338 | } 339 | } 340 | 341 | // ---------------------------------------- 342 | // 4) The Axum App with Our PermissionChecker 343 | // ---------------------------------------- 344 | 345 | #[tokio::main] 346 | async fn main() { 347 | // Build our single permission checker and share it with handlers as Extension state. 348 | let checker = build_permission_checker(); 349 | 350 | // Construct Axum Router 351 | let app = Router::new() 352 | .route("/invoices/{invoice_id}", get(view_invoice_handler)) 353 | .route("/invoices/{invoice_id}/edit", post(edit_invoice_handler)) 354 | .route( 355 | "/payments/{payment_id}/approve", 356 | post(approve_payment_handler), 357 | ) 358 | .layer(Extension(checker)); 359 | 360 | // Run Axum App 361 | let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); 362 | println!("Listening on http://0.0.0.0:8000"); 363 | axum::serve(listener, app).await.unwrap(); 364 | } 365 | 366 | #[cfg(test)] 367 | mod tests { 368 | use super::*; 369 | use gatehouse::AccessEvaluation; 370 | use std::time::{Duration, SystemTime}; 371 | 372 | // Helper to quickly build an invoice with desired properties 373 | fn make_invoice(owner_id: Uuid, locked: bool, age_in_days: u64) -> Invoice { 374 | Invoice { 375 | id: Uuid::new_v4(), 376 | owner_id, 377 | locked, 378 | created_at: SystemTime::now() - Duration::from_secs(age_in_days * 24 * 60 * 60), 379 | } 380 | } 381 | 382 | // Helper to quickly build a payment 383 | fn make_payment(invoice_id: Uuid, is_refunded: bool, approved: bool) -> Payment { 384 | Payment { 385 | id: Uuid::new_v4(), 386 | invoice_id, 387 | is_refunded, 388 | approved, 389 | } 390 | } 391 | 392 | // Helper to build a RequestContext 393 | fn context_now() -> RequestContext { 394 | RequestContext { 395 | current_time: SystemTime::now(), 396 | } 397 | } 398 | 399 | #[tokio::test] 400 | async fn test_admin_override() { 401 | let checker = build_permission_checker(); 402 | let admin_user = User { 403 | id: Uuid::new_v4(), 404 | roles: vec!["admin".to_string()], 405 | }; 406 | 407 | // Attempt any action on any resource 408 | let invoice = make_invoice( 409 | admin_user.id, 410 | /*locked=*/ true, 411 | /*age_in_days=*/ 60, 412 | ); 413 | let resource = Resource::Invoice(invoice); 414 | 415 | let result = checker 416 | .evaluate_access(&admin_user, &Action::Edit, &resource, &context_now()) 417 | .await; 418 | 419 | assert!( 420 | result.is_granted(), 421 | "AdminOverridePolicy should allow admin to do anything" 422 | ); 423 | 424 | match result { 425 | AccessEvaluation::Granted { policy_type, .. } => { 426 | assert_eq!(&policy_type, "AdminOverridePolicy"); 427 | } 428 | _ => panic!("Expected admin override to be granted"), 429 | } 430 | } 431 | 432 | #[tokio::test] 433 | async fn test_invoice_editing_owner_unlocked_recent() { 434 | let checker = build_permission_checker(); 435 | let owner_id = Uuid::new_v4(); 436 | let user = User { 437 | id: owner_id, 438 | roles: vec!["user".to_string()], 439 | }; 440 | 441 | // Invoice is not locked, 10 days old 442 | let invoice = make_invoice(owner_id, /*locked=*/ false, /*age_in_days=*/ 10); 443 | let resource = Resource::Invoice(invoice); 444 | 445 | // The user is the owner, the invoice is unlocked, <30 days old => should be granted 446 | let result = checker 447 | .evaluate_access(&user, &Action::Edit, &resource, &context_now()) 448 | .await; 449 | 450 | assert!( 451 | result.is_granted(), 452 | "Invoice editing policy should allow owner if under 30 days, unlocked" 453 | ); 454 | } 455 | 456 | #[tokio::test] 457 | async fn test_invoice_editing_denied_if_locked() { 458 | let checker = build_permission_checker(); 459 | let owner_id = Uuid::new_v4(); 460 | let user = User { 461 | id: owner_id, 462 | roles: vec!["user".to_string()], 463 | }; 464 | 465 | // Invoice is locked, 10 days old 466 | let invoice = make_invoice(owner_id, /*locked=*/ true, /*age_in_days=*/ 10); 467 | let resource = Resource::Invoice(invoice); 468 | 469 | let result = checker 470 | .evaluate_access(&user, &Action::Edit, &resource, &context_now()) 471 | .await; 472 | 473 | assert!( 474 | !result.is_granted(), 475 | "Should be denied if invoice is locked" 476 | ); 477 | 478 | // We can also look at trace to see which sub-policy failed 479 | if let AccessEvaluation::Denied { trace, .. } = result { 480 | let trace_str = trace.format(); 481 | assert!( 482 | trace_str.contains("InvoiceNotLocked"), 483 | "Expected InvoiceNotLocked sub-policy to fail in trace: \n{}", 484 | trace_str 485 | ); 486 | } 487 | } 488 | 489 | #[tokio::test] 490 | async fn test_invoice_editing_denied_if_not_owner() { 491 | let checker = build_permission_checker(); 492 | let actual_owner_id = Uuid::new_v4(); 493 | let another_user_id = Uuid::new_v4(); 494 | 495 | let user = User { 496 | id: another_user_id, 497 | roles: vec!["user".to_string()], 498 | }; 499 | 500 | let invoice = make_invoice( 501 | actual_owner_id, 502 | /*locked=*/ false, 503 | /*age_in_days=*/ 10, 504 | ); 505 | let resource = Resource::Invoice(invoice); 506 | 507 | let result = checker 508 | .evaluate_access(&user, &Action::Edit, &resource, &context_now()) 509 | .await; 510 | 511 | assert!( 512 | !result.is_granted(), 513 | "Should be denied if user is not the owner" 514 | ); 515 | 516 | if let AccessEvaluation::Denied { trace, .. } = result { 517 | let trace_str = trace.format(); 518 | assert!( 519 | trace_str.contains("IsOwnerOfInvoice"), 520 | "Expected IsOwnerOfInvoice sub-policy to fail" 521 | ); 522 | } 523 | } 524 | 525 | #[tokio::test] 526 | async fn test_invoice_editing_denied_if_too_old() { 527 | let checker = build_permission_checker(); 528 | let owner_id = Uuid::new_v4(); 529 | let user = User { 530 | id: owner_id, 531 | roles: vec!["user".to_string()], 532 | }; 533 | 534 | // 31 days old => should fail the "InvoiceAgeUnder30Days" sub-policy 535 | let invoice = make_invoice(owner_id, /*locked=*/ false, /*age_in_days=*/ 31); 536 | let resource = Resource::Invoice(invoice); 537 | 538 | let result = checker 539 | .evaluate_access(&user, &Action::Edit, &resource, &context_now()) 540 | .await; 541 | assert!( 542 | !result.is_granted(), 543 | "Should be denied if invoice is older than 30 days" 544 | ); 545 | } 546 | 547 | #[tokio::test] 548 | async fn test_payment_approve_finance_manager() { 549 | let checker = build_permission_checker(); 550 | 551 | // finance_manager role is allowed to ApprovePayment 552 | let user = User { 553 | id: Uuid::new_v4(), 554 | roles: vec!["finance_manager".to_string()], 555 | }; 556 | let payment = make_payment( 557 | Uuid::new_v4(), 558 | /*is_refunded=*/ false, 559 | /*approved=*/ false, 560 | ); 561 | let resource = Resource::Payment(payment); 562 | 563 | let result = checker 564 | .evaluate_access(&user, &Action::ApprovePayment, &resource, &context_now()) 565 | .await; 566 | 567 | assert!( 568 | result.is_granted(), 569 | "PaymentApprovePolicy should allow finance_manager to approve" 570 | ); 571 | } 572 | 573 | #[tokio::test] 574 | async fn test_payment_approve_denied_for_regular_user() { 575 | let checker = build_permission_checker(); 576 | 577 | let user = User { 578 | id: Uuid::new_v4(), 579 | roles: vec!["regular_user".to_string()], 580 | }; 581 | let payment = make_payment(Uuid::new_v4(), false, false); 582 | let resource = Resource::Payment(payment); 583 | 584 | // Not finance_manager or admin => deny 585 | let result = checker 586 | .evaluate_access(&user, &Action::ApprovePayment, &resource, &context_now()) 587 | .await; 588 | assert!( 589 | !result.is_granted(), 590 | "Regular user should not be able to approve" 591 | ); 592 | } 593 | 594 | #[tokio::test] 595 | async fn test_payment_refund_finance_or_refund_specialist() { 596 | let checker = build_permission_checker(); 597 | 598 | let user_finance = User { 599 | id: Uuid::new_v4(), 600 | roles: vec!["finance_manager".to_string()], 601 | }; 602 | let user_refund_specialist = User { 603 | id: Uuid::new_v4(), 604 | roles: vec!["refund_specialist".to_string()], 605 | }; 606 | 607 | let payment = make_payment(Uuid::new_v4(), false, false); 608 | let resource = Resource::Payment(payment); 609 | 610 | // 1) finance_manager can refund 611 | let res1 = checker 612 | .evaluate_access( 613 | &user_finance, 614 | &Action::RefundPayment, 615 | &resource, 616 | &context_now(), 617 | ) 618 | .await; 619 | assert!(res1.is_granted(), "finance_manager is allowed to refund"); 620 | 621 | // 2) refund_specialist can refund 622 | let res2 = checker 623 | .evaluate_access( 624 | &user_refund_specialist, 625 | &Action::RefundPayment, 626 | &resource, 627 | &context_now(), 628 | ) 629 | .await; 630 | assert!(res2.is_granted(), "refund_specialist is allowed to refund"); 631 | } 632 | 633 | #[tokio::test] 634 | async fn test_payment_refund_denied_for_regular_user() { 635 | let checker = build_permission_checker(); 636 | 637 | let user = User { 638 | id: Uuid::new_v4(), 639 | roles: vec!["user".to_string()], 640 | }; 641 | let payment = make_payment(Uuid::new_v4(), false, false); 642 | let resource = Resource::Payment(payment); 643 | 644 | // Should be denied 645 | let result = checker 646 | .evaluate_access(&user, &Action::RefundPayment, &resource, &context_now()) 647 | .await; 648 | assert!(!result.is_granted(), "Regular user can't refund payment"); 649 | } 650 | } 651 | 652 | #[cfg(test)] 653 | mod integration_tests { 654 | use super::*; 655 | use axum::{ 656 | body::Body, 657 | http::{Request, StatusCode}, 658 | Router, 659 | }; 660 | use tower::ServiceExt; 661 | 662 | fn test_app() -> Router { 663 | let checker = build_permission_checker(); 664 | Router::new() 665 | .route("/invoices/{invoice_id}/edit", post(edit_invoice_handler)) 666 | .layer(Extension(checker)) 667 | } 668 | 669 | #[tokio::test] 670 | async fn test_edit_invoice_handler_allows_admin() { 671 | let app = test_app(); 672 | 673 | // We'll pretend the path param is some random UUID 674 | let req = Request::builder() 675 | .method("POST") 676 | .uri("/invoices/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit") 677 | .body(Body::empty()) 678 | .unwrap(); 679 | 680 | let response = app.clone().oneshot(req).await.unwrap(); 681 | // Because from_request always sets user as admin in this example, we expect 200 OK 682 | assert_eq!(response.status(), StatusCode::OK); 683 | } 684 | 685 | #[tokio::test] 686 | async fn test_edit_invoice_handler_denies_regular_user_if_locked() { 687 | // Suppose we want to forcibly pretend the user is not admin. 688 | // For demonstration, we can modify the `from_request` extraction or create 689 | // a separate test route. But with the current code, user is always admin. 690 | // In a real app, you'd have a token with roles, etc. 691 | // 692 | // As a workaround, let's show how you'd do it if from_request used a shared state or override. 693 | 694 | // For simplicity, we'll just test the normal route with the default "admin user". 695 | // That means it's always allowed. So, let's just demonstrate the structure here: 696 | 697 | let app = test_app(); 698 | 699 | let req = Request::builder() 700 | .method("POST") 701 | .uri("/invoices/cccccccc-cccc-cccc-cccc-cccccccccccc/edit") 702 | .body(Body::empty()) 703 | .unwrap(); 704 | 705 | let response = app.clone().oneshot(req).await.unwrap(); 706 | 707 | // Because in our sample extraction we always set the user as admin, 708 | // the invoice editing would be allowed. So we'd see 200 OK again. 709 | // In a real test environment, you'd mock or override the user roles. 710 | 711 | assert_eq!(response.status(), StatusCode::OK); 712 | } 713 | } 714 | -------------------------------------------------------------------------------- /examples/combinator_policy.rs: -------------------------------------------------------------------------------- 1 | //! # Combinator Policy Short-Circuit Evaluation Example 2 | //! 3 | //! This example demonstrates how the permission system's combinators use 4 | //! short-circuit evaluation for efficiency. 5 | //! 6 | //! To run this example: 7 | //! ``` 8 | //! cargo run --example combinator_policy 9 | //! ``` 10 | 11 | use async_trait::async_trait; 12 | use gatehouse::*; 13 | use std::sync::atomic::{AtomicUsize, Ordering}; 14 | use std::sync::Arc; 15 | use uuid::Uuid; 16 | 17 | // Define simple types for the example 18 | #[derive(Debug, Clone)] 19 | struct User { 20 | id: Uuid, 21 | } 22 | impl User { 23 | fn new() -> Self { 24 | Self { id: Uuid::new_v4() } 25 | } 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | struct Document { 30 | id: Uuid, 31 | } 32 | impl Document { 33 | fn new() -> Self { 34 | Self { id: Uuid::new_v4() } 35 | } 36 | } 37 | 38 | #[derive(Debug, Clone)] 39 | struct ViewAction; 40 | 41 | #[derive(Debug, Clone)] 42 | struct EmptyContext; 43 | 44 | // A policy that records when it's evaluated 45 | struct CountingPolicy { 46 | allow: bool, 47 | name: String, 48 | counter: Arc, 49 | } 50 | 51 | #[async_trait] 52 | impl Policy for CountingPolicy { 53 | async fn evaluate_access( 54 | &self, 55 | _subject: &User, 56 | _action: &ViewAction, 57 | _resource: &Document, 58 | _context: &EmptyContext, 59 | ) -> PolicyEvalResult { 60 | // Increment evaluation counter 61 | self.counter.fetch_add(1, Ordering::SeqCst); 62 | println!("Evaluating policy: {}", self.name); 63 | 64 | if self.allow { 65 | PolicyEvalResult::Granted { 66 | policy_type: self.policy_type(), 67 | reason: Some(format!("{} grants access", self.name)), 68 | } 69 | } else { 70 | PolicyEvalResult::Denied { 71 | policy_type: self.policy_type(), 72 | reason: format!("{} denies access", self.name), 73 | } 74 | } 75 | } 76 | 77 | fn policy_type(&self) -> String { 78 | format!("CountingPolicy({})", self.name) 79 | } 80 | } 81 | 82 | #[tokio::main] 83 | async fn main() { 84 | let user = User::new(); 85 | let document = Document::new(); 86 | let action = ViewAction; 87 | let context = EmptyContext; 88 | 89 | println!("=== AND Policy Short-Circuit Example ==="); 90 | { 91 | let counter = Arc::new(AtomicUsize::new(0)); 92 | 93 | // Create an AND policy with a deny policy first 94 | let and_policy = AndPolicy::try_new(vec![ 95 | Arc::new(CountingPolicy { 96 | allow: false, 97 | name: "DenyFirst".to_string(), 98 | counter: counter.clone(), 99 | }), 100 | Arc::new(CountingPolicy { 101 | allow: true, 102 | name: "AllowSecond".to_string(), 103 | counter: counter.clone(), 104 | }), 105 | ]) 106 | .expect("Unable to create and-policy policy"); 107 | 108 | println!("Evaluating AND(DenyFirst, AllowSecond):"); 109 | let result = and_policy 110 | .evaluate_access(&user, &action, &document, &context) 111 | .await; 112 | println!( 113 | "Result: {}", 114 | if result.is_granted() { 115 | "Access granted" 116 | } else { 117 | "Access denied" 118 | } 119 | ); 120 | println!("Policies evaluated: {}", counter.load(Ordering::SeqCst)); 121 | println!("Trace:\n{}", result.format(0)); 122 | 123 | // The second policy should not be evaluated due to short-circuiting 124 | assert_eq!(counter.load(Ordering::SeqCst), 1); 125 | } 126 | 127 | println!("\n=== OR Policy Short-Circuit Example ==="); 128 | { 129 | let counter = Arc::new(AtomicUsize::new(0)); 130 | 131 | // Create an OR policy with an allow policy first 132 | let or_policy = OrPolicy::try_new(vec![ 133 | Arc::new(CountingPolicy { 134 | allow: true, 135 | name: "AllowFirst".to_string(), 136 | counter: counter.clone(), 137 | }), 138 | Arc::new(CountingPolicy { 139 | allow: false, 140 | name: "DenySecond".to_string(), 141 | counter: counter.clone(), 142 | }), 143 | ]) 144 | .expect("Unable to create or-policy policy"); 145 | 146 | println!("Evaluating OR(AllowFirst, DenySecond):"); 147 | let result = or_policy 148 | .evaluate_access(&user, &action, &document, &context) 149 | .await; 150 | println!( 151 | "Result: {}", 152 | if result.is_granted() { 153 | "Access granted" 154 | } else { 155 | "Access denied" 156 | } 157 | ); 158 | println!("Policies evaluated: {}", counter.load(Ordering::SeqCst)); 159 | println!("Trace:\n{}", result.format(0)); 160 | 161 | // The second policy should not be evaluated due to short-circuiting 162 | assert_eq!(counter.load(Ordering::SeqCst), 1); 163 | } 164 | 165 | println!("\n=== Complex Nested Policy Example ==="); 166 | { 167 | let counter = Arc::new(AtomicUsize::new(0)); 168 | 169 | // Create a complex nested policy: OR(AND(Deny, Allow), Allow) 170 | let inner_and = AndPolicy::try_new(vec![ 171 | Arc::new(CountingPolicy { 172 | allow: false, 173 | name: "DenyInner".to_string(), 174 | counter: counter.clone(), 175 | }), 176 | Arc::new(CountingPolicy { 177 | allow: true, 178 | name: "AllowInner".to_string(), 179 | counter: counter.clone(), 180 | }), 181 | ]) 182 | .expect("Unable to create and-policy policy"); 183 | 184 | let complex_policy = OrPolicy::try_new(vec![ 185 | Arc::new(inner_and), 186 | Arc::new(CountingPolicy { 187 | allow: true, 188 | name: "AllowOuter".to_string(), 189 | counter: counter.clone(), 190 | }), 191 | ]) 192 | .expect("Unable to create or-policy policy"); 193 | 194 | println!("Evaluating OR(AND(DenyInner, AllowInner), AllowOuter):"); 195 | let result = complex_policy 196 | .evaluate_access(&user, &action, &document, &context) 197 | .await; 198 | println!( 199 | "Result: {} for document with ID {} for user with ID {}", 200 | if result.is_granted() { 201 | "Access granted" 202 | } else { 203 | "Access denied" 204 | }, 205 | document.id, 206 | user.id 207 | ); 208 | println!("Policies evaluated: {}", counter.load(Ordering::SeqCst)); 209 | println!("Trace:\n{}", result.format(0)); 210 | 211 | // The inner AND should evaluate only DenyInner (shorts-circuit), 212 | // then the OR continues to AllowOuter which grants access 213 | assert_eq!(counter.load(Ordering::SeqCst), 2); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /examples/groups_policy.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use gatehouse::*; 3 | use uuid::Uuid; 4 | 5 | #[derive(Debug, Clone)] 6 | struct OrganizationAuthorizationDetails { 7 | id: Uuid, 8 | is_org_admin: bool, 9 | permissions: Vec, 10 | } 11 | 12 | #[derive(Debug, Clone)] 13 | struct Permission { 14 | scope: String, 15 | } 16 | 17 | #[derive(Debug, Clone)] 18 | struct SubjectV2 { 19 | id: Uuid, 20 | authorization_details: OrganizationAuthorizationDetails, 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | struct Group { 25 | id: Uuid, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | struct GroupManagementAction; 30 | 31 | #[derive(Debug, Clone)] 32 | struct EmptyContext; 33 | 34 | // Policy that grants access if the user is an organization admin. 35 | struct OrgAdminPolicy; 36 | 37 | #[async_trait] 38 | impl Policy for OrgAdminPolicy { 39 | async fn evaluate_access( 40 | &self, 41 | subject: &SubjectV2, 42 | _action: &GroupManagementAction, 43 | _resource: &Group, 44 | _context: &EmptyContext, 45 | ) -> PolicyEvalResult { 46 | if subject.authorization_details.is_org_admin { 47 | PolicyEvalResult::Granted { 48 | policy_type: self.policy_type(), 49 | reason: Some("User is organization admin".to_string()), 50 | } 51 | } else { 52 | PolicyEvalResult::Denied { 53 | policy_type: self.policy_type(), 54 | reason: "User is not organization admin".to_string(), 55 | } 56 | } 57 | } 58 | 59 | fn policy_type(&self) -> String { 60 | "OrgAdminPolicy".to_string() 61 | } 62 | } 63 | 64 | // Policy that grants access if the user has the `staff` permission. 65 | struct StaffPolicy; 66 | 67 | #[async_trait] 68 | impl Policy for StaffPolicy { 69 | async fn evaluate_access( 70 | &self, 71 | subject: &SubjectV2, 72 | _action: &GroupManagementAction, 73 | _resource: &Group, 74 | _context: &EmptyContext, 75 | ) -> PolicyEvalResult { 76 | if subject 77 | .authorization_details 78 | .permissions 79 | .iter() 80 | .any(|p| p.scope == "staff") 81 | { 82 | PolicyEvalResult::Granted { 83 | policy_type: self.policy_type(), 84 | reason: Some("User has staff permission".to_string()), 85 | } 86 | } else { 87 | PolicyEvalResult::Denied { 88 | policy_type: self.policy_type(), 89 | reason: "User lacks staff permission".to_string(), 90 | } 91 | } 92 | } 93 | 94 | fn policy_type(&self) -> String { 95 | "StaffPolicy".to_string() 96 | } 97 | } 98 | 99 | // Combine the policies into a permission checker - if either check passes, access is granted. 100 | fn create_group_management_checker( 101 | ) -> PermissionChecker { 102 | let mut checker = PermissionChecker::new(); 103 | checker.add_policy(OrgAdminPolicy); 104 | checker.add_policy(StaffPolicy); 105 | checker 106 | } 107 | 108 | #[tokio::main] 109 | async fn main() { 110 | // Example subject with staff but not org admin: 111 | let subject = SubjectV2 { 112 | id: Uuid::new_v4(), 113 | authorization_details: OrganizationAuthorizationDetails { 114 | id: Uuid::new_v4(), 115 | is_org_admin: false, 116 | permissions: vec![Permission { 117 | scope: "staff".to_string(), 118 | }], 119 | }, 120 | }; 121 | 122 | let group = Group { id: Uuid::new_v4() }; 123 | let action = GroupManagementAction; 124 | let context = EmptyContext; 125 | 126 | let checker = create_group_management_checker(); 127 | let result = checker 128 | .evaluate_access(&subject, &action, &group, &context) 129 | .await; 130 | assert!(result.is_granted()); 131 | println!( 132 | "Evaluating subject {} with org id {} and group {}", 133 | subject.id, subject.authorization_details.id, group.id 134 | ); 135 | println!("{}", result.display_trace()); 136 | 137 | // [GRANTED] by StaffPolicy - User has staff permission 138 | // Evaluation Trace: 139 | // ✔ PermissionChecker (OR) 140 | // ✘ OrgAdminPolicy DENIED: User is not organization admin 141 | // ✔ PartlyStaffPolicy GRANTED: User has staff permission 142 | } 143 | -------------------------------------------------------------------------------- /examples/policy_builder.rs: -------------------------------------------------------------------------------- 1 | //! # Edit User Settings Permission Example 2 | //! 3 | //! This example demonstrates creating a custom policy using the `PolicyBuilder` 4 | //! to check if an organization's authorization details grant the "edit_user_settings" 5 | //! permission for a specified target entity. 6 | //! 7 | //! To run this example: 8 | //! ```sh 9 | //! cargo run --example policy_builder 10 | //! ``` 11 | use gatehouse::*; 12 | use uuid::Uuid; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct GroupPermission { 16 | /// The scope of the permission (e.g., `edit_user_settings`). 17 | pub scope: String, 18 | /// The entity the permission applies to (e.g., an organization ID as string). 19 | pub entity: String, 20 | } 21 | 22 | #[derive(Debug, Clone)] 23 | pub struct OrganizationAuthorizationDetails { 24 | /// Organization ID. 25 | pub id: Uuid, 26 | /// A vector of permissions associated with the organization. 27 | pub permissions: Vec, 28 | } 29 | 30 | // A helper function that creates a policy for checking permissions based on the scope. 31 | fn org_has_permission( 32 | scope: String, 33 | ) -> Box> { 34 | PolicyBuilder::new(scope.clone()) 35 | .when( 36 | move |org: &OrganizationAuthorizationDetails, _action, _resource, target_entity| { 37 | // Check if the permission matches the scope and target entity. 38 | org.permissions 39 | .iter() 40 | .any(|p| p.scope == scope && p.entity == *target_entity) 41 | }, 42 | ) 43 | .build() 44 | } 45 | 46 | #[tokio::main] 47 | async fn main() { 48 | // Build a PermissionChecker with two custom policies using the above helper. 49 | let mut checker = PermissionChecker::::new(); 50 | checker.add_policy(org_has_permission("edit_user_settings".to_string())); 51 | checker.add_policy(org_has_permission("edit_org_settings".to_string())); 52 | 53 | checker.add_policy( 54 | PolicyBuilder::new("GlobalAdmin") 55 | .subjects(|org: &OrganizationAuthorizationDetails| { 56 | org.permissions.iter().any(|p| p.scope == "global_admin") 57 | }) 58 | .build(), 59 | ); 60 | 61 | // Create sample organization authorization details. 62 | // org1 has "edit_user_settings" for "org1". 63 | let org1 = OrganizationAuthorizationDetails { 64 | id: Uuid::new_v4(), 65 | permissions: vec![GroupPermission { 66 | scope: "edit_user_settings".to_string(), 67 | entity: "org1".to_string(), 68 | }], 69 | }; 70 | 71 | // org2 has "edit_user_settings" for "org2". 72 | let org2 = OrganizationAuthorizationDetails { 73 | id: Uuid::new_v4(), 74 | permissions: vec![GroupPermission { 75 | scope: "edit_user_settings".to_string(), 76 | entity: "org2".to_string(), 77 | }], 78 | }; 79 | 80 | // org3 has no permissions for any org 81 | let org3 = OrganizationAuthorizationDetails { 82 | id: Uuid::new_v4(), 83 | permissions: vec![], 84 | }; 85 | // org4 has global admin permissions 86 | let org4 = OrganizationAuthorizationDetails { 87 | id: Uuid::new_v4(), 88 | permissions: vec![GroupPermission { 89 | scope: "global_admin".to_string(), 90 | entity: "".to_string(), 91 | }], 92 | }; 93 | 94 | // Evaluate the policy with different target entities as context. 95 | // 1. org1 should be granted access when the target is "org1". 96 | let result1 = checker 97 | .evaluate_access(&org1, &(), &(), &"org1".to_string()) 98 | .await; 99 | println!("Org1 on 'org1': {}", result1); 100 | assert!(result1.is_granted()); 101 | 102 | // 2. org2 should be denied access when the target is "org1". 103 | let result2 = checker 104 | .evaluate_access(&org2, &(), &(), &"org1".to_string()) 105 | .await; 106 | println!("Org2 on 'org1': {}", result2); 107 | assert!(!result2.is_granted()); 108 | 109 | // 3. org2 should be granted access when the target is "org2". 110 | let result3 = checker 111 | .evaluate_access(&org2, &(), &(), &"org2".to_string()) 112 | .await; 113 | println!("Org2 on 'org2': {}", result3); 114 | assert!(result3.is_granted()); 115 | 116 | // 4. org3 should be denied access regardless of the target since it doesn't have the correct permission. 117 | let result4 = checker 118 | .evaluate_access(&org3, &(), &(), &"org1".to_string()) 119 | .await; 120 | println!("Org3 on 'org1': {}", result4); 121 | assert!(!result4.is_granted()); 122 | 123 | // 5. org4 should be granted access since it has global admin permissions 124 | let result5 = checker 125 | .evaluate_access(&org4, &(), &(), &"org1".to_string()) 126 | .await; 127 | println!("Org4 on 'org1': {}", result5); 128 | assert!(result5.is_granted()); 129 | } 130 | -------------------------------------------------------------------------------- /examples/rbac_policy.rs: -------------------------------------------------------------------------------- 1 | //! # Role-Based Access Control Policy Example 2 | //! 3 | //! This example demonstrates how to use the built-in RBAC policy 4 | //! for role-based permission management. 5 | //! 6 | //! To run this example: 7 | //! ``` 8 | //! cargo run --example rbac_policy 9 | //! ``` 10 | 11 | use gatehouse::*; 12 | use std::collections::HashSet; 13 | use uuid::Uuid; 14 | 15 | // Define types for our permission system 16 | #[derive(Debug, Clone)] 17 | struct User { 18 | id: Uuid, 19 | roles: HashSet, 20 | } 21 | 22 | #[derive(Debug, Clone)] 23 | struct Document { 24 | id: Uuid, 25 | required_role_ids: HashSet, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | struct ReadAction; 30 | 31 | #[derive(Debug, Clone)] 32 | struct EmptyContext; 33 | 34 | #[tokio::main] 35 | async fn main() { 36 | println!("=== RBAC Policy Example ===\n"); 37 | 38 | // Create some role IDs 39 | let admin_role_id = Uuid::new_v4(); 40 | let editor_role_id = Uuid::new_v4(); 41 | let viewer_role_id = Uuid::new_v4(); 42 | 43 | println!("Role IDs:"); 44 | println!(" Admin: {}", admin_role_id); 45 | println!(" Editor: {}", editor_role_id); 46 | println!(" Viewer: {}", viewer_role_id); 47 | println!(); 48 | 49 | // Create users with different roles 50 | let admin_user = User { 51 | id: Uuid::new_v4(), 52 | roles: [admin_role_id].into_iter().collect(), 53 | }; 54 | 55 | let editor_user = User { 56 | id: Uuid::new_v4(), 57 | roles: [editor_role_id].into_iter().collect(), 58 | }; 59 | 60 | let multi_role_user = User { 61 | id: Uuid::new_v4(), 62 | roles: [editor_role_id, viewer_role_id].into_iter().collect(), 63 | }; 64 | 65 | let unauthorized_user = User { 66 | id: Uuid::new_v4(), 67 | roles: HashSet::new(), 68 | }; 69 | 70 | println!("Users:"); 71 | println!(" Admin User ID: {}", admin_user.id); 72 | println!(" Editor User ID: {}", editor_user.id); 73 | println!(" Multi-role User ID: {}", multi_role_user.id); 74 | println!(" No-role User ID: {}", unauthorized_user.id); 75 | println!(); 76 | 77 | // Create documents with different role requirements 78 | let admin_doc = Document { 79 | id: Uuid::new_v4(), 80 | required_role_ids: [admin_role_id].into_iter().collect(), 81 | }; 82 | 83 | let editor_doc = Document { 84 | id: Uuid::new_v4(), 85 | required_role_ids: [editor_role_id].into_iter().collect(), 86 | }; 87 | 88 | let multi_role_doc = Document { 89 | id: Uuid::new_v4(), 90 | required_role_ids: [editor_role_id, viewer_role_id].into_iter().collect(), 91 | }; 92 | 93 | println!("Documents:"); 94 | println!(" Admin Document: {}", admin_doc.id); 95 | println!(" Editor Document: {}", editor_doc.id); 96 | println!(" Multi-role Document: {}", multi_role_doc.id); 97 | println!(); 98 | 99 | // Create RBAC policy 100 | // The first function extracts required roles from a document 101 | // The second function extracts roles from a user 102 | let rbac_policy = RbacPolicy::new( 103 | |doc: &Document, _: &ReadAction| doc.required_role_ids.iter().cloned().collect(), 104 | |user: &User| user.roles.iter().cloned().collect(), 105 | ); 106 | 107 | // Create a strongly typed permission checker and add our RBAC policy 108 | let mut checker = PermissionChecker::::new(); 109 | checker.add_policy(rbac_policy); 110 | 111 | println!("=== Testing Access Control ===\n"); 112 | 113 | // Test admin user accessing various documents 114 | test_access( 115 | &checker, 116 | "Admin user", 117 | &admin_user, 118 | "Admin document", 119 | &admin_doc, 120 | ) 121 | .await; 122 | test_access( 123 | &checker, 124 | "Admin user", 125 | &admin_user, 126 | "Editor document", 127 | &editor_doc, 128 | ) 129 | .await; 130 | 131 | // Test editor user accessing various documents 132 | test_access( 133 | &checker, 134 | "Editor user", 135 | &editor_user, 136 | "Admin document", 137 | &admin_doc, 138 | ) 139 | .await; 140 | test_access( 141 | &checker, 142 | "Editor user", 143 | &editor_user, 144 | "Editor document", 145 | &editor_doc, 146 | ) 147 | .await; 148 | 149 | // Test multi-role user 150 | test_access( 151 | &checker, 152 | "Multi-role user", 153 | &multi_role_user, 154 | "Editor document", 155 | &editor_doc, 156 | ) 157 | .await; 158 | test_access( 159 | &checker, 160 | "Multi-role user", 161 | &multi_role_user, 162 | "Multi-role document", 163 | &multi_role_doc, 164 | ) 165 | .await; 166 | 167 | // Test unauthorized user 168 | test_access( 169 | &checker, 170 | "Unauthorized user", 171 | &unauthorized_user, 172 | "Admin document", 173 | &admin_doc, 174 | ) 175 | .await; 176 | test_access( 177 | &checker, 178 | "Unauthorized user", 179 | &unauthorized_user, 180 | "Editor document", 181 | &editor_doc, 182 | ) 183 | .await; 184 | } 185 | 186 | async fn test_access( 187 | checker: &PermissionChecker, 188 | user_desc: &str, 189 | user: &User, 190 | doc_desc: &str, 191 | doc: &Document, 192 | ) { 193 | let context = EmptyContext; 194 | let action = ReadAction; 195 | 196 | let result = checker.evaluate_access(user, &action, doc, &context).await; 197 | 198 | println!( 199 | "{} accessing {}: {}", 200 | user_desc, 201 | doc_desc, 202 | if result.is_granted() { 203 | "GRANTED ✓" 204 | } else { 205 | "DENIED ✗" 206 | } 207 | ); 208 | 209 | println!( 210 | "Evaluation trace:\n{}\n", 211 | match &result { 212 | AccessEvaluation::Granted { trace, .. } => trace.format(), 213 | AccessEvaluation::Denied { trace, .. } => trace.format(), 214 | } 215 | ); 216 | } 217 | -------------------------------------------------------------------------------- /examples/rebac_policy.rs: -------------------------------------------------------------------------------- 1 | //! # Relationship-Based Access Control Policy Example 2 | //! 3 | //! This example demonstrates how to use the built-in ReBAC policy 4 | //! for relationship-based permissions management, including error handling 5 | //! during relationship resolution. 6 | //! 7 | //! To run this example: 8 | //! ``` 9 | //! cargo run --example rebac_policy 10 | //! ``` 11 | 12 | use async_trait::async_trait; 13 | use gatehouse::*; 14 | use std::time::Duration; 15 | use uuid::Uuid; 16 | 17 | // Define types for our permission system 18 | #[derive(Debug, Clone)] 19 | struct User { 20 | id: Uuid, 21 | name: String, 22 | } 23 | 24 | #[derive(Debug, Clone)] 25 | struct Project { 26 | id: Uuid, 27 | name: String, 28 | } 29 | 30 | #[derive(Debug, Clone)] 31 | struct EditAction; 32 | 33 | #[derive(Debug, Clone)] 34 | struct EmptyContext; 35 | 36 | // A relationship resolver that simulates database access 37 | // and can demonstrate different error conditions 38 | #[derive(Debug, Clone)] 39 | struct ProjectRelationshipResolver { 40 | // Simulate a database of relationships: (user_id, project_id, relationship_type) 41 | relationships: Vec<(Uuid, Uuid, String)>, 42 | // Flag to simulate a database error 43 | simulate_error: bool, 44 | // Flag to simulate a timeout 45 | simulate_timeout: bool, 46 | } 47 | 48 | impl ProjectRelationshipResolver { 49 | fn new(relationships: Vec<(Uuid, Uuid, String)>) -> Self { 50 | Self { 51 | relationships, 52 | simulate_error: false, 53 | simulate_timeout: false, 54 | } 55 | } 56 | 57 | fn with_error(mut self) -> Self { 58 | self.simulate_error = true; 59 | self 60 | } 61 | 62 | fn with_timeout(mut self) -> Self { 63 | self.simulate_timeout = true; 64 | self 65 | } 66 | } 67 | 68 | #[async_trait] 69 | impl RelationshipResolver for ProjectRelationshipResolver { 70 | async fn has_relationship(&self, user: &User, project: &Project, relationship: &str) -> bool { 71 | println!( 72 | "Checking if user {} has '{}' relationship with project {}", 73 | user.name, relationship, project.name 74 | ); 75 | 76 | // Simulate a database error 77 | if self.simulate_error { 78 | println!("⚠️ Database error while checking relationship!"); 79 | return false; // Return false on error 80 | } 81 | 82 | // Simulate a timeout 83 | if self.simulate_timeout { 84 | println!("⏱️ Simulating database timeout (3 seconds)..."); 85 | tokio::time::sleep(Duration::from_secs(3)).await; 86 | println!("⚠️ Database timeout while checking relationship!"); 87 | return false; // Return false on timeout 88 | } 89 | 90 | // Normal processing - check if relationship exists 91 | let has_rel = self.relationships.iter().any(|(user_id, project_id, rel)| { 92 | *user_id == user.id && *project_id == project.id && rel == relationship 93 | }); 94 | 95 | println!( 96 | "Relationship check result: {}", 97 | if has_rel { "EXISTS ✓" } else { "MISSING ✗" } 98 | ); 99 | has_rel 100 | } 101 | } 102 | 103 | #[tokio::main] 104 | async fn main() { 105 | println!("=== ReBAC Policy Example ===\n"); 106 | 107 | // Create some users 108 | let owner = User { 109 | id: Uuid::new_v4(), 110 | name: "Alice (Owner)".to_string(), 111 | }; 112 | 113 | let contributor = User { 114 | id: Uuid::new_v4(), 115 | name: "Bob (Contributor)".to_string(), 116 | }; 117 | 118 | let viewer = User { 119 | id: Uuid::new_v4(), 120 | name: "Charlie (Viewer)".to_string(), 121 | }; 122 | 123 | let unauthorized = User { 124 | id: Uuid::new_v4(), 125 | name: "Dave (Unauthorized)".to_string(), 126 | }; 127 | 128 | println!("Users:"); 129 | println!(" Owner: {}", owner.name); 130 | println!(" Contributor: {}", contributor.name); 131 | println!(" Viewer: {}", viewer.name); 132 | println!(" Unauthorized: {}", unauthorized.name); 133 | println!(); 134 | 135 | // Create a project 136 | let project = Project { 137 | id: Uuid::new_v4(), 138 | name: "Sample Project".to_string(), 139 | }; 140 | 141 | println!("Project:"); 142 | println!(" Name: {}", project.name); 143 | println!(" ID: {}", project.id); 144 | println!(); 145 | 146 | // Setup relationship database 147 | let relationships = vec![ 148 | (owner.id, project.id, "owner".to_string()), 149 | (contributor.id, project.id, "contributor".to_string()), 150 | (viewer.id, project.id, "viewer".to_string()), 151 | ]; 152 | 153 | println!("=== Normal Relationship Resolution ===\n"); 154 | 155 | // Create resolver with normal operation 156 | let normal_resolver = ProjectRelationshipResolver::new(relationships.clone()); 157 | 158 | // Create ReBAC policies for different relationships 159 | let owner_policy = RebacPolicy::::new( 160 | "owner", 161 | normal_resolver.clone(), 162 | ); 163 | 164 | let contributor_policy = RebacPolicy::::new( 165 | "contributor", 166 | normal_resolver.clone(), 167 | ); 168 | 169 | let _viewer_policy = 170 | RebacPolicy::::new("viewer", normal_resolver); 171 | 172 | // Create a permission checker with multiple policies 173 | // Only owners and contributors can edit, not viewers 174 | let mut checker = PermissionChecker::::new(); 175 | checker.add_policy(owner_policy); 176 | checker.add_policy(contributor_policy); 177 | 178 | // Test normal access 179 | println!("Testing normal access patterns:"); 180 | test_access(&checker, &owner, &project).await; 181 | test_access(&checker, &contributor, &project).await; 182 | test_access(&checker, &viewer, &project).await; 183 | test_access(&checker, &unauthorized, &project).await; 184 | 185 | println!("\n=== Error During Relationship Resolution ===\n"); 186 | 187 | // Create a resolver that simulates a database error 188 | let error_resolver = ProjectRelationshipResolver::new(relationships.clone()).with_error(); 189 | let error_policy = 190 | RebacPolicy::::new("owner", error_resolver); 191 | 192 | let mut error_checker = PermissionChecker::::new(); 193 | error_checker.add_policy(error_policy); 194 | 195 | println!("Testing with database error:"); 196 | test_access(&error_checker, &owner, &project).await; 197 | 198 | println!("\n=== Timeout During Relationship Resolution ===\n"); 199 | 200 | // Create a resolver that simulates a timeout 201 | let timeout_resolver = ProjectRelationshipResolver::new(relationships).with_timeout(); 202 | let timeout_policy = 203 | RebacPolicy::::new("owner", timeout_resolver); 204 | 205 | let mut timeout_checker = PermissionChecker::::new(); 206 | timeout_checker.add_policy(timeout_policy); 207 | 208 | println!("Testing with database timeout:"); 209 | test_access(&timeout_checker, &owner, &project).await; 210 | } 211 | 212 | async fn test_access( 213 | checker: &PermissionChecker, 214 | user: &User, 215 | project: &Project, 216 | ) { 217 | let context = EmptyContext; 218 | let action = EditAction; 219 | 220 | println!("\nChecking if {} can edit {}:", user.name, project.name); 221 | let result = checker 222 | .evaluate_access(user, &action, project, &context) 223 | .await; 224 | 225 | println!( 226 | "Access {} for {}", 227 | if result.is_granted() { 228 | "GRANTED ✓" 229 | } else { 230 | "DENIED ✗" 231 | }, 232 | user.name 233 | ); 234 | 235 | println!( 236 | "Evaluation trace:\n{}\n", 237 | match &result { 238 | AccessEvaluation::Granted { trace, .. } => trace.format(), 239 | AccessEvaluation::Denied { trace, .. } => trace.format(), 240 | } 241 | ); 242 | } 243 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A flexible authorization library that combines role‐based (RBAC), 2 | //! attribute‐based (ABAC), and relationship‐based (ReBAC) policies. 3 | //! The library provides a generic `Policy` trait for defining custom policies, 4 | //! a builder pattern for creating custom policies as well as several built-in 5 | //! policies for common use cases, and combinators for composing complex 6 | //! authorization logic. 7 | //! 8 | //! # Overview 9 | //! 10 | //! A *Policy* is an asynchronous decision unit that checks if a given subject may 11 | //! perform an action on a resource within a given context. Policies implement the 12 | //! [`Policy`] trait. A [`PermissionChecker`] aggregates multiple policies and uses OR 13 | //! logic by default (i.e. if any policy grants access, then access is allowed). 14 | //! The [`PolicyBuilder`] offers a builder pattern for creating custom policies. 15 | //! 16 | //! ## Built in Policies 17 | //! The library provides a few built-in policies: 18 | //! - [`RbacPolicy`]: A role-based access control policy. 19 | //! - [`AbacPolicy`]: An attribute-based access control policy. 20 | //! - [`RebacPolicy`]: A relationship-based access control policy. 21 | //! 22 | //! ## Custom Policies 23 | //! 24 | //! Below we define a simple system where a user may read a document if they 25 | //! are an admin (via a simple role-based policy) or if they are the owner of the document (via 26 | //! an attribute-based policy). 27 | //! 28 | //! ```rust 29 | //! # use uuid::Uuid; 30 | //! # use async_trait::async_trait; 31 | //! # use std::sync::Arc; 32 | //! # use gatehouse::*; 33 | //! 34 | //! // Define our core types. 35 | //! #[derive(Debug, Clone)] 36 | //! pub struct User { 37 | //! pub id: Uuid, 38 | //! pub roles: Vec, 39 | //! } 40 | //! 41 | //! #[derive(Debug, Clone)] 42 | //! pub struct Document { 43 | //! pub id: Uuid, 44 | //! pub owner_id: Uuid, 45 | //! } 46 | //! 47 | //! #[derive(Debug, Clone)] 48 | //! pub struct ReadAction; 49 | //! 50 | //! #[derive(Debug, Clone)] 51 | //! pub struct EmptyContext; 52 | //! 53 | //! // A simple RBAC policy: grant access if the user has the "admin" role. 54 | //! struct AdminPolicy; 55 | //! #[async_trait] 56 | //! impl Policy for AdminPolicy { 57 | //! async fn evaluate_access( 58 | //! &self, 59 | //! user: &User, 60 | //! _action: &ReadAction, 61 | //! _resource: &Document, 62 | //! _context: &EmptyContext, 63 | //! ) -> PolicyEvalResult { 64 | //! if user.roles.contains(&"admin".to_string()) { 65 | //! PolicyEvalResult::Granted { 66 | //! policy_type: self.policy_type(), 67 | //! reason: Some("User is admin".to_string()), 68 | //! } 69 | //! } else { 70 | //! PolicyEvalResult::Denied { 71 | //! policy_type: self.policy_type(), 72 | //! reason: "User is not admin".to_string(), 73 | //! } 74 | //! } 75 | //! } 76 | //! fn policy_type(&self) -> String { "AdminPolicy".to_string() } 77 | //! } 78 | //! 79 | //! // An ABAC policy: grant access if the user is the owner of the document. 80 | //! struct OwnerPolicy; 81 | //! 82 | //! #[async_trait] 83 | //! impl Policy for OwnerPolicy { 84 | //! async fn evaluate_access( 85 | //! &self, 86 | //! user: &User, 87 | //! _action: &ReadAction, 88 | //! document: &Document, 89 | //! _context: &EmptyContext, 90 | //! ) -> PolicyEvalResult { 91 | //! if user.id == document.owner_id { 92 | //! PolicyEvalResult::Granted { 93 | //! policy_type: self.policy_type(), 94 | //! reason: Some("User is the owner".to_string()), 95 | //! } 96 | //! } else { 97 | //! PolicyEvalResult::Denied { 98 | //! policy_type: self.policy_type(), 99 | //! reason: "User is not the owner".to_string(), 100 | //! } 101 | //! } 102 | //! } 103 | //! fn policy_type(&self) -> String { 104 | //! "OwnerPolicy".to_string() 105 | //! } 106 | //! } 107 | //! 108 | //! // Create a PermissionChecker (which uses OR semantics by default) and add both policies. 109 | //! fn create_document_checker() -> PermissionChecker { 110 | //! let mut checker = PermissionChecker::new(); 111 | //! checker.add_policy(AdminPolicy); 112 | //! checker.add_policy(OwnerPolicy); 113 | //! checker 114 | //! } 115 | //! 116 | //! # tokio_test::block_on(async { 117 | //! let admin_user = User { 118 | //! id: Uuid::new_v4(), 119 | //! roles: vec!["admin".into()], 120 | //! }; 121 | //! 122 | //! let owner_user = User { 123 | //! id: Uuid::new_v4(), 124 | //! roles: vec!["user".into()], 125 | //! }; 126 | //! 127 | //! let document = Document { 128 | //! id: Uuid::new_v4(), 129 | //! owner_id: owner_user.id, 130 | //! }; 131 | //! 132 | //! let checker = create_document_checker(); 133 | //! 134 | //! // An admin should have access. 135 | //! assert!(checker.evaluate_access(&admin_user, &ReadAction, &document, &EmptyContext).await.is_granted()); 136 | //! 137 | //! // The owner should have access. 138 | //! assert!(checker.evaluate_access(&owner_user, &ReadAction, &document, &EmptyContext).await.is_granted()); 139 | //! 140 | //! // A random user should be denied access. 141 | //! let random_user = User { 142 | //! id: Uuid::new_v4(), 143 | //! roles: vec!["user".into()], 144 | //! }; 145 | //! assert!(!checker.evaluate_access(&random_user, &ReadAction, &document, &EmptyContext).await.is_granted()); 146 | //! # }); 147 | //! ``` 148 | //! 149 | //! ## Evaluation Tracing 150 | //! 151 | //! The permission system provides detailed tracing of policy decisions, see [`AccessEvaluation`] 152 | //! for an example. 153 | //! 154 | //! 155 | //! ## Combinators 156 | //! 157 | //! Sometimes you may want to require that several policies pass (AND), require that 158 | //! at least one passes (OR), or even invert a policy (NOT). `gatehouse` provides 159 | //! combinators for this purpose: 160 | //! 161 | //! - [`AndPolicy`]: Grants access only if all inner policies allow access. Otherwise, 162 | //! returns a combined error. 163 | //! - [`OrPolicy`]: Grants access if any inner policy allows access; otherwise returns a 164 | //! combined error. 165 | //! - [`NotPolicy`]: Inverts the decision of an inner policy. 166 | //! 167 | //! 168 | 169 | #![allow(clippy::type_complexity)] 170 | use async_trait::async_trait; 171 | use std::fmt; 172 | use std::sync::Arc; 173 | 174 | /// The type of boolean combining operation a policy might represent. 175 | #[derive(Debug, PartialEq, Clone)] 176 | pub enum CombineOp { 177 | And, 178 | Or, 179 | Not, 180 | } 181 | 182 | impl fmt::Display for CombineOp { 183 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 184 | match self { 185 | CombineOp::And => write!(f, "AND"), 186 | CombineOp::Or => write!(f, "OR"), 187 | CombineOp::Not => write!(f, "NOT"), 188 | } 189 | } 190 | } 191 | 192 | /// The result of evaluating a single policy (or a combination). 193 | /// 194 | /// This enum is used both by individual policies and by combinators to represent the 195 | /// outcome of access evaluation. 196 | /// 197 | /// - [`PolicyEvalResult::Granted`]: Indicates that access is granted, with an optional reason. 198 | /// - [`PolicyEvalResult::Denied`]: Indicates that access is denied, along with an explanatory reason. 199 | /// - [`PolicyEvalResult::Combined`]: Represents the aggregate result of combining multiple policies. 200 | #[derive(Debug, Clone)] 201 | pub enum PolicyEvalResult { 202 | /// Access granted. Contains the policy type and an optional reason. 203 | Granted { 204 | policy_type: String, 205 | reason: Option, 206 | }, 207 | /// Access denied. Contains the policy type and a reason. 208 | Denied { policy_type: String, reason: String }, 209 | /// Combined result from multiple policy evaluations. 210 | /// Contains the policy type, the combining operation ([`CombineOp`]), 211 | /// a list of child evaluation results, and the overall outcome. 212 | Combined { 213 | policy_type: String, 214 | operation: CombineOp, 215 | children: Vec, 216 | outcome: bool, 217 | }, 218 | } 219 | 220 | /// The complete result of a permission evaluation. 221 | /// Contains both the final decision and a detailed trace for debugging. 222 | /// 223 | /// ### Evaluation Tracing 224 | /// 225 | /// The permission system provides detailed tracing of policy decisions: 226 | /// ```rust 227 | /// # use gatehouse::*; 228 | /// # use uuid::Uuid; 229 | /// # 230 | /// # // Define simple types for the example 231 | /// # #[derive(Debug, Clone)] 232 | /// # struct User { id: Uuid } 233 | /// # #[derive(Debug, Clone)] 234 | /// # struct Document { id: Uuid } 235 | /// # #[derive(Debug, Clone)] 236 | /// # struct ReadAction; 237 | /// # #[derive(Debug, Clone)] 238 | /// # struct EmptyContext; 239 | /// # 240 | /// # async fn example() -> AccessEvaluation { 241 | /// # let mut checker = PermissionChecker::::new(); 242 | /// # let user = User { id: Uuid::new_v4() }; 243 | /// # let document = Document { id: Uuid::new_v4() }; 244 | /// # checker.evaluate_access(&user, &ReadAction, &document, &EmptyContext).await 245 | /// # } 246 | /// # 247 | /// # tokio_test::block_on(async { 248 | /// let result = example().await; 249 | /// 250 | /// match result { 251 | /// AccessEvaluation::Granted { policy_type, reason, trace } => { 252 | /// println!("Access granted by {}: {:?}", policy_type, reason); 253 | /// println!("Full evaluation trace:\n{}", trace.format()); 254 | /// } 255 | /// AccessEvaluation::Denied { reason, trace } => { 256 | /// println!("Access denied: {}", reason); 257 | /// println!("Full evaluation trace:\n{}", trace.format()); 258 | /// } 259 | /// } 260 | /// # }); 261 | /// ``` 262 | #[derive(Debug, Clone)] 263 | pub enum AccessEvaluation { 264 | /// Access was granted. 265 | Granted { 266 | /// The policy that granted access 267 | policy_type: String, 268 | /// Optional reason for granting 269 | reason: Option, 270 | /// Full evaluation trace including any rejected policies 271 | trace: EvalTrace, 272 | }, 273 | /// Access was denied. 274 | Denied { 275 | /// The complete evaluation trace showing all policy decisions 276 | trace: EvalTrace, 277 | /// Summary reason for denial 278 | reason: String, 279 | }, 280 | } 281 | 282 | impl AccessEvaluation { 283 | /// Whether access was granted 284 | pub fn is_granted(&self) -> bool { 285 | matches!(self, Self::Granted { .. }) 286 | } 287 | 288 | /// Converts the evaluation into a `Result`, mapping a denial into an error. 289 | pub fn to_result(&self, error_fn: impl FnOnce(&str) -> E) -> Result<(), E> { 290 | match self { 291 | Self::Granted { .. } => Ok(()), 292 | Self::Denied { reason, .. } => Err(error_fn(reason)), 293 | } 294 | } 295 | 296 | pub fn display_trace(&self) -> String { 297 | let trace = match self { 298 | AccessEvaluation::Granted { 299 | policy_type: _, 300 | reason: _, 301 | trace, 302 | } => trace, 303 | AccessEvaluation::Denied { reason: _, trace } => trace, 304 | }; 305 | 306 | // If there's an actual tree to show, add it. Otherwise, fallback. 307 | let trace_str = trace.format(); 308 | if trace_str == "No evaluation trace available" { 309 | format!("{}\n(No evaluation trace available)", self) 310 | } else { 311 | format!("{}\nEvaluation Trace:\n{}", self, trace_str) 312 | } 313 | } 314 | } 315 | 316 | /// A concise line about the final decision. 317 | impl fmt::Display for AccessEvaluation { 318 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 319 | match self { 320 | Self::Granted { 321 | policy_type, 322 | reason, 323 | trace: _, 324 | } => { 325 | // Headline 326 | match reason { 327 | Some(r) => write!(f, "[GRANTED] by {} - {}", policy_type, r), 328 | None => write!(f, "[GRANTED] by {}", policy_type), 329 | } 330 | } 331 | Self::Denied { reason, trace: _ } => { 332 | write!(f, "[Denied] - {}", reason) 333 | } 334 | } 335 | } 336 | } 337 | 338 | /// Container for the evaluation tree 339 | /// Detailed trace of all policy evaluations 340 | #[derive(Debug, Clone, Default)] 341 | pub struct EvalTrace { 342 | root: Option, 343 | } 344 | 345 | impl EvalTrace { 346 | pub fn new() -> Self { 347 | Self { root: None } 348 | } 349 | 350 | pub fn with_root(result: PolicyEvalResult) -> Self { 351 | Self { root: Some(result) } 352 | } 353 | 354 | pub fn set_root(&mut self, result: PolicyEvalResult) { 355 | self.root = Some(result); 356 | } 357 | 358 | pub fn root(&self) -> Option<&PolicyEvalResult> { 359 | self.root.as_ref() 360 | } 361 | 362 | /// Returns a formatted representation of the evaluation tree 363 | pub fn format(&self) -> String { 364 | match &self.root { 365 | Some(root) => root.format(0), 366 | None => "No evaluation trace available".to_string(), 367 | } 368 | } 369 | } 370 | 371 | impl PolicyEvalResult { 372 | /// Returns whether this evaluation resulted in access being granted 373 | pub fn is_granted(&self) -> bool { 374 | match self { 375 | Self::Granted { .. } => true, 376 | Self::Denied { .. } => false, 377 | Self::Combined { outcome, .. } => *outcome, 378 | } 379 | } 380 | 381 | /// Returns the reason string if available 382 | pub fn reason(&self) -> Option { 383 | match self { 384 | Self::Granted { reason, .. } => reason.clone(), 385 | Self::Denied { reason, .. } => Some(reason.clone()), 386 | Self::Combined { .. } => None, 387 | } 388 | } 389 | 390 | /// Formats the evaluation tree with indentation for readability 391 | pub fn format(&self, indent: usize) -> String { 392 | let indent_str = " ".repeat(indent); 393 | 394 | match self { 395 | Self::Granted { 396 | policy_type, 397 | reason, 398 | } => { 399 | let reason_text = reason 400 | .as_ref() 401 | .map_or("".to_string(), |r| format!(": {}", r)); 402 | format!("{}✔ {} GRANTED{}", indent_str, policy_type, reason_text) 403 | } 404 | Self::Denied { 405 | policy_type, 406 | reason, 407 | } => { 408 | format!("{}✘ {} DENIED: {}", indent_str, policy_type, reason) 409 | } 410 | Self::Combined { 411 | policy_type, 412 | operation, 413 | children, 414 | outcome, 415 | } => { 416 | let outcome_char = if *outcome { "✔" } else { "✘" }; 417 | let mut result = format!( 418 | "{}{} {} ({})", 419 | indent_str, outcome_char, policy_type, operation 420 | ); 421 | 422 | for child in children { 423 | result.push_str(&format!("\n{}", child.format(indent + 2))); 424 | } 425 | result 426 | } 427 | } 428 | } 429 | } 430 | 431 | impl fmt::Display for PolicyEvalResult { 432 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 433 | let tree = self.format(0); 434 | write!(f, "{}", tree) 435 | } 436 | } 437 | 438 | /// A generic async trait representing a single authorization policy. 439 | /// A policy determines if a subject is allowed to perform an action on 440 | /// a resource within a given context. 441 | #[async_trait] 442 | pub trait Policy: Send + Sync { 443 | /// Evaluates whether access should be granted. 444 | /// 445 | /// # Arguments 446 | /// 447 | /// * `subject` - The entity requesting access. 448 | /// * `action` - The action being performed. 449 | /// * `resource` - The target resource. 450 | /// * `context` - Additional context that may affect the decision. 451 | /// 452 | /// # Returns 453 | /// 454 | /// A [`PolicyEvalResult`] indicating whether access is granted or denied. 455 | async fn evaluate_access( 456 | &self, 457 | subject: &Subject, 458 | action: &Action, 459 | resource: &Resource, 460 | context: &Context, 461 | ) -> PolicyEvalResult; 462 | 463 | /// Policy name for debugging 464 | fn policy_type(&self) -> String; 465 | } 466 | 467 | /// A container for multiple policies, applied in an "OR" fashion. 468 | /// (If any policy returns Ok, access is granted) 469 | /// **Important**: 470 | /// If no policies are added, access is always denied. 471 | #[derive(Clone)] 472 | pub struct PermissionChecker { 473 | policies: Vec>>, 474 | } 475 | 476 | impl Default for PermissionChecker { 477 | fn default() -> Self { 478 | Self::new() 479 | } 480 | } 481 | 482 | impl PermissionChecker { 483 | /// Creates a new `PermissionChecker` with no policies. 484 | pub fn new() -> Self { 485 | Self { 486 | policies: Vec::new(), 487 | } 488 | } 489 | 490 | /// Adds a policy to the checker. 491 | /// 492 | /// # Arguments 493 | /// 494 | /// * `policy` - A type implementing [`Policy`]. It is stored as an `Arc` for shared ownership. 495 | pub fn add_policy + 'static>(&mut self, policy: P) { 496 | self.policies.push(Arc::new(policy)); 497 | } 498 | 499 | /// Evaluates all policies against the given parameters. 500 | /// 501 | /// Policies are evaluated sequentially with OR semantics (short-circuiting on first success). 502 | /// Returns an [`AccessEvaluation`] with detailed tracing. 503 | #[tracing::instrument(skip_all)] 504 | pub async fn evaluate_access( 505 | &self, 506 | subject: &S, 507 | action: &A, 508 | resource: &R, 509 | context: &C, 510 | ) -> AccessEvaluation { 511 | if self.policies.is_empty() { 512 | tracing::debug!("No policies configured"); 513 | let result = PolicyEvalResult::Denied { 514 | policy_type: "PermissionChecker".to_string(), 515 | reason: "No policies configured".to_string(), 516 | }; 517 | 518 | return AccessEvaluation::Denied { 519 | trace: EvalTrace::with_root(result), 520 | reason: "No policies configured".to_string(), 521 | }; 522 | } 523 | tracing::trace!(num_policies = self.policies.len(), "Checking access"); 524 | 525 | let mut policy_results = Vec::new(); 526 | 527 | // Evaluate each policy 528 | for policy in &self.policies { 529 | let result = policy 530 | .evaluate_access(subject, action, resource, context) 531 | .await; 532 | let result_passes = result.is_granted(); 533 | policy_results.push(result.clone()); 534 | 535 | // If any policy allows access, return immediately 536 | if result_passes { 537 | let combined = PolicyEvalResult::Combined { 538 | policy_type: "PermissionChecker".to_string(), 539 | operation: CombineOp::Or, 540 | children: policy_results, 541 | outcome: true, 542 | }; 543 | 544 | return AccessEvaluation::Granted { 545 | policy_type: policy.policy_type(), 546 | reason: result.reason(), 547 | trace: EvalTrace::with_root(combined), 548 | }; 549 | } 550 | } 551 | 552 | // If all policies denied access 553 | tracing::trace!("No policies allowed access, returning Forbidden"); 554 | let combined = PolicyEvalResult::Combined { 555 | policy_type: "PermissionChecker".to_string(), 556 | operation: CombineOp::Or, 557 | children: policy_results, 558 | outcome: false, 559 | }; 560 | 561 | AccessEvaluation::Denied { 562 | trace: EvalTrace::with_root(combined), 563 | reason: "All policies denied access".to_string(), 564 | } 565 | } 566 | } 567 | 568 | /// Represents the intended effect of a policy. 569 | /// 570 | /// `Allow` means the policy grants access; `Deny` means it denies access. 571 | #[derive(Debug, Clone, PartialEq, Eq)] 572 | pub enum Effect { 573 | Allow, 574 | Deny, 575 | } 576 | 577 | /// An internal policy type (not exposed to API users) that is constructed via the builder. 578 | struct InternalPolicy { 579 | name: String, 580 | effect: Effect, 581 | // The predicate returns true if all conditions pass. 582 | predicate: Box bool + Send + Sync>, 583 | } 584 | 585 | #[async_trait] 586 | impl Policy for InternalPolicy 587 | where 588 | S: Send + Sync, 589 | R: Send + Sync, 590 | A: Send + Sync, 591 | C: Send + Sync, 592 | { 593 | async fn evaluate_access( 594 | &self, 595 | subject: &S, 596 | action: &A, 597 | resource: &R, 598 | context: &C, 599 | ) -> PolicyEvalResult { 600 | if (self.predicate)(subject, action, resource, context) { 601 | match self.effect { 602 | Effect::Allow => PolicyEvalResult::Granted { 603 | policy_type: self.name.clone(), 604 | reason: Some("Policy allowed access".into()), 605 | }, 606 | Effect::Deny => PolicyEvalResult::Denied { 607 | policy_type: self.name.clone(), 608 | reason: "Policy denied access".into(), 609 | }, 610 | } 611 | } else { 612 | // Predicate didn't match – treat as non-applicable (denied). 613 | PolicyEvalResult::Denied { 614 | policy_type: self.name.clone(), 615 | reason: "Policy predicate did not match".into(), 616 | } 617 | } 618 | } 619 | fn policy_type(&self) -> String { 620 | self.name.clone() 621 | } 622 | } 623 | 624 | // Tell the compiler that a Box implements the Policy trait so we can keep 625 | // our internal policy type private. 626 | #[async_trait] 627 | impl Policy for Box> 628 | where 629 | S: Send + Sync, 630 | R: Send + Sync, 631 | A: Send + Sync, 632 | C: Send + Sync, 633 | { 634 | async fn evaluate_access( 635 | &self, 636 | subject: &S, 637 | action: &A, 638 | resource: &R, 639 | context: &C, 640 | ) -> PolicyEvalResult { 641 | (**self) 642 | .evaluate_access(subject, action, resource, context) 643 | .await 644 | } 645 | 646 | fn policy_type(&self) -> String { 647 | (**self).policy_type() 648 | } 649 | } 650 | 651 | /// A builder API for creating custom policies. 652 | /// 653 | /// A fluent interface to combine predicate functions on the subject, action, resource, 654 | /// and context. Use it to construct a policy that can be added to a [`PermissionChecker`]. 655 | /// 656 | pub struct PolicyBuilder 657 | where 658 | S: Send + Sync + 'static, 659 | R: Send + Sync + 'static, 660 | A: Send + Sync + 'static, 661 | C: Send + Sync + 'static, 662 | { 663 | name: String, 664 | effect: Effect, 665 | subject_pred: Option bool + Send + Sync>>, 666 | action_pred: Option bool + Send + Sync>>, 667 | resource_pred: Option bool + Send + Sync>>, 668 | context_pred: Option bool + Send + Sync>>, 669 | // Note the order here matches the evaluate_access signature 670 | extra_condition: Option bool + Send + Sync>>, 671 | } 672 | 673 | impl PolicyBuilder 674 | where 675 | Subject: Send + Sync + 'static, 676 | Resource: Send + Sync + 'static, 677 | Action: Send + Sync + 'static, 678 | Context: Send + Sync + 'static, 679 | { 680 | /// Creates a new policy builder with the given name. 681 | pub fn new(name: impl Into) -> Self { 682 | Self { 683 | name: name.into(), 684 | effect: Effect::Allow, 685 | subject_pred: None, 686 | action_pred: None, 687 | resource_pred: None, 688 | context_pred: None, 689 | extra_condition: None, 690 | } 691 | } 692 | 693 | /// Sets the effect (Allow or Deny) for the policy. 694 | /// Defaults to Allow 695 | pub fn effect(mut self, effect: Effect) -> Self { 696 | self.effect = effect; 697 | self 698 | } 699 | 700 | /// Adds a predicate that tests the subject. 701 | pub fn subjects(mut self, pred: F) -> Self 702 | where 703 | F: Fn(&Subject) -> bool + Send + Sync + 'static, 704 | { 705 | self.subject_pred = Some(Box::new(pred)); 706 | self 707 | } 708 | 709 | /// Adds a predicate that tests the action. 710 | pub fn actions(mut self, pred: F) -> Self 711 | where 712 | F: Fn(&Action) -> bool + Send + Sync + 'static, 713 | { 714 | self.action_pred = Some(Box::new(pred)); 715 | self 716 | } 717 | 718 | /// Adds a predicate that tests the resource. 719 | pub fn resources(mut self, pred: F) -> Self 720 | where 721 | F: Fn(&Resource) -> bool + Send + Sync + 'static, 722 | { 723 | self.resource_pred = Some(Box::new(pred)); 724 | self 725 | } 726 | 727 | /// Add a predicate that validates the context. 728 | pub fn context(mut self, pred: F) -> Self 729 | where 730 | F: Fn(&Context) -> bool + Send + Sync + 'static, 731 | { 732 | self.context_pred = Some(Box::new(pred)); 733 | self 734 | } 735 | 736 | /// Add a condition that considers all four inputs. 737 | pub fn when(mut self, pred: F) -> Self 738 | where 739 | F: Fn(&Subject, &Action, &Resource, &Context) -> bool + Send + Sync + 'static, 740 | { 741 | self.extra_condition = Some(Box::new(pred)); 742 | self 743 | } 744 | 745 | /// Build the policy. Returns a boxed policy that can be added to a PermissionChecker. 746 | pub fn build(self) -> Box> { 747 | let effect = self.effect; 748 | let subject_pred = self.subject_pred; 749 | let action_pred = self.action_pred; 750 | let resource_pred = self.resource_pred; 751 | let context_pred = self.context_pred; 752 | let extra_condition = self.extra_condition; 753 | 754 | let predicate = Box::new(move |s: &Subject, a: &Action, r: &Resource, c: &Context| { 755 | subject_pred.as_ref().is_none_or(|f| f(s)) 756 | && action_pred.as_ref().is_none_or(|f| f(a)) 757 | && resource_pred.as_ref().is_none_or(|f| f(r)) 758 | && context_pred.as_ref().is_none_or(|f| f(c)) 759 | && extra_condition.as_ref().is_none_or(|f| f(s, a, r, c)) 760 | }); 761 | 762 | Box::new(InternalPolicy { 763 | name: self.name, 764 | effect, 765 | predicate, 766 | }) 767 | } 768 | } 769 | 770 | /// A role-based access control policy. 771 | /// 772 | /// `required_roles_resolver` is a closure that determines which roles are required 773 | /// for the given (resource, action). `user_roles_resolver` extracts the subject's roles. 774 | pub struct RbacPolicy { 775 | required_roles_resolver: F1, 776 | user_roles_resolver: F2, 777 | _marker: std::marker::PhantomData, 778 | } 779 | 780 | impl RbacPolicy { 781 | pub fn new(required_roles_resolver: F1, user_roles_resolver: F2) -> Self { 782 | Self { 783 | required_roles_resolver, 784 | user_roles_resolver, 785 | _marker: std::marker::PhantomData, 786 | } 787 | } 788 | } 789 | 790 | #[async_trait] 791 | impl Policy for RbacPolicy 792 | where 793 | S: Sync + Send, 794 | R: Sync + Send, 795 | A: Sync + Send, 796 | C: Sync + Send, 797 | F1: Fn(&R, &A) -> Vec + Sync + Send, 798 | F2: Fn(&S) -> Vec + Sync + Send, 799 | { 800 | async fn evaluate_access( 801 | &self, 802 | subject: &S, 803 | action: &A, 804 | resource: &R, 805 | _context: &C, 806 | ) -> PolicyEvalResult { 807 | let required_roles = (self.required_roles_resolver)(resource, action); 808 | let user_roles = (self.user_roles_resolver)(subject); 809 | let has_role = required_roles.iter().any(|role| user_roles.contains(role)); 810 | 811 | if has_role { 812 | PolicyEvalResult::Granted { 813 | policy_type: Policy::::policy_type(self), 814 | reason: Some("User has required role".to_string()), 815 | } 816 | } else { 817 | PolicyEvalResult::Denied { 818 | policy_type: Policy::::policy_type(self), 819 | reason: "User doesn't have required role".to_string(), 820 | } 821 | } 822 | } 823 | 824 | fn policy_type(&self) -> String { 825 | "RbacPolicy".to_string() 826 | } 827 | } 828 | 829 | /// An attribute-based access control policy. 830 | /// Define a `condition` closure that determines whether a subject is allowed to 831 | /// perform an action on a resource, given the additional context. If it returns 832 | /// true, access is granted. Otherwise, access is denied. 833 | /// 834 | /// ## Example 835 | /// 836 | /// We define simple types for a user, a resource, an action, and a context. 837 | /// We then create a built-in ABAC policy that grants access if the user "owns" 838 | /// a resource as determined by the resource's owner_id. 839 | /// 840 | /// ```rust 841 | /// # use async_trait::async_trait; 842 | /// # use std::sync::Arc; 843 | /// # use uuid::Uuid; 844 | /// # use gatehouse::*; 845 | /// 846 | /// // Define our core types. 847 | /// #[derive(Debug, Clone)] 848 | /// struct User { 849 | /// id: Uuid, 850 | /// } 851 | /// 852 | /// #[derive(Debug, Clone)] 853 | /// struct Resource { 854 | /// owner_id: Uuid, 855 | /// } 856 | /// 857 | /// #[derive(Debug, Clone)] 858 | /// struct Action; 859 | /// 860 | /// #[derive(Debug, Clone)] 861 | /// struct EmptyContext; 862 | /// 863 | /// // Create an ABAC policy. 864 | /// // This policy grants access if the user's ID matches the resource's owner. 865 | /// let abac_policy = AbacPolicy::new( 866 | /// |user: &User, resource: &Resource, _action: &Action, _context: &EmptyContext| { 867 | /// user.id == resource.owner_id 868 | /// }, 869 | /// ); 870 | /// 871 | /// // Create a PermissionChecker and add the ABAC policy. 872 | /// let mut checker = PermissionChecker::::new(); 873 | /// checker.add_policy(abac_policy); 874 | /// 875 | /// // Create a sample user 876 | /// let user = User { 877 | /// id: Uuid::new_v4(), 878 | /// }; 879 | /// 880 | /// // Create a resource owned by the user, and one that is not 881 | /// let owned_resource = Resource { owner_id: user.id }; 882 | /// let other_resource = Resource { owner_id: Uuid::new_v4() }; 883 | /// let context = EmptyContext; 884 | /// 885 | /// # tokio_test::block_on(async { 886 | /// // This check should succeed because the user is the owner: 887 | /// assert!(checker.evaluate_access(&user, &Action, &owned_resource, &context).await.is_granted()); 888 | /// 889 | /// // This check should fail because the user is not the owner: 890 | /// assert!(!checker.evaluate_access(&user, &Action, &other_resource, &context).await.is_granted()); 891 | /// # }); 892 | /// ``` 893 | /// 894 | pub struct AbacPolicy { 895 | condition: F, 896 | _marker: std::marker::PhantomData<(S, R, A, C)>, 897 | } 898 | 899 | impl AbacPolicy { 900 | pub fn new(condition: F) -> Self { 901 | Self { 902 | condition, 903 | _marker: std::marker::PhantomData, 904 | } 905 | } 906 | } 907 | 908 | #[async_trait] 909 | impl Policy for AbacPolicy 910 | where 911 | S: Sync + Send, 912 | R: Sync + Send, 913 | A: Sync + Send, 914 | C: Sync + Send, 915 | F: Fn(&S, &R, &A, &C) -> bool + Sync + Send, 916 | { 917 | async fn evaluate_access( 918 | &self, 919 | subject: &S, 920 | action: &A, 921 | resource: &R, 922 | context: &C, 923 | ) -> PolicyEvalResult { 924 | let condition_met = (self.condition)(subject, resource, action, context); 925 | 926 | if condition_met { 927 | PolicyEvalResult::Granted { 928 | policy_type: self.policy_type(), 929 | reason: Some("Condition evaluated to true".to_string()), 930 | } 931 | } else { 932 | PolicyEvalResult::Denied { 933 | policy_type: self.policy_type(), 934 | reason: "Condition evaluated to false".to_string(), 935 | } 936 | } 937 | } 938 | 939 | fn policy_type(&self) -> String { 940 | "AbacPolicy".to_string() 941 | } 942 | } 943 | 944 | /// A trait that abstracts a relationship resolver. 945 | /// Given a subject and a resource, the resolver answers whether the 946 | /// specified relationship e.g. "creator", "manager" exists between them. 947 | #[async_trait] 948 | pub trait RelationshipResolver: Send + Sync { 949 | async fn has_relationship(&self, subject: &S, resource: &R, relationship: &str) -> bool; 950 | } 951 | 952 | /// ### ReBAC Policy 953 | /// 954 | /// In this example, we show how to use a built-in relationship-based (ReBAC) policy. We define 955 | /// a dummy relationship resolver that checks if a user is the manager of a project. 956 | /// 957 | /// ```rust 958 | /// use async_trait::async_trait; 959 | /// use std::sync::Arc; 960 | /// use uuid::Uuid; 961 | /// use gatehouse::*; 962 | /// 963 | /// #[derive(Debug, Clone)] 964 | /// pub struct Employee { 965 | /// pub id: Uuid, 966 | /// } 967 | /// 968 | /// #[derive(Debug, Clone)] 969 | /// pub struct Project { 970 | /// pub id: Uuid, 971 | /// pub manager_id: Uuid, 972 | /// } 973 | /// 974 | /// #[derive(Debug, Clone)] 975 | /// pub struct AccessAction; 976 | /// 977 | /// #[derive(Debug, Clone)] 978 | /// pub struct EmptyContext; 979 | /// 980 | /// // Define a dummy relationship resolver that considers an employee to be a manager 981 | /// // of a project if their id matches the project's manager_id. 982 | /// struct DummyRelationshipResolver; 983 | /// 984 | /// #[async_trait] 985 | /// impl RelationshipResolver for DummyRelationshipResolver { 986 | /// async fn has_relationship( 987 | /// &self, 988 | /// employee: &Employee, 989 | /// project: &Project, 990 | /// relationship: &str, 991 | /// ) -> bool { 992 | /// relationship == "manager" && employee.id == project.manager_id 993 | /// } 994 | /// } 995 | /// 996 | /// // Create a ReBAC policy that checks for the "manager" relationship. 997 | /// let rebac_policy = RebacPolicy::::new( 998 | /// "manager", 999 | /// DummyRelationshipResolver, 1000 | /// ); 1001 | /// 1002 | /// // Create a PermissionChecker and add the ReBAC policy. 1003 | /// let mut checker = PermissionChecker::::new(); 1004 | /// checker.add_policy(rebac_policy); 1005 | /// 1006 | /// // Create a sample employee and project. 1007 | /// let manager = Employee { id: Uuid::new_v4() }; 1008 | /// let project = Project { 1009 | /// id: Uuid::new_v4(), 1010 | /// manager_id: manager.id, 1011 | /// }; 1012 | /// let context = EmptyContext; 1013 | /// 1014 | /// // The manager should have access. 1015 | /// # tokio_test::block_on(async { 1016 | /// assert!(checker.evaluate_access(&manager, &AccessAction, &project, &context).await.is_granted()); 1017 | /// 1018 | /// // A different employee should be denied access. 1019 | /// let other_employee = Employee { id: Uuid::new_v4() }; 1020 | /// assert!(!checker.evaluate_access(&other_employee, &AccessAction, &project, &context).await.is_granted()); 1021 | /// # }); 1022 | /// ``` 1023 | pub struct RebacPolicy { 1024 | pub relationship: String, 1025 | pub resolver: RG, 1026 | _marker: std::marker::PhantomData<(S, R, A, C)>, 1027 | } 1028 | 1029 | impl RebacPolicy { 1030 | /// Create a new RebacPolicy for a given relationship string. 1031 | pub fn new(relationship: impl Into, resolver: RG) -> Self { 1032 | Self { 1033 | relationship: relationship.into(), 1034 | resolver, 1035 | _marker: std::marker::PhantomData, 1036 | } 1037 | } 1038 | } 1039 | 1040 | #[async_trait] 1041 | impl Policy for RebacPolicy 1042 | where 1043 | S: Sync + Send, 1044 | R: Sync + Send, 1045 | A: Sync + Send, 1046 | C: Sync + Send, 1047 | RG: RelationshipResolver + Send + Sync, 1048 | { 1049 | async fn evaluate_access( 1050 | &self, 1051 | subject: &S, 1052 | _action: &A, 1053 | resource: &R, 1054 | _context: &C, 1055 | ) -> PolicyEvalResult { 1056 | let has_relationship = self 1057 | .resolver 1058 | .has_relationship(subject, resource, &self.relationship) 1059 | .await; 1060 | 1061 | if has_relationship { 1062 | PolicyEvalResult::Granted { 1063 | policy_type: self.policy_type(), 1064 | reason: Some(format!( 1065 | "Subject has '{}' relationship with resource", 1066 | self.relationship 1067 | )), 1068 | } 1069 | } else { 1070 | PolicyEvalResult::Denied { 1071 | policy_type: self.policy_type(), 1072 | reason: format!( 1073 | "Subject does not have '{}' relationship with resource", 1074 | self.relationship 1075 | ), 1076 | } 1077 | } 1078 | } 1079 | 1080 | fn policy_type(&self) -> String { 1081 | "RebacPolicy".to_string() 1082 | } 1083 | } 1084 | 1085 | /// --- 1086 | /// Policy Combinators 1087 | /// --- 1088 | /// 1089 | /// AndPolicy 1090 | /// 1091 | /// Combines multiple policies with a logical AND. Access is granted only if every 1092 | /// inner policy grants access. 1093 | pub struct AndPolicy { 1094 | policies: Vec>>, 1095 | } 1096 | 1097 | /// Error returned when no policies are provided to a combinator policy. 1098 | #[derive(Debug, Copy, Clone)] 1099 | pub struct EmptyPoliciesError(pub &'static str); 1100 | 1101 | impl AndPolicy { 1102 | pub fn try_new(policies: Vec>>) -> Result { 1103 | if policies.is_empty() { 1104 | Err(EmptyPoliciesError( 1105 | "AndPolicy must have at least one policy", 1106 | )) 1107 | } else { 1108 | Ok(Self { policies }) 1109 | } 1110 | } 1111 | } 1112 | 1113 | #[async_trait] 1114 | impl Policy for AndPolicy 1115 | where 1116 | S: Sync + Send, 1117 | R: Sync + Send, 1118 | A: Sync + Send, 1119 | C: Sync + Send, 1120 | { 1121 | // Override the default policy_type implementation 1122 | fn policy_type(&self) -> String { 1123 | "AndPolicy".to_string() 1124 | } 1125 | 1126 | async fn evaluate_access( 1127 | &self, 1128 | subject: &S, 1129 | action: &A, 1130 | resource: &R, 1131 | context: &C, 1132 | ) -> PolicyEvalResult { 1133 | let mut children_results = Vec::new(); 1134 | 1135 | for policy in &self.policies { 1136 | let result = policy 1137 | .evaluate_access(subject, action, resource, context) 1138 | .await; 1139 | children_results.push(result.clone()); 1140 | 1141 | // Short-circuit on first denial 1142 | if !result.is_granted() { 1143 | return PolicyEvalResult::Combined { 1144 | policy_type: self.policy_type(), 1145 | operation: CombineOp::And, 1146 | children: children_results, 1147 | outcome: false, 1148 | }; 1149 | } 1150 | } 1151 | 1152 | // All policies granted access 1153 | PolicyEvalResult::Combined { 1154 | policy_type: self.policy_type(), 1155 | operation: CombineOp::And, 1156 | children: children_results, 1157 | outcome: true, 1158 | } 1159 | } 1160 | } 1161 | 1162 | /// OrPolicy 1163 | /// 1164 | /// Combines multiple policies with a logical OR. Access is granted if any inner policy 1165 | /// grants access. 1166 | pub struct OrPolicy { 1167 | policies: Vec>>, 1168 | } 1169 | 1170 | impl OrPolicy { 1171 | pub fn try_new(policies: Vec>>) -> Result { 1172 | if policies.is_empty() { 1173 | Err(EmptyPoliciesError("OrPolicy must have at least one policy")) 1174 | } else { 1175 | Ok(Self { policies }) 1176 | } 1177 | } 1178 | } 1179 | 1180 | #[async_trait] 1181 | impl Policy for OrPolicy 1182 | where 1183 | S: Sync + Send, 1184 | R: Sync + Send, 1185 | A: Sync + Send, 1186 | C: Sync + Send, 1187 | { 1188 | // Override the default policy_type implementation 1189 | fn policy_type(&self) -> String { 1190 | "OrPolicy".to_string() 1191 | } 1192 | async fn evaluate_access( 1193 | &self, 1194 | subject: &S, 1195 | action: &A, 1196 | resource: &R, 1197 | context: &C, 1198 | ) -> PolicyEvalResult { 1199 | let mut children_results = Vec::new(); 1200 | 1201 | for policy in &self.policies { 1202 | let result = policy 1203 | .evaluate_access(subject, action, resource, context) 1204 | .await; 1205 | children_results.push(result.clone()); 1206 | 1207 | // Short-circuit on first success 1208 | if result.is_granted() { 1209 | return PolicyEvalResult::Combined { 1210 | policy_type: self.policy_type(), 1211 | operation: CombineOp::Or, 1212 | children: children_results, 1213 | outcome: true, 1214 | }; 1215 | } 1216 | } 1217 | 1218 | // All policies denied access 1219 | PolicyEvalResult::Combined { 1220 | policy_type: self.policy_type(), 1221 | operation: CombineOp::Or, 1222 | children: children_results, 1223 | outcome: false, 1224 | } 1225 | } 1226 | } 1227 | 1228 | /// NotPolicy 1229 | /// 1230 | /// Inverts the result of an inner policy. If the inner policy allows access, then NotPolicy 1231 | /// denies it, and vice versa. 1232 | pub struct NotPolicy { 1233 | policy: Arc>, 1234 | } 1235 | 1236 | impl NotPolicy { 1237 | pub fn new(policy: impl Policy + 'static) -> Self { 1238 | Self { 1239 | policy: Arc::new(policy), 1240 | } 1241 | } 1242 | } 1243 | 1244 | #[async_trait] 1245 | impl Policy for NotPolicy 1246 | where 1247 | S: Sync + Send, 1248 | R: Sync + Send, 1249 | A: Sync + Send, 1250 | C: Sync + Send, 1251 | { 1252 | // Override the default policy_type implementation 1253 | fn policy_type(&self) -> String { 1254 | "NotPolicy".to_string() 1255 | } 1256 | 1257 | async fn evaluate_access( 1258 | &self, 1259 | subject: &S, 1260 | action: &A, 1261 | resource: &R, 1262 | context: &C, 1263 | ) -> PolicyEvalResult { 1264 | let inner_result = self 1265 | .policy 1266 | .evaluate_access(subject, action, resource, context) 1267 | .await; 1268 | 1269 | PolicyEvalResult::Combined { 1270 | policy_type: Policy::::policy_type(self), 1271 | operation: CombineOp::Not, 1272 | children: vec![inner_result.clone()], 1273 | outcome: !inner_result.is_granted(), 1274 | } 1275 | } 1276 | } 1277 | 1278 | #[cfg(test)] 1279 | mod tests { 1280 | use super::*; 1281 | 1282 | // Dummy resource/action/context types for testing 1283 | #[derive(Debug, Clone)] 1284 | pub struct TestSubject { 1285 | pub id: uuid::Uuid, 1286 | } 1287 | 1288 | #[derive(Debug, Clone)] 1289 | pub struct TestResource { 1290 | pub id: uuid::Uuid, 1291 | } 1292 | 1293 | #[derive(Debug, Clone)] 1294 | pub struct TestAction; 1295 | 1296 | #[derive(Debug, Clone)] 1297 | pub struct TestContext; 1298 | 1299 | // A policy that always allows 1300 | struct AlwaysAllowPolicy; 1301 | 1302 | #[async_trait] 1303 | impl Policy for AlwaysAllowPolicy { 1304 | async fn evaluate_access( 1305 | &self, 1306 | _subject: &TestSubject, 1307 | _action: &TestAction, 1308 | _resource: &TestResource, 1309 | _context: &TestContext, 1310 | ) -> PolicyEvalResult { 1311 | PolicyEvalResult::Granted { 1312 | policy_type: self.policy_type(), 1313 | reason: Some("Always allow policy".to_string()), 1314 | } 1315 | } 1316 | 1317 | fn policy_type(&self) -> String { 1318 | "AlwaysAllowPolicy".to_string() 1319 | } 1320 | } 1321 | 1322 | // A policy that always denies, with a custom reason 1323 | struct AlwaysDenyPolicy(&'static str); 1324 | 1325 | #[async_trait] 1326 | impl Policy for AlwaysDenyPolicy { 1327 | async fn evaluate_access( 1328 | &self, 1329 | _subject: &TestSubject, 1330 | _action: &TestAction, 1331 | _resource: &TestResource, 1332 | _context: &TestContext, 1333 | ) -> PolicyEvalResult { 1334 | PolicyEvalResult::Denied { 1335 | policy_type: self.policy_type(), 1336 | reason: self.0.to_string(), 1337 | } 1338 | } 1339 | 1340 | fn policy_type(&self) -> String { 1341 | "AlwaysDenyPolicy".to_string() 1342 | } 1343 | } 1344 | 1345 | #[tokio::test] 1346 | async fn test_no_policies() { 1347 | let checker = 1348 | PermissionChecker::::new(); 1349 | 1350 | let subject = TestSubject { 1351 | id: uuid::Uuid::new_v4(), 1352 | }; 1353 | let resource = TestResource { 1354 | id: uuid::Uuid::new_v4(), 1355 | }; 1356 | let result = checker 1357 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1358 | .await; 1359 | 1360 | match result { 1361 | AccessEvaluation::Denied { reason, trace: _ } => { 1362 | assert!(reason.contains("No policies configured")); 1363 | } 1364 | _ => panic!("Expected Denied(No policies configured), got {:?}", result), 1365 | } 1366 | } 1367 | 1368 | #[tokio::test] 1369 | async fn test_one_policy_allow() { 1370 | let mut checker = PermissionChecker::new(); 1371 | checker.add_policy(AlwaysAllowPolicy); 1372 | 1373 | let subject = TestSubject { 1374 | id: uuid::Uuid::new_v4(), 1375 | }; 1376 | let resource = TestResource { 1377 | id: uuid::Uuid::new_v4(), 1378 | }; 1379 | 1380 | let result = checker 1381 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1382 | .await; 1383 | 1384 | if let AccessEvaluation::Granted { 1385 | policy_type, 1386 | reason, 1387 | trace, 1388 | } = result 1389 | { 1390 | assert_eq!(policy_type, "AlwaysAllowPolicy"); 1391 | assert_eq!(reason, Some("Always allow policy".to_string())); 1392 | // Check the trace to ensure the policy was evaluated 1393 | let trace_str = trace.format(); 1394 | assert!(trace_str.contains("AlwaysAllowPolicy")); 1395 | } else { 1396 | panic!("Expected AccessEvaluation::Granted, got {:?}", result); 1397 | } 1398 | } 1399 | 1400 | #[tokio::test] 1401 | async fn test_one_policy_deny() { 1402 | let mut checker = PermissionChecker::new(); 1403 | checker.add_policy(AlwaysDenyPolicy("DeniedByPolicy")); 1404 | 1405 | let subject = TestSubject { 1406 | id: uuid::Uuid::new_v4(), 1407 | }; 1408 | let resource = TestResource { 1409 | id: uuid::Uuid::new_v4(), 1410 | }; 1411 | 1412 | let result = checker 1413 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1414 | .await; 1415 | 1416 | assert!(!result.is_granted()); 1417 | if let AccessEvaluation::Denied { reason, trace } = result { 1418 | assert!(reason.contains("All policies denied access")); 1419 | let trace_str = trace.format(); 1420 | assert!(trace_str.contains("DeniedByPolicy")); 1421 | } else { 1422 | panic!("Expected AccessEvaluation::Denied, got {:?}", result); 1423 | } 1424 | } 1425 | 1426 | #[tokio::test] 1427 | async fn test_multiple_policies_or_success() { 1428 | // First policy denies, second allows. Checker should return Ok, short-circuiting on second. 1429 | let mut checker = PermissionChecker::new(); 1430 | checker.add_policy(AlwaysDenyPolicy("DenyPolicy")); 1431 | checker.add_policy(AlwaysAllowPolicy); 1432 | 1433 | let subject = TestSubject { 1434 | id: uuid::Uuid::new_v4(), 1435 | }; 1436 | let resource = TestResource { 1437 | id: uuid::Uuid::new_v4(), 1438 | }; 1439 | let result = checker 1440 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1441 | .await; 1442 | if let AccessEvaluation::Granted { 1443 | policy_type, 1444 | trace, 1445 | reason: _, 1446 | } = result 1447 | { 1448 | assert_eq!(policy_type, "AlwaysAllowPolicy"); 1449 | let trace_str = trace.format(); 1450 | assert!(trace_str.contains("DenyPolicy")); 1451 | } else { 1452 | panic!("Expected AccessEvaluation::Granted, got {:?}", result); 1453 | } 1454 | } 1455 | 1456 | #[tokio::test] 1457 | async fn test_multiple_policies_all_deny_collect_reasons() { 1458 | // Both policies deny, so we expect a Forbidden 1459 | let mut checker = PermissionChecker::new(); 1460 | checker.add_policy(AlwaysDenyPolicy("DenyPolicy1")); 1461 | checker.add_policy(AlwaysDenyPolicy("DenyPolicy2")); 1462 | 1463 | let subject = TestSubject { 1464 | id: uuid::Uuid::new_v4(), 1465 | }; 1466 | let resource = TestResource { 1467 | id: uuid::Uuid::new_v4(), 1468 | }; 1469 | let result = checker 1470 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1471 | .await; 1472 | 1473 | if let AccessEvaluation::Denied { trace, reason } = result { 1474 | let trace_str = trace.format(); 1475 | assert!(trace_str.contains("DenyPolicy1")); 1476 | assert!(trace_str.contains("DenyPolicy2")); 1477 | assert_eq!(reason, "All policies denied access"); 1478 | } else { 1479 | panic!("Expected AccessEvaluation::Denied, got {:?}", result); 1480 | } 1481 | } 1482 | 1483 | // RebacPolicy tests with a dummy resolver. 1484 | 1485 | /// In-memory relationship resolver for testing. 1486 | /// It holds a vector of tuples (subject_id, resource_id, relationship) 1487 | /// to represent existing relationships. 1488 | pub struct DummyRelationshipResolver { 1489 | relationships: Vec<(uuid::Uuid, uuid::Uuid, String)>, 1490 | } 1491 | 1492 | impl DummyRelationshipResolver { 1493 | pub fn new(relationships: Vec<(uuid::Uuid, uuid::Uuid, String)>) -> Self { 1494 | Self { relationships } 1495 | } 1496 | } 1497 | 1498 | #[async_trait] 1499 | impl RelationshipResolver for DummyRelationshipResolver { 1500 | async fn has_relationship( 1501 | &self, 1502 | subject: &TestSubject, 1503 | resource: &TestResource, 1504 | relationship: &str, 1505 | ) -> bool { 1506 | self.relationships 1507 | .iter() 1508 | .any(|(s, r, rel)| s == &subject.id && r == &resource.id && rel == relationship) 1509 | } 1510 | } 1511 | 1512 | #[tokio::test] 1513 | async fn test_rebac_policy_allows_when_relationship_exists() { 1514 | let subject_id = uuid::Uuid::new_v4(); 1515 | let resource_id = uuid::Uuid::new_v4(); 1516 | let relationship = "manager"; 1517 | 1518 | let subject = TestSubject { id: subject_id }; 1519 | let resource = TestResource { id: resource_id }; 1520 | 1521 | // Create a dummy resolver that knows the subject is a manager of the resource. 1522 | let resolver = DummyRelationshipResolver::new(vec![( 1523 | subject_id, 1524 | resource_id, 1525 | relationship.to_string(), 1526 | )]); 1527 | 1528 | let policy = RebacPolicy::::new( 1529 | relationship, 1530 | resolver, 1531 | ); 1532 | 1533 | // Action and context are not used by RebacPolicy, so we pass dummy values. 1534 | let result = policy 1535 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1536 | .await; 1537 | 1538 | assert!( 1539 | result.is_granted(), 1540 | "Access should be allowed if relationship exists" 1541 | ); 1542 | } 1543 | 1544 | #[tokio::test] 1545 | async fn test_rebac_policy_denies_when_relationship_missing() { 1546 | let subject_id = uuid::Uuid::new_v4(); 1547 | let resource_id = uuid::Uuid::new_v4(); 1548 | let relationship = "manager"; 1549 | 1550 | let subject = TestSubject { id: subject_id }; 1551 | let resource = TestResource { id: resource_id }; 1552 | 1553 | // Create a dummy resolver with no relationships. 1554 | let resolver = DummyRelationshipResolver::new(vec![]); 1555 | 1556 | let policy = RebacPolicy::::new( 1557 | relationship, 1558 | resolver, 1559 | ); 1560 | 1561 | let result = policy 1562 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1563 | .await; 1564 | // Check access is denied 1565 | assert!( 1566 | !result.is_granted(), 1567 | "Access should be denied if relationship does not exist" 1568 | ); 1569 | } 1570 | 1571 | // Combinator tests. 1572 | #[tokio::test] 1573 | async fn test_and_policy_allows_when_all_allow() { 1574 | let policy = AndPolicy::try_new(vec![ 1575 | Arc::new(AlwaysAllowPolicy), 1576 | Arc::new(AlwaysAllowPolicy), 1577 | ]) 1578 | .expect("Unable to create and-policy policy"); 1579 | let subject = TestSubject { 1580 | id: uuid::Uuid::new_v4(), 1581 | }; 1582 | let resource = TestResource { 1583 | id: uuid::Uuid::new_v4(), 1584 | }; 1585 | let result = policy 1586 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1587 | .await; 1588 | assert!( 1589 | result.is_granted(), 1590 | "AndPolicy should allow access when all inner policies allow" 1591 | ); 1592 | } 1593 | #[tokio::test] 1594 | async fn test_and_policy_denies_when_one_denies() { 1595 | let policy = AndPolicy::try_new(vec![ 1596 | Arc::new(AlwaysAllowPolicy), 1597 | Arc::new(AlwaysDenyPolicy("DenyInAnd")), 1598 | ]) 1599 | .expect("Unable to create and-policy policy"); 1600 | let subject = TestSubject { 1601 | id: uuid::Uuid::new_v4(), 1602 | }; 1603 | let resource = TestResource { 1604 | id: uuid::Uuid::new_v4(), 1605 | }; 1606 | let result = policy 1607 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1608 | .await; 1609 | match result { 1610 | PolicyEvalResult::Combined { 1611 | policy_type, 1612 | operation, 1613 | children, 1614 | outcome, 1615 | } => { 1616 | assert_eq!(operation, CombineOp::And); 1617 | assert!(!outcome); 1618 | assert_eq!(children.len(), 2); 1619 | assert!(children[1].format(0).contains("DenyInAnd")); 1620 | assert_eq!(policy_type, "AndPolicy"); 1621 | } 1622 | _ => panic!("Expected Combined result from AndPolicy, got {:?}", result), 1623 | } 1624 | } 1625 | #[tokio::test] 1626 | async fn test_or_policy_allows_when_one_allows() { 1627 | let policy = OrPolicy::try_new(vec![ 1628 | Arc::new(AlwaysDenyPolicy("Deny1")), 1629 | Arc::new(AlwaysAllowPolicy), 1630 | ]) 1631 | .expect("Unable to create or-policy policy"); 1632 | let subject = TestSubject { 1633 | id: uuid::Uuid::new_v4(), 1634 | }; 1635 | let resource = TestResource { 1636 | id: uuid::Uuid::new_v4(), 1637 | }; 1638 | let result = policy 1639 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1640 | .await; 1641 | assert!( 1642 | result.is_granted(), 1643 | "OrPolicy should allow access when at least one inner policy allows" 1644 | ); 1645 | } 1646 | #[tokio::test] 1647 | async fn test_or_policy_denies_when_all_deny() { 1648 | let policy = OrPolicy::try_new(vec![ 1649 | Arc::new(AlwaysDenyPolicy("Deny1")), 1650 | Arc::new(AlwaysDenyPolicy("Deny2")), 1651 | ]) 1652 | .expect("Unable to create or-policy policy"); 1653 | let subject = TestSubject { 1654 | id: uuid::Uuid::new_v4(), 1655 | }; 1656 | let resource = TestResource { 1657 | id: uuid::Uuid::new_v4(), 1658 | }; 1659 | let result = policy 1660 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1661 | .await; 1662 | match result { 1663 | PolicyEvalResult::Combined { 1664 | policy_type, 1665 | operation, 1666 | children, 1667 | outcome, 1668 | } => { 1669 | assert_eq!(operation, CombineOp::Or); 1670 | assert!(!outcome); 1671 | assert_eq!(children.len(), 2); 1672 | assert!(children[0].format(0).contains("Deny1")); 1673 | assert!(children[1].format(0).contains("Deny2")); 1674 | assert_eq!(policy_type, "OrPolicy"); 1675 | } 1676 | _ => panic!("Expected Combined result from OrPolicy, got {:?}", result), 1677 | } 1678 | } 1679 | #[tokio::test] 1680 | async fn test_not_policy_allows_when_inner_denies() { 1681 | let policy = NotPolicy::new(AlwaysDenyPolicy("AlwaysDeny")); 1682 | let subject = TestSubject { 1683 | id: uuid::Uuid::new_v4(), 1684 | }; 1685 | let resource = TestResource { 1686 | id: uuid::Uuid::new_v4(), 1687 | }; 1688 | let result = policy 1689 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1690 | .await; 1691 | assert!( 1692 | result.is_granted(), 1693 | "NotPolicy should allow access when inner policy denies" 1694 | ); 1695 | } 1696 | #[tokio::test] 1697 | async fn test_not_policy_denies_when_inner_allows() { 1698 | let policy = NotPolicy::new(AlwaysAllowPolicy); 1699 | let subject = TestSubject { 1700 | id: uuid::Uuid::new_v4(), 1701 | }; 1702 | let resource = TestResource { 1703 | id: uuid::Uuid::new_v4(), 1704 | }; 1705 | let result = policy 1706 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1707 | .await; 1708 | match result { 1709 | PolicyEvalResult::Combined { 1710 | policy_type, 1711 | operation, 1712 | children, 1713 | outcome, 1714 | } => { 1715 | assert_eq!(operation, CombineOp::Not); 1716 | assert!(!outcome); 1717 | assert_eq!(children.len(), 1); 1718 | assert!(children[0].format(0).contains("AlwaysAllowPolicy")); 1719 | assert_eq!(policy_type, "NotPolicy"); 1720 | } 1721 | _ => panic!("Expected Combined result from NotPolicy, got {:?}", result), 1722 | } 1723 | } 1724 | 1725 | #[tokio::test] 1726 | async fn test_empty_policies_in_combinators() { 1727 | // Test AndPolicy with no policies 1728 | let and_policy_result = 1729 | AndPolicy::::try_new(vec![]); 1730 | 1731 | assert!(and_policy_result.is_err()); 1732 | 1733 | // Test OrPolicy with no policies 1734 | let or_policy_result = 1735 | OrPolicy::::try_new(vec![]); 1736 | assert!(or_policy_result.is_err()); 1737 | } 1738 | 1739 | #[tokio::test] 1740 | async fn test_deeply_nested_combinators() { 1741 | // Create a complex policy structure: NOT(AND(Allow, OR(Deny, NOT(Deny)))) 1742 | let inner_not = NotPolicy::new(AlwaysDenyPolicy("InnerDeny")); 1743 | 1744 | let inner_or = OrPolicy::try_new(vec![ 1745 | Arc::new(AlwaysDenyPolicy("MidDeny")), 1746 | Arc::new(inner_not), 1747 | ]) 1748 | .expect("Unable to create or-policy policy"); 1749 | 1750 | let inner_and = AndPolicy::try_new(vec![Arc::new(AlwaysAllowPolicy), Arc::new(inner_or)]) 1751 | .expect("Unable to create and-policy policy"); 1752 | 1753 | let outer_not = NotPolicy::new(inner_and); 1754 | 1755 | let subject = TestSubject { 1756 | id: uuid::Uuid::new_v4(), 1757 | }; 1758 | let resource = TestResource { 1759 | id: uuid::Uuid::new_v4(), 1760 | }; 1761 | 1762 | let result = outer_not 1763 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1764 | .await; 1765 | 1766 | // This complex structure should result in a denial 1767 | assert!(!result.is_granted()); 1768 | 1769 | // Verify the correct structure of the trace 1770 | let trace_str = result.format(0); 1771 | assert!(trace_str.contains("NOT")); 1772 | assert!(trace_str.contains("AND")); 1773 | assert!(trace_str.contains("OR")); 1774 | assert!(trace_str.contains("InnerDeny")); 1775 | } 1776 | 1777 | #[derive(Debug, Clone)] 1778 | struct FeatureFlagContext { 1779 | feature_enabled: bool, 1780 | } 1781 | 1782 | struct FeatureFlagPolicy; 1783 | 1784 | #[async_trait] 1785 | impl Policy for FeatureFlagPolicy { 1786 | async fn evaluate_access( 1787 | &self, 1788 | _subject: &TestSubject, 1789 | _action: &TestAction, 1790 | _resource: &TestResource, 1791 | context: &FeatureFlagContext, 1792 | ) -> PolicyEvalResult { 1793 | if context.feature_enabled { 1794 | PolicyEvalResult::Granted { 1795 | policy_type: self.policy_type(), 1796 | reason: Some("Feature flag enabled".to_string()), 1797 | } 1798 | } else { 1799 | PolicyEvalResult::Denied { 1800 | policy_type: self.policy_type(), 1801 | reason: "Feature flag disabled".to_string(), 1802 | } 1803 | } 1804 | } 1805 | 1806 | fn policy_type(&self) -> String { 1807 | "FeatureFlagPolicy".to_string() 1808 | } 1809 | } 1810 | 1811 | #[tokio::test] 1812 | async fn test_context_sensitive_policy() { 1813 | let policy = FeatureFlagPolicy; 1814 | let subject = TestSubject { 1815 | id: uuid::Uuid::new_v4(), 1816 | }; 1817 | let resource = TestResource { 1818 | id: uuid::Uuid::new_v4(), 1819 | }; 1820 | 1821 | // Test with flag enabled 1822 | let context_enabled = FeatureFlagContext { 1823 | feature_enabled: true, 1824 | }; 1825 | let result = policy 1826 | .evaluate_access(&subject, &TestAction, &resource, &context_enabled) 1827 | .await; 1828 | assert!(result.is_granted()); 1829 | 1830 | // Test with flag disabled 1831 | let context_disabled = FeatureFlagContext { 1832 | feature_enabled: false, 1833 | }; 1834 | let result = policy 1835 | .evaluate_access(&subject, &TestAction, &resource, &context_disabled) 1836 | .await; 1837 | assert!(!result.is_granted()); 1838 | } 1839 | 1840 | #[tokio::test] 1841 | async fn test_short_circuit_evaluation() { 1842 | // Create a counter to track policy evaluation 1843 | use std::sync::atomic::{AtomicUsize, Ordering}; 1844 | use std::sync::Arc as StdArc; 1845 | 1846 | let evaluation_count = StdArc::new(AtomicUsize::new(0)); 1847 | 1848 | struct CountingPolicy { 1849 | result: bool, 1850 | counter: StdArc, 1851 | } 1852 | 1853 | #[async_trait] 1854 | impl Policy for CountingPolicy { 1855 | async fn evaluate_access( 1856 | &self, 1857 | _subject: &TestSubject, 1858 | _action: &TestAction, 1859 | _resource: &TestResource, 1860 | _context: &TestContext, 1861 | ) -> PolicyEvalResult { 1862 | self.counter.fetch_add(1, Ordering::SeqCst); 1863 | 1864 | if self.result { 1865 | PolicyEvalResult::Granted { 1866 | policy_type: self.policy_type(), 1867 | reason: Some("Counting policy granted".to_string()), 1868 | } 1869 | } else { 1870 | PolicyEvalResult::Denied { 1871 | policy_type: self.policy_type(), 1872 | reason: "Counting policy denied".to_string(), 1873 | } 1874 | } 1875 | } 1876 | 1877 | fn policy_type(&self) -> String { 1878 | "CountingPolicy".to_string() 1879 | } 1880 | } 1881 | 1882 | // Test AND short circuit on first deny 1883 | let count_clone = evaluation_count.clone(); 1884 | evaluation_count.store(0, Ordering::SeqCst); 1885 | 1886 | let and_policy = AndPolicy::try_new(vec![ 1887 | Arc::new(CountingPolicy { 1888 | result: false, 1889 | counter: count_clone.clone(), 1890 | }), 1891 | Arc::new(CountingPolicy { 1892 | result: true, 1893 | counter: count_clone, 1894 | }), 1895 | ]) 1896 | .expect("Unable to create 'and' policy"); 1897 | 1898 | let subject = TestSubject { 1899 | id: uuid::Uuid::new_v4(), 1900 | }; 1901 | let resource = TestResource { 1902 | id: uuid::Uuid::new_v4(), 1903 | }; 1904 | and_policy 1905 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1906 | .await; 1907 | 1908 | assert_eq!( 1909 | evaluation_count.load(Ordering::SeqCst), 1910 | 1, 1911 | "AND policy should short-circuit after first deny" 1912 | ); 1913 | 1914 | // Test OR short circuit on first allow 1915 | let count_clone = evaluation_count.clone(); 1916 | evaluation_count.store(0, Ordering::SeqCst); 1917 | 1918 | let or_policy = OrPolicy::try_new(vec![ 1919 | Arc::new(CountingPolicy { 1920 | result: true, 1921 | counter: count_clone.clone(), 1922 | }), 1923 | Arc::new(CountingPolicy { 1924 | result: false, 1925 | counter: count_clone, 1926 | }), 1927 | ]) 1928 | .unwrap(); 1929 | 1930 | or_policy 1931 | .evaluate_access(&subject, &TestAction, &resource, &TestContext) 1932 | .await; 1933 | 1934 | assert_eq!( 1935 | evaluation_count.load(Ordering::SeqCst), 1936 | 1, 1937 | "OR policy should short-circuit after first allow" 1938 | ); 1939 | } 1940 | } 1941 | 1942 | #[cfg(test)] 1943 | mod policy_builder_tests { 1944 | use super::*; 1945 | use uuid::Uuid; 1946 | 1947 | // Define simple test types 1948 | #[derive(Debug, Clone)] 1949 | struct TestSubject { 1950 | pub name: String, 1951 | } 1952 | #[derive(Debug, Clone)] 1953 | struct TestAction; 1954 | #[derive(Debug, Clone)] 1955 | struct TestResource; 1956 | #[derive(Debug, Clone)] 1957 | struct TestContext; 1958 | 1959 | // Test that with no predicates the builder returns a policy that always "matches" 1960 | #[tokio::test] 1961 | async fn test_policy_builder_allows_when_no_predicates() { 1962 | let policy = PolicyBuilder::::new( 1963 | "NoPredicatesPolicy", 1964 | ) 1965 | .build(); 1966 | 1967 | let result = policy 1968 | .evaluate_access( 1969 | &TestSubject { name: "Any".into() }, 1970 | &TestAction, 1971 | &TestResource, 1972 | &TestContext, 1973 | ) 1974 | .await; 1975 | assert!( 1976 | result.is_granted(), 1977 | "Policy built with no predicates should allow access (default true)" 1978 | ); 1979 | } 1980 | 1981 | // Test that a subject predicate is applied correctly. 1982 | #[tokio::test] 1983 | async fn test_policy_builder_with_subject_predicate() { 1984 | let policy = PolicyBuilder::::new( 1985 | "SubjectPolicy", 1986 | ) 1987 | .subjects(|s: &TestSubject| s.name == "Alice") 1988 | .build(); 1989 | 1990 | // Should allow if the subject's name is "Alice" 1991 | let result1 = policy 1992 | .evaluate_access( 1993 | &TestSubject { 1994 | name: "Alice".into(), 1995 | }, 1996 | &TestAction, 1997 | &TestResource, 1998 | &TestContext, 1999 | ) 2000 | .await; 2001 | assert!( 2002 | result1.is_granted(), 2003 | "Policy should allow access for subject 'Alice'" 2004 | ); 2005 | 2006 | // Otherwise, it should deny 2007 | let result2 = policy 2008 | .evaluate_access( 2009 | &TestSubject { name: "Bob".into() }, 2010 | &TestAction, 2011 | &TestResource, 2012 | &TestContext, 2013 | ) 2014 | .await; 2015 | assert!( 2016 | !result2.is_granted(), 2017 | "Policy should deny access for subject not named 'Alice'" 2018 | ); 2019 | } 2020 | 2021 | // Test that setting the effect to Deny overrides an otherwise matching predicate. 2022 | #[tokio::test] 2023 | async fn test_policy_builder_effect_deny() { 2024 | let policy = 2025 | PolicyBuilder::::new("DenyPolicy") 2026 | .effect(Effect::Deny) 2027 | .build(); 2028 | 2029 | // Even though no predicate fails (so predicate returns true), 2030 | // the effect should result in a Denied outcome. 2031 | let result = policy 2032 | .evaluate_access( 2033 | &TestSubject { 2034 | name: "Anyone".into(), 2035 | }, 2036 | &TestAction, 2037 | &TestResource, 2038 | &TestContext, 2039 | ) 2040 | .await; 2041 | assert!( 2042 | !result.is_granted(), 2043 | "Policy with effect Deny should result in denial even if the predicate passes" 2044 | ); 2045 | } 2046 | 2047 | // Test that extra conditions (combining multiple inputs) work correctly. 2048 | #[tokio::test] 2049 | async fn test_policy_builder_with_extra_condition() { 2050 | #[derive(Debug, Clone)] 2051 | struct ExtendedSubject { 2052 | pub id: Uuid, 2053 | pub name: String, 2054 | } 2055 | #[derive(Debug, Clone)] 2056 | struct ExtendedResource { 2057 | pub owner_id: Uuid, 2058 | } 2059 | #[derive(Debug, Clone)] 2060 | struct ExtendedAction; 2061 | #[derive(Debug, Clone)] 2062 | struct ExtendedContext; 2063 | 2064 | // Build a policy that checks: 2065 | // 1. Subject's name is "Alice" 2066 | // 2. And that subject.id == resource.owner_id (via extra condition) 2067 | let subject_id = Uuid::new_v4(); 2068 | let policy = PolicyBuilder::< 2069 | ExtendedSubject, 2070 | ExtendedResource, 2071 | ExtendedAction, 2072 | ExtendedContext, 2073 | >::new("AliceOwnerPolicy") 2074 | .subjects(|s: &ExtendedSubject| s.name == "Alice") 2075 | .when(|s, _a, r, _c| s.id == r.owner_id) 2076 | .build(); 2077 | 2078 | // Case where both conditions are met. 2079 | let result1 = policy 2080 | .evaluate_access( 2081 | &ExtendedSubject { 2082 | id: subject_id, 2083 | name: "Alice".into(), 2084 | }, 2085 | &ExtendedAction, 2086 | &ExtendedResource { 2087 | owner_id: subject_id, 2088 | }, 2089 | &ExtendedContext, 2090 | ) 2091 | .await; 2092 | assert!( 2093 | result1.is_granted(), 2094 | "Policy should allow access when conditions are met" 2095 | ); 2096 | 2097 | // Case where extra condition fails (different id) 2098 | let result2 = policy 2099 | .evaluate_access( 2100 | &ExtendedSubject { 2101 | id: subject_id, 2102 | name: "Alice".into(), 2103 | }, 2104 | &ExtendedAction, 2105 | &ExtendedResource { 2106 | owner_id: Uuid::new_v4(), 2107 | }, 2108 | &ExtendedContext, 2109 | ) 2110 | .await; 2111 | assert!( 2112 | !result2.is_granted(), 2113 | "Policy should deny access when extra condition fails" 2114 | ); 2115 | } 2116 | } 2117 | --------------------------------------------------------------------------------