Skip to content

feat(tpe): wire type-aware partial evaluation into the authorization engine - #372

Open
muditchaudhary wants to merge 1 commit into
cedar-policy:mainfrom
muditchaudhary:tpe-step2-integrate
Open

muditchaudhary wants to merge 1 commit into
cedar-policy:mainfrom
muditchaudhary:tpe-step2-integrate

Conversation

@muditchaudhary

@muditchaudhary muditchaudhary commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Wire type-aware partial evaluation into the authorization engine

Summary

Adds isAuthorizedTypeAwarePartial and reauthorize to AuthorizationEngine, with the native
handlers behind them. Completes the TPE surface begun in #370 (entity model) and #371 (request and
response types). Experimental, and the JSON contract between Java and Rust is CedarJava-owned and
unstable.

What

  • AuthorizationEngine.isAuthorizedTypeAwarePartial(request, policySet, entities) — two overloads,
    one taking PartialEntities, one lifting fully-known Entities. Both default, so existing
    implementors still compile.
  • TypeAwarePartialAuthorizationSuccessResponse.reauthorize(request, entities) — completes an
    authorization once the unknowns are known. Runs natively; residuals never cross back.
  • Residuals cross the FFI boundary as Cedar source text keyed by policy id, not EST.
  • getErroredResidualIds() / getErroredResiduals() — residuals containing a subexpression Cedar
    already knows would error. Source-only.
  • getPolicySet() removed. Residuals are inspection-only.
  • Both TPE operations now stay in the native dispatch table when the tpe feature is off, so a
    caller gets MissingExperimentalFeatureException rather than a deserialization error.

Why

Residuals travel as source, not EST. Cedar renders a residual whose evaluation errors using a
synthetic error node that Cedar itself cannot parse back — in EST or in source form. So any
design that re-imports Cedar's own output for such a residual fails, and because the policy set is
parsed in one call, one unparseable policy fails the entire response. Sending source keeps the
rendering faithful and lets Java hold it without parsing.

Residuals are inspection-only, so getPolicySet() is gone. A residual has the original
request's values folded in — one computed for User::"alice" literally contains User::"alice" in
its condition. Authorizing it for User::"bob" grants Bob access only Alice had, silently and with
no error. getPolicySet() existed only in Unreleased, so removing it breaks no released API.

Completion is native. The success response carries the inputs its residuals were computed from,
so reauthorize cannot be handed partial inputs that disagree with what the caller inspected. Cedar
then validates the completed request and entities against them and rejects a mismatch — which
authorizing a residual set cannot do, because it has no partial request to compare against.

Errored ids come from the PST, via Policy::to_pst() + pst::Expr::has_error(), not
error_permits()/error_forbids(). Those report only policies that reduced entirely to an error
and miss one nested inside a live condition, which is still unparseable. It also happens to be the
only detection available on Cedar 4.11, which has no error buckets at all.

Testing notes

Expectations in the new tests were derived by running the same fixtures through Cedar Rust directly,
not by observing CedarJava — several earlier tests were found passing for the wrong reason. A
throwaway differential harness compared 152 cases across both implementations (residual ids, residual
source byte-for-byte, the trivial/non-trivial/errored classification, and every reauthorization
outcome) at zero divergences. Not committed.


API surface

Everything a caller touches. #370/#371 columns mark types that landed earlier and are listed for
completeness; the rest is new here.

Entry points — AuthorizationEngine

Method Does
isAuthorizedTypeAwarePartial(request, policySet, PartialEntities) Evaluates as far as the known data allows. Returns a decision if one is reachable, and a residual for every policy either way.
isAuthorizedTypeAwarePartial(request, policySet, Entities) Same, for entities that are all fully known — lifts them using the request's schema.
reauthorize(request, policySet, partialEntities, concreteRequest, entities) Lower-level form of the above. Prefer TypeAwarePartialAuthorizationSuccessResponse.reauthorize, which supplies the first three from the response so they cannot disagree with the residuals you inspected.

All three are default and throw UnsupportedOperationException unless the engine implements them, so
adding them broke no existing implementor.

Building a request — TypeAwarePartialAuthorizationRequest.builder()

Method Does
.principal(EntityUID) / .resource(EntityUID) The entity is fully known.
.principal(EntityTypeName) / .resource(EntityTypeName) The id is unknown; only the type is pinned. Cedar still enforces the type on reauthorization.
.principal(PartialEntityUID) / .resource(PartialEntityUID) Explicit form of the two above.
.action(EntityUID) Required and always concrete — TPE has no unknown action.
.context(Map<String, Value>) / .context(Context) The context is known.
.emptyContext() The context is known to be empty. Not the same as omitting it, which means unknown.
.schema(Schema) Required, unlike plain authorization. TPE validates the request and the policy set against it.
.build() Validates the request natively. Throws InternalException if it does not typecheck.

Partially known entities

Type Does
new PartialEntity(euid, Optional attrs, Optional parents, Optional tags, schema) One entity. Optional.empty() for a field means unknown; a present value means complete and final — a present attrs must carry every required attribute.
new PartialEntity(Entity, schema) Lifts a fully-known entity.
new PartialEntities(Set<PartialEntity>, schema) The entity store, validated against the schema.
new PartialEntities(Entities, schema) Lifts a fully-known store.
PartialEntities.empty() Nothing is known about any entity — not "there are no entities".
new PartialEntityUID(type) / (type, id) An entity reference whose id may be unknown.

Reading the response — TypeAwarePartialAuthorizationResponse

Method Does
getType() Success or Failure. Check this first — a policy set Cedar rejects arrives as a Failure, not an exception.
getSuccess() The residuals and decision, present iff Success.
getErrors() DetailedErrors, present iff Failure. For a policy-set validation failure the actual reason is in each error's related.
getWarnings() Always empty for TPE — Cedar's TpeResponse exposes none.

Reading a success — TypeAwarePartialAuthorizationSuccessResponse

Method Does
getDecision() The decision, or null if TPE could not reach one.
getResiduals() Every residual, one per policy, including those that fully resolved. Not for authorizing against — see §3.
getNontrivialResiduals() The residuals that did not reduce to a concrete true, false or error — the ones whose conditions are worth inspecting.
getTrivialResiduals() The ones that did. Partitions getResiduals() with the above.
getNontrivialResidualIds() Just the ids of the non-trivial set.
getErroredResidualIds() Ids of residuals containing a subexpression Cedar knows would error. Cuts across the trivial/non-trivial split rather than being a third partition, and does not mean the policy will error.
getErroredResiduals() Those residuals. Source-only: getSource(), getID(), toString() work; effect(), toJson(), getAnnotations(), getAnnotation() all throw.
reauthorize(concreteRequest, entities) Completes the authorization natively. The form to use. Cedar rejects a completion that disagrees with the partial request or entities.
toString() Decision, residuals, and the errored ids if any.

Using it

All examples use this schema:

entity Group;
entity User in [Group] { department?: String } tags String;
entity Photo;
action view appliesTo {
  principal: [User],
  resource: [Photo],
  context: { authenticated?: Bool }
};

and this policy, which cannot be decided until principal.department is known:

permit(principal, action == Action::"view", resource)
when { principal has department && principal.department == "eng" };

1. Happy path — everything known, a decision comes straight back

var engine = new BasicAuthorizationEngine();

var request = TypeAwarePartialAuthorizationRequest.builder()
        .principal(alice)              // EntityUID — concrete
        .action(view)
        .resource(door)
        .emptyContext()                // known to be empty; see §2
        .schema(schema)
        .build();

var aliceInEng = new Entity(alice, Map.of("department", new PrimString("eng")), Set.of(), Map.of());

var success = engine
        .isAuthorizedTypeAwarePartial(request, policies, new Entities(Set.of(aliceInEng)))
        .getSuccess().orElseThrow();

success.getDecision();                 // Allow
success.getNontrivialResiduals();      // empty — nothing left to decide
success.getResiduals();                // one residual: permit(principal, action, resource) when { true };

Note the residual is still there with the condition folded to true. Every policy produces a
residual, including ones that fully resolved.

2. Check for a failure before unwrapping

A policy set that authorizes fine can fail here, because TPE validates it against the schema and
plain authorization does not. An unconstrained scope means the condition must typecheck for every
entity type the action accepts, and an optional attribute needs a has guard.

var response = engine.isAuthorizedTypeAwarePartial(request, policies, entities);

if (response.getType() == SuccessOrFailure.Failure) {
    for (var error : response.getErrors().orElseThrow()) {
        log.error("{}", error.message);          // e.g. "policy failed to validate against the schema"
        error.related.forEach(r -> log.error("  {}", r.message));   // the actual reason lives here
    }
    return;
}

3. Something unknown — a residual comes back instead of a decision

Two shapes, and the difference matters.

Unknown principal id — principal stays symbolic:

var request = TypeAwarePartialAuthorizationRequest.builder()
        .principal(EntityTypeName.parse("User").get())    // type only; id unknown
        .action(view).resource(door).emptyContext().schema(schema).build();

var success = engine.isAuthorizedTypeAwarePartial(request, policies, PartialEntities.empty())
        .getSuccess().orElseThrow();

success.getDecision();     // null — not decidable yet
success.getResiduals().iterator().next().getSource();
// permit(principal, action, resource) when { (principal has department) && (principal.department == "eng") };

Known id, unknown attributes — the entity is substituted into the condition:

var partialEntities = new PartialEntities(
        Set.of(new PartialEntity(alice,
                Optional.empty(),         // attrs UNKNOWN
                Optional.of(Set.of()),    // parents known to be empty
                Optional.of(Map.of()),    // tags known to be empty
                schema)),
        schema);

// permit(principal, action, resource) when { (User::"alice" has department) && (User::"alice".department == "eng") };

User::"alice" is now hard-coded into the condition. This is why residuals must not be authorized
for a different principal
— evaluating that text for Bob reads Alice's attributes.

Omitted versus empty

Omitting a field means unknown; supplying it empty means known to be empty. They give
different answers:

Optional.empty()    // attrs unknown  -> decision null, residual: ... when { User::"alice" has department }
Optional.of(Map.of())  // attrs known empty -> decision Deny, residual: ... when { false };

The same rule applies to parents, tags, and the request context — omit context() for unknown,
call emptyContext() for known-empty.

4. Reauthorize — complete it once the unknowns are known

// ... earlier: got `success` back with getDecision() == null, inspected the residuals,
//     and learned that `department` is what matters. Now fetch just that.
var alice = new Entity(aliceUid, Map.of("department", new PrimString(fetchedDepartment)),
        Set.of(), Map.of());

var done = success.reauthorize(
        new AuthorizationRequest(aliceUid, view, door, Map.of()),   // the same request, completed
        new Entities(Set.of(alice)));

done.success.orElseThrow().getDecision();   // Allow for "eng", Deny for "sales"

You do not pass the policy set or partial entities again — the response carries them, so they cannot
disagree with the residuals you just inspected.

A mismatched completion is rejected, not answered:

var wrong = success.reauthorize(
        new AuthorizationRequest(bobUid, view, door, Map.of()),     // different principal
        new Entities(Set.of(bob)));

wrong.type;                                  // Failure
wrong.errors.orElseThrow().get(0).message;
// partial request principal id `alice` does not match concrete request principal id `bob`

Cedar checks the principal, resource, action and context, and for every entity the partial set knew
about it compares the whole attribute map, the whole tag map, and the ancestor closure. Entities you
add are not checked.

5. Errored residuals

A policy whose evaluation errors — an integer overflow, say — still produces a residual, and Cedar
still reaches a decision:

permit(principal, action == Action::"view", resource)
when { 9223372036854775807 + 1 > 0 };          // overflows at evaluation time
success.getDecision();              // Deny  — an erroring permit grants nothing
success.getErroredResidualIds();    // ["boom"]
success.getTrivialResiduals();      // ["boom"] — it reached a concrete outcome, which is an error
success.getNontrivialResiduals();   // empty

var errored = success.getErroredResiduals().iterator().next();
errored.getSource();                // permit(principal, action, resource) when { error() };
errored.getID();                    // "boom"
errored.effect();                   // throws InternalException — `error` is not a valid function
errored.toJson();                   // throws
errored.getAnnotations();           // throws

These residuals are source-only: anything that re-parses the policy natively throws, because
Cedar cannot read its own error marker back. A clean residual has no such limitation:

var clean = success.getResiduals().iterator().next();
clean.toJson();                     // the EST, fine
clean.effect();                     // PERMIT

Containing an error node does not mean the policy errors. If the error sits in a branch that
short-circuits away, the policy decides normally:

permit(principal, action == Action::"view", resource)
when { (principal has department) || (9223372036854775807 + 1 > 0) };
success.getDecision();              // null — still undecided
success.getErroredResidualIds();    // ["p0"]
success.getNontrivialResiduals();   // ["p0"]  <- in BOTH sets

So the errored set cuts across the trivial/non-trivial split rather than being a third partition.

Reauthorization handles all of this, because it evaluates the real residual natively rather than
Cedar's rendering of it:

var done = success.reauthorize(completedRequest, completedEntities);
done.success.orElseThrow().getDecision();                // a decision still comes back
done.success.orElseThrow().getErrors().size();           // the evaluation error is reported too

One shape worth knowing: an erroring forbid is skipped, so a trivially-true permit still wins —
Allow alongside a non-zero error count.

6. If the native library lacks the feature

isAuthorizedTypeAwarePartial, reauthorize, and the PartialEntity/PartialEntities constructors
all throw MissingExperimentalFeatureException, naming the flag to rebuild with:

Missing experimental feature. To enable this feature please recompile
the CedarJava native library with "--features=tpe".

…engine

Signed-off-by: Mudit Chaudhary <chmudit@amazon.com>
@muditchaudhary
muditchaudhary marked this pull request as ready for review October 1, 2026 21:39
*/
@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION)
public AuthorizationResponse reauthorize(AuthorizationRequest request,
com.cedarpolicy.model.entity.Entities entities) throws AuthException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit importing com.cedarpolicy.model.entity.Entities above

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants