1use 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#[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
163fn 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; 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
248pub 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
276pub 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#[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}