diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/schema/Schema.java b/CedarJava/src/main/java/com/cedarpolicy/model/schema/Schema.java index d11bb16e..7a35e951 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/model/schema/Schema.java +++ b/CedarJava/src/main/java/com/cedarpolicy/model/schema/Schema.java @@ -16,13 +16,15 @@ package com.cedarpolicy.model.schema; +import java.util.Optional; + import com.cedarpolicy.loader.LibraryLoader; import com.cedarpolicy.model.exception.InternalException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Optional; - /** Represents a schema. */ public final class Schema { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -122,16 +124,58 @@ public static Schema parse(JsonOrCedar type, String str) throws InternalExceptio } + /** + * Converts a schema to Cedar format + * + * @return String representing the schema in Cedar format + * @throws InternalException If conversion from JSON to Cedar format fails + * @throws IllegalStateException If schema content is missing + * @throws NullPointerException + */ + public String toCedarFormat() throws InternalException, IllegalStateException, NullPointerException { + if (type == JsonOrCedar.Cedar && schemaText.isPresent()) { + return schemaText.get(); + } else if (type == JsonOrCedar.Json && schemaJson.isPresent()) { + return jsonToCedarJni(schemaJson.get().toString()); + } else { + throw new IllegalStateException("No schema found"); + } + } + + /** + * Converts a Cedar format schema to JSON format + * + * @return JsonNode representing the schema in JSON format + * @throws InternalException If conversion from Cedar to JSON format fails + * @throws IllegalStateException If schema content is missing + * @throws JsonMappingException If invalid JSON + * @throws JsonProcessingException If invalid JSON + * @throws NullPointerException + */ + public JsonNode toJsonFormat() + throws InternalException, JsonMappingException, JsonProcessingException, NullPointerException, + IllegalStateException { + if (type == JsonOrCedar.Json && schemaJson.isPresent()) { + return schemaJson.get(); + } else if (type == JsonOrCedar.Cedar && schemaText.isPresent()) { + return OBJECT_MAPPER.readTree(cedarToJsonJni(schemaText.get())); + } else { + throw new IllegalStateException("No schema found"); + } + } + /** Specifies the schema format used. */ public enum JsonOrCedar { /** - * Cedar JSON schema format. See - * https://docs.cedarpolicy.com/schema/json-schema.html + * Cedar JSON schema format. See + * + * https://docs.cedarpolicy.com/schema/json-schema.html */ Json, /** - * Cedar schema format. See - * https://docs.cedarpolicy.com/schema/human-readable-schema.html + * Cedar schema format. See + * + * https://docs.cedarpolicy.com/schema/human-readable-schema.html */ Cedar } @@ -139,4 +183,8 @@ public enum JsonOrCedar { private static native String parseJsonSchemaJni(String schemaJson) throws InternalException, NullPointerException; private static native String parseCedarSchemaJni(String schemaText) throws InternalException, NullPointerException; + + private static native String jsonToCedarJni(String json) throws InternalException, NullPointerException; + + private static native String cedarToJsonJni(String cedar) throws InternalException, NullPointerException; } diff --git a/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java b/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java index 5657c793..1883e9a2 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java @@ -16,13 +16,21 @@ package com.cedarpolicy; -import com.cedarpolicy.model.schema.Schema; -import com.cedarpolicy.model.schema.Schema.JsonOrCedar; - -import org.junit.jupiter.api.Test; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.schema.Schema; +import com.cedarpolicy.model.schema.Schema.JsonOrCedar; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; public class SchemaTests { @Test @@ -107,4 +115,124 @@ public void parseCedarSchema() { Schema.parse(JsonOrCedar.Cedar, "namspace Foo::Bar;"); }); } + + @Nested + @DisplayName("toCedarFormat Tests") + class ToCedarFormatTests { + + @Test + @DisplayName("Should return the same Cedar schema text") + void testFromCedar() throws InternalException { + String cedarSchema = "entity User;"; + Schema cedarSchemaObj = new Schema(cedarSchema); + String result = cedarSchemaObj.toCedarFormat(); + assertNotNull(result, "Result should not be null"); + assertEquals(cedarSchema, result, "Should return the original Cedar schema"); + } + + @Test + @DisplayName("Should convert JSON schema to Cedar format") + void testFromJson() throws InternalException { + String jsonSchema = """ + { + "": { + "entityTypes": { + "User": {} + }, + "actions": {} + } + } + """; + Schema jsonSchemaObj = Schema.parse(JsonOrCedar.Json, jsonSchema); + String result = jsonSchemaObj.toCedarFormat(); + + assertNotNull(result, "Result should not be null"); + String expectedCedar = "entity User;"; + assertEquals(expectedCedar, result.trim(), "Converted Cedar should match expected format"); + } + + @Test + @DisplayName("Should throw IllegalStateException for empty schema") + void testEmptySchema() { + Schema emptySchema = new Schema(JsonOrCedar.Cedar, Optional.empty(), Optional.empty()); + Exception exception = assertThrows(IllegalStateException.class, emptySchema::toCedarFormat); + assertEquals("No schema found", exception.getMessage()); + } + + @Test + @DisplayName("Should throw exception for malformed JSON schema") + void testMalformedSchema() { + String malformedJson = """ + { + "": { + "entityMalformedTypes": { + "User": {} + }, + "actions": {} + } + } + """; + Schema malformedSchema = new Schema(JsonOrCedar.Json, Optional.of(malformedJson), Optional.empty()); + assertNotNull(malformedSchema.schemaJson); + assertThrows(InternalException.class, malformedSchema::toCedarFormat); + } + } + + @Nested + @DisplayName("toJsonFormat Tests") + class ToJsonFormatTests { + + @Test + @DisplayName("Should convert Cedar schema to JSON format") + void testFromCedar() throws Exception { + String cedarSchema = "entity User;"; + Schema cedarSchemaObj = new Schema(cedarSchema); + JsonNode result = cedarSchemaObj.toJsonFormat(); + + String expectedJson = "{\"\":{\"entityTypes\":{\"User\":{}},\"actions\":{}}}"; + JsonNode expectedNode = new ObjectMapper().readTree(expectedJson); + + assertNotNull(result, "Result should not be null"); + assertEquals(expectedNode, result, "JSON should match expected structure"); + } + + @Test + @DisplayName("Should return the same JSON schema object") + void testFromJson() throws Exception { + String jsonSchema = """ + { + "": { + "entityTypes": { + "User": {} + }, + "actions": {} + } + } + """; + Schema jsonSchemaObj = Schema.parse(JsonOrCedar.Json, jsonSchema); + JsonNode result = jsonSchemaObj.toJsonFormat(); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedNode = mapper.readTree(jsonSchema); + + assertNotNull(result, "Result should not be null"); + assertEquals(expectedNode, result, "JSON should match the original schema"); + } + + @Test + @DisplayName("Should throw IllegalStateException for empty schema") + void testEmptySchema() { + Schema emptySchema = new Schema(JsonOrCedar.Cedar, Optional.empty(), Optional.empty()); + Exception exception = assertThrows(IllegalStateException.class, emptySchema::toJsonFormat); + assertEquals("No schema found", exception.getMessage()); + } + + @Test + @DisplayName("Should throw exception for malformed Cedar schema") + void testMalformedSchema() { + String malformedCedar = "entty User"; + Schema malformedSchema = new Schema(JsonOrCedar.Cedar, Optional.empty(), Optional.of(malformedCedar)); + assertThrows(InternalException.class, malformedSchema::toJsonFormat); + } + } } diff --git a/CedarJavaFFI/src/interface.rs b/CedarJavaFFI/src/interface.rs index d0534fa4..8f88f3d3 100644 --- a/CedarJavaFFI/src/interface.rs +++ b/CedarJavaFFI/src/interface.rs @@ -16,6 +16,9 @@ use cedar_policy::entities_errors::EntitiesError; #[cfg(feature = "partial-eval")] use cedar_policy::ffi::is_authorized_partial_json_str; +use cedar_policy::ffi::{ + schema_to_json, schema_to_text, Schema as FFISchema, SchemaToJsonAnswer, SchemaToTextAnswer, +}; use cedar_policy::{ ffi::{is_authorized_json_str, validate_json_str}, Entities, EntityUid, Policy, PolicySet, Schema, Template, @@ -702,6 +705,72 @@ fn policies_str_to_pretty_internal<'a>( } } } +#[jni_fn("com.cedarpolicy.model.schema.Schema")] +pub fn jsonToCedarJni<'a>(mut env: JNIEnv<'a>, _: JClass, json_schema: JString<'a>) -> jvalue { + match get_cedar_schema_internal(&mut env, json_schema) { + Ok(val) => val.as_jni(), + Err(e) => jni_failed(&mut env, e.as_ref()), + } +} +pub fn get_cedar_schema_internal<'a>( + env: &mut JNIEnv<'a>, + schema_json_jstr: JString<'a>, +) -> Result> { + let rust_str = env.get_string(&schema_json_jstr)?; + let schema_str = rust_str.to_str()?; + + let schema: FFISchema = serde_json::from_str(schema_str)?; + let cedar_format = schema_to_text(schema); + + match cedar_format { + SchemaToTextAnswer::Success { text, warnings } => { + let jstr = env.new_string(&text)?; + Ok(JValueGen::Object(JObject::from(jstr)).into()) + } + SchemaToTextAnswer::Failure { errors } => { + let joined_errors = errors + .iter() + .map(|e| e.message.clone()) + .collect::>() + .join("; "); + Err(joined_errors.into()) + } + } +} + +#[jni_fn("com.cedarpolicy.model.schema.Schema")] +pub fn cedarToJsonJni<'a>(mut env: JNIEnv<'a>, _: JClass, cedar_schema: JString<'a>) -> jvalue { + match get_json_schema_internal(&mut env, cedar_schema) { + Ok(val) => val.as_jni(), + Err(e) => jni_failed(&mut env, e.as_ref()), + } +} + +pub fn get_json_schema_internal<'a>( + env: &mut JNIEnv<'a>, + cedar_schema_jstr: JString<'a>, +) -> Result> { + let schema_jstr = env.get_string(&cedar_schema_jstr)?; + let schema_str = schema_jstr.to_str()?; + let cedar_schema_str = FFISchema::Cedar(schema_str.into()); + let json_format = schema_to_json(cedar_schema_str); + + match json_format { + SchemaToJsonAnswer::Success { json, warnings: _ } => { + let json_pretty = serde_json::to_string_pretty(&json)?; + let jstr = env.new_string(&json_pretty)?; + Ok(JValueGen::Object(JObject::from(jstr)).into()) + } + SchemaToJsonAnswer::Failure { errors } => { + let joined_errors = errors + .iter() + .map(|e| e.message.clone()) + .collect::>() + .join("; "); + Err(joined_errors.into()) + } + } +} #[cfg(test)] pub(crate) mod jvm_based_tests { @@ -932,38 +1001,38 @@ pub(crate) mod jvm_based_tests { let mut env = JVM.attach_current_thread().unwrap(); let policy_json = r#" - { - "effect": "permit", - "principal": { - "op": "==", - "entity": { "type": "User", "id": "12UA45" } - }, - "action": { - "op": "==", - "entity": { "type": "Action", "id": "view" } - }, - "resource": { - "op": "in", - "entity": { "type": "Folder", "id": "abc" } - }, - "conditions": [ - { - "kind": "when", - "body": { - "==": { - "left": { - ".": { - "left": { "Var": "context" }, - "attr": "tls_version" + { + "effect": "permit", + "principal": { + "op": "==", + "entity": { "type": "User", "id": "12UA45" } + }, + "action": { + "op": "==", + "entity": { "type": "Action", "id": "view" } + }, + "resource": { + "op": "in", + "entity": { "type": "Folder", "id": "abc" } + }, + "conditions": [ + { + "kind": "when", + "body": { + "==": { + "left": { + ".": { + "left": { "Var": "context" }, + "attr": "tls_version" + } + }, + "right": { "Value": "1.3" } + } } - }, - "right": { "Value": "1.3" } - } + } + ] } - } - ] - } - "#; + "#; let java_str = env.new_string(policy_json).unwrap(); let result = from_json_internal(&mut env, java_str); @@ -1215,24 +1284,24 @@ pub(crate) mod jvm_based_tests { fn parse_json_schema_internal_valid_test() { let mut env = JVM.attach_current_thread().unwrap(); let input = r#"{ - "schema": { - "entityTypes": { - "User": { - "memberOfTypes": ["Group"] - }, - "Group": {}, - "File": {} - }, - "actions": { - "read": { - "appliesTo": { - "principalTypes": ["User"], - "resourceTypes": ["File"] + "schema": { + "entityTypes": { + "User": { + "memberOfTypes": ["Group"] + }, + "Group": {}, + "File": {} + }, + "actions": { + "read": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["File"] + } + } + } } - } - } - } -}"#; + }"#; let jstr = env.new_string(input).unwrap(); let result = parse_json_schema_internal(&mut env, jstr); assert!(result.is_ok(), "Expected schema to parse successfully"); @@ -1248,24 +1317,24 @@ pub(crate) mod jvm_based_tests { fn parse_json_schema_internal_invalid_test() { let mut env = JVM.attach_current_thread().unwrap(); let invalid_input = r#"{ - "Schema": { - "entityTypes": { - "User": { - "MemberOfTypes": ["Group"] - }, - "Group": {}, - "File": {} - }, - "Actions": { - "read": { - "AppliesTo": { - "principalTypes": ["User"], - "AesourceTypes": ["File"] + "Schema": { + "entityTypes": { + "User": { + "MemberOfTypes": ["Group"] + }, + "Group": {}, + "File": {} + }, + "Actions": { + "read": { + "AppliesTo": { + "principalTypes": ["User"], + "AesourceTypes": ["File"] + } + } + } } - } - } - } -}"#; + }"#; let jstr = env.new_string(invalid_input).unwrap(); let result = parse_json_schema_internal(&mut env, jstr); @@ -1351,4 +1420,161 @@ pub(crate) mod jvm_based_tests { ); } } + mod conversion_tests { + use super::*; + + #[test] + fn get_cedar_schema_internal_valid() { + let mut env = JVM.attach_current_thread().unwrap(); + let json_input = r#"{ + "schema": { + "entityTypes": { + "User": { + "memberOfTypes": ["Group"] + }, + "Group": {}, + "File": {} + }, + "actions": { + "read": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["File"] + } + } + } + } + }"#; + + let jstr = env.new_string(json_input).unwrap(); + let result = get_cedar_schema_internal(&mut env, jstr); + assert!(result.is_ok(), "Expected Cedar conversion to succeed"); + + let cedar_jval = result.unwrap(); + let cedar_jstr = JString::cast(&mut env, cedar_jval.l().unwrap()).unwrap(); + let cedar_str = String::from(env.get_string(&cedar_jstr).unwrap()); + + let expected_cedar = "namespace schema {\n entity File;\n\n entity Group;\n\n entity User in [Group];\n\n action \"read\" appliesTo {\n principal: [User],\n resource: [File],\n context: {}\n };\n}"; + assert_eq!( + cedar_str.trim(), + expected_cedar, + "Cedar schema output did not match expected" + ); + } + + #[test] + fn get_cedar_schema_internal_invalid() { + let mut env = JVM.attach_current_thread().unwrap(); + let json_input = r#" + + entity User = { + name: String, + age?: Long, + }; + entity Photo in Album; + entity Album; + action view appliesTo { + principal : [User], + resource: [Album,Photo] + }; + "#; + + let jstr = env.new_string(json_input).unwrap(); + let result = get_cedar_schema_internal(&mut env, jstr); + assert!( + result.is_err(), + "Expected get_cedar_schema_internal to fail {:?}", + result + ); + } + + #[test] + fn get_json_schema_internal_valid() { + let mut env = JVM.attach_current_thread().unwrap(); + let cedar_input = r#" + namespace schema { + entity File; + + entity Group; + + entity User in [Group]; + + action "read" appliesTo { + principal: [User], + resource: [File], + context: {} + }; + } + "#; + + let jstr = env.new_string(cedar_input).unwrap(); + let result = get_json_schema_internal(&mut env, jstr); + assert!(result.is_ok(), "Expected JSON conversion to succeed"); + + let json_jval = result.unwrap(); + let json_jstr = JString::cast(&mut env, json_jval.l().unwrap()).unwrap(); + let json_str = String::from(env.get_string(&json_jstr).unwrap()); + let actual_json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + let expected_json = serde_json::json!({ + "schema": { + "entityTypes": { + "File": {}, + "Group": {}, + "User": { + "memberOfTypes": ["Group"] + } + }, + "actions": { + "read": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["File"] + } + } + } + } + }); + + assert_eq!( + actual_json, expected_json, + "JSON schema doesn't match expected structure" + ); + } + + #[test] + fn get_json_schema_internal_null() { + let mut env = JVM.attach_current_thread().unwrap(); + let null_str = JString::from(JObject::null()); + let result = get_json_schema_internal(&mut env, null_str); + assert!(result.is_err(), "Expected error on null input"); + } + + #[test] + fn get_json_schema_internal_invalid_input() { + let mut env = JVM.attach_current_thread().unwrap(); + let invalid_cedar = r#" + namespace schema { + entity File + + entity Group with no semicolon + + entity User in [NonExistentGroup]; + + action "read" appliesTo { + principal: [MissingEntity], + resource: [File], + context: {} + }; + } + "#; + let jstr = env.new_string(invalid_cedar).unwrap(); + + let result = get_json_schema_internal(&mut env, jstr); + assert!( + result.is_err(), + "Expected get_json_schema_internal to fail: {:?}", + result + ); + } + } }