dwara_core/security/cedar/
mod.rs

1//! Cedar policy engine integration (DW-060, CFG-07 / #241: hot reload).
2//!
3//! This module provides a thin wrapper around the `cedar-policy` crate,
4//! offering:
5//!
6//! - [`CedarAuthorizer`] — a compiled Cedar policy set + entity store,
7//!   ready to evaluate authorization requests.
8//! - [`CedarRequest`] — an authorization request (principal, action,
9//!   resource, context).
10//! - [`CedarDecision`] — the authorization decision (Allow or Deny).
11//! - [`HotReloadCedarAuthorizer`] — a hot-reloadable wrapper that swaps
12//!   the compiled policy set atomically without blocking the request
13//!   path (DP-07, #241).
14//!
15//! ## Design (section 6-Extensibility, §9.3)
16//!
17//! Cedar is Rust-native (AWS), so authz becomes fine-grained data
18//! (policies), not code, without an FFI boundary. Policies are
19//! compiled once at config publish time and embedded in the snapshot.
20//! The request path only evaluates — it never parses or compiles.
21//!
22//! ## Hot reload (CFG-07, #241)
23//!
24//! The [`HotReloadCedarAuthorizer`] wraps a [`CedarAuthorizer`] in an
25//! `ArcSwap`, allowing the policy set to be swapped atomically without
26//! blocking the request path. The reload compiles the new policy set
27//! on a background task and then swaps the `Arc` in a single atomic
28//! operation. The request path always reads the current generation
29//! via `ArcSwap::load`, which is a single relaxed load.
30
31use std::sync::Arc;
32
33use arc_swap::ArcSwap;
34
35pub mod opa;
36
37use cedar_policy::{
38    Authorizer, Context, Decision, Entities, EntityUid, PolicySet, Request, Schema,
39};
40
41/// A compiled Cedar authorizer: a policy set + entity store, ready to
42/// evaluate authorization requests.
43///
44/// Created at config publish time by [`CedarAuthorizer::new`]. The
45/// authorizer is immutable and can be safely shared across threads
46/// (Arc internals).
47#[derive(Clone)]
48pub struct CedarAuthorizer {
49    policies: Arc<PolicySet>,
50    entities: Arc<Entities>,
51    schema: Option<Arc<Schema>>,
52}
53
54/// A hot-reloadable Cedar authorizer (CFG-07, #241). Wraps a
55/// [`CedarAuthorizer`] in an `ArcSwap` so the policy set can be
56/// swapped atomically without blocking the request path.
57///
58/// The reload compiles the new policy set on the calling thread (or
59/// a background task) and then swaps the `Arc` in a single atomic
60/// store. The request path's `is_authorized` call loads the current
61/// generation via `ArcSwap::load` (a single relaxed load) and
62/// evaluates against it — no lock, no I/O, no blocking.
63pub struct HotReloadCedarAuthorizer {
64    inner: ArcSwap<CedarAuthorizer>,
65}
66
67impl HotReloadCedarAuthorizer {
68    /// Create a new hot-reloadable authorizer from an initial
69    /// [`CedarAuthorizer`].
70    pub fn new(initial: CedarAuthorizer) -> Self {
71        Self {
72            inner: ArcSwap::from_pointee(initial),
73        }
74    }
75
76    /// Create from raw policy/entity/schema sources (convenience).
77    pub fn from_sources(
78        policies_src: &str,
79        entities_json: Option<&str>,
80        schema_json: Option<&str>,
81    ) -> Result<Self, CedarError> {
82        let authorizer = CedarAuthorizer::new(policies_src, entities_json, schema_json)?;
83        Ok(Self::new(authorizer))
84    }
85
86    /// Atomically swap the compiled policy set. The new authorizer
87    /// is compiled by the caller (typically on a background task)
88    /// and swapped in with a single atomic store. The request path
89    /// sees the new generation on its next `is_authorized` call.
90    ///
91    /// Returns the previous authorizer (for drop/cleanup).
92    pub fn reload(&self, new: CedarAuthorizer) -> CedarAuthorizer {
93        let prev = self.inner.swap(Arc::new(new));
94        // Unwrap the Arc back to owned — there may be in-flight
95        // readers holding clones, but the swap guarantees the
96        // returned value is the previous generation.
97        Arc::try_unwrap(prev).unwrap_or_else(|arc| (*arc).clone())
98    }
99
100    /// Reload from raw sources. Compiles the new policy set and swaps
101    /// it in. Returns an error if compilation fails (the currently
102    /// loaded generation is unchanged on error).
103    pub fn reload_from_sources(
104        &self,
105        policies_src: &str,
106        entities_json: Option<&str>,
107        schema_json: Option<&str>,
108    ) -> Result<(), CedarError> {
109        let new = CedarAuthorizer::new(policies_src, entities_json, schema_json)?;
110        self.reload(new);
111        Ok(())
112    }
113
114    /// Evaluate an authorization request against the currently loaded
115    /// policy set. Loads the current generation via `ArcSwap::load`
116    /// (a single relaxed atomic load) and evaluates — no lock, no I/O.
117    pub fn is_authorized(&self, req: &CedarRequest) -> Result<CedarDecision, CedarError> {
118        let authorizer = self.inner.load();
119        authorizer.is_authorized(req)
120    }
121
122    /// The number of policies in the currently loaded set.
123    pub fn policy_count(&self) -> usize {
124        self.inner.load().policy_count()
125    }
126
127    /// Get a clone of the currently loaded authorizer (for
128    /// inspection or testing).
129    pub fn current(&self) -> CedarAuthorizer {
130        (**self.inner.load()).clone()
131    }
132}
133
134/// A Cedar authorization request.
135#[derive(Clone, Debug)]
136pub struct CedarRequest {
137    /// The principal (who is making the request), e.g.
138    /// `User::"alice"`.
139    pub principal: String,
140    /// The action being performed, e.g. `Action::"read"`.
141    pub action: String,
142    /// The resource being accessed, e.g. `Route::"api-v1"`.
143    pub resource: String,
144    /// Additional context (a JSON object serialized as a Cedar context).
145    pub context: Option<serde_json::Value>,
146}
147
148/// The result of a Cedar authorization check.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum CedarDecision {
151    Allow,
152    Deny,
153}
154
155/// An error from the Cedar policy engine.
156#[derive(Debug)]
157pub enum CedarError {
158    /// Failed to parse the policy set.
159    PolicyParse(String),
160    /// Failed to parse the entity store.
161    EntityParse(String),
162    /// Failed to parse the schema.
163    SchemaParse(String),
164    /// Failed to build the request.
165    RequestBuild(String),
166    /// Authorization evaluation failed.
167    AuthzError(String),
168}
169
170impl std::fmt::Display for CedarError {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        match self {
173            Self::PolicyParse(s) => write!(f, "Cedar policy parse error: {s}"),
174            Self::EntityParse(s) => write!(f, "Cedar entity parse error: {s}"),
175            Self::SchemaParse(s) => write!(f, "Cedar schema parse error: {s}"),
176            Self::RequestBuild(s) => write!(f, "Cedar request build error: {s}"),
177            Self::AuthzError(s) => write!(f, "Cedar authz error: {s}"),
178        }
179    }
180}
181
182impl std::error::Error for CedarError {}
183
184impl CedarAuthorizer {
185    /// Create a new authorizer from a policy set source string, an
186    /// entities JSON string, and an optional schema JSON string.
187    ///
188    /// This is the compile step. It should be called at config publish
189    /// time, never on the request path.
190    pub fn new(
191        policies_src: &str,
192        entities_json: Option<&str>,
193        schema_json: Option<&str>,
194    ) -> Result<Self, CedarError> {
195        let policy_set: PolicySet = policies_src
196            .parse::<PolicySet>()
197            .map_err(|e: cedar_policy::ParseErrors| CedarError::PolicyParse(e.to_string()))?;
198
199        let entities = if let Some(json) = entities_json {
200            Entities::from_json_str(json, None)
201                .map_err(|e| CedarError::EntityParse(e.to_string()))?
202        } else {
203            Entities::empty()
204        };
205
206        let schema = if let Some(json) = schema_json {
207            Some(Arc::new(
208                Schema::from_json_str(json).map_err(|e| CedarError::SchemaParse(e.to_string()))?,
209            ))
210        } else {
211            None
212        };
213
214        Ok(Self {
215            policies: Arc::new(policy_set),
216            entities: Arc::new(entities),
217            schema,
218        })
219    }
220
221    /// Create an empty authorizer (denies everything).
222    pub fn empty() -> Self {
223        Self {
224            policies: Arc::new(PolicySet::new()),
225            entities: Arc::new(Entities::empty()),
226            schema: None,
227        }
228    }
229
230    /// Evaluate an authorization request.
231    ///
232    /// This is the hot-path call. It evaluates the compiled policies
233    /// against the request and returns a decision.
234    pub fn is_authorized(&self, req: &CedarRequest) -> Result<CedarDecision, CedarError> {
235        let principal = parse_euid(&req.principal)
236            .map_err(|e| CedarError::RequestBuild(format!("principal: {e}")))?;
237        let action = parse_euid(&req.action)
238            .map_err(|e| CedarError::RequestBuild(format!("action: {e}")))?;
239        let resource = parse_euid(&req.resource)
240            .map_err(|e| CedarError::RequestBuild(format!("resource: {e}")))?;
241
242        let context = if let Some(ctx_json) = &req.context {
243            Context::from_json_value(ctx_json.clone(), None)
244                .map_err(|e| CedarError::RequestBuild(format!("context: {e}")))?
245        } else {
246            Context::empty()
247        };
248
249        let request = Request::new(principal, action, resource, context, self.schema.as_deref())
250            .map_err(|e| CedarError::RequestBuild(e.to_string()))?;
251
252        let authorizer = Authorizer::new();
253        let response = authorizer.is_authorized(&request, &self.policies, &self.entities);
254        match response.decision() {
255            Decision::Allow => Ok(CedarDecision::Allow),
256            Decision::Deny => {
257                for err in response.diagnostics().errors() {
258                    tracing::debug!(
259                        error = %err,
260                        "Cedar policy denied request"
261                    );
262                }
263                Ok(CedarDecision::Deny)
264            }
265        }
266    }
267
268    /// The number of policies in the set.
269    pub fn policy_count(&self) -> usize {
270        self.policies.policies().count()
271    }
272}
273
274/// Parse a Cedar entity UID from a string like `User::"alice"`.
275fn parse_euid(s: &str) -> Result<EntityUid, String> {
276    s.parse::<EntityUid>().map_err(|e| format!("{e}"))
277}