didbot_policy_cedar/lib.rs
1//! The Cedar policy engine, behind the same [`Evaluator`] contract as
2//! `didbot-policy-regex`.
3//!
4//! A document is Cedar text. [`Document::load`] holds it to three rules
5//! before it can judge anything: every statement carries an `@id` unique
6//! within the document, every statement is a forbid with a `@reason`, and
7//! every statement validates in strict mode against the schema of each
8//! collection it can reach (see [`schema`]). A refusal is the statement's
9//! `@id` and `@reason`.
10//!
11//! Cedar treats a policy that errors as not satisfied. For a forbid that
12//! means a misspelled field, or a missing `has` guard, would permit the
13//! request and tuck the error into the diagnostics. [`Document::decide`]
14//! reads the diagnostics after every decision and turns any error into a
15//! refusal naming the statement, so a document with a typo refuses
16//! everything in its scope rather than permitting it silently.
17//!
18//! Through the [`Evaluator`] contract the engine sees a
19//! [`didbot_policy::Subject`]. A write becomes a `record.write` request
20//! whose `value` and `existing` are reassembled from the diff, so an update
21//! carries only the fields it changed; a grant becomes an `oauth.authorize`
22//! request with no principal; a token becomes an `oauth.token` request for
23//! the account, with no `grant`; a create becomes an `account.create`
24//! request for one pool and zone, whose `Name` carries `name` and `label`
25//! only when the create gave a name. A statement that needs what the gate does
26//! not carry refuses rather than failing to match — see [`Principal`].
27//!
28//! A write's `context.client` is the same `Client` entity the two OAuth
29//! actions take as their resource, so the rule refusing an app its writes
30//! reads like the one refusing it sign-in:
31//!
32//! ```cedar
33//! @id("no-app-writes")
34//! @reason("This app may not write here.")
35//! forbid(principal, action == Didbot::Action::"record.write", resource)
36//! when { context has client && context.client.id like "https://app.example/*" };
37//! ```
38//!
39//! A write made with the account's own credential has no client, so a
40//! statement reads `context.client` only after `context has client`.
41//! Without that guard it fails to load, or, where it is unvalidated,
42//! refuses every write that has no client.
43
44#![forbid(unsafe_code)]
45
46mod load;
47mod request;
48pub mod schema;
49mod shared;
50
51use std::collections::{HashMap, HashSet};
52use std::sync::{Arc, RwLock};
53
54use cedar_policy::{AuthorizationError, Authorizer};
55use didbot_policy::{
56 CompileOutcome, CompiledId, EvalError, Evaluator, Lifecycle, Observed, Outcome, Staleness,
57 Subject,
58};
59use serde_json::Value;
60
61pub use load::{Document, Failure, LoadError, PrincipalNeed, Statement, BASELINE_ID};
62use request::Prepared;
63pub use request::{AccountAttributes, Action, Operation, Principal, Request, RequestError};
64pub use schema::{ActionName, Field, FieldType, Shape, ShapeError, Shapes};
65
66/// What a document decided about one request.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum Decision {
69 /// No statement fired.
70 Allow,
71 /// A statement fired.
72 Forbidden {
73 /// The statement's `@id`.
74 id: String,
75 /// The statement's `@reason`.
76 reason: String,
77 },
78 /// A statement could not complete its check, which refuses the
79 /// request: an error is never a permit.
80 Failed {
81 /// The statement's `@id`.
82 id: String,
83 /// What went wrong.
84 error: String,
85 },
86}
87
88impl Document {
89 /// Judge `request`. The first statement in document order that fails
90 /// wins over any that fired, so a typo is reported before anything it
91 /// might have hidden.
92 ///
93 /// # Errors
94 ///
95 /// The request itself could not be built. Nothing was evaluated, and
96 /// the caller refuses.
97 pub fn decide(&self, request: &Request<'_>, shapes: &Shapes) -> Result<Decision, RequestError> {
98 self.judge(&request.prepare(shapes))
99 }
100
101 /// [`decide`](Self::decide), against a request already prepared.
102 fn judge(&self, prepared: &Prepared) -> Result<Decision, RequestError> {
103 let action = prepared.action;
104 for statement in self
105 .statements
106 .iter()
107 .filter(|s| s.actions().contains(&action))
108 {
109 if statement.principal() > prepared.provides {
110 return Ok(Decision::Failed {
111 id: statement.id().to_owned(),
112 error: format!(
113 "the statement's scope needs {}, which the {action} gate does not carry",
114 statement.principal()
115 ),
116 });
117 }
118 }
119
120 let built = prepared.built.as_ref().map_err(Clone::clone)?;
121 let response = Authorizer::new().is_authorized(&built.request, &self.set, &built.entities);
122 let errors: HashMap<String, String> = response
123 .diagnostics()
124 .errors()
125 .map(|error| match error {
126 AuthorizationError::PolicyEvaluationError(error) => {
127 (error.policy_id().to_string(), error.inner().to_string())
128 }
129 })
130 .collect();
131 // Document order first, so the report is stable. An error under an
132 // id no statement carries still refuses, under the id Cedar gave it.
133 let failed = self
134 .statements
135 .iter()
136 .find_map(|s| errors.get_key_value(s.id()))
137 .or_else(|| errors.iter().min_by_key(|(id, _)| *id));
138 if let Some((id, error)) = failed {
139 tracing::debug!(statement = %id, %error, "a cedar statement errored; refusing");
140 return Ok(Decision::Failed {
141 id: id.clone(),
142 error: error.clone(),
143 });
144 }
145 if response.decision() == cedar_policy::Decision::Deny {
146 let fired: HashSet<String> = response
147 .diagnostics()
148 .reason()
149 .map(ToString::to_string)
150 .collect();
151 if let Some(statement) = self.statements.iter().find(|s| fired.contains(s.id())) {
152 return Ok(Decision::Forbidden {
153 id: statement.id().to_owned(),
154 reason: statement.reason().to_owned(),
155 });
156 }
157 return Ok(Decision::Failed {
158 id: BASELINE_ID.to_owned(),
159 error: "the baseline permit did not apply".to_owned(),
160 });
161 }
162 Ok(Decision::Allow)
163 }
164}
165
166/// What the evaluator contract returns for a document's decision.
167///
168/// A statement that fired is the document's own [`Outcome::Reject`]. A
169/// statement, or the request itself, that could not be judged at all is an
170/// [`EvalError`] instead of a manufactured `Reject` -- the same channel a
171/// timed-out or unreachable evaluator uses -- so `PolicyTree::decide`
172/// refuses it exactly as fail-closed as before, and `decide_detailed` can
173/// still tell a caller this was not the document's own decision.
174fn outcome(decision: Result<Decision, RequestError>) -> Result<Outcome, EvalError> {
175 match decision {
176 Ok(Decision::Allow) => Ok(Outcome::Allow),
177 Ok(Decision::Forbidden { reason, .. }) => Ok(Outcome::Reject { reason }),
178 Ok(Decision::Failed { id, error }) => Err(EvalError::Failed(format!(
179 "statement `{id}` could not complete its check: {error}"
180 ))),
181 Err(error) => Err(EvalError::Failed(format!(
182 "the request could not be built for the policy: {error}"
183 ))),
184 }
185}
186
187/// The source text a `bot.did.policy#cedar` document's predicate carries.
188///
189/// # Errors
190///
191/// The predicate is not `{"source": <text>}`.
192pub fn source_of(predicate: &Value) -> Result<&str, String> {
193 predicate
194 .get("source")
195 .and_then(Value::as_str)
196 .ok_or_else(|| "a cedar predicate is {\"source\": <document text>}".to_owned())
197}
198
199/// The engine: one set of shapes, and every document compiled against it.
200pub struct CedarEvaluator {
201 shapes: Arc<Shapes>,
202 documents: RwLock<Vec<Arc<Document>>>,
203}
204
205impl CedarEvaluator {
206 /// An engine that validates against `shapes`.
207 #[must_use]
208 pub fn new(shapes: Arc<Shapes>) -> Self {
209 Self {
210 shapes,
211 documents: RwLock::new(Vec::new()),
212 }
213 }
214
215 fn document(&self, compiled: CompiledId) -> Result<Arc<Document>, EvalError> {
216 let index = usize::try_from(compiled.0)
217 .map_err(|_| EvalError::Failed(format!("no compiled document {}", compiled.0)))?;
218 self.documents
219 .read()
220 .unwrap_or_else(std::sync::PoisonError::into_inner)
221 .get(index)
222 .cloned()
223 .ok_or_else(|| EvalError::Failed(format!("no compiled document {}", compiled.0)))
224 }
225}
226
227impl Evaluator for CedarEvaluator {
228 fn compile(&self, predicate: &Value) -> CompileOutcome {
229 let source = match source_of(predicate) {
230 Ok(source) => source,
231 Err(reason) => return CompileOutcome::Rejected(reason),
232 };
233 match Document::load(source, &self.shapes) {
234 Ok(document) => {
235 let warnings = document.warnings().to_vec();
236 let mut documents = self
237 .documents
238 .write()
239 .unwrap_or_else(std::sync::PoisonError::into_inner);
240 let id = CompiledId(documents.len() as u64);
241 documents.push(Arc::new(document));
242 if warnings.is_empty() {
243 CompileOutcome::Compiled(id)
244 } else {
245 CompileOutcome::CompiledWithWarnings(id, warnings)
246 }
247 }
248 Err(error) => CompileOutcome::Rejected(error.to_string()),
249 }
250 }
251
252 /// Statements asked about the same subject on one thread share the
253 /// request built for it, whichever evaluator holds them.
254 fn evaluate(&self, subject: &Subject<'_>, compiled: CompiledId) -> Result<Outcome, EvalError> {
255 let document = self.document(compiled)?;
256 // A blob is judged when its upload starts, before its bytes are
257 // read, and again while they arrive and once they are in. `Blob`
258 // takes every fact as a `String` or a `Long`, so this engine judges
259 // only the later ones, which carry them all.
260 if matches!(subject, Subject::Blob { sniffed: None, .. }) {
261 return Ok(Outcome::Allow);
262 }
263 let prepared = shared::prepared(subject, &self.shapes);
264 outcome(document.judge(&prepared))
265 }
266
267 fn observe(&self, _event: &Observed<'_>) {}
268
269 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
270
271 fn staleness(&self) -> Staleness {
272 Staleness::Linearized
273 }
274}