1use 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
17pub const BASELINE_ID: &str = "baseline";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum PrincipalNeed {
27 Nothing,
29 Identity,
31 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#[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 #[must_use]
73 pub fn id(&self) -> &str {
74 &self.id
75 }
76
77 #[must_use]
79 pub fn reason(&self) -> &str {
80 &self.reason
81 }
82
83 #[must_use]
85 pub fn source(&self) -> &str {
86 &self.source
87 }
88
89 #[must_use]
92 pub fn actions(&self) -> &BTreeSet<ActionName> {
93 &self.actions
94 }
95
96 #[must_use]
98 pub fn principal(&self) -> PrincipalNeed {
99 self.principal
100 }
101
102 #[must_use]
107 pub fn collections(&self) -> &BTreeSet<String> {
108 &self.collections
109 }
110
111 #[must_use]
114 pub fn pools(&self) -> &BTreeSet<String> {
115 &self.pools
116 }
117
118 #[must_use]
121 pub fn reads_record(&self) -> bool {
122 self.reads_record
123 }
124
125 #[must_use]
128 pub fn reads_client(&self) -> bool {
129 self.reads_client
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Failure {
136 pub statement: String,
139 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#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct LoadError {
153 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 #[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#[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 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 #[must_use]
353 pub fn statements(&self) -> &[Statement] {
354 &self.statements
355 }
356
357 #[must_use]
359 pub fn warnings(&self) -> &[String] {
360 &self.warnings
361 }
362}
363
364fn 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
399fn 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
429fn 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
464fn 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}