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") +}