diff --git a/CedarJava/CHANGELOG.md b/CedarJava/CHANGELOG.md index 72fb7fd..283fbd2 100644 --- a/CedarJava/CHANGELOG.md +++ b/CedarJava/CHANGELOG.md @@ -4,6 +4,10 @@ * Added Schema conversion APIs [#325](https://github.com/cedar-policy/cedar-java/pull/325) * Added Level Validation [#327](https://github.com/cedar-policy/cedar-java/pull/327) * Added DateTime extension support [#328](https://github.com/cedar-policy/cedar-java/pull/328) +* Added Duration extension support [#331](https://github.com/cedar-policy/cedar-java/pull/331) +* Added Offset function support [#331](https://github.com/cedar-policy/cedar-java/pull/331) +* Added PolicySet to JSON conversion API [#329](https://github.com/cedar-policy/cedar-java/pull/329) +* Added Cedar Schema support for Entity Validation [#332](https://github.com/cedar-policy/cedar-java/pull/332) ## 4.3.1 ### Added diff --git a/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java b/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java index 6cfc2b3..b5a9a35 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java @@ -17,6 +17,7 @@ package com.cedarpolicy; import static com.cedarpolicy.TestUtil.loadSchemaResource; +import static com.cedarpolicy.TestUtil.loadCedarSchemaResource; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -51,10 +52,19 @@ public class EntityValidationTests { public void testValidEntity() throws AuthException { Entity entity = EntityValidationTests.entityGen.arbitraryEntity(); - EntityValidationRequest r = new EntityValidationRequest( - ROLE_SCHEMA, List.of(entity)); + EntityValidationRequest request = new EntityValidationRequest(ROLE_SCHEMA, List.of(entity)); + engine.validateEntities(request); +} + + /** + * Test that a valid entity with the schema in Cedar format is accepted. + */ + @Test + public void testValidEntityWithCedarSchema() throws AuthException { + Entity entity = EntityValidationTests.entityGen.arbitraryEntity(); + EntityValidationRequest cedarFormatRequest = new EntityValidationRequest(ROLE_SCHEMA_CEDAR, List.of(entity)); - engine.validateEntities(r); + engine.validateEntities(cedarFormatRequest); } /** @@ -67,11 +77,32 @@ public void testEntityWithUnknownAttribute() throws AuthException { EntityValidationRequest request = new EntityValidationRequest(ROLE_SCHEMA, List.of(entity)); - BadRequestException exception = assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); + BadRequestException exception = + assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); String errMsg = exception.getErrors().get(0); - assertTrue(errMsg.matches("attribute `test` on `Role::\".*\"` should not exist according to the schema"), + assertTrue(errMsg.matches( + "attribute `test` on `Role::\".*\"` should not exist according to the schema"), "Expected to match regex but was: '%s'".formatted(errMsg)); +} + +/** + * Test that an entity with an attribute not specified in the schema in Cedar format throws an + * exception. + */ +@Test +public void testEntityWithUnknownAttributeWithCedarSchema() throws AuthException { + Entity entity = EntityValidationTests.entityGen.arbitraryEntity(); + entity.attrs.put("test", new PrimBool(true)); + + EntityValidationRequest cedarFormatRequest = new EntityValidationRequest(ROLE_SCHEMA_CEDAR, List.of(entity)); + + BadRequestException exception = + assertThrows(BadRequestException.class, () -> engine.validateEntities(cedarFormatRequest)); + + String errMsg = exception.getErrors().get(0); + assertTrue(errMsg.matches("attribute `test` on `Role::\".*\"` should not exist according to the schema"), + "Expected to match regex but was: '%s'".formatted(errMsg)); } /** @@ -87,13 +118,40 @@ public void testEntitiesWithCyclicParentRelationship() throws AuthException { childEntity.parentsEUIDs.add(parentEntity.getEUID()); parentEntity.parentsEUIDs.add(childEntity.getEUID()); - EntityValidationRequest request = new EntityValidationRequest(ROLE_SCHEMA, List.of(parentEntity, childEntity)); + EntityValidationRequest request = + new EntityValidationRequest(ROLE_SCHEMA, List.of(parentEntity, childEntity)); - BadRequestException exception = assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); + BadRequestException exception = + assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); String errMsg = exception.getErrors().get(0); assertTrue(errMsg.matches("input graph has a cycle containing vertex `Role::\".*\"`"), "Expected to match regex but was: '%s'".formatted(errMsg)); +} + +/** + * Test that entities with a cyclic parent relationship throw an exception with the schema in Cedar + * format. + */ +@Test +public void testEntitiesWithCyclicParentRelationshipWithCedarSchema() throws AuthException { + // Arrange + Entity childEntity = EntityValidationTests.entityGen.arbitraryEntity(); + Entity parentEntity = EntityValidationTests.entityGen.arbitraryEntity(); + + // Create a cyclic parent relationship between the entities + childEntity.parentsEUIDs.add(parentEntity.getEUID()); + parentEntity.parentsEUIDs.add(childEntity.getEUID()); + + EntityValidationRequest cedarFormatRequest = + new EntityValidationRequest(ROLE_SCHEMA_CEDAR, List.of(parentEntity, childEntity)); + + BadRequestException exception = + assertThrows(BadRequestException.class, () -> engine.validateEntities(cedarFormatRequest)); + + String errMsg = exception.getErrors().get(0); + assertTrue(errMsg.matches("input graph has a cycle containing vertex `Role::\".*\"`"), + "Expected to match regex but was: '%s'".formatted(errMsg)); } /** @@ -106,12 +164,33 @@ public void testEntityWithUnknownTag() throws AuthException { EntityValidationRequest request = new EntityValidationRequest(ROLE_SCHEMA, List.of(entity)); - BadRequestException exception = assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); + BadRequestException exception = + assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); + + String errMsg = exception.getErrors().get(0); + assertTrue( + errMsg.matches("found a tag `test` on `Role::\".*\"`, " + + "but no tags should exist on `Role::\".*\"` according to the schema"), + "Expected to match regex but was: '%s'".formatted(errMsg)); +} + +/** + * Test that an entity with a tag not specified in the schema in Cedar format throws an exception. + */ +@Test +public void testEntityWithUnknownTagWithCedarSchema() throws AuthException { + Entity entity = EntityValidationTests.entityGen.arbitraryEntity(); + entity.tags.put("test", new PrimString("value")); + + EntityValidationRequest cedarFormatRequest = new EntityValidationRequest(ROLE_SCHEMA_CEDAR, List.of(entity)); + + BadRequestException exception = + assertThrows(BadRequestException.class, () -> engine.validateEntities(cedarFormatRequest)); String errMsg = exception.getErrors().get(0); assertTrue(errMsg.matches("found a tag `test` on `Role::\".*\"`, " - + "but no tags should exist on `Role::\".*\"` according to the schema"), - "Expected to match regex but was: '%s'".formatted(errMsg)); + + "but no tags should exist on `Role::\".*\"` according to the schema"), + "Expected to match regex but was: '%s'".formatted(errMsg)); } @BeforeAll @@ -124,4 +203,5 @@ public static void setUp() { } private static final Schema ROLE_SCHEMA = loadSchemaResource("/role_schema.json"); + private static final Schema ROLE_SCHEMA_CEDAR = loadCedarSchemaResource("/role_schema.cedarschema"); } diff --git a/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java b/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java index 28bc3ec..3e99ae7 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java +++ b/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java @@ -56,6 +56,18 @@ public static Schema loadSchemaResource(String schemaFile) { } } + public static Schema loadCedarSchemaResource(String schemaFile) { + try { + String text = new String(Files.readAllBytes( + Paths.get( + ValidationTests.class.getResource(schemaFile).toURI())), + StandardCharsets.UTF_8); + return new Schema(JsonOrCedar.Cedar, Optional.empty(), Optional.of(text)); + } catch (Exception e) { + throw new RuntimeException("Failed to load test schema file " + schemaFile, e); + } + } + public static PolicySet buildValidPolicySet() { EntityTypeName principalType = EntityTypeName.parse("User").get(); Set policies = new HashSet<>(); diff --git a/CedarJava/src/test/resources/role_schema.cedarschema b/CedarJava/src/test/resources/role_schema.cedarschema new file mode 100644 index 0000000..a8f9fa7 --- /dev/null +++ b/CedarJava/src/test/resources/role_schema.cedarschema @@ -0,0 +1 @@ +entity Role in [Role]; diff --git a/CedarJavaFFI/src/interface.rs b/CedarJavaFFI/src/interface.rs index 3b8e1c0..df70898 100644 --- a/CedarJavaFFI/src/interface.rs +++ b/CedarJavaFFI/src/interface.rs @@ -151,25 +151,31 @@ pub fn json_validate_entities(input: &str) -> serde_json::Result { /// returns unit value () which is null value when serialized to json. pub fn validate_entities(input: &str) -> serde_json::Result { let validate_entity_call = from_str::(&input)?; - match Schema::from_json_value(validate_entity_call.schema) { - Err(e) => Ok(Answer::fail_bad_request(vec![e.to_string()])), - Ok(schema) => { - match Entities::from_json_value(validate_entity_call.entities, Some(&schema)) { - Err(error) => { - let err_message = match error { - EntitiesError::Serialization(err) => err.to_string(), - EntitiesError::Deserialization(err) => err.to_string(), - EntitiesError::Duplicate(err) => err.to_string(), - EntitiesError::TransitiveClosureError(err) => err.to_string(), - EntitiesError::InvalidEntity(err) => err.to_string(), - }; - Ok(Answer::fail_bad_request(vec![err_message])) - } - Ok(_entities) => Ok(Answer::Success { - result: "null".to_string(), - }), - } + let schema = match validate_entity_call.schema { + Value::String(cedarschema_str) => match Schema::from_cedarschema_str(&cedarschema_str) { + Ok(s) => s.0, + Err(e) => return Ok(Answer::fail_bad_request(vec![e.to_string()])), + }, + cedarschema_json_obj => match Schema::from_json_value(cedarschema_json_obj) { + Ok(s) => s, + Err(e) => return Ok(Answer::fail_bad_request(vec![e.to_string()])), + }, + }; + + match Entities::from_json_value(validate_entity_call.entities, Some(&schema)) { + Err(error) => { + let err_message = match error { + EntitiesError::Serialization(err) => err.to_string(), + EntitiesError::Deserialization(err) => err.to_string(), + EntitiesError::Duplicate(err) => err.to_string(), + EntitiesError::TransitiveClosureError(err) => err.to_string(), + EntitiesError::InvalidEntity(err) => err.to_string(), + }; + Ok(Answer::fail_bad_request(vec![err_message])) } + Ok(_entities) => Ok(Answer::Success { + result: "null".to_string(), + }), } } diff --git a/CedarJavaFFI/src/tests.rs b/CedarJavaFFI/src/tests.rs index 4ac2b1e..2a905a1 100644 --- a/CedarJavaFFI/src/tests.rs +++ b/CedarJavaFFI/src/tests.rs @@ -459,6 +459,100 @@ mod entity_validation_tests { assert_success(&result); } + #[test] + fn validate_entities_with_cedarschema_succeeds() { + let json_data = json!( + { + "entities":[ + { + "uid": { + "type": "PhotoApp::User", + "id": "alice" + }, + "attrs": { + "userId": "897345789237492878", + "personInformation": { + "age": 25, + "name": "alice" + }, + }, + "parents": [ + { + "type": "PhotoApp::UserGroup", + "id": "alice_friends" + }, + { + "type": "PhotoApp::UserGroup", + "id": "AVTeam" + } + ] + }, + { + "uid": { + "type": "PhotoApp::Photo", + "id": "vacationPhoto.jpg" + }, + "attrs": { + "private": false, + "account": { + "__entity": { + "type": "PhotoApp::Account", + "id": "ahmad" + } + } + }, + "parents": [] + }, + { + "uid": { + "type": "PhotoApp::UserGroup", + "id": "alice_friends" + }, + "attrs": {}, + "parents": [] + }, + { + "uid": { + "type": "PhotoApp::UserGroup", + "id": "AVTeam" + }, + "attrs": {}, + "parents": [] + } + ], + "schema":r#" + namespace PhotoApp { + type ContextType = { + "authenticated": __cedar::Bool, + "ip"?: __cedar::ipaddr + }; + + type PersonType = { + "age": __cedar::Long, + "name": __cedar::String + }; + + entity Account; + + entity Album; + + entity Photo in [Album, Account] = { + "account": Account, + "private": __cedar::Bool + }; + + entity User in [UserGroup] = { + "personInformation": PersonType, + "userId": __cedar::String + }; + + entity UserGroup; + }"# + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_success(&result); + } + #[test] fn validate_entities_field_missing() { let json_data = json!( @@ -609,6 +703,95 @@ mod entity_validation_tests { assert_failure(&result); } + #[test] + fn validate_entities_with_cedarschema_field_missing() { + let json_data = json!( + { + "entities":[ + { + "uid": { + "type": "PhotoApp::User", + "id": "alice" + }, + "attrs": { + "userId": "897345789237492878" + }, + "parents": [ + { + "type": "PhotoApp::UserGroup", + "id": "alice_friends" + }, + { + "type": "PhotoApp::UserGroup", + "id": "AVTeam" + } + ] + }, + { + "uid": { + "type": "PhotoApp::Photo", + "id": "vacationPhoto.jpg" + }, + "attrs": { + "private": false, + "account": { + "__entity": { + "type": "PhotoApp::Account", + "id": "ahmad" + } + } + }, + "parents": [] + }, + { + "uid": { + "type": "PhotoApp::UserGroup", + "id": "alice_friends" + }, + "attrs": {}, + "parents": [] + }, + { + "uid": { + "type": "PhotoApp::UserGroup", + "id": "AVTeam" + }, + "attrs": {}, + "parents": [] + } + ], + "schema":r#"namespace PhotoApp { + type ContextType = { + "authenticated": __cedar::Bool, + "ip"?: __cedar::ipaddr + }; + + type PersonType = { + "age": __cedar::Long, + "name": __cedar::String + }; + + entity Account; + + entity Album; + + entity Photo in [Album, Account] = { + "account": Account, + "private": __cedar::Bool + }; + + entity User in [UserGroup] = { + "personInformation": PersonType, + "userId": __cedar::String + }; + + entity UserGroup; + }"# + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_failure(&result); + } + #[test] #[should_panic] fn validate_entities_invalid_json_fails() { @@ -651,6 +834,50 @@ mod entity_validation_tests { ); } + #[test] + fn validate_entities_invalid_cedarschema_fails() { + let json_data = json!( + { + "entities": [ + + ], + "schema": r#"namespace PhotoApp { + type ContextType = { + "authenticated": __cedar::Bool, + "ip"?: __cedar::ipaddr + }; + + type PersonType = { + "age": __cedar::Long, + "name": __cedar::String + }; + + entity Account; + + entity Album; + + entity Photo in [Album, Account] = { + "account": Account, + "private": __cedar::Tool + }; + + entity User in [UserGroup] = { + "personInformation": PersonType, + "userId": __cedar::String + }; + + entity UserGroup; + }"# + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_failure(&result); + + assert!( + result.contains("failed to resolve type: __cedar::Tool"), + "result was `{result}`", + ); + } + #[test] fn validate_entities_detect_cycle_fails() { let json_data = json!( @@ -709,6 +936,52 @@ mod entity_validation_tests { "result was `{result}`", ); } + + #[test] + fn validate_entities_with_cedarschema_detect_cycle_fails() { + let json_data = json!( + { + "entities": [ + { + "uid": { + "type": "PhotoApp::UserGroup", + "id": "ABCTeam" + }, + "attrs": {}, + "parents": [ + { + "type": "PhotoApp::UserGroup", + "id": "AVTeam" + } + ] + }, + { + "uid": { + "type": "PhotoApp::UserGroup", + "id": "AVTeam" + }, + "attrs": {}, + "parents": [ + { + "type": "PhotoApp::UserGroup", + "id": "ABCTeam" + } + ] + } + ], + "schema": r#"namespace PhotoApp { + entity UserGroup in [UserGroup]; + } + "# + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_failure(&result); + + assert!( + result.contains("input graph has a cycle containing vertex `PhotoApp::UserGroup"), + "result was `{result}`", + ); + } } #[cfg(feature = "partial-eval")]