diff --git a/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java b/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java index f99985f..25f64dc 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java +++ b/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java @@ -80,6 +80,17 @@ PartialAuthorizationResponse isAuthorizedPartial(PartialAuthorizationRequest req */ ValidationResponse validate(ValidationRequest request) throws AuthException; + /** + * Asks whether the entities in the given {@link EntityValidationRequest} q are correct + * when validated against the schema it describes. + * + * @param request The request containing the entities to validate and the schema to validate them + * against. + * @throws BadRequestException if any errors were found in the syntax of the entities. + * @throws AuthException if any internal errors occurred while validating the entities. + */ + void validateEntities(EntityValidationRequest request) throws AuthException; + /** * Get the Cedar language major version (e.g., "1.2") used by this CedarJava library. * diff --git a/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java b/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java index 173d7d3..2832899 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java +++ b/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java @@ -24,15 +24,20 @@ import com.cedarpolicy.loader.LibraryLoader; import com.cedarpolicy.model.*; import com.cedarpolicy.model.exception.AuthException; +import com.cedarpolicy.model.exception.BadRequestException; import com.cedarpolicy.model.exception.InternalException; import com.cedarpolicy.model.exception.MissingExperimentalFeatureException; import com.cedarpolicy.model.entity.Entity; import com.cedarpolicy.model.policy.PolicySet; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.List; import java.util.Set; /** An authorization engine that is compiled in process. Communicated with via JNI. */ @@ -73,6 +78,18 @@ public ValidationResponse validate(ValidationRequest q) throws AuthException { return call("ValidateOperation", ValidationResponse.class, q); } + @Override + public void validateEntities(EntityValidationRequest q) throws AuthException { + EntityValidationResponse entityValidationResponse = call("ValidateEntities", EntityValidationResponse.class, q); + if (!entityValidationResponse.success) { + if (entityValidationResponse.isInternal) { + throw new InternalException(entityValidationResponse.errors.toArray(new String[0])); + } else { + throw new BadRequestException(entityValidationResponse.errors.toArray(new String[0])); + } + } + } + private static RESP call(String operation, Class responseClass, REQ request) throws AuthException { try { @@ -100,6 +117,40 @@ private static RESP call(String operation, Class responseClass } } + /** + * The result of processing an EntityValidationRequest. + */ + @JsonIgnoreProperties({"result"}) // Ignore only the 'result' field + private static final class EntityValidationResponse { + + /** A string that indicates if the operation was successful.*/ + private final boolean success; + + /** A boolean flag that indicates whether the error is internal.*/ + private final boolean isInternal; + + /** A list of error messages encountered during the operation.*/ + private final List errors; + + /** + * Parameterized constructor for initializing all fields of EntityValidationResponse. + * + * @param success A boolean indicating success status. + * @param isInternal A boolean indicating if the error is internal. + * @param errors A list of error messages. + */ + @JsonCreator + @SuppressFBWarnings + EntityValidationResponse( + @JsonProperty("success") boolean success, + @JsonProperty("isInternal") boolean isInternal, + @JsonProperty("errors") List errors) { + this.success = success; + this.isInternal = isInternal; + this.errors = errors; + } + } + private static class AuthorizationRequest extends com.cedarpolicy.model.AuthorizationRequest { @JsonProperty private final PolicySet policies; @JsonProperty private final Set entities; diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/EntityValidationRequest.java b/CedarJava/src/main/java/com/cedarpolicy/model/EntityValidationRequest.java new file mode 100644 index 0000000..fb48563 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/model/EntityValidationRequest.java @@ -0,0 +1,83 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + package com.cedarpolicy.model; + + import java.util.List; + import java.util.Objects; + + import com.cedarpolicy.model.entity.Entity; + import com.cedarpolicy.model.schema.Schema; + import com.fasterxml.jackson.annotation.JsonProperty; + import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + + /** + * Information passed to Cedar for entities validation. + */ + public final class EntityValidationRequest { + @JsonProperty("schema") + private final Schema schema; + @JsonProperty("entities") + private final List entities; + + /** + * Construct a validation request. + * + * @param schema Schema for the request + * @param entities Map. + */ + @SuppressFBWarnings + public EntityValidationRequest(Schema schema, List entities) { + if (schema == null) { + throw new NullPointerException("schema"); + } + + if (entities == null) { + throw new NullPointerException("entities"); + } + + this.schema = schema; + this.entities = entities; + } + + /** + * Test equality. + */ + @Override + public boolean equals(final Object o) { + if (!(o instanceof EntityValidationRequest)) { + return false; + } + + final EntityValidationRequest other = (EntityValidationRequest) o; + return schema.equals(other.schema) && entities.equals(other.entities); + } + + /** + * Hash. + */ + @Override + public int hashCode() { + return Objects.hash(schema, entities); + } + + /** + * Get readable string representation. + */ + public String toString() { + return "EntityValidationRequest(schema=" + schema + ", entities=" + entities + ")"; + } + } diff --git a/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java b/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java new file mode 100644 index 0000000..812e1a6 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java @@ -0,0 +1,109 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cedarpolicy; + +import static com.cedarpolicy.TestUtil.loadSchemaResource; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeAll; + +import org.junit.jupiter.api.Test; + +import java.util.List; + + +import com.cedarpolicy.model.EntityValidationRequest; +import com.cedarpolicy.model.entity.Entity; +import com.cedarpolicy.model.exception.AuthException; +import com.cedarpolicy.model.exception.BadRequestException; +import com.cedarpolicy.model.schema.Schema; +import com.cedarpolicy.pbt.EntityGen; +import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.value.PrimBool; + + +/** + * Tests for entity validator + */ +public class EntityValidationTests { + private static EntityGen entityGen; + private static AuthorizationEngine engine; + + /** + * Test that a valid entity is accepted. + */ + @Test + public void testValidEntity() throws AuthException { + Entity entity = EntityValidationTests.entityGen.arbitraryEntity(); + + EntityValidationRequest r = new EntityValidationRequest( + ROLE_SCHEMA, List.of(entity)); + + engine.validateEntities(r); + } + + /** + * Test that an entity with an attribute not specified in the schema throws an exception. + */ + @Test + public void testEntityWithUnknownAttribute() throws AuthException { + Entity entity = EntityValidationTests.entityGen.arbitraryEntity(); + entity.attrs.put("test", new PrimBool(true)); + + EntityValidationRequest request = new EntityValidationRequest(ROLE_SCHEMA, List.of(entity)); + + 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"), + "Expected to match regex but was: '%s'".formatted(errMsg)); + } + + /** + * Test that entities with a cyclic parent relationship throw an exception. + */ + @Test + public void testEntitiesWithCyclicParentRelationship() 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 request = new EntityValidationRequest(ROLE_SCHEMA, List.of(parentEntity, childEntity)); + + 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)); + } + + @BeforeAll + public static void setUp() { + + engine = new BasicAuthorizationEngine(); + EntityTypeName user = EntityTypeName.parse("Role").get(); + + EntityValidationTests.entityGen = new EntityGen(user); + } + + private static final Schema ROLE_SCHEMA = loadSchemaResource("/role_schema.json"); +} diff --git a/CedarJava/src/test/resources/role_schema.json b/CedarJava/src/test/resources/role_schema.json new file mode 100644 index 0000000..de1ae29 --- /dev/null +++ b/CedarJava/src/test/resources/role_schema.json @@ -0,0 +1,12 @@ +{ + "": { + "entityTypes": { + "Role": { + "memberOfTypes": [ + "Role" + ] + } + }, + "actions": {} + } +} diff --git a/CedarJavaFFI/src/interface.rs b/CedarJavaFFI/src/interface.rs index 0017af3..1347845 100644 --- a/CedarJavaFFI/src/interface.rs +++ b/CedarJavaFFI/src/interface.rs @@ -14,11 +14,12 @@ * limitations under the License. */ +use cedar_policy::entities_errors::EntitiesError; #[cfg(feature = "partial-eval")] use cedar_policy::ffi::is_authorized_partial_json_str; use cedar_policy::{ ffi::{is_authorized_json_str, validate_json_str}, - EntityUid, Policy, PolicySet, Schema, Template, + Entities, EntityUid, Policy, PolicySet, Schema, Template, }; use cedar_policy_formatter::{policies_str_to_pretty, Config}; use jni::{ @@ -28,6 +29,7 @@ use jni::{ }; use jni_fn::jni_fn; use serde::{Deserialize, Serialize}; +use serde_json::{from_str, Value}; use std::{error::Error, str::FromStr, thread}; use crate::{ @@ -43,6 +45,7 @@ const V0_AUTH_OP: &str = "AuthorizationOperation"; #[cfg(feature = "partial-eval")] const V0_AUTH_PARTIAL_OP: &str = "AuthorizationPartialOperation"; const V0_VALIDATE_OP: &str = "ValidateOperation"; +const V0_VALIDATE_ENTITIES: &str = "ValidateEntities"; fn build_err_obj(env: &JNIEnv<'_>, err: &str) -> jstring { env.new_string( @@ -115,6 +118,7 @@ pub(crate) fn call_cedar(call: &str, input: &str) -> String { #[cfg(feature = "partial-eval")] V0_AUTH_PARTIAL_OP => is_authorized_partial_json_str(input), V0_VALIDATE_OP => validate_json_str(input), + V0_VALIDATE_ENTITIES => json_validate_entities(&input), _ => { let ires = Answer::fail_internally(format!("unsupported operation: {}", call)); serde_json::to_string(&ires) @@ -125,6 +129,43 @@ pub(crate) fn call_cedar(call: &str, input: &str) -> String { }) } +#[derive(Serialize, Deserialize)] +struct ValidateEntityCall { + schema: Value, + entities: Value, +} + +pub fn json_validate_entities(input: &str) -> serde_json::Result { + let ans = validate_entities(input)?; + serde_json::to_string(&ans) +} + +/// public string-based JSON interface to be invoked by FFIs. Takes in a `ValidateEntityCall` and (if successful) +/// 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(), + }), + } + } + } +} + #[derive(Debug, Serialize, Deserialize)] struct JavaInterfaceCall { pub call: String, diff --git a/CedarJavaFFI/src/tests.rs b/CedarJavaFFI/src/tests.rs index d77b0b1..f0b9688 100644 --- a/CedarJavaFFI/src/tests.rs +++ b/CedarJavaFFI/src/tests.rs @@ -29,6 +29,14 @@ fn assert_failure(result: String) { assert_matches!(result, Answer::Failure { .. }); } +fn assert_success(result: String) { + let result: Answer = serde_json::from_str(result.as_str()).unwrap(); + match result { + Answer::Success { .. } => {} + Answer::Failure { .. } => panic!("expected a success, not {:?}", result), + }; +} + #[track_caller] fn assert_authorization_success(result: String) { let result: AuthorizationAnswer = serde_json::from_str(result.as_str()).unwrap(); @@ -190,6 +198,408 @@ mod validation_tests { } } +mod entity_validation_tests { + use super::*; + use serde_json::json; + + #[test] + fn validate_entities_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":{ + "PhotoApp": { + "commonTypes": { + "PersonType": { + "type": "Record", + "attributes": { + "age": { + "type": "Long" + }, + "name": { + "type": "String" + } + } + }, + "ContextType": { + "type": "Record", + "attributes": { + "ip": { + "type": "Extension", + "name": "ipaddr", + "required": false + }, + "authenticated": { + "type": "Boolean", + "required": true + } + } + } + }, + "entityTypes": { + "User": { + "shape": { + "type": "Record", + "attributes": { + "userId": { + "type": "String" + }, + "personInformation": { + "type": "PersonType" + } + } + }, + "memberOfTypes": [ + "UserGroup" + ] + }, + "UserGroup": { + "shape": { + "type": "Record", + "attributes": {} + } + }, + "Photo": { + "shape": { + "type": "Record", + "attributes": { + "account": { + "type": "Entity", + "name": "Account", + "required": true + }, + "private": { + "type": "Boolean", + "required": true + } + } + }, + "memberOfTypes": [ + "Album", + "Account" + ] + }, + "Album": { + "shape": { + "type": "Record", + "attributes": {} + } + }, + "Account": { + "shape": { + "type": "Record", + "attributes": {} + } + } + }, + "actions": {} + } + } + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_success(result); + } + + #[test] + fn validate_entities_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":{ + "PhotoApp": { + "commonTypes": { + "PersonType": { + "type": "Record", + "attributes": { + "age": { + "type": "Long" + }, + "name": { + "type": "String" + } + } + }, + "ContextType": { + "type": "Record", + "attributes": { + "ip": { + "type": "Extension", + "name": "ipaddr", + "required": false + }, + "authenticated": { + "type": "Boolean", + "required": true + } + } + } + }, + "entityTypes": { + "User": { + "shape": { + "type": "Record", + "attributes": { + "userId": { + "type": "String" + }, + "personInformation": { + "type": "PersonType" + } + } + }, + "memberOfTypes": [ + "UserGroup" + ] + }, + "UserGroup": { + "shape": { + "type": "Record", + "attributes": {} + } + }, + "Photo": { + "shape": { + "type": "Record", + "attributes": { + "account": { + "type": "Entity", + "name": "Account", + "required": true + }, + "private": { + "type": "Boolean", + "required": true + } + } + }, + "memberOfTypes": [ + "Album", + "Account" + ] + }, + "Album": { + "shape": { + "type": "Record", + "attributes": {} + } + }, + "Account": { + "shape": { + "type": "Record", + "attributes": {} + } + } + }, + "actions": {} + } + } + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_failure(result); + } + + #[test] + #[should_panic] + fn validate_entities_invalid_json_fails() { + call_cedar("ValidateEntities", "{]"); + } + + #[test] + fn validate_entities_invalid_schema_fails() { + let json_data = json!( + { + "entities": [ + + ], + "schema": { + "PhotoApp": { + "commonTypes": {}, + "entityTypes": { + "UserGroup": { + "shape44": { + "type": "Record", + "attributes": {} + }, + "memberOfTypes": [ + "UserGroup" + ] + } + }, + "actions": {} + } + } + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_failure(result.clone()); + + assert!(result.contains("unknown field `shape44`, expected `memberOfTypes` or `shape`")); + } + + #[test] + fn validate_entities_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": { + "PhotoApp": { + "commonTypes": {}, + "entityTypes": { + "UserGroup": { + "shape": { + "type": "Record", + "attributes": {} + }, + "memberOfTypes": [ + "UserGroup" + ] + } + }, + "actions": {} + } + } + }); + let result = call_cedar("ValidateEntities", json_data.to_string().as_str()); + assert_failure(result.clone()); + + assert!(result.contains("input graph has a cycle containing vertex `PhotoApp::UserGroup")); + } +} + #[cfg(feature = "partial-eval")] mod partial_authorization_tests { use super::*;