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::*;