Skip to main content

policy_builder/
policy_builder.rs

1//! # PolicyBuilder Example — scoped admin permissions
2//!
3//! A staff user holds scoped permission grants like "edit_user_settings on
4//! org-1". The organization being administered is the *resource*, the action
5//! selects which scope is required, and `PolicyBuilder` chains the predicates
6//! with AND logic. (The decision needs nothing call-specific, so the context
7//! type is `()` — see `mfa_freshness_context` for when a real context earns
8//! its place.)
9//!
10//! To run this example:
11//! ```sh
12//! cargo run --example policy_builder
13//! ```
14use gatehouse::*;
15
16/// One grant: a permission scope applied to one entity.
17#[derive(Debug, Clone)]
18pub struct GroupPermission {
19    /// The scope of the permission (e.g., `edit_user_settings`).
20    pub scope: &'static str,
21    /// The entity the permission applies to (an organization id).
22    pub entity: String,
23}
24
25#[derive(Debug, Clone)]
26pub struct StaffUser {
27    pub name: &'static str,
28    pub permissions: Vec<GroupPermission>,
29}
30
31/// The resource: the organization being administered.
32#[derive(Debug, Clone)]
33pub struct Organization {
34    pub id: String,
35}
36
37#[derive(Debug, Clone, Copy)]
38pub enum AdminAction {
39    EditUserSettings,
40    EditOrgSettings,
41}
42
43impl AdminAction {
44    fn required_scope(self) -> &'static str {
45        match self {
46            Self::EditUserSettings => "edit_user_settings",
47            Self::EditOrgSettings => "edit_org_settings",
48        }
49    }
50}
51
52struct AdminDomain;
53
54impl PolicyDomain for AdminDomain {
55    type Subject = StaffUser;
56    type Action = AdminAction;
57    type Resource = Organization;
58    type Context = ();
59}
60
61/// Grants when the user holds the scope the action requires *on this
62/// organization*. The predicate reads three axes (subject, action, resource),
63/// which is exactly the cross-axis case `.when()` exists for.
64fn scoped_permission_policy() -> Box<dyn Policy<AdminDomain>> {
65    PolicyBuilder::<AdminDomain>::new("ScopedPermission")
66        .when(
67            |user: &StaffUser, action: &AdminAction, org: &Organization, _ctx: &()| {
68                user.permissions
69                    .iter()
70                    .any(|p| p.scope == action.required_scope() && p.entity == org.id)
71            },
72        )
73        .build()
74}
75
76/// Grants on a single axis — the subject — so it uses `.subjects()` rather
77/// than `.when()`: single-axis predicates batch better and read clearer.
78fn global_admin_policy() -> Box<dyn Policy<AdminDomain>> {
79    PolicyBuilder::<AdminDomain>::new("GlobalAdmin")
80        .subjects(|user: &StaffUser| user.permissions.iter().any(|p| p.scope == "global_admin"))
81        .build()
82}
83
84#[tokio::main]
85async fn main() {
86    let mut checker = PermissionChecker::<AdminDomain>::new();
87    checker.add_policy(scoped_permission_policy());
88    checker.add_policy(global_admin_policy());
89
90    let org1 = Organization { id: "org-1".into() };
91    let org2 = Organization { id: "org-2".into() };
92
93    let org1_admin = StaffUser {
94        name: "org1-admin",
95        permissions: vec![GroupPermission {
96            scope: "edit_user_settings",
97            entity: "org-1".into(),
98        }],
99    };
100    let org2_admin = StaffUser {
101        name: "org2-admin",
102        permissions: vec![GroupPermission {
103            scope: "edit_user_settings",
104            entity: "org-2".into(),
105        }],
106    };
107    let no_grants = StaffUser {
108        name: "no-grants",
109        permissions: vec![],
110    };
111    let global_admin = StaffUser {
112        name: "global-admin",
113        permissions: vec![GroupPermission {
114            scope: "global_admin",
115            entity: String::new(),
116        }],
117    };
118
119    // (user, action, organization, expected outcome)
120    let cases = [
121        // Scoped grant matches its own org…
122        (&org1_admin, AdminAction::EditUserSettings, &org1, true),
123        // …but not another org, and not another scope on the same org.
124        (&org2_admin, AdminAction::EditUserSettings, &org1, false),
125        (&org1_admin, AdminAction::EditOrgSettings, &org1, false),
126        (&org2_admin, AdminAction::EditUserSettings, &org2, true),
127        (&no_grants, AdminAction::EditUserSettings, &org1, false),
128        // The global admin passes via the subject-axis policy on any org.
129        (&global_admin, AdminAction::EditOrgSettings, &org1, true),
130    ];
131
132    for (user, action, org, expected_granted) in cases {
133        let session = EvaluationSession::empty();
134        let decision = checker.bind(&session, user, &action, &()).check(org).await;
135        println!(
136            "{:<12} {:?} on {}: {}",
137            user.name,
138            action,
139            org.id,
140            if decision.is_granted() {
141                "GRANTED"
142            } else {
143                "DENIED"
144            }
145        );
146        assert_eq!(decision.is_granted(), expected_granted);
147    }
148
149    // The trace names the policy that decided; for a denial it shows every
150    // policy that was consulted and why each said no.
151    println!("\nWhy org2-admin cannot edit user settings on org-1:");
152    let session = EvaluationSession::empty();
153    let decision = checker
154        .bind(&session, &org2_admin, &AdminAction::EditUserSettings, &())
155        .check(&org1)
156        .await;
157    println!("{}", decision.display_trace());
158}