Skip to main content

actix_web/
actix_web.rs

1// Actix Web example showing Gatehouse in a fact-backed service: shared app
2// state owns a long-lived `PermissionChecker` and a relationship `FactSource`,
3// and each request builds its own `EvaluationSession`. A blog post is viewable
4// or editable by its author, by a registered collaborator (an "editor"
5// relationship loaded through the session), or by an admin.
6//
7// The server exposes four routes:
8//
9// - `GET  /posts`                 lists the posts the caller may view (batched).
10// - `GET  /posts/{id}`            reads a post when it is published or the caller is privileged.
11// - `PUT  /posts/{id}`            edits a post if the caller is allowed.
12// - `POST /posts/{id}/publish`    publishes a post for editors.
13//
14// Try it with curl (the demo grants user 2222… an editor relationship on the
15// demo posts, so they can view drafts and edit without being the author):
16//
17// ```bash
18// # The author lists their posts
19// curl -s http://127.0.0.1:8080/posts \
20//   -H "x-user-id: 11111111-1111-1111-1111-111111111111"
21//
22// # A collaborator (editor relationship) edits a draft they did not author
23// curl -i -X PUT http://127.0.0.1:8080/posts/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa \
24//   -H "x-user-id: 22222222-2222-2222-2222-222222222222"
25//
26// # Anyone can view a published post
27// curl -i http://127.0.0.1:8080/posts/00000000-0000-0000-0000-000000000000 \
28//   -H "x-post-published: true"
29// ```
30//
31// Each handler pulls the shared `AppState` from Actix Web's `Data` extractor,
32// builds a request-scoped `EvaluationSession`, and evaluates with
33// `bind(...).check(...)` (single resource) or
34// `bind(...).filter(...)` (the list endpoint).
35//
36// Note: on denial these handlers echo the evaluation trace back in the HTTP
37// response so you can see the decision from `curl`. That is a demo convenience,
38// not a production pattern — see `forbidden` below.
39
40use actix_web::{
41    dev::Payload, web, App, FromRequest, HttpRequest, HttpResponse, HttpServer, Responder,
42};
43use async_trait::async_trait;
44use gatehouse::{
45    AccessEvaluation, AndPolicy, EvalTrace, EvaluationSession, FactLoadResult, FactRegistry,
46    FactSource, PermissionChecker, Policy, PolicyBuilder, PolicyDomain, RebacPolicy,
47    RelationshipQuery,
48};
49use serde::Serialize;
50use std::collections::HashSet;
51use std::fmt;
52use std::future::{ready, Ready};
53use std::sync::Arc;
54use std::time::{Duration, SystemTime};
55use uuid::Uuid;
56
57// --------------------
58// 1) Domain Modeling
59// --------------------
60
61#[derive(Debug, Clone)]
62pub struct User {
63    pub id: Uuid,
64    pub roles: Vec<String>,
65}
66
67#[derive(Debug, Clone)]
68pub struct AuthenticatedUser(pub User);
69
70impl FromRequest for AuthenticatedUser {
71    type Error = actix_web::Error;
72    type Future = Ready<Result<Self, Self::Error>>;
73
74    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
75        let id = req
76            .headers()
77            .get("x-user-id")
78            .and_then(|value| value.to_str().ok())
79            .and_then(|value| Uuid::parse_str(value).ok())
80            .unwrap_or_else(Uuid::nil);
81
82        let roles = req
83            .headers()
84            .get("x-roles")
85            .and_then(|value| value.to_str().ok())
86            .map(|raw| {
87                raw.split(',')
88                    .map(|role| role.trim().to_lowercase())
89                    .filter(|role| !role.is_empty())
90                    .collect::<Vec<_>>()
91            })
92            .unwrap_or_default();
93
94        ready(Ok(AuthenticatedUser(User { id, roles })))
95    }
96}
97
98fn parse_bool(value: &str) -> Option<bool> {
99    match value.trim().to_ascii_lowercase().as_str() {
100        "true" | "1" | "yes" => Some(true),
101        "false" | "0" | "no" => Some(false),
102        _ => None,
103    }
104}
105
106/// Header overrides so a single demo post can be coerced into different shapes
107/// (locked, published, older than the draft window) from `curl`.
108#[derive(Debug, Clone, Default)]
109pub struct PostOverrides {
110    locked: Option<bool>,
111    published: Option<bool>,
112    age_days: Option<u64>,
113}
114
115impl PostOverrides {
116    pub fn from_request(req: &HttpRequest) -> Self {
117        let header_bool = |name: &str| {
118            req.headers()
119                .get(name)
120                .and_then(|value| value.to_str().ok())
121                .and_then(parse_bool)
122        };
123
124        Self {
125            locked: header_bool("x-post-locked"),
126            published: header_bool("x-post-published"),
127            age_days: req
128                .headers()
129                .get("x-post-age-days")
130                .and_then(|value| value.to_str().ok())
131                .and_then(|raw| raw.parse::<u64>().ok()),
132        }
133    }
134}
135
136#[derive(Debug, Clone)]
137pub struct BlogPost {
138    pub id: Uuid,
139    pub title: String,
140    pub author_id: Uuid,
141    pub locked: bool,
142    pub published_at: Option<SystemTime>,
143    pub created_at: SystemTime,
144}
145
146#[derive(Debug, Clone)]
147pub enum Action {
148    Edit,
149    Publish,
150    View,
151}
152
153#[derive(Debug, Clone)]
154pub struct RequestContext {
155    pub current_time: SystemTime,
156}
157
158impl RequestContext {
159    fn now() -> Self {
160        Self {
161            current_time: SystemTime::now(),
162        }
163    }
164}
165
166pub struct BlogDomain;
167
168impl PolicyDomain for BlogDomain {
169    type Subject = User;
170    type Action = Action;
171    type Resource = BlogPost;
172    type Context = RequestContext;
173}
174
175// A typed relation set, even though the in-memory store could use strings. The
176// session deduplicates and caches by the typed `RelationshipQuery`.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
178pub enum Relation {
179    Editor,
180}
181
182impl fmt::Display for Relation {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            Self::Editor => f.write_str("editor"),
186        }
187    }
188}
189
190type PostRelationship = RelationshipQuery<Uuid, Uuid, Relation>;
191
192/// In-memory collaborator relationships. A real service would back this with a
193/// database pool or graph client; the `FactSource` boundary is identical.
194#[derive(Clone)]
195pub struct InMemoryRelationshipSource {
196    grants: Arc<HashSet<PostRelationship>>,
197}
198
199impl InMemoryRelationshipSource {
200    fn new(grants: impl IntoIterator<Item = PostRelationship>) -> Self {
201        Self {
202            grants: Arc::new(grants.into_iter().collect()),
203        }
204    }
205}
206
207#[async_trait]
208impl FactSource<PostRelationship> for InMemoryRelationshipSource {
209    async fn load_many(&self, keys: &[PostRelationship]) -> Vec<FactLoadResult<bool>> {
210        keys.iter()
211            .map(|key| FactLoadResult::Found(self.grants.contains(key)))
212            .collect()
213    }
214}
215
216// --------------------------
217// 2) Shared application state
218// --------------------------
219
220/// The long-lived pieces: the checker and fact registry are built once at
221/// startup and shared across requests. Each request derives a fresh
222/// `EvaluationSession` from the registry.
223#[derive(Clone)]
224pub struct AppState {
225    checker: Arc<PermissionChecker<BlogDomain>>,
226    fact_registry: FactRegistry,
227    posts: Arc<Vec<BlogPost>>,
228}
229
230impl AppState {
231    pub fn demo() -> Self {
232        let author_id = demo_author_id();
233        let collaborator_id = demo_collaborator_id();
234        let posts = demo_posts(author_id);
235
236        // The collaborator has an editor relationship on every demo post.
237        let grants = posts.iter().map(|post| PostRelationship {
238            subject_id: collaborator_id,
239            resource_id: post.id,
240            relation: Relation::Editor,
241        });
242
243        Self {
244            checker: Arc::new(build_permission_checker()),
245            fact_registry: FactRegistry::builder()
246                .with_arc::<PostRelationship>(Arc::new(InMemoryRelationshipSource::new(grants)))
247                .build(),
248            posts: Arc::new(posts),
249        }
250    }
251
252    fn request_session(&self) -> EvaluationSession {
253        self.fact_registry.session()
254    }
255}
256
257fn demo_author_id() -> Uuid {
258    Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
259}
260
261fn demo_collaborator_id() -> Uuid {
262    Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap()
263}
264
265fn demo_posts(author_id: Uuid) -> Vec<BlogPost> {
266    let now = SystemTime::now();
267    vec![
268        BlogPost {
269            id: Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
270            title: "draft roadmap".into(),
271            author_id,
272            locked: false,
273            published_at: None,
274            created_at: now - Duration::from_secs(3 * 24 * 60 * 60),
275        },
276        BlogPost {
277            id: Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap(),
278            title: "published announcement".into(),
279            author_id,
280            locked: false,
281            published_at: Some(now - Duration::from_secs(2 * 24 * 60 * 60)),
282            created_at: now - Duration::from_secs(10 * 24 * 60 * 60),
283        },
284    ]
285}
286
287// --------------------------
288// 3) Building Our Policies
289// --------------------------
290
291fn admin_override_policy() -> Box<dyn Policy<BlogDomain>> {
292    PolicyBuilder::<BlogDomain>::new("AdminOverride")
293        .when(|user, _action, _post, _ctx| user.roles.iter().any(|role| role == "admin"))
294        .build()
295}
296
297/// Editing rule for the author: edit your own unpublished, unlocked draft that
298/// is still inside the 30-day window.
299fn author_can_edit_policy() -> Box<dyn Policy<BlogDomain>> {
300    const MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
301    PolicyBuilder::<BlogDomain>::new("AuthorCanEdit")
302        .when(|user, action, post, ctx| {
303            matches!(action, Action::Edit)
304                && user.id == post.author_id
305                && !post.locked
306                && post.published_at.is_none()
307                && ctx
308                    .current_time
309                    .duration_since(post.created_at)
310                    .unwrap_or_default()
311                    <= MAX_AGE
312        })
313        .build()
314}
315
316/// The fact-backed rule: a registered collaborator (an "editor" relationship,
317/// loaded through the session) may view and edit the post, author or not. The
318/// guard restricts the relationship check to the View/Edit actions; publishing
319/// stays role-gated below.
320fn collaborator_policy() -> Box<dyn Policy<BlogDomain>> {
321    let is_view_or_edit: Arc<dyn Policy<BlogDomain>> = Arc::from(
322        PolicyBuilder::<BlogDomain>::new("IsViewOrEdit")
323            .when(|_user, action, _post, _ctx| matches!(action, Action::View | Action::Edit))
324            .build(),
325    );
326    let has_editor_relationship: Arc<dyn Policy<BlogDomain>> =
327        Arc::new(RebacPolicy::<BlogDomain, Uuid, Uuid, Relation>::new(
328            |user: &User| user.id,
329            |post: &BlogPost| post.id,
330            Relation::Editor,
331        ));
332
333    Box::new(
334        AndPolicy::try_new(vec![is_view_or_edit, has_editor_relationship])
335            .expect("collaborator policy has a guard and a relationship check"),
336    )
337}
338
339fn editors_can_publish_policy() -> Box<dyn Policy<BlogDomain>> {
340    PolicyBuilder::<BlogDomain>::new("EditorsCanPublish")
341        .when(|user, action, post, _ctx| {
342            matches!(action, Action::Publish)
343                && !post.locked
344                && user
345                    .roles
346                    .iter()
347                    .any(|role| role == "editor" || role == "admin")
348        })
349        .build()
350}
351
352fn published_posts_are_public_policy() -> Box<dyn Policy<BlogDomain>> {
353    PolicyBuilder::<BlogDomain>::new("PublishedPostsArePublic")
354        .when(|user, action, post, _ctx| {
355            matches!(action, Action::View)
356                && (post.published_at.is_some() || user.id == post.author_id)
357        })
358        .build()
359}
360
361pub fn build_permission_checker() -> PermissionChecker<BlogDomain> {
362    let mut checker = PermissionChecker::named("BlogPostChecker");
363    checker.add_policy(admin_override_policy());
364    checker.add_policy(author_can_edit_policy());
365    checker.add_policy(collaborator_policy());
366    checker.add_policy(editors_can_publish_policy());
367    checker.add_policy(published_posts_are_public_policy());
368    checker
369}
370
371// -------------------------
372// 4) Actix Web Handlers
373// -------------------------
374
375#[derive(Debug, Serialize)]
376pub struct PostSummary {
377    pub id: Uuid,
378    pub title: String,
379    pub published: bool,
380}
381
382impl From<&BlogPost> for PostSummary {
383    fn from(post: &BlogPost) -> Self {
384        Self {
385            id: post.id,
386            title: post.title.clone(),
387            published: post.published_at.is_some(),
388        }
389    }
390}
391
392/// Build the 403 response for a denied request.
393///
394/// This demo echoes the full evaluation trace back to the caller so you can see
395/// *why* a request was denied from `curl` alone. Don't do this in production:
396/// the reason strings and trace are an internal audit surface (see the README's
397/// "Tracing And Telemetry" section) and can expose policy structure or any data
398/// a policy interpolates into a reason. In a real service, log the trace
399/// server-side and return a generic message to the client.
400fn forbidden(reason: &str, trace: &EvalTrace) -> HttpResponse {
401    HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
402}
403
404/// Load a single post by id, applying any header overrides. A miss falls back
405/// to a synthesized post so the demo works for arbitrary ids from `curl`.
406fn load_post(state: &AppState, post_id: Uuid, overrides: &PostOverrides) -> BlogPost {
407    if let Some(post) = state.posts.iter().find(|post| post.id == post_id) {
408        let mut post = post.clone();
409        if let Some(locked) = overrides.locked {
410            post.locked = locked;
411        }
412        if let Some(published) = overrides.published {
413            post.published_at =
414                published.then(|| SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60));
415        }
416        if let Some(age_days) = overrides.age_days {
417            post.created_at = SystemTime::now() - Duration::from_secs(age_days * 24 * 60 * 60);
418        }
419        return post;
420    }
421
422    BlogPost {
423        id: post_id,
424        title: "untitled".into(),
425        author_id: demo_author_id(),
426        locked: overrides.locked.unwrap_or(false),
427        published_at: overrides
428            .published
429            .unwrap_or(false)
430            .then(|| SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60)),
431        created_at: SystemTime::now()
432            - Duration::from_secs(overrides.age_days.unwrap_or(7) * 24 * 60 * 60),
433    }
434}
435
436/// List the posts the caller is allowed to view. The relationship checks for
437/// every candidate are batched and deduplicated through one request-scoped
438/// session.
439pub async fn list_posts(
440    AuthenticatedUser(user): AuthenticatedUser,
441    state: web::Data<AppState>,
442) -> impl Responder {
443    let session = state.request_session();
444    let context = RequestContext::now();
445    let candidates = state.posts.as_ref().clone();
446
447    let visible = state
448        .checker
449        .bind(&session, &user, &Action::View, &context)
450        .filter(candidates)
451        .await;
452
453    let summaries = visible.iter().map(PostSummary::from).collect::<Vec<_>>();
454    HttpResponse::Ok().json(summaries)
455}
456
457pub async fn view_post(
458    path: web::Path<Uuid>,
459    req: HttpRequest,
460    AuthenticatedUser(user): AuthenticatedUser,
461    state: web::Data<AppState>,
462) -> impl Responder {
463    let post = load_post(&state, *path, &PostOverrides::from_request(&req));
464    let session = state.request_session();
465    let context = RequestContext::now();
466
467    match state
468        .checker
469        .bind(&session, &user, &Action::View, &context)
470        .check(&post)
471        .await
472    {
473        AccessEvaluation::Granted { .. } => {
474            HttpResponse::Ok().body(format!("Viewing '{}'", post.title))
475        }
476        AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
477        _ => HttpResponse::Forbidden().body("Access denied"),
478    }
479}
480
481pub async fn edit_post(
482    path: web::Path<Uuid>,
483    req: HttpRequest,
484    AuthenticatedUser(user): AuthenticatedUser,
485    state: web::Data<AppState>,
486) -> impl Responder {
487    let post = load_post(&state, *path, &PostOverrides::from_request(&req));
488    let session = state.request_session();
489    let context = RequestContext::now();
490
491    match state
492        .checker
493        .bind(&session, &user, &Action::Edit, &context)
494        .check(&post)
495        .await
496    {
497        AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post updated"),
498        AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
499        _ => HttpResponse::Forbidden().body("Access denied"),
500    }
501}
502
503pub async fn publish_post(
504    path: web::Path<Uuid>,
505    req: HttpRequest,
506    AuthenticatedUser(user): AuthenticatedUser,
507    state: web::Data<AppState>,
508) -> impl Responder {
509    let post = load_post(&state, *path, &PostOverrides::from_request(&req));
510    let session = state.request_session();
511    let context = RequestContext::now();
512
513    match state
514        .checker
515        .bind(&session, &user, &Action::Publish, &context)
516        .check(&post)
517        .await
518    {
519        AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post published"),
520        AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
521        _ => HttpResponse::Forbidden().body("Access denied"),
522    }
523}
524
525// -------------------------
526// 5) Actix Web App Startup
527// -------------------------
528
529#[actix_web::main]
530async fn main() -> std::io::Result<()> {
531    let state = web::Data::new(AppState::demo());
532
533    println!("🚪 Gatehouse with Actix Web running on http://127.0.0.1:8080");
534    println!("Use the curl commands from the top of this file to try it out.\n");
535
536    HttpServer::new(move || {
537        App::new()
538            .app_data(state.clone())
539            .route("/posts", web::get().to(list_posts))
540            .route("/posts/{id}", web::get().to(view_post))
541            .route("/posts/{id}", web::put().to(edit_post))
542            .route("/posts/{id}/publish", web::post().to(publish_post))
543    })
544    .bind(("127.0.0.1", 8080))?
545    .run()
546    .await
547}