Skip to main content

gatekeep_axum/
authorizer.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use gatekeep::{
4    AuditEntry, AuditSink, Context, Decision, DecisionSummary, DecisiveClause, DenyShape, Effect,
5    EffectKind, FactResolver, IdentityReasonCatalog, Lattice, NoopAuditSink, NoopPolicyObserver,
6    Policy, PolicyAnchor, PolicyId, PolicyObserver, ReasonCatalog, evaluate, required_facts,
7};
8use serde::Serialize;
9
10use crate::{DenialResponseConfig, GatekeepAxumError, GatekeepRejection};
11
12/// Whether audit entries should include request subject identifiers.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum AuditSubjects {
15    /// Leave tenant and principal identifiers out of audit entries.
16    #[default]
17    Omit,
18    /// Copy tenant and principal identifiers from the request context.
19    Record,
20}
21
22/// Successful authorization result returned to handlers.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct Authorized<O> {
25    /// Granted outcome.
26    pub outcome: O,
27    /// Full decision returned by the pure evaluator.
28    pub decision: Decision<O>,
29}
30
31/// Axum-friendly authorization boundary.
32pub struct Gatekeeper<R, A = NoopAuditSink, C = IdentityReasonCatalog, W = NoopPolicyObserver> {
33    resolver: Arc<R>,
34    audit_sink: Arc<A>,
35    reason_catalog: Arc<C>,
36    observer: Arc<W>,
37    denial_response: DenialResponseConfig,
38    audit_subjects: AuditSubjects,
39}
40
41impl<R, A, C, W> Clone for Gatekeeper<R, A, C, W> {
42    fn clone(&self) -> Self {
43        Self {
44            resolver: Arc::clone(&self.resolver),
45            audit_sink: Arc::clone(&self.audit_sink),
46            reason_catalog: Arc::clone(&self.reason_catalog),
47            observer: Arc::clone(&self.observer),
48            denial_response: self.denial_response.clone(),
49            audit_subjects: self.audit_subjects,
50        }
51    }
52}
53
54impl<R> Gatekeeper<R> {
55    /// Creates a gatekeeper with no-op audit and identity reason rendering.
56    #[must_use]
57    pub fn new(resolver: R) -> Self {
58        Self {
59            resolver: Arc::new(resolver),
60            audit_sink: Arc::new(NoopAuditSink),
61            reason_catalog: Arc::new(IdentityReasonCatalog),
62            observer: Arc::new(NoopPolicyObserver),
63            denial_response: DenialResponseConfig::default(),
64            audit_subjects: AuditSubjects::default(),
65        }
66    }
67}
68
69impl<R, A, C, W> Gatekeeper<R, A, C, W> {
70    /// Replaces the audit sink.
71    #[must_use]
72    pub fn with_audit_sink<NextAudit>(
73        self,
74        audit_sink: NextAudit,
75    ) -> Gatekeeper<R, NextAudit, C, W> {
76        Gatekeeper {
77            resolver: self.resolver,
78            audit_sink: Arc::new(audit_sink),
79            reason_catalog: self.reason_catalog,
80            observer: self.observer,
81            denial_response: self.denial_response,
82            audit_subjects: self.audit_subjects,
83        }
84    }
85
86    /// Replaces the reason catalog used for forbidden denials.
87    #[must_use]
88    pub fn with_reason_catalog<NextCatalog>(
89        self,
90        reason_catalog: NextCatalog,
91    ) -> Gatekeeper<R, A, NextCatalog, W> {
92        Gatekeeper {
93            resolver: self.resolver,
94            audit_sink: self.audit_sink,
95            reason_catalog: Arc::new(reason_catalog),
96            observer: self.observer,
97            denial_response: self.denial_response,
98            audit_subjects: self.audit_subjects,
99        }
100    }
101
102    /// Replaces the side-channel decision observer.
103    #[must_use]
104    pub fn with_observer<NextObserver>(
105        self,
106        observer: NextObserver,
107    ) -> Gatekeeper<R, A, C, NextObserver> {
108        Gatekeeper {
109            resolver: self.resolver,
110            audit_sink: self.audit_sink,
111            reason_catalog: self.reason_catalog,
112            observer: Arc::new(observer),
113            denial_response: self.denial_response,
114            audit_subjects: self.audit_subjects,
115        }
116    }
117
118    /// Replaces denial presentation settings.
119    #[must_use]
120    pub fn with_denial_response(mut self, denial_response: DenialResponseConfig) -> Self {
121        self.denial_response = denial_response;
122        self
123    }
124
125    /// Controls whether audit entries include tenant and principal identifiers.
126    #[must_use]
127    pub const fn with_audit_subjects(mut self, audit_subjects: AuditSubjects) -> Self {
128        self.audit_subjects = audit_subjects;
129        self
130    }
131}
132
133impl<R, A, C, W> Gatekeeper<R, A, C, W>
134where
135    R: FactResolver,
136    A: AuditSink,
137    C: ReasonCatalog + Send + Sync,
138    W: PolicyObserver,
139{
140    /// Resolves facts, evaluates the policy, observes and audits the decision,
141    /// and returns an axum rejection for denied requests.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`GatekeepRejection`] when policy hashing, fact resolution,
146    /// trace conversion, or audit persistence fails, or when the policy denies
147    /// the request.
148    pub async fn authorize<O>(
149        &self,
150        policy_id: PolicyId,
151        policy: &Policy<O>,
152        context: Context,
153    ) -> Result<Authorized<O>, GatekeepRejection<R::Error, A::Error>>
154    where
155        O: Lattice + Serialize + Send + Sync,
156    {
157        let anchor = PolicyAnchor {
158            policy_id,
159            policy_hash: policy
160                .hash()
161                .map_err(GatekeepAxumError::PolicyHash)
162                .map_err(GatekeepRejection::from_error)?,
163        };
164        let required = required_facts(policy).into_iter().collect::<Vec<_>>();
165        let facts = self
166            .resolver
167            .resolve_for_decision(&required, &context)
168            .await
169            .map_err(GatekeepAxumError::Resolve)
170            .map_err(GatekeepRejection::from_error)?;
171        let decision = evaluate(policy, &facts);
172
173        self.observe_and_audit(&anchor, &decision, &context)
174            .await
175            .map_err(GatekeepRejection::from_error)?;
176
177        match decision.effect.clone() {
178            Effect::Permit(outcome) => Ok(Authorized { outcome, decision }),
179            Effect::Deny => {
180                let reason = decision
181                    .denial_reason()
182                    .map_err(GatekeepAxumError::Trace)
183                    .map_err(GatekeepRejection::from_error)?;
184                let response = self.denial_response.denied(
185                    denial_shape(&decision),
186                    reason.as_ref(),
187                    &context.locale,
188                    self.reason_catalog.as_ref(),
189                );
190                Err(response.into())
191            }
192        }
193    }
194
195    async fn observe_and_audit<O>(
196        &self,
197        anchor: &PolicyAnchor,
198        decision: &Decision<O>,
199        context: &Context,
200    ) -> Result<(), GatekeepAxumError<R::Error, A::Error>>
201    where
202        O: Serialize + Clone + Sync,
203    {
204        let (tenant, principal) = match self.audit_subjects {
205            AuditSubjects::Omit => (None, None),
206            AuditSubjects::Record => (
207                Some(context.tenant.clone()),
208                Some(context.principal.clone()),
209            ),
210        };
211        let trace = decision.to_trace().map_err(GatekeepAxumError::Trace)?;
212        let entry = AuditEntry {
213            request_id: context.request_id.clone(),
214            anchor: anchor.clone(),
215            effect: EffectKind::from(decision),
216            obligations: decision.obligations.clone(),
217            consulted: trace.consulted.clone(),
218            decisive: trace.decisive.clone(),
219            denial_reason: decision.denial_reason().map_err(GatekeepAxumError::Trace)?,
220            trace,
221            tenant,
222            principal,
223            subjects: match self.audit_subjects {
224                AuditSubjects::Omit => BTreeMap::default(),
225                AuditSubjects::Record => context.subjects.clone(),
226            },
227        };
228        let summary = DecisionSummary {
229            anchor: anchor.clone(),
230            effect: EffectKind::from(decision),
231            obligations: decision.obligations.clone(),
232            consulted: decision.trace.consulted.clone(),
233        };
234        self.audit_sink
235            .record(&entry)
236            .await
237            .map_err(GatekeepAxumError::Audit)?;
238
239        self.observer.observe(&summary);
240        Ok(())
241    }
242}
243
244const fn denial_shape<O>(decision: &Decision<O>) -> DenyShape {
245    match &decision.trace.decisive {
246        DecisiveClause::Deny { shape, .. } => *shape,
247        DecisiveClause::Permit { .. } => DenyShape::Forbidden,
248    }
249}