From f69c4b1f77124461b3db0226a096edb558555789 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Tue, 22 Sep 2026 21:45:06 +0000 Subject: [PATCH] feat(tpe): add type-aware partial authorization request and response types Completes the type-aware partial evaluation (TPE) surface on the Java side. Step 1 added the entity model; this adds the request and response types plus the FFI request validator. All new API is @Experimental and purely additive. Signed-off-by: Mudit Chaudhary --- CedarJava/config/checkstyle/suppressions.xml | 1 + .../TypeAwarePartialAuthorizationRequest.java | 229 ++++++++++++++++++ ...TypeAwarePartialAuthorizationResponse.java | 116 +++++++++ ...rePartialAuthorizationSuccessResponse.java | 160 ++++++++++++ ...AwarePartialAuthorizationRequestTests.java | 185 ++++++++++++++ ...warePartialAuthorizationResponseTests.java | 226 +++++++++++++++++ CedarJavaFFI/src/interface.rs | 178 ++++++++++++-- CedarJavaFFI/src/tests.rs | 195 ++++++++++++++- CedarJavaFFI/src/tpe.rs | 70 +++++- 9 files changed, 1325 insertions(+), 35 deletions(-) create mode 100644 CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationRequest.java create mode 100644 CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java create mode 100644 CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java create mode 100644 CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationRequestTests.java create mode 100644 CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java diff --git a/CedarJava/config/checkstyle/suppressions.xml b/CedarJava/config/checkstyle/suppressions.xml index 5d51c990..0c00856e 100644 --- a/CedarJava/config/checkstyle/suppressions.xml +++ b/CedarJava/config/checkstyle/suppressions.xml @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationRequest.java b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationRequest.java new file mode 100644 index 00000000..ad32f358 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationRequest.java @@ -0,0 +1,229 @@ +/* + * 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.Experimental; +import com.cedarpolicy.ExperimentalFeature; +import com.cedarpolicy.loader.LibraryLoader; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.schema.Schema; +import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.value.EntityUID; +import com.cedarpolicy.value.PartialEntityUID; +import com.cedarpolicy.value.Value; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import static com.cedarpolicy.CedarJson.objectWriter; + +/** + * A type-aware partial authorization request. The principal and resource may have an unknown id, but their types are + * always known, and the action must be concrete. Cedar validates the request against the schema, which is therefore + * required rather than optional. + * + *

The context is all-or-nothing: absent means the whole context is unknown, whereas {@link Builder#emptyContext} + * states that the context is known to be empty. Individual context keys cannot be left unknown. + */ +@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION) +@JsonInclude(JsonInclude.Include.NON_ABSENT) +public class TypeAwarePartialAuthorizationRequest { + + static { + LibraryLoader.loadLibrary(); + } + + /** EUID of the principal in the request, whose id may be unknown. */ + public final PartialEntityUID principal; + + /** + * EUID of the action in the request. Note this serializes with the {@code __entity} escape, because + * {@link EntityUID} does, whereas the principal and resource serialize as a bare type/id pair. Cedar accepts both. + */ + public final EntityUID action; + + /** EUID of the resource in the request, whose id may be unknown. */ + public final PartialEntityUID resource; + + /** Key/Value map representing the context of the request. An empty {@code Optional} means it is unknown. */ + public final Optional> context; + + /** Schema used to validate the request, and for schema-based parsing of `context`. */ + public final Schema schema; + + /** + * Create a type-aware partial authorization request without validating it. Use {@link #builder()} instead, + * which validates the request against the schema. + * + * @param principal Principal's partial EUID. + * @param action Action's EUID, which must be concrete. + * @param resource Resource's partial EUID. + * @param context Key/Value context. An empty {@code Optional} means the whole context is unknown. + * @param schema Schema. + */ + protected TypeAwarePartialAuthorizationRequest( + PartialEntityUID principal, + EntityUID action, + PartialEntityUID resource, + Optional> context, + Schema schema) { + this.principal = principal; + this.action = action; + this.resource = resource; + this.context = context; + this.schema = schema; + } + + /** + * Copy an existing type-aware partial authorization request. + * + * @param other The request to copy. + */ + protected TypeAwarePartialAuthorizationRequest(TypeAwarePartialAuthorizationRequest other) { + this(other.principal, other.action, other.resource, other.context, other.schema); + } + + /** + * Creates a builder of type-aware partial authorization request. + * + * @return The builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder of type-aware partial authorization requests. */ + public static final class Builder { + private PartialEntityUID principalEUID; + private EntityUID actionEUID; + private PartialEntityUID resourceEUID; + private Optional> context = Optional.empty(); + private Schema schema; + + private Builder() { + } + + public Builder principal(PartialEntityUID principalEUID) { + this.principalEUID = principalEUID; + return this; + } + + public Builder principal(EntityUID principalEUID) { + this.principalEUID = new PartialEntityUID(principalEUID); + return this; + } + + /** + * Set a principal whose id is unknown. Its type is still required, since Cedar validates the request. + * + * @param principalType the principal's entity type + * @return The builder. + */ + public Builder principal(EntityTypeName principalType) { + this.principalEUID = new PartialEntityUID(principalType); + return this; + } + + public Builder action(EntityUID actionEUID) { + this.actionEUID = actionEUID; + return this; + } + + public Builder resource(PartialEntityUID resourceEUID) { + this.resourceEUID = resourceEUID; + return this; + } + + public Builder resource(EntityUID resourceEUID) { + this.resourceEUID = new PartialEntityUID(resourceEUID); + return this; + } + + /** + * Set a resource whose id is unknown. Its type is still required, since Cedar validates the request. + * + * @param resourceType the resource's entity type + * @return The builder. + */ + public Builder resource(EntityTypeName resourceType) { + this.resourceEUID = new PartialEntityUID(resourceType); + return this; + } + + public Builder context(Map context) { + this.context = Optional.of(Map.copyOf(context)); + return this; + } + + public Builder context(Context context) { + this.context = Optional.of(Map.copyOf(context.getContext())); + return this; + } + + /** + * Set the context to be empty, not unknown. + * @return The builder. + */ + public Builder emptyContext() { + this.context = Optional.of(Map.of()); + return this; + } + + public Builder schema(Schema schema) { + this.schema = schema; + return this; + } + + /** + * Build the type-aware partial authorization request, validating it against the schema. + * + * @return The request. + * @throws InternalException If the request does not validate against the schema, if the context contains an + * {@link com.cedarpolicy.value.Unknown} (the context here is all-or-nothing, so individual values cannot be + * left unknown), or if the request cannot be serialized. + * @throws NullPointerException If the principal, action, resource, or schema was not set. + */ + public TypeAwarePartialAuthorizationRequest build() throws InternalException { + Objects.requireNonNull(principalEUID, "principal is required, pass a PartialEntityUID built from just " + + "its type if the id is unknown"); + Objects.requireNonNull(actionEUID, "action is required and must be concrete"); + Objects.requireNonNull(resourceEUID, "resource is required, pass a PartialEntityUID built from just " + + "its type if the id is unknown"); + Objects.requireNonNull(schema, "schema is required, type-aware partial evaluation validates the request"); + final TypeAwarePartialAuthorizationRequest request = new TypeAwarePartialAuthorizationRequest( + principalEUID, + actionEUID, + resourceEUID, + context, + schema); + try { + validateTypeAwarePartialRequestJni(objectWriter().writeValueAsString(request)); + } catch (JsonProcessingException e) { + throw new InternalException("failed to serialize the type-aware partial authorization request: " + + e.getMessage()); + } catch (InternalException e) { + throw ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION.translateIfDisabled(e); + } + return request; + } + } + + private static native String validateTypeAwarePartialRequestJni(String requestJson) throws InternalException; +} diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java new file mode 100644 index 00000000..d9d151c1 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java @@ -0,0 +1,116 @@ +/* + * 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.Experimental; +import com.cedarpolicy.ExperimentalFeature; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Result of a type-aware partial authorization request. Unknown properties are ignored, so the native side can start + * emitting a field before this class reads it — {@code reason} and the permit/forbid breakdown are not read yet. + */ +@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class TypeAwarePartialAuthorizationResponse { + private final SuccessOrFailure type; + private final Optional success; + private final Optional> errors; + private final List warnings; + + @JsonCreator + TypeAwarePartialAuthorizationResponse( + @JsonProperty("type") SuccessOrFailure type, + @JsonProperty("response") Optional success, + @JsonProperty("errors") Optional> errors, + @JsonProperty("warnings") ArrayList warnings + ) { + this.type = type; + this.success = success; + this.errors = errors.>map(List::copyOf); + this.warnings = warnings == null ? List.of() : List.copyOf(warnings); + } + + /** + * Whether this is a success or a failure response. + * + * @return the response kind + */ + @JsonProperty("type") + public SuccessOrFailure getType() { + return this.type; + } + + /** + * The residuals, present if and only if {@link #getType()} is {@code Success}. + * + * @return the successful response + */ + @JsonProperty("response") + public Optional getSuccess() { + return this.success; + } + + /** + * The errors, present if and only if {@link #getType()} is {@code Failure}. + * + * @return the errors + */ + @JsonProperty("errors") + public Optional> getErrors() { + return this.errors; + } + + /** + * Warnings, which either kind of response may carry. + * + * @return the warnings, empty if there were none + */ + // The field is assigned from List.copyOf, so it is immutable and cannot be mutated through this + // reference. SpotBugs does not recognise List.copyOf as establishing that, the way it does + // Set.copyOf and Map.copyOf elsewhere in this package. + @SuppressFBWarnings("EI_EXPOSE_REP") + @JsonProperty("warnings") + public List getWarnings() { + return this.warnings; + } + + @Override + public String toString() { + final String warningsString = warnings.isEmpty() ? "" : "\nwith warnings: " + warnings; + if (type == SuccessOrFailure.Success) { + return "SUCCESS: " + success.get() + warningsString; + } else { + return "FAILURE: " + errors.get() + warningsString; + } + } + + /** Whether the response carries residuals or an error. */ + public enum SuccessOrFailure { + @JsonProperty("residuals") + Success, + @JsonProperty("failure") + Failure, + } +} diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java new file mode 100644 index 00000000..45d9a05d --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java @@ -0,0 +1,160 @@ +/* + * 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.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.cedarpolicy.Experimental; +import com.cedarpolicy.ExperimentalFeature; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.policy.Policy; +import com.cedarpolicy.model.policy.PolicySet; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Successful type-aware partial authorization response. + */ +@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class TypeAwarePartialAuthorizationSuccessResponse { + private final AuthorizationSuccessResponse.Decision decision; + private final Set nontrivialResidualIds; + private final Set residuals; + private final Set nontrivialResiduals; + private final Set trivialResiduals; + + /** + * Reads a successful response, parsing every residual into a {@link Policy} up front. + * + * @throws InternalException if a residual is not a valid Cedar policy + */ + @JsonCreator + TypeAwarePartialAuthorizationSuccessResponse( + @JsonProperty("decision") AuthorizationSuccessResponse.Decision decision, + @JsonProperty("residuals") Map residuals, + @JsonProperty("nontrivialResiduals") Set nontrivialResiduals) throws InternalException { + this.decision = decision; + this.nontrivialResidualIds = + nontrivialResiduals == null ? Set.of() : Set.copyOf(nontrivialResiduals); + // One native call parses every residual; the partitions are filters over the result. + this.residuals = Set.copyOf( + toPolicySet(residuals == null ? Map.of() : residuals).policies); + this.nontrivialResiduals = Set.copyOf(partition(this.residuals, this.nontrivialResidualIds, true)); + this.trivialResiduals = Set.copyOf(partition(this.residuals, this.nontrivialResidualIds, false)); + } + + /** + * The decision, or null if type-aware partial evaluation could not reach one. + * + * @return the decision, nullable + */ + @JsonIgnore + public AuthorizationSuccessResponse.Decision getDecision() { + return this.decision; + } + + /** + * The ids of the non-trivial residuals, which is the wire field the trivial/non-trivial split is derived from. + * + * @return ids of the residuals that were not reduced to a concrete true, false, or error + */ + @JsonIgnore + public Set getNontrivialResidualIds() { + return this.nontrivialResidualIds; + } + + /** + * Every residual. Each keeps the policy id and annotations of the policy it came from, has an unconstrained scope, + * and carries the residual expression in a single {@code when} clause. Call {@link Policy#toJson()} for the JSON + * form. + * + * @return every residual + */ + @JsonIgnore + public Set getResiduals() { + return this.residuals; + } + + /** + * The residuals that were not reduced to a concrete true, false, or error, so the ones whose conditions are worth + * inspecting. + * + * @return the non-trivial residuals + */ + @JsonIgnore + public Set getNontrivialResiduals() { + return this.nontrivialResiduals; + } + + /** + * The residuals that were reduced to a concrete true, false, or error. + * + * @return the trivial residuals + */ + @JsonIgnore + public Set getTrivialResiduals() { + return this.trivialResiduals; + } + + /** + * Every residual as a policy set, which is the form {@link com.cedarpolicy.AuthorizationEngine} accepts and so + * what to reauthorize against once the unknowns are filled in. Counterpart of {@code TpeResponse::policy_set}. + * + *

Trivial residuals are included deliberately: dropping a trivially true permit would turn an Allow into a + * Deny. + * + *

Residuals have values from the original request and entities already folded in, and scope constraints + * rewritten into the condition, so this set is only valid for reauthorizing that same request with its unknowns + * resolved — not for a different request. + * + *

The set holds only static policies. That is Cedar's behaviour, not a CedarJava simplification: type-aware + * partial evaluation substitutes a template link's slots and builds a fresh policy from the result, so the residual + * of a linked policy reports no template even to a Rust caller. It keeps the original policy id, which is the only + * way to trace it back to the template it came from. + * + * @return policy set of every residual + */ + @JsonIgnore + public PolicySet getPolicySet() { + return new PolicySet(Set.copyOf(this.residuals)); + } + + private static Set partition(Set residuals, Set nontrivialIds, boolean nontrivial) { + return residuals.stream() + .filter(p -> nontrivialIds.contains(p.getID()) == nontrivial) + .collect(Collectors.toSet()); + } + + private static PolicySet toPolicySet(Map policies) throws InternalException { + final ObjectNode staticPolicies = JsonNodeFactory.instance.objectNode(); + // Residuals always resolve to static policies + staticPolicies.setAll(policies); + final ObjectNode policySet = JsonNodeFactory.instance.objectNode(); + policySet.set("staticPolicies", staticPolicies); + policySet.set("templates", JsonNodeFactory.instance.objectNode()); + policySet.set("templateLinks", JsonNodeFactory.instance.arrayNode()); + return PolicySet.parsePoliciesJson(policySet.toString()); + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationRequestTests.java b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationRequestTests.java new file mode 100644 index 00000000..f9f7acff --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationRequestTests.java @@ -0,0 +1,185 @@ +/* + * 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 com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.schema.Schema; +import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.value.EntityUID; +import com.cedarpolicy.value.PartialEntityUID; +import com.cedarpolicy.value.Unknown; +import com.cedarpolicy.value.Value; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static com.cedarpolicy.CedarJson.objectWriter; +import static com.cedarpolicy.TestUtil.assertJSONEqual; +import static com.cedarpolicy.TestUtil.assertMessageContains; +import static com.cedarpolicy.TestUtil.buildEuidObject; +import static com.cedarpolicy.TestUtil.buildUidObject; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests for {@link TypeAwarePartialAuthorizationRequest}, whose principal id, resource id, and context may be unknown. */ +public class TypeAwarePartialAuthorizationRequestTests { + /** See {@link PartialEntityTests} for what this schema is shaped to catch. */ + private static final Schema TPE_SCHEMA = TestUtil.loadSchemaResource("/tpe_schema.json"); + + @Test + public void testTypeAwarePartialRequest() throws InternalException { + var view = new EntityUID(EntityTypeName.parse("Action").get(), "view"); + var door = new EntityUID(EntityTypeName.parse("Photo").get(), "door"); + var schemaJson = TPE_SCHEMA.schemaJson.orElseThrow(); + + var unknownPrincipal = TypeAwarePartialAuthorizationRequest.builder() + .principal(new PartialEntityUID(EntityTypeName.parse("User").get())) + .action(view) + .resource(door) + .schema(TPE_SCHEMA) + .build(); + ObjectNode n = JsonNodeFactory.instance.objectNode(); + n.set("principal", buildUidObject("User")); + n.set("action", buildEuidObject("Action", "view")); + n.set("resource", buildUidObject("Photo", "door")); + n.set("schema", schemaJson); + assertJSONEqual(n, unknownPrincipal); + + JsonNode serialized = assertDoesNotThrow( + () -> CedarJson.objectMapper().readTree(objectWriter().writeValueAsString(unknownPrincipal))); + assertFalse(serialized.get("principal").has("id")); + assertFalse(serialized.has("context")); + assertFalse(serialized.has("validateRequest")); + + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var knownPrincipal = TypeAwarePartialAuthorizationRequest.builder() + .principal(alice) + .action(view) + .resource(door) + .emptyContext() + .schema(TPE_SCHEMA) + .build(); + n = JsonNodeFactory.instance.objectNode(); + n.set("principal", buildUidObject("User", "alice")); + n.set("action", buildEuidObject("Action", "view")); + n.set("resource", buildUidObject("Photo", "door")); + n.set("context", JsonNodeFactory.instance.objectNode()); + n.set("schema", schemaJson); + assertJSONEqual(n, knownPrincipal); + } + + @Test + public void testTypeAwarePartialRequestWithUnknownResourceId() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var view = new EntityUID(EntityTypeName.parse("Action").get(), "view"); + var schemaJson = TPE_SCHEMA.schemaJson.orElseThrow(); + + // Cedar treats the principal and the resource symmetrically, so the resource id may be unknown on its own. + var unknownResource = TypeAwarePartialAuthorizationRequest.builder() + .principal(alice) + .action(view) + .resource(new PartialEntityUID(EntityTypeName.parse("Photo").get())) + .emptyContext() + .schema(TPE_SCHEMA) + .build(); + ObjectNode n = JsonNodeFactory.instance.objectNode(); + n.set("principal", buildUidObject("User", "alice")); + n.set("action", buildEuidObject("Action", "view")); + n.set("resource", buildUidObject("Photo")); + n.set("context", JsonNodeFactory.instance.objectNode()); + n.set("schema", schemaJson); + assertJSONEqual(n, unknownResource); + + JsonNode serialized = assertDoesNotThrow( + () -> CedarJson.objectMapper().readTree(objectWriter().writeValueAsString(unknownResource))); + assertFalse(serialized.get("resource").has("id")); + + // Both ids unknown at once is also valid. + var bothUnknown = TypeAwarePartialAuthorizationRequest.builder() + .principal(new PartialEntityUID(EntityTypeName.parse("User").get())) + .action(view) + .resource(new PartialEntityUID(EntityTypeName.parse("Photo").get())) + .emptyContext() + .schema(TPE_SCHEMA) + .build(); + n = JsonNodeFactory.instance.objectNode(); + n.set("principal", buildUidObject("User")); + n.set("action", buildEuidObject("Action", "view")); + n.set("resource", buildUidObject("Photo")); + n.set("context", JsonNodeFactory.instance.objectNode()); + n.set("schema", schemaJson); + assertJSONEqual(n, bothUnknown); + } + + @Test + public void testTypeAwarePartialRequestTypeChecksAgainstSchema() { + var view = new EntityUID(EntityTypeName.parse("Action").get(), "view"); + var door = new EntityUID(EntityTypeName.parse("Photo").get(), "door"); + var wrongPrincipalType = TypeAwarePartialAuthorizationRequest.builder() + .principal(new PartialEntityUID(EntityTypeName.parse("Photo").get())) + .action(view) + .resource(door) + .schema(TPE_SCHEMA); + assertMessageContains(assertThrows(InternalException.class, wrongPrincipalType::build), + "principal type `Photo`", "is not valid for", "Action::\"view\""); + + // The resource type is checked the same way the principal type is. + var wrongResourceType = TypeAwarePartialAuthorizationRequest.builder() + .principal(new PartialEntityUID(EntityTypeName.parse("User").get())) + .action(view) + .resource(new PartialEntityUID(EntityTypeName.parse("User").get())) + .schema(TPE_SCHEMA); + assertMessageContains(assertThrows(InternalException.class, wrongResourceType::build), + "resource type `User`", "is not valid for", "Action::\"view\""); + } + + @Test + public void testTypeAwarePartialRequestRejectsUnknownInContext() { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var view = new EntityUID(EntityTypeName.parse("Action").get(), "view"); + var door = new EntityUID(EntityTypeName.parse("Photo").get(), "door"); + // The context is all-or-nothing here: a per-key Unknown, which is the partial evaluation idiom, is rejected. + Map context = Map.of("authenticated", new Unknown("AuthenticatedIsUnknown")); + var builder = TypeAwarePartialAuthorizationRequest.builder() + .principal(alice) + .action(view) + .resource(door) + .context(context) + .schema(TPE_SCHEMA); + assertMessageContains(assertThrows(InternalException.class, builder::build), + "Context contains unknowns"); + } + + @Test + public void testTypeAwarePartialRequestRequiresSchema() { + var view = new EntityUID(EntityTypeName.parse("Action").get(), "view"); + var door = new EntityUID(EntityTypeName.parse("Photo").get(), "door"); + var builder = TypeAwarePartialAuthorizationRequest.builder() + .principal(new PartialEntityUID(EntityTypeName.parse("User").get())) + .action(view) + .resource(door); + NullPointerException e = assertThrows(NullPointerException.class, () -> builder.build()); + assertTrue(e.getMessage().contains("schema is required")); + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java new file mode 100644 index 00000000..32d1cf69 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java @@ -0,0 +1,226 @@ +/* + * 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 com.cedarpolicy.model.AuthorizationSuccessResponse.Decision; +import com.cedarpolicy.model.TypeAwarePartialAuthorizationResponse; +import com.cedarpolicy.model.TypeAwarePartialAuthorizationResponse.SuccessOrFailure; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.Effect; +import com.cedarpolicy.model.policy.Policy; +import com.cedarpolicy.model.policy.PolicySet; +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import static com.cedarpolicy.CedarJson.objectReader; +import static com.cedarpolicy.TestUtil.assertJSONEqual; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** Tests for {@link TypeAwarePartialAuthorizationResponse} and its success response. */ +public class TypeAwarePartialAuthorizationResponseTests { + + /** + * A concrete `allow` still carries the permit that decided it. Cedar reaches a concrete `Allow` only when some + * permit reduced to concretely true, and `TpeResponse::policies` — which fills `residuals` — includes concretely + * true residuals, so `residuals` cannot be empty here. It is `nontrivialResiduals` that is empty, because the + * deciding permit is trivial. Keeping the fixture faithful matters: an empty `residuals` alongside a concrete + * decision would suggest the handler may skip residuals once the decision is known, and reauthorizing against that + * empty set would Deny what the full engine Allows. + */ + @Test + public void testTPEConcreteResponse() { + final String trivialPermit = "{ \"effect\": \"permit\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"conditions\": [ { \"kind\": \"when\", \"body\": { \"Value\": true } } ] }"; + String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": \"allow\", \"residuals\": {\"trivial\": " + + trivialPermit + " }, \"nontrivialResiduals\": [] }, \"warnings\": [] }"; + try { + TypeAwarePartialAuthorizationResponse r = + objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src); + assertEquals(SuccessOrFailure.Success, r.getType()); + var success = r.getSuccess().orElseThrow(); + assertEquals(Decision.Allow, success.getDecision()); + assertEquals(Set.of("trivial"), ids(success.getResiduals())); + assertTrue(success.getNontrivialResiduals().isEmpty()); + assertTrue(r.getWarnings().isEmpty()); + } catch (JsonProcessingException e) { + fail(e); + } + } + + @Test + public void testTPEResidualResponse() { + final String policy = "{ \"effect\": \"permit\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"conditions\": [ { \"kind\": \"when\", \"body\": { \"==\": { \"left\": { \".\": { \"left\": { \"Var\": \"principal\" }, \"attr\": \"department\" } }, \"right\": { \"Value\": \"eng\" } } } } ] }"; + final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {\"p0\": " + policy + " } }, \"warnings\": [] }"; + try { + TypeAwarePartialAuthorizationResponse r = + objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src); + var success = r.getSuccess().orElseThrow(); + assertNull(success.getDecision()); + var residuals = success.getResiduals(); + assertEquals(1, residuals.size()); + Policy residual = residuals.iterator().next(); + assertEquals("p0", residual.getID()); + // Policy.toJson() reproduces the EST the FFI sent. + assertJSONEqual(objectReader().readTree(policy), objectReader().readTree(residual.toJson())); + } catch (JsonProcessingException | InternalException e) { + fail(e); + } + } + + @Test + public void testTPEFailureResponse() { + String src = "{ \"type\": \"failure\", \"errors\": [{ \"message\": \"failed to type check the request\" }], \"warnings\": [\"policy p0 is impossible\"] }"; + try { + TypeAwarePartialAuthorizationResponse r = + objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src); + assertEquals(SuccessOrFailure.Failure, r.getType()); + assertTrue(r.getSuccess().isEmpty()); + var errors = r.getErrors().orElseThrow(); + assertEquals(1, errors.size()); + assertEquals("failed to type check the request", errors.get(0).message); + assertEquals(1, r.getWarnings().size()); + } catch (JsonProcessingException e) { + fail(e); + } + } + + @Test + public void testTPEResponseOmittedErrorsBindsToEmptyOptional() { + String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {} }, \"warnings\": [] }"; + try { + TypeAwarePartialAuthorizationResponse r = + objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src); + assertTrue(r.getErrors().isEmpty()); + } catch (JsonProcessingException e) { + fail(e); + } + } + + @Test + public void testTPEResponseToleratesUnknownFields() { + String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"reason\": [], \"errored\": [], \"residuals\": {}, \"mayBePermits\": [\"p0\"] }, \"warnings\": [], \"aFieldFromTheFuture\": 7 }"; + TypeAwarePartialAuthorizationResponse r = assertDoesNotThrow( + () -> objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src)); + assertTrue(r.getSuccess().isPresent()); + } + + /** The policy-set shape is what the authorization engine accepts, so it is what reauthorization depends on. */ + @Test + public void testResidualPolicySetRoundTrip() throws InternalException, JsonProcessingException { + final String permitAll = "{ \"effect\": \"permit\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"conditions\": [] }"; + final String forbidAll = "{ \"effect\": \"forbid\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"conditions\": [] }"; + final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {\"myPermit\": " + + permitAll + ", \"myForbid\": " + forbidAll + " } }, \"warnings\": [] }"; + TypeAwarePartialAuthorizationResponse r = + objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src); + PolicySet residuals = r.getSuccess().orElseThrow().getPolicySet(); + assertEquals(Set.of("myPermit", "myForbid"), residuals.getStaticPolicies().keySet()); + assertTrue(residuals.getTemplates().isEmpty()); + assertTrue(residuals.templateLinks.isEmpty()); + } + + /** + * Pins the meaning of the three residual accessors against Cedar's main line, which CedarJava's main line builds + * against. `getPolicySet` is every residual, so it must keep the trivially-true permit; + * `getNontrivialResiduals` and `getTrivialResiduals` partition the same set for inspection. + * + *

No accessor here is named after a Cedar method whose meaning changed between 4.11 and 4.12, so these + * assertions hold in both versions. The version delta lives in the producer, which has to derive + * `nontrivialResiduals` from whichever Cedar accessor returns the non-trivial subset. + */ + @Test + public void testResidualAccessorsSplitTrivialFromNontrivial() throws InternalException, JsonProcessingException { + // p0 reduced to a concrete true; p1 still has a condition. The ids are deliberately neutral: + // the split comes from the `nontrivialResiduals` field below, not from the policy bodies. + final String reducedToTrue = "{ \"effect\": \"permit\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"conditions\": [ { \"kind\": \"when\", \"body\": { \"Value\": true } } ] }"; + final String stillConditional = "{ \"effect\": \"forbid\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"annotations\": { \"owner\": \"team-a\", \"audit\": \"\" }, \"conditions\": [ { \"kind\": \"when\", \"body\": { \"==\": { \"left\": { \".\": { \"left\": { \"Var\": \"principal\" }, \"attr\": \"department\" } }, \"right\": { \"Value\": \"eng\" } } } } ] }"; + final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {\"p0\": " + + reducedToTrue + ", \"p1\": " + stillConditional + " }, \"nontrivialResiduals\": [\"p1\"] }," + + " \"warnings\": [] }"; + var success = objectReader().forType(TypeAwarePartialAuthorizationResponse.class) + .readValue(src).getSuccess().orElseThrow(); + + assertEquals(Set.of("p0", "p1"), ids(success.getResiduals())); + assertEquals(Set.of("p0", "p1"), success.getPolicySet().getStaticPolicies().keySet()); + assertEquals(Set.of("p1"), ids(success.getNontrivialResiduals())); + assertEquals(Set.of("p0"), ids(success.getTrivialResiduals())); + + // The residual is a usable Policy, which is the point of returning these rather than raw JSON. + Policy p1 = success.getNontrivialResiduals().iterator().next(); + assertEquals("p1", p1.getID()); + assertEquals(Effect.FORBID, p1.effect()); + // Annotations survive EST -> source -> Policy. An annotation with no value reads as "". + assertEquals(Map.of("owner", "team-a", "audit", ""), p1.getAnnotations()); + assertEquals("team-a", p1.getAnnotation("owner")); + assertNull(p1.getAnnotation("nosuch")); + // toJson() returns the EST the FFI sent. + assertJSONEqual(objectReader().readTree(stillConditional), objectReader().readTree(p1.toJson())); + // The two partitions are disjoint and together account for every residual. + var nontrivial = ids(success.getNontrivialResiduals()); + var trivial = ids(success.getTrivialResiduals()); + assertTrue(Collections.disjoint(nontrivial, trivial)); + var union = new HashSet(nontrivial); + union.addAll(trivial); + assertEquals(ids(success.getResiduals()), union); + } + + /** An omitted `nontrivialResiduals` means every residual is trivial, not that there are none. */ + @Test + public void testOmittedNontrivialResidualsMeansAllTrivial() + throws InternalException, JsonProcessingException { + final String permitAll = "{ \"effect\": \"permit\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"conditions\": [] }"; + final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {\"myPermit\": " + + permitAll + " } }, \"warnings\": [] }"; + var success = objectReader().forType(TypeAwarePartialAuthorizationResponse.class) + .readValue(src).getSuccess().orElseThrow(); + assertTrue(success.getNontrivialResiduals().isEmpty()); + assertEquals(Set.of("myPermit"), ids(success.getTrivialResiduals())); + assertEquals(Set.of("myPermit"), success.getPolicySet().getStaticPolicies().keySet()); + } + + @Test + public void testTPESuccessResponseReadsEveryWireField() throws InternalException, JsonProcessingException { + final String permitAll = "{ \"effect\": \"permit\", \"principal\": { \"op\": \"All\" }, \"action\": { \"op\": \"All\" }, \"resource\": { \"op\": \"All\" }, \"conditions\": [] }"; + final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {\"myPermit\": " + + permitAll + " }, \"nontrivialResiduals\": [\"myPermit\"] }, \"warnings\": [] }"; + var success = objectReader().forType(TypeAwarePartialAuthorizationResponse.class) + .readValue(src) + .getSuccess() + .orElseThrow(); + + assertNull(success.getDecision()); + assertEquals(Set.of("myPermit"), success.getNontrivialResidualIds()); + assertEquals(Set.of("myPermit"), ids(success.getResiduals())); + assertEquals(Set.of("myPermit"), ids(success.getNontrivialResiduals())); + assertTrue(success.getTrivialResiduals().isEmpty()); + } + + /** Policy carries its own id, so the accessors return sets rather than maps. */ + private static Set ids(Set policies) { + return policies.stream().map(Policy::getID).collect(Collectors.toSet()); + } +} diff --git a/CedarJavaFFI/src/interface.rs b/CedarJavaFFI/src/interface.rs index 731e3f17..99199ec2 100644 --- a/CedarJavaFFI/src/interface.rs +++ b/CedarJavaFFI/src/interface.rs @@ -46,7 +46,9 @@ use crate::{ jmap::Map, jset::Set, objects::{JEntityId, JEntityTypeName, JEntityUID, JLinkValue, JPolicy, JTemplateLink, Object}, - tpe::{validate_partial_entities, validate_partial_entity}, + tpe::{ + validate_partial_entities, validate_partial_entity, validate_type_aware_partial_request, + }, utils::raise_npe, }; use crate::{helpers::validate_with_level_json_str, objects::JFormatterConfig}; @@ -1280,6 +1282,33 @@ fn validate_partial_entity_internal<'a>( } } +/// Public string-based JSON interface to validate a type-aware partial request against the schema +/// carried by the request itself +#[jni_fn("com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest")] +pub fn validateTypeAwarePartialRequestJni<'a>( + mut env: JNIEnv<'a>, + _: JClass, + request_jstr: JString<'a>, +) -> jvalue { + match validate_type_aware_partial_request_internal(&mut env, request_jstr) { + Ok(v) => v.as_jni(), + Err(e) => jni_failed(&mut env, e.as_ref()), + } +} + +fn validate_type_aware_partial_request_internal<'a>( + env: &mut JNIEnv<'a>, + request_jstr: JString<'a>, +) -> Result> { + if request_jstr.is_null() { + raise_npe(env) + } else { + let request_json = String::from(env.get_string(&request_jstr)?); + validate_type_aware_partial_request(&request_json)?; + Ok(JValueGen::Object(env.new_string("success")?.into())) + } +} + /// Public string-based JSON interface to validate a collection of partial entities against a schema #[jni_fn("com.cedarpolicy.model.entity.PartialEntities")] pub fn validatePartialEntitiesJni<'a>( @@ -1322,21 +1351,7 @@ pub(crate) mod jvm_based_tests { #[cfg(feature = "tpe")] mod tpe_tests { use super::*; - - const SCHEMA: &str = r#" - entity Group; - entity User in [Group] = { "isAdmin": Bool }; - entity Photo; - action view appliesTo { - principal: [User], - resource: [Photo], - context: { "authenticated": Bool } - }; - "#; - - fn schema_json() -> String { - serde_json::json!(SCHEMA).to_string() - } + use crate::tests::tpe_schema_fixtures::{schema_cedar_src, schema_json, CEDAR_SCHEMA_SRC}; /// Read back the string an `_internal` returned on success. #[track_caller] @@ -1370,7 +1385,7 @@ pub(crate) mod jvm_based_tests { "attrs": { "isAdmin": false } }); let entity_jstr = env.new_string(entity.to_string()).unwrap(); - let schema_jstr = env.new_string(schema_json()).unwrap(); + let schema_jstr = env.new_string(schema_cedar_src()).unwrap(); let result = validate_partial_entity_internal(&mut env, entity_jstr, schema_jstr).unwrap(); @@ -1386,7 +1401,7 @@ pub(crate) mod jvm_based_tests { "attrs": { "isAdmin": 3 } }); let entity_jstr = env.new_string(entity.to_string()).unwrap(); - let schema_jstr = env.new_string(schema_json()).unwrap(); + let schema_jstr = env.new_string(schema_cedar_src()).unwrap(); assert_err_contains( validate_partial_entity_internal(&mut env, entity_jstr, schema_jstr), @@ -1403,7 +1418,7 @@ pub(crate) mod jvm_based_tests { #[test] fn validate_partial_entity_internal_null() { let mut env = JVM.attach_current_thread().unwrap(); - let schema_jstr = env.new_string(schema_json()).unwrap(); + let schema_jstr = env.new_string(schema_cedar_src()).unwrap(); let result = validate_partial_entity_internal( &mut env, JString::from(JObject::null()), @@ -1431,7 +1446,7 @@ pub(crate) mod jvm_based_tests { } ]); let entities_jstr = env.new_string(entities.to_string()).unwrap(); - let schema_jstr = env.new_string(schema_json()).unwrap(); + let schema_jstr = env.new_string(schema_cedar_src()).unwrap(); let result = validate_partial_entities_internal(&mut env, entities_jstr, schema_jstr).unwrap(); @@ -1447,7 +1462,7 @@ pub(crate) mod jvm_based_tests { { "uid": { "type": "User", "id": "alice" }, "attrs": { "isAdmin": true }, "parents": [] } ]); let entities_jstr = env.new_string(entities.to_string()).unwrap(); - let schema_jstr = env.new_string(schema_json()).unwrap(); + let schema_jstr = env.new_string(schema_cedar_src()).unwrap(); assert_err_contains( validate_partial_entities_internal(&mut env, entities_jstr, schema_jstr), @@ -1474,6 +1489,127 @@ pub(crate) mod jvm_based_tests { ); env.exception_clear().unwrap(); } + + #[test] + fn validate_partial_entity_internal_json_format_schema() { + let mut env = JVM.attach_current_thread().unwrap(); + let entity = serde_json::json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false } + }); + let entity_jstr = env.new_string(entity.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + + let result = + validate_partial_entity_internal(&mut env, entity_jstr, schema_jstr).unwrap(); + assert_eq!(success_string(&mut env, result), "success"); + assert!(!env.exception_check().unwrap()); + + let mistyped = serde_json::json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": 3 } + }); + let mistyped_jstr = env.new_string(mistyped.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + assert_err_contains( + validate_partial_entity_internal(&mut env, mistyped_jstr, schema_jstr), + &[ + "attribute `isAdmin`", + "User::\"alice\"", + "type mismatch", + "expected to have type bool", + "actually has type long", + ], + ); + } + + #[test] + fn validate_partial_entities_internal_json_format_schema() { + let mut env = JVM.attach_current_thread().unwrap(); + let entities = serde_json::json!([ + { + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false }, + "parents": [ { "type": "Group", "id": "admins" } ] + } + ]); + let entities_jstr = env.new_string(entities.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + + let result = + validate_partial_entities_internal(&mut env, entities_jstr, schema_jstr).unwrap(); + assert_eq!(success_string(&mut env, result), "success"); + assert!(!env.exception_check().unwrap()); + + let duplicated = serde_json::json!([ + { "uid": { "type": "User", "id": "alice" }, "attrs": { "isAdmin": false }, "parents": [] }, + { "uid": { "type": "User", "id": "alice" }, "attrs": { "isAdmin": true }, "parents": [] } + ]); + let duplicated_jstr = env.new_string(duplicated.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + assert_err_contains( + validate_partial_entities_internal(&mut env, duplicated_jstr, schema_jstr), + &["duplicate entity entry", "User::\"alice\""], + ); + } + + #[test] + fn validate_type_aware_partial_request_internal_success() { + let mut env = JVM.attach_current_thread().unwrap(); + let request = serde_json::json!({ + "principal": { "type": "User" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo", "id": "door" }, + "context": { "authenticated": true }, + "schema": CEDAR_SCHEMA_SRC + }); + let request_jstr = env.new_string(request.to_string()).unwrap(); + + let result = + validate_type_aware_partial_request_internal(&mut env, request_jstr).unwrap(); + assert_eq!(success_string(&mut env, result), "success"); + assert!(!env.exception_check().unwrap()); + } + + #[test] + fn validate_type_aware_partial_request_internal_type_check_failure() { + let mut env = JVM.attach_current_thread().unwrap(); + let request = serde_json::json!({ + "principal": { "type": "Admin" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo", "id": "door" }, + "context": { "authenticated": true }, + "schema": CEDAR_SCHEMA_SRC + }); + let request_jstr = env.new_string(request.to_string()).unwrap(); + + assert_err_contains( + validate_type_aware_partial_request_internal(&mut env, request_jstr), + &[ + "principal type `Admin`", + "is not valid for", + "Action::\"view\"", + ], + ); + } + + #[test] + fn validate_type_aware_partial_request_internal_null() { + let mut env = JVM.attach_current_thread().unwrap(); + let result = validate_type_aware_partial_request_internal( + &mut env, + JString::from(JObject::null()), + ); + assert!( + result.is_ok(), + "a null input is reported to Java, not to us" + ); + assert!( + env.exception_check().unwrap(), + "Expected java exception due to a null input" + ); + env.exception_clear().unwrap(); + } } mod policy_tests { diff --git a/CedarJavaFFI/src/tests.rs b/CedarJavaFFI/src/tests.rs index acec52f2..8d1c9648 100644 --- a/CedarJavaFFI/src/tests.rs +++ b/CedarJavaFFI/src/tests.rs @@ -1035,13 +1035,16 @@ mod partial_authorization_tests { } } +/// Schema fixtures shared by the TPE tests here and the JNI-boundary tests in `interface.rs`. +/// +/// `ffi::Schema` is an untagged enum discriminated by JSON type: a Cedar-format schema travels as a +/// JSON string, a JSON-format one as a JSON object. Each fixture selects one arm. #[cfg(feature = "tpe")] -mod tpe_validation_tests { - use super::*; - use crate::tpe::{validate_partial_entities, validate_partial_entity}; +pub(crate) mod tpe_schema_fixtures { + use cedar_policy::SchemaFragment; use serde_json::json; - const SCHEMA: &str = r#" + pub(crate) const CEDAR_SCHEMA_SRC: &str = r#" entity Group; entity User in [Group] = { "isAdmin": Bool }; entity Photo; @@ -1052,10 +1055,32 @@ mod tpe_validation_tests { }; "#; - fn schema_json() -> String { - json!(SCHEMA).to_string() + /// The Cedar-format schema, JSON-encoded as a string. + pub(crate) fn schema_cedar_src() -> String { + json!(CEDAR_SCHEMA_SRC).to_string() } + /// The same schema in Cedar's JSON schema format, as a JSON object. Derived from the Cedar + /// source above rather than written out again, so the two cannot drift apart. + pub(crate) fn schema_json() -> String { + let (fragment, _) = SchemaFragment::from_cedarschema_str(CEDAR_SCHEMA_SRC) + .expect("the Cedar-format fixture should parse"); + fragment + .to_json_value() + .expect("a parsed schema should convert to JSON") + .to_string() + } +} + +#[cfg(feature = "tpe")] +mod tpe_validation_tests { + use super::*; + use crate::tests::tpe_schema_fixtures::{schema_cedar_src, schema_json, CEDAR_SCHEMA_SRC}; + use crate::tpe::{ + validate_partial_entities, validate_partial_entity, validate_type_aware_partial_request, + }; + use serde_json::json; + /// Assert that validation failed for the expected reason. Checking several fragments of the /// message rather than only that an error occurred keeps the assertion specific to the rule /// under test: every error crossing this boundary is flattened to a `Box`, so @@ -1079,7 +1104,7 @@ mod tpe_validation_tests { "attrs": { "isAdmin": false } }); assert_matches!( - validate_partial_entity(&entity.to_string(), &schema_json()), + validate_partial_entity(&entity.to_string(), &schema_cedar_src()), Ok(()) ); } @@ -1091,7 +1116,7 @@ mod tpe_validation_tests { "attrs": { "isAdmin": 3 } }); assert_err_contains( - validate_partial_entity(&entity.to_string(), &schema_json()), + validate_partial_entity(&entity.to_string(), &schema_cedar_src()), &[ "attribute `isAdmin`", "User::\"alice\"", @@ -1106,7 +1131,7 @@ mod tpe_validation_tests { fn validate_partial_entity_with_undeclared_type_fails() { let entity = json!({ "uid": { "type": "Album", "id": "trip" } }); assert_err_contains( - validate_partial_entity(&entity.to_string(), &schema_json()), + validate_partial_entity(&entity.to_string(), &schema_cedar_src()), &[ "entity `Album::\"trip\"`", "type `Album`", @@ -1129,7 +1154,7 @@ mod tpe_validation_tests { } ]); assert_err_contains( - validate_partial_entities(&entities.to_string(), &schema_json()), + validate_partial_entities(&entities.to_string(), &schema_cedar_src()), &[ "ancestor `Group::\"admins\"`", "of `User::\"alice\"`", @@ -1148,7 +1173,7 @@ mod tpe_validation_tests { } ]); assert_matches!( - validate_partial_entities(&entities.to_string(), &schema_json()), + validate_partial_entities(&entities.to_string(), &schema_cedar_src()), Ok(()) ); } @@ -1168,10 +1193,158 @@ mod tpe_validation_tests { } ]); assert_err_contains( + validate_partial_entities(&entities.to_string(), &schema_cedar_src()), + &["duplicate entity entry", "User::\"alice\""], + ); + } + + #[test] + fn validate_type_aware_partial_request_succeeds() { + let request = json!({ + "principal": { "type": "User" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo", "id": "door" }, + "context": { "authenticated": true }, + "schema": CEDAR_SCHEMA_SRC + }); + assert_matches!( + validate_type_aware_partial_request(&request.to_string()), + Ok(()) + ); + } + + #[test] + fn validate_type_aware_partial_request_with_unknown_context_succeeds() { + let request = json!({ + "principal": { "type": "User", "id": "alice" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo" }, + "schema": CEDAR_SCHEMA_SRC + }); + assert_matches!( + validate_type_aware_partial_request(&request.to_string()), + Ok(()) + ); + } + + #[test] + fn validate_type_aware_partial_request_with_undeclared_principal_type_fails() { + let request = json!({ + "principal": { "type": "Admin" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo", "id": "door" }, + "context": { "authenticated": true }, + "schema": CEDAR_SCHEMA_SRC + }); + assert_err_contains( + validate_type_aware_partial_request(&request.to_string()), + &[ + "principal type `Admin`", + "is not valid for", + "Action::\"view\"", + ], + ); + } + + #[test] + fn validate_type_aware_partial_request_with_unknown_in_context_fails() { + let request = json!({ + "principal": { "type": "User", "id": "alice" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo", "id": "door" }, + "context": { "authenticated": { "__extn": { "fn": "unknown", "arg": "authn" } } }, + "schema": CEDAR_SCHEMA_SRC + }); + assert_err_contains( + validate_type_aware_partial_request(&request.to_string()), + &["Context contains unknowns"], + ); + } + + #[test] + fn validate_partial_entity_with_json_format_schema() { + let entity = json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false } + }); + assert_matches!( + validate_partial_entity(&entity.to_string(), &schema_json()), + Ok(()) + ); + + // The same violation the Cedar-format schema catches, so a passing case above cannot be a + // schema that parsed into something permissive. + let mistyped = json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": 3 } + }); + assert_err_contains( + validate_partial_entity(&mistyped.to_string(), &schema_json()), + &[ + "attribute `isAdmin`", + "User::\"alice\"", + "type mismatch", + "expected to have type bool", + "actually has type long", + ], + ); + } + + #[test] + fn validate_partial_entities_with_json_format_schema() { + let entities = json!([ + { + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false }, + "parents": [ { "type": "Group", "id": "admins" } ] + } + ]); + assert_matches!( validate_partial_entities(&entities.to_string(), &schema_json()), + Ok(()) + ); + + let duplicated = json!([ + { "uid": { "type": "User", "id": "alice" }, "attrs": { "isAdmin": false }, "parents": [] }, + { "uid": { "type": "User", "id": "alice" }, "attrs": { "isAdmin": true }, "parents": [] } + ]); + assert_err_contains( + validate_partial_entities(&duplicated.to_string(), &schema_json()), &["duplicate entity entry", "User::\"alice\""], ); } + + #[test] + fn validate_type_aware_partial_request_with_json_format_schema() { + let schema: serde_json::Value = serde_json::from_str(&schema_json()).unwrap(); + let request = json!({ + "principal": { "type": "User" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo", "id": "door" }, + "context": { "authenticated": true }, + "schema": schema + }); + assert_matches!( + validate_type_aware_partial_request(&request.to_string()), + Ok(()) + ); + + let bad_principal = json!({ + "principal": { "type": "Admin" }, + "action": { "__entity": { "type": "Action", "id": "view" } }, + "resource": { "type": "Photo", "id": "door" }, + "context": { "authenticated": true }, + "schema": schema + }); + assert_err_contains( + validate_type_aware_partial_request(&bad_principal.to_string()), + &[ + "principal type `Admin`", + "is not valid for", + "Action::\"view\"", + ], + ); + } } mod parsing_tests {} diff --git a/CedarJavaFFI/src/tpe.rs b/CedarJavaFFI/src/tpe.rs index 183c9c04..27dc33a6 100644 --- a/CedarJavaFFI/src/tpe.rs +++ b/CedarJavaFFI/src/tpe.rs @@ -17,9 +17,16 @@ //! Validation helpers for type-aware partial evaluation inputs. #[cfg(feature = "tpe")] -use cedar_policy::{ffi::Schema as FFISchema, PartialEntities, Schema}; +use cedar_policy::{ + ffi::Schema as FFISchema, Context, EntityId, EntityTypeName, EntityUid, PartialEntities, + PartialEntityUid, PartialRequest, Schema, +}; +#[cfg(feature = "tpe")] +use serde::Deserialize; #[cfg(feature = "tpe")] use serde_json::Value; +#[cfg(feature = "tpe")] +use std::str::FromStr; use crate::utils::Result; @@ -30,14 +37,50 @@ use crate::utils::Result; const TPE_DISABLED: &str = "TypeAwarePartialEvaluationNotEnabled: the `tpe` feature is disabled in this build"; +/// A partial entity UID, whose entity id may be unknown. Cedar has no serde for +/// `PartialEntityUid`, so the wire form is spelled out here. #[cfg(feature = "tpe")] -fn parse_schema_str(schema_json: &str) -> Result { - match serde_json::from_str(schema_json)? { +#[derive(Debug, Deserialize)] +struct PartialEntityUidJson { + #[serde(rename = "type")] + ty: String, + id: Option, +} + +#[cfg(feature = "tpe")] +impl PartialEntityUidJson { + fn parse(self) -> Result { + Ok(PartialEntityUid::new( + EntityTypeName::from_str(&self.ty)?, + self.id.map(EntityId::new), + )) + } +} + +/// A partial request, whose principal id, resource id and context may be unknown +#[cfg(feature = "tpe")] +#[derive(Debug, Deserialize)] +struct TypeAwarePartialRequestJson { + principal: PartialEntityUidJson, + action: Value, + resource: PartialEntityUidJson, + context: Option, + schema: FFISchema, +} + +#[cfg(feature = "tpe")] +fn parse_schema(schema: FFISchema) -> Result { + match schema { FFISchema::Cedar(src) => Ok(Schema::from_cedarschema_str(&src)?.0), FFISchema::Json(json) => Ok(Schema::from_json_value(json.into())?), } } +#[cfg(feature = "tpe")] +fn parse_schema_str(schema_json: &str) -> Result { + parse_schema(serde_json::from_str(schema_json)?) +} + /// Validates a single partial entity against a schema. Note that this cannot /// detect problems that span multiple entities, such as an ancestor with /// unknown ancestors. @@ -58,6 +101,22 @@ pub fn validate_partial_entities(entities_json: &str, schema_json: &str) -> Resu Ok(()) } +/// Validates a partial request against the schema carried by the request itself. +#[cfg(feature = "tpe")] +pub fn validate_type_aware_partial_request(request_json: &str) -> Result<()> { + let request: TypeAwarePartialRequestJson = serde_json::from_str(request_json)?; + let schema = parse_schema(request.schema)?; + let action = EntityUid::from_json(request.action)?; + let principal = request.principal.parse()?; + let resource = request.resource.parse()?; + let context = request + .context + .map(|c| Context::from_json_value(c, Some((&schema, &action)))) + .transpose()?; + PartialRequest::new(principal, action, resource, context, &schema)?; + Ok(()) +} + #[cfg(not(feature = "tpe"))] pub fn validate_partial_entity(_entity_json: &str, _schema_json: &str) -> Result<()> { Err(TPE_DISABLED.into()) @@ -67,3 +126,8 @@ pub fn validate_partial_entity(_entity_json: &str, _schema_json: &str) -> Result pub fn validate_partial_entities(_entities_json: &str, _schema_json: &str) -> Result<()> { Err(TPE_DISABLED.into()) } + +#[cfg(not(feature = "tpe"))] +pub fn validate_type_aware_partial_request(_request_json: &str) -> Result<()> { + Err(TPE_DISABLED.into()) +}