diff --git a/cedar-policy-cli/CHANGELOG.md b/cedar-policy-cli/CHANGELOG.md index dea7b65d0..f443d65a4 100644 --- a/cedar-policy-cli/CHANGELOG.md +++ b/cedar-policy-cli/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to Cedar CLI tool will be documented in this file. Changes to the Cedar language, which are likely to affect users of the CLI, are documented separately in the [primary changelog](../cedar-policy/CHANGELOG.md). ## Unreleased +- Added a new option when converting cedar schemas to JSON that allows the caller to get back a JSON schema where all types are resolved to commontype or entity (never entityOrCommon). ## 4.8.2 diff --git a/cedar-policy-cli/src/lib.rs b/cedar-policy-cli/src/lib.rs index b03b43022..e51ab6e67 100644 --- a/cedar-policy-cli/src/lib.rs +++ b/cedar-policy-cli/src/lib.rs @@ -170,6 +170,11 @@ pub enum SchemaTranslationDirection { JsonToCedar, /// Cedar schema syntax -> JSON CedarToJson, + /// Cedar schema syntax -> JSON with all types resolved to entity or common. + /// + /// In contrast to `cedar-to-json`, this option requires that every type + /// referenced in the schema is also defined. + CedarToJsonWithResolvedTypes, } #[derive(Debug, Default, Clone, Copy, ValueEnum)] @@ -1251,10 +1256,31 @@ fn translate_schema_to_json(cedar_src: impl AsRef) -> Result { Ok(output) } +fn translate_schema_to_json_with_resolved_types(cedar_src: impl AsRef) -> Result { + match cedar_policy::schema_str_to_json_with_resolved_types(cedar_src.as_ref()) { + Ok((json_value, warnings)) => { + // Output warnings to stderr + for warning in &warnings { + eprintln!("{warning}"); + } + + // Serialize to JSON with pretty formatting + serde_json::to_string_pretty(&json_value).into_diagnostic() + } + Err(error) => { + // Convert CedarSchemaError to miette::Report to preserve all diagnostic information + Err(miette::Report::new(error)) + } + } +} + fn translate_schema_inner(args: &TranslateSchemaArgs) -> Result { let translate = match args.direction { SchemaTranslationDirection::JsonToCedar => translate_schema_to_cedar, SchemaTranslationDirection::CedarToJson => translate_schema_to_json, + SchemaTranslationDirection::CedarToJsonWithResolvedTypes => { + translate_schema_to_json_with_resolved_types + } }; read_from_file_or_stdin(args.input_file.as_ref(), "schema").and_then(translate) } diff --git a/cedar-policy-cli/tests/sample.rs b/cedar-policy-cli/tests/sample.rs index 32a1b360a..e6bdfbce6 100644 --- a/cedar-policy-cli/tests/sample.rs +++ b/cedar-policy-cli/tests/sample.rs @@ -1274,6 +1274,187 @@ fn test_translate_schema() { .code(0); } +#[test] +fn test_translate_schema_with_resolved_types() { + let cedar_filename = "sample-data/tiny_sandboxes/translate-schema/tinytodo.cedarschema"; + + // Test cedar -> json with resolved types + let output = cargo::cargo_bin_cmd!("cedar") + .arg("translate-schema") + .arg("--direction") + .arg("cedar-to-json-with-resolved-types") + .arg("-s") + .arg(cedar_filename) + .assert() + .code(0); + + let json_output = + std::str::from_utf8(&output.get_output().stdout).expect("output should be decodable"); + + // Parse the JSON to ensure it's valid + let parsed_json: serde_json::Value = + serde_json::from_str(json_output).expect("output should be valid JSON"); + + // Verify that the output contains resolved types (no "EntityOrCommon" strings) + let json_str = serde_json::to_string(&parsed_json).unwrap(); + assert!( + !json_str.contains("EntityOrCommon"), + "Output should not contain unresolved EntityOrCommon types: {}", + json_str + ); + + // Verify that the output contains expected entity types + assert!( + json_str.contains("List"), + "Output should contain List entity type" + ); + assert!( + json_str.contains("User"), + "Output should contain User entity type" + ); + assert!( + json_str.contains("Team"), + "Output should contain Team entity type" + ); + assert!( + json_str.contains("Application"), + "Output should contain Application entity type" + ); +} +#[test] + +fn test_translate_schema_with_resolved_types_stdin() { + // Test with stdin input + let stdin_output = cargo::cargo_bin_cmd!("cedar") + .arg("translate-schema") + .arg("--direction") + .arg("cedar-to-json-with-resolved-types") + .write_stdin("entity User; action \"view\";") + .assert() + .code(0); + + let stdin_json = std::str::from_utf8(&stdin_output.get_output().stdout) + .expect("stdin output should be decodable"); + + // Parse and verify stdin JSON + let stdin_parsed: serde_json::Value = + serde_json::from_str(stdin_json).expect("stdin output should be valid JSON"); + + let stdin_json_str = serde_json::to_string(&stdin_parsed).unwrap(); + assert!( + !stdin_json_str.contains("EntityOrCommon"), + "Stdin output should not contain unresolved EntityOrCommon types" + ); + assert!( + stdin_json_str.contains("User"), + "Stdin output should contain User entity type" + ); +} + +#[test] +fn test_translate_schema_with_resolved_types_invalid_input() { + // Test with invalid Cedar schema syntax + let invalid_output = cargo::cargo_bin_cmd!("cedar") + .arg("translate-schema") + .arg("--direction") + .arg("cedar-to-json-with-resolved-types") + .write_stdin("invalid cedar syntax {") + .assert() + .code(1); // Should fail with non-zero exit code + + let stderr = std::str::from_utf8(&invalid_output.get_output().stderr) + .expect("stderr should be decodable"); + + // Should contain error message about parsing failure + assert!( + stderr.contains("error parsing schema"), + "Error message should indicate parsing failure: {}", + stderr + ); +} + +#[test] +fn test_translate_schema_with_resolved_types_warnings() { + // Create a schema that generates warnings (Shadowing a primitive type with an entity) + let schema_with_warnings = r#" + entity String; + "#; + + let output = cargo::cargo_bin_cmd!("cedar") + .arg("translate-schema") + .arg("--direction") + .arg("cedar-to-json-with-resolved-types") + .write_stdin(schema_with_warnings) + .assert() + .code(0); // Should succeed despite warnings + + let json_output = + std::str::from_utf8(&output.get_output().stdout).expect("output should be decodable"); + + // Should produce valid JSON + let _parsed_json: serde_json::Value = + serde_json::from_str(json_output).expect("output should be valid JSON"); + + // Warnings should be output to stderr (if any) + let _stderr = + std::str::from_utf8(&output.get_output().stderr).expect("stderr should be decodable"); + + assert!(_stderr.contains("The name `String` shadows a builtin Cedar name")); +} + +#[test] +fn test_translate_schema_with_resolved_types_file_errors() { + // Test with non-existent input file + let nonexistent_output = cargo::cargo_bin_cmd!("cedar") + .arg("translate-schema") + .arg("--direction") + .arg("cedar-to-json-with-resolved-types") + .arg("-s") + .arg("nonexistent-file.cedarschema") + .assert() + .code(1); // Should fail with non-zero exit code + + let stderr = std::str::from_utf8(&nonexistent_output.get_output().stderr) + .expect("stderr should be decodable"); + + // Should contain error message about file not found + assert!( + stderr.contains("nonexistent-file.cedarschema") || stderr.contains("No such file"), + "Error message should indicate file not found: {}", + stderr + ); +} + +#[test] +fn test_translate_schema_with_resolved_types_unresolvable_references() { + // Test with schema that has unresolvable type references + let unresolvable_schema = r#" + entity User = { "manager": Manager }; + // Manager entity type is not defined + action "view"; + "#; + + let output = cargo::cargo_bin_cmd!("cedar") + .arg("translate-schema") + .arg("--direction") + .arg("cedar-to-json-with-resolved-types") + .write_stdin(unresolvable_schema) + .assert() + .code(1); // Should fail with non-zero exit code + + let stderr = + std::str::from_utf8(&output.get_output().stderr).expect("stderr should be decodable"); + + // Should contain error message about unresolvable references + assert!( + stderr.contains("Failed to resolve schema types") + || stderr.contains("Manager") + || stderr.contains("resolve"), + "Error message should indicate unresolvable type reference: {}", + stderr + ); +} + #[rstest] fn visualize_entities_parses_as_dot( #[files("sample-data/**/entities.json")] diff --git a/cedar-policy-core/src/validator/json_schema.rs b/cedar-policy-core/src/validator/json_schema.rs index acd9e0b80..4f0e433bf 100644 --- a/cedar-policy-core/src/validator/json_schema.rs +++ b/cedar-policy-core/src/validator/json_schema.rs @@ -198,7 +198,8 @@ impl Fragment { } /// Convert this `Fragment` to a `Fragment` where all the - /// entity or common type references have been resolved + /// entity or common type references have been resolved. If the fragment contains any type + /// references that are not defined, this will return Err. pub fn to_internal_name_fragment_with_resolved_types( &self, ) -> std::result::Result, SchemaError> { @@ -218,7 +219,12 @@ impl Fragment { .collect(); for tyname in &primitives_as_internal_names { + // Add __cedar-prefixed primitives as commontypes all_defs.mark_as_defined_as_common_type(tyname.qualify_with(Some(&cedar_namespace))); + // Add aliases for primitive types in the empty namespace (so "String" resolves to "__cedar::String") + if !all_defs.is_defined_as_common(tyname) && !all_defs.is_defined_as_entity(tyname) { + all_defs.mark_as_defined_as_common_type(tyname.clone()); + } } // Add extension types in __cedar namespace and also without @@ -234,13 +240,6 @@ impl Fragment { } } - // Add aliases for primitive types in the empty namespace (so "String" resolves to "__cedar::String") - for tyname in &primitives_as_internal_names { - if !all_defs.is_defined_as_common(tyname) && !all_defs.is_defined_as_entity(tyname) { - all_defs.mark_as_defined_as_common_type(tyname.clone()); - } - } - // Step 1: Convert Fragment to Fragment let conditional_fragment = Fragment( self.0 diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs index 575db763b..aee7aabfb 100644 --- a/cedar-policy/src/api.rs +++ b/cedar-policy/src/api.rs @@ -2137,9 +2137,140 @@ impl Schema { } } +/// Convert a Cedar schema string to JSON format with resolved types. +/// +/// This function resolves ambiguous "`EntityOrCommon`" types to their specific +/// Entity or `CommonType` classifications using the schema's type definitions. +/// This is primarily meant to be used when working with schemas programmatically, +/// for example when creating a schema building UI. +/// +/// Returns `Ok((json_value, warnings))` on success, or `Err(error)` on failure. +/// Fails if there are any types in the schema that are unresolved. +pub fn schema_str_to_json_with_resolved_types( + schema_str: &str, +) -> Result<(serde_json::Value, Vec), CedarSchemaError> { + // Parse the Cedar schema string into a fragment + let (json_schema_fragment, warnings) = + json_schema::Fragment::from_cedarschema_str(schema_str, Extensions::all_available()) + .map_err( + |e: cedar_policy_core::validator::CedarSchemaError| -> CedarSchemaError { + e.into() + }, + )?; + + let warnings_as_schema_warnings: Vec = warnings.collect(); + + // Use the new method from json_schema.rs to get the resolved fragment + let fully_resolved_fragment = + match json_schema_fragment.to_internal_name_fragment_with_resolved_types() { + Ok(fragment) => fragment, + Err(e) => { + // SchemaError can be directly converted to CedarSchemaError + return Err(e.into()); + } + }; + + // Serialize the resolved fragment to JSON + let json_value = serde_json::to_value(&fully_resolved_fragment).map_err(|e| { + let schema_error = SchemaError::JsonSerialization( + cedar_policy_core::validator::schema_errors::JsonSerializationError::from(e), + ); + CedarSchemaError::Schema(schema_error) + })?; + + Ok((json_value, warnings_as_schema_warnings)) +} + /// Contains the result of policy validation. /// /// The result includes the list of issues found by validation and whether validation succeeds or fails. + +#[cfg(test)] +mod test_schema_str_to_json_with_resolved_types { + use super::*; + + #[test] + fn test_unresolved_type_error() { + let schema_str = r#"entity User = { "name": MyName };"#; + + let result = schema_str_to_json_with_resolved_types(schema_str); + + // Should return an error because MyName is not defined + match result { + Ok(_) => panic!("Expected error but got success - MyName should not be resolved"), + Err(CedarSchemaError::Schema(SchemaError::TypeNotDefined(type_not_defined_error))) => { + // Verify that the error message contains information about the undefined type "MyName" + let error_message = format!("{}", type_not_defined_error); + assert!( + error_message.contains("MyName"), + "Expected error message to contain 'MyName', but got: {}", + error_message + ); + + // Verify it's specifically about failing to resolve types + assert!( + error_message.contains("failed to resolve type"), + "Expected error message to mention 'failed to resolve type', but got: {}", + error_message + ); + } + Err(CedarSchemaError::Schema(other_schema_error)) => { + panic!( + "Expected TypeNotDefined error, but got different SchemaError: {:?}", + other_schema_error + ); + } + Err(CedarSchemaError::Parse(parse_error)) => { + panic!( + "Expected TypeNotDefined error, but got parse error: {:?}", + parse_error + ); + } + Err(CedarSchemaError::Io(io_error)) => { + panic!( + "Expected TypeNotDefined error, but got IO error: {:?}", + io_error + ); + } + } + } + + #[test] + fn test_successful_resolution() { + let schema_str = r#" + type MyName = String; + entity User = { "name": MyName }; + "#; + + let result = schema_str_to_json_with_resolved_types(schema_str); + + match result { + Ok((json_value, warnings)) => { + // Verify we got a JSON value + assert!(json_value.is_object(), "Expected JSON object"); + + // Verify the JSON doesn't contain "EntityOrCommon" (should be resolved) + let json_str = serde_json::to_string(&json_value).unwrap(); + assert!( + !json_str.contains("EntityOrCommon"), + "JSON should not contain unresolved EntityOrCommon types: {}", + json_str + ); + + // Verify MyName is resolved to a reference to the common type + assert!( + json_str.contains("MyName"), + "JSON should contain resolved MyName type reference: {}", + json_str + ); + + // Should have no warnings for this simple valid schema + assert_eq!(warnings.len(), 0, "Expected no warnings for valid schema"); + } + Err(e) => panic!("Expected success but got error: {:?}", e), + } + } +} /// Validation succeeds if there are no fatal errors. There may still be /// non-fatal warnings present when validation passes. #[derive(Debug, Clone)] diff --git a/cedar-policy/src/ffi/convert.rs b/cedar-policy/src/ffi/convert.rs index 87fdac6ea..84561c593 100644 --- a/cedar-policy/src/ffi/convert.rs +++ b/cedar-policy/src/ffi/convert.rs @@ -21,9 +21,6 @@ use super::utils::JsonValueWithNoDuplicateKeys; use super::{DetailedError, Policy, Schema, Template}; use crate::api::{PolicySet, StringifiedPolicySet}; -use cedar_policy_core::{ - extensions::Extensions, validator::cedar_schema::parser::parse_cedar_schema_fragment, -}; use serde::{Deserialize, Serialize}; use std::str::FromStr; #[cfg(feature = "wasm")] @@ -191,41 +188,17 @@ pub fn schema_to_json(schema: Schema) -> SchemaToJsonAnswer { wasm_bindgen(js_name = "schemaToJsonWithResolvedTypes") )] pub fn schema_to_json_with_resolved_types(schema_str: &str) -> SchemaToJsonWithResolvedTypesAnswer { - let (json_schema_fragment, warnings) = - match parse_cedar_schema_fragment(schema_str, Extensions::all_available()) { - Ok((json_schema, warnings)) => (json_schema, warnings), - Err(e) => { - return SchemaToJsonWithResolvedTypesAnswer::Failure { - errors: vec![miette::Report::new(e).into()], - }; - } - }; - - let warnings_as_detailed_errs: Vec = warnings.map(|w| (&w).into()).collect(); - - // Use the new method from json_schema.rs to get the resolved fragment - let fully_resolved_fragment = - match json_schema_fragment.to_internal_name_fragment_with_resolved_types() { - Ok(fragment) => fragment, - Err(e) => { - return SchemaToJsonWithResolvedTypesAnswer::Failure { - errors: vec![miette::Report::new(e).into()], - }; - } - }; - - // Serialize the resolved Fragment to JSON - match serde_json::to_value(&fully_resolved_fragment) { - Ok(json) => SchemaToJsonWithResolvedTypesAnswer::Success { - json: json.into(), - warnings: warnings_as_detailed_errs, - }, - Err(e) => SchemaToJsonWithResolvedTypesAnswer::Failure { - errors: vec![ - DetailedError::from_str(&format!("JSON serialization failed: {e}")) - .unwrap_or_default(), - ], + match crate::api::schema_str_to_json_with_resolved_types(schema_str) { + Ok((json_value, warnings)) => SchemaToJsonWithResolvedTypesAnswer::Success { + json: json_value.into(), + warnings: warnings.iter().map(std::convert::Into::into).collect(), }, + Err(error) => { + // Convert CedarSchemaError to DetailedError + SchemaToJsonWithResolvedTypesAnswer::Failure { + errors: vec![(&error).into()], + } + } } }