diff --git a/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java b/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java
index e3fc5ef4..d6c289e1 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/AuthorizationEngine.java
@@ -23,9 +23,12 @@
import com.cedarpolicy.model.EntityValidationRequest;
import com.cedarpolicy.model.PartialAuthorizationRequest;
import com.cedarpolicy.model.PartialAuthorizationResponse;
+import com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest;
+import com.cedarpolicy.model.TypeAwarePartialAuthorizationResponse;
import com.cedarpolicy.model.ValidationRequest;
import com.cedarpolicy.model.ValidationResponse;
import com.cedarpolicy.model.entity.Entities;
+import com.cedarpolicy.model.entity.PartialEntities;
import com.cedarpolicy.model.entity.Entity;
import com.cedarpolicy.model.exception.AuthException;
import com.cedarpolicy.model.exception.BadRequestException;
@@ -109,6 +112,73 @@ PartialAuthorizationResponse isAuthorizedPartial(PartialAuthorizationRequest req
PartialAuthorizationResponse isAuthorizedPartial(PartialAuthorizationRequest request,
PolicySet policySet, Entities entities) throws AuthException;
+ /**
+ * Asks what the given {@link TypeAwarePartialAuthorizationRequest} evaluates to against the {@code policySet} and
+ * the partially known {@code entities}, returning residual policies for whatever could not be decided.
+ *
+ *
The policy set is validated against the request's schema, which plain authorization does not do, so a
+ * policy set that authorizes fine can fail here — an unconstrained scope means every condition must typecheck for
+ * every entity type the action accepts, and an optional attribute needs a {@code has} guard.
+ *
+ * @param request The request to evaluate.
+ * @param policySet The policy set to evaluate against.
+ * @param entities The partially known entities to evaluate against.
+ * @return Every residual, plus a concrete decision if one could be reached.
+ * @throws AuthException On failure to make the authorization request.
+ * @throws UnsupportedOperationException If the implementation does not support type-aware partial evaluation.
+ */
+ @Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION)
+ default TypeAwarePartialAuthorizationResponse isAuthorizedTypeAwarePartial(
+ TypeAwarePartialAuthorizationRequest request, PolicySet policySet,
+ PartialEntities entities) throws AuthException {
+ throw new UnsupportedOperationException(
+ "isAuthorizedTypeAwarePartial is not implemented by " + getClass().getName());
+ }
+
+ /**
+ * Overload for entities that are all fully known; they are lifted using the schema the request already carries.
+ *
+ * @param request The request to evaluate.
+ * @param policySet The policy set to evaluate against.
+ * @param entities The concrete entities to evaluate against.
+ * @return Every residual, plus a concrete decision if one could be reached.
+ * @throws AuthException On failure to make the authorization request, or if the entities do not check out against
+ * the request's schema.
+ */
+ @Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION)
+ default TypeAwarePartialAuthorizationResponse isAuthorizedTypeAwarePartial(
+ TypeAwarePartialAuthorizationRequest request, PolicySet policySet,
+ Entities entities) throws AuthException {
+ return isAuthorizedTypeAwarePartial(request, policySet,
+ new PartialEntities(entities, request.schema));
+ }
+
+ /**
+ * Completes a type-aware partial authorization now that the previously unknown parts of the request and entities
+ * are known, returning the decision the original policies would have reached.
+ *
+ *
Prefer {@link com.cedarpolicy.model.TypeAwarePartialAuthorizationSuccessResponse#reauthorize}, which supplies
+ * the first three arguments from the response itself so they cannot disagree with the residuals you inspected. This
+ * form exists for that method to delegate to, and for a caller who has the partial inputs but not the response.
+ *
+ * @param request The partial request the residuals were computed from.
+ * @param policySet The policy set the residuals were computed from.
+ * @param partialEntities The partially known entities the residuals were computed from.
+ * @param concreteRequest The same request with its unknowns filled in.
+ * @param entities The same entities with their unknowns filled in. Cedar validates both against the partial
+ * inputs and rejects a mismatch; see
+ * {@link com.cedarpolicy.model.TypeAwarePartialAuthorizationSuccessResponse#reauthorize} for what it compares.
+ * @return The authorization decision, or a failure response if the completed inputs are inconsistent with the
+ * partial ones.
+ * @throws AuthException On failure to make the authorization request.
+ */
+ @Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION)
+ default AuthorizationResponse reauthorize(TypeAwarePartialAuthorizationRequest request, PolicySet policySet,
+ PartialEntities partialEntities, AuthorizationRequest concreteRequest, Entities entities)
+ throws AuthException {
+ throw new UnsupportedOperationException("reauthorize is not implemented by " + getClass().getName());
+ }
+
/**
* Asks whether the policies in the given {@link ValidationRequest} 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 ff8d512e..95d4f5a3 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java
@@ -30,13 +30,18 @@
import com.cedarpolicy.model.EntityValidationRequest;
import com.cedarpolicy.model.LevelValidationRequest;
import com.cedarpolicy.model.PartialAuthorizationResponse;
+import com.cedarpolicy.model.TypeAwarePartialAuthorizationResponse;
import com.cedarpolicy.model.ValidationRequest;
import com.cedarpolicy.model.ValidationResponse;
import com.cedarpolicy.model.entity.Entities;
import com.cedarpolicy.model.entity.Entity;
+import com.cedarpolicy.model.entity.PartialEntities;
+import com.cedarpolicy.model.entity.PartialEntity;
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.DetailedError;
import com.cedarpolicy.model.policy.PolicySet;
import com.cedarpolicy.value.Value;
import com.fasterxml.jackson.annotation.JsonCreator;
@@ -123,6 +128,44 @@ public PartialAuthorizationResponse isAuthorizedPartial(com.cedarpolicy.model.Pa
return isAuthorizedPartial(q, policySet, entities.getEntities());
}
+ @Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION)
+ @Override
+ public TypeAwarePartialAuthorizationResponse isAuthorizedTypeAwarePartial(
+ com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest q, PolicySet policySet,
+ PartialEntities entities) throws AuthException {
+ var response = call("AuthorizationTypeAwarePartialOperation",
+ TypeAwarePartialAuthorizationResponse.class,
+ new TypeAwarePartialAuthorizationRequest(q, policySet, entities));
+ throwIfFeatureDisabled(response.getErrors().orElse(List.of()));
+ return response.withInputs(q, policySet, entities, this);
+ }
+
+ @Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION)
+ @Override
+ public AuthorizationResponse reauthorize(com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest request,
+ PolicySet policySet, PartialEntities partialEntities,
+ com.cedarpolicy.model.AuthorizationRequest concreteRequest, Entities entities) throws AuthException {
+ var response = call("TypeAwarePartialReauthorizeOperation", AuthorizationResponse.class,
+ new TypeAwarePartialReauthorizeRequest(request, policySet, partialEntities, concreteRequest, entities));
+ throwIfFeatureDisabled(response.errors.map(e -> (List) e).orElse(List.of()));
+ return response;
+ }
+
+ /**
+ * The native library reports a disabled experimental feature as a failure answer rather than a thrown exception,
+ * because {@link #call} converts every native error into an {@link AuthException}. Recognising the token here is
+ * what turns it back into {@link MissingExperimentalFeatureException}.
+ */
+ private static void throwIfFeatureDisabled(List errors) throws AuthException {
+ for (var error : errors) {
+ var translated = ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION
+ .translateIfDisabled(new InternalException(error.message));
+ if (translated instanceof MissingExperimentalFeatureException) {
+ throw translated;
+ }
+ }
+ }
+
@Override
public ValidationResponse validate(ValidationRequest q) throws AuthException {
return call("ValidateOperation", ValidationResponse.class, q);
@@ -213,6 +256,40 @@ private static class AuthorizationRequest extends com.cedarpolicy.model.Authoriz
}
}
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ private static final class TypeAwarePartialAuthorizationRequest
+ extends com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest {
+ @JsonProperty private final PolicySet policies;
+ @JsonProperty private final Set entities;
+
+ TypeAwarePartialAuthorizationRequest(com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest request,
+ PolicySet policySet, PartialEntities entities) {
+ super(request);
+ this.policies = policySet;
+ this.entities = entities.getEntities();
+ }
+ }
+
+ /** The partial payload plus the completed request and entities Cedar validates it against. */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ private static final class TypeAwarePartialReauthorizeRequest
+ extends com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest {
+ @JsonProperty private final PolicySet policies;
+ @JsonProperty private final Set entities;
+ @JsonProperty private final com.cedarpolicy.model.AuthorizationRequest concreteRequest;
+ @JsonProperty private final Set concreteEntities;
+
+ TypeAwarePartialReauthorizeRequest(com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest request,
+ PolicySet policySet, PartialEntities partialEntities,
+ com.cedarpolicy.model.AuthorizationRequest concreteRequest, Entities concreteEntities) {
+ super(request);
+ this.policies = policySet;
+ this.entities = partialEntities.getEntities();
+ this.concreteRequest = concreteRequest;
+ this.concreteEntities = concreteEntities.getEntities();
+ }
+ }
+
@JsonInclude(JsonInclude.Include.NON_ABSENT)
private static final class PartialAuthorizationRequest extends com.cedarpolicy.model.PartialAuthorizationRequest {
@JsonProperty private final PolicySet policies;
diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java
index d9d151c1..fad48cd9 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationResponse.java
@@ -52,6 +52,41 @@ public final class TypeAwarePartialAuthorizationResponse {
this.warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
+ private TypeAwarePartialAuthorizationResponse(TypeAwarePartialAuthorizationResponse response,
+ TypeAwarePartialAuthorizationRequest request, com.cedarpolicy.model.policy.PolicySet policies,
+ com.cedarpolicy.model.entity.PartialEntities entities, com.cedarpolicy.AuthorizationEngine engine) {
+ this.type = response.type;
+ this.success = response.success.map(
+ s -> new TypeAwarePartialAuthorizationSuccessResponse(s, request, policies, entities, engine));
+ this.errors = response.errors;
+ this.warnings = response.warnings;
+ }
+
+ /**
+ * Returns a copy whose success response, if any, remembers what the residuals were computed from, so that
+ * {@link TypeAwarePartialAuthorizationSuccessResponse#reauthorize} does not need them passed again.
+ *
+ * Public only because the engine that calls it is in another package; callers should not use it. Pass
+ * inputs other than the ones the residuals were computed from and the residuals describe one authorization while
+ * {@code reauthorize} answers a different one — as a success, because Cedar can only check the completed request
+ * against the partial inputs it is handed.
+ *
+ * @param request the partial request the residuals were computed from
+ * @param policies the policy set the residuals were computed from
+ * @param entities the partially known entities the residuals were computed from
+ * @param engine the engine to reauthorize through
+ * @return a copy carrying those inputs
+ */
+ public TypeAwarePartialAuthorizationResponse withInputs(TypeAwarePartialAuthorizationRequest request,
+ com.cedarpolicy.model.policy.PolicySet policies,
+ com.cedarpolicy.model.entity.PartialEntities entities, com.cedarpolicy.AuthorizationEngine engine) {
+ java.util.Objects.requireNonNull(request, "request");
+ java.util.Objects.requireNonNull(policies, "policies");
+ java.util.Objects.requireNonNull(entities, "entities");
+ java.util.Objects.requireNonNull(engine, "engine");
+ return new TypeAwarePartialAuthorizationResponse(this, request, policies, entities, engine);
+ }
+
/**
* Whether this is a success or a failure response.
*
@@ -83,7 +118,7 @@ public Optional> getErrors() {
}
/**
- * Warnings, which either kind of response may carry.
+ * Warnings, always empty for type-aware partial evaluation: Cedar's {@code TpeResponse} exposes none.
*
* @return the warnings, empty if there were none
*/
diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java
index 45d9a05d..506dff75 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/model/TypeAwarePartialAuthorizationSuccessResponse.java
@@ -16,53 +16,116 @@
package com.cedarpolicy.model;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
+import com.cedarpolicy.AuthorizationEngine;
import com.cedarpolicy.Experimental;
import com.cedarpolicy.ExperimentalFeature;
-import com.cedarpolicy.model.exception.InternalException;
+import com.cedarpolicy.model.exception.AuthException;
+import com.cedarpolicy.model.entity.PartialEntities;
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.
+ * Successful type-aware partial authorization response. Residuals are for inspection; to finish an
+ * authorization once the unknowns are known call {@link #reauthorize} rather than authorizing them yourself — see
+ * {@link #getResiduals()} for why.
*/
@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 erroredResidualIds;
private final Set residuals;
private final Set nontrivialResiduals;
private final Set trivialResiduals;
+ private final Set erroredResiduals;
+ private final TypeAwarePartialAuthorizationRequest evaluatedRequest;
+ private final PolicySet evaluatedPolicies;
+ private final PartialEntities evaluatedEntities;
+ private final AuthorizationEngine engine;
- /**
- * 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 {
+ @JsonProperty("residuals") Map residuals,
+ @JsonProperty("nontrivialResiduals") Set nontrivialResiduals,
+ @JsonProperty("erroredResiduals") Set erroredResiduals) {
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));
+ this.erroredResidualIds =
+ erroredResiduals == null ? Set.of() : Set.copyOf(erroredResiduals);
+ this.residuals = Set.copyOf((residuals == null ? Map.of() : residuals).entrySet().stream()
+ .map(e -> new Policy(e.getValue(), e.getKey()))
+ .collect(Collectors.toSet()));
+ this.nontrivialResiduals = Set.copyOf(select(this.residuals, this.nontrivialResidualIds, true));
+ this.trivialResiduals = Set.copyOf(select(this.residuals, this.nontrivialResidualIds, false));
+ this.erroredResiduals = Set.copyOf(select(this.residuals, this.erroredResidualIds, true));
+ this.evaluatedRequest = null;
+ this.evaluatedPolicies = null;
+ this.evaluatedEntities = null;
+ this.engine = null;
+ }
+
+ /** Copy that remembers what the residuals were computed from, so {@link #reauthorize} needs only what is new. */
+ TypeAwarePartialAuthorizationSuccessResponse(TypeAwarePartialAuthorizationSuccessResponse other,
+ TypeAwarePartialAuthorizationRequest request, PolicySet policies, PartialEntities entities,
+ AuthorizationEngine engine) {
+ this.decision = other.decision;
+ // Already immutable; copied again so SpotBugs can see every assignment path produce an immutable set.
+ this.nontrivialResidualIds = Set.copyOf(other.nontrivialResidualIds);
+ this.erroredResidualIds = Set.copyOf(other.erroredResidualIds);
+ this.residuals = Set.copyOf(other.residuals);
+ this.nontrivialResiduals = Set.copyOf(other.nontrivialResiduals);
+ this.trivialResiduals = Set.copyOf(other.trivialResiduals);
+ this.erroredResiduals = Set.copyOf(other.erroredResiduals);
+ this.evaluatedRequest = request;
+ // Snapshotted, not aliased: PolicySet's collections are public and non-final and its constructors do not copy,
+ // so keeping the caller's instance would let them mutate the set after inspecting the residuals and have
+ // reauthorize decide from a policy that appears in none of them.
+ this.evaluatedPolicies = new PolicySet(Set.copyOf(policies.policies), Set.copyOf(policies.templates),
+ List.copyOf(policies.templateLinks));
+ this.evaluatedEntities = entities;
+ this.engine = engine;
+ }
+
+ /**
+ * Completes this authorization now that the previously unknown parts of the request and entities are known,
+ * returning the decision the original policies would have reached. Prefer this over authorizing
+ * {@link #getResiduals()} yourself: a residual carries the original request's values folded in, so authorizing it
+ * for a different request silently grants access the original policies denied.
+ *
+ * This response carries the partial request, policy set and entities the residuals came from, so they cannot
+ * disagree with what you inspected. Cedar validates {@code request} and {@code entities} against them and rejects a
+ * mismatch: it examines only the entities the partial set knew about, but compares each one's whole attribute map,
+ * whole tag map and ancestor closure, so identical declared {@code parents} can still be rejected if the
+ * partial set lacked an entity further up the chain.
+ *
+ * @param request The same request these residuals were computed from, with its unknowns filled in.
+ * @param entities The same entities, with their unknowns filled in.
+ * @return The authorization decision, or a failure response if the completed inputs are inconsistent with the
+ * partial ones.
+ * @throws AuthException On failure to make the authorization request, or if this response was deserialized directly
+ * rather than obtained from an {@link AuthorizationEngine} and so has nothing to reauthorize.
+ */
+ @Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION)
+ public AuthorizationResponse reauthorize(AuthorizationRequest request,
+ com.cedarpolicy.model.entity.Entities entities) throws AuthException {
+ if (this.engine == null) {
+ throw new AuthException("this response was not produced by an authorization engine, "
+ + "so it carries nothing to reauthorize");
+ }
+ return this.engine.reauthorize(this.evaluatedRequest, this.evaluatedPolicies, this.evaluatedEntities,
+ request, entities);
}
/**
@@ -76,7 +139,7 @@ public AuthorizationSuccessResponse.Decision getDecision() {
}
/**
- * The ids of the non-trivial residuals, which is the wire field the trivial/non-trivial split is derived from.
+ * The ids of the non-trivial residuals.
*
* @return ids of the residuals that were not reduced to a concrete true, false, or error
*/
@@ -86,9 +149,17 @@ public Set getNontrivialResidualIds() {
}
/**
- * 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.
+ * Every residual. Each keeps the id and the annotations of the policy it came from.
+ *
+ * These are not policies you can authorize against. A residual has the original request's values folded
+ * in and its scope constraints rewritten into the condition, so one computed for {@code User::"alice"} carries no
+ * principal constraint: authorizing it for {@code User::"bob"} grants Bob access that only Alice had,
+ * silently and without any error. And a residual containing a known-error subexpression cannot be parsed by Cedar
+ * at all, so a policy set containing one fails to parse, taking every other residual with it — see
+ * {@link #getErroredResidualIds()}, which also lists the methods such a residual does not support.
+ *
+ *
Use {@link #reauthorize} instead: it checks the completed request against the partial one, which authorizing
+ * these policies cannot do.
*
* @return every residual
*/
@@ -99,7 +170,8 @@ public Set getResiduals() {
/**
* The residuals that were not reduced to a concrete true, false, or error, so the ones whose conditions are worth
- * inspecting.
+ * inspecting. Together with {@link #getTrivialResiduals()} this partitions {@link #getResiduals()};
+ * {@link #getErroredResidualIds()} cuts across that split, and a residual named there is source-only.
*
* @return the non-trivial residuals
*/
@@ -109,7 +181,9 @@ public Set getNontrivialResiduals() {
}
/**
- * The residuals that were reduced to a concrete true, false, or error.
+ * The residuals that were reduced to a concrete true, false, or error. Together with
+ * {@link #getNontrivialResiduals()} this partitions {@link #getResiduals()}; a residual named by
+ * {@link #getErroredResidualIds()} may appear here too, in which case it is source-only.
*
* @return the trivial residuals
*/
@@ -119,42 +193,47 @@ public Set getTrivialResiduals() {
}
/**
- * 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.
+ * The ids of the residuals that contain a subexpression Cedar already knows would error — an integer
+ * overflow, for instance. Cedar marks it with a node it can render but not parse back, so these residuals are
+ * source-only: {@link Policy#getSource()}, {@link Policy#getID()} and {@code toString()} work, while
+ * {@link Policy#effect()}, {@link Policy#toJson()}, {@link Policy#getAnnotations()} and
+ * {@link Policy#getAnnotation(String)} each re-parse the source and throw
+ * {@link com.cedarpolicy.model.exception.InternalException}.
*
- *
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.
+ *
Containing an error node does not mean the policy errors: in {@code somethingUnknown || error} whose
+ * left side turns out true, the policy is satisfied and decides the authorization normally. So this is not a set of
+ * inert policies, and it cuts across the {@link #getNontrivialResiduals()} / {@link #getTrivialResiduals()} split
+ * rather than being a third partition — a residual that reduced entirely to an error is trivial, one with an error
+ * nested inside a live condition is not. {@link #reauthorize} is unaffected either way: it evaluates the real
+ * residual natively, never Cedar's rendering of it.
*
- *
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 ids of the residuals containing a known-error subexpression, empty if none do
+ */
+ @JsonIgnore
+ public Set getErroredResidualIds() {
+ return this.erroredResidualIds;
+ }
+
+ /**
+ * The residuals containing a known-error subexpression. See {@link #getErroredResidualIds()} for which of their
+ * methods are usable, and why such a policy may still decide an authorization.
*
- * @return policy set of every residual
+ * @return the residuals containing a known-error subexpression, empty if none do
*/
@JsonIgnore
- public PolicySet getPolicySet() {
- return new PolicySet(Set.copyOf(this.residuals));
+ public Set getErroredResiduals() {
+ return this.erroredResiduals;
}
- private static Set partition(Set residuals, Set nontrivialIds, boolean nontrivial) {
- return residuals.stream()
- .filter(p -> nontrivialIds.contains(p.getID()) == nontrivial)
- .collect(Collectors.toSet());
+ @Override
+ public String toString() {
+ return "decision: " + this.decision + "\nresiduals: " + this.residuals
+ + (this.erroredResidualIds.isEmpty() ? "" : "\nerrored: " + this.erroredResidualIds);
}
- 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());
+ private static Set select(Set residuals, Set ids, boolean present) {
+ return residuals.stream()
+ .filter(p -> ids.contains(p.getID()) == present)
+ .collect(Collectors.toSet());
}
}
diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/exception/MissingExperimentalFeatureException.java b/CedarJava/src/main/java/com/cedarpolicy/model/exception/MissingExperimentalFeatureException.java
index 64e5d969..343bcf4e 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/model/exception/MissingExperimentalFeatureException.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/model/exception/MissingExperimentalFeatureException.java
@@ -25,11 +25,26 @@ public class MissingExperimentalFeatureException extends InternalException {
public MissingExperimentalFeatureException(ExperimentalFeature experimentalFeature) {
super("Missing experimental feature. To enable this feature please recompile "
- + Path.of(System.getenv("CEDAR_JAVA_FFI_LIB")).getFileName()
+ + nativeLibraryName()
+ " with \"--features=" + experimentalFeature.getCompileFlag() + "\".");
this.experimentalFeature = experimentalFeature;
}
+ /**
+ * The library to name in the message. {@code CEDAR_JAVA_FFI_LIB} is only an override, so it is unset in the normal
+ * bundled-library case — reading it unguarded threw a {@link NullPointerException} from inside this constructor,
+ * which discarded the very error being reported.
+ */
+ private static String nativeLibraryName() {
+ String override = System.getenv("CEDAR_JAVA_FFI_LIB");
+ if (override == null || override.isEmpty()) {
+ return "the CedarJava native library";
+ }
+ // getFileName() is null for a path with no name element, such as a bare root.
+ Path name = Path.of(override).getFileName();
+ return name == null ? override : name.toString();
+ }
+
public ExperimentalFeature getExperimentalFeature() {
return this.experimentalFeature;
}
diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/PartialEntityUID.java b/CedarJava/src/main/java/com/cedarpolicy/value/PartialEntityUID.java
index 87af6d60..cf6acd8a 100644
--- a/CedarJava/src/main/java/com/cedarpolicy/value/PartialEntityUID.java
+++ b/CedarJava/src/main/java/com/cedarpolicy/value/PartialEntityUID.java
@@ -28,7 +28,7 @@
/**
* An entity UID whose id may be unknown. The entity type is always known: type-aware partial evaluation requires the
- * type of an unknown principal or resource in order to type check the request.
+ * type of an unknown principal or resource in order to validate the request.
*
* This is the partial counterpart to {@link EntityUID} and holds the same component types, {@link EntityTypeName}
* and {@link EntityIdentifier}, so that an id that is present has already been through the same validation as a
diff --git a/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java b/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java
index 8da4afe3..82bb8316 100644
--- a/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java
+++ b/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java
@@ -24,7 +24,6 @@
import com.cedarpolicy.model.PartialAuthorizationResponse;
import com.cedarpolicy.model.AuthorizationResponse.SuccessOrFailure;
import com.cedarpolicy.model.AuthorizationSuccessResponse.Decision;
-import com.cedarpolicy.model.exception.MissingExperimentalFeatureException;
import com.cedarpolicy.model.entity.Entity;
import com.cedarpolicy.model.policy.Policy;
import com.cedarpolicy.model.Context;
@@ -37,7 +36,6 @@
import com.cedarpolicy.model.entity.Entities;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.function.Executable;
import static org.junit.jupiter.api.Assertions.*;
@@ -143,7 +141,7 @@ public void concrete() {
var policies = new HashSet();
policies.add(new Policy("permit(principal == User::\"alice\",action,resource);", "p0"));
var policySet = new PolicySet(policies);
- assumePartialEvaluation(() -> {
+ TestUtil.assumeExperimentalFeature(() -> {
try {
final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new HashSet<>());
assertEquals(Decision.Allow, response.success.orElseThrow().getDecision());
@@ -170,7 +168,7 @@ public void partialAuthConcreteWithBackwardCompatibleContext() {
policies.add(new Policy("permit(principal == User::\"alice\",action,resource) when {context.authenticated == true};", "p0"));
var policySet = new PolicySet(policies);
- assumePartialEvaluation(() -> {
+ TestUtil.assumeExperimentalFeature(() -> {
try {
final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new HashSet<>());
assertEquals(Decision.Allow, response.success.orElseThrow().getDecision());
@@ -194,7 +192,7 @@ public void partialAuthConcreteWithContextObject() {
var policies = new HashSet();
policies.add(new Policy("permit(principal == User::\"alice\",action,resource) when {context.authenticated == true};", "p0"));
var policySet = new PolicySet(policies);
- assumePartialEvaluation(() -> {
+ TestUtil.assumeExperimentalFeature(() -> {
try {
final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new HashSet<>());
assertEquals(Decision.Allow, response.success.orElseThrow().getDecision());
@@ -218,7 +216,7 @@ public void partialAuthConcreteWithEntitiesObject() {
var policies = new HashSet();
policies.add(new Policy("permit(principal == User::\"alice\",action,resource) when {context.authenticated == true};", "p0"));
var policySet = new PolicySet(policies);
- assumePartialEvaluation(() -> {
+ TestUtil.assumeExperimentalFeature(() -> {
try {
final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new Entities());
assertEquals(Decision.Allow, response.success.orElseThrow().getDecision());
@@ -239,7 +237,7 @@ public void residual() {
var policies = new HashSet();
policies.add(new Policy("permit(principal == User::\"alice\",action,resource);", "p0"));
var policySet = new PolicySet(policies);
- assumePartialEvaluation(() -> {
+ TestUtil.assumeExperimentalFeature(() -> {
try {
final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new HashSet<>());
assertTrue(response.success.orElseThrow().getDecision() == null);
@@ -260,7 +258,7 @@ public void residualWithUnknownValue() {
var policies = new HashSet();
policies.add(new Policy("permit(principal == User::\"alice\",action,resource) when{context.authenticated};", "p0"));
var policySet = new PolicySet(policies);
- assumePartialEvaluation(() -> {
+ TestUtil.assumeExperimentalFeature(() -> {
try {
final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new HashSet<>());
assertNull(response.success.orElseThrow().getDecision());
@@ -271,13 +269,4 @@ public void residualWithUnknownValue() {
});
}
- private void assumePartialEvaluation(Executable executable) {
- try {
- executable.execute();
- } catch (MissingExperimentalFeatureException e) {
- System.err.println("Skipping assertions: " + e.getMessage());
- } catch (Throwable e) {
- throw new RuntimeException(e);
- }
- }
}
diff --git a/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java b/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java
index e5d646e8..378eda40 100644
--- a/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java
+++ b/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java
@@ -21,6 +21,9 @@
import com.cedarpolicy.model.policy.TemplateLink;
import com.cedarpolicy.model.policy.PolicySet;
import com.cedarpolicy.model.policy.LinkValue;
+import com.cedarpolicy.model.exception.MissingExperimentalFeatureException;
+import org.junit.jupiter.api.function.Executable;
+import java.util.stream.Collectors;
import com.cedarpolicy.model.policy.Policy;
import com.cedarpolicy.model.entity.Entity;
import com.cedarpolicy.value.EntityTypeName;
@@ -207,4 +210,27 @@ public static PolicySet buildInvalidPolicySet() {
return new PolicySet(policies, templates, templateLinks);
}
+
+ /** Policy carries its own id, so residual accessors return sets rather than maps. */
+ public static Set ids(Set policies) {
+ return policies.stream().map(Policy::getID).collect(Collectors.toSet());
+ }
+
+ /**
+ * Skips the assertions when the native library was built without the experimental feature they need.
+ *
+ * An {@link AssertionError} is rethrown unchanged so a genuine failure keeps its expected/actual diff instead of
+ * arriving wrapped.
+ */
+ public static void assumeExperimentalFeature(Executable executable) {
+ try {
+ executable.execute();
+ } catch (MissingExperimentalFeatureException e) {
+ System.err.println("Skipping assertions: " + e.getMessage());
+ } catch (AssertionError e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new RuntimeException(e);
+ }
+ }
}
diff --git a/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationEngineTests.java b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationEngineTests.java
new file mode 100644
index 00000000..6e89f76a
--- /dev/null
+++ b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationEngineTests.java
@@ -0,0 +1,824 @@
+/*
+ * 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 java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.cedarpolicy.model.AuthorizationRequest;
+import com.cedarpolicy.model.AuthorizationResponse.SuccessOrFailure;
+import com.cedarpolicy.model.AuthorizationSuccessResponse.Decision;
+import com.cedarpolicy.model.TypeAwarePartialAuthorizationRequest;
+import com.cedarpolicy.model.TypeAwarePartialAuthorizationResponse;
+import com.cedarpolicy.model.entity.Entities;
+import com.cedarpolicy.model.entity.Entity;
+import com.cedarpolicy.model.entity.PartialEntities;
+import com.cedarpolicy.model.entity.PartialEntity;
+import com.cedarpolicy.model.exception.InternalException;
+import com.cedarpolicy.model.policy.LinkValue;
+import com.cedarpolicy.model.policy.Policy;
+import com.cedarpolicy.model.policy.PolicySet;
+import com.cedarpolicy.model.policy.TemplateLink;
+import com.cedarpolicy.model.schema.Schema;
+import com.cedarpolicy.value.EntityTypeName;
+import com.cedarpolicy.value.EntityUID;
+import com.cedarpolicy.value.PrimBool;
+import com.cedarpolicy.value.PrimString;
+import com.cedarpolicy.value.Value;
+
+import org.junit.jupiter.api.Test;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Type-aware partial evaluation through {@link BasicAuthorizationEngine}, end to end over the JNI bridge.
+ *
+ *
Separate from {@link AuthTests} so that a failure loading the TPE schema fixture cannot take the plain
+ * authorization tests down with it through a static initializer.
+ */
+public class TypeAwarePartialAuthorizationEngineTests {
+
+ private static final Schema TPE_SCHEMA = TestUtil.loadSchemaResource("/tpe_schema.json");
+ private static final EntityUID TPE_VIEW = new EntityUID(EntityTypeName.parse("Action").get(), "view");
+ private static final EntityUID TPE_DOOR = new EntityUID(EntityTypeName.parse("Photo").get(), "door");
+ private static final EntityUID TPE_ALICE = new EntityUID(EntityTypeName.parse("User").get(), "alice");
+
+ /** Decides only once {@code principal.department} is known, so it residualizes while the principal is not. */
+ private static final String TPE_ENG_POLICY = "permit(principal, action == Action::\"view\", resource)\n"
+ + "when { principal has department && principal.department == \"eng\" };";
+
+ /** Always errors at evaluation time: `i64::MAX + 1` overflows. Validates against any schema. */
+ private static final String TPE_OVERFLOW_POLICY = "permit(principal, action == Action::\"view\", resource)\n"
+ + "when { 9223372036854775807 + 1 > 0 };";
+
+ private static PolicySet tpePolicies(String src, String id) {
+ return new PolicySet(Set.of(new Policy(src, id)));
+ }
+
+ @Test
+ public void tpeConcreteRequestReachesADecision() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var response = auth.isAuthorizedTypeAwarePartial(q,
+ tpePolicies("permit(principal, action, resource);", "p0"), PartialEntities.empty());
+ var success = response.getSuccess().orElseThrow();
+ assertEquals(Decision.Allow, success.getDecision());
+ // A concrete Allow still carries the permit that decided it, and that permit is trivial.
+ assertEquals(Set.of("p0"), TestUtil.ids(success.getResiduals()));
+ assertTrue(success.getNontrivialResiduals().isEmpty());
+ });
+ }
+
+ @Test
+ public void tpeUnknownPrincipalIdYieldsAResidual() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(EntityTypeName.parse("User").get()).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var response = auth.isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_ENG_POLICY, "p0"),
+ PartialEntities.empty());
+ var success = response.getSuccess().orElseThrow();
+ assertNull(success.getDecision(), "no decision is reachable while the principal is unknown");
+ assertEquals(Set.of("p0"), TestUtil.ids(success.getResiduals()));
+ assertEquals(Set.of("p0"), TestUtil.ids(success.getNontrivialResiduals()));
+ // The principal id is unknown, so `principal` survives as a variable.
+ assertEquals("permit(principal, action, resource) when "
+ + "{ (principal has department) && (principal.department == \"eng\") };",
+ success.getResiduals().iterator().next().getSource());
+ });
+ }
+
+ /**
+ * The residual's content, not just its id. Cedar rewrites a residual thoroughly: the satisfied scope
+ * constraints are folded away, and every reference to {@code principal} is replaced by the concrete entity the
+ * request named.
+ *
+ *
That substitution is the sharpest reason a residual must never be authorized against a different request. The
+ * text below does not merely lose {@code principal == User::"alice"} — it hard-codes {@code User::"alice"} into the
+ * condition, so evaluating it for another principal still reads alice's attributes and can grant that
+ * principal access alice alone had.
+ */
+ @Test
+ public void tpeResidualTextShowsTheFoldedCondition() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ // Constrained to alice, and alice is who we asked about — so the constraint is known true.
+ var policies = tpePolicies("@owner(\"team-a\")\n"
+ + "permit(principal == User::\"alice\", action == Action::\"view\", resource)\n"
+ + "when { principal has department && principal.department == \"eng\" };", "p0");
+ var entities = new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()), Optional.of(Map.of()),
+ TPE_SCHEMA)),
+ TPE_SCHEMA);
+
+ var success = auth.isAuthorizedTypeAwarePartial(q, policies, entities).getSuccess().orElseThrow();
+ var residual = success.getResiduals().iterator().next();
+
+ assertEquals("p0", residual.getID());
+ assertEquals("@owner(\"team-a\")\npermit(principal, action, resource) when "
+ + "{ (User::\"alice\" has department) && (User::\"alice\".department == \"eng\") };",
+ residual.getSource());
+ // The annotation survives, and the residual is still a usable Policy because nothing errored.
+ assertEquals(Map.of("owner", "team-a"), residual.getAnnotations());
+ assertDoesNotThrow(residual::effect);
+ assertTrue(success.getErroredResidualIds().isEmpty());
+ });
+ }
+
+ @Test
+ public void tpeUnknownContextYieldsAResidual() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ // No context(...) call at all, so the whole context is unknown.
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR).schema(TPE_SCHEMA).build();
+ var response = auth.isAuthorizedTypeAwarePartial(q,
+ tpePolicies("permit(principal, action == Action::\"view\", resource)\n"
+ + "when { context has authenticated && context.authenticated };", "p0"),
+ PartialEntities.empty());
+ var success = response.getSuccess().orElseThrow();
+ assertNull(success.getDecision());
+ assertEquals(Set.of("p0"), TestUtil.ids(success.getNontrivialResiduals()));
+ });
+ }
+
+ @Test
+ public void tpeConcreteEntitiesAreLifted() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var alice = new Entity(TPE_ALICE, Map.of("department", new PrimString("eng")), Set.of(), Map.of());
+
+ var success = auth
+ .isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_ENG_POLICY, "p0"), new Entities(Set.of(alice)))
+ .getSuccess().orElseThrow();
+ // The policy reads an attribute, so a decision is only reachable if the lift carried alice's attributes
+ // over rather than marking them unknown.
+ assertEquals(Decision.Allow, success.getDecision());
+ assertTrue(success.getNontrivialResiduals().isEmpty());
+ });
+ }
+
+ @Test
+ public void tpeUnknownAttrsYieldsAResidual() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ // alice is known to exist, but her attributes are not known, so the condition cannot be resolved.
+ var entities = new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()), Optional.of(Map.of()),
+ TPE_SCHEMA)),
+ TPE_SCHEMA);
+ var response = auth.isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_ENG_POLICY, "p0"), entities);
+ var success = response.getSuccess().orElseThrow();
+ assertNull(success.getDecision());
+ assertEquals(Set.of("p0"), TestUtil.ids(success.getNontrivialResiduals()));
+ // The principal id is known here, unlike the test above, so `principal` is substituted with the
+ // entity — which is why a residual must not be evaluated for a different principal.
+ assertEquals("permit(principal, action, resource) when "
+ + "{ (User::\"alice\" has department) && (User::\"alice\".department == \"eng\") };",
+ success.getResiduals().iterator().next().getSource());
+ });
+ }
+
+ /**
+ * The reauthorization path, which runs natively: the residuals never cross back. The decision must track the data
+ * filled in — {@code "eng"} satisfies the condition and {@code "sales"} does not — and must agree with authorizing
+ * the original policies directly. A single policy, so neither outcome can be masked by another permit.
+ */
+ @Test
+ public void tpeReauthorizeCompletesAuthorization() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var policies = tpePolicies(TPE_ENG_POLICY, "conditional");
+ var partialRequest = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var partialEntities = new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()), Optional.of(Map.of()),
+ TPE_SCHEMA)),
+ TPE_SCHEMA);
+
+ var success = auth.isAuthorizedTypeAwarePartial(partialRequest, policies, partialEntities)
+ .getSuccess().orElseThrow();
+ assertNull(success.getDecision(), "the attributes are unknown, so nothing is decided yet");
+
+ for (var expected : Map.of("eng", Decision.Allow, "sales", Decision.Deny).entrySet()) {
+ var department = expected.getKey();
+ var alice = new Entity(TPE_ALICE, Map.of("department", new PrimString(department)), Set.of(), Map.of());
+ var concreteRequest = new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR,
+ new HashMap());
+ var entities = new Entities(Set.of(alice));
+
+ var viaReauthorize = success.reauthorize(concreteRequest, entities)
+ .success.orElseThrow().getDecision();
+ var viaOriginal = auth.isAuthorized(concreteRequest, policies, entities)
+ .success.orElseThrow().getDecision();
+
+ assertEquals(expected.getValue(), viaReauthorize, "department " + department);
+ assertEquals(viaOriginal, viaReauthorize,
+ "reauthorizing must agree with authorizing the original policies, department " + department);
+ }
+ });
+ }
+
+ /**
+ * Reauthorizing a different request is rejected rather than answered. Authorizing the residuals directly
+ * cannot detect this and would grant access the original policies denied.
+ */
+ @Test
+ public void tpeReauthorizeRejectsAMismatchedRequest() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var bob = new EntityUID(EntityTypeName.parse("User").get(), "bob");
+ var policies = tpePolicies("permit(principal == User::\"alice\", action == Action::\"view\", resource);",
+ "alice_only");
+ var partialRequest = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .schema(TPE_SCHEMA).build();
+
+ var success = auth.isAuthorizedTypeAwarePartial(partialRequest, policies, PartialEntities.empty())
+ .getSuccess().orElseThrow();
+ var response = success.reauthorize(new AuthorizationRequest(bob, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of()));
+
+ assertEquals(SuccessOrFailure.Failure, response.type);
+ assertTrue(response.success.isEmpty());
+ var message = response.errors.orElseThrow().get(0).message;
+ assertTrue(message.contains("alice") && message.contains("bob"),
+ "expected the error to name both principals but was: " + message);
+ });
+ }
+
+ /**
+ * A residual whose condition errors is reported by id, and carries Cedar's own rendering rather than being dropped
+ * or rewritten. Its {@code effect()} throws because Cedar cannot parse the marker back.
+ */
+ @Test
+ public void tpeErroredResidualIsReportedAndSourceOnly() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var alice = new Entity(TPE_ALICE, Map.of(), Set.of(), Map.of());
+ var success = auth.isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_OVERFLOW_POLICY, "boom"),
+ new PartialEntities(new Entities(Set.of(alice)), TPE_SCHEMA))
+ .getSuccess().orElseThrow();
+
+ assertEquals(Set.of("boom"), success.getErroredResidualIds());
+ assertEquals(Set.of("boom"), TestUtil.ids(success.getErroredResiduals()));
+ // Cedar still reaches a decision: an erroring permit grants nothing, so Deny. And it classifies the
+ // residual as trivial — it reached a concrete outcome, that outcome just happens to be an error.
+ assertEquals(Decision.Deny, success.getDecision());
+ assertEquals(Set.of("boom"), TestUtil.ids(success.getTrivialResiduals()));
+ assertTrue(success.getNontrivialResiduals().isEmpty());
+ var errored = success.getErroredResiduals().iterator().next();
+ assertTrue(errored.getSource().contains("error()"),
+ "the residual should carry Cedar's rendering but was: " + errored.getSource());
+ assertThrows(InternalException.class, errored::effect,
+ "Cedar cannot parse the error marker back, so effect() must fail rather than mislead");
+ });
+ }
+
+ /**
+ * The counterpart to the errored-residual case: a residual with no error node is a fully usable {@link Policy}.
+ * {@link Policy#toJson()} re-parses the source natively, so this also shows the residual Cedar rendered is valid
+ * Cedar — and the EST round-trips back to the same source, which is exactly what an errored residual cannot do.
+ */
+ @Test
+ public void tpeCleanResidualConvertsToJsonAndBack() throws Exception {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(EntityTypeName.parse("User").get()).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var success = auth.isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_ENG_POLICY, "p0"),
+ PartialEntities.empty()).getSuccess().orElseThrow();
+
+ assertTrue(success.getErroredResidualIds().isEmpty(), "this residual must not contain an error node");
+ var residual = success.getResiduals().iterator().next();
+
+ var json = assertDoesNotThrow(residual::toJson);
+ assertTrue(json.contains("\"effect\"") && json.contains("\"permit\""),
+ "expected an EST policy but got: " + json);
+ // Round trip: the EST parses back to a policy with the same source Cedar rendered.
+ assertEquals(residual.getSource(), Policy.fromJson("p0", json).getSource());
+ // The other re-parsing accessors work too, unlike on an errored residual.
+ assertDoesNotThrow(residual::effect);
+ assertDoesNotThrow(residual::getAnnotations);
+ });
+ }
+
+ /**
+ * A residual containing a known-error subexpression cannot be serialized, but reauthorization never serializes it —
+ * it evaluates the residual natively, so the erroring policy is reported in the diagnostics and the decision still
+ * comes back.
+ */
+ @Test
+ public void tpeReauthorizeWorksWhenAResidualErrors() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var policies = tpePolicies(TPE_OVERFLOW_POLICY, "boom");
+ var partialRequest = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var alice = new Entity(TPE_ALICE, Map.of(), Set.of(), Map.of());
+ var entities = new Entities(Set.of(alice));
+
+ // The residual really does error, otherwise this test proves nothing.
+ var success = auth.isAuthorizedTypeAwarePartial(partialRequest, policies,
+ new PartialEntities(entities, TPE_SCHEMA)).getSuccess().orElseThrow();
+ assertEquals(Set.of("boom"), success.getErroredResidualIds());
+
+ var response = success.reauthorize(new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap()),
+ entities);
+
+ // The permit errors, so it grants nothing and the decision defaults to Deny — but a decision is reached,
+ // which is the point: serializing this residual could not have produced one.
+ var reauthorized = response.success.orElseThrow();
+ assertEquals(Decision.Deny, reauthorized.getDecision());
+ assertEquals(1, reauthorized.getErrors().size(),
+ "the erroring policy must be reported in the diagnostics, not silently dropped");
+ });
+ }
+
+ /**
+ * {@code getErroredResiduals()} must filter, not return everything. One residual errors, one does not.
+ */
+ @Test
+ public void tpeErroredResidualsIsAFilterNotEverything() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(EntityTypeName.parse("User").get()).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var policies = new PolicySet(Set.of(
+ new Policy(TPE_OVERFLOW_POLICY, "boom"),
+ new Policy(TPE_ENG_POLICY, "clean")));
+
+ var success = auth.isAuthorizedTypeAwarePartial(q, policies, PartialEntities.empty())
+ .getSuccess().orElseThrow();
+
+ assertEquals(Set.of("boom", "clean"), TestUtil.ids(success.getResiduals()));
+ assertEquals(Set.of("boom"), success.getErroredResidualIds());
+ assertEquals(Set.of("boom"), TestUtil.ids(success.getErroredResiduals()));
+ // The errored set cuts across the trivial/non-trivial split rather than being a third partition.
+ assertEquals(Set.of("boom"), TestUtil.ids(success.getTrivialResiduals()));
+ assertEquals(Set.of("clean"), TestUtil.ids(success.getNontrivialResiduals()));
+ // The clean one is a usable Policy; the errored one is source-only.
+ var clean = success.getResiduals().stream().filter(p -> p.getID().equals("clean")).findFirst()
+ .orElseThrow();
+ assertDoesNotThrow(clean::effect);
+ });
+ }
+
+ @Test
+ public void tpeForbidReachesADeny() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var policies = new PolicySet(Set.of(
+ new Policy("permit(principal, action, resource);", "p0"),
+ new Policy("forbid(principal, action, resource);", "f0")));
+
+ var success = auth.isAuthorizedTypeAwarePartial(q, policies, PartialEntities.empty())
+ .getSuccess().orElseThrow();
+ assertEquals(Decision.Deny, success.getDecision());
+ });
+ }
+
+ /**
+ * Type-aware partial evaluation validates the policy set against the schema, which plain authorization does not, so
+ * a policy that parses but does not type check comes back as a failure response rather than as residuals.
+ */
+ @Test
+ public void tpePolicyFailingSchemaValidationIsAFailure() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var response = auth.isAuthorizedTypeAwarePartial(q,
+ tpePolicies("permit(principal, action, resource) when { principal.nosuchattr };", "p0"),
+ PartialEntities.empty());
+
+ assertEquals(TypeAwarePartialAuthorizationResponse.SuccessOrFailure.Failure, response.getType());
+ assertTrue(response.getSuccess().isEmpty());
+ assertEquals(List.of("policy failed to validate against the schema"),
+ response.getErrors().orElseThrow().stream().map(e -> e.message)
+ .collect(Collectors.toList()));
+ });
+ }
+
+ /**
+ * A template-linked policy residualizes as a static policy keyed by the link id, so a caller inspecting residuals
+ * sees the link, not the template.
+ */
+ @Test
+ public void tpeTemplateLinkResidualizesUnderItsLinkId() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var policies = new PolicySet(Set.of(),
+ Set.of(new Policy("permit(principal == ?principal, action == Action::\"view\", resource)\n"
+ + "when { principal has department && principal.department == \"eng\" };", "tmpl")),
+ List.of(new TemplateLink("tmpl", "linked",
+ List.of(new LinkValue("?principal", TPE_ALICE)))));
+
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(EntityTypeName.parse("User").get()).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var success = auth.isAuthorizedTypeAwarePartial(q, policies, PartialEntities.empty())
+ .getSuccess().orElseThrow();
+
+ assertEquals(Set.of("linked"), TestUtil.ids(success.getResiduals()));
+ assertTrue(success.getErroredResidualIds().isEmpty());
+ });
+ }
+ /**
+ * The three-state distinction the whole partial-entity model rests on, asserted against Cedar's own behaviour: a
+ * field omitted is unknown and leaves a residual, while the same field present-but-empty is known
+ * to be empty and folds the condition to a constant. Getting these confused would silently turn an undecided
+ * request into a Deny.
+ */
+ @Test
+ public void tpeOmittedFieldIsUnknownWhileEmptyFieldIsKnown() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var policies = tpePolicies(
+ "permit(principal, action == Action::\"view\", resource) when { principal has department };", "p0");
+
+ // attrs omitted -> unknown
+ var unknownAttrs = auth.isAuthorizedTypeAwarePartial(q, policies, new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()),
+ Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA)).getSuccess().orElseThrow();
+ assertNull(unknownAttrs.getDecision());
+ assertEquals(Set.of("p0"), TestUtil.ids(unknownAttrs.getNontrivialResiduals()));
+ assertEquals("permit(principal, action, resource) when { User::\"alice\" has department };",
+ unknownAttrs.getResiduals().iterator().next().getSource());
+
+ // attrs present but empty -> known absent, so `has` folds to false and Cedar decides
+ var knownEmptyAttrs = auth.isAuthorizedTypeAwarePartial(q, policies, new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.of(Map.of()), Optional.of(Set.of()),
+ Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA)).getSuccess().orElseThrow();
+ assertEquals(Decision.Deny, knownEmptyAttrs.getDecision());
+ assertTrue(knownEmptyAttrs.getNontrivialResiduals().isEmpty());
+ assertEquals("permit(principal, action, resource) when { false };",
+ knownEmptyAttrs.getResiduals().iterator().next().getSource());
+ });
+ }
+
+ /** Tags are a separate namespace from attributes and follow the same omitted-versus-empty rule. */
+ @Test
+ public void tpeOmittedTagsAreUnknownWhileEmptyTagsAreKnown() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var policies = tpePolicies("permit(principal, action == Action::\"view\", resource)\n"
+ + "when { principal.hasTag(\"clearance\") && principal.getTag(\"clearance\") == \"high\" };", "p0");
+
+ var unknownTags = auth.isAuthorizedTypeAwarePartial(q, policies, new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.of(Map.of()), Optional.of(Set.of()),
+ Optional.empty(), TPE_SCHEMA)),
+ TPE_SCHEMA)).getSuccess().orElseThrow();
+ assertNull(unknownTags.getDecision());
+ assertEquals(Set.of("p0"), TestUtil.ids(unknownTags.getNontrivialResiduals()));
+
+ var knownEmptyTags = auth.isAuthorizedTypeAwarePartial(q, policies, new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.of(Map.of()), Optional.of(Set.of()),
+ Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA)).getSuccess().orElseThrow();
+ assertEquals(Decision.Deny, knownEmptyTags.getDecision());
+ assertEquals("permit(principal, action, resource) when { false };",
+ knownEmptyTags.getResiduals().iterator().next().getSource());
+ });
+ }
+
+ /**
+ * An entity absent from the store is treated as one whose every field is unknown — not as an error. Worth pinning
+ * before anyone "hardens" the serializer into rejecting entities the request refers to but does not supply.
+ */
+ @Test
+ public void tpeAbsentEntityBehavesLikeAFullyUnknownOne() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var policies = tpePolicies(
+ "permit(principal, action == Action::\"view\", resource) when { principal has department };", "p0");
+
+ var absent = auth.isAuthorizedTypeAwarePartial(q, policies, PartialEntities.empty())
+ .getSuccess().orElseThrow();
+ var unknown = auth.isAuthorizedTypeAwarePartial(q, policies, new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()),
+ Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA)).getSuccess().orElseThrow();
+
+ assertNull(absent.getDecision());
+ assertEquals(unknown.getResiduals().iterator().next().getSource(),
+ absent.getResiduals().iterator().next().getSource());
+ });
+ }
+
+ /**
+ * An error nested inside a still-live condition is reported as errored and non-trivial. This is the case
+ * Cedar's own {@code error_permits()} bucket misses — it lists only policies that reduced entirely to an error —
+ * and the reason the errored ids are derived from the PST instead.
+ */
+ @Test
+ public void tpeNestedErrorIsBothNontrivialAndErrored() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ // The left disjunct stays unknown, so the `||` cannot short-circuit and the error survives nested.
+ var policies = tpePolicies("permit(principal, action == Action::\"view\", resource)\n"
+ + "when { (principal has department) || (9223372036854775807 + 1 > 0) };", "p0");
+ var entities = new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()),
+ Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA);
+
+ var success = auth.isAuthorizedTypeAwarePartial(q, policies, entities).getSuccess().orElseThrow();
+
+ assertNull(success.getDecision());
+ assertEquals(Set.of("p0"), TestUtil.ids(success.getNontrivialResiduals()));
+ assertEquals(Set.of("p0"), success.getErroredResidualIds(),
+ "a nested error must still be reported, which error_permits() alone would not do");
+ assertEquals("permit(principal, action, resource) when "
+ + "{ (User::\"alice\" has department) || error() };",
+ success.getResiduals().iterator().next().getSource());
+ });
+ }
+
+ /**
+ * Every dimension Cedar checks when completing a partial request, one deliberate mismatch each. This is the safety
+ * property {@code reauthorize} exists for: a residual has the original request's values folded in, so completing a
+ * different request against it could grant access the original policies denied.
+ *
+ * Expected messages are Cedar's own, taken from running the same completions through Cedar Rust directly. Note
+ * entities are checked before the request, so each completion here varies exactly one thing — vary two and you get
+ * whichever check runs first.
+ */
+ @Test
+ public void tpeReauthorizeRejectsEveryKindOfMismatch() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var bob = new EntityUID(EntityTypeName.parse("User").get(), "bob");
+ var window = new EntityUID(EntityTypeName.parse("Photo").get(), "window");
+ var edit = new EntityUID(EntityTypeName.parse("Action").get(), "edit");
+ var group = new EntityUID(EntityTypeName.parse("Group").get(), "eng");
+
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var policies = tpePolicies(TPE_ENG_POLICY, "p0");
+ // alice is known to exist with known parents and tags; only her attributes are unknown.
+ var partialEntities = new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()),
+ Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA);
+ var success = auth.isAuthorizedTypeAwarePartial(q, policies, partialEntities)
+ .getSuccess().orElseThrow();
+
+ var aliceEng = new Entity(TPE_ALICE, Map.of("department", new PrimString("eng")), Set.of(), Map.of());
+
+ // A matching completion is accepted, so the rejections below are not the check refusing everything.
+ assertEquals(Decision.Allow, success.reauthorize(
+ new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of(aliceEng))).success.orElseThrow().getDecision());
+
+ record Mismatch(String label, AuthorizationRequest request, Entities entities, String expected) { }
+ var mismatches = List.of(
+ new Mismatch("principal id",
+ new AuthorizationRequest(bob, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of(aliceEng,
+ new Entity(bob, Map.of("department", new PrimString("eng")), Set.of(), Map.of()))),
+ "partial request principal id `alice` does not match concrete request principal id `bob`"),
+ new Mismatch("resource id",
+ new AuthorizationRequest(TPE_ALICE, TPE_VIEW, window, new HashMap()),
+ new Entities(Set.of(aliceEng)),
+ "partial request resource id `door` does not match concrete request resource id `window`"),
+ new Mismatch("action",
+ new AuthorizationRequest(TPE_ALICE, edit, TPE_DOOR, new HashMap()),
+ new Entities(Set.of(aliceEng)),
+ "does not match concrete request action"),
+ new Mismatch("context",
+ new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR,
+ new HashMap<>(Map.of("authenticated", new PrimBool(true)))),
+ new Entities(Set.of(aliceEng)),
+ "the partial and concrete request contexts do not match"),
+ new Mismatch("entity missing from the concrete store",
+ new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of()),
+ "entity `User::\"alice\"` is present in the partial entities but missing from the "
+ + "concrete entities"),
+ new Mismatch("parents",
+ new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of(new Entity(TPE_ALICE,
+ Map.of("department", new PrimString("eng")), Set.of(group), Map.of()))),
+ "concrete entity `User::\"alice\"` has ancestors that do not match the partial entity"),
+ new Mismatch("tags",
+ new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of(new Entity(TPE_ALICE, Map.of("department", new PrimString("eng")),
+ Set.of(), Map.of("clearance", new PrimString("high"))))),
+ "concrete entity `User::\"alice\"` has tag values that do not match the partial entity"));
+
+ for (var m : mismatches) {
+ var response = success.reauthorize(m.request(), m.entities());
+ assertEquals(SuccessOrFailure.Failure, response.type,
+ "completing a different " + m.label() + " must be rejected, not answered");
+ assertTrue(response.success.isEmpty(), m.label());
+ var message = response.errors.orElseThrow().get(0).message;
+ assertTrue(message.contains(m.expected()),
+ m.label() + ": expected a message containing\n " + m.expected() + "\nbut got\n " + message);
+ }
+ });
+ }
+
+ /**
+ * A known attribute value must be unchanged by the completion. The mismatch test above leaves attrs unknown, so
+ * this is the dimension it cannot reach — Cedar compares the attribute map as a whole and rejects any difference.
+ */
+ @Test
+ public void tpeReauthorizeRejectsAChangedAttributeValue() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ // department is KNOWN here, unlike the other reauthorize fixtures.
+ var partialEntities = new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.of(Map.of("department", new PrimString("eng"))),
+ Optional.of(Set.of()), Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA);
+ var success = auth.isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_ENG_POLICY, "p0"), partialEntities)
+ .getSuccess().orElseThrow();
+ var request = new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap());
+
+ var same = success.reauthorize(request, new Entities(Set.of(
+ new Entity(TPE_ALICE, Map.of("department", new PrimString("eng")), Set.of(), Map.of()))));
+ assertEquals(Decision.Allow, same.success.orElseThrow().getDecision());
+
+ var changed = success.reauthorize(request, new Entities(Set.of(
+ new Entity(TPE_ALICE, Map.of("department", new PrimString("sales")), Set.of(), Map.of()))));
+ assertEquals(SuccessOrFailure.Failure, changed.type);
+ assertTrue(changed.errors.orElseThrow().get(0).message.contains(
+ "concrete entity `User::\"alice\"` has attribute values that do not match the partial entity"),
+ "unexpected message: " + changed.errors.orElseThrow().get(0).message);
+ });
+ }
+
+ /**
+ * An unknown entity id still pins the entity type. Easy to assume otherwise: the action below
+ * accepts both principal types, so completing with an {@code Admin} is a legal request in its own right — Cedar
+ * rejects it because the residuals were computed for a {@code User}.
+ */
+ @Test
+ public void tpeReauthorizeRejectsAChangedPrincipalTypeWhenTheIdWasUnknown() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ // Its own schema: the shared fixture declares only one principal type, so a type mismatch there would be
+ // rejected as an invalid request rather than as an inconsistent completion.
+ var schema = Schema.parse(Schema.JsonOrCedar.Cedar,
+ "entity User { department?: String } tags String;\n"
+ + "entity Admin { department?: String } tags String;\n"
+ + "entity Photo;\n"
+ + "action view appliesTo { principal: [User, Admin], resource: [Photo], context: {} };");
+ var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice");
+ var root = new EntityUID(EntityTypeName.parse("Admin").get(), "root");
+
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(EntityTypeName.parse("User").get())
+ .action(TPE_VIEW).resource(TPE_DOOR).emptyContext().schema(schema).build();
+ var success = auth.isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_ENG_POLICY, "p0"),
+ PartialEntities.empty()).getSuccess().orElseThrow();
+
+ var asUser = success.reauthorize(
+ new AuthorizationRequest(alice, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of(
+ new Entity(alice, Map.of("department", new PrimString("eng")), Set.of(), Map.of()))));
+ assertEquals(Decision.Allow, asUser.success.orElseThrow().getDecision());
+
+ var asAdmin = success.reauthorize(
+ new AuthorizationRequest(root, TPE_VIEW, TPE_DOOR, new HashMap()),
+ new Entities(Set.of(
+ new Entity(root, Map.of("department", new PrimString("eng")), Set.of(), Map.of()))));
+ assertEquals(SuccessOrFailure.Failure, asAdmin.type);
+ assertTrue(asAdmin.errors.orElseThrow().get(0).message.contains(
+ "partial request principal type `User` does not match concrete request principal type `Admin`"),
+ "unexpected message: " + asAdmin.errors.orElseThrow().get(0).message);
+ });
+ }
+
+ /**
+ * Cedar checks only that every entity it already knew about is unchanged — an entity added to the concrete
+ * store is never examined, because the consistency check iterates the partial store and looks each entity up in the
+ * concrete one. Pinned because it is the half of the check that is easy to assume works the other way.
+ */
+ @Test
+ public void tpeReauthorizeAcceptsAnEntityAbsentFromThePartialStore() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var success = auth.isAuthorizedTypeAwarePartial(q, tpePolicies(TPE_ENG_POLICY, "p0"),
+ new PartialEntities(
+ Set.of(new PartialEntity(TPE_ALICE, Optional.empty(), Optional.of(Set.of()),
+ Optional.of(Map.of()), TPE_SCHEMA)),
+ TPE_SCHEMA)).getSuccess().orElseThrow();
+
+ var alice = new Entity(TPE_ALICE, Map.of("department", new PrimString("eng")), Set.of(), Map.of());
+ var stranger = new Entity(new EntityUID(EntityTypeName.parse("User").get(), "carol"),
+ Map.of("department", new PrimString("sales")), Set.of(), Map.of());
+ var request = new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap());
+
+ assertEquals(Decision.Allow,
+ success.reauthorize(request, new Entities(Set.of(alice))).success.orElseThrow().getDecision());
+ assertEquals(Decision.Allow,
+ success.reauthorize(request, new Entities(Set.of(alice, stranger)))
+ .success.orElseThrow().getDecision(),
+ "an entity the partial store never mentioned is not checked");
+ });
+ }
+
+ /**
+ * An erroring forbid is skipped, so a trivially-true permit still wins: the decision is Allow while the
+ * diagnostics carry the evaluation error. A non-zero error count alongside an Allow is an easy shape to assert
+ * wrongly, so it is pinned here.
+ */
+ @Test
+ public void tpeReauthorizeAllowsDespiteAnErroringForbid() {
+ var auth = new BasicAuthorizationEngine();
+ TestUtil.assumeExperimentalFeature(() -> {
+ var q = TypeAwarePartialAuthorizationRequest.builder()
+ .principal(TPE_ALICE).action(TPE_VIEW).resource(TPE_DOOR)
+ .emptyContext().schema(TPE_SCHEMA).build();
+ var policies = new PolicySet(Set.of(
+ new Policy("permit(principal, action == Action::\"view\", resource);", "allowAll"),
+ new Policy("forbid(principal, action == Action::\"view\", resource)\n"
+ + "when { 9223372036854775807 + 1 > 0 };", "erroringForbid")));
+ var alice = new Entity(TPE_ALICE, Map.of(), Set.of(), Map.of());
+ var entities = new Entities(Set.of(alice));
+
+ var success = auth.isAuthorizedTypeAwarePartial(q, policies,
+ new PartialEntities(entities, TPE_SCHEMA)).getSuccess().orElseThrow();
+ assertEquals(Set.of("erroringForbid"), success.getErroredResidualIds());
+
+ var reauthorized = success.reauthorize(
+ new AuthorizationRequest(TPE_ALICE, TPE_VIEW, TPE_DOOR, new HashMap()),
+ entities).success.orElseThrow();
+
+ assertEquals(Decision.Allow, reauthorized.getDecision(),
+ "an erroring forbid is skipped, so the permit still decides");
+ assertEquals(1, reauthorized.getErrors().size(),
+ "and the error is still reported, alongside the Allow");
+ });
+ }
+
+}
diff --git a/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java
index 32d1cf69..db5aa045 100644
--- a/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java
+++ b/CedarJava/src/test/java/com/cedarpolicy/TypeAwarePartialAuthorizationResponseTests.java
@@ -19,10 +19,10 @@
import com.cedarpolicy.model.AuthorizationSuccessResponse.Decision;
import com.cedarpolicy.model.TypeAwarePartialAuthorizationResponse;
import com.cedarpolicy.model.TypeAwarePartialAuthorizationResponse.SuccessOrFailure;
+import com.cedarpolicy.model.exception.AuthException;
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;
@@ -34,10 +34,10 @@
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -54,9 +54,9 @@ public class TypeAwarePartialAuthorizationResponseTests {
*/
@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\": [] }";
+ final String trivialPermit = "permit(principal, action, resource) when { true };";
+ String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": \"allow\", \"residuals\": {\"trivial\": \""
+ + trivialPermit + "\" }, \"nontrivialResiduals\": [] }, \"warnings\": [] }";
try {
TypeAwarePartialAuthorizationResponse r =
objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src);
@@ -73,8 +73,8 @@ public void testTPEConcreteResponse() {
@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\": [] }";
+ final String policy = "permit(principal, action, resource) when { principal.department == \\\"eng\\\" };";
+ final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {\"p0\": \"" + policy + "\" } }, \"warnings\": [] }";
try {
TypeAwarePartialAuthorizationResponse r =
objectReader().forType(TypeAwarePartialAuthorizationResponse.class).readValue(src);
@@ -84,9 +84,10 @@ public void testTPEResidualResponse() {
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) {
+ // The residual carries the Cedar source the FFI sent, verbatim.
+ assertEquals("permit(principal, action, resource) when { principal.department == \"eng\" };",
+ residual.getSource());
+ } catch (JsonProcessingException e) {
fail(e);
}
}
@@ -128,25 +129,10 @@ public void testTPEResponseToleratesUnknownFields() {
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.
+ * against. `getResiduals` is every residual; `getNontrivialResiduals` and `getTrivialResiduals`
+ * partition it 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
@@ -156,16 +142,16 @@ public void testResidualPolicySetRoundTrip() throws InternalException, JsonProce
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\"] },"
+ final String reducedToTrue = "permit(principal, action, resource) when { true };";
+ final String stillConditional = "@owner(\\\"team-a\\\") @audit(\\\"\\\") "
+ + "forbid(principal, action, resource) when { principal.department == \\\"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()));
@@ -177,8 +163,6 @@ public void testResidualAccessorsSplitTrivialFromNontrivial() throws InternalExc
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());
@@ -192,21 +176,21 @@ public void testResidualAccessorsSplitTrivialFromNontrivial() throws InternalExc
@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\": [] }";
+ final String permitAll = "permit(principal, action, resource);";
+ 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());
+ assertEquals(Set.of("myPermit"), ids(success.getResiduals()));
}
@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\": [] }";
+ final String permitAll = "permit(principal, action, resource);";
+ final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": {\"myPermit\": \""
+ + permitAll + "\" }, \"nontrivialResiduals\": [\"myPermit\"] }, \"warnings\": [] }";
var success = objectReader().forType(TypeAwarePartialAuthorizationResponse.class)
.readValue(src)
.getSuccess()
@@ -219,6 +203,21 @@ public void testTPESuccessResponseReadsEveryWireField() throws InternalException
assertTrue(success.getTrivialResiduals().isEmpty());
}
+ /**
+ * A response deserialized directly, rather than obtained from an engine, has nothing to reauthorize against — so
+ * the attempt fails with an explanation rather than a NullPointerException.
+ */
+ @Test
+ public void testDeserializedResponseCannotBeReauthorized() throws JsonProcessingException {
+ final String src = "{ \"type\": \"residuals\", \"response\": { \"decision\": null, \"residuals\": "
+ + "{\"p0\": \"permit(principal, action, resource);\" } }, \"warnings\": [] }";
+ var success = objectReader().forType(TypeAwarePartialAuthorizationResponse.class)
+ .readValue(src).getSuccess().orElseThrow();
+
+ var e = assertThrows(AuthException.class, () -> success.reauthorize(null, null));
+ assertTrue(e.getMessage().contains("carries nothing to reauthorize"), "unexpected message: " + e.getMessage());
+ }
+
/** 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/CedarJava/src/test/resources/tpe_schema.cedarschema b/CedarJava/src/test/resources/tpe_schema.cedarschema
index fb4b5ffd..ca8a52f4 100644
--- a/CedarJava/src/test/resources/tpe_schema.cedarschema
+++ b/CedarJava/src/test/resources/tpe_schema.cedarschema
@@ -13,3 +13,11 @@ action view appliesTo {
authenticated?: Bool,
}
};
+
+action edit appliesTo {
+ principal: [User],
+ resource: [Photo],
+ context: {
+ authenticated?: Bool,
+ }
+};
diff --git a/CedarJava/src/test/resources/tpe_schema.json b/CedarJava/src/test/resources/tpe_schema.json
index a856c415..02059e9a 100644
--- a/CedarJava/src/test/resources/tpe_schema.json
+++ b/CedarJava/src/test/resources/tpe_schema.json
@@ -3,26 +3,59 @@
"entityTypes": {
"Group": {},
"User": {
- "memberOfTypes": ["Group"],
+ "memberOfTypes": [
+ "Group"
+ ],
"shape": {
"type": "Record",
"attributes": {
- "department": { "type": "String", "required": false }
+ "department": {
+ "type": "String",
+ "required": false
+ }
}
},
- "tags": { "type": "String" }
+ "tags": {
+ "type": "String"
+ }
},
"Photo": {}
},
"actions": {
"view": {
"appliesTo": {
- "principalTypes": ["User"],
- "resourceTypes": ["Photo"],
+ "principalTypes": [
+ "User"
+ ],
+ "resourceTypes": [
+ "Photo"
+ ],
+ "context": {
+ "type": "Record",
+ "attributes": {
+ "authenticated": {
+ "type": "Boolean",
+ "required": false
+ }
+ }
+ }
+ }
+ },
+ "edit": {
+ "appliesTo": {
+ "principalTypes": [
+ "User"
+ ],
+ "resourceTypes": [
+ "Photo"
+ ],
"context": {
"type": "Record",
"attributes": {
- "authenticated": { "type": "Boolean", "required": false }
+ "authenticated": {
+ "type": "Boolean",
+ "required": false
+ }
}
}
}
diff --git a/CedarJavaFFI/src/interface.rs b/CedarJavaFFI/src/interface.rs
index b1a137bb..3c511839 100644
--- a/CedarJavaFFI/src/interface.rs
+++ b/CedarJavaFFI/src/interface.rs
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+use crate::tpe::{json_type_aware_partial_is_authorized, json_type_aware_partial_reauthorize};
use cedar_policy::entities_errors::EntitiesError;
#[cfg(feature = "partial-eval")]
use cedar_policy::ffi::is_authorized_partial_json_str;
@@ -62,6 +63,8 @@ const V0_VALIDATE_OP: &str = "ValidateOperation";
const V0_VALIDATE_LEVEL_OP: &str = "ValidateWithLevelOperation";
const V0_VALIDATE_ENTITIES: &str = "ValidateEntities";
const V0_STATEFUL_AUTH_OP: &str = "StatefulAuthorizationOperation";
+const V0_AUTH_TYPE_AWARE_PARTIAL_OP: &str = "AuthorizationTypeAwarePartialOperation";
+const V0_TYPE_AWARE_PARTIAL_REAUTHORIZE_OP: &str = "TypeAwarePartialReauthorizeOperation";
thread_local! {
static AUTHORIZER: Authorizer = Authorizer::new();
@@ -327,6 +330,8 @@ pub(crate) fn call_cedar(call: &str, input: &str) -> String {
V0_AUTH_OP => is_authorized_json_str(input),
#[cfg(feature = "partial-eval")]
V0_AUTH_PARTIAL_OP => is_authorized_partial_json_str(input),
+ V0_AUTH_TYPE_AWARE_PARTIAL_OP => json_type_aware_partial_is_authorized(input),
+ V0_TYPE_AWARE_PARTIAL_REAUTHORIZE_OP => json_type_aware_partial_reauthorize(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),
@@ -360,7 +365,7 @@ fn default_true() -> bool {
true
}
-fn make_auth_failure(msg: String) -> AuthorizationAnswer {
+pub(crate) fn make_auth_failure(msg: String) -> AuthorizationAnswer {
AuthorizationAnswer::Failure {
errors: vec![DetailedError {
message: msg,
diff --git a/CedarJavaFFI/src/tests.rs b/CedarJavaFFI/src/tests.rs
index 8d1c9648..91da11cb 100644
--- a/CedarJavaFFI/src/tests.rs
+++ b/CedarJavaFFI/src/tests.rs
@@ -1046,7 +1046,7 @@ pub(crate) mod tpe_schema_fixtures {
pub(crate) const CEDAR_SCHEMA_SRC: &str = r#"
entity Group;
- entity User in [Group] = { "isAdmin": Bool };
+ entity User in [Group] = { "isAdmin": Bool, "num"?: Long };
entity Photo;
action view appliesTo {
principal: [User],
@@ -1072,6 +1072,34 @@ pub(crate) mod tpe_schema_fixtures {
}
}
+/// With the `tpe` feature off, both type-aware operations must still be dispatched and must answer with
+/// something Java can deserialize, carrying the token that becomes
+/// `MissingExperimentalFeatureException`. Falling through to `call_cedar`'s unsupported-operation arm
+/// would produce an `errors` array of bare strings that the TPE response types cannot bind.
+#[cfg(not(feature = "tpe"))]
+mod tpe_disabled_tests {
+ use super::*;
+
+ #[test]
+ fn both_operations_report_the_disabled_feature_token() {
+ for op in [
+ "AuthorizationTypeAwarePartialOperation",
+ "TypeAwarePartialReauthorizeOperation",
+ ] {
+ let answer: serde_json::Value = serde_json::from_str(&call_cedar(op, "{}"))
+ .unwrap_or_else(|e| panic!("{op} must answer with valid JSON: {e}"));
+ assert_eq!(answer["type"], "failure", "{op}");
+ let message = answer["errors"][0]["message"].as_str().unwrap();
+ assert!(
+ message.contains("TypeAwarePartialEvaluationNotEnabled"),
+ "{op} must carry the token but said: {message}"
+ );
+ // The error object must have the DetailedError shape, not a bare string.
+ assert!(answer["errors"][0]["related"].is_array(), "{op}");
+ }
+ }
+}
+
#[cfg(feature = "tpe")]
mod tpe_validation_tests {
use super::*;
@@ -1261,6 +1289,485 @@ mod tpe_validation_tests {
);
}
+ /// The authorization handler, end to end through the operation dispatch.
+ mod authorization {
+ use super::*;
+ use crate::call_cedar;
+
+ const OP: &str = "AuthorizationTypeAwarePartialOperation";
+
+ fn input(principal: serde_json::Value, policies: serde_json::Value) -> serde_json::Value {
+ json!({
+ "principal": principal,
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "context": { "authenticated": true },
+ "schema": CEDAR_SCHEMA_SRC,
+ "policies": policies,
+ "entities": []
+ })
+ }
+
+ fn call_with(input: &serde_json::Value) -> serde_json::Value {
+ serde_json::from_str(&call_cedar(OP, &input.to_string())).unwrap()
+ }
+
+ fn call(principal: serde_json::Value, static_policies: &str) -> serde_json::Value {
+ call_with(&input(
+ principal,
+ json!({ "staticPolicies": static_policies }),
+ ))
+ }
+
+ fn failure_messages(answer: &serde_json::Value) -> Vec {
+ assert_eq!(answer["type"], "failure");
+ answer["errors"]
+ .as_array()
+ .expect("a failure answer must carry an errors array")
+ .iter()
+ .map(|e| {
+ e["message"]
+ .as_str()
+ .expect("every error must carry a message")
+ .to_string()
+ })
+ .collect()
+ }
+
+ #[test]
+ fn concrete_request_reaches_a_decision() {
+ let answer = call(
+ json!({ "type": "User", "id": "alice" }),
+ r#"permit(principal, action, resource);"#,
+ );
+ assert_eq!(answer["type"], "residuals");
+ assert_eq!(answer["response"]["decision"], "allow");
+ // A concrete Allow still carries the permit that decided it, and that permit is trivial.
+ assert_eq!(
+ answer["response"]["residuals"].as_object().unwrap().len(),
+ 1
+ );
+ assert!(answer["response"]["nontrivialResiduals"]
+ .as_array()
+ .unwrap()
+ .is_empty());
+ }
+
+ #[test]
+ fn unknown_principal_id_yields_a_residual() {
+ let answer = call(
+ json!({ "type": "User" }),
+ r#"permit(principal, action, resource) when { principal.isAdmin };"#,
+ );
+ assert_eq!(answer["type"], "residuals");
+ assert!(answer["response"]["decision"].is_null());
+ let residuals = answer["response"]["residuals"].as_object().unwrap();
+ assert_eq!(residuals.len(), 1);
+ let id = residuals.keys().next().unwrap();
+ assert_eq!(
+ answer["response"]["nontrivialResiduals"]
+ .as_array()
+ .unwrap(),
+ &vec![serde_json::Value::String(id.clone())],
+ "a residual with a condition left must be non-trivial"
+ );
+ }
+
+ #[test]
+ fn trivial_and_nontrivial_residuals_are_distinguished() {
+ let answer = call(
+ json!({ "type": "User" }),
+ r#"permit(principal, action, resource);
+ permit(principal, action, resource) when { principal.isAdmin };"#,
+ );
+ let residuals = answer["response"]["residuals"].as_object().unwrap();
+ let nontrivial = answer["response"]["nontrivialResiduals"]
+ .as_array()
+ .unwrap();
+ assert_eq!(residuals.len(), 2, "every residual is reported");
+ assert_eq!(nontrivial.len(), 1, "only one of them is non-trivial");
+
+ // Assert which residual landed on which side, so an inverted split fails here. The
+ // non-trivial one is the policy whose condition survived, and it is identified by the
+ // attribute it still reads rather than by id, because ids are generated positionally.
+ let nontrivial_id = nontrivial[0]
+ .as_str()
+ .expect("a residual id must be a string");
+ let nontrivial_body = residuals
+ .get(nontrivial_id)
+ .expect("nontrivialResiduals must be a subset of residuals' keys")
+ .to_string();
+ assert!(
+ nontrivial_body.contains("isAdmin"),
+ "the non-trivial residual should still read isAdmin but was: {nontrivial_body}"
+ );
+ let trivial_body = residuals
+ .iter()
+ .find(|(id, _)| *id != nontrivial_id)
+ .map(|(_, body)| body.to_string())
+ .expect("the other residual should be the trivial one");
+ assert!(
+ !trivial_body.contains("isAdmin"),
+ "the trivial residual should carry no condition but was: {trivial_body}"
+ );
+ }
+
+ #[test]
+ fn undeclared_principal_type_is_a_failure_not_a_panic() {
+ let answer = call(
+ json!({ "type": "Admin" }),
+ r#"permit(principal, action, resource);"#,
+ );
+ let messages = failure_messages(&answer);
+ assert_eq!(messages.len(), 1);
+ assert!(
+ messages[0].contains("Admin") && messages[0].contains("is not valid for"),
+ "expected the error to say the type is not valid for the action but was: {messages:?}"
+ );
+ }
+
+ #[test]
+ fn a_forbid_reaches_a_deny() {
+ let answer = call(
+ json!({ "type": "User", "id": "alice" }),
+ r#"permit(principal, action, resource);
+ forbid(principal, action, resource);"#,
+ );
+ assert_eq!(answer["type"], "residuals");
+ assert_eq!(answer["response"]["decision"], "deny");
+ }
+
+ /// Type-aware partial evaluation validates the policy set against the schema, which plain
+ /// authorization does not. A policy that parses but does not type check is a failure, not a
+ /// residual.
+ #[test]
+ fn a_policy_that_fails_schema_validation_is_a_failure() {
+ let answer = call(
+ json!({ "type": "User", "id": "alice" }),
+ r#"permit(principal, action, resource) when { principal.nosuchattr };"#,
+ );
+ let messages = failure_messages(&answer);
+ assert_eq!(
+ messages,
+ vec!["policy failed to validate against the schema"]
+ );
+ }
+
+ /// A template-linked policy residualizes as a static policy keyed by the link id, which is
+ /// what lets the Java side rebuild the residuals with no templates of its own.
+ #[test]
+ fn a_template_link_residualizes_under_its_link_id() {
+ let answer = call_with(&input(
+ json!({ "type": "User" }),
+ json!({
+ "templates": {
+ "tmpl": "permit(principal == ?principal, action, resource) when { principal.isAdmin };"
+ },
+ "templateLinks": [{
+ "templateId": "tmpl",
+ "newId": "linked",
+ "values": { "?principal": { "type": "User", "id": "alice" } }
+ }]
+ }),
+ ));
+ assert_eq!(answer["type"], "residuals");
+ let residuals = answer["response"]["residuals"].as_object().unwrap();
+ assert_eq!(
+ residuals.keys().collect::>(),
+ vec!["linked"],
+ "the residual is keyed by the link id, not the template id"
+ );
+ let body = residuals["linked"].to_string();
+ assert!(
+ !body.contains("slot") && !body.contains('?'),
+ "the link's slot should be substituted away but was: {body}"
+ );
+ }
+
+ #[test]
+ fn malformed_json_is_a_failure_not_a_panic() {
+ let answer: serde_json::Value =
+ serde_json::from_str(&call_cedar(OP, "{ not json")).unwrap();
+ let messages = failure_messages(&answer);
+ assert_eq!(messages.len(), 1);
+ assert!(
+ messages[0].contains("key must be a string"),
+ "expected the error to describe the bad JSON but was: {messages:?}"
+ );
+ }
+
+ #[test]
+ fn a_bad_policy_set_is_a_failure() {
+ let answer = call(
+ json!({ "type": "User", "id": "alice" }),
+ "this is not a policy",
+ );
+ let messages = failure_messages(&answer);
+ // The underlying parse error is preserved, not flattened to the wrapper's context.
+ assert_eq!(
+ messages,
+ vec!["failed to parse policies from string: unexpected token `is`"]
+ );
+ }
+
+ /// An entity set where alice's `num` is known, so arithmetic on it can overflow.
+ fn overflowing_entities() -> serde_json::Value {
+ json!([{ "uid": { "type": "User", "id": "alice" },
+ "attrs": { "isAdmin": true, "num": 1 },
+ "parents": [], "tags": {} }])
+ }
+
+ const OVERFLOWS: &str = "permit(principal, action, resource) \
+ when { principal has num && principal.num + 9223372036854775807 > 0 };";
+
+ /// Residuals arrive as Cedar source, and an errored one is reported by id.
+ #[test]
+ fn an_errored_residual_is_reported_by_id() {
+ let mut input = input(
+ json!({ "type": "User", "id": "alice" }),
+ json!({ "staticPolicies": OVERFLOWS }),
+ );
+ input["entities"] = overflowing_entities();
+ let answer = call_with(&input);
+ assert_eq!(answer["type"], "residuals");
+ let residuals = answer["response"]["residuals"].as_object().unwrap();
+ assert_eq!(residuals.len(), 1);
+ let (id, src) = residuals.iter().next().unwrap();
+ assert!(
+ src.as_str().unwrap().contains("error()"),
+ "the residual should carry Cedar's own rendering but was: {src}"
+ );
+ assert_eq!(
+ answer["response"]["erroredResiduals"].as_array().unwrap(),
+ &vec![serde_json::Value::String(id.clone())]
+ );
+ }
+
+ /// An error nested inside a live residual: absent from Cedar's error buckets, but still
+ /// unparseable, so it must be reported.
+ #[test]
+ fn a_nested_errored_residual_is_reported_by_id() {
+ // No `context` key at all, so `context.authenticated` cannot be resolved and the `||`
+ // cannot short-circuit — which is what keeps the error nested rather than folded away.
+ let input = json!({
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "schema": CEDAR_SCHEMA_SRC,
+ "policies": { "staticPolicies":
+ "permit(principal, action, resource) when { context.authenticated || (principal has num && principal.num + 9223372036854775807 > 0) };" },
+ "entities": overflowing_entities()
+ });
+ let answer = call_with(&input);
+ assert_eq!(answer["type"], "residuals");
+ let nontrivial = answer["response"]["nontrivialResiduals"]
+ .as_array()
+ .unwrap();
+ let errored = answer["response"]["erroredResiduals"].as_array().unwrap();
+ assert_eq!(errored.len(), 1, "the nested error must be reported");
+ assert_eq!(
+ nontrivial, errored,
+ "and it is also a non-trivial residual, which is what error_permits() would miss"
+ );
+ }
+
+ #[test]
+ fn a_clean_residual_is_not_reported_as_errored() {
+ let answer = call(
+ json!({ "type": "User" }),
+ r#"permit(principal, action, resource) when { principal.isAdmin };"#,
+ );
+ assert!(answer["response"]["erroredResiduals"]
+ .as_array()
+ .unwrap()
+ .is_empty());
+ }
+
+ /// Reauthorization, which runs natively so residuals never cross to Java and back.
+ mod reauthorize {
+ use super::*;
+
+ const REAUTH_OP: &str = "TypeAwarePartialReauthorizeOperation";
+
+ /// A partial call with the context unknown, completed by `concrete`.
+ fn reauth(
+ policies: &str,
+ entities: serde_json::Value,
+ concrete_principal: serde_json::Value,
+ concrete_context: serde_json::Value,
+ concrete_entities: serde_json::Value,
+ ) -> serde_json::Value {
+ let input = json!({
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "schema": CEDAR_SCHEMA_SRC,
+ "policies": { "staticPolicies": policies },
+ "entities": entities,
+ "concreteRequest": {
+ "principal": concrete_principal,
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "context": concrete_context
+ },
+ "concreteEntities": concrete_entities
+ });
+ serde_json::from_str(&call_cedar(REAUTH_OP, &input.to_string())).unwrap()
+ }
+
+ #[test]
+ fn completes_an_undecided_authorization() {
+ let answer = reauth(
+ "permit(principal, action, resource) when { context.authenticated };",
+ json!([]),
+ json!({ "type": "User", "id": "alice" }),
+ json!({ "authenticated": true }),
+ json!([]),
+ );
+ assert_eq!(answer["type"], "success");
+ assert_eq!(answer["response"]["decision"], "allow");
+ }
+
+ /// The case that motivates running this natively: an errored residual cannot be
+ /// serialized, but reauthorization never serializes it.
+ #[test]
+ fn works_when_a_residual_errored() {
+ let answer = reauth(
+ OVERFLOWS,
+ overflowing_entities(),
+ json!({ "type": "User", "id": "alice" }),
+ json!({ "authenticated": true }),
+ // The partial entities named alice, so the concrete ones must too — Cedar
+ // checks entity consistency as well as request consistency.
+ json!([{ "uid": { "type": "User", "id": "alice" },
+ "attrs": { "isAdmin": true, "num": 1 }, "parents": [] }]),
+ );
+ assert_eq!(
+ answer["type"], "success",
+ "an errored residual must not make reauthorization fail: {answer}"
+ );
+ assert_eq!(answer["response"]["decision"], "deny");
+ let errors = answer["response"]["diagnostics"]["errors"]
+ .as_array()
+ .expect("the erroring policy should be reported in diagnostics");
+ assert_eq!(errors.len(), 1, "expected one evaluation error: {answer}");
+ }
+
+ /// Diagnostics survive the reauthorize path, not just the partial-authorization one. An entity
+ /// that does not conform names the entity and the attribute; flattening to `to_string()` used
+ /// to reduce this to "entity does not conform to the schema".
+ #[test]
+ fn preserves_diagnostics_for_a_nonconforming_concrete_entity() {
+ let input = json!({
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "schema": CEDAR_SCHEMA_SRC,
+ "policies": { "staticPolicies": "permit(principal, action, resource);" },
+ "entities": [],
+ "concreteRequest": {
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "context": { "authenticated": true }
+ },
+ // `isAdmin` is required by the fixture schema and omitted here.
+ "concreteEntities": [{ "uid": { "type": "User", "id": "alice" }, "attrs": {}, "parents": [] }]
+ });
+ let answer: serde_json::Value =
+ serde_json::from_str(&call_cedar(REAUTH_OP, &input.to_string())).unwrap();
+ assert_eq!(answer["type"], "failure");
+ let message = answer["errors"][0]["message"].as_str().unwrap();
+ assert!(
+ message.contains("isAdmin") && message.contains("alice"),
+ "the diagnostic must name the entity and the attribute, but was: {message}"
+ );
+ }
+
+ /// A concrete request must supply a context. Defaulting an absent one to empty would answer a
+ /// different question than the caller asked: a `forbid ... when { context.blocked }` would
+ /// evaluate false and flip a Deny to an Allow, silently.
+ #[test]
+ fn rejects_a_concrete_request_with_no_context() {
+ let input = json!({
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "schema": CEDAR_SCHEMA_SRC,
+ "policies": { "staticPolicies": "permit(principal, action, resource);" },
+ "entities": [],
+ "concreteRequest": {
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" }
+ // no context
+ },
+ "concreteEntities": []
+ });
+ let answer: serde_json::Value =
+ serde_json::from_str(&call_cedar(REAUTH_OP, &input.to_string())).unwrap();
+ assert_eq!(answer["type"], "failure");
+ let message = answer["errors"][0]["message"].as_str().unwrap();
+ assert!(
+ message.contains("context"),
+ "expected the error to name the missing context but was: {message}"
+ );
+ }
+
+ /// A JSON null context is rejected too, which is what Java sends for an empty `Optional`.
+ #[test]
+ fn rejects_a_concrete_request_with_a_null_context() {
+ let input = json!({
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "schema": CEDAR_SCHEMA_SRC,
+ "policies": { "staticPolicies": "permit(principal, action, resource);" },
+ "entities": [],
+ "concreteRequest": {
+ "principal": { "type": "User", "id": "alice" },
+ "action": { "__entity": { "type": "Action", "id": "view" } },
+ "resource": { "type": "Photo", "id": "door" },
+ "context": null
+ },
+ "concreteEntities": []
+ });
+ let answer: serde_json::Value =
+ serde_json::from_str(&call_cedar(REAUTH_OP, &input.to_string())).unwrap();
+ assert_eq!(answer["type"], "failure");
+ }
+
+ /// Completing a *different* request is rejected. Authorizing a residual policy set
+ /// cannot detect this and would silently grant access.
+ #[test]
+ fn rejects_a_request_that_does_not_match_the_partial_one() {
+ let answer = reauth(
+ "permit(principal == User::\"alice\", action, resource) when { context.authenticated };",
+ json!([]),
+ json!({ "type": "User", "id": "bob" }),
+ json!({ "authenticated": true }),
+ json!([]),
+ );
+ assert_eq!(answer["type"], "failure");
+ let message = answer["errors"][0]["message"].as_str().unwrap();
+ assert!(
+ message.contains("alice") && message.contains("bob"),
+ "expected the error to name both principals but was: {message}"
+ );
+ }
+ }
+
+ #[test]
+ fn an_unknown_request_field_is_ignored() {
+ let mut input = input(
+ json!({ "type": "User", "id": "alice" }),
+ json!({ "staticPolicies": "permit(principal, action, resource);" }),
+ );
+ input["aFieldFromTheFuture"] = json!(7);
+ assert_eq!(call_with(&input)["type"], "residuals");
+ }
+ }
+
#[test]
fn validate_partial_entity_with_json_format_schema() {
let entity = json!({
diff --git a/CedarJavaFFI/src/tpe.rs b/CedarJavaFFI/src/tpe.rs
index 27dc33a6..aea781c5 100644
--- a/CedarJavaFFI/src/tpe.rs
+++ b/CedarJavaFFI/src/tpe.rs
@@ -18,14 +18,19 @@
#[cfg(feature = "tpe")]
use cedar_policy::{
- ffi::Schema as FFISchema, Context, EntityId, EntityTypeName, EntityUid, PartialEntities,
- PartialEntityUid, PartialRequest, Schema,
+ ffi::{DetailedError, PolicySet as FFIPolicySet, Schema as FFISchema},
+ Context, Decision, EntityId, EntityTypeName, EntityUid, PartialEntities, PartialEntityUid,
+ PartialRequest, Schema,
};
#[cfg(feature = "tpe")]
use serde::Deserialize;
#[cfg(feature = "tpe")]
+use serde::Serialize;
+#[cfg(feature = "tpe")]
use serde_json::Value;
#[cfg(feature = "tpe")]
+use std::collections::{HashMap, HashSet};
+#[cfg(feature = "tpe")]
use std::str::FromStr;
use crate::utils::Result;
@@ -131,3 +136,374 @@ pub fn validate_partial_entities(_entities_json: &str, _schema_json: &str) -> Re
pub fn validate_type_aware_partial_request(_request_json: &str) -> Result<()> {
Err(TPE_DISABLED.into())
}
+
+/// The authorization call the Java engine sends. Deliberately **not**
+/// `#[serde(deny_unknown_fields)]`: a newer Java sending a field an older `.so` does not know about
+/// must still work, and `CEDAR_JAVA_FFI_LIB` makes that skew reachable.
+#[cfg(feature = "tpe")]
+#[derive(Debug, Deserialize)]
+struct TypeAwarePartialAuthCall {
+ principal: PartialEntityUidJson,
+ action: Value,
+ resource: PartialEntityUidJson,
+ #[serde(default)]
+ context: Option,
+ schema: FFISchema,
+ policies: FFIPolicySet,
+ entities: Value,
+}
+
+/// The residual set, mirroring the field names `TypeAwarePartialAuthorizationSuccessResponse` binds.
+#[cfg(feature = "tpe")]
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct TypeAwarePartialAuthSuccess {
+ /// `None` becomes JSON `null`, meaning no conclusive decision.
+ decision: Option,
+ /// Every residual, as Cedar source keyed by policy id. Source rather than EST because a residual
+ /// whose evaluation errored contains a node Cedar can write but not read back, in either form.
+ /// Sending source keeps it faithful and lets Java hold it without parsing.
+ residuals: HashMap,
+ /// Ids only. The bodies are already in `residuals`, and this is a subset of its keys.
+ nontrivial_residuals: HashSet,
+ /// Ids of the residuals that *contain* a subexpression Cedar already knows would error. Cedar
+ /// cannot parse these back, so the `Policy` Java builds from one carries its source and nothing
+ /// more.
+ ///
+ /// Containing an error node does not mean the policy will error: `unknown || error` decides
+ /// normally when the left side turns out true. Derived from the PST rather than
+ /// `error_permits`/`error_forbids`, which report only policies that reduced *entirely* to an
+ /// error and so miss this nested case.
+ errored_residuals: HashSet,
+}
+
+/// The reauthorization call. Carries everything the original type-aware partial authorization needed,
+/// plus the now-known request and entities.
+///
+/// The partial side is resent rather than the residuals because Cedar's `reauthorize` validates the
+/// concrete request against the partial one — residuals alone cannot detect a caller completing a
+/// *different* request, which silently grants access the original policies would have denied.
+#[cfg(feature = "tpe")]
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct TypeAwarePartialReauthorizeCall {
+ #[serde(flatten)]
+ partial: TypeAwarePartialAuthCall,
+ concrete_request: ConcreteRequestJson,
+ concrete_entities: Value,
+}
+
+/// A fully concrete request.
+#[cfg(feature = "tpe")]
+#[derive(Debug, Deserialize)]
+struct ConcreteRequestJson {
+ principal: Value,
+ action: Value,
+ resource: Value,
+ /// Required, unlike the partial request's context where absence means *unknown*. A concrete request
+ /// has no unknowns, so an absent context is a caller error — defaulting it to empty would answer a
+ /// different question than the caller asked, and silently.
+ context: Value,
+}
+
+#[cfg(feature = "tpe")]
+#[derive(Debug, Serialize)]
+#[serde(tag = "type")]
+enum TypeAwarePartialAuthAnswer {
+ #[serde(rename = "residuals")]
+ Success {
+ response: TypeAwarePartialAuthSuccess,
+ warnings: Vec,
+ },
+ #[serde(rename = "failure")]
+ Failure {
+ errors: Vec,
+ warnings: Vec,
+ },
+}
+
+/// Wrap a `miette::Diagnostic` error as a `DetailedError`, preserving its help text, code, severity,
+/// source spans and `related` errors. Policy-set validation puts the actual failures in `related`, so
+/// flattening to `to_string()` would report only "policy failed to validate against the schema".
+#[cfg(feature = "tpe")]
+fn detailed(e: E) -> DetailedError
+where
+ E: miette::Diagnostic + Send + Sync + 'static,
+{
+ DetailedError::from(miette::Report::new(e))
+}
+
+#[cfg(feature = "tpe")]
+fn tpe_failure_detailed(errors: Vec) -> TypeAwarePartialAuthAnswer {
+ TypeAwarePartialAuthAnswer::Failure {
+ errors,
+ warnings: vec![],
+ }
+}
+
+/// A `DetailedError` carrying only a message, for the error types on these paths that are not
+/// `miette::Diagnostic`s — `serde_json::Error` and the boxed errors from `PartialEntityUidJson::parse`.
+#[cfg(feature = "tpe")]
+fn plain(message: String) -> DetailedError {
+ DetailedError {
+ message,
+ help: None,
+ code: None,
+ url: None,
+ severity: None,
+ source_locations: vec![],
+ related: vec![],
+ }
+}
+
+#[cfg(feature = "tpe")]
+fn tpe_failure(message: String) -> TypeAwarePartialAuthAnswer {
+ tpe_failure_detailed(vec![plain(message)])
+}
+
+/// Run type-aware partial authorization. Every failure that a caller could have caused — malformed
+/// JSON, an unparseable schema or policy set, a request that does not type check, entities that do
+/// not conform — comes back as a `Failure` answer rather than an error, because `call_cedar`
+/// panics on an `Err` and the panic reaches Java as a non-JSON string.
+#[cfg(feature = "tpe")]
+pub fn json_type_aware_partial_is_authorized(input: &str) -> serde_json::Result {
+ serde_json::to_string(&type_aware_partial_is_authorized(input))
+}
+
+/// The inputs a type-aware partial evaluation needs, parsed and validated. Shared so that
+/// authorization and reauthorization cannot drift in how they interpret the same payload.
+#[cfg(feature = "tpe")]
+struct ParsedCall {
+ policies: cedar_policy::PolicySet,
+ schema: Schema,
+ request: PartialRequest,
+ entities: PartialEntities,
+}
+
+#[cfg(feature = "tpe")]
+impl TypeAwarePartialAuthCall {
+ /// Every error here is caller-caused, so the diagnostics are returned for the caller to funnel into
+ /// a `Failure` answer. One `DetailedError` per underlying report, each keeping its help text and
+ /// source span.
+ fn parse_all(self) -> std::result::Result> {
+ let schema = parse_schema(self.schema).map_err(|e| vec![plain(e.to_string())])?;
+ let policies = self.policies.parse().map_err(|errs| {
+ errs.into_iter()
+ .map(DetailedError::from)
+ .collect::>()
+ })?;
+ let action = EntityUid::from_json(self.action).map_err(|e| vec![detailed(e)])?;
+ let principal = self
+ .principal
+ .parse()
+ .map_err(|e| vec![plain(e.to_string())])?;
+ let resource = self
+ .resource
+ .parse()
+ .map_err(|e| vec![plain(e.to_string())])?;
+ let context = self
+ .context
+ .map(|c| Context::from_json_value(c, Some((&schema, &action))))
+ .transpose()
+ .map_err(|e| vec![detailed(e)])?;
+ let entities = PartialEntities::from_json_value(self.entities, &schema)
+ .map_err(|e| vec![detailed(e)])?;
+ let request = PartialRequest::new(principal, action, resource, context, &schema)
+ .map_err(|e| vec![detailed(e)])?;
+ Ok(ParsedCall {
+ policies,
+ schema,
+ request,
+ entities,
+ })
+ }
+}
+
+/// Whether this residual contains a known-error subexpression, at any depth.
+///
+/// This is about *representability*, not evaluation: Cedar's own `has_error` doc notes it "says
+/// nothing about whether the expression will, or even can, evaluate to an error", and is meant for
+/// deciding whether an expression can be written as valid Cedar text. That is exactly the question
+/// here, because such a residual cannot be parsed back.
+///
+/// Uses the PST rather than `TpeResponse::error_permits`/`error_forbids`, which report only policies
+/// that reduced *entirely* to an error. An error nested inside a live residual — `unknown || error` —
+/// is absent from those buckets but still makes the residual unparseable.
+#[cfg(feature = "tpe")]
+fn residual_errors(policy: &cedar_policy::Policy) -> std::result::Result {
+ use cedar_policy::pst::Clause;
+ let pst = policy.to_pst().map_err(|e| e.to_string())?;
+ Ok(pst.body().clauses().iter().any(|clause| match clause {
+ Clause::When(e) | Clause::Unless(e) => e.has_error(),
+ }))
+}
+
+#[cfg(feature = "tpe")]
+fn type_aware_partial_is_authorized(input: &str) -> TypeAwarePartialAuthAnswer {
+ macro_rules! or_failure {
+ ($e:expr) => {
+ match $e {
+ Ok(v) => v,
+ Err(e) => return tpe_failure(e.to_string()),
+ }
+ };
+ }
+
+ let call: TypeAwarePartialAuthCall = or_failure!(serde_json::from_str(input));
+ let ParsedCall {
+ policies,
+ schema,
+ request,
+ entities,
+ } = match call.parse_all() {
+ Ok(parsed) => parsed,
+ Err(errors) => return tpe_failure_detailed(errors),
+ };
+ let response = match policies.tpe(&request, &entities, &schema) {
+ Ok(response) => response,
+ Err(e) => return tpe_failure_detailed(vec![detailed(e)]),
+ };
+
+ let mut residuals = HashMap::new();
+ let mut errored_residuals = HashSet::new();
+ for p in response.policies() {
+ let id = p.id().to_string();
+ if or_failure!(residual_errors(&p)) {
+ errored_residuals.insert(id.clone());
+ }
+ residuals.insert(id, p.to_string());
+ }
+ // `nontrivial_residual_policies` deliberately, not `residual_policies`: this spelling means the
+ // non-trivial subset in Cedar 4.11 *and* 4.12+, while `residual_policies` means every residual in
+ // 4.11 and the non-trivial subset in 4.12+. Deprecated here, but a deprecation warning is cheaper
+ // than a rename that compiles clean and silently inverts the set on the 4.11 release branch.
+ #[allow(deprecated)]
+ let nontrivial_residuals = response
+ .nontrivial_residual_policies()
+ .map(|p| p.id().to_string())
+ .collect();
+
+ TypeAwarePartialAuthAnswer::Success {
+ response: TypeAwarePartialAuthSuccess {
+ decision: response.decision(),
+ residuals,
+ nontrivial_residuals,
+ errored_residuals,
+ },
+ // TpeResponse exposes no warnings, so there is nothing to populate this with yet.
+ warnings: vec![],
+ }
+}
+
+/// Complete a type-aware partial authorization now that the unknowns are known.
+///
+/// Runs entirely natively: the residuals never cross to Java and back. That is deliberate — Cedar's
+/// `reauthorize` checks the concrete request against the partial one and rejects a mismatch, which
+/// authorizing a residual policy set cannot do.
+#[cfg(feature = "tpe")]
+pub fn json_type_aware_partial_reauthorize(input: &str) -> serde_json::Result {
+ serde_json::to_string(&type_aware_partial_reauthorize(input))
+}
+
+#[cfg(feature = "tpe")]
+fn auth_failure(errors: Vec) -> cedar_policy::ffi::AuthorizationAnswer {
+ cedar_policy::ffi::AuthorizationAnswer::Failure {
+ errors,
+ warnings: vec![],
+ }
+}
+
+#[cfg(feature = "tpe")]
+fn type_aware_partial_reauthorize(input: &str) -> cedar_policy::ffi::AuthorizationAnswer {
+ /// For errors that implement `miette::Diagnostic`, so the help text, source spans and `related`
+ /// errors survive. Entity-conformance failures put the entity and attribute in the cause chain,
+ /// which `to_string()` drops — see `detailed`.
+ macro_rules! or_failure {
+ ($e:expr) => {
+ match $e {
+ Ok(v) => v,
+ Err(e) => return auth_failure(vec![detailed(e)]),
+ }
+ };
+ }
+ /// For the error types on this path that are not `miette::Diagnostic`s: `serde_json::Error`, and
+ /// `TpeReauthorizationError`, which Cedar does not derive `Diagnostic` on even though every one of
+ /// its inner variants does.
+ macro_rules! or_failure_plain {
+ ($e:expr) => {
+ match $e {
+ Ok(v) => v,
+ Err(e) => return auth_failure(vec![plain(e.to_string())]),
+ }
+ };
+ }
+
+ let call: TypeAwarePartialReauthorizeCall = or_failure_plain!(serde_json::from_str(input));
+ let ParsedCall {
+ policies,
+ schema,
+ request,
+ entities,
+ } = match call.partial.parse_all() {
+ Ok(parsed) => parsed,
+ Err(errors) => return auth_failure(errors),
+ };
+ let response = or_failure!(policies.tpe(&request, &entities, &schema));
+
+ let concrete = call.concrete_request;
+ let action = or_failure!(EntityUid::from_json(concrete.action));
+ let context = or_failure!(Context::from_json_value(
+ concrete.context,
+ Some((&schema, &action))
+ ));
+ let concrete_request = or_failure!(cedar_policy::Request::new(
+ or_failure!(EntityUid::from_json(concrete.principal)),
+ action,
+ or_failure!(EntityUid::from_json(concrete.resource)),
+ context,
+ Some(&schema),
+ ));
+ let concrete_entities = or_failure!(cedar_policy::Entities::from_json_value(
+ call.concrete_entities,
+ Some(&schema)
+ ));
+
+ match response.reauthorize(&concrete_request, &concrete_entities) {
+ Ok(response) => cedar_policy::ffi::AuthorizationAnswer::Success {
+ response: response.into(),
+ warnings: vec![],
+ },
+ Err(e) => auth_failure(vec![plain(e.to_string())]),
+ }
+}
+
+/// A serialized failure answer carrying [`TPE_DISABLED`]. Both TPE operations stay in `call_cedar`'s
+/// dispatch table when the feature is off so that the Java layer receives something it can
+/// deserialize, and recognises the token; falling through to the unsupported-operation arm instead
+/// would produce an `errors` array of bare strings that the response types cannot bind.
+#[cfg(not(feature = "tpe"))]
+fn tpe_disabled_answer(kind: &str) -> serde_json::Result {
+ serde_json::to_string(&serde_json::json!({
+ "type": kind,
+ "errors": [{
+ "message": TPE_DISABLED,
+ "help": null,
+ "code": null,
+ "url": null,
+ "severity": null,
+ "sourceLocations": [],
+ "related": []
+ }],
+ "warnings": []
+ }))
+}
+
+#[cfg(not(feature = "tpe"))]
+pub fn json_type_aware_partial_is_authorized(_input: &str) -> serde_json::Result {
+ tpe_disabled_answer("failure")
+}
+
+#[cfg(not(feature = "tpe"))]
+pub fn json_type_aware_partial_reauthorize(_input: &str) -> serde_json::Result {
+ tpe_disabled_answer("failure")
+}