soaprs-auth 0.5.0

Protocol-neutral authentication and authorization contracts for soaprs
Documentation
//! Portable authorization policies and decisions.

use soaprs_core::{BoxFuture, SoapError, SoapResult};

use crate::{Authentication, AuthorizationName, Principal};

/// Declarative authentication and authorization requirement.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AuthorizationPolicy {
    /// No authentication is requested and no identity is required.
    #[default]
    Public,
    /// Authenticate with any configured strategy when credentials are present.
    Optional,
    /// Authenticate with the named strategy when credentials are present.
    OptionalStrategy(AuthorizationName),
    /// Any authenticated identity is allowed.
    Authenticated,
    /// One named authentication strategy must authenticate the request.
    Strategy(AuthorizationName),
    /// The identity must have at least one listed role.
    AnyRole(Vec<AuthorizationName>),
    /// The identity must have every listed role.
    AllRoles(Vec<AuthorizationName>),
    /// The identity must have at least one listed permission.
    AnyPermission(Vec<AuthorizationName>),
    /// The identity must have every listed permission.
    AllPermissions(Vec<AuthorizationName>),
    /// An application-defined authorization policy decides access.
    Named(AuthorizationName),
}

impl AuthorizationPolicy {
    /// Creates an optional named authentication-strategy policy.
    pub fn optional_strategy(name: impl Into<String>) -> SoapResult<Self> {
        AuthorizationName::new(name).map(Self::OptionalStrategy)
    }

    /// Creates a required named authentication-strategy policy.
    pub fn strategy(name: impl Into<String>) -> SoapResult<Self> {
        AuthorizationName::new(name).map(Self::Strategy)
    }

    /// Creates an application-defined authorization-policy requirement.
    pub fn named(name: impl Into<String>) -> SoapResult<Self> {
        AuthorizationName::new(name).map(Self::Named)
    }

    /// Creates an any-role policy.
    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)
    }

    /// Creates an all-roles policy.
    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)
    }

    /// Creates an any-permission policy.
    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)
    }

    /// Creates an all-permissions policy.
    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)
    }

    /// Validates directly constructed list policies.
    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(()),
        }
    }

    /// Reports whether missing credentials are an authentication failure.
    pub const fn requires_identity(&self) -> bool {
        !matches!(
            self,
            Self::Public | Self::Optional | Self::OptionalStrategy(_)
        )
    }

    /// Reports whether supplied credentials should be inspected.
    pub const fn authenticates_when_present(&self) -> bool {
        !matches!(self, Self::Public)
    }

    /// Reports whether a shared public response cache is safe by default.
    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)
    }
}

/// Safe reason for a denied authorization decision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationFailure {
    /// Required authentication is absent.
    MissingAuthentication,
    /// Authentication used a different strategy from the required one.
    StrategyMismatch,
    /// The principal lacks a required role combination.
    MissingRole,
    /// The principal lacks a required permission combination.
    MissingPermission,
    /// An application-defined policy denied access.
    PolicyDenied,
}

/// Authorization result that can defer one named policy to application code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationDecision {
    /// Access is allowed.
    Allowed,
    /// Access is denied for a safe stable reason.
    Denied(AuthorizationFailure),
    /// A named policy evaluator must make the final decision.
    RequiresPolicy(AuthorizationName),
}

impl AuthorizationDecision {
    /// Converts a final decision into the stable soaprs error model.
    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",
            )),
        }
    }
}

/// Evaluates every built-in authorization policy without I/O.
#[derive(Debug, Clone, Copy, Default)]
pub struct DefaultAuthorizationEvaluator;

impl DefaultAuthorizationEvaluator {
    /// Evaluates a built-in policy or defers a named policy.
    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),
    }
}

/// Asynchronous application authorization port for contextual or named policies.
pub trait Authorizer<P, C>: Send + Sync
where
    P: Send + Sync,
    C: Send + Sync,
{
    /// Evaluates one policy with application-specific context.
    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(_))
        ));
    }
}