use actix_web::{
dev::Payload, web, App, FromRequest, HttpRequest, HttpResponse, HttpServer, Responder,
};
use gatehouse::{AccessEvaluation, AndPolicy, PermissionChecker, Policy, PolicyBuilder};
use std::future::{ready, Ready};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct User {
pub id: Uuid,
pub roles: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct AuthenticatedUser(pub User);
impl FromRequest for AuthenticatedUser {
type Error = actix_web::Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let default_id = Uuid::nil();
let id = req
.headers()
.get("x-user-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| Uuid::parse_str(value).ok())
.unwrap_or(default_id);
let roles = req
.headers()
.get("x-roles")
.and_then(|value| value.to_str().ok())
.map(|raw| {
raw.split(',')
.map(|role| role.trim().to_lowercase())
.filter(|role| !role.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec!["author".to_string()]);
let user = User { id, roles };
ready(Ok(AuthenticatedUser(user)))
}
}
fn parse_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Some(true),
"false" | "0" | "no" => Some(false),
_ => None,
}
}
#[derive(Debug, Clone, Default)]
pub struct PostOverrides {
locked: Option<bool>,
published: Option<bool>,
age_days: Option<u64>,
}
impl PostOverrides {
pub fn from_request(req: &HttpRequest) -> Self {
let locked = req
.headers()
.get("x-post-locked")
.and_then(|value| value.to_str().ok())
.and_then(parse_bool);
let published = req
.headers()
.get("x-post-published")
.and_then(|value| value.to_str().ok())
.and_then(parse_bool);
let age_days = req
.headers()
.get("x-post-age-days")
.and_then(|value| value.to_str().ok())
.and_then(|raw| raw.parse::<u64>().ok());
Self {
locked,
published,
age_days,
}
}
fn locked_or(&self, default: bool) -> bool {
self.locked.unwrap_or(default)
}
fn published_or(&self, default: bool) -> bool {
self.published.unwrap_or(default)
}
fn age_days_or(&self, default: u64) -> u64 {
self.age_days.unwrap_or(default)
}
}
#[derive(Debug, Clone)]
pub struct BlogPost {
pub id: Uuid,
pub author_id: Uuid,
pub locked: bool,
pub published_at: Option<SystemTime>,
pub created_at: SystemTime,
}
#[derive(Debug, Clone)]
pub enum Action {
Edit,
Publish,
View,
}
#[derive(Debug, Clone)]
pub enum Resource {
Post(BlogPost),
}
#[derive(Debug, Clone)]
pub struct RequestContext {
pub current_time: SystemTime,
}
fn admin_override_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
PolicyBuilder::<User, Resource, Action, RequestContext>::new("AdminOverride")
.when(|user, _action, _resource, _ctx| user.roles.iter().any(|r| r == "admin"))
.build()
}
fn author_can_edit_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
PolicyBuilder::<User, Resource, Action, RequestContext>::new("AuthorCanEdit")
.when(|user, action, resource, _ctx| match (action, resource) {
(Action::Edit, Resource::Post(post)) => {
user.id == post.author_id && !post.locked && post.published_at.is_none()
}
_ => false,
})
.build()
}
fn draft_recency_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
const MAX_AGE: u64 = 30 * 24 * 60 * 60; PolicyBuilder::<User, Resource, Action, RequestContext>::new("DraftRecencyWindow")
.when(
move |_user, action, resource, ctx| match (action, resource) {
(Action::Edit, Resource::Post(post)) => {
if post.published_at.is_some() {
return false;
}
ctx.current_time
.duration_since(post.created_at)
.unwrap_or_default()
.as_secs()
<= MAX_AGE
}
_ => false,
},
)
.build()
}
fn editors_can_publish() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
PolicyBuilder::<User, Resource, Action, RequestContext>::new("EditorsCanPublish")
.when(|user, action, resource, _ctx| match (action, resource) {
(Action::Publish, Resource::Post(post)) => {
!post.locked
&& user
.roles
.iter()
.any(|role| role == "editor" || role == "admin")
}
_ => false,
})
.build()
}
fn published_posts_are_public() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
PolicyBuilder::<User, Resource, Action, RequestContext>::new("PublishedPostsArePublic")
.when(|user, action, resource, _ctx| match (action, resource) {
(Action::View, Resource::Post(post)) => {
post.published_at.is_some() || user.id == post.author_id
}
_ => false,
})
.build()
}
pub fn build_permission_checker() -> PermissionChecker<User, Resource, Action, RequestContext> {
let mut checker = PermissionChecker::new();
checker.add_policy(admin_override_policy());
let combined_edit_policy = AndPolicy::try_new(vec![
Arc::from(author_can_edit_policy()),
Arc::from(draft_recency_policy()),
])
.expect("Edit policy should contain at least one rule");
checker.add_policy(combined_edit_policy);
checker.add_policy(editors_can_publish());
checker.add_policy(published_posts_are_public());
checker
}
pub fn load_post(post_id: Uuid, overrides: &PostOverrides) -> BlogPost {
let created_at =
SystemTime::now() - Duration::from_secs(overrides.age_days_or(7) * 24 * 60 * 60);
BlogPost {
id: post_id,
author_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
locked: overrides.locked_or(false),
published_at: if overrides.published_or(false) {
Some(SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60))
} else {
None
},
created_at,
}
}
pub fn load_published_post(post_id: Uuid, overrides: &PostOverrides) -> BlogPost {
let mut overrides = overrides.clone();
if overrides.published.is_none() {
overrides.published = Some(true);
}
load_post(post_id, &overrides)
}
pub async fn edit_post(
path: web::Path<Uuid>,
req: HttpRequest,
AuthenticatedUser(user): AuthenticatedUser,
checker: web::Data<PermissionChecker<User, Resource, Action, RequestContext>>,
) -> impl Responder {
let overrides = PostOverrides::from_request(&req);
let post = load_post(*path, &overrides);
let ctx = RequestContext {
current_time: SystemTime::now(),
};
match checker
.evaluate_access(&user, &Action::Edit, &Resource::Post(post), &ctx)
.await
{
AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post updated"),
AccessEvaluation::Denied { reason, trace } => {
HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
}
}
}
pub async fn publish_post(
path: web::Path<Uuid>,
req: HttpRequest,
AuthenticatedUser(user): AuthenticatedUser,
checker: web::Data<PermissionChecker<User, Resource, Action, RequestContext>>,
) -> impl Responder {
let overrides = PostOverrides::from_request(&req);
let post = load_post(*path, &overrides);
let ctx = RequestContext {
current_time: SystemTime::now(),
};
match checker
.evaluate_access(&user, &Action::Publish, &Resource::Post(post), &ctx)
.await
{
AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post published"),
AccessEvaluation::Denied { reason, trace } => {
HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
}
}
}
pub async fn view_post(
path: web::Path<Uuid>,
req: HttpRequest,
maybe_user: Option<AuthenticatedUser>,
checker: web::Data<PermissionChecker<User, Resource, Action, RequestContext>>,
) -> impl Responder {
let user = maybe_user
.map(|AuthenticatedUser(user)| user)
.unwrap_or(User {
id: Uuid::nil(),
roles: vec![],
});
let overrides = PostOverrides::from_request(&req);
let post = load_published_post(*path, &overrides);
let ctx = RequestContext {
current_time: SystemTime::now(),
};
match checker
.evaluate_access(&user, &Action::View, &Resource::Post(post), &ctx)
.await
{
AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Here is your post"),
AccessEvaluation::Denied { reason, trace } => {
HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let checker = web::Data::new(build_permission_checker());
println!("🚪 Gatehouse with Actix Web running on http://127.0.0.1:8080");
println!("Use curl commands from the top of this file to try it out.\n");
HttpServer::new(move || {
App::new()
.app_data(checker.clone())
.route("/posts/{id}", web::put().to(edit_post))
.route("/posts/{id}/publish", web::post().to(publish_post))
.route("/posts/{id}", web::get().to(view_post))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}