feat: expose type-aware partial evaluation (TPE) as tpe_authorize - #109
Open
h0rv wants to merge 8 commits into
Open
feat: expose type-aware partial evaluation (TPE) as tpe_authorize#109h0rv wants to merge 8 commits into
h0rv wants to merge 8 commits into
Conversation
h0rv
force-pushed
the
feat/tpe-authorize
branch
from
August 21, 2026 19:01
b4b516d to
409d6b6
Compare
h0rv
marked this pull request as ready for review
August 28, 2026 13:42
h0rv
force-pushed
the
feat/tpe-authorize
branch
3 times, most recently
from
August 28, 2026 22:10
4f257d7 to
ea6bb3b
Compare
h0rv
force-pushed
the
feat/tpe-authorize
branch
3 times, most recently
from
August 31, 2026 19:31
60ae834 to
60d097f
Compare
Adds tpe_authorize for a request whose principal and/or resource identity is unknown but whose type is known. Separate from is_authorized_partial, which it does not call, change, or share a response shape with. TPE's residuals are checked against the schema and convert to PST; is_authorized_partial's do not, for the reason the previous commit found. principal/resource accept Type::"id" or a bare Type, since TPE's PartialEntityUid needs a type even when the id is unknown. action must be concrete. schema is required. entities must be fully concrete. The response is a TpeAuthzResult dataclass. permits/forbids stay separate, each a TpeClassification of residual/true/false/error policy ids. residual_policies maps id to a cedarpy.pst.Template, the same typed node the previous commit exposes, not JSON. Reuses its PstClasses/build_template rather than a second converter. Enables the tpe Cargo feature. Checked the resolved lockfile: no change, since neither crate's tpe feature has a dependency edge. Every failure raises ValueError: unlike is_authorized/ is_authorized_partial, there is no decision to fall back to when the input itself cannot be resolved. Closes the rest of k9securityio#107.
context=None now means unknown, as PartialRequest and is_authorized_partial define it, instead of a known-empty context. Adds reauthorize (binding the unknowns and evaluating only the residuals, with Cedar's consistency checks), residual_policy_set, PartialEntities for entity data that is not yet known, and the dict/EntityType input forms for principal and resource. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PEP 604/585 syntax, parameterized the raw-JSON dicts the result wrappers hold, replaced the parameter-rebinding normalization with typed helpers, and declared __all__. mypy cedarpy/ is clean. No runtime behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The walk shipped with tests over static policies and templates only, so nothing pinned the case that motivates it: a residual from `is_authorized_partial` cannot become a PST, but a TPE residual is a `pst.Template`, and asking it which entities it names is how a caller knows what to load before finishing the evaluation. Pins the difference between the two views as well, since they are easy to confuse. `residual_policies` holds only the undecided policies as the evaluator reduced them, with the concrete request substituted in. `residual_policy_set` holds every residual, including the concretely true, false and erroring ones, each keeping its original scope, and being a handle rather than nodes it needs `to_pst()` before the walk will take it. The entities they name differ. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same pass as the commit below, over the text this branch adds. - Changelog: the `tpe_authorize` bullet goes from 207 words to 122 and points at the README for the argument forms. Adds a short bullet for `pst.ResidualError`, which is a new public node type here. - README: unwrapped lines and `*` bullets to match the surrounding sections, and asserts in place of `print` with an expected-value comment. All five code blocks were executed as written, which caught two wrong entity documents: an entity-valued attribute needs the `__entity` wrapper, and a concrete `Doc` needs both attributes the schema declares. - Removed the em dash and the two `--` in this section. - Docstrings: cut to the part a reader needs, and split the clauses that were joined with a colon or semicolon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the pattern set by the partial-authorization and PST guides, and the README doctrine in CLAUDE.md: the README keeps one section per feature with a single example and a pointer, and the comprehensive documentation lives in docs/guides/. The guide carries what the README section held (argument forms, the context contract, the two views of the residuals, reauthorize, partially known entities) plus a table contrasting TPE with is_authorized_partial, an API reference, and the caveats a reader needs: a TPE result is not a decision, a residual holding pst.ResidualError cannot be rebuilt into a PolicySet, and the 100-level expression nesting limit applies to residual conversion too. The remaining README example is byte-identical to test_type_only_resource_produces_a_residual, so it is covered by a test.
Aligns this branch with the review standards applied to k9securityio#108. - The pydocs and Rust doc comments now lead with what each entry point does and name the error each raises and its condition: tpe_authorize, tpe_reauthorize, and the two _internal stubs, which had no docstrings. - TpeAuthzResult had no class docstring; it now says decision is None exactly when the unknowns can still change the outcome. residual_policies gets the docstring residual_policy_set already had, since the difference between the two views is the part callers get wrong. - pst.ResidualError documents that PolicySet.from_pst raises TypeError on a node tree containing one, with a test. read_expr has no arm for it, so this was already the behaviour, undocumented and untested. - CLAUDE.md records the tpe Cargo feature and what it gates, the TpeResponse schema borrow that keeps it out of a pyclass, the PartialEntities wholly-present-or-absent rule, and that the two residual views name different entities. - The pst engine-bump step notes that pst_variants.json records each variant's cfg gate, so a variant only this build's features reach is audited as such. - The 4.12 engine entry said the breaking changes were confined to features cedarpy does not enable, listing tpe. This branch enables tpe, so the sentence now reads "did not then enable".
h0rv
force-pushed
the
feat/tpe-authorize
branch
from
September 1, 2026 21:57
60d097f to
b4167c6
Compare
Same editorial pass k9securityio#108 got. No behavior change, and the only Rust edits are doc comments. Splits the sentences that were joined with a semicolon or a colon, drops the words that carried no information ("sanctioned", "matters", "drops straight into"), and names the actor where an inanimate subject was taking an action verb. TpeClassification, _TpeInputs, and pst.ResidualError get first lines that say what the thing is.
Contributor
Author
|
@skuenzli Thanks for the other review - this one is rebased and ready for review 😄 |
Contributor
|
Hey Robby - I haven't forgotten about this PR. Thanks for your patience. I'll review it as soon as I can. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased onto
mainnow that #108 has merged. Eight commits: the five that were here, plus three that pick up your review changes.956bd9aaddstpe_authorize4090666completes the TPE surface and settles thecontextcontract7f2e75eis a typing cleanup with no behavior change, easy to drop if you would rather it went in on its own32cf41dadds theentity_uids-on-a-residual tests you asked for, and pins the difference between the two residual views611b50ctrims the prose this branch adds to match the rest of the libraryff28445moves the TPE documentation intodocs/guides/type-aware-partial-evaluation-guide.mdand cuts the README section down to one example and a pointer, following the doctrine you codified in CLAUDE.md while reviewing feat: expose the PST as typed nodes via policies_to_pst #108b4167c6states the TPE contracts where your feat: expose the PST as typed nodes via policies_to_pst #108 changes state the PST ones: the pydocs and Rust doc comments now name the error each entry point raises and its condition,TpeAuthzResultgets the class docstring it lacked, and CLAUDE.md records thetpefeature, theTpeResponseschema borrow, and the two-residual-views gotcha715747bis the same plain-language pass you did on the feat: expose the PST as typed nodes via policies_to_pst #108 prose, applied to this branch's docstrings, doc comments, and the new guideWhat it adds
tpe_authorize, an entry point for type-aware partial evaluation. Use it when you know the type of the principal or resource but not which one: someUser, not yet sure which, rather than a concreteUser::"alice".This is separate from
is_authorized_partial. It does not call it, change it, or share its response shape. The two partial-evaluation implementations in cedar-policy differ in a way that matters here:is_authorized_partial's residuals can hold untypedunknown(...)nodes that PST cannot represent (see #108 and #107), while TPE's residuals are checked against the schema and do convert. Confirmed by callingto_pst()on real output from both.Inputs
principalandresourceeach take one of:'User::"alice"'or{"type": "User", "id": "alice"}"User",pst.EntityType("User"), or{"type": "User"}actionmust be concrete and takes either concrete form. The dict form matters for the same reason it does onis_authorized: it accepts entity ids that Cedar's surface parser rejects as needing normalization.schemais required, unlikeis_authorizedandis_authorized_partialwhere it is optional, because TPE builds its request and entity types against it.contextfollowsis_authorized_partial. Leaving it out, or passingNone, means the context is unknown, so a policy reading it stays residual.{}means a known-empty context. This is whatPartialRequestitself documents (None"will result in a residual for partial evaluation"). An earlier revision of this branch mappedNonetoContext::empty(), which quietly meant known-empty and left no way at all to ask for an unknown context, so the same argument name meant opposite things in the two functions. Fixed in1c73e03, with tests for unknown, explicit, and empty.What comes back
A
TpeAuthzResult.permitsandforbidsstay separate, each aTpeClassificationofresidual_ids,true_ids,false_ids,error_ids.residual_policiesmaps a policy id to acedarpy.pst.Template, the typed node from #108, not JSON.Every failure raises
ValueError. Unlikeis_authorized, there is no decision to fall back to when the input itself cannot be resolved.Binding the unknowns later
The point of TPE is that you re-evaluate only the residuals once you know the rest, not the whole policy set. That was missing from the first revision, which returned the residuals and stopped:
It returns an ordinary
AuthzResult.entitiesdefaults to whatever thetpe_authorizecall ran against. This goes through the engine's ownTpeResponse::reauthorize, so Cedar checks the concrete request and entities against the partial ones first: a request naming a different principal than the one TPE was given raises, rather than returning a decision the partial evaluation never sanctioned.cedarpy.tpe_reauthorize(...)is the same thing as a free function, taking the inputs explicitly.result.residual_policy_setis every residual as a reusablePolicySethandle, for driving evaluation yourself. It drops straight intois_authorized.Which entities a residual still needs
This is what
pst.entity_uidsfrom #108 is for, and the case its own tests did not cover until56ebb65. A residual is apst.Template, so you can ask it what to load before finishing the evaluation:The two views of the residuals are not interchangeable, which is easy to trip over.
residual_policiesholds only the policies still undecided, as the evaluator reduced them, with the concrete parts of the request already substituted in.residual_policy_setholds every residual, including the ones that came out concretely true, false or erroring, each keeping its original scope. So the entities they name differ, and being a handle rather than nodes, the latter needsto_pst()beforeentity_uidswill take it.56ebb65pins all of that.One implementation note:
TpeResponse<'a>borrows the schema, so it cannot be stored in a#[pyclass]without a self-referential struct.reauthorizetherefore re-runs TPE from the inputs the result carries, and then reauthorizes. That keeps the engine's consistency checks, with nounsafe. If you would rather avoid the second TPE pass,residual_policy_setis the cheap path, minus those checks.Entities that are only partly known
A concrete entity set asserts that every entity's attributes, parents, and tags are known. TPE also accepts a document where an entity exists but one of those is not loaded yet, so policies reading it stay residual. That is what lets a caller work out what to fetch before fetching it, and the first revision could not express it:
Each of
attrs,parents, andtagsmust be wholly present or wholly absent per entity, and a parent entity cannot itself have unknown parents. That is the engine's rule, not ours. Plain concrete entities still work exactly as before.The
tpefeatureEnables
tpealongsidepartial-evalinCargo.toml.git diff Cargo.lockis empty:cedar-policy'stpe = ["cedar-policy-core/tpe"]andcedar-policy-core'stpe = []have no dependency edges. 4.12.0 putspartial-evalandtpein the same experimental bucket, and cedarpy already shipspartial-evalas its documented partial-authorization API.Turning
tpeon is also what makespst.ResidualErrorreal, so this PR adds it back as a node type. TPE emits a subexpression it knows will error as a call toerror(), and cedar's PST intercepts that name and returnsExpr::ResidualError, but the interception is behind the feature. #108 drops the node for that reason, and the variant audit it adds is feature-aware, so withtpeenabled here it fails unless the node is modelled. That is the audit doing its job across the two branches.Your #108 review corrected the
psthalf of the 4.12 changelog entry. This branch corrects the other half: the sentence said the engine's breaking changes were confined to featurescedarpydoes not enable, listingtpe, so it now reads "did not then enable".The typing cleanup commit
caa252ais separable and changes no behavior. It bringscedarpy's own annotations in line with what the functions actually accept and return: PEP 604 and 585 syntax throughout, the raw-JSON dicts the result wrappers hold are parameterized (Mapping[str, str]for the@idannotation maps,Mapping[str, int]formetrics) instead of a baredict, and the entities and schema normalization no longer rebinds a parameter to a type its own annotation forbids.cedarpy.__all__now declares the public surface, soimport *stops re-exportingjson,copy, and the typing imports.mypy cedarpy/goes from 10 errors to clean.Testing
This machine has no Rust toolchain any more, so I could not rebuild the extension locally after the rebase. CI is the verification for everything that needs compiled code.
pytest tests/unitpytest tests/integration/test_cedar_integration_tests.pymypy cedarpy/git diff Cargo.lockThe corpus suite and
cargo clippydo not run in CI and I cannot run them here. At the pre-rebase tip they were 60801 passed and 10 clippy warnings, the same 10 as onmain, none in this diff. Read those two as verified at60d097f, not at the current tip. The rebase changed no Rust behavior, only doc comments.The tests cover a type-only resource, a concrete resource resolving allow and deny, a satisfied forbid overriding a satisfied permit while the permit stays in
true_ids, a residual forbid stopping an otherwise-true permit from becoming a decision, the residual as a typed node with a working pattern match, trivial residuals not repeated inresidual_policies,is_authorized_partialbehaving exactly as before, and errors for a missing schema, an unparseable principal, a bare-type action, unparseable policies, and unparseable entities.The later tests cover context unknown against explicit against empty, all four entity-uid input forms plus an id the surface parser rejects,
residual_policy_setfeedingis_authorized, reauthorization reaching allow and deny, a contradicting request being refused, the correlation id surviving, the free function, a hand-built result refusing to reauthorize, unknown entity attributes staying residual, the same entities fully known resolving, and a partial document requiring concrete entities to reauthorize.b4167c6adds one more.PolicySet.from_pstraisesTypeErroron a node tree holdingpst.ResidualError.read_exprhas no arm for it, so that was already the behavior, undocumented and untested.The one example left in the README is byte-identical to
test_type_only_resource_produces_a_residual, so a test covers it. The guide's examples are the ones the README carried before this rebase.Not run:
make benchmark-compare, same reason as #108.