cedar_policy_validator/cedar_schema/
fmt.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//! `Display` implementations for formatting a [`json_schema::Fragment`] in the
18//! Cedar schema syntax
19
20use std::{collections::HashSet, fmt::Display};
21
22use itertools::Itertools;
23use miette::Diagnostic;
24use nonempty::NonEmpty;
25use thiserror::Error;
26
27use crate::{json_schema, RawName};
28use cedar_policy_core::{ast::InternalName, impl_diagnostic_from_method_on_nonempty_field};
29
30impl<N: Display> Display for json_schema::Fragment<N> {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        for (ns, def) in &self.0 {
33            match ns {
34                None => write!(f, "{def}")?,
35                Some(ns) => write!(f, "{}namespace {ns} {{\n{}}}\n", def.annotations, def)?,
36            }
37        }
38        Ok(())
39    }
40}
41
42impl<N: Display> Display for json_schema::NamespaceDefinition<N> {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        for (n, ty) in &self.common_types {
45            writeln!(f, "{}type {n} = {};", ty.annotations, ty.ty)?
46        }
47        for (n, ty) in &self.entity_types {
48            writeln!(f, "{}entity {n}{};", ty.annotations, ty)?
49        }
50        for (n, a) in &self.actions {
51            writeln!(f, "{}action \"{}\"{};", a.annotations, n.escape_debug(), a)?
52        }
53        Ok(())
54    }
55}
56
57impl<N: Display> Display for json_schema::Type<N> {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            json_schema::Type::Type { ty, .. } => match ty {
61                json_schema::TypeVariant::Boolean => write!(f, "__cedar::Bool"),
62                json_schema::TypeVariant::Entity { name } => write!(f, "{name}"),
63                json_schema::TypeVariant::EntityOrCommon { type_name } => {
64                    write!(f, "{type_name}")
65                }
66                json_schema::TypeVariant::Extension { name } => write!(f, "__cedar::{name}"),
67                json_schema::TypeVariant::Long => write!(f, "__cedar::Long"),
68                json_schema::TypeVariant::Record(rty) => write!(f, "{rty}"),
69                json_schema::TypeVariant::Set { element } => write!(f, "Set < {element} >"),
70                json_schema::TypeVariant::String => write!(f, "__cedar::String"),
71            },
72            json_schema::Type::CommonTypeRef { type_name, .. } => write!(f, "{type_name}"),
73        }
74    }
75}
76
77impl<N: Display> Display for json_schema::RecordType<N> {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "{{")?;
80        for (i, (n, ty)) in self.attributes.iter().enumerate() {
81            write!(
82                f,
83                "{}\"{}\"{}: {}",
84                ty.annotations,
85                n.escape_debug(),
86                if ty.required { "" } else { "?" },
87                ty.ty
88            )?;
89            if i < (self.attributes.len() - 1) {
90                writeln!(f, ", ")?;
91            }
92        }
93        write!(f, "}}")?;
94        Ok(())
95    }
96}
97
98fn fmt_non_empty_slice<T: Display>(
99    f: &mut std::fmt::Formatter<'_>,
100    (head, tail): (&T, &[T]),
101) -> std::fmt::Result {
102    write!(f, "[{head}")?;
103    for e in tail {
104        write!(f, ", {e}")?;
105    }
106    write!(f, "]")
107}
108
109impl<N: Display> Display for json_schema::EntityType<N> {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match &self.kind {
112            json_schema::EntityTypeKind::Standard(ty) => ty.fmt(f),
113            json_schema::EntityTypeKind::Enum { choices } => write!(
114                f,
115                " enum [{}]",
116                choices
117                    .iter()
118                    .map(|e| format!("\"{}\"", e.escape_debug()))
119                    .join(", ")
120            ),
121        }
122    }
123}
124
125impl<N: Display> Display for json_schema::StandardEntityType<N> {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        if let Some(non_empty) = self.member_of_types.split_first() {
128            write!(f, " in ")?;
129            fmt_non_empty_slice(f, non_empty)?;
130        }
131
132        let ty = &self.shape;
133        // Don't print `= { }`
134        if !ty.is_empty_record() {
135            write!(f, " = {ty}")?;
136        }
137
138        if let Some(tags) = &self.tags {
139            write!(f, " tags {tags}")?;
140        }
141
142        Ok(())
143    }
144}
145
146impl<N: Display> Display for json_schema::ActionType<N> {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        if let Some(parents) = self.member_of.as_ref().and_then(|refs| refs.split_first()) {
149            write!(f, " in ")?;
150            fmt_non_empty_slice(f, parents)?;
151        }
152        if let Some(spec) = &self.applies_to {
153            match (
154                spec.principal_types.split_first(),
155                spec.resource_types.split_first(),
156            ) {
157                // One of the lists is empty
158                // This can only be represented by the empty action
159                // This implies an action group
160                (None, _) | (_, None) => {
161                    write!(f, "")?;
162                }
163                // Both list are non empty
164                (Some(ps), Some(rs)) => {
165                    write!(f, " appliesTo {{")?;
166                    write!(f, "\n  principal: ")?;
167                    fmt_non_empty_slice(f, ps)?;
168                    write!(f, ",\n  resource: ")?;
169                    fmt_non_empty_slice(f, rs)?;
170                    write!(f, ",\n  context: {}", &spec.context.0)?;
171                    write!(f, "\n}}")?;
172                }
173            }
174        }
175        // No `appliesTo` key: action does not apply to anything
176        Ok(())
177    }
178}
179
180/// Error converting a schema to the Cedar syntax
181#[derive(Debug, Diagnostic, Error)]
182pub enum ToCedarSchemaSyntaxError {
183    /// Collisions between names prevented the conversion to the Cedar syntax
184    #[diagnostic(transparent)]
185    #[error(transparent)]
186    NameCollisions(#[from] NameCollisionsError),
187}
188
189/// Duplicate names were found in the schema
190//
191// This is NOT a publicly exported error type.
192#[derive(Debug, Error)]
193#[error("There are name collisions: [{}]", .names.iter().join(", "))]
194pub struct NameCollisionsError {
195    /// Names that had collisions
196    names: NonEmpty<InternalName>,
197}
198
199impl Diagnostic for NameCollisionsError {
200    impl_diagnostic_from_method_on_nonempty_field!(names, loc);
201}
202
203impl NameCollisionsError {
204    /// Get the names that had collisions
205    pub fn names(&self) -> impl Iterator<Item = &InternalName> {
206        self.names.iter()
207    }
208}
209
210/// Convert a [`json_schema::Fragment`] to a string containing the Cedar schema syntax
211///
212/// As of this writing, this existing code throws an error if any
213/// fully-qualified name in a non-empty namespace is a valid common type and
214/// also a valid entity type.
215//
216// Two notes:
217// 1) This check is more conservative than necessary. Schemas are allowed to
218// shadow an entity type with a common type declaration in the same namespace;
219// see RFCs 24 and 70. What the Cedar syntax can't express is if, in that
220// situation, we then specifically refer to the shadowed entity type name.  But
221// it's harder to walk all type references than it is to walk all type
222// declarations, so the conservative code here is fine; we can always make it
223// less conservative in the future without breaking people.
224// 2) This code is also likely the cause of #1063; see that issue
225pub fn json_schema_to_cedar_schema_str<N: Display>(
226    json_schema: &json_schema::Fragment<N>,
227) -> Result<String, ToCedarSchemaSyntaxError> {
228    let mut name_collisions: Vec<InternalName> = Vec::new();
229    for (name, ns) in json_schema.0.iter().filter(|(name, _)| !name.is_none()) {
230        let entity_types: HashSet<InternalName> = ns
231            .entity_types
232            .keys()
233            .map(|ty_name| {
234                RawName::new_from_unreserved(ty_name.clone(), None).qualify_with_name(name.as_ref())
235            })
236            .collect();
237        let common_types: HashSet<InternalName> = ns
238            .common_types
239            .keys()
240            .map(|ty_name| {
241                RawName::new_from_unreserved(ty_name.clone().into(), None)
242                    .qualify_with_name(name.as_ref())
243            })
244            .collect();
245        name_collisions.extend(entity_types.intersection(&common_types).cloned());
246    }
247    if let Some(non_empty_collisions) = NonEmpty::from_vec(name_collisions) {
248        return Err(NameCollisionsError {
249            names: non_empty_collisions,
250        }
251        .into());
252    }
253    Ok(json_schema.to_string())
254}
255
256#[cfg(test)]
257mod tests {
258    use cedar_policy_core::extensions::Extensions;
259
260    use crate::{cedar_schema::parser::parse_cedar_schema_fragment, json_schema, RawName};
261
262    use similar_asserts::assert_eq;
263
264    #[track_caller]
265    fn test_round_trip(src: &str) {
266        let (cedar_schema, _) =
267            parse_cedar_schema_fragment(src, Extensions::none()).expect("should parse");
268        let printed_cedar_schema = cedar_schema.to_cedarschema().expect("should convert");
269        let (parsed_cedar_schema, _) =
270            parse_cedar_schema_fragment(&printed_cedar_schema, Extensions::none())
271                .expect("should parse");
272        assert_eq!(cedar_schema, parsed_cedar_schema);
273    }
274
275    #[test]
276    fn rfc_example() {
277        let src = "entity User = {
278            jobLevel: Long,
279          } tags Set<String>;
280          entity Document = {
281            owner: User,
282          } tags Set<String>;";
283        test_round_trip(src);
284    }
285
286    #[test]
287    fn annotations() {
288        let src = r#"@doc("this is the namespace")
289namespace TinyTodo {
290    @doc("a common type representing a task")
291    type Task = {
292        @doc("task id")
293        "id": Long,
294        "name": String,
295        "state": String,
296    };
297    @doc("a common type representing a set of tasks")
298    type Tasks = Set<Task>;
299
300    @doc("an entity type representing a list")
301    @docComment("any entity type is a child of type `Application`")
302    entity List in [Application] = {
303        @doc("editors of a list")
304        "editors": Team,
305        "name": String,
306        "owner": User,
307        @doc("readers of a list")
308        "readers": Team,
309        "tasks": Tasks,
310    };
311
312    @doc("actions that a user can operate on a list")
313    action DeleteList, GetList, UpdateList appliesTo {
314        principal: [User],
315        resource: [List]
316    };
317}"#;
318        test_round_trip(src);
319    }
320
321    #[test]
322    fn attrs_types_roundtrip() {
323        test_round_trip(r#"entity Foo {a: Bool};"#);
324        test_round_trip(r#"entity Foo {a: Long};"#);
325        test_round_trip(r#"entity Foo {a: String};"#);
326        test_round_trip(r#"entity Foo {a: Set<Bool>};"#);
327        test_round_trip(r#"entity Foo {a: {b: Long}};"#);
328        test_round_trip(r#"entity Foo {a: {}};"#);
329        test_round_trip(
330            r#"
331        type A = Long;
332        entity Foo {a: A};
333        "#,
334        );
335        test_round_trip(
336            r#"
337        entity A;
338        entity Foo {a: A};
339        "#,
340        );
341    }
342
343    #[test]
344    fn enum_entities_roundtrip() {
345        test_round_trip(r#"entity Foo enum ["Bar", "Baz"];"#);
346        test_round_trip(r#"entity Foo enum ["Bar"];"#);
347        test_round_trip(r#"entity Foo enum ["\0\n\x7f"];"#);
348        test_round_trip(r#"entity enum enum ["enum"];"#);
349    }
350
351    #[test]
352    fn action_in_roundtrip() {
353        test_round_trip(r#"action Delete in Action::"Edit";"#);
354        test_round_trip(r#"action Delete in Action::"\n\x00";"#);
355        test_round_trip(r#"action Delete in [Action::"Edit", Action::"Destroy"];"#);
356    }
357
358    #[test]
359    fn primitives_roundtrip_to_entity_or_common() {
360        // Converting cedar->json never produces these primitve type nodes, instead using `EntityOrCommon`, so we need to test this starting from json.
361        let schema_json = serde_json::json!(
362            {
363                "": {
364                    "entityTypes": {
365                        "User": { },
366                        "Photo": {
367                            "shape": {
368                                "type": "Record",
369                                "attributes": {
370                                    "foo": { "type": "Long" },
371                                    "bar": { "type": "String" },
372                                    "baz": { "type": "Boolean" }
373                                }
374                            }
375                        }
376                    },
377                    "actions": {}
378                }
379            }
380        );
381
382        let fragment: json_schema::Fragment<RawName> = serde_json::from_value(schema_json).unwrap();
383        let cedar_schema = fragment.to_cedarschema().unwrap();
384
385        let (parsed_cedar_schema, _) =
386            parse_cedar_schema_fragment(&cedar_schema, Extensions::all_available()).unwrap();
387
388        let roundtrip_json = serde_json::to_value(parsed_cedar_schema).unwrap();
389        let expected_roundtrip = serde_json::json!(
390            {
391                "": {
392                    "entityTypes": {
393                        "User": { },
394                        "Photo": {
395                            "shape": {
396                                "type": "Record",
397                                "attributes": {
398                                    "foo": {
399                                        "type": "EntityOrCommon",
400                                        "name": "__cedar::Long"
401                                    },
402                                    "bar": {
403                                        "type": "EntityOrCommon",
404                                        "name": "__cedar::String"
405                                    },
406                                    "baz": {
407                                        "type": "EntityOrCommon",
408                                        "name": "__cedar::Bool"
409                                    }
410                                }
411                            }
412                        }
413                    },
414                    "actions": {}
415                }
416            }
417        );
418
419        assert_eq!(expected_roundtrip, roundtrip_json,);
420    }
421
422    #[test]
423    fn entity_type_reference_roundtrips_to_entity_or_common() {
424        // Converting cedar->json never produces `Entity` nodes, so we need to test this starting from json.
425        let schema_json = serde_json::json!(
426            {
427                "": {
428                    "entityTypes": {
429                        "User": { },
430                        "Photo": {
431                            "shape": {
432                                "type": "Record",
433                                "attributes": {
434                                    "owner": {
435                                        "type": "Entity",
436                                        "name": "User"
437                                    }
438                                }
439                            }
440                        }
441                    },
442                    "actions": {}
443                }
444            }
445        );
446
447        let fragment: json_schema::Fragment<RawName> = serde_json::from_value(schema_json).unwrap();
448        let cedar_schema = fragment.to_cedarschema().unwrap();
449
450        let (parsed_cedar_schema, _) =
451            parse_cedar_schema_fragment(&cedar_schema, Extensions::all_available()).unwrap();
452
453        let roundtrip_json = serde_json::to_value(parsed_cedar_schema).unwrap();
454        let expected_roundtrip = serde_json::json!(
455            {
456                "": {
457                    "entityTypes": {
458                        "User": { },
459                        "Photo": {
460                            "shape": {
461                                "type": "Record",
462                                "attributes": {
463                                    "owner": {
464                                        "type": "EntityOrCommon",
465                                        "name": "User"
466                                    }
467                                }
468                            }
469                        }
470                    },
471                    "actions": {}
472                }
473            }
474        );
475
476        assert_eq!(expected_roundtrip, roundtrip_json,);
477    }
478
479    #[test]
480    fn extension_type_roundtrips_to_entity_or_common() {
481        // Converting cedar->json never produces `Extension` nodes, so we need to test this starting from json.
482        let schema_json = serde_json::json!(
483            {
484                "": {
485                    "entityTypes": {
486                        "User": { },
487                        "Photo": {
488                            "shape": {
489                                "type": "Record",
490                                "attributes": {
491                                    "owner": {
492                                        "type": "Extension",
493                                        "name": "Decimal"
494                                    }
495                                }
496                            }
497                        }
498                    },
499                    "actions": {}
500                }
501            }
502        );
503
504        let fragment: json_schema::Fragment<RawName> = serde_json::from_value(schema_json).unwrap();
505        let cedar_schema = fragment.to_cedarschema().unwrap();
506
507        let (parsed_cedar_schema, _) =
508            parse_cedar_schema_fragment(&cedar_schema, Extensions::all_available()).unwrap();
509
510        let roundtrip_json = serde_json::to_value(parsed_cedar_schema).unwrap();
511        let expected_roundtrip = serde_json::json!(
512            {
513                "": {
514                    "entityTypes": {
515                        "User": { },
516                        "Photo": {
517                            "shape": {
518                                "type": "Record",
519                                "attributes": {
520                                    "owner": {
521                                        "type": "EntityOrCommon",
522                                        "name": "__cedar::Decimal"
523                                    }
524                                }
525                            }
526                        }
527                    },
528                    "actions": {}
529                }
530            }
531        );
532
533        assert_eq!(expected_roundtrip, roundtrip_json,);
534    }
535}