├── .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 |
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 | [](https://github.com/thepartly/gatehouse/actions/workflows/ci.yml)
4 | [](https://crates.io/crates/gatehouse)
5 | [](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 | 
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 |
--------------------------------------------------------------------------------