Skip to main content

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}