use std::any::Any;
use std::collections::HashMap;
use std::sync::Arc;
use rainier_support::{Error, Result};
pub trait Actor: Send + Sync + 'static {}
impl<T: Send + Sync + 'static> Actor for T {}
type Ability<A> = Arc<dyn Fn(&A, Option<&dyn Any>) -> bool + Send + Sync>;
type BeforeCheck<A> = Arc<dyn Fn(&A, &str) -> Option<bool> + Send + Sync>;
pub struct Gate<A: Actor> {
abilities: HashMap<String, Ability<A>>,
before: Vec<BeforeCheck<A>>,
}
impl<A: Actor> Default for Gate<A> {
fn default() -> Self {
Self::new()
}
}
impl<A: Actor> Gate<A> {
pub fn new() -> Self {
Self { abilities: HashMap::new(), before: Vec::new() }
}
pub fn define<S, F>(mut self, ability: impl Into<String>, check: F) -> Self
where
S: 'static,
F: Fn(&A, Option<&S>) -> bool + Send + Sync + 'static,
{
let erased: Ability<A> = Arc::new(move |user, subject| {
match subject {
Some(subject) => match subject.downcast_ref::<S>() {
Some(typed) => check(user, Some(typed)),
None => false,
},
None => check(user, None),
}
});
self.abilities.insert(ability.into(), erased);
self
}
pub fn define_simple<F>(self, ability: impl Into<String>, check: F) -> Self
where
F: Fn(&A) -> bool + Send + Sync + 'static,
{
self.define::<(), _>(ability, move |user, _| check(user))
}
pub fn before<F>(mut self, check: F) -> Self
where
F: Fn(&A, &str) -> Option<bool> + Send + Sync + 'static,
{
self.before.push(Arc::new(check));
self
}
pub fn allows<S: 'static>(&self, ability: &str, actor: &A, subject: Option<&S>) -> bool {
for before in &self.before {
if let Some(decision) = before(actor, ability) {
return decision;
}
}
match self.abilities.get(ability) {
Some(check) => check(actor, subject.map(|s| s as &dyn Any)),
None => false,
}
}
pub fn denies<S: 'static>(&self, ability: &str, actor: &A, subject: Option<&S>) -> bool {
!self.allows(ability, actor, subject)
}
pub fn allows_any(&self, ability: &str, actor: &A) -> bool {
self.allows::<()>(ability, actor, None)
}
pub fn authorize<S: 'static>(
&self,
ability: &str,
actor: &A,
subject: Option<&S>,
) -> Result<()> {
if self.allows(ability, actor, subject) {
return Ok(());
}
Err(Error::unauthorized("This action is unauthorized."))
}
pub fn has(&self, ability: &str) -> bool {
self.abilities.contains_key(ability)
}
pub fn abilities(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.abilities.keys().map(String::as_str).collect();
names.sort();
names
}
}
impl<A: Actor> std::fmt::Debug for Gate<A> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Gate")
.field("abilities", &self.abilities())
.field("before", &self.before.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone)]
struct User {
id: u64,
admin: bool,
banned: bool,
}
#[derive(Debug)]
struct Post {
author_id: u64,
}
#[derive(Debug)]
struct Comment;
fn user(id: u64) -> User {
User { id, admin: false, banned: false }
}
fn admin() -> User {
User { id: 99, admin: true, banned: false }
}
fn gate() -> Gate<User> {
Gate::new()
.define("posts.update", |user: &User, post: Option<&Post>| {
post.is_some_and(|post| post.author_id == user.id)
})
.define_simple("posts.create", |user: &User| !user.banned)
}
#[test]
fn an_ability_decides_from_the_user_and_subject() {
let gate = gate();
let post = Post { author_id: 1 };
assert!(gate.allows("posts.update", &user(1), Some(&post)));
assert!(gate.denies("posts.update", &user(2), Some(&post)));
}
#[test]
fn an_undefined_ability_is_denied() {
let gate = gate();
assert!(gate.denies::<Post>("posts.publish", &admin(), None));
assert!(!gate.has("posts.publish"));
}
#[test]
fn an_ability_with_no_subject() {
let gate = gate();
assert!(gate.allows_any("posts.create", &user(1)));
let banned = User { banned: true, ..user(1) };
assert!(!gate.allows_any("posts.create", &banned));
}
#[test]
fn a_subject_of_the_wrong_type_is_denied_rather_than_ignored() {
let gate = gate();
assert!(gate.denies("posts.update", &user(1), Some(&Comment)));
}
#[test]
fn a_before_check_short_circuits_everything() {
let gate = gate().before(|user: &User, _ability: &str| user.admin.then_some(true));
let someone_elses = Post { author_id: 1 };
assert!(gate.allows("posts.update", &admin(), Some(&someone_elses)));
assert!(gate.allows_any("posts.anything.undefined", &admin()));
}
#[test]
fn a_before_check_can_also_deny_outright() {
let gate = gate().before(|user: &User, _: &str| user.banned.then_some(false));
let banned = User { banned: true, ..user(1) };
let own_post = Post { author_id: 1 };
assert!(gate.denies("posts.update", &banned, Some(&own_post)));
}
#[test]
fn a_before_check_returning_none_defers_to_the_ability() {
let gate = gate().before(|_: &User, _: &str| None);
let post = Post { author_id: 1 };
assert!(gate.allows("posts.update", &user(1), Some(&post)));
assert!(gate.denies("posts.update", &user(2), Some(&post)));
}
#[test]
fn authorize_turns_a_denial_into_a_403() {
let gate = gate();
let post = Post { author_id: 1 };
assert!(gate.authorize("posts.update", &user(1), Some(&post)).is_ok());
let err = gate.authorize("posts.update", &user(2), Some(&post)).unwrap_err();
assert_eq!(err.status(), 403);
assert_eq!(err.message(), "This action is unauthorized.");
}
#[test]
fn defined_abilities_can_be_listed() {
assert_eq!(gate().abilities(), vec!["posts.create", "posts.update"]);
assert!(gate().has("posts.update"));
}
#[derive(Debug)]
struct ApiClient {
id: String,
scopes: Vec<&'static str>,
}
#[derive(Debug)]
struct StsPrincipal {
arn: String,
}
#[test]
fn a_gate_authorizes_an_api_client() {
let gate = Gate::<ApiClient>::new().define_simple("posts.read", |client: &ApiClient| {
client.scopes.contains(&"posts:read")
});
let reader = ApiClient { id: "svc-reports".into(), scopes: vec!["posts:read"] };
let writer = ApiClient { id: "svc-import".into(), scopes: vec!["posts:write"] };
assert!(gate.allows_any("posts.read", &reader));
assert!(gate.denies::<()>("posts.read", &writer, None));
assert_eq!(reader.id, "svc-reports");
}
#[test]
fn a_gate_authorizes_a_cloud_principal() {
let gate = Gate::<StsPrincipal>::new().define_simple("admin.enter", |principal| {
principal.arn.starts_with("arn:aws:sts::123456789012:assumed-role/Deployer/")
});
let deployer =
StsPrincipal { arn: "arn:aws:sts::123456789012:assumed-role/Deployer/ci".into() };
let stranger =
StsPrincipal { arn: "arn:aws:sts::999999999999:assumed-role/Other/x".into() };
assert!(gate.allows_any("admin.enter", &deployer));
assert!(!gate.allows_any("admin.enter", &stranger));
}
#[test]
fn an_undefined_ability_is_denied_for_a_machine_too() {
let gate = Gate::<ApiClient>::new();
let client = ApiClient { id: "svc".into(), scopes: vec!["everything"] };
assert!(!gate.allows_any("posts.read", &client));
}
}