use soaprs_core::{BoxFuture, SoapError, SoapResult};
use crate::{Authentication, AuthorizationName, Principal};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AuthorizationPolicy {
#[default]
Public,
Optional,
OptionalStrategy(AuthorizationName),
Authenticated,
Strategy(AuthorizationName),
AnyRole(Vec<AuthorizationName>),
AllRoles(Vec<AuthorizationName>),
AnyPermission(Vec<AuthorizationName>),
AllPermissions(Vec<AuthorizationName>),
Named(AuthorizationName),
}
impl AuthorizationPolicy {
pub fn optional_strategy(name: impl Into<String>) -> SoapResult<Self> {
AuthorizationName::new(name).map(Self::OptionalStrategy)
}
pub fn strategy(name: impl Into<String>) -> SoapResult<Self> {
AuthorizationName::new(name).map(Self::Strategy)
}
pub fn named(name: impl Into<String>) -> SoapResult<Self> {
AuthorizationName::new(name).map(Self::Named)
}
pub fn any_role<I, S>(roles: I) -> SoapResult<Self>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
names("authorization roles", roles).map(Self::AnyRole)
}
pub fn all_roles<I, S>(roles: I) -> SoapResult<Self>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
names("authorization roles", roles).map(Self::AllRoles)
}
pub fn any_permission<I, S>(permissions: I) -> SoapResult<Self>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
names("authorization permissions", permissions).map(Self::AnyPermission)
}
pub fn all_permissions<I, S>(permissions: I) -> SoapResult<Self>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
names("authorization permissions", permissions).map(Self::AllPermissions)
}
pub fn validate(&self) -> SoapResult<()> {
match self {
Self::AnyRole(values)
| Self::AllRoles(values)
| Self::AnyPermission(values)
| Self::AllPermissions(values)
if values.is_empty() =>
{
Err(SoapError::validation(
"authorization name list cannot be empty",
))
}
_ => Ok(()),
}
}
pub const fn requires_identity(&self) -> bool {
!matches!(
self,
Self::Public | Self::Optional | Self::OptionalStrategy(_)
)
}
pub const fn authenticates_when_present(&self) -> bool {
!matches!(self, Self::Public)
}
pub const fn allows_public_response_cache(&self) -> bool {
matches!(self, Self::Public)
}
}
fn names<I, S>(kind: &str, values: I) -> SoapResult<Vec<AuthorizationName>>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let values = values
.into_iter()
.map(AuthorizationName::new)
.collect::<SoapResult<Vec<_>>>()?;
if values.is_empty() {
Err(SoapError::validation(format!("{kind} cannot be empty")))
} else {
Ok(values)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationFailure {
MissingAuthentication,
StrategyMismatch,
MissingRole,
MissingPermission,
PolicyDenied,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationDecision {
Allowed,
Denied(AuthorizationFailure),
RequiresPolicy(AuthorizationName),
}
impl AuthorizationDecision {
pub fn enforce(self) -> SoapResult<()> {
match self {
Self::Allowed => Ok(()),
Self::Denied(
AuthorizationFailure::MissingAuthentication
| AuthorizationFailure::StrategyMismatch,
) => Err(SoapError::unauthorized()),
Self::Denied(
AuthorizationFailure::MissingRole
| AuthorizationFailure::MissingPermission
| AuthorizationFailure::PolicyDenied,
) => Err(SoapError::forbidden()),
Self::RequiresPolicy(_) => Err(SoapError::unsupported(
"named authorization policy requires an application evaluator",
)),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DefaultAuthorizationEvaluator;
impl DefaultAuthorizationEvaluator {
pub fn evaluate<P>(
&self,
authentication: Option<&Authentication<P>>,
policy: &AuthorizationPolicy,
) -> SoapResult<AuthorizationDecision>
where
P: Principal,
{
policy.validate()?;
let decision = match policy {
AuthorizationPolicy::Public | AuthorizationPolicy::Optional => {
AuthorizationDecision::Allowed
}
AuthorizationPolicy::OptionalStrategy(strategy) => match authentication {
None => AuthorizationDecision::Allowed,
Some(authentication) if authentication.strategy() == strategy => {
AuthorizationDecision::Allowed
}
Some(_) => AuthorizationDecision::Denied(AuthorizationFailure::StrategyMismatch),
},
AuthorizationPolicy::Authenticated => require_authentication(authentication, |_| true),
AuthorizationPolicy::Strategy(strategy) => {
require_authentication(authentication, |authentication| {
authentication.strategy() == strategy
})
}
AuthorizationPolicy::AnyRole(roles) => {
require_authentication(authentication, |authentication| {
roles
.iter()
.any(|role| authentication.principal().has_role(role))
})
.map_denial(AuthorizationFailure::MissingRole)
}
AuthorizationPolicy::AllRoles(roles) => {
require_authentication(authentication, |authentication| {
roles
.iter()
.all(|role| authentication.principal().has_role(role))
})
.map_denial(AuthorizationFailure::MissingRole)
}
AuthorizationPolicy::AnyPermission(permissions) => {
require_authentication(authentication, |authentication| {
permissions
.iter()
.any(|permission| authentication.principal().has_permission(permission))
})
.map_denial(AuthorizationFailure::MissingPermission)
}
AuthorizationPolicy::AllPermissions(permissions) => {
require_authentication(authentication, |authentication| {
permissions
.iter()
.all(|permission| authentication.principal().has_permission(permission))
})
.map_denial(AuthorizationFailure::MissingPermission)
}
AuthorizationPolicy::Named(name) => AuthorizationDecision::RequiresPolicy(name.clone()),
};
Ok(decision)
}
}
trait MapDenial {
fn map_denial(self, denial: AuthorizationFailure) -> Self;
}
impl MapDenial for AuthorizationDecision {
fn map_denial(self, denial: AuthorizationFailure) -> Self {
match self {
Self::Denied(AuthorizationFailure::StrategyMismatch) => Self::Denied(denial),
decision => decision,
}
}
}
fn require_authentication<P, F>(
authentication: Option<&Authentication<P>>,
predicate: F,
) -> AuthorizationDecision
where
F: FnOnce(&Authentication<P>) -> bool,
{
match authentication {
None => AuthorizationDecision::Denied(AuthorizationFailure::MissingAuthentication),
Some(authentication) if predicate(authentication) => AuthorizationDecision::Allowed,
Some(_) => AuthorizationDecision::Denied(AuthorizationFailure::StrategyMismatch),
}
}
pub trait Authorizer<P, C>: Send + Sync
where
P: Send + Sync,
C: Send + Sync,
{
fn authorize<'a>(
&'a self,
authentication: Option<&'a Authentication<P>>,
context: &'a C,
policy: &'a AuthorizationPolicy,
) -> BoxFuture<'a, SoapResult<AuthorizationDecision>>;
}
#[cfg(test)]
mod tests {
use soaprs_core::SoapErrorKind;
use super::{
AuthorizationDecision, AuthorizationFailure, AuthorizationPolicy,
DefaultAuthorizationEvaluator,
};
use crate::{Authentication, StandardPrincipal};
#[test]
fn built_in_policies_distinguish_authentication_and_grant_failures() {
let principal = StandardPrincipal::new("user-42")
.and_then(|principal| principal.role("admin"))
.and_then(|principal| principal.permission("users:read"));
let Some(principal) = principal.ok() else {
panic!("valid principal");
};
let Some(authentication) = Authentication::new("jwt", principal).ok() else {
panic!("valid authentication");
};
let evaluator = DefaultAuthorizationEvaluator;
let allowed = AuthorizationPolicy::all_permissions(["users:read"])
.and_then(|policy| evaluator.evaluate(Some(&authentication), &policy));
assert_eq!(allowed.ok(), Some(AuthorizationDecision::Allowed));
let denied = AuthorizationPolicy::any_role(["owner"])
.and_then(|policy| evaluator.evaluate(Some(&authentication), &policy));
assert_eq!(
denied.as_ref().ok(),
Some(&AuthorizationDecision::Denied(
AuthorizationFailure::MissingRole
))
);
assert_eq!(
denied
.and_then(AuthorizationDecision::enforce)
.as_ref()
.map_err(|error| error.kind()),
Err(SoapErrorKind::Forbidden)
);
let missing = evaluator
.evaluate::<StandardPrincipal>(None, &AuthorizationPolicy::Authenticated)
.and_then(AuthorizationDecision::enforce);
assert_eq!(
missing.as_ref().map_err(|error| error.kind()),
Err(SoapErrorKind::Unauthorized)
);
}
#[test]
fn optional_and_named_policies_are_explicit() {
let evaluator = DefaultAuthorizationEvaluator;
assert_eq!(
evaluator
.evaluate::<StandardPrincipal>(None, &AuthorizationPolicy::Optional)
.ok(),
Some(AuthorizationDecision::Allowed)
);
let Some(named) = AuthorizationPolicy::named("resource.owner").ok() else {
panic!("valid named policy");
};
assert!(matches!(
evaluator.evaluate::<StandardPrincipal>(None, &named),
Ok(AuthorizationDecision::RequiresPolicy(_))
));
}
}