Skip to main content

rocketmq_security_api/
layered_authorization.rs

1// Copyright 2026 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Runtime-neutral contracts for staged authorization.
16
17use crate::SecurityRequestView;
18
19/// Final authorization decision returned by a policy layer.
20#[must_use = "authorization decisions must be checked; Ok(Deny(_)) rejects the operation"]
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AuthorizationDecision {
23    /// The operation is authorized.
24    Allow,
25    /// The operation is rejected for a closed, non-sensitive reason.
26    Deny(AuthorizationDenial),
27}
28
29/// Closed, non-sensitive reasons for rejecting an authorization request.
30///
31/// Resource, subject, credential, policy, and request values deliberately do
32/// not appear in this contract.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum AuthorizationDenial {
35    /// The subject cannot be authorized without exposing whether it exists.
36    SubjectUnknown,
37    /// The resource cannot be authorized without exposing whether it exists.
38    ResourceUnknown,
39    /// The evaluated policy does not grant the requested operation.
40    PermissionDenied,
41    /// No applicable policy grants the requested operation.
42    PolicyNotApplicable,
43    /// A maintenance restriction rejects the requested operation.
44    MaintenanceRestricted,
45}
46
47/// The coarse ingress decision made before authentication or detailed policy evaluation.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum IngressDecision {
50    /// The request may continue to authentication and detailed authorization.
51    AllowToContinue,
52    /// The request must be rejected before evaluating a detailed layer.
53    Deny,
54}
55
56/// The result of a detailed authorization policy evaluation.
57///
58/// `Abstain` is intentionally local to the layered contract and is resolved
59/// only by [`combine_layered_authorization`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum DetailedDecision {
62    /// The detailed policy allows the request.
63    Allow,
64    /// The detailed policy denies the request.
65    Deny,
66    /// The detailed policy is deliberately not installed or enabled.
67    Abstain,
68}
69
70/// A fail-closed category for a layer that could not return a decision.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LayerFailureKind {
73    /// The layer or its required dependency is not available.
74    Unavailable,
75    /// The layer returned an operational or policy-evaluation error.
76    Error,
77    /// The layer exceeded its configured deadline.
78    Timeout,
79}
80
81/// Whether a detailed authorization decision is required for a request path.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum LayerRequirement {
84    /// A detailed decision must be present; `Abstain` is a denial.
85    Required,
86    /// An explicitly optional detailed layer permits `Abstain`.
87    ///
88    /// This variant is only for a deliberately disabled optional layer. It must
89    /// not convert an unavailable layer or any other failure into an allow.
90    Optional,
91}
92
93/// The result returned by one authorization layer.
94///
95/// [`combine_layered_authorization`] preserves every [`Err`] value for the
96/// owning boundary to project while remaining fail closed.
97pub type LayerEvaluation<T> = Result<T, LayerFailureKind>;
98
99/// A coarse ingress policy that can inspect a request without authenticating it.
100///
101/// Implementations classify only the ingress surface. Authentication and
102/// resource-level authorization remain the responsibility of later layers.
103pub trait IngressPolicy: Send + Sync {
104    /// Evaluates whether the request may proceed to the detailed layer.
105    fn evaluate_ingress(&self, request: SecurityRequestView<'_>) -> LayerEvaluation<IngressDecision>;
106}
107
108/// Combines coarse ingress and detailed authorization under fail-closed semantics.
109///
110/// A coarse deny is sticky and does not invoke `detailed`. An ingress failure, a
111/// detailed failure, or a detailed deny never produces an allow. Failures remain
112/// typed [`Err`] values for the owning boundary to project. A detailed abstention
113/// is allowed only for an explicit [`LayerRequirement::Optional`]; required
114/// authorization resolves it to [`AuthorizationDenial::PolicyNotApplicable`].
115pub fn combine_layered_authorization<F>(
116    ingress: LayerEvaluation<IngressDecision>,
117    requirement: LayerRequirement,
118    detailed: F,
119) -> LayerEvaluation<AuthorizationDecision>
120where
121    F: FnOnce() -> LayerEvaluation<DetailedDecision>,
122{
123    match ingress {
124        Ok(IngressDecision::AllowToContinue) => match detailed() {
125            Ok(DetailedDecision::Allow) => Ok(AuthorizationDecision::Allow),
126            Ok(DetailedDecision::Deny) => Ok(AuthorizationDecision::Deny(AuthorizationDenial::PermissionDenied)),
127            Ok(DetailedDecision::Abstain) if requirement == LayerRequirement::Optional => {
128                Ok(AuthorizationDecision::Allow)
129            }
130            Ok(DetailedDecision::Abstain) => Ok(AuthorizationDecision::Deny(AuthorizationDenial::PolicyNotApplicable)),
131            Err(failure) => Err(failure),
132        },
133        Ok(IngressDecision::Deny) => Ok(AuthorizationDecision::Deny(AuthorizationDenial::PermissionDenied)),
134        Err(failure) => Err(failure),
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use std::cell::Cell;
141
142    use super::*;
143
144    fn is_allow(decision: LayerEvaluation<AuthorizationDecision>) -> bool {
145        matches!(decision, Ok(AuthorizationDecision::Allow))
146    }
147
148    #[test]
149    fn complete_truth_table_is_fail_closed() {
150        for ingress in [IngressDecision::AllowToContinue, IngressDecision::Deny] {
151            for requirement in [LayerRequirement::Required, LayerRequirement::Optional] {
152                for detailed in [
153                    DetailedDecision::Allow,
154                    DetailedDecision::Deny,
155                    DetailedDecision::Abstain,
156                ] {
157                    let detailed_called = Cell::new(false);
158                    let result = combine_layered_authorization(Ok(ingress), requirement, || {
159                        detailed_called.set(true);
160                        Ok(detailed)
161                    });
162                    let expected_allow = matches!(
163                        (ingress, requirement, detailed),
164                        (IngressDecision::AllowToContinue, _, DetailedDecision::Allow)
165                            | (
166                                IngressDecision::AllowToContinue,
167                                LayerRequirement::Optional,
168                                DetailedDecision::Abstain
169                            )
170                    );
171
172                    assert_eq!(is_allow(result), expected_allow);
173                    assert_eq!(detailed_called.get(), ingress == IngressDecision::AllowToContinue);
174                }
175            }
176        }
177    }
178
179    #[test]
180    fn coarse_deny_is_sticky_and_skips_detailed_evaluation() {
181        let detailed_called = Cell::new(false);
182        let result = combine_layered_authorization(Ok(IngressDecision::Deny), LayerRequirement::Optional, || {
183            detailed_called.set(true);
184            Ok(DetailedDecision::Allow)
185        });
186
187        assert_eq!(
188            result,
189            Ok(AuthorizationDecision::Deny(AuthorizationDenial::PermissionDenied))
190        );
191        assert!(!detailed_called.get());
192    }
193
194    #[test]
195    fn every_layer_failure_is_preserved() {
196        for failure in [
197            LayerFailureKind::Unavailable,
198            LayerFailureKind::Error,
199            LayerFailureKind::Timeout,
200        ] {
201            assert_eq!(
202                combine_layered_authorization(Err(failure), LayerRequirement::Optional, || {
203                    Ok(DetailedDecision::Allow)
204                }),
205                Err(failure)
206            );
207            assert_eq!(
208                combine_layered_authorization(
209                    Ok(IngressDecision::AllowToContinue),
210                    LayerRequirement::Optional,
211                    || Err(failure),
212                ),
213                Err(failure)
214            );
215        }
216    }
217
218    #[test]
219    fn detailed_deny_and_required_abstention_have_closed_reasons() {
220        let denied =
221            combine_layered_authorization(Ok(IngressDecision::AllowToContinue), LayerRequirement::Required, || {
222                Ok(DetailedDecision::Deny)
223            });
224        let abstained =
225            combine_layered_authorization(Ok(IngressDecision::AllowToContinue), LayerRequirement::Required, || {
226                Ok(DetailedDecision::Abstain)
227            });
228
229        assert_eq!(
230            denied,
231            Ok(AuthorizationDecision::Deny(AuthorizationDenial::PermissionDenied))
232        );
233        assert_eq!(
234            abstained,
235            Ok(AuthorizationDecision::Deny(AuthorizationDenial::PolicyNotApplicable))
236        );
237    }
238
239    #[test]
240    fn every_denial_reason_is_closed_and_value_free() {
241        let reasons = [
242            AuthorizationDenial::SubjectUnknown,
243            AuthorizationDenial::ResourceUnknown,
244            AuthorizationDenial::PermissionDenied,
245            AuthorizationDenial::PolicyNotApplicable,
246            AuthorizationDenial::MaintenanceRestricted,
247        ];
248
249        for reason in reasons {
250            let decision = AuthorizationDecision::Deny(reason);
251            assert_eq!(decision, AuthorizationDecision::Deny(reason));
252        }
253    }
254}