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    // String literals stay Cow::Borrowed on the trace path.
66    PolicyBuilder::<AdminDomain>::new("ScopedPermission")
67        .when(
68            |user: &StaffUser, action: &AdminAction, org: &Organization, _ctx: &()| {
69                user.permissions
70                    .iter()
71                    .any(|p| p.scope == action.required_scope() && p.entity == org.id)
72            },
73        )
74        .build()
75}
76
77/// Grants on a single axis — the subject — so it uses `.subjects()` rather
78/// than `.when()`: single-axis predicates batch better and read clearer.
79fn global_admin_policy() -> Box<dyn Policy<AdminDomain>> {
80    PolicyBuilder::<AdminDomain>::new("GlobalAdmin")
81        .subjects(|user: &StaffUser| user.permissions.iter().any(|p| p.scope == "global_admin"))
82        .build()
83}
84
85#[tokio::main]
86async fn main() {
87    let mut checker = PermissionChecker::<AdminDomain>::new();
88    checker.add_policy(scoped_permission_policy());
89    checker.add_policy(global_admin_policy());
90
91    let org1 = Organization { id: "org-1".into() };
92    let org2 = Organization { id: "org-2".into() };
93
94    let org1_admin = StaffUser {
95        name: "org1-admin",
96        permissions: vec![GroupPermission {
97            scope: "edit_user_settings",
98            entity: "org-1".into(),
99        }],
100    };
101    let org2_admin = StaffUser {
102        name: "org2-admin",
103        permissions: vec![GroupPermission {
104            scope: "edit_user_settings",
105            entity: "org-2".into(),
106        }],
107    };
108    let no_grants = StaffUser {
109        name: "no-grants",
110        permissions: vec![],
111    };
112    let global_admin = StaffUser {
113        name: "global-admin",
114        permissions: vec![GroupPermission {
115            scope: "global_admin",
116            entity: String::new(),
117        }],
118    };
119
120    // (user, action, organization, expected outcome)
121    let cases = [
122        // Scoped grant matches its own org…
123        (&org1_admin, AdminAction::EditUserSettings, &org1, true),
124        // …but not another org, and not another scope on the same org.
125        (&org2_admin, AdminAction::EditUserSettings, &org1, false),
126        (&org1_admin, AdminAction::EditOrgSettings, &org1, false),
127        (&org2_admin, AdminAction::EditUserSettings, &org2, true),
128        (&no_grants, AdminAction::EditUserSettings, &org1, false),
129        // The global admin passes via the subject-axis policy on any org.
130        (&global_admin, AdminAction::EditOrgSettings, &org1, true),
131    ];
132
133    for (user, action, org, expected_granted) in cases {
134        let session = EvaluationSession::empty();
135        let decision = checker.bind(&session, user, &action, &()).check(org).await;
136        println!(
137            "{:<12} {:?} on {}: {}",
138            user.name,
139            action,
140            org.id,
141            if decision.is_granted() {
142                "GRANTED"
143            } else {
144                "DENIED"
145            }
146        );
147        assert_eq!(decision.is_granted(), expected_granted);
148    }
149
150    // The trace names the policy that decided; for a denial it shows every
151    // policy that was consulted and why each said no.
152    println!("\nWhy org2-admin cannot edit user settings on org-1:");
153    let session = EvaluationSession::empty();
154    let decision = checker
155        .bind(&session, &org2_admin, &AdminAction::EditUserSettings, &())
156        .check(&org1)
157        .await;
158    println!("{}", decision.display_trace());
159}