use actix_web::{
dev::Payload, web, App, FromRequest, HttpRequest, HttpResponse, HttpServer, Responder,
};
use async_trait::async_trait;
use gatehouse::{
AccessEvaluation, AndPolicy, EvalTrace, EvaluationSession, FactLoadResult, FactRegistry,
FactSource, PermissionChecker, Policy, PolicyBuilder, PolicyDomain, RebacPolicy,
RelationshipQuery,
};
use serde::Serialize;
use std::collections::HashSet;
use std::fmt;
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 id = req
.headers()
.get("x-user-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| Uuid::parse_str(value).ok())
.unwrap_or_else(Uuid::nil);
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_default();
ready(Ok(AuthenticatedUser(User { id, roles })))
}
}
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 header_bool = |name: &str| {
req.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(parse_bool)
};
Self {
locked: header_bool("x-post-locked"),
published: header_bool("x-post-published"),
age_days: req
.headers()
.get("x-post-age-days")
.and_then(|value| value.to_str().ok())
.and_then(|raw| raw.parse::<u64>().ok()),
}
}
}
#[derive(Debug, Clone)]
pub struct BlogPost {
pub id: Uuid,
pub title: String,
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 struct RequestContext {
pub current_time: SystemTime,
}
impl RequestContext {
fn now() -> Self {
Self {
current_time: SystemTime::now(),
}
}
}
pub struct BlogDomain;
impl PolicyDomain for BlogDomain {
type Subject = User;
type Action = Action;
type Resource = BlogPost;
type Context = RequestContext;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Relation {
Editor,
}
impl fmt::Display for Relation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Editor => f.write_str("editor"),
}
}
}
type PostRelationship = RelationshipQuery<Uuid, Uuid, Relation>;
#[derive(Clone)]
pub struct InMemoryRelationshipSource {
grants: Arc<HashSet<PostRelationship>>,
}
impl InMemoryRelationshipSource {
fn new(grants: impl IntoIterator<Item = PostRelationship>) -> Self {
Self {
grants: Arc::new(grants.into_iter().collect()),
}
}
}
#[async_trait]
impl FactSource<PostRelationship> for InMemoryRelationshipSource {
async fn load_many(&self, keys: &[PostRelationship]) -> Vec<FactLoadResult<bool>> {
keys.iter()
.map(|key| FactLoadResult::Found(self.grants.contains(key)))
.collect()
}
}
#[derive(Clone)]
pub struct AppState {
checker: Arc<PermissionChecker<BlogDomain>>,
fact_registry: FactRegistry,
posts: Arc<Vec<BlogPost>>,
}
impl AppState {
pub fn demo() -> Self {
let author_id = demo_author_id();
let collaborator_id = demo_collaborator_id();
let posts = demo_posts(author_id);
let grants = posts.iter().map(|post| PostRelationship {
subject_id: collaborator_id,
resource_id: post.id,
relation: Relation::Editor,
});
Self {
checker: Arc::new(build_permission_checker()),
fact_registry: FactRegistry::builder()
.with_arc::<PostRelationship>(Arc::new(InMemoryRelationshipSource::new(grants)))
.build(),
posts: Arc::new(posts),
}
}
fn request_session(&self) -> EvaluationSession {
self.fact_registry.session()
}
}
fn demo_author_id() -> Uuid {
Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
}
fn demo_collaborator_id() -> Uuid {
Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap()
}
fn demo_posts(author_id: Uuid) -> Vec<BlogPost> {
let now = SystemTime::now();
vec![
BlogPost {
id: Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
title: "draft roadmap".into(),
author_id,
locked: false,
published_at: None,
created_at: now - Duration::from_secs(3 * 24 * 60 * 60),
},
BlogPost {
id: Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap(),
title: "published announcement".into(),
author_id,
locked: false,
published_at: Some(now - Duration::from_secs(2 * 24 * 60 * 60)),
created_at: now - Duration::from_secs(10 * 24 * 60 * 60),
},
]
}
fn admin_override_policy() -> Box<dyn Policy<BlogDomain>> {
PolicyBuilder::<BlogDomain>::new("AdminOverride")
.when(|user, _action, _post, _ctx| user.roles.iter().any(|role| role == "admin"))
.build()
}
fn author_can_edit_policy() -> Box<dyn Policy<BlogDomain>> {
const MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
PolicyBuilder::<BlogDomain>::new("AuthorCanEdit")
.when(|user, action, post, ctx| {
matches!(action, Action::Edit)
&& user.id == post.author_id
&& !post.locked
&& post.published_at.is_none()
&& ctx
.current_time
.duration_since(post.created_at)
.unwrap_or_default()
<= MAX_AGE
})
.build()
}
fn collaborator_policy() -> Box<dyn Policy<BlogDomain>> {
let is_view_or_edit: Arc<dyn Policy<BlogDomain>> = Arc::from(
PolicyBuilder::<BlogDomain>::new("IsViewOrEdit")
.when(|_user, action, _post, _ctx| matches!(action, Action::View | Action::Edit))
.build(),
);
let has_editor_relationship: Arc<dyn Policy<BlogDomain>> =
Arc::new(RebacPolicy::<BlogDomain, Uuid, Uuid, Relation>::new(
|user: &User| user.id,
|post: &BlogPost| post.id,
Relation::Editor,
));
Box::new(
AndPolicy::try_new(vec![is_view_or_edit, has_editor_relationship])
.expect("collaborator policy has a guard and a relationship check"),
)
}
fn editors_can_publish_policy() -> Box<dyn Policy<BlogDomain>> {
PolicyBuilder::<BlogDomain>::new("EditorsCanPublish")
.when(|user, action, post, _ctx| {
matches!(action, Action::Publish)
&& !post.locked
&& user
.roles
.iter()
.any(|role| role == "editor" || role == "admin")
})
.build()
}
fn published_posts_are_public_policy() -> Box<dyn Policy<BlogDomain>> {
PolicyBuilder::<BlogDomain>::new("PublishedPostsArePublic")
.when(|user, action, post, _ctx| {
matches!(action, Action::View)
&& (post.published_at.is_some() || user.id == post.author_id)
})
.build()
}
pub fn build_permission_checker() -> PermissionChecker<BlogDomain> {
let mut checker = PermissionChecker::named("BlogPostChecker");
checker.add_policy(admin_override_policy());
checker.add_policy(author_can_edit_policy());
checker.add_policy(collaborator_policy());
checker.add_policy(editors_can_publish_policy());
checker.add_policy(published_posts_are_public_policy());
checker
}
#[derive(Debug, Serialize)]
pub struct PostSummary {
pub id: Uuid,
pub title: String,
pub published: bool,
}
impl From<&BlogPost> for PostSummary {
fn from(post: &BlogPost) -> Self {
Self {
id: post.id,
title: post.title.clone(),
published: post.published_at.is_some(),
}
}
}
fn forbidden(reason: &str, trace: &EvalTrace) -> HttpResponse {
HttpResponse::Forbidden().body(format!("Denied: {}\n{}", reason, trace.format()))
}
fn load_post(state: &AppState, post_id: Uuid, overrides: &PostOverrides) -> BlogPost {
if let Some(post) = state.posts.iter().find(|post| post.id == post_id) {
let mut post = post.clone();
if let Some(locked) = overrides.locked {
post.locked = locked;
}
if let Some(published) = overrides.published {
post.published_at =
published.then(|| SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60));
}
if let Some(age_days) = overrides.age_days {
post.created_at = SystemTime::now() - Duration::from_secs(age_days * 24 * 60 * 60);
}
return post;
}
BlogPost {
id: post_id,
title: "untitled".into(),
author_id: demo_author_id(),
locked: overrides.locked.unwrap_or(false),
published_at: overrides
.published
.unwrap_or(false)
.then(|| SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60)),
created_at: SystemTime::now()
- Duration::from_secs(overrides.age_days.unwrap_or(7) * 24 * 60 * 60),
}
}
pub async fn list_posts(
AuthenticatedUser(user): AuthenticatedUser,
state: web::Data<AppState>,
) -> impl Responder {
let session = state.request_session();
let context = RequestContext::now();
let candidates = state.posts.as_ref().clone();
let visible = state
.checker
.bind(&session, &user, &Action::View, &context)
.filter(candidates)
.await;
let summaries = visible.iter().map(PostSummary::from).collect::<Vec<_>>();
HttpResponse::Ok().json(summaries)
}
pub async fn view_post(
path: web::Path<Uuid>,
req: HttpRequest,
AuthenticatedUser(user): AuthenticatedUser,
state: web::Data<AppState>,
) -> impl Responder {
let post = load_post(&state, *path, &PostOverrides::from_request(&req));
let session = state.request_session();
let context = RequestContext::now();
match state
.checker
.bind(&session, &user, &Action::View, &context)
.check(&post)
.await
{
AccessEvaluation::Granted { .. } => {
HttpResponse::Ok().body(format!("Viewing '{}'", post.title))
}
AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
_ => HttpResponse::Forbidden().body("Access denied"),
}
}
pub async fn edit_post(
path: web::Path<Uuid>,
req: HttpRequest,
AuthenticatedUser(user): AuthenticatedUser,
state: web::Data<AppState>,
) -> impl Responder {
let post = load_post(&state, *path, &PostOverrides::from_request(&req));
let session = state.request_session();
let context = RequestContext::now();
match state
.checker
.bind(&session, &user, &Action::Edit, &context)
.check(&post)
.await
{
AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post updated"),
AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
_ => HttpResponse::Forbidden().body("Access denied"),
}
}
pub async fn publish_post(
path: web::Path<Uuid>,
req: HttpRequest,
AuthenticatedUser(user): AuthenticatedUser,
state: web::Data<AppState>,
) -> impl Responder {
let post = load_post(&state, *path, &PostOverrides::from_request(&req));
let session = state.request_session();
let context = RequestContext::now();
match state
.checker
.bind(&session, &user, &Action::Publish, &context)
.check(&post)
.await
{
AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post published"),
AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
_ => HttpResponse::Forbidden().body("Access denied"),
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let state = web::Data::new(AppState::demo());
println!("🚪 Gatehouse with Actix Web running on http://127.0.0.1:8080");
println!("Use the curl commands from the top of this file to try it out.\n");
HttpServer::new(move || {
App::new()
.app_data(state.clone())
.route("/posts", web::get().to(list_posts))
.route("/posts/{id}", web::get().to(view_post))
.route("/posts/{id}", web::put().to(edit_post))
.route("/posts/{id}/publish", web::post().to(publish_post))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}