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}