use pocopine_core::ServerError;
use crate::principal::Principal;
use crate::role::{Permission, Role};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DenyReason {
Unauthorized,
Forbidden,
Custom(&'static str),
}
impl DenyReason {
pub const fn as_str(&self) -> &'static str {
match self {
DenyReason::Unauthorized => "unauthorized",
DenyReason::Forbidden => "forbidden",
DenyReason::Custom(s) => s,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny(DenyReason),
}
pub trait Predicate: Send + Sync + 'static {
fn check(&self, principal: &Principal) -> Decision;
}
impl<F> Predicate for F
where
F: Fn(&Principal) -> Decision + Send + Sync + 'static,
{
fn check(&self, principal: &Principal) -> Decision {
self(principal)
}
}
pub fn require_auth() -> impl Predicate {
|principal: &Principal| {
if principal.is_authenticated() {
Decision::Allow
} else {
Decision::Deny(DenyReason::Unauthorized)
}
}
}
pub fn require_role(role: &'static str) -> impl Predicate {
move |principal: &Principal| {
if !principal.is_authenticated() {
return Decision::Deny(DenyReason::Unauthorized);
}
if principal.has_role(&Role::new(role)) {
Decision::Allow
} else {
Decision::Deny(DenyReason::Forbidden)
}
}
}
pub fn require_permission(permission: &'static str) -> impl Predicate {
move |principal: &Principal| {
if !principal.is_authenticated() {
return Decision::Deny(DenyReason::Unauthorized);
}
if principal.has_permission(&Permission::new(permission)) {
Decision::Allow
} else {
Decision::Deny(DenyReason::Forbidden)
}
}
}
pub fn any_of<P, Q>(p: P, q: Q) -> impl Predicate
where
P: Predicate,
Q: Predicate,
{
move |principal: &Principal| match p.check(principal) {
Decision::Allow => Decision::Allow,
Decision::Deny(_) => q.check(principal),
}
}
pub fn all_of<P, Q>(p: P, q: Q) -> impl Predicate
where
P: Predicate,
Q: Predicate,
{
move |principal: &Principal| match p.check(principal) {
Decision::Deny(reason) => Decision::Deny(reason),
Decision::Allow => q.check(principal),
}
}
impl From<Decision> for Result<(), ServerError> {
fn from(decision: Decision) -> Self {
match decision {
Decision::Allow => Ok(()),
Decision::Deny(DenyReason::Unauthorized) => {
Err(ServerError::unauthorized("unauthorized"))
}
Decision::Deny(DenyReason::Forbidden) => Err(ServerError::forbidden("forbidden")),
Decision::Deny(DenyReason::Custom(reason)) => Err(ServerError::forbidden(reason)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::user::AuthUser;
fn build_user_with_role(role: Role) -> AuthUser {
AuthUser::new("uid-1").with_role(role)
}
#[test]
fn require_auth_allows_authenticated_denies_anonymous() {
let auth = require_auth();
assert_eq!(
auth.check(&Principal::from_user(AuthUser::new("uid-1"))),
Decision::Allow
);
assert_eq!(
auth.check(&Principal::anonymous()),
Decision::Deny(DenyReason::Unauthorized)
);
}
#[test]
fn require_role_denies_unauthorized_before_role_check() {
let admin_only = require_role("admin");
assert_eq!(
admin_only.check(&Principal::anonymous()),
Decision::Deny(DenyReason::Unauthorized)
);
assert_eq!(
admin_only.check(&Principal::from_user(build_user_with_role(Role::user()))),
Decision::Deny(DenyReason::Forbidden)
);
assert_eq!(
admin_only.check(&Principal::from_user(build_user_with_role(Role::admin()))),
Decision::Allow
);
}
#[test]
fn require_permission_short_circuits_on_anonymous() {
let perm = require_permission("posts.write");
assert_eq!(
perm.check(&Principal::anonymous()),
Decision::Deny(DenyReason::Unauthorized)
);
let user = AuthUser::new("uid-1").with_permission(Permission::new("posts.write"));
assert_eq!(perm.check(&Principal::from_user(user)), Decision::Allow);
}
#[test]
fn any_of_returns_second_reason_on_double_deny() {
let p = any_of(require_role("admin"), require_role("editor"));
let viewer = Principal::from_user(build_user_with_role(Role::user()));
assert_eq!(p.check(&viewer), Decision::Deny(DenyReason::Forbidden));
assert_eq!(
p.check(&Principal::anonymous()),
Decision::Deny(DenyReason::Unauthorized)
);
}
#[test]
fn any_of_allows_when_either_predicate_allows() {
let p = any_of(require_role("admin"), require_role("editor"));
let editor = Principal::from_user(build_user_with_role(Role::new("editor")));
let admin = Principal::from_user(build_user_with_role(Role::admin()));
assert_eq!(p.check(&editor), Decision::Allow);
assert_eq!(p.check(&admin), Decision::Allow);
}
#[test]
fn all_of_short_circuits_on_first_deny() {
let p = all_of(require_auth(), require_role("admin"));
assert_eq!(
p.check(&Principal::anonymous()),
Decision::Deny(DenyReason::Unauthorized)
);
let viewer = Principal::from_user(build_user_with_role(Role::user()));
assert_eq!(p.check(&viewer), Decision::Deny(DenyReason::Forbidden));
let admin = Principal::from_user(build_user_with_role(Role::admin()));
assert_eq!(p.check(&admin), Decision::Allow);
}
#[test]
fn decision_to_server_result_maps_unauthorized_vs_forbidden() {
let allow: Result<(), ServerError> = Decision::Allow.into();
assert!(allow.is_ok());
let unauth: Result<(), ServerError> = Decision::Deny(DenyReason::Unauthorized).into();
assert!(matches!(unauth, Err(ServerError::Unauthorized(_))));
let forbidden: Result<(), ServerError> = Decision::Deny(DenyReason::Forbidden).into();
assert!(matches!(forbidden, Err(ServerError::Forbidden(_))));
let custom: Result<(), ServerError> =
Decision::Deny(DenyReason::Custom("missing_role:admin")).into();
match custom {
Err(ServerError::Forbidden(reason)) => assert_eq!(reason, "missing_role:admin"),
other => panic!("expected Forbidden(\"missing_role:admin\"), got {other:?}"),
}
}
#[test]
fn deny_reason_as_str_round_trips() {
assert_eq!(DenyReason::Unauthorized.as_str(), "unauthorized");
assert_eq!(DenyReason::Forbidden.as_str(), "forbidden");
assert_eq!(
DenyReason::Custom("missing_tenant").as_str(),
"missing_tenant"
);
}
#[test]
fn closure_implements_predicate() {
let only_uid_one = |p: &Principal| match p.user() {
Some(user) if user.id == "uid-1" => Decision::Allow,
_ => Decision::Deny(DenyReason::Forbidden),
};
assert_eq!(
only_uid_one.check(&Principal::from_user(AuthUser::new("uid-1"))),
Decision::Allow
);
assert_eq!(
only_uid_one.check(&Principal::from_user(AuthUser::new("uid-2"))),
Decision::Deny(DenyReason::Forbidden)
);
}
}