1use 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#[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#[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#[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#[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#[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 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
287fn 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
297fn 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
316fn 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#[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
392fn forbidden(reason: &str, trace: &EvalTrace) -> HttpResponse {
401 HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
402}
403
404fn 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
436pub 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#[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}