cedar_policy_validator/human_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
17use std::{collections::HashSet, fmt::Display};
18
19use cedar_policy_core::ast::Name;
20use itertools::Itertools;
21use miette::Diagnostic;
22use nonempty::NonEmpty;
23use smol_str::{SmolStr, ToSmolStr};
24use thiserror::Error;
25
26use crate::{
27    ActionType, EntityType, NamespaceDefinition, SchemaFragment, SchemaType, SchemaTypeVariant,
28};
29
30impl Display for SchemaFragment {
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} {{{def}}}")?,
36            }
37        }
38        Ok(())
39    }
40}
41
42impl Display for NamespaceDefinition {
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};")?
46        }
47        for (n, ty) in &self.entity_types {
48            writeln!(f, "entity {n}{ty};")?
49        }
50        for (n, a) in &self.actions {
51            writeln!(f, "action \"{}\"{a};", n.escape_debug())?
52        }
53        Ok(())
54    }
55}
56
57impl Display for SchemaType {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            SchemaType::Type(ty) => match ty {
61                SchemaTypeVariant::Boolean => write!(f, "__cedar::Bool"),
62                SchemaTypeVariant::Entity { name } => write!(f, "{name}"),
63                SchemaTypeVariant::Extension { name } => write!(f, "__cedar::{name}"),
64                SchemaTypeVariant::Long => write!(f, "__cedar::Long"),
65                SchemaTypeVariant::Record {
66                    attributes,
67                    additional_attributes: _,
68                } => {
69                    write!(f, "{{")?;
70                    for (i, (n, ty)) in attributes.iter().enumerate() {
71                        write!(
72                            f,
73                            "\"{}\"{}: {}",
74                            n.escape_debug(),
75                            if ty.required { "" } else { "?" },
76                            ty.ty
77                        )?;
78                        if i < (attributes.len() - 1) {
79                            write!(f, ", ")?;
80                        }
81                    }
82                    write!(f, "}}")?;
83                    Ok(())
84                }
85                SchemaTypeVariant::Set { element } => write!(f, "Set < {element} >"),
86                SchemaTypeVariant::String => write!(f, "__cedar::String"),
87            },
88            SchemaType::TypeDef { type_name } => write!(f, "{type_name}"),
89        }
90    }
91}
92
93/// Create a non-empty with borrowed contents from a slice
94fn non_empty_slice<T>(v: &[T]) -> Option<NonEmpty<&T>> {
95    let vs: Vec<&T> = v.iter().collect();
96    NonEmpty::from_vec(vs)
97}
98
99fn fmt_vec<T: Display>(f: &mut std::fmt::Formatter<'_>, ets: NonEmpty<T>) -> std::fmt::Result {
100    let contents = ets.iter().map(T::to_string).join(", ");
101    write!(f, "[{contents}]")
102}
103
104impl Display for EntityType {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        if let Some(non_empty) = non_empty_slice(&self.member_of_types) {
107            write!(f, " in ")?;
108            fmt_vec(f, non_empty)?;
109        }
110
111        let ty = &self.shape.0;
112        // Don't print `= { }`
113        if !ty.is_empty_record() {
114            write!(f, " = {ty}")?;
115        }
116
117        Ok(())
118    }
119}
120
121impl Display for ActionType {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        if let Some(parents) = self
124            .member_of
125            .as_ref()
126            .and_then(|refs| non_empty_slice(refs.as_slice()))
127        {
128            write!(f, " in ")?;
129            fmt_vec(f, parents)?;
130        }
131        if let Some(spec) = &self.applies_to {
132            match (
133                spec.principal_types
134                    .as_ref()
135                    .map(|refs| non_empty_slice(refs.as_slice())),
136                spec.resource_types
137                    .as_ref()
138                    .map(|refs| non_empty_slice(refs.as_slice())),
139            ) {
140                // One of the lists is empty
141                // This can only be represented by the empty action
142                // This implies an action group
143                (Some(None), _) | (_, Some(None)) => {
144                    write!(f, "")?;
145                }
146                // Both list are present and non empty
147                (Some(Some(ps)), Some(Some(rs))) => {
148                    write!(f, " appliesTo {{")?;
149                    write!(f, "\n  principal: ")?;
150                    fmt_vec(f, ps)?;
151                    write!(f, ",\n  resource: ")?;
152                    fmt_vec(f, rs)?;
153                    write!(f, ",\n  context: {}", &spec.context.0)?;
154                    write!(f, "\n}}")?;
155                }
156                // Only principals are present, resource is unspecified
157                (Some(Some(ps)), None) => {
158                    write!(f, " appliesTo {{")?;
159                    write!(f, "\n  principal: ")?;
160                    fmt_vec(f, ps)?;
161                    write!(f, ",\n  context: {}", &spec.context.0)?;
162                    write!(f, "\n}}")?;
163                }
164                // Only resources is present, principal is unspecified
165                (None, Some(Some(rs))) => {
166                    write!(f, " appliesTo {{")?;
167                    write!(f, "\n  resource: ")?;
168                    fmt_vec(f, rs)?;
169                    write!(f, ",\n  context: {}", &spec.context.0)?;
170                    write!(f, "\n}}")?;
171                }
172                // Neither are present, both principal and resource are unspecified
173                (None, None) => {
174                    write!(f, " appliesTo {{")?;
175                    write!(f, "\n  context: {}", &spec.context.0)?;
176                    write!(f, "\n}}")?;
177                }
178            }
179        } else {
180            // No `appliesTo` key: both principal and resource must be unspecified entities
181            write!(f, " appliesTo {{")?;
182            // context is an empty record
183            write!(f, "\n  context: {{}}")?;
184            write!(f, "\n}}")?;
185        }
186        Ok(())
187    }
188}
189
190#[derive(Debug, Diagnostic, Error)]
191pub enum ToHumanSchemaStrError {
192    #[error("There are name collisions: [{}]", .0.iter().join(", "))]
193    NameCollisions(NonEmpty<SmolStr>),
194}
195
196pub fn json_schema_to_custom_schema_str(
197    json_schema: &SchemaFragment,
198) -> Result<String, ToHumanSchemaStrError> {
199    let mut name_collisions: Vec<SmolStr> = Vec::new();
200
201    let all_empty_ns_types = json_schema
202        .0
203        .get(&None)
204        .iter()
205        .flat_map(|ns| {
206            ns.entity_types
207                .keys()
208                .chain(ns.common_types.keys())
209                .map(|id| id.to_smolstr())
210                .collect::<HashSet<_>>()
211        })
212        .collect::<HashSet<_>>();
213
214    for (name, ns) in json_schema.0.iter().filter(|(name, _)| !name.is_none()) {
215        let entity_types: HashSet<SmolStr> = ns
216            .entity_types
217            .keys()
218            .map(|ty_name| {
219                Name::unqualified_name(ty_name.clone())
220                    .prefix_namespace_if_unqualified(name.clone())
221                    .to_smolstr()
222            })
223            .collect();
224        let common_types: HashSet<SmolStr> = ns
225            .common_types
226            .keys()
227            .map(|ty_name| {
228                Name::unqualified_name(ty_name.clone())
229                    .prefix_namespace_if_unqualified(name.clone())
230                    .to_smolstr()
231            })
232            .collect();
233        name_collisions.extend(entity_types.intersection(&common_types).cloned());
234
235        // Due to implicit qualification, a type in a namespace may collide with
236        // a type in the empty namespace.  See <https://github.com/cedar-policy/cedar/issues/1063>.
237        let unqual_types = ns
238            .entity_types
239            .keys()
240            .chain(ns.common_types.keys())
241            .map(|ty_name| ty_name.to_smolstr())
242            .collect::<HashSet<_>>();
243        name_collisions.extend(unqual_types.intersection(&all_empty_ns_types).cloned())
244    }
245    if let Some(name_collisions) = NonEmpty::from_vec(name_collisions) {
246        return Err(ToHumanSchemaStrError::NameCollisions(name_collisions));
247    }
248    Ok(json_schema.to_string())
249}
250
251/// Test errors that should be reported when trying to convert a Cedar format
252/// schema to a JSON format schema that do not exist when parsing a JSON format
253/// schema an using it directly.
254#[cfg(test)]
255mod test_to_custom_schema_errors {
256    use cedar_policy_core::test_utils::{expect_err, ExpectedErrorMessageBuilder};
257    use cool_asserts::assert_matches;
258    use miette::Report;
259
260    use crate::{human_schema::json_schema_to_custom_schema_str, SchemaFragment};
261
262    #[test]
263    fn issue_1063_empty_ns_entity_type_collides_with_common_type() {
264        let json = SchemaFragment::from_json_value(serde_json::json!({
265            "": {
266              "entityTypes": {
267                "User": {}
268              },
269              "actions": {}
270            },
271            "NS": {
272              "commonTypes": {
273                "User": { "type": "String" }
274              },
275              "entityTypes": {
276                "Foo": {
277                  "shape": {
278                    "type": "Record",
279                    "attributes": {
280                      "owner": { "type": "Entity", "name": "User" }
281                    }
282                  }
283                }
284              },
285              "actions": {}
286            }
287        }))
288        .unwrap();
289
290        assert_matches!(json_schema_to_custom_schema_str(&json), Err(e) => {
291            expect_err(
292                "",
293                &Report::new(e),
294                &ExpectedErrorMessageBuilder::error("There are name collisions: [User]").build()
295            )
296        });
297    }
298
299    #[test]
300    fn same_namespace_common_type_entity_type_collision() {
301        let json = SchemaFragment::from_json_value(serde_json::json!({
302            "NS": {
303                "commonTypes": {
304                    "User": { "type": "String"}
305                },
306                "entityTypes": {
307                    "User": {}
308                },
309                "actions": {}
310            }
311        }))
312        .unwrap();
313
314        assert_matches!(json_schema_to_custom_schema_str(&json), Err(e) => {
315            expect_err(
316                "",
317                &Report::new(e),
318                &ExpectedErrorMessageBuilder::error("There are name collisions: [NS::User]").build()
319            )
320        });
321    }
322
323    #[test]
324    fn empty_ns_common_type_collides_with_entity_type() {
325        let json = SchemaFragment::from_json_value(serde_json::json!({
326            "": {
327                "commonTypes": {
328                    "User": { "type": "String"}
329                },
330                "entityTypes": {},
331                "actions": {}
332            },
333            "NS": {
334                "entityTypes": {
335                    "User": {}
336                },
337                "actions": {}
338            }
339        }))
340        .unwrap();
341
342        assert_matches!(json_schema_to_custom_schema_str(&json), Err(e) => {
343            expect_err(
344                "",
345                &Report::new(e),
346                &ExpectedErrorMessageBuilder::error("There are name collisions: [User]").build()
347            )
348        });
349    }
350}