Skip to main content

actix_web/
actix_web.rs

1// Actix Web example showcasing how to plug Gatehouse policies into
2// request handlers. The server exposes three routes:
3//
4// - `PUT /posts/{id}` edits a blog post if the author is allowed.
5// - `POST /posts/{id}/publish` publishes a post for editors.
6// - `GET /posts/{id}` reads a post when it is public or the caller is privileged.
7//
8// Try it with curl:
9//
10// ```bash
11// # Author editing their own draft succeeds
12// curl -i -X PUT http://127.0.0.1:8080/posts/11111111-1111-1111-1111-111111111111 \
13//   -H "x-user-id: 11111111-1111-1111-1111-111111111111" \
14//   -H "x-roles: author"
15//
16// # Publishing requires the `editor` role
17// curl -i -X POST http://127.0.0.1:8080/posts/11111111-1111-1111-1111-111111111111 \
18//   -H "x-user-id: 22222222-2222-2222-2222-222222222222" \
19//   -H "x-roles: editor"
20//
21// # Viewing a published post works for anonymous users as well
22// curl -i http://127.0.0.1:8080/posts/00000000-0000-0000-0000-000000000000
23// ```
24//
25// The example uses the [`PolicyBuilder`] to compose a few policies and
26// stores them inside a shared [`PermissionChecker`]. Each handler pulls the
27// checker from Actix Web's `Data` extractor and evaluates the request before
28// continuing.
29
30use actix_web::{
31    dev::Payload, web, App, FromRequest, HttpRequest, HttpResponse, HttpServer, Responder,
32};
33use gatehouse::{AccessEvaluation, AndPolicy, PermissionChecker, Policy, PolicyBuilder};
34use std::future::{ready, Ready};
35use std::sync::Arc;
36use std::time::{Duration, SystemTime};
37use uuid::Uuid;
38
39// --------------------
40// 1) Domain Modeling
41// --------------------
42
43#[derive(Debug, Clone)]
44pub struct User {
45    pub id: Uuid,
46    pub roles: Vec<String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct AuthenticatedUser(pub User);
51
52impl FromRequest for AuthenticatedUser {
53    type Error = actix_web::Error;
54    type Future = Ready<Result<Self, Self::Error>>;
55
56    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
57        let default_id = Uuid::nil();
58        let id = req
59            .headers()
60            .get("x-user-id")
61            .and_then(|value| value.to_str().ok())
62            .and_then(|value| Uuid::parse_str(value).ok())
63            .unwrap_or(default_id);
64
65        let roles = req
66            .headers()
67            .get("x-roles")
68            .and_then(|value| value.to_str().ok())
69            .map(|raw| {
70                raw.split(',')
71                    .map(|role| role.trim().to_lowercase())
72                    .filter(|role| !role.is_empty())
73                    .collect::<Vec<_>>()
74            })
75            .unwrap_or_else(|| vec!["author".to_string()]);
76
77        let user = User { id, roles };
78        ready(Ok(AuthenticatedUser(user)))
79    }
80}
81
82fn parse_bool(value: &str) -> Option<bool> {
83    match value.trim().to_ascii_lowercase().as_str() {
84        "true" | "1" | "yes" => Some(true),
85        "false" | "0" | "no" => Some(false),
86        _ => None,
87    }
88}
89
90#[derive(Debug, Clone, Default)]
91pub struct PostOverrides {
92    locked: Option<bool>,
93    published: Option<bool>,
94    age_days: Option<u64>,
95}
96
97impl PostOverrides {
98    pub fn from_request(req: &HttpRequest) -> Self {
99        let locked = req
100            .headers()
101            .get("x-post-locked")
102            .and_then(|value| value.to_str().ok())
103            .and_then(parse_bool);
104
105        let published = req
106            .headers()
107            .get("x-post-published")
108            .and_then(|value| value.to_str().ok())
109            .and_then(parse_bool);
110
111        let age_days = req
112            .headers()
113            .get("x-post-age-days")
114            .and_then(|value| value.to_str().ok())
115            .and_then(|raw| raw.parse::<u64>().ok());
116
117        Self {
118            locked,
119            published,
120            age_days,
121        }
122    }
123
124    fn locked_or(&self, default: bool) -> bool {
125        self.locked.unwrap_or(default)
126    }
127
128    fn published_or(&self, default: bool) -> bool {
129        self.published.unwrap_or(default)
130    }
131
132    fn age_days_or(&self, default: u64) -> u64 {
133        self.age_days.unwrap_or(default)
134    }
135}
136
137#[derive(Debug, Clone)]
138pub struct BlogPost {
139    pub id: Uuid,
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 enum Resource {
155    Post(BlogPost),
156}
157
158#[derive(Debug, Clone)]
159pub struct RequestContext {
160    pub current_time: SystemTime,
161}
162
163// --------------------------
164// 2) Building Our Policies
165// --------------------------
166
167fn admin_override_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
168    PolicyBuilder::<User, Resource, Action, RequestContext>::new("AdminOverride")
169        .when(|user, _action, _resource, _ctx| user.roles.iter().any(|r| r == "admin"))
170        .build()
171}
172
173fn author_can_edit_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
174    PolicyBuilder::<User, Resource, Action, RequestContext>::new("AuthorCanEdit")
175        .when(|user, action, resource, _ctx| match (action, resource) {
176            (Action::Edit, Resource::Post(post)) => {
177                user.id == post.author_id && !post.locked && post.published_at.is_none()
178            }
179            _ => false,
180        })
181        .build()
182}
183
184fn draft_recency_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
185    const MAX_AGE: u64 = 30 * 24 * 60 * 60; // 30 days
186    PolicyBuilder::<User, Resource, Action, RequestContext>::new("DraftRecencyWindow")
187        .when(
188            move |_user, action, resource, ctx| match (action, resource) {
189                (Action::Edit, Resource::Post(post)) => {
190                    if post.published_at.is_some() {
191                        return false;
192                    }
193
194                    ctx.current_time
195                        .duration_since(post.created_at)
196                        .unwrap_or_default()
197                        .as_secs()
198                        <= MAX_AGE
199                }
200                _ => false,
201            },
202        )
203        .build()
204}
205
206fn editors_can_publish() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
207    PolicyBuilder::<User, Resource, Action, RequestContext>::new("EditorsCanPublish")
208        .when(|user, action, resource, _ctx| match (action, resource) {
209            (Action::Publish, Resource::Post(post)) => {
210                !post.locked
211                    && user
212                        .roles
213                        .iter()
214                        .any(|role| role == "editor" || role == "admin")
215            }
216            _ => false,
217        })
218        .build()
219}
220
221fn published_posts_are_public() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
222    PolicyBuilder::<User, Resource, Action, RequestContext>::new("PublishedPostsArePublic")
223        .when(|user, action, resource, _ctx| match (action, resource) {
224            (Action::View, Resource::Post(post)) => {
225                post.published_at.is_some() || user.id == post.author_id
226            }
227            _ => false,
228        })
229        .build()
230}
231
232pub fn build_permission_checker() -> PermissionChecker<User, Resource, Action, RequestContext> {
233    let mut checker = PermissionChecker::new();
234    checker.add_policy(admin_override_policy());
235
236    let combined_edit_policy = AndPolicy::try_new(vec![
237        Arc::from(author_can_edit_policy()),
238        Arc::from(draft_recency_policy()),
239    ])
240    .expect("Edit policy should contain at least one rule");
241    checker.add_policy(combined_edit_policy);
242
243    checker.add_policy(editors_can_publish());
244    checker.add_policy(published_posts_are_public());
245    checker
246}
247
248// -------------------------------
249// 3) Helpers for Mocked Resources
250// -------------------------------
251
252pub fn load_post(post_id: Uuid, overrides: &PostOverrides) -> BlogPost {
253    let created_at =
254        SystemTime::now() - Duration::from_secs(overrides.age_days_or(7) * 24 * 60 * 60);
255    BlogPost {
256        id: post_id,
257        author_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
258        locked: overrides.locked_or(false),
259        published_at: if overrides.published_or(false) {
260            Some(SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60))
261        } else {
262            None
263        },
264        created_at,
265    }
266}
267
268pub fn load_published_post(post_id: Uuid, overrides: &PostOverrides) -> BlogPost {
269    let mut overrides = overrides.clone();
270    if overrides.published.is_none() {
271        overrides.published = Some(true);
272    }
273    load_post(post_id, &overrides)
274}
275
276// -------------------------
277// 4) Actix Web Handlers
278// -------------------------
279
280pub async fn edit_post(
281    path: web::Path<Uuid>,
282    req: HttpRequest,
283    AuthenticatedUser(user): AuthenticatedUser,
284    checker: web::Data<PermissionChecker<User, Resource, Action, RequestContext>>,
285) -> impl Responder {
286    let overrides = PostOverrides::from_request(&req);
287    let post = load_post(*path, &overrides);
288    let ctx = RequestContext {
289        current_time: SystemTime::now(),
290    };
291
292    match checker
293        .evaluate_access(&user, &Action::Edit, &Resource::Post(post), &ctx)
294        .await
295    {
296        AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post updated"),
297        AccessEvaluation::Denied { reason, trace } => {
298            HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
299        }
300    }
301}
302
303pub async fn publish_post(
304    path: web::Path<Uuid>,
305    req: HttpRequest,
306    AuthenticatedUser(user): AuthenticatedUser,
307    checker: web::Data<PermissionChecker<User, Resource, Action, RequestContext>>,
308) -> impl Responder {
309    let overrides = PostOverrides::from_request(&req);
310    let post = load_post(*path, &overrides);
311    let ctx = RequestContext {
312        current_time: SystemTime::now(),
313    };
314
315    match checker
316        .evaluate_access(&user, &Action::Publish, &Resource::Post(post), &ctx)
317        .await
318    {
319        AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post published"),
320        AccessEvaluation::Denied { reason, trace } => {
321            HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
322        }
323    }
324}
325
326pub async fn view_post(
327    path: web::Path<Uuid>,
328    req: HttpRequest,
329    maybe_user: Option<AuthenticatedUser>,
330    checker: web::Data<PermissionChecker<User, Resource, Action, RequestContext>>,
331) -> impl Responder {
332    let user = maybe_user
333        .map(|AuthenticatedUser(user)| user)
334        .unwrap_or(User {
335            id: Uuid::nil(),
336            roles: vec![],
337        });
338
339    let overrides = PostOverrides::from_request(&req);
340    let post = load_published_post(*path, &overrides);
341    let ctx = RequestContext {
342        current_time: SystemTime::now(),
343    };
344
345    match checker
346        .evaluate_access(&user, &Action::View, &Resource::Post(post), &ctx)
347        .await
348    {
349        AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Here is your post"),
350        AccessEvaluation::Denied { reason, trace } => {
351            HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
352        }
353    }
354}
355
356// -------------------------
357// 5) Actix Web App Startup
358// -------------------------
359
360#[actix_web::main]
361async fn main() -> std::io::Result<()> {
362    let checker = web::Data::new(build_permission_checker());
363
364    println!("🚪 Gatehouse with Actix Web running on http://127.0.0.1:8080");
365    println!("Use curl commands from the top of this file to try it out.\n");
366
367    HttpServer::new(move || {
368        App::new()
369            .app_data(checker.clone())
370            .route("/posts/{id}", web::put().to(edit_post))
371            .route("/posts/{id}/publish", web::post().to(publish_post))
372            .route("/posts/{id}", web::get().to(view_post))
373    })
374    .bind(("127.0.0.1", 8080))?
375    .run()
376    .await
377}