diff --git a/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java b/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java
index abcde75e..e3fc5ef4 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java
@@ -30,6 +30,7 @@
import com.cedarpolicy.model.exception.AuthException;
import com.cedarpolicy.model.exception.BadRequestException;
import com.cedarpolicy.model.policy.PolicySet;
+import com.cedarpolicy.model.LevelValidationRequest;
/**
* Implementations of the AuthorizationEngine interface invoke Cedar to respond to an authorization
@@ -120,6 +121,18 @@ PartialAuthorizationResponse isAuthorizedPartial(PartialAuthorizationRequest req
*/
ValidationResponse validate(ValidationRequest request) throws AuthException;
+ /**
+ * Asks whether the policies in the given {@link LevelValidationRequest} q are correct
+ * when validated against the schema it describes. If validation passes, run level validation (RFC 76)
+ *
+ * @param request The request containing the policies to validate, the schema to validate them
+ * against and maximum dereferencing level.
+ * @return A {@link ValidationResponse} describing any validation errors found in the policies.
+ * @throws BadRequestException if any errors were found in the syntax of the policies.
+ * @throws AuthException if any internal errors occurred while validating the policies.
+ */
+ ValidationResponse validateWithLevel(LevelValidationRequest request) throws AuthException;
+
/**
* Asks whether the entities in the given {@link EntityValidationRequest} q are correct
* when validated against the schema it describes.
diff --git a/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java b/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java
index 1b001bb6..e794cdbe 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java
@@ -26,6 +26,7 @@
import com.cedarpolicy.loader.LibraryLoader;
import com.cedarpolicy.model.AuthorizationResponse;
import com.cedarpolicy.model.EntityValidationRequest;
+import com.cedarpolicy.model.LevelValidationRequest;
import com.cedarpolicy.model.PartialAuthorizationResponse;
import com.cedarpolicy.model.ValidationRequest;
import com.cedarpolicy.model.ValidationResponse;
@@ -57,7 +58,7 @@ public BasicAuthorizationEngine() {
@Override
public AuthorizationResponse isAuthorized(com.cedarpolicy.model.AuthorizationRequest q,
- PolicySet policySet, Set entities) throws AuthException {
+ PolicySet policySet, Set entities) throws AuthException {
final AuthorizationRequest request = new AuthorizationRequest(q, policySet, entities);
return call("AuthorizationOperation", AuthorizationResponse.class, request);
}
@@ -67,14 +68,14 @@ public AuthorizationResponse isAuthorized(com.cedarpolicy.model.AuthorizationReq
*/
@Override
public AuthorizationResponse isAuthorized(com.cedarpolicy.model.AuthorizationRequest q,
- PolicySet policySet, Entities entities) throws AuthException {
+ PolicySet policySet, Entities entities) throws AuthException {
return isAuthorized(q, policySet, entities.getEntities());
}
@Experimental(ExperimentalFeature.PARTIAL_EVALUATION)
@Override
public PartialAuthorizationResponse isAuthorizedPartial(com.cedarpolicy.model.PartialAuthorizationRequest q,
- PolicySet policySet, Set entities) throws AuthException {
+ PolicySet policySet, Set entities) throws AuthException {
try {
final PartialAuthorizationRequest request = new PartialAuthorizationRequest(q, policySet, entities);
return call("AuthorizationPartialOperation", PartialAuthorizationResponse.class, request);
@@ -93,7 +94,7 @@ public PartialAuthorizationResponse isAuthorizedPartial(com.cedarpolicy.model.Pa
@Experimental(ExperimentalFeature.PARTIAL_EVALUATION)
@Override
public PartialAuthorizationResponse isAuthorizedPartial(com.cedarpolicy.model.PartialAuthorizationRequest q,
- PolicySet policySet, Entities entities) throws AuthException {
+ PolicySet policySet, Entities entities) throws AuthException {
return isAuthorizedPartial(q, policySet, entities.getEntities());
}
@@ -102,6 +103,11 @@ public ValidationResponse validate(ValidationRequest q) throws AuthException {
return call("ValidateOperation", ValidationResponse.class, q);
}
+ @Override
+ public ValidationResponse validateWithLevel(LevelValidationRequest q) throws AuthException {
+ return call("ValidateWithLevelOperation", ValidationResponse.class, q);
+ }
+
@Override
public void validateEntities(EntityValidationRequest q) throws AuthException {
EntityValidationResponse entityValidationResponse = call("ValidateEntities", EntityValidationResponse.class, q);
@@ -199,12 +205,12 @@ private static final class PartialAuthorizationRequest extends com.cedarpolicy.m
PartialAuthorizationRequest(com.cedarpolicy.model.PartialAuthorizationRequest request, PolicySet policySet, Set entities) {
super(
- request.principal,
- request.action,
- request.resource,
- request.context,
- request.schema,
- request.enableRequestValidation);
+ request.principal,
+ request.action,
+ request.resource,
+ request.context,
+ request.schema,
+ request.enableRequestValidation);
this.policies = policySet;
this.entities = entities;
}
diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/LevelValidationRequest.java b/CedarJava/src/main/java/com/cedarpolicy/model/LevelValidationRequest.java
new file mode 100644
index 00000000..166149b8
--- /dev/null
+++ b/CedarJava/src/main/java/com/cedarpolicy/model/LevelValidationRequest.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.model;
+
+import com.cedarpolicy.model.schema.Schema;
+import com.cedarpolicy.model.policy.PolicySet;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+import java.util.Objects;
+
+/** Information passed to Cedar for level validation. */
+public final class LevelValidationRequest {
+ private final Schema schema;
+ private final PolicySet policies;
+ private final long maxDerefLevel; // Must be non-negative (>=0)
+
+ /**
+ * Construct a validation request.
+ *
+ * @param schema Schema for the request
+ * @param policies Map of Policy ID to policy
+ * @param maxDerefLevel Maximum level of dereferencing allowed for validation. Must be non-negative (>=0)
+ */
+ @SuppressFBWarnings
+ public LevelValidationRequest(Schema schema, PolicySet policies, long maxDerefLevel) {
+ if (schema == null) {
+ throw new NullPointerException("schema");
+ }
+
+ if (policies == null) {
+ throw new NullPointerException("policies");
+ }
+
+ if (maxDerefLevel < 0) {
+ throw new IllegalArgumentException("maxDerefLevel must be non-negative");
+ }
+
+ this.schema = schema;
+ this.policies = policies;
+ this.maxDerefLevel = maxDerefLevel;
+ }
+
+ /**
+ * Get the schema.
+ *
+ * @return The schema.
+ */
+ public Schema getSchema() {
+ return this.schema;
+ }
+
+ /**
+ * Get the policy set.
+ *
+ * @return A `PolicySet` object
+ */
+ @JsonProperty("policies")
+ public PolicySet getPolicySet() {
+ return this.policies;
+ }
+
+ /**
+ * Get the maximum deref level.
+ *
+ * @return The maximum deref level value for validation
+ */
+ public long getMaxDerefLevel() {
+ return this.maxDerefLevel;
+ }
+
+ /** Test equality. */
+ @Override
+ public boolean equals(final Object o) {
+ if (!(o instanceof LevelValidationRequest)) {
+ return false;
+ }
+
+ final LevelValidationRequest other = (LevelValidationRequest) o;
+ return schema.equals(other.schema) && policies.equals(other.policies) && maxDerefLevel == other.maxDerefLevel;
+ }
+
+ /** Hash. */
+ @Override
+ public int hashCode() {
+ return Objects.hash(schema, policies, maxDerefLevel);
+ }
+
+ /** Get readable string representation. */
+ public String toString() {
+ return "ValidationRequest(schema=" + schema + ", policies=" + policies + ", maxDerefLevel=" + maxDerefLevel
+ + ")";
+ }
+}
diff --git a/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java b/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java
index dcd0cbcb..2478d400 100644
--- a/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java
+++ b/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java
@@ -23,6 +23,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.cedarpolicy.model.DetailedError;
+import com.cedarpolicy.model.LevelValidationRequest;
import com.cedarpolicy.model.ValidationRequest;
import com.cedarpolicy.model.ValidationResponse;
import com.cedarpolicy.model.ValidationResponse.SuccessOrFailure;
@@ -56,6 +57,8 @@ public void givenEmptySchemaAndNoPolicyReturnsValid() {
givenSchema(EMPTY_SCHEMA);
ValidationResponse response = whenValidated();
thenIsValid(response);
+ ValidationResponse levelResponse = whenLevelValidated(1);
+ thenIsValid(levelResponse);
}
/** Test. */
@@ -71,6 +74,8 @@ public void givenExampleSchemaAndCorrectPolicyReturnsValid() {
+ ");");
ValidationResponse response = whenValidated();
thenIsValid(response);
+ ValidationResponse levelResponse = whenLevelValidated(1);
+ thenIsValid(levelResponse);
}
/** Test. */
@@ -86,6 +91,8 @@ public void givenExampleSchemaAndIncorrectPolicyReturnsValid() {
+ ");");
ValidationResponse response = whenValidated();
thenIsNotValid(response);
+ ValidationResponse levelResponse = whenLevelValidated(1);
+ thenIsNotValid(levelResponse);
}
/** Test. */
@@ -95,6 +102,8 @@ public void givenInvalidPolicyThrowsBadRequestError() {
givenPolicy("policy0", "permit { }");
ValidationResponse response = whenValidated();
thenValidationFailed(response);
+ ValidationResponse levelResponse = whenLevelValidated(1);
+ thenValidationFailed(levelResponse);
}
@Test
@@ -119,6 +128,8 @@ public void validateTemplateLinkedPolicySuccessTest() {
this.policies = new PolicySet(new HashSet<>(), templates, templateLinks);
ValidationResponse response = whenValidated();
thenIsValid(response);
+ ValidationResponse levelResponse = whenLevelValidated(1);
+ thenIsValid(levelResponse);
}
@Test
@@ -136,34 +147,79 @@ public void validateTemplateLinkedPolicyFailsWhenExpected() {
// fails if we provide a value for the wrong slot
this.policies = new PolicySet(new HashSet<>(), templates,
List.of(new TemplateLink("template1", "policy", List.of(principalLink))));
- ValidationResponse response2 = whenValidated();
- thenValidationFailed(response2);
+ ValidationResponse response1 = whenValidated();
+ thenValidationFailed(response1);
+ ValidationResponse levelResponse1 = whenLevelValidated(1);
+ thenValidationFailed(levelResponse1);
// fails if we provide a value for too many slots
this.policies = new PolicySet(new HashSet<>(), templates,
List.of(new TemplateLink("template2", "policy", List.of(principalLink, resourceLink))));
- ValidationResponse response3 = whenValidated();
- thenValidationFailed(response3);
+ ValidationResponse response2 = whenValidated();
+ thenValidationFailed(response2);
+ ValidationResponse levelResponse2 = whenLevelValidated(1);
+ thenValidationFailed(levelResponse2);
// fails if we don't provide a value for all slots
this.policies = new PolicySet(new HashSet<>(), templates,
List.of(new TemplateLink("template0", "policy", List.of(resourceLink))));
- ValidationResponse response4 = whenValidated();
- thenValidationFailed(response4);
+ ValidationResponse response3 = whenValidated();
+ thenValidationFailed(response3);
+ ValidationResponse levelResponse3 = whenLevelValidated(1);
+ thenValidationFailed(levelResponse3);
+
// validation returns an error if we provide a link with the wrong type
LinkValue badLink1 = new LinkValue("?resource", EntityUID.parse("Library::User::\"Victor\"").get());
this.policies = new PolicySet(new HashSet<>(), templates,
List.of(new TemplateLink("template1", "policy", List.of(badLink1))));
- ValidationResponse response5 = whenValidated();
- thenIsNotValid(response5);
+ ValidationResponse response4 = whenValidated();
+ thenIsNotValid(response4);
+ ValidationResponse levelResponse4 = whenLevelValidated(1);
+ thenIsNotValid(levelResponse4);
// validation returns an error if we provide a link with an invalid type
LinkValue badLink2 = new LinkValue("?resource", EntityUID.parse("Library::BOOK::\"The black Swan\"").get());
this.policies = new PolicySet(new HashSet<>(), templates,
List.of(new TemplateLink("template1", "policy", List.of(badLink2))));
- ValidationResponse response6 = whenValidated();
- thenIsNotValid(response6);
+ ValidationResponse response5 = whenValidated();
+ thenIsNotValid(response5);
+ ValidationResponse levelResponse5 = whenLevelValidated(1);
+ thenIsNotValid(levelResponse5);
+ }
+
+ @Test
+ public void validateLevelPolicySuccessTest() {
+ givenSchema(LEVEL_SCHEMA);
+ givenPolicy(
+ "policy0",
+ "permit(\n"
+ + " principal in UserGroup::\"alice_friends\",\n"
+ + " action == Action::\"viewPhoto\",\n"
+ + " resource\n"
+ + ") when {principal in resource.owner.friend};");
+
+ ValidationResponse response = whenValidated();
+ thenIsValid(response);
+ ValidationResponse levelResponse = whenLevelValidated(2);
+ thenIsValid(levelResponse);
+ }
+
+ @Test
+ public void validateLevelPolicyFailsWhenExpected() {
+ givenSchema(LEVEL_SCHEMA);
+ givenPolicy(
+ "policy0",
+ "permit(\n"
+ + " principal in UserGroup::\"alice_friends\",\n"
+ + " action == Action::\"viewPhoto\",\n"
+ + " resource\n"
+ + ") when {principal in resource.owner.friend};");
+
+ ValidationResponse response = whenValidated();
+ thenIsValid(response);
+ ValidationResponse levelResponse = whenLevelValidated(1);
+ thenIsNotValid(levelResponse);
}
private void givenSchema(Schema testSchema) {
@@ -180,6 +236,11 @@ private ValidationResponse whenValidated() {
return assertDoesNotThrow(() -> engine.validate(request));
}
+ private ValidationResponse whenLevelValidated(long maxDerefLevel) {
+ LevelValidationRequest request = new LevelValidationRequest(schema, policies, maxDerefLevel);
+ return assertDoesNotThrow(() -> engine.validateWithLevel(request));
+ }
+
private void thenIsValid(ValidationResponse response) {
assertEquals(response.type, SuccessOrFailure.Success);
final ValidationSuccessResponse success = assertDoesNotThrow(() -> response.success.get());
@@ -223,4 +284,5 @@ private void reset() {
private static final Schema EMPTY_SCHEMA = loadSchemaResource("/empty_schema.json");
private static final Schema PHOTOFLASH_SCHEMA = loadSchemaResource("/photoflash_schema.json");
private static final Schema LIBRARY_SCHEMA = loadSchemaResource("/library_schema.json");
+ private static final Schema LEVEL_SCHEMA = loadSchemaResource("/level_schema.json");
}
diff --git a/CedarJava/src/test/resources/level_schema.json b/CedarJava/src/test/resources/level_schema.json
new file mode 100644
index 00000000..ffa7d9f0
--- /dev/null
+++ b/CedarJava/src/test/resources/level_schema.json
@@ -0,0 +1,63 @@
+{
+"": {
+ "entityTypes": {
+ "User": {
+ "memberOfTypes": [ "UserGroup" ],
+ "shape": {
+ "type": "Record",
+ "attributes": {
+ "friend": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Photo": {
+ "memberOfTypes": [ "Album", "Account" ],
+ "shape":{
+ "type": "Record",
+ "attributes": {
+ "owner": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Album": {
+ "memberOfTypes": [ "Album", "Account" ]
+ },
+ "Account": { },
+ "UserGroup": {}
+ },
+ "actions": {
+ "readOnly": { },
+ "readWrite": { },
+ "createAlbum": {
+ "appliesTo": {
+ "resourceTypes": [ "Account", "Album" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "addPhotoToAlbum": {
+ "appliesTo": {
+ "resourceTypes": [ "Album" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "viewPhoto": {
+ "appliesTo": {
+ "resourceTypes": [ "Photo" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "viewComments": {
+ "appliesTo": {
+ "resourceTypes": [ "Photo" ],
+ "principalTypes": [ "User" ]
+ }
+ }
+ }
+ }
+}
diff --git a/CedarJavaFFI/Cargo.toml b/CedarJavaFFI/Cargo.toml
index 386af560..7805f68f 100644
--- a/CedarJavaFFI/Cargo.toml
+++ b/CedarJavaFFI/Cargo.toml
@@ -13,6 +13,7 @@ serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
thiserror = "2.0"
itertools = "0.14"
+miette = "7.6.0"
# JNI Support
jni = "0.21.1"
diff --git a/CedarJavaFFI/src/helpers.rs b/CedarJavaFFI/src/helpers.rs
new file mode 100644
index 00000000..4d989935
--- /dev/null
+++ b/CedarJavaFFI/src/helpers.rs
@@ -0,0 +1,364 @@
+/*
+ * 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.
+ */
+use cedar_policy::{
+ ffi::{
+ JsonValueWithNoDuplicateKeys, PolicySet as FFIPolicySet, ValidationAnswer, ValidationError,
+ },
+ PolicySet, Schema, SchemaFragment, SchemaWarning, ValidationMode, Validator,
+};
+use miette::Context;
+use serde::{Deserialize, Serialize};
+
+/// Configuration for the validation call
+#[derive(Serialize, Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase")]
+#[serde(deny_unknown_fields)]
+pub struct ValidationSettings {
+ /// Used to control how a policy is validated. See comments on [`ValidationMode`].
+ mode: ValidationMode,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+#[serde(deny_unknown_fields)]
+pub struct LevelValidationCall {
+ /// Validation settings
+ #[serde(default)]
+ pub validation_settings: ValidationSettings,
+ /// Schema to use for validation
+ pub schema: FFISchema,
+ /// Policies to validate
+ pub policies: FFIPolicySet,
+ /// Max deref level
+ pub max_deref_level: u32,
+}
+
+pub struct WithWarnings {
+ pub t: T,
+ pub warnings: Vec,
+}
+
+/// Represents a schema in either the Cedar or JSON schema format
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(untagged)]
+#[serde(
+ expecting = "expected a schema in the Cedar or JSON policy format (with no duplicate keys)"
+)]
+pub enum FFISchema {
+ /// Schema in the Cedar schema format. See
+ Cedar(String),
+ /// Schema in Cedar's JSON schema format. See
+ Json(JsonValueWithNoDuplicateKeys),
+}
+
+impl FFISchema {
+ /// Parse a [`Schema`] into a [`crate::Schema`]
+ pub(super) fn parse(
+ self,
+ ) -> Result<(Schema, Box>), miette::Report> {
+ let (schema_frag, warnings) = self.parse_schema_fragment()?;
+ Ok((schema_frag.try_into()?, warnings))
+ }
+
+ /// Return a [`crate::SchemaFragment`], which can be printed with `.to_string()`
+ /// and converted to JSON with `.to_json()`.
+ pub(super) fn parse_schema_fragment(
+ self,
+ ) -> Result<(SchemaFragment, Box>), miette::Report> {
+ match self {
+ Self::Cedar(str) => SchemaFragment::from_cedarschema_str(&str)
+ .map(|(sch, warnings)| {
+ (
+ sch,
+ Box::new(warnings) as Box>,
+ )
+ })
+ .wrap_err("failed to parse schema from string"),
+ Self::Json(val) => SchemaFragment::from_json_value(val.into())
+ .map(|sch| {
+ (
+ sch,
+ Box::new(std::iter::empty()) as Box>,
+ )
+ })
+ .wrap_err("failed to parse schema from JSON"),
+ }
+ }
+}
+
+impl LevelValidationCall {
+ fn get_components(
+ self,
+ ) -> WithWarnings>>
+ {
+ let mut errs = vec![];
+ let policies = match self.policies.parse() {
+ Ok(policies) => policies,
+ Err(e) => {
+ errs.extend(e);
+ PolicySet::new()
+ }
+ };
+ let pair = match self.schema.parse() {
+ Ok((schema, warnings)) => Some((schema, warnings)),
+ Err(e) => {
+ errs.push(e);
+ None
+ }
+ };
+ match (errs.is_empty(), pair) {
+ (true, Some((schema, warnings))) => WithWarnings {
+ t: Ok((
+ policies,
+ schema,
+ self.validation_settings,
+ self.max_deref_level,
+ )),
+ warnings: warnings.map(miette::Report::new).collect(),
+ },
+ _ => WithWarnings {
+ t: Err(errs),
+ warnings: vec![],
+ },
+ }
+ }
+}
+
+pub fn validate_with_level_json_str(json: &str) -> Result {
+ let ans = validate_with_level(serde_json::from_str(json)?);
+ serde_json::to_string(&ans)
+}
+
+pub fn validate_with_level(call: LevelValidationCall) -> ValidationAnswer {
+ match call.get_components() {
+ WithWarnings {
+ t: Ok((policies, schema, settings, max_deref_level)),
+ warnings,
+ } => {
+ let validator = Validator::new(schema);
+
+ let validation_result =
+ validator.validate_with_level(&policies, settings.mode, max_deref_level);
+ let validation_errors = validation_result.validation_errors();
+ let validation_warnings = validation_result.validation_warnings();
+
+ let validation_errors: Vec = validation_errors
+ .map(|error| ValidationError {
+ policy_id: error.policy_id().clone(),
+ error: miette::Report::new(error.clone()).into(),
+ })
+ .collect();
+ let validation_warnings: Vec = validation_warnings
+ .map(|error| ValidationError {
+ policy_id: error.policy_id().clone(),
+ error: miette::Report::new(error.clone()).into(),
+ })
+ .collect();
+ ValidationAnswer::Success {
+ validation_errors,
+ validation_warnings,
+ other_warnings: warnings.into_iter().map(Into::into).collect(),
+ }
+ }
+ WithWarnings {
+ t: Err(errors),
+ warnings,
+ } => ValidationAnswer::Failure {
+ errors: errors.into_iter().map(Into::into).collect(),
+ warnings: warnings.into_iter().map(Into::into).collect(),
+ },
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use crate::helpers::validate_with_level;
+ use cedar_policy::ffi::{ValidationAnswer, ValidationError};
+ use cool_asserts::assert_matches;
+ use serde_json::json;
+
+ #[track_caller]
+ fn assert_validates_without_errors(json: serde_json::Value) {
+ let ans = validate_with_level(serde_json::from_value(json).unwrap());
+ let ans_val = serde_json::to_value(ans).unwrap();
+ let result: Result = serde_json::from_value(ans_val);
+ assert_matches!(result, Ok(ValidationAnswer::Success { validation_errors, validation_warnings: _, other_warnings: _ }) => {
+ assert_eq!(validation_errors.len(), 0, "Unexpected validation errors: {validation_errors:?}");
+ });
+ }
+
+ #[track_caller]
+ fn assert_validates_with_errors(json: serde_json::Value) -> Vec {
+ let ans = validate_with_level(serde_json::from_value(json).unwrap());
+ let ans_val = serde_json::to_value(ans).unwrap();
+ assert_matches!(ans_val.get("validationErrors"), Some(_)); // should be present, with this camelCased name
+ assert_matches!(ans_val.get("validationWarnings"), Some(_)); // should be present, with this camelCased name
+ let result: Result = serde_json::from_value(ans_val);
+ assert_matches!(result, Ok(ValidationAnswer::Success { validation_errors, validation_warnings: _, other_warnings: _ }) => {
+ validation_errors
+ })
+ }
+
+ #[test]
+ fn test_correct_policy_validates_without_errors() {
+ let json = json!({
+ "schema": { "": {
+ "entityTypes": {
+ "User": {
+ "memberOfTypes": [ "UserGroup" ],
+ "shape": {
+ "type": "Record",
+ "attributes": {
+ "friend": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Photo": {
+ "memberOfTypes": [ "Album", "Account" ],
+ "shape":{
+ "type": "Record",
+ "attributes": {
+ "owner": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Album": {
+ "memberOfTypes": [ "Album", "Account" ]
+ },
+ "Account": { },
+ "UserGroup": {}
+ },
+ "actions": {
+ "readOnly": { },
+ "readWrite": { },
+ "createAlbum": {
+ "appliesTo": {
+ "resourceTypes": [ "Account", "Album" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "addPhotoToAlbum": {
+ "appliesTo": {
+ "resourceTypes": [ "Album" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "viewPhoto": {
+ "appliesTo": {
+ "resourceTypes": [ "Photo" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "viewComments": {
+ "appliesTo": {
+ "resourceTypes": [ "Photo" ],
+ "principalTypes": [ "User" ]
+ }
+ }
+ }
+ }},
+ "policies": {
+ "staticPolicies": {
+ "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource) when {principal in resource.owner.friend};"
+ }
+ },
+ "maxDerefLevel": 2});
+ assert_validates_without_errors(json);
+ }
+
+ #[test]
+ fn test_invalid_policy_validates_with_errors() {
+ let json = json!({
+ "schema": { "": {
+ "entityTypes": {
+ "User": {
+ "memberOfTypes": [ "UserGroup" ],
+ "shape": {
+ "type": "Record",
+ "attributes": {
+ "friend": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Photo": {
+ "memberOfTypes": [ "Album", "Account" ],
+ "shape":{
+ "type": "Record",
+ "attributes": {
+ "owner": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Album": {
+ "memberOfTypes": [ "Album", "Account" ]
+ },
+ "Account": { },
+ "UserGroup": {}
+ },
+ "actions": {
+ "readOnly": { },
+ "readWrite": { },
+ "createAlbum": {
+ "appliesTo": {
+ "resourceTypes": [ "Account", "Album" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "addPhotoToAlbum": {
+ "appliesTo": {
+ "resourceTypes": [ "Album" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "viewPhoto": {
+ "appliesTo": {
+ "resourceTypes": [ "Photo" ],
+ "principalTypes": [ "User" ]
+ }
+ },
+ "viewComments": {
+ "appliesTo": {
+ "resourceTypes": [ "Photo" ],
+ "principalTypes": [ "User" ]
+ }
+ }
+ }
+ }},
+ "policies": {
+ "staticPolicies": {
+ "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource) when {principal in resource.owner.friend};"
+ }
+ },
+ "maxDerefLevel": 1});
+
+ let errs = assert_validates_with_errors(json);
+
+ assert_eq!(errs.len(), 1, "expected 1 error but saw {}", errs.len());
+ assert_eq!(errs[0].error.message, "for policy `policy0`, this policy requires level 2, which exceeds the maximum allowed level (1)");
+ }
+}
diff --git a/CedarJavaFFI/src/interface.rs b/CedarJavaFFI/src/interface.rs
index 8f88f3d3..bab88455 100644
--- a/CedarJavaFFI/src/interface.rs
+++ b/CedarJavaFFI/src/interface.rs
@@ -34,7 +34,6 @@ use serde::{Deserialize, Serialize};
use serde_json::{from_str, Value};
use std::{error::Error, str::FromStr, thread};
-use crate::objects::JFormatterConfig;
use crate::{
answer::Answer,
jmap::Map,
@@ -42,6 +41,7 @@ use crate::{
objects::{JEntityId, JEntityTypeName, JEntityUID, JPolicy, Object},
utils::raise_npe,
};
+use crate::{helpers::validate_with_level_json_str, objects::JFormatterConfig};
type Result = std::result::Result>;
@@ -49,6 +49,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_LEVEL_OP: &str = "ValidateWithLevelOperation";
const V0_VALIDATE_ENTITIES: &str = "ValidateEntities";
fn build_err_obj(env: &JNIEnv<'_>, err: &str) -> jstring {
@@ -123,6 +124,7 @@ pub(crate) fn call_cedar(call: &str, input: &str) -> String {
V0_AUTH_PARTIAL_OP => is_authorized_partial_json_str(input),
V0_VALIDATE_OP => validate_json_str(input),
V0_VALIDATE_ENTITIES => json_validate_entities(&input),
+ V0_VALIDATE_LEVEL_OP => validate_with_level_json_str(input),
_ => {
let ires = Answer::fail_internally(format!("unsupported operation: {}", call));
serde_json::to_string(&ires)
@@ -783,10 +785,6 @@ pub(crate) mod jvm_based_tests {
// Static JVM to be used by all the tests. LazyLock for thread-safe lazy initialization
mod policy_tests {
- use std::result;
-
- use cedar_policy::Effect;
-
use super::*;
#[track_caller]
diff --git a/CedarJavaFFI/src/lib.rs b/CedarJavaFFI/src/lib.rs
index 5b740146..c1cd54a5 100644
--- a/CedarJavaFFI/src/lib.rs
+++ b/CedarJavaFFI/src/lib.rs
@@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
mod answer;
+mod helpers;
mod interface;
mod jlist;
mod jmap;
@@ -24,5 +25,4 @@ mod jvm_test_utils;
mod objects;
mod tests;
mod utils;
-
pub use interface::*;
diff --git a/CedarJavaFFI/src/tests.rs b/CedarJavaFFI/src/tests.rs
index 8b9e2e64..4ac2b1e9 100644
--- a/CedarJavaFFI/src/tests.rs
+++ b/CedarJavaFFI/src/tests.rs
@@ -194,6 +194,111 @@ mod validation_tests {
let result = call_cedar("ValidateOperation", r#"{ "schema": "", "policies": {} }"#);
assert_validation_success(&result);
}
+
+ #[test]
+ fn validate_with_level_succeeds() {
+ let input = r#" {
+ "schema": {
+ "": {
+ "entityTypes": {
+ "User": {
+ "memberOfTypes": [
+ "UserGroup"
+ ],
+ "shape": {
+ "type": "Record",
+ "attributes": {
+ "friend": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Photo": {
+ "memberOfTypes": [
+ "Album",
+ "Account"
+ ],
+ "shape": {
+ "type": "Record",
+ "attributes": {
+ "owner": {
+ "type": "Entity",
+ "name": "User"
+ }
+ }
+ }
+ },
+ "Album": {
+ "memberOfTypes": [
+ "Album",
+ "Account"
+ ]
+ },
+ "Account": {},
+ "UserGroup": {}
+ },
+ "actions": {
+ "readOnly": {},
+ "readWrite": {},
+ "createAlbum": {
+ "appliesTo": {
+ "resourceTypes": [
+ "Account",
+ "Album"
+ ],
+ "principalTypes": [
+ "User"
+ ]
+ }
+ },
+ "addPhotoToAlbum": {
+ "appliesTo": {
+ "resourceTypes": [
+ "Album"
+ ],
+ "principalTypes": [
+ "User"
+ ]
+ }
+ },
+ "viewPhoto": {
+ "appliesTo": {
+ "resourceTypes": [
+ "Photo"
+ ],
+ "principalTypes": [
+ "User"
+ ]
+ }
+ },
+ "viewComments": {
+ "appliesTo": {
+ "resourceTypes": [
+ "Photo"
+ ],
+ "principalTypes": [
+ "User"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "policies": {
+ "staticPolicies": {
+ "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource) when {principal in resource.owner.friend};"
+ }
+ },
+ "maxDerefLevel": 2
+ }
+ "#;
+
+ let result = call_cedar("ValidateWithLevelOperation", input);
+
+ assert_validation_success(&result);
+ }
}
mod entity_validation_tests {