cedar_policy/ffi/
convert.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 converting between JSON and Cedar formats. The
18//! Cedar Wasm conversion functions are generated from the functions in this
19//! file.
20
21use super::utils::JsonValueWithNoDuplicateKeys;
22use super::{DetailedError, Policy, Schema, Template};
23use crate::api::{PolicySet, StringifiedPolicySet};
24use serde::{Deserialize, Serialize};
25use std::str::FromStr;
26#[cfg(feature = "wasm")]
27use wasm_bindgen::prelude::wasm_bindgen;
28
29#[cfg(feature = "wasm")]
30extern crate tsify;
31
32/// Takes a PolicySet represented as string and return the policies
33/// and templates split into vecs and sorted by id.
34#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "policySetTextToParts"))]
35pub fn policy_set_text_to_parts(policyset_str: &str) -> PolicySetTextToPartsAnswer {
36    let parsed_ps: Result<PolicySet, _> = PolicySet::from_str(policyset_str);
37    match parsed_ps {
38        Ok(policy_set) => {
39            if let Some(StringifiedPolicySet {
40                policies,
41                policy_templates,
42            }) = policy_set.stringify()
43            {
44                PolicySetTextToPartsAnswer::Success {
45                    policies,
46                    policy_templates,
47                }
48            } else {
49                // This should never happen due to the nature of the input but we cover it
50                // just in case, to future-proof the interface
51                PolicySetTextToPartsAnswer::Failure {
52                    errors: vec![DetailedError::from_str(
53                        "Policy set input contained template linked policies",
54                    )
55                    .unwrap_or_default()],
56                }
57            }
58        }
59        Err(e) => PolicySetTextToPartsAnswer::Failure {
60            errors: vec![(&e).into()],
61        },
62    }
63}
64
65/// Return the Cedar (textual) representation of a policy.
66#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "policyToText"))]
67pub fn policy_to_text(policy: Policy) -> PolicyToTextAnswer {
68    match policy.parse(None) {
69        Ok(policy) => PolicyToTextAnswer::Success {
70            text: policy.to_string(),
71        },
72        Err(e) => PolicyToTextAnswer::Failure {
73            errors: vec![e.into()],
74        },
75    }
76}
77
78/// Return the Cedar (textual) representation of a template.
79#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "templateToText"))]
80pub fn template_to_text(template: Template) -> PolicyToTextAnswer {
81    match template.parse(None) {
82        Ok(template) => PolicyToTextAnswer::Success {
83            text: template.to_string(),
84        },
85        Err(e) => PolicyToTextAnswer::Failure {
86            errors: vec![e.into()],
87        },
88    }
89}
90
91/// Return the JSON representation of a policy.
92#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "policyToJson"))]
93pub fn policy_to_json(policy: Policy) -> PolicyToJsonAnswer {
94    match policy.parse(None) {
95        Ok(policy) => match policy.to_json() {
96            Ok(json) => PolicyToJsonAnswer::Success { json: json.into() },
97            Err(e) => PolicyToJsonAnswer::Failure {
98                errors: vec![miette::Report::new(e).into()],
99            },
100        },
101        Err(e) => PolicyToJsonAnswer::Failure {
102            errors: vec![e.into()],
103        },
104    }
105}
106
107/// Return the JSON representation of a template.
108#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "templateToJson"))]
109pub fn template_to_json(template: Template) -> PolicyToJsonAnswer {
110    match template.parse(None) {
111        Ok(template) => match template.to_json() {
112            Ok(json) => PolicyToJsonAnswer::Success { json: json.into() },
113            Err(e) => PolicyToJsonAnswer::Failure {
114                errors: vec![miette::Report::new(e).into()],
115            },
116        },
117        Err(e) => PolicyToJsonAnswer::Failure {
118            errors: vec![e.into()],
119        },
120    }
121}
122
123/// Return the Cedar (textual) representation of a schema.
124#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "schemaToText"))]
125pub fn schema_to_text(schema: Schema) -> SchemaToTextAnswer {
126    match schema.parse_schema_fragment() {
127        Ok((schema_frag, warnings)) => {
128            match schema_frag.to_cedarschema() {
129                Ok(text) => {
130                    // Before returning, check that the schema fragment corresponds to a valid schema
131                    if let Err(e) = TryInto::<crate::Schema>::try_into(schema_frag) {
132                        SchemaToTextAnswer::Failure {
133                            errors: vec![miette::Report::new(e).into()],
134                        }
135                    } else {
136                        SchemaToTextAnswer::Success {
137                            text,
138                            warnings: warnings.map(|e| miette::Report::new(e).into()).collect(),
139                        }
140                    }
141                }
142                Err(e) => SchemaToTextAnswer::Failure {
143                    errors: vec![miette::Report::new(e).into()],
144                },
145            }
146        }
147        Err(e) => SchemaToTextAnswer::Failure {
148            errors: vec![e.into()],
149        },
150    }
151}
152
153/// Return the JSON representation of a schema.
154#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "schemaToJson"))]
155pub fn schema_to_json(schema: Schema) -> SchemaToJsonAnswer {
156    match schema.parse_schema_fragment() {
157        Ok((schema_frag, warnings)) => match schema_frag.to_json_value() {
158            Ok(json) => {
159                // Before returning, check that the schema fragment corresponds to a valid schema
160                if let Err(e) = crate::Schema::from_json_value(json.clone()) {
161                    SchemaToJsonAnswer::Failure {
162                        errors: vec![miette::Report::new(e).into()],
163                    }
164                } else {
165                    SchemaToJsonAnswer::Success {
166                        json: json.into(),
167                        warnings: warnings.map(|e| miette::Report::new(e).into()).collect(),
168                    }
169                }
170            }
171            Err(e) => SchemaToJsonAnswer::Failure {
172                errors: vec![miette::Report::new(e).into()],
173            },
174        },
175        Err(e) => SchemaToJsonAnswer::Failure {
176            errors: vec![e.into()],
177        },
178    }
179}
180
181/// Result of converting a policy or template to the Cedar format
182#[derive(Debug, Serialize, Deserialize)]
183#[serde(tag = "type")]
184#[serde(rename_all = "camelCase")]
185#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
186#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
187pub enum PolicyToTextAnswer {
188    /// Represents a successful call
189    Success {
190        /// Cedar format policy
191        text: String,
192    },
193    /// Represents a failed call (e.g., because the input is ill-formed)
194    Failure {
195        /// Errors
196        errors: Vec<DetailedError>,
197    },
198}
199
200/// Result of converting a policyset as a string into its Cedar
201/// format components
202#[derive(Debug, Serialize, Deserialize)]
203#[serde(tag = "type")]
204#[serde(rename_all = "camelCase")]
205#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
206#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
207pub enum PolicySetTextToPartsAnswer {
208    /// Represents a successful call
209    Success {
210        /// Cedar format policies
211        policies: Vec<String>,
212        /// Cedar format policy templates
213        policy_templates: Vec<String>,
214    },
215    /// Represents a failed call (e.g., because the input is ill-formed)
216    Failure {
217        /// Errors
218        errors: Vec<DetailedError>,
219    },
220}
221
222/// Result of converting a policy or template to JSON
223#[derive(Debug, Serialize, Deserialize)]
224#[serde(tag = "type")]
225#[serde(rename_all = "camelCase")]
226#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
227#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
228pub enum PolicyToJsonAnswer {
229    /// Represents a successful call
230    Success {
231        /// JSON format policy
232        #[cfg_attr(feature = "wasm", tsify(type = "PolicyJson"))]
233        json: JsonValueWithNoDuplicateKeys,
234    },
235    /// Represents a failed call (e.g., because the input is ill-formed)
236    Failure {
237        /// Errors
238        errors: Vec<DetailedError>,
239    },
240}
241
242/// Result of converting a schema to the Cedar format
243#[derive(Debug, Serialize, Deserialize)]
244#[serde(tag = "type")]
245#[serde(rename_all = "camelCase")]
246#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
247#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
248pub enum SchemaToTextAnswer {
249    /// Represents a successful call
250    Success {
251        /// Cedar format schema
252        text: String,
253        /// Warnings
254        warnings: Vec<DetailedError>,
255    },
256    /// Represents a failed call (e.g., because the input is ill-formed)
257    Failure {
258        /// Errors
259        errors: Vec<DetailedError>,
260    },
261}
262
263/// Result of converting a schema to JSON
264#[derive(Debug, Serialize, Deserialize)]
265#[serde(tag = "type")]
266#[serde(rename_all = "camelCase")]
267#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
268#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
269pub enum SchemaToJsonAnswer {
270    /// Represents a successful call
271    Success {
272        /// JSON format schema
273        #[cfg_attr(feature = "wasm", tsify(type = "SchemaJson<string>"))]
274        json: JsonValueWithNoDuplicateKeys,
275        /// Warnings
276        warnings: Vec<DetailedError>,
277    },
278    /// Represents a failed call (e.g., because the input is ill-formed)
279    Failure {
280        /// Errors
281        errors: Vec<DetailedError>,
282    },
283}
284
285#[cfg(test)]
286mod test {
287    use super::*;
288
289    use crate::ffi::test_utils::*;
290    use cool_asserts::assert_matches;
291    use serde_json::json;
292
293    #[test]
294    fn test_policy_to_json() {
295        let text = r#"
296            permit(principal, action, resource)
297            when { principal has "Email" && principal.Email == "a@a.com" };
298        "#;
299        let result = policy_to_json(Policy::Cedar(text.into()));
300        let expected = json!({
301            "effect": "permit",
302            "principal": {
303                "op": "All"
304            },
305            "action": {
306                "op": "All"
307            },
308            "resource": {
309                "op": "All"
310            },
311            "conditions": [
312                {
313                    "kind": "when",
314                    "body": {
315                        "&&": {
316                            "left": {
317                                "has": {
318                                    "left": {
319                                        "Var": "principal"
320                                    },
321                                    "attr": "Email"
322                                }
323                            },
324                            "right": {
325                                "==": {
326                                    "left": {
327                                        ".": {
328                                            "left": {
329                                                "Var": "principal"
330                                            },
331                                            "attr": "Email"
332                                        }
333                                    },
334                                    "right": {
335                                        "Value": "a@a.com"
336                                    }
337                                }
338                            }
339                        }
340                    }
341                }
342            ]
343        });
344        assert_matches!(result, PolicyToJsonAnswer::Success { json } =>
345          assert_eq!(json, expected.into())
346        );
347    }
348
349    #[test]
350    fn test_policy_to_json_error() {
351        let text = r#"
352            permit(principal, action, resource)
353            when { principal has "Email" && principal.Email == };
354        "#;
355        let result = policy_to_json(Policy::Cedar(text.into()));
356        assert_matches!(result, PolicyToJsonAnswer::Failure { errors } => {
357            assert_exactly_one_error(
358                &errors,
359                "failed to parse policy from string: unexpected token `}`",
360                None,
361            );
362        });
363    }
364
365    #[test]
366    fn test_policy_to_text() {
367        let json = json!({
368            "effect": "permit",
369            "action": {
370                "entity": {
371                    "id": "pop",
372                    "type": "Action"
373                },
374                "op": "=="
375            },
376            "principal": {
377                "entity": {
378                    "id": "DeathRowRecords",
379                    "type": "UserGroup"
380                },
381                "op": "in"
382            },
383            "resource": {
384                "op": "All"
385            },
386            "conditions": []
387        });
388        let result = policy_to_text(Policy::Json(json.into()));
389        assert_matches!(result, PolicyToTextAnswer::Success { text } => {
390            assert_eq!(
391                &text,
392                "permit(principal in UserGroup::\"DeathRowRecords\", action == Action::\"pop\", resource);"
393            );
394        });
395    }
396
397    #[test]
398    fn test_template_to_json() {
399        let text = r"
400            permit(principal in ?principal, action, resource);
401        ";
402        let result = template_to_json(Template::Cedar(text.into()));
403        let expected = json!({
404            "effect": "permit",
405            "principal": {
406                "op": "in",
407                "slot": "?principal"
408            },
409            "action": {
410                "op": "All"
411            },
412            "resource": {
413                "op": "All"
414            },
415            "conditions": []
416        });
417        assert_matches!(result, PolicyToJsonAnswer::Success { json } =>
418          assert_eq!(json, expected.into())
419        );
420    }
421
422    #[test]
423    fn test_template_to_text() {
424        let json = json!({
425            "effect": "permit",
426            "principal": {
427                "op": "All"
428            },
429            "action": {
430                "op": "All"
431            },
432            "resource": {
433                "op": "in",
434                "slot": "?resource"
435            },
436            "conditions": []
437        });
438        let result = template_to_text(Template::Json(json.into()));
439        assert_matches!(result, PolicyToTextAnswer::Success { text } => {
440            assert_eq!(
441                &text,
442                "permit(principal, action, resource in ?resource);"
443            );
444        });
445    }
446
447    #[test]
448    fn test_template_to_text_error() {
449        let json = json!({
450            "effect": "permit",
451            "action": {
452                "entity": {
453                    "id": "pop",
454                    "type": "Action"
455                },
456                "op": "=="
457            },
458            "principal": {
459                "entity": {
460                    "id": "DeathRowRecords",
461                    "type": "UserGroup"
462                },
463                "op": "in"
464            },
465            "resource": {
466                "op": "All"
467            },
468            "conditions": []
469        });
470        let result = template_to_text(Template::Json(json.into()));
471        assert_matches!(result, PolicyToTextAnswer::Failure { errors } => {
472            assert_exactly_one_error(
473                &errors,
474                "failed to parse template from JSON: error deserializing a policy/template from JSON: expected a template, got a static policy",
475                Some("a template should include slot(s) `?principal` or `?resource`"),
476            );
477        });
478    }
479
480    #[test]
481    fn test_schema_to_json() {
482        let text = r#"
483            entity User = { "name": String };
484            action sendMessage appliesTo {principal: User, resource: User};
485        "#;
486        let result = schema_to_json(Schema::Cedar(text.into()));
487        let expected = json!({
488        "": {
489            "entityTypes": {
490                "User": {
491                    "shape": {
492                        "type": "Record",
493                        "attributes": {
494                            "name": {"type": "EntityOrCommon", "name": "String"} // this will resolve to the builtin type `String` unless the user defines their own common or entity type `String` in the empty namespace, in another fragment
495                        }
496                    }
497                }
498            },
499            "actions": {
500                "sendMessage": {
501                    "appliesTo": {
502                        "resourceTypes": ["User"],
503                        "principalTypes": ["User"]
504                    }
505                }}
506            }
507        });
508        assert_matches!(result, SchemaToJsonAnswer::Success { json, warnings:_ } =>
509          assert_eq!(json, expected.into())
510        );
511    }
512
513    #[test]
514    fn test_schema_to_json_error() {
515        let text = r"
516            action sendMessage appliesTo {principal: User, resource: User};
517        ";
518        let result = schema_to_json(Schema::Cedar(text.into()));
519        assert_matches!(result, SchemaToJsonAnswer::Failure { errors } => {
520            assert_exactly_one_error(
521                &errors,
522                "failed to resolve types: User, User",
523                Some("`User` has not been declared as an entity type"),
524            );
525        });
526    }
527
528    #[test]
529    fn test_schema_to_text() {
530        let json = json!({
531        "": {
532            "entityTypes": {
533                "User": {
534                    "shape": {
535                        "type": "Record",
536                        "attributes": {
537                            "name": {"type": "String"}
538                        }
539                    }
540                }
541            },
542            "actions": {
543                "sendMessage": {
544                    "appliesTo": {
545                        "resourceTypes": ["User"],
546                        "principalTypes": ["User"]
547                    }
548                }}
549            }
550        });
551        let result = schema_to_text(Schema::Json(json.into()));
552        assert_matches!(result, SchemaToTextAnswer::Success { text, warnings:_ } => {
553            assert_eq!(
554                &text,
555                r#"entity User = {
556  "name": __cedar::String
557};
558
559action "sendMessage" appliesTo {
560  principal: [User],
561  resource: [User],
562  context: {}
563};
564"#
565            );
566        });
567    }
568
569    #[test]
570    fn policy_set_to_text_to_parts() {
571        let policy_set_str = r#"
572            permit(principal, action, resource)
573            when { principal has "Email" && principal.Email == "a@a.com" };
574            
575            permit(principal in UserGroup::"DeathRowRecords", action == Action::"pop", resource);
576
577            permit(principal in ?principal, action, resource);
578        "#;
579
580        let result = policy_set_text_to_parts(policy_set_str);
581        assert_matches!(result, PolicySetTextToPartsAnswer::Success { policies, policy_templates } => {
582            assert_eq!(policies.len(), 2);
583            assert_eq!(policy_templates.len(), 1);
584        });
585    }
586
587    #[test]
588    fn test_policy_set_text_to_parts_parse_failure() {
589        let invalid_input = "This is not a valid PolicySet string";
590
591        let result = policy_set_text_to_parts(invalid_input);
592
593        assert_matches!(result, PolicySetTextToPartsAnswer::Failure { errors } => {
594            assert_exactly_one_error(
595                &errors,
596                "unexpected token `is`",
597                None,
598            );
599        });
600    }
601}