1#![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#[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 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
74pub 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
86pub 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#[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 #[serde(default)]
107 pub validation_settings: ValidationSettings,
108 #[cfg_attr(feature = "wasm", tsify(type = "Schema"))]
110 pub schema: Schema,
111 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#[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 mode: ValidationMode,
158}
159
160#[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 #[cfg_attr(feature = "wasm", tsify(type = "string"))]
169 pub policy_id: PolicyId,
170 pub error: DetailedError,
174}
175
176#[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 #[serde(rename_all = "camelCase")]
185 Failure {
186 errors: Vec<DetailedError>,
188 warnings: Vec<DetailedError>,
190 },
191 #[serde(rename_all = "camelCase")]
193 Success {
194 validation_errors: Vec<ValidationError>,
196 validation_warnings: Vec<ValidationError>,
198 other_warnings: Vec<DetailedError>,
201 },
202}
203
204#[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 #[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 #[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(_)); assert_matches!(ans_val.get("validationWarnings"), Some(_)); 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 #[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 #[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 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 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 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}