Skip to main content

soaprs_auth/
authorization.rs

1//! Portable authorization policies and decisions.
2
3use soaprs_core::{BoxFuture, SoapError, SoapResult};
4
5use crate::{Authentication, AuthorizationName, Principal};
6
7/// Declarative authentication and authorization requirement.
8#[derive(Debug, Clone, PartialEq, Eq, Default)]
9pub enum AuthorizationPolicy {
10    /// No authentication is requested and no identity is required.
11    #[default]
12    Public,
13    /// Authenticate with any configured strategy when credentials are present.
14    Optional,
15    /// Authenticate with the named strategy when credentials are present.
16    OptionalStrategy(AuthorizationName),
17    /// Any authenticated identity is allowed.
18    Authenticated,
19    /// One named authentication strategy must authenticate the request.
20    Strategy(AuthorizationName),
21    /// The identity must have at least one listed role.
22    AnyRole(Vec<AuthorizationName>),
23    /// The identity must have every listed role.
24    AllRoles(Vec<AuthorizationName>),
25    /// The identity must have at least one listed permission.
26    AnyPermission(Vec<AuthorizationName>),
27    /// The identity must have every listed permission.
28    AllPermissions(Vec<AuthorizationName>),
29    /// An application-defined authorization policy decides access.
30    Named(AuthorizationName),
31}
32
33impl AuthorizationPolicy {
34    /// Creates an optional named authentication-strategy policy.
35    pub fn optional_strategy(name: impl Into<String>) -> SoapResult<Self> {
36        AuthorizationName::new(name).map(Self::OptionalStrategy)
37    }
38
39    /// Creates a required named authentication-strategy policy.
40    pub fn strategy(name: impl Into<String>) -> SoapResult<Self> {
41        AuthorizationName::new(name).map(Self::Strategy)
42    }
43
44    /// Creates an application-defined authorization-policy requirement.
45    pub fn named(name: impl Into<String>) -> SoapResult<Self> {
46        AuthorizationName::new(name).map(Self::Named)
47    }
48
49    /// Creates an any-role policy.
50    pub fn any_role<I, S>(roles: I) -> SoapResult<Self>
51    where
52        I: IntoIterator<Item = S>,
53        S: Into<String>,
54    {
55        names("authorization roles", roles).map(Self::AnyRole)
56    }
57
58    /// Creates an all-roles policy.
59    pub fn all_roles<I, S>(roles: I) -> SoapResult<Self>
60    where
61        I: IntoIterator<Item = S>,
62        S: Into<String>,
63    {
64        names("authorization roles", roles).map(Self::AllRoles)
65    }
66
67    /// Creates an any-permission policy.
68    pub fn any_permission<I, S>(permissions: I) -> SoapResult<Self>
69    where
70        I: IntoIterator<Item = S>,
71        S: Into<String>,
72    {
73        names("authorization permissions", permissions).map(Self::AnyPermission)
74    }
75
76    /// Creates an all-permissions policy.
77    pub fn all_permissions<I, S>(permissions: I) -> SoapResult<Self>
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        names("authorization permissions", permissions).map(Self::AllPermissions)
83    }
84
85    /// Validates directly constructed list policies.
86    pub fn validate(&self) -> SoapResult<()> {
87        match self {
88            Self::AnyRole(values)
89            | Self::AllRoles(values)
90            | Self::AnyPermission(values)
91            | Self::AllPermissions(values)
92                if values.is_empty() =>
93            {
94                Err(SoapError::validation(
95                    "authorization name list cannot be empty",
96                ))
97            }
98            _ => Ok(()),
99        }
100    }
101
102    /// Reports whether missing credentials are an authentication failure.
103    pub const fn requires_identity(&self) -> bool {
104        !matches!(
105            self,
106            Self::Public | Self::Optional | Self::OptionalStrategy(_)
107        )
108    }
109
110    /// Reports whether supplied credentials should be inspected.
111    pub const fn authenticates_when_present(&self) -> bool {
112        !matches!(self, Self::Public)
113    }
114
115    /// Reports whether a shared public response cache is safe by default.
116    pub const fn allows_public_response_cache(&self) -> bool {
117        matches!(self, Self::Public)
118    }
119}
120
121fn names<I, S>(kind: &str, values: I) -> SoapResult<Vec<AuthorizationName>>
122where
123    I: IntoIterator<Item = S>,
124    S: Into<String>,
125{
126    let values = values
127        .into_iter()
128        .map(AuthorizationName::new)
129        .collect::<SoapResult<Vec<_>>>()?;
130    if values.is_empty() {
131        Err(SoapError::validation(format!("{kind} cannot be empty")))
132    } else {
133        Ok(values)
134    }
135}
136
137/// Safe reason for a denied authorization decision.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum AuthorizationFailure {
140    /// Required authentication is absent.
141    MissingAuthentication,
142    /// Authentication used a different strategy from the required one.
143    StrategyMismatch,
144    /// The principal lacks a required role combination.
145    MissingRole,
146    /// The principal lacks a required permission combination.
147    MissingPermission,
148    /// An application-defined policy denied access.
149    PolicyDenied,
150}
151
152/// Authorization result that can defer one named policy to application code.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum AuthorizationDecision {
155    /// Access is allowed.
156    Allowed,
157    /// Access is denied for a safe stable reason.
158    Denied(AuthorizationFailure),
159    /// A named policy evaluator must make the final decision.
160    RequiresPolicy(AuthorizationName),
161}
162
163impl AuthorizationDecision {
164    /// Converts a final decision into the stable soaprs error model.
165    pub fn enforce(self) -> SoapResult<()> {
166        match self {
167            Self::Allowed => Ok(()),
168            Self::Denied(
169                AuthorizationFailure::MissingAuthentication
170                | AuthorizationFailure::StrategyMismatch,
171            ) => Err(SoapError::unauthorized()),
172            Self::Denied(
173                AuthorizationFailure::MissingRole
174                | AuthorizationFailure::MissingPermission
175                | AuthorizationFailure::PolicyDenied,
176            ) => Err(SoapError::forbidden()),
177            Self::RequiresPolicy(_) => Err(SoapError::unsupported(
178                "named authorization policy requires an application evaluator",
179            )),
180        }
181    }
182}
183
184/// Evaluates every built-in authorization policy without I/O.
185#[derive(Debug, Clone, Copy, Default)]
186pub struct DefaultAuthorizationEvaluator;
187
188impl DefaultAuthorizationEvaluator {
189    /// Evaluates a built-in policy or defers a named policy.
190    pub fn evaluate<P>(
191        &self,
192        authentication: Option<&Authentication<P>>,
193        policy: &AuthorizationPolicy,
194    ) -> SoapResult<AuthorizationDecision>
195    where
196        P: Principal,
197    {
198        policy.validate()?;
199        let decision = match policy {
200            AuthorizationPolicy::Public | AuthorizationPolicy::Optional => {
201                AuthorizationDecision::Allowed
202            }
203            AuthorizationPolicy::OptionalStrategy(strategy) => match authentication {
204                None => AuthorizationDecision::Allowed,
205                Some(authentication) if authentication.strategy() == strategy => {
206                    AuthorizationDecision::Allowed
207                }
208                Some(_) => AuthorizationDecision::Denied(AuthorizationFailure::StrategyMismatch),
209            },
210            AuthorizationPolicy::Authenticated => require_authentication(authentication, |_| true),
211            AuthorizationPolicy::Strategy(strategy) => {
212                require_authentication(authentication, |authentication| {
213                    authentication.strategy() == strategy
214                })
215            }
216            AuthorizationPolicy::AnyRole(roles) => {
217                require_authentication(authentication, |authentication| {
218                    roles
219                        .iter()
220                        .any(|role| authentication.principal().has_role(role))
221                })
222                .map_denial(AuthorizationFailure::MissingRole)
223            }
224            AuthorizationPolicy::AllRoles(roles) => {
225                require_authentication(authentication, |authentication| {
226                    roles
227                        .iter()
228                        .all(|role| authentication.principal().has_role(role))
229                })
230                .map_denial(AuthorizationFailure::MissingRole)
231            }
232            AuthorizationPolicy::AnyPermission(permissions) => {
233                require_authentication(authentication, |authentication| {
234                    permissions
235                        .iter()
236                        .any(|permission| authentication.principal().has_permission(permission))
237                })
238                .map_denial(AuthorizationFailure::MissingPermission)
239            }
240            AuthorizationPolicy::AllPermissions(permissions) => {
241                require_authentication(authentication, |authentication| {
242                    permissions
243                        .iter()
244                        .all(|permission| authentication.principal().has_permission(permission))
245                })
246                .map_denial(AuthorizationFailure::MissingPermission)
247            }
248            AuthorizationPolicy::Named(name) => AuthorizationDecision::RequiresPolicy(name.clone()),
249        };
250        Ok(decision)
251    }
252}
253
254trait MapDenial {
255    fn map_denial(self, denial: AuthorizationFailure) -> Self;
256}
257
258impl MapDenial for AuthorizationDecision {
259    fn map_denial(self, denial: AuthorizationFailure) -> Self {
260        match self {
261            Self::Denied(AuthorizationFailure::StrategyMismatch) => Self::Denied(denial),
262            decision => decision,
263        }
264    }
265}
266
267fn require_authentication<P, F>(
268    authentication: Option<&Authentication<P>>,
269    predicate: F,
270) -> AuthorizationDecision
271where
272    F: FnOnce(&Authentication<P>) -> bool,
273{
274    match authentication {
275        None => AuthorizationDecision::Denied(AuthorizationFailure::MissingAuthentication),
276        Some(authentication) if predicate(authentication) => AuthorizationDecision::Allowed,
277        Some(_) => AuthorizationDecision::Denied(AuthorizationFailure::StrategyMismatch),
278    }
279}
280
281/// Asynchronous application authorization port for contextual or named policies.
282pub trait Authorizer<P, C>: Send + Sync
283where
284    P: Send + Sync,
285    C: Send + Sync,
286{
287    /// Evaluates one policy with application-specific context.
288    fn authorize<'a>(
289        &'a self,
290        authentication: Option<&'a Authentication<P>>,
291        context: &'a C,
292        policy: &'a AuthorizationPolicy,
293    ) -> BoxFuture<'a, SoapResult<AuthorizationDecision>>;
294}
295
296#[cfg(test)]
297mod tests {
298    use soaprs_core::SoapErrorKind;
299
300    use super::{
301        AuthorizationDecision, AuthorizationFailure, AuthorizationPolicy,
302        DefaultAuthorizationEvaluator,
303    };
304    use crate::{Authentication, StandardPrincipal};
305
306    #[test]
307    fn built_in_policies_distinguish_authentication_and_grant_failures() {
308        let principal = StandardPrincipal::new("user-42")
309            .and_then(|principal| principal.role("admin"))
310            .and_then(|principal| principal.permission("users:read"));
311        let Some(principal) = principal.ok() else {
312            panic!("valid principal");
313        };
314        let Some(authentication) = Authentication::new("jwt", principal).ok() else {
315            panic!("valid authentication");
316        };
317        let evaluator = DefaultAuthorizationEvaluator;
318
319        let allowed = AuthorizationPolicy::all_permissions(["users:read"])
320            .and_then(|policy| evaluator.evaluate(Some(&authentication), &policy));
321        assert_eq!(allowed.ok(), Some(AuthorizationDecision::Allowed));
322
323        let denied = AuthorizationPolicy::any_role(["owner"])
324            .and_then(|policy| evaluator.evaluate(Some(&authentication), &policy));
325        assert_eq!(
326            denied.as_ref().ok(),
327            Some(&AuthorizationDecision::Denied(
328                AuthorizationFailure::MissingRole
329            ))
330        );
331        assert_eq!(
332            denied
333                .and_then(AuthorizationDecision::enforce)
334                .as_ref()
335                .map_err(|error| error.kind()),
336            Err(SoapErrorKind::Forbidden)
337        );
338
339        let missing = evaluator
340            .evaluate::<StandardPrincipal>(None, &AuthorizationPolicy::Authenticated)
341            .and_then(AuthorizationDecision::enforce);
342        assert_eq!(
343            missing.as_ref().map_err(|error| error.kind()),
344            Err(SoapErrorKind::Unauthorized)
345        );
346    }
347
348    #[test]
349    fn optional_and_named_policies_are_explicit() {
350        let evaluator = DefaultAuthorizationEvaluator;
351        assert_eq!(
352            evaluator
353                .evaluate::<StandardPrincipal>(None, &AuthorizationPolicy::Optional)
354                .ok(),
355            Some(AuthorizationDecision::Allowed)
356        );
357        let Some(named) = AuthorizationPolicy::named("resource.owner").ok() else {
358            panic!("valid named policy");
359        };
360        assert!(matches!(
361            evaluator.evaluate::<StandardPrincipal>(None, &named),
362            Ok(AuthorizationDecision::RequiresPolicy(_))
363        ));
364    }
365}