cedar_policy/ffi/
validate.rs

1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! JSON FFI entry points for the Cedar validator. The Cedar Wasm validator is
18//! generated from the [`validate()`] function in this file.
19
20#![allow(clippy::module_name_repetitions)]
21use super::utils::{DetailedError, PolicySet, Schema, WithWarnings};
22use crate::{PolicyId, ValidationMode, Validator};
23use serde::{Deserialize, Serialize};
24#[cfg(feature = "wasm")]
25use wasm_bindgen::prelude::wasm_bindgen;
26
27#[cfg(feature = "wasm")]
28extern crate tsify;
29
30/// Parse a policy set and optionally validate it against a provided schema
31///
32/// This is the basic validator interface, using [`ValidationCall`] and
33/// [`ValidationAnswer`] types
34#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "validate"))]
35pub fn validate(call: ValidationCall) -> ValidationAnswer {
36    match call.get_components() {
37        WithWarnings {
38            t: Ok((policies, schema, settings)),
39            warnings,
40        } => {
41            // otherwise, call `Validator::validate`
42            let validator = Validator::new(schema);
43            let (validation_errors, validation_warnings) = validator
44                .validate(&policies, settings.mode)
45                .into_errors_and_warnings();
46            let validation_errors: Vec<ValidationError> = validation_errors
47                .map(|error| ValidationError {
48                    policy_id: error.policy_id().clone(),
49                    error: miette::Report::new(error).into(),
50                })
51                .collect();
52            let validation_warnings: Vec<ValidationError> = validation_warnings
53                .map(|error| ValidationError {
54                    policy_id: error.policy_id().clone(),
55                    error: miette::Report::new(error).into(),
56                })
57                .collect();
58            ValidationAnswer::Success {
59                validation_errors,
60                validation_warnings,
61                other_warnings: warnings.into_iter().map(Into::into).collect(),
62            }
63        }
64        WithWarnings {
65            t: Err(errors),
66            warnings,
67        } => ValidationAnswer::Failure {
68            errors: errors.into_iter().map(Into::into).collect(),
69            warnings: warnings.into_iter().map(Into::into).collect(),
70        },
71    }
72}
73
74/// Input is a JSON encoding of [`ValidationCall`] and output is a JSON
75/// encoding of [`ValidationAnswer`]
76///
77/// # Errors
78///
79/// Will return `Err` if the input JSON cannot be deserialized as a
80/// [`ValidationCall`].
81pub fn validate_json(json: serde_json::Value) -> Result<serde_json::Value, serde_json::Error> {
82    let ans = validate(serde_json::from_value(json)?);
83    serde_json::to_value(ans)
84}
85
86/// Input and output are strings containing serialized JSON, in the shapes
87/// expected by [`validate_json()`]
88///
89/// # Errors
90///
91/// Will return `Err` if the input cannot be converted to valid JSON or
92/// deserialized as a [`ValidationCall`].
93pub fn validate_json_str(json: &str) -> Result<String, serde_json::Error> {
94    let ans = validate(serde_json::from_str(json)?);
95    serde_json::to_string(&ans)
96}
97
98/// Struct containing the input data for validation
99#[derive(Serialize, Deserialize, Debug)]
100#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
101#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
102#[serde(rename_all = "camelCase")]
103#[serde(deny_unknown_fields)]
104pub struct ValidationCall {
105    /// Validation settings
106    #[serde(default)]
107    pub validation_settings: ValidationSettings,
108    /// Schema to use for validation
109    #[cfg_attr(feature = "wasm", tsify(type = "Schema"))]
110    pub schema: Schema,
111    /// Policies to validate
112    pub policies: PolicySet,
113}
114
115impl ValidationCall {
116    fn get_components(
117        self,
118    ) -> WithWarnings<
119        Result<(crate::PolicySet, crate::Schema, ValidationSettings), Vec<miette::Report>>,
120    > {
121        let mut errs = vec![];
122        let policies = match self.policies.parse() {
123            Ok(policies) => policies,
124            Err(e) => {
125                errs.extend(e);
126                crate::PolicySet::new()
127            }
128        };
129        let pair = match self.schema.parse() {
130            Ok((schema, warnings)) => Some((schema, warnings)),
131            Err(e) => {
132                errs.push(e);
133                None
134            }
135        };
136        match (errs.is_empty(), pair) {
137            (true, Some((schema, warnings))) => WithWarnings {
138                t: Ok((policies, schema, self.validation_settings)),
139                warnings: warnings.map(miette::Report::new).collect(),
140            },
141            _ => WithWarnings {
142                t: Err(errs),
143                warnings: vec![],
144            },
145        }
146    }
147}
148
149/// Configuration for the validation call
150#[derive(Serialize, Deserialize, Debug, Default)]
151#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
152#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
153#[serde(rename_all = "camelCase")]
154#[serde(deny_unknown_fields)]
155pub struct ValidationSettings {
156    /// Used to control how a policy is validated. See comments on [`ValidationMode`].
157    mode: ValidationMode,
158}
159
160/// Error (or warning) for a specified policy after validation
161#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
162#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
163#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
164#[serde(rename_all = "camelCase")]
165#[serde(deny_unknown_fields)]
166pub struct ValidationError {
167    /// Id of the policy where the error (or warning) occurred
168    #[cfg_attr(feature = "wasm", tsify(type = "string"))]
169    pub policy_id: PolicyId,
170    /// Error (or warning) itself.
171    /// You can look at the `severity` field to see whether it is actually an
172    /// error or a warning.
173    pub error: DetailedError,
174}
175
176/// Result struct for validation
177#[derive(Debug, Serialize, Deserialize)]
178#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
179#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
180#[serde(tag = "type")]
181#[serde(rename_all = "camelCase")]
182pub enum ValidationAnswer {
183    /// Represents a failure to parse or call the validator
184    #[serde(rename_all = "camelCase")]
185    Failure {
186        /// Parsing errors
187        errors: Vec<DetailedError>,
188        /// Warnings encountered
189        warnings: Vec<DetailedError>,
190    },
191    /// Represents a successful validation call
192    #[serde(rename_all = "camelCase")]
193    Success {
194        /// Errors from any issues found during validation
195        validation_errors: Vec<ValidationError>,
196        /// Warnings from any issues found during validation
197        validation_warnings: Vec<ValidationError>,
198        /// Other warnings, not associated with specific policies.
199        /// For instance, warnings about your schema itself.
200        other_warnings: Vec<DetailedError>,
201    },
202}
203
204// PANIC SAFETY unit tests
205#[allow(clippy::panic, clippy::indexing_slicing)]
206#[cfg(test)]
207mod test {
208    use super::*;
209
210    use crate::ffi::test_utils::*;
211    use cool_asserts::assert_matches;
212    use serde_json::json;
213
214    /// Assert that [`validate_json()`] returns [`ValidationAnswer::Success`]
215    /// with no errors
216    #[track_caller]
217    fn assert_validates_without_errors(json: serde_json::Value) {
218        let ans_val = validate_json(json).unwrap();
219        let result: Result<ValidationAnswer, _> = serde_json::from_value(ans_val);
220        assert_matches!(result, Ok(ValidationAnswer::Success { validation_errors, validation_warnings: _, other_warnings: _ }) => {
221            assert_eq!(validation_errors.len(), 0, "Unexpected validation errors: {validation_errors:?}");
222        });
223    }
224
225    /// Assert that [`validate_json()`] returns [`ValidationAnswer::Success`]
226    /// and return the enclosed errors
227    #[track_caller]
228    fn assert_validates_with_errors(json: serde_json::Value) -> Vec<ValidationError> {
229        let ans_val = validate_json(json).unwrap();
230        assert_matches!(ans_val.get("validationErrors"), Some(_)); // should be present, with this camelCased name
231        assert_matches!(ans_val.get("validationWarnings"), Some(_)); // should be present, with this camelCased name
232        let result: Result<ValidationAnswer, _> = serde_json::from_value(ans_val);
233        assert_matches!(result, Ok(ValidationAnswer::Success { validation_errors, validation_warnings: _, other_warnings: _ }) => {
234            validation_errors
235        })
236    }
237
238    /// Assert that [`validate_json_str()`] returns a `serde_json::Error`
239    /// error with a message that matches `msg`
240    #[track_caller]
241    fn assert_validate_json_str_is_failure(call: &str, msg: &str) {
242        assert_matches!(validate_json_str(call), Err(e) => {
243            assert_eq!(e.to_string(), msg);
244        });
245    }
246
247    /// Assert that [`validate_json()`] returns [`ValidationAnswer::Failure`]
248    /// and return the enclosed errors
249    #[track_caller]
250    fn assert_is_failure(json: serde_json::Value) -> Vec<DetailedError> {
251        let ans_val =
252            validate_json(json).expect("expected it to at least parse into ValidationCall");
253        let result: Result<ValidationAnswer, _> = serde_json::from_value(ans_val);
254        assert_matches!(result, Ok(ValidationAnswer::Failure { errors, .. }) => errors)
255    }
256
257    #[test]
258    fn test_validate_empty_policy() {
259        let call = ValidationCall {
260            validation_settings: ValidationSettings::default(),
261            schema: Schema::Json(json!({}).into()),
262            policies: PolicySet::new(),
263        };
264
265        assert_validates_without_errors(serde_json::to_value(&call).unwrap());
266
267        let call = ValidationCall {
268            validation_settings: ValidationSettings::default(),
269            schema: Schema::Cedar(String::new()),
270            policies: PolicySet::new(),
271        };
272
273        assert_validates_without_errors(serde_json::to_value(&call).unwrap());
274
275        let call = json!({
276            "schema": {},
277            "policies": {}
278        });
279
280        assert_validates_without_errors(call);
281    }
282
283    #[test]
284    fn test_nontrivial_correct_policy_validates_without_errors() {
285        let json = json!({
286        "schema": { "": {
287          "entityTypes": {
288            "User": {
289              "memberOfTypes": [ "UserGroup" ]
290            },
291            "Photo": {
292              "memberOfTypes": [ "Album", "Account" ]
293            },
294            "Album": {
295              "memberOfTypes": [ "Album", "Account" ]
296            },
297            "Account": { },
298            "UserGroup": {}
299          },
300          "actions": {
301            "readOnly": { },
302            "readWrite": { },
303            "createAlbum": {
304              "appliesTo": {
305                "resourceTypes": [ "Account", "Album" ],
306                "principalTypes": [ "User" ]
307              }
308            },
309            "addPhotoToAlbum": {
310              "appliesTo": {
311                "resourceTypes": [ "Album" ],
312                "principalTypes": [ "User" ]
313              }
314            },
315            "viewPhoto": {
316              "appliesTo": {
317                "resourceTypes": [ "Photo" ],
318                "principalTypes": [ "User" ]
319              }
320            },
321            "viewComments": {
322              "appliesTo": {
323                "resourceTypes": [ "Photo" ],
324                "principalTypes": [ "User" ]
325              }
326            }
327          }
328        }},
329        "policies": {
330          "staticPolicies": {
331            "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource);"
332          }
333        }});
334
335        assert_validates_without_errors(json);
336    }
337
338    #[test]
339    fn test_policy_with_parse_error_fails_passing_on_errors() {
340        let json = json!({
341            "schema": { "": {
342                "entityTypes": {},
343                "actions": {}
344            }},
345            "policies": {
346                "staticPolicies": {
347                  "policy0": "azfghbjknnhbud"
348                }
349            }
350        });
351
352        let errs = assert_is_failure(json);
353        assert_exactly_one_error(
354            &errs,
355            "failed to parse policy with id `policy0` from string: unexpected end of input",
356            None,
357        );
358    }
359
360    #[test]
361    fn test_semantically_incorrect_policy_fails_with_errors() {
362        let json = json!({
363        "schema": { "": {
364          "entityTypes": {
365            "User": {
366              "memberOfTypes": [ ]
367            },
368            "Photo": {
369              "memberOfTypes": [ ]
370            }
371          },
372          "actions": {
373            "viewPhoto": {
374              "appliesTo": {
375                "resourceTypes": [ "Photo" ],
376                "principalTypes": [ "User" ]
377              }
378            }
379          }
380        }},
381        "policies": {
382          "staticPolicies": {
383            "policy0": "permit(principal == Photo::\"photo.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice\");",
384            "policy1": "permit(principal == Photo::\"photo2.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice2\");"
385          }
386        }});
387
388        let errs = assert_validates_with_errors(json);
389        assert_length_matches(&errs, 2);
390        for err in errs {
391            if err.policy_id == PolicyId::new("policy0") {
392                assert_error_matches(
393                    &err.error,
394                    "for policy `policy0`, unable to find an applicable action given the policy scope constraints",
395                    None
396                );
397            } else if err.policy_id == PolicyId::new("policy1") {
398                assert_error_matches(
399                    &err.error,
400                    "for policy `policy1`, unable to find an applicable action given the policy scope constraints",
401                    None
402                );
403            } else {
404                panic!("unexpected validation error: {err:?}");
405            }
406        }
407    }
408
409    #[test]
410    fn test_nontrivial_correct_policy_validates_without_errors_concatenated_policies() {
411        let json = json!({
412        "schema": { "": {
413          "entityTypes": {
414            "User": {
415              "memberOfTypes": [ "UserGroup" ]
416            },
417            "Photo": {
418              "memberOfTypes": [ "Album", "Account" ]
419            },
420            "Album": {
421              "memberOfTypes": [ "Album", "Account" ]
422            },
423            "Account": { },
424            "UserGroup": {}
425          },
426          "actions": {
427            "readOnly": {},
428            "readWrite": {},
429            "createAlbum": {
430              "appliesTo": {
431                "resourceTypes": [ "Account", "Album" ],
432                "principalTypes": [ "User" ]
433              }
434            },
435            "addPhotoToAlbum": {
436              "appliesTo": {
437                "resourceTypes": [ "Album" ],
438                "principalTypes": [ "User" ]
439              }
440            },
441            "viewPhoto": {
442              "appliesTo": {
443                "resourceTypes": [ "Photo" ],
444                "principalTypes": [ "User" ]
445              }
446            },
447            "viewComments": {
448              "appliesTo": {
449                "resourceTypes": [ "Photo" ],
450                "principalTypes": [ "User" ]
451              }
452            }
453          }
454        }},
455        "policies": {
456          "staticPolicies": {
457            "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource);"
458          }
459        }
460        });
461
462        assert_validates_without_errors(json);
463    }
464
465    #[test]
466    fn test_policy_with_parse_error_fails_passing_on_errors_concatenated_policies() {
467        let json = json!({
468            "schema": { "": {
469                "entityTypes": {},
470                "actions": {}
471            }},
472            "policies": {
473              "staticPolicies": "azfghbjknnhbud"
474            }
475        });
476
477        let errs = assert_is_failure(json);
478        assert_exactly_one_error(
479            &errs,
480            "failed to parse policies from string: unexpected end of input",
481            None,
482        );
483    }
484
485    #[test]
486    fn test_semantically_incorrect_policy_fails_with_errors_concatenated_policies() {
487        let json = json!({
488          "schema": { "": {
489            "entityTypes": {
490              "User": {
491                "memberOfTypes": [ ]
492              },
493              "Photo": {
494                "memberOfTypes": [ ]
495              }
496            },
497            "actions": {
498              "viewPhoto": {
499                "appliesTo": {
500                  "resourceTypes": [ "Photo" ],
501                  "principalTypes": [ "User" ]
502                }
503              }
504            }
505          }},
506          "policies": {
507            "staticPolicies": "forbid(principal, action, resource);permit(principal == Photo::\"photo.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice\");"
508          }
509        });
510
511        let errs = assert_validates_with_errors(json);
512        assert_length_matches(&errs, 1);
513        assert_eq!(errs[0].policy_id, PolicyId::new("policy1"));
514        assert_error_matches(
515            &errs[0].error,
516            "for policy `policy1`, unable to find an applicable action given the policy scope constraints",
517            None
518        );
519    }
520
521    #[test]
522    fn test_policy_with_parse_error_fails_concatenated_policies() {
523        let json = json!({
524            "schema": { "": {
525                "entityTypes": {},
526                "actions": {}
527            }},
528            "policies": {
529              "staticPolicies": "permit(principal, action, resource);forbid"
530            }
531        });
532
533        let errs = assert_is_failure(json);
534        assert_exactly_one_error(
535            &errs,
536            "failed to parse policies from string: unexpected end of input",
537            None,
538        );
539    }
540
541    #[test]
542    fn test_bad_call_format_fails() {
543        assert_matches!(validate_json(json!("uerfheriufheiurfghtrg")), Err(e) => {
544            assert!(e.to_string().contains("invalid type: string \"uerfheriufheiurfghtrg\", expected struct ValidationCall"), "actual error message was {e}");
545        });
546    }
547
548    #[test]
549    fn test_validate_fails_on_duplicate_namespace() {
550        let text = r#"{
551            "schema": {
552              "foo": { "entityTypes": {}, "actions": {} },
553              "foo": { "entityTypes": {}, "actions": {} }
554            },
555            "policies": {}
556        }"#;
557
558        assert_validate_json_str_is_failure(
559            text,
560            "expected a schema in the Cedar or JSON policy format (with no duplicate keys) at line 5 column 13",
561        );
562    }
563
564    #[test]
565    fn test_validate_fails_on_duplicate_policy_id() {
566        let text = r#"{
567            "schema": { "": { "entityTypes": {}, "actions": {} } },
568            "policies": {
569              "staticPolicies": {
570                "ID0": "permit(principal, action, resource);",
571                "ID0": "permit(principal, action, resource);"
572              }
573            }
574        }"#;
575
576        assert_validate_json_str_is_failure(
577            text,
578            "expected a static policy set represented by a string, JSON array, or JSON object (with no duplicate keys) at line 8 column 13",
579        );
580    }
581
582    #[test]
583    fn test_validate_with_templates() {
584        // Successful validation with templates and template links
585        let json = json!({
586            "schema": "entity User, Photo; action viewPhoto appliesTo { principal: User, resource: Photo };",
587            "policies": {
588              "staticPolicies": {
589                "ID0": "permit(principal == User::\"alice\", action, resource);"
590              },
591              "templates": {
592                "ID1": "permit(principal == ?principal, action, resource);"
593              },
594              "templateLinks": [{
595                "templateId": "ID1",
596                "newId": "ID2",
597                "values": {
598                    "?principal": { "type": "User", "id": "bob" }
599                }
600              }]
601            }
602        });
603        assert_validates_without_errors(json);
604
605        // Validation fails due to bad template
606        let json = json!({
607            "schema": "entity User, Photo; action viewPhoto appliesTo { principal: User, resource: Photo };",
608            "policies": {
609              "staticPolicies": {
610                "ID0": "permit(principal == User::\"alice\", action, resource);"
611              },
612              "templates": {
613                "ID1": "permit(principal == ?principal, action == Action::\"foo\", resource);"
614              },
615              "templateLinks": [{
616                "templateId": "ID1",
617                "newId": "ID2",
618                "values": {
619                    "?principal": { "type": "User", "id": "bob" }
620                }
621              }]
622            }
623        });
624        let errs = assert_validates_with_errors(json);
625        assert_length_matches(&errs, 3);
626        for err in errs {
627            if err.policy_id == PolicyId::new("ID1") {
628                if err.error.message.contains("unrecognized action") {
629                    assert_error_matches(
630                        &err.error,
631                        "for policy `ID1`, unrecognized action `Action::\"foo\"`",
632                        Some("did you mean `Action::\"viewPhoto\"`?"),
633                    );
634                } else {
635                    assert_error_matches(
636                        &err.error,
637                        "for policy `ID1`, unable to find an applicable action given the policy scope constraints",
638                        None,
639                    );
640                }
641            } else if err.policy_id == PolicyId::new("ID2") {
642                assert_error_matches(
643                    &err.error,
644                    "for policy `ID2`, unable to find an applicable action given the policy scope constraints",
645                    None,
646                );
647            } else {
648                panic!("unexpected validation error: {err:?}");
649            }
650        }
651
652        // Validation fails due to bad link
653        let json = json!({
654            "schema": "entity User, Photo; action viewPhoto appliesTo { principal: User, resource: Photo };",
655            "policies": {
656              "staticPolicies": {
657                "ID0": "permit(principal == User::\"alice\", action, resource);"
658              },
659              "templates": {
660                "ID1": "permit(principal == ?principal, action, resource);"
661              },
662              "templateLinks": [{
663                "templateId": "ID1",
664                "newId": "ID2",
665                "values": {
666                    "?principal": { "type": "Photo", "id": "bob" }
667                }
668              }]
669            }
670        });
671        let errs = assert_validates_with_errors(json);
672        assert_length_matches(&errs, 1);
673        assert_eq!(errs[0].policy_id, PolicyId::new("ID2"));
674        assert_error_matches(
675            &errs[0].error,
676            "for policy `ID2`, unable to find an applicable action given the policy scope constraints",
677            None
678        );
679    }
680}