Skip to main content

delegating_policy/
delegating_policy.rs

1//! Cross-domain delegation with `DelegatingPolicy`.
2//!
3//! Sometimes a decision in one domain is really a decision in another. Comment
4//! moderation is the classic case: you may edit a comment if you wrote it, OR
5//! if you are allowed to edit the document it hangs off. That second clause is
6//! not a comment rule at all — it is the *document* domain's decision.
7//!
8//! Rather than copy the document's owner/admin logic into the comment checker
9//! (where the two copies will drift), `DelegatingPolicy` maps the comment
10//! request into the document domain and asks the document checker, reusing the
11//! same `EvaluationSession` and folding the child decision into the trace.
12//!
13//! Run with:
14//!
15//! ```text
16//! cargo run --example delegating_policy
17//! ```
18
19use async_trait::async_trait;
20use gatehouse::{
21    DelegatingPolicy, EvalCtx, EvaluationSession, PermissionChecker, Policy, PolicyBuilder,
22    PolicyDomain, PolicyEvalResult,
23};
24use std::borrow::Cow;
25use uuid::Uuid;
26
27// ---- child domain: documents --------------------------------------
28
29#[derive(Debug, Clone)]
30struct DocUser {
31    id: Uuid,
32    is_admin: bool,
33}
34
35#[derive(Debug, Clone)]
36struct Document {
37    owner_id: Uuid,
38}
39
40#[derive(Debug, Clone)]
41struct EditDoc;
42
43struct DocumentDomain;
44
45impl PolicyDomain for DocumentDomain {
46    type Subject = DocUser;
47    type Action = EditDoc;
48    type Resource = Document;
49    type Context = ();
50}
51
52/// The document domain owns its own access rules — the owner can edit, and so
53/// can an admin. This checker is the single source of truth for "can edit this
54/// document"; the comment domain borrows it rather than reimplementing it.
55fn document_checker() -> PermissionChecker<DocumentDomain> {
56    let owner = PolicyBuilder::<DocumentDomain>::new("DocumentOwner")
57        .when(|user, _action, document, _ctx| user.id == document.owner_id)
58        .build();
59    let admin = PolicyBuilder::<DocumentDomain>::new("DocumentAdmin")
60        .subjects(|user| user.is_admin)
61        .build();
62
63    let mut checker = PermissionChecker::named("DocumentChecker");
64    checker.add_policy(owner);
65    checker.add_policy(admin);
66    checker
67}
68
69// ---- parent domain: comments --------------------------------------
70
71#[derive(Debug, Clone)]
72struct Principal {
73    user_id: Uuid,
74    is_admin: bool,
75}
76
77#[derive(Debug, Clone)]
78struct Comment {
79    author_id: Uuid,
80    /// The document this comment hangs off. A real app loads it alongside the
81    /// comment; the delegating policy reads it to form the child request.
82    document: Document,
83}
84
85#[derive(Debug, Clone)]
86struct EditComment;
87
88struct CommentDomain;
89
90impl PolicyDomain for CommentDomain {
91    type Subject = Principal;
92    type Action = EditComment;
93    type Resource = Comment;
94    type Context = ();
95}
96
97/// Direct comment rule: you can always edit your own comment.
98struct AuthorCanEditComment;
99
100#[async_trait]
101impl Policy<CommentDomain> for AuthorCanEditComment {
102    async fn evaluate(&self, ctx: &EvalCtx<'_, CommentDomain>) -> PolicyEvalResult {
103        if ctx.subject.user_id == ctx.resource.author_id {
104            ctx.grant("subject is the comment author")
105        } else {
106            ctx.not_applicable("subject is not the comment author")
107        }
108    }
109    fn policy_type(&self) -> Cow<'static, str> {
110        Cow::Borrowed("AuthorCanEditComment")
111    }
112}
113
114/// The comment checker: edit your own comment OR inherit edit rights from the
115/// parent document via delegation.
116fn comment_checker() -> PermissionChecker<CommentDomain> {
117    // The four mappers are the entire bridge between the comment domain and the
118    // document domain. Subject, action, and context map once per batch; resource
119    // maps once per item.
120    let inherit_from_document = DelegatingPolicy::new(
121        "InheritDocumentEdit",
122        document_checker(),
123        |principal: &Principal| DocUser {
124            id: principal.user_id,
125            is_admin: principal.is_admin,
126        },
127        |_action: &EditComment| EditDoc,
128        |_subject: &Principal, _action: &EditComment, comment: &Comment, _ctx: &()| {
129            comment.document.clone()
130        },
131        |_subject, _action, _ctx| (),
132    );
133
134    let mut checker = PermissionChecker::named("CommentChecker");
135    checker.add_policy(AuthorCanEditComment);
136    checker.add_policy(inherit_from_document);
137    checker
138}
139
140// ---- driver --------------------------------------------------------
141
142#[tokio::main]
143async fn main() {
144    let owner_id = Uuid::new_v4();
145    let author_id = Uuid::new_v4();
146
147    let comment = Comment {
148        author_id,
149        document: Document { owner_id },
150    };
151
152    let author = Principal {
153        user_id: author_id,
154        is_admin: false,
155    };
156    let document_owner = Principal {
157        user_id: owner_id,
158        is_admin: false,
159    };
160    let admin = Principal {
161        user_id: Uuid::new_v4(),
162        is_admin: true,
163    };
164    let stranger = Principal {
165        user_id: Uuid::new_v4(),
166        is_admin: false,
167    };
168
169    let checker = comment_checker();
170    let session = EvaluationSession::empty();
171    let action = EditComment;
172    let context = ();
173
174    let cases = [
175        ("author", &author),
176        ("document owner (not author)", &document_owner),
177        ("admin (not author/owner)", &admin),
178        ("unrelated user", &stranger),
179    ];
180    for (who, principal) in cases {
181        let granted = checker
182            .bind(&session, principal, &action, &context)
183            .check(&comment)
184            .await
185            .is_granted();
186        println!(
187            "{who:<28} can edit the comment? {}",
188            if granted { "yes" } else { "no" }
189        );
190    }
191
192    // The document owner is not the comment author, so the direct rule denies;
193    // the delegating policy then asks the document checker, which grants. The
194    // trace shows the decision crossing the domain boundary.
195    println!("\nTrace — document owner (not the author) editing the comment:");
196    let decision = checker
197        .bind(&session, &document_owner, &action, &context)
198        .check(&comment)
199        .await;
200    println!("{}", decision.display_trace());
201
202    assert!(checker
203        .bind(&session, &author, &action, &context)
204        .check(&comment)
205        .await
206        .is_granted());
207    assert!(checker
208        .bind(&session, &document_owner, &action, &context)
209        .check(&comment)
210        .await
211        .is_granted());
212    assert!(checker
213        .bind(&session, &admin, &action, &context)
214        .check(&comment)
215        .await
216        .is_granted());
217    assert!(!checker
218        .bind(&session, &stranger, &action, &context)
219        .check(&comment)
220        .await
221        .is_granted());
222}