Skip to main content

didbot_policy_cedar/
load.rs

1//! Loading a document: Cedar splits it into statements, and this module
2//! holds each to the three rules `plan/cedar-engine.md` sets — every
3//! statement carries a unique `@id`, every statement is a forbid with a
4//! `@reason`, and every statement validates in strict mode against the
5//! schema of each collection it can reach.
6
7use std::collections::{BTreeSet, HashSet};
8use std::str::FromStr;
9
10use cedar_policy::{
11    ActionConstraint, Effect, Policy, PolicyId, PolicySet, PrincipalConstraint, ValidationMode,
12};
13use serde_json::Value;
14
15use crate::schema::{ActionName, Shapes};
16
17/// The id of the `permit(principal, action, resource)` the server adds
18/// beside every document, so that a document's forbids are the only thing
19/// that can turn a request down. Reserved: a statement may not use it.
20pub const BASELINE_ID: &str = "baseline";
21
22/// How much a statement's scope says about `principal`, and therefore how
23/// much the gate must know about the account for the statement to be
24/// judged at all.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum PrincipalNeed {
27    /// `principal` is unconstrained.
28    Nothing,
29    /// `principal ==` or `principal is`: the account's identity.
30    Identity,
31    /// `principal in`: the account's admission chain.
32    Chain,
33}
34
35impl PrincipalNeed {
36    fn of(constraint: &PrincipalConstraint) -> Self {
37        match constraint {
38            PrincipalConstraint::Any => PrincipalNeed::Nothing,
39            PrincipalConstraint::Eq(_) | PrincipalConstraint::Is(_) => PrincipalNeed::Identity,
40            PrincipalConstraint::In(_) | PrincipalConstraint::IsIn(..) => PrincipalNeed::Chain,
41        }
42    }
43}
44
45impl std::fmt::Display for PrincipalNeed {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(match self {
48            PrincipalNeed::Nothing => "nothing about the account",
49            PrincipalNeed::Identity => "the account's identity",
50            PrincipalNeed::Chain => "the account's admission chain",
51        })
52    }
53}
54
55/// One loaded forbid.
56#[derive(Debug, Clone)]
57pub struct Statement {
58    id: String,
59    reason: String,
60    source: String,
61    actions: BTreeSet<ActionName>,
62    principal: PrincipalNeed,
63    collections: BTreeSet<String>,
64    pools: BTreeSet<String>,
65    reads_record: bool,
66    reads_client: bool,
67    policy: Policy,
68}
69
70impl Statement {
71    /// The statement's `@id`.
72    #[must_use]
73    pub fn id(&self) -> &str {
74        &self.id
75    }
76
77    /// The statement's `@reason`: what a caller is told when it fires.
78    #[must_use]
79    pub fn reason(&self) -> &str {
80        &self.reason
81    }
82
83    /// The statement's own text, annotations included, loadable on its own.
84    #[must_use]
85    pub fn source(&self) -> &str {
86        &self.source
87    }
88
89    /// The actions the statement's scope admits. A statement with an
90    /// unconstrained `action` admits every one.
91    #[must_use]
92    pub fn actions(&self) -> &BTreeSet<ActionName> {
93        &self.actions
94    }
95
96    /// What the statement's scope needs to know about the account.
97    #[must_use]
98    pub fn principal(&self) -> PrincipalNeed {
99        self.principal
100    }
101
102    /// The collections the statement compares `resource.collection` to.
103    /// Empty when it names none, which is not the same as applying to
104    /// none: applicability is the gate's business, this is only what the
105    /// loader can see.
106    #[must_use]
107    pub fn collections(&self) -> &BTreeSet<String> {
108        &self.collections
109    }
110
111    /// The pools the statement compares `resource.pool` to, by `==`, `!=`
112    /// or `[..].contains`.
113    #[must_use]
114    pub fn pools(&self) -> &BTreeSet<String> {
115        &self.pools
116    }
117
118    /// Whether the statement reads `context.value` or `context.existing`,
119    /// the two things only a collection's shape can type.
120    #[must_use]
121    pub fn reads_record(&self) -> bool {
122        self.reads_record
123    }
124
125    /// Whether the statement reads or tests `context.client`, the client
126    /// a `record.write` was made through.
127    #[must_use]
128    pub fn reads_client(&self) -> bool {
129        self.reads_client
130    }
131}
132
133/// Why one statement, or the document as a whole, did not load.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Failure {
136    /// The statement's `@id`, its position as `#n` when it has no id, or
137    /// `document` when the failure is not one statement's.
138    pub statement: String,
139    /// What was wrong.
140    pub reason: String,
141}
142
143impl std::fmt::Display for Failure {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        write!(f, "{}: {}", self.statement, self.reason)
146    }
147}
148
149/// A document that did not load. Every failure the loader found, so the
150/// dashboard shows all of them at once.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct LoadError {
153    /// Each failure, in document order.
154    pub failures: Vec<Failure>,
155}
156
157impl LoadError {
158    fn one(statement: impl Into<String>, reason: impl Into<String>) -> Self {
159        Self {
160            failures: vec![Failure {
161                statement: statement.into(),
162                reason: reason.into(),
163            }],
164        }
165    }
166
167    /// Whether some failure names `statement`.
168    #[must_use]
169    pub fn names(&self, statement: &str) -> bool {
170        self.failures.iter().any(|f| f.statement == statement)
171    }
172}
173
174impl std::fmt::Display for LoadError {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        for (index, failure) in self.failures.iter().enumerate() {
177            if index > 0 {
178                f.write_str("; ")?;
179            }
180            write!(f, "{failure}")?;
181        }
182        Ok(())
183    }
184}
185
186impl std::error::Error for LoadError {}
187
188/// A loaded document: its statements keyed by `@id`, and the policy set
189/// the authorizer runs, which is those statements plus the baseline permit.
190#[derive(Debug, Clone)]
191pub struct Document {
192    pub(crate) statements: Vec<Statement>,
193    pub(crate) set: PolicySet,
194    warnings: Vec<String>,
195}
196
197impl Document {
198    /// Load `source` against `shapes`.
199    ///
200    /// # Errors
201    ///
202    /// Every rule the document breaks, each naming its statement.
203    pub fn load(source: &str, shapes: &Shapes) -> Result<Self, LoadError> {
204        let parsed = PolicySet::from_str(source)
205            .map_err(|error| LoadError::one("document", format!("does not parse: {error}")))?;
206        let mut failures = Vec::new();
207        if parsed.templates().next().is_some() {
208            failures.push(Failure {
209                statement: "document".to_owned(),
210                reason: "holds a template; a document is static forbids only".to_owned(),
211            });
212        }
213
214        let mut seen = HashSet::new();
215        let mut statements = Vec::new();
216        for (position, policy) in parsed.policies().enumerate() {
217            let label = format!("#{}", position + 1);
218            let Some(id) = policy.annotation("id") else {
219                failures.push(Failure {
220                    statement: label,
221                    reason: "has no @id".to_owned(),
222                });
223                continue;
224            };
225            let id = id.to_owned();
226            if id == BASELINE_ID {
227                failures.push(Failure {
228                    statement: label,
229                    reason: format!("@id(\"{BASELINE_ID}\") is reserved for the server's permit"),
230                });
231                continue;
232            }
233            if !seen.insert(id.clone()) {
234                failures.push(Failure {
235                    statement: label,
236                    reason: format!("@id(\"{id}\") is already used by an earlier statement"),
237                });
238                continue;
239            }
240            if policy.effect() == Effect::Permit {
241                failures.push(Failure {
242                    statement: id,
243                    reason: "is a permit; a document may only forbid".to_owned(),
244                });
245                continue;
246            }
247            let Some(reason) = policy.annotation("reason") else {
248                failures.push(Failure {
249                    statement: id,
250                    reason: "has no @reason".to_owned(),
251                });
252                continue;
253            };
254            let actions = match actions_of(&policy.action_constraint()) {
255                Ok(actions) => actions,
256                Err(unknown) => {
257                    failures.push(Failure {
258                        statement: id,
259                        reason: format!(
260                            "names `{unknown}`, which is not one of this server's actions"
261                        ),
262                    });
263                    continue;
264                }
265            };
266            let Some(source) = policy.to_cedar() else {
267                failures.push(Failure {
268                    statement: id,
269                    reason: "has no source text of its own".to_owned(),
270                });
271                continue;
272            };
273            let policy = policy.new_id(PolicyId::new(&id));
274            let mut collections = BTreeSet::new();
275            let mut pools = BTreeSet::new();
276            let mut reads_record = false;
277            let mut reads_client = false;
278            if let Ok(est) = policy.to_json() {
279                compared_to(&est, "collection", &["=="], &mut collections);
280                compared_to(&est, "pool", &["==", "!="], &mut pools);
281                reads_record = reads_context(&est, &["value", "existing"]);
282                reads_client = reads_context(&est, &["client"]);
283            }
284            statements.push(Statement {
285                reason: reason.to_owned(),
286                source,
287                actions,
288                principal: PrincipalNeed::of(&policy.principal_constraint()),
289                collections,
290                pools,
291                reads_record,
292                reads_client,
293                policy,
294                id,
295            });
296        }
297        if !failures.is_empty() {
298            return Err(LoadError { failures });
299        }
300
301        let mut warnings = Vec::new();
302        for statement in &statements {
303            let alone = PolicySet::from_policies([statement.policy.clone()])
304                .expect("a set of one freshly relabelled policy");
305            for collection in unvalidated_for(statement, shapes) {
306                warnings.push(format!(
307                    "{}: unvalidated for {collection}; a field it reads there is checked \
308                     at the first write, not here",
309                    statement.id
310                ));
311            }
312            for (collection, validator) in validation_targets(statement, shapes) {
313                let result = validator.validate(&alone, ValidationMode::Strict);
314                let against = collection.map_or(String::new(), |c| format!("for `{c}`: "));
315                for error in result.validation_errors() {
316                    failures.push(Failure {
317                        statement: statement.id.clone(),
318                        reason: format!("{against}{error}"),
319                    });
320                }
321                for warning in result.validation_warnings() {
322                    warnings.push(format!("{}: {against}{warning}", statement.id));
323                }
324            }
325        }
326        if !failures.is_empty() {
327            return Err(LoadError { failures });
328        }
329
330        let mut set = PolicySet::from_policies(statements.iter().map(|s| s.policy.clone()))
331            .expect("statement ids are unique");
332        let baseline = Policy::parse(
333            Some(PolicyId::new(BASELINE_ID)),
334            "permit(principal, action, resource);",
335        )
336        .expect("the baseline permit is this crate's own text");
337        set.add(baseline)
338            .expect("no statement may use the baseline id");
339        tracing::debug!(
340            statements = statements.len(),
341            warnings = warnings.len(),
342            "loaded a cedar document"
343        );
344        Ok(Self {
345            statements,
346            set,
347            warnings,
348        })
349    }
350
351    /// The statements, in document order.
352    #[must_use]
353    pub fn statements(&self) -> &[Statement] {
354        &self.statements
355    }
356
357    /// What the validator had to say without refusing the document.
358    #[must_use]
359    pub fn warnings(&self) -> &[String] {
360        &self.warnings
361    }
362}
363
364/// The schemas a statement is validated against, each labelled with the
365/// collection whose record type it carries (`None` for the base schema).
366///
367/// A statement that can reach `record.write` is checked against every
368/// described collection it names, or every described collection when it
369/// names none. One that reaches no described collection, or cannot reach
370/// `record.write` at all, is checked against the base schema, where no
371/// record type is in play — unless it reads the record, which the base
372/// schema cannot type; then nothing checks it, and [`unvalidated_for`]
373/// says so.
374fn validation_targets<'a>(
375    statement: &'a Statement,
376    shapes: &'a Shapes,
377) -> Vec<(Option<&'a str>, &'a cedar_policy::Validator)> {
378    let described: Vec<(Option<&str>, &cedar_policy::Validator)> =
379        if !statement.actions.contains(&ActionName::RecordWrite) {
380            Vec::new()
381        } else if statement.collections.is_empty() {
382            shapes
383                .collections()
384                .filter_map(|c| shapes.validator_for(c).map(|v| (Some(c), v)))
385                .collect()
386        } else {
387            statement
388                .collections
389                .iter()
390                .filter_map(|c| shapes.validator_for(c).map(|v| (Some(c.as_str()), v)))
391                .collect()
392        };
393    if described.is_empty() && !statement.reads_record {
394        return vec![(None, shapes.base())];
395    }
396    described
397}
398
399/// The collections a statement reads records of without a shape to check
400/// them against, as the warning names them: each undescribed collection it
401/// names, or "any collection without a shape" when it names none.
402fn unvalidated_for(statement: &Statement, shapes: &Shapes) -> Vec<String> {
403    if !statement.reads_record || !statement.actions.contains(&ActionName::RecordWrite) {
404        return Vec::new();
405    }
406    if statement.collections.is_empty() {
407        return vec!["any collection without a shape".to_owned()];
408    }
409    statement
410        .collections
411        .iter()
412        .filter(|c| shapes.shape_of(c).is_none())
413        .map(|c| format!("`{c}`"))
414        .collect()
415}
416
417fn actions_of(constraint: &ActionConstraint) -> Result<BTreeSet<ActionName>, String> {
418    let named: Vec<&cedar_policy::EntityUid> = match constraint {
419        ActionConstraint::Any => return Ok(ActionName::ALL.into_iter().collect()),
420        ActionConstraint::Eq(uid) => vec![uid],
421        ActionConstraint::In(uids) => uids.iter().collect(),
422    };
423    named
424        .into_iter()
425        .map(|uid| ActionName::of_uid(uid).ok_or_else(|| uid.to_string()))
426        .collect()
427}
428
429/// Collect every string literal the expression compares `resource.<attr>`
430/// to, by one of `ops` on either side or by `[..].contains(resource.<attr>)`.
431fn compared_to(est: &Value, attr: &str, ops: &[&str], out: &mut BTreeSet<String>) {
432    match est {
433        Value::Object(map) => {
434            for op in ops {
435                let Some(args) = map.get(*op) else { continue };
436                for (subject, literal) in [("left", "right"), ("right", "left")] {
437                    if args.get(subject).is_some_and(|e| is_resource_attr(e, attr)) {
438                        if let Some(name) = args.get(literal).and_then(string_literal) {
439                            out.insert(name.to_owned());
440                        }
441                    }
442                }
443            }
444            if let Some(args) = map.get("contains") {
445                if args.get("right").is_some_and(|e| is_resource_attr(e, attr)) {
446                    if let Some(Value::Array(items)) = args.get("left").and_then(|s| s.get("Set")) {
447                        out.extend(items.iter().filter_map(string_literal).map(str::to_owned));
448                    }
449                }
450            }
451            for child in map.values() {
452                compared_to(child, attr, ops, out);
453            }
454        }
455        Value::Array(items) => {
456            for item in items {
457                compared_to(item, attr, ops, out);
458            }
459        }
460        _ => {}
461    }
462}
463
464/// Whether the expression reads or tests any of `attrs` on `context`.
465fn reads_context(est: &Value, attrs: &[&str]) -> bool {
466    match est {
467        Value::Object(map) => {
468            for key in [".", "has"] {
469                if let Some(access) = map.get(key) {
470                    let on_context = access
471                        .get("left")
472                        .and_then(|v| v.get("Var"))
473                        .and_then(Value::as_str)
474                        == Some("context");
475                    let attr = access.get("attr").and_then(Value::as_str);
476                    if on_context && attr.is_some_and(|attr| attrs.contains(&attr)) {
477                        return true;
478                    }
479                }
480            }
481            map.values().any(|child| reads_context(child, attrs))
482        }
483        Value::Array(items) => items.iter().any(|item| reads_context(item, attrs)),
484        _ => false,
485    }
486}
487
488fn is_resource_attr(expr: &Value, attr: &str) -> bool {
489    let Some(access) = expr.get(".") else {
490        return false;
491    };
492    access
493        .get("left")
494        .and_then(|v| v.get("Var"))
495        .and_then(Value::as_str)
496        == Some("resource")
497        && access.get("attr").and_then(Value::as_str) == Some(attr)
498}
499
500fn string_literal(expr: &Value) -> Option<&str> {
501    expr.get("Value")?.as_str()
502}