feat: expose the PST as typed nodes via policies_to_pst - #108
Conversation
Adds policies_to_pst, which parses policy text into cedarpy.pst nodes: frozen dataclasses, one per cedar_policy::pst node kind. A consumer pattern-matches on real types instead of walking a dict and matching string keys. Rust walks pst::Expr once (matching what to_json_str already parses, just to a different target) and constructs the corresponding dataclass directly by calling its constructor from Rust. No JSON in between, no second Rust converter: the typed tree is the only thing Rust builds. dataclasses.asdict() and json.dumps() work on the result for free, since the nodes are plain dataclasses. Several pst types are #[non_exhaustive] upstream, so the matches on them keep a wildcard arm regardless. It raises ValueError naming the variant rather than building something silently wrong. Static policies and unlinked templates only. A residual from is_authorized_partial cannot convert this way: PST's own policy type rejects any clause containing an unresolved unknown(...) node, and every non-trivial residual has one. Confirmed by calling to_pst() on real residuals, not from the changelog. is_authorized_partial and its residuals field are untouched. Closes part of k9securityio#107.
Operator names, effect, var and slot names become Literal aliases; Bool and Long literals become distinct nodes; entity type namespaces stay structured; collection fields are fully parameterized; nodes are hashable and their mapping fields are read-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Hi @h0rv - thank you for the PR! I've spent quite a while working to understand this change and I have several questions:
|
Answers 'which entities does this policy reference', which is what a caller needs to decide what to load before evaluating. Walks the fields generically, so a node kind added later is covered. Static policies and templates only, since that is all policies_to_pst produces. The engine computes the same thing per residual policy, but only on a crate-internal type, and a residual cannot become a PST here anyway, so there is nothing to mirror yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A policy set is many small nodes, so dropping the per-instance __dict__ is worth having. Also uses PEP 604/585 syntax directly rather than typing aliases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Unknown` and `ResidualError` were in the `Expr` union but neither is reachable. A `pst::Template`'s clauses are `pub(crate)` and only settable through methods that validate each one, and that validation rejects any clause holding an `Unknown`; scope constraints hold no expression. So no template `policies_to_pst` can receive contains one, which the test now pins to cedar's own error. `ResidualError` only exists with cedar's `tpe` feature, which this build does not enable, so Rust was looking up a class it could never use. Also moves `Mapping` to `collections.abc` and sorts `__all__`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The walk returned an empty frozenset for anything it could not walk, so passing the engine's own `PolicySet` handle instead of a `pst.PolicySet` read as "this policy names no entities". It now takes a node, or a mapping or tuple of nodes, and raises `TypeError` otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PolicySet.from_pst(nodes)` reads the dataclasses back into `cedar_policy::pst` types and hands them to the engine's own `PolicySet::from_pst`, so a set can be taken apart, rewritten, and authorized against. `PolicySet.to_pst()` is the other half, which makes `policies_to_pst(text)` the same thing as `PolicySet.from_str(text).to_pst()`. Reading dispatches on the dataclass name, mirroring the way the forward direction dispatches on the Rust variant. Constructing the pst types directly is allowed: the enums are `#[non_exhaustive]`, which blocks an exhaustive match from outside the crate but not building a known variant. Every one of the 7,551 corpus policies that converts to nodes rebuilds into an identical node tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every pst enum is `#[non_exhaustive]`, so no exhaustive match is possible from outside the crate and the compiler cannot report a variant cedarpy does not model. The audit reads the variant names, and the cargo feature each needs, out of the cedar-policy-core source `Cargo.lock` resolves to, and compares them against a snapshot here. A bump that adds, removes or renames a variant fails and names it. The corpus suite also runs all 7,600 fuzzer-generated policies through `policies_to_pst` and back. That catches a regression in what is already modelled, but not a new variant, since a new variant needs syntax the existing corpus does not contain. Today 7,551 convert and rebuild identically; the 49 that do not are cedar's own PST construction rejecting extension functions called with the wrong arity, none of them the "unrepresentable variant" error cedarpy raises for an unmodelled node. Also fixes the entity_uids output in the README, which showed a uid the policy above it does not name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All six are handled in code. #108 is now
3 and 4. Every pst enum is
Two things I fixed while checking that. |
The changelog entry, README section, and docstrings I added were longer and differently shaped than everything around them. - Changelog: three bullets instead of four, 233 words instead of 471. The design detail moved to the README, and the audit test came out, since a changelog lists user-visible changes. No indented continuation paragraphs, which this changelog does not use anywhere else. - README: unwrapped lines and `*` bullets to match the surrounding sections, examples that assert their own results the way the other sections do, and no colons joining clauses. All four code blocks were executed as written. - Docstrings: cut the rationale down to the part a reader needs. The `PolicySet` class docstring now lists `from_pst` alongside the other two constructors. - Dropped a banner comment in lib.rs; nothing else in the file uses one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the substantial updates, which I think improved the PR a lot. I'll review and share feedback. |
The pst module tracks the Cedar engine rather than cedarpy's usual pure-additive API stability: the mirrored Rust enums are non_exhaustive and grow with the language, so engine bumps may add node types, fields, or Literal members in cedarpy minor releases, and unmodelled syntax raises ValueError until modelled. Stated in the module docstring (full detail), the to_pst/policies_to_pst pydocs (one line, pointing at the module docs), and the README (one line). Co-Authored-By: Claude
Measured on the v4.12.0 corpus: 7,551 of 7,600 policies convert, and all 49 cedar-side rejections are one shape - extension functions called with the wrong arity (unchecked at parse, checked at PST construction). Assert that shape per rejection instead of a 0.99 conversion floor: the floor let up to 76 unexplained cedar-side failures pass silently, while the category check fails on the first rejection of any new kind, named. Corpus shrinkage is guarded directly (> 7000 files). Co-Authored-By: Claude
| def policies_to_pst(s: str) -> "cedarpy.pst.PolicySet": ... | ||
|
|
||
|
|
||
| def validate_policies(policies: str, schema: Union[str, "Schema"]) -> str: ... |
| """Parse Cedar policy text into typed PST nodes from cedarpy.pst.""" | ||
| return _internal.policies_to_pst(policies) | ||
|
|
||
|
|
Co-Authored-By: Claude
Co-Authored-By: Claude
|
|
||
| for root in roots: | ||
| walk(root) | ||
| return frozenset(found) |
| "VariadicOp": { | ||
| "IsInRange": null | ||
| } | ||
| } |
README: 'mirroring' participle tail becomes a clause; residuals are 'represented', not 'parsed'; the Mapping/FrozenMap sentence splits at its garden path. pst.py: normalize the contract paragraph to the module's single-backtick style; unknot the FrozenMap dependency rationale's trailing clause. Co-Authored-By: Claude
The README serves engineers new to cedar-py: what the library can do and one clear example per feature, with advanced material in docs/guides (the pattern partial-authorization-guide.md set). The two PST sections shrink to one section with the match example, the engine-tracking line, and a pointer to the new docs/guides/policy-syntax-tree-guide.md, which carries the closed-set guarantees, node value semantics, entity_uids, and the from_pst rewrite example in full. Co-Authored-By: Claude
Codifies the README quick-start doctrine (advanced features get a short section plus a docs/guides guide) and the prose rules applied in the PR k9securityio#108 copy review, so contributions start from the standard rather than getting edited toward it. Co-Authored-By: Claude
Cedar API gotchas gains the from_pst/to_pst facts verified during the PR k9securityio#108 review: the lossless cache makes to_pst(from_pst(x)) == x trivially true, and upstream PolicySet equality is source_loc-sensitive, so fidelity tests must go through rendered text or compare at the PST level. Cedar engine upgrades gains the pst-mirror maintenance step (converter arms, pst.py types, regenerated variant snapshot). Co-Authored-By: Claude
Lead with the solution (inspect policies as a typed tree) rather than the function name, per the copy standard's quick-start doctrine. Co-Authored-By: Claude
…line 'New engine variants' was Rust-enum jargon a cedar-py user has no way to interpret. The line now names what the reader experiences: engine upgrades can bring Cedar Policy syntax changes, the cedarpy.pst mirror grows to model them, and unmodelled syntax raises ValueError until it does. Co-Authored-By: Claude
Co-Authored-By: Claude
Both conversion directions recurse once per expression level, and deep enough input overflows the stack: a RecursionError from Python's walk, a process abort from the Rust builder. build_expr and read_expr now track depth and raise ValueError past 100 levels, enforced symmetrically so any tree to_pst produces, from_pst accepts. entity_uids's walk becomes iterative (explicit work stack), so reading hand-built trees has no depth ceiling at all. The limit is a behavioral contract: raising it later is backwards compatible; lowering it would be breaking. 100 gives ~6x headroom over the deepest expression in the fuzzer corpus (17 tree levels across 7,551 policies, measured 2026-09-01). The boundary is pinned by unit tests (98 nested sets convert, 99 raise) and documented in the module docstring, function pydocs, the guide, and the changelog. Verified: unit 274, integration 78, corpus 60,801 all pass. Co-Authored-By: Claude
Fix the stale README pointer (the node types and guarantees moved to the Policy Syntax Tree Guide), credit @h0rv with the PR k9securityio#108 link per the contribution convention, correct the engine-upgrade entry's claim that pst is an experimental feature (it is unconditionally public; cedarpy simply did not use it at 4.8.2), and reorder the Unreleased sections to Keep a Changelog order (Added, Changed, Removed, Security). Co-Authored-By: Claude
|
Hi @h0rv - thank you for the updates. I reviewed the revised PR and it was very good. Rather than round-trip more feedback, I pushed my review changes directly to your branch ( Behavior change (the one to look at first):
Tests:
Docs:
Everything verified locally at the pushed tip: release build, 274 unit / 78 integration / 60,801 corpus tests pass. Please flag anything you'd have done differently - particularly the depth limit, since it constrains input your applications might produce. |
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".
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.
Rebased on current
main. Nine commits, all part of this change.The last five are a response to @skuenzli's review, and I have answered the questions on the PR itself. Short version: the two node types nothing could produce are gone,
entity_uidsno longer reports an empty result for input it cannot walk, the round trip he asked about is here, and there is now a test that fails CI when cedar-policy adds a PST variant.What it adds
policies_to_pst(policies) -> cedarpy.pst.PolicySet. It parses Cedar policy text and hands back the PST as typed Python objects, one frozen dataclass percedar_policy::pstnode kind. You match on real types instead of digging through string keys:Rust builds the objects directly. It walks
pst::Expronce and calls the matching Python constructor for each variant, with the child nodes already built. There is no JSON step and no second walker.PolicySet.from_pst(nodes)andPolicySet.to_pst()are the other direction, so a set can be taken apart as nodes, changed, and handed back to the engine:Reading back needs no crate internals:
cedar_policy::Policy::from_pst,Template::from_pstandPolicySet::from_pstare all public in 4.12. The enums are#[non_exhaustive], which blocks an exhaustive match from outside the crate but not constructing a variant that exists, so the reader buildspst::Exprvalues directly and lets the engine's ownfrom_pstdo the validating.policies_to_pst(text)is now justPolicySet.from_str(text).to_pst().Why typed objects instead of JSON
Cedar already checked all of this on the Rust side. A clause cannot hold a slot. A clause cannot hold an unknown. Every expression is well formed. Handing back a JSON string throws those guarantees away and makes every caller re-check them by matching on strings, usually getting it wrong. A typed object carries the guarantee in the type, so there is nothing left to re-check.
The same idea drives the rest of the design: anything Cedar models as a closed set stays a closed set in Python, so a type checker can catch a mistake instead of a caller finding it at runtime.
Operator names are closed.
UnaryOp.opandBinaryOp.opareLiteralaliases listing every name Rust can emit (UnaryOpName, 18 names;BinaryOpName, 24).Template.effect,Var.name, andSlot.nameare the same. So amatchover one can be proven exhaustive withassert_never, and a typo likeop == "equals"is a type error rather than a branch that never runs.BoolandLongliterals are separate nodes.boolis a subclass ofintin Python, so one node holding either could not tell Cedar'sBoolfrom itsLong:isinstance(v, int)is true forTrue. They areBoolLitandLongLit, alongsideStringLitandEntityLit.Entity type names keep their namespace.
EntityType(basename="User", namespace=("MyApp",))instead of the string"MyApp::User", so no caller has to split on::to get the namespace back.str()gives the Cedar form.Every collection is parameterized.
tuple[Expr, ...],tuple[Clause, ...], and so on. A baretupleistuple[Any, ...], which means the union evaporates as soon as you index into it.HasAttr.attrsrejects an empty path, which is theNonEmpty<SmolStr>invariant Rust holds.Nodes are values. Frozen, slotted (a policy set is many small objects, and there is no reason for each to carry a
__dict__), and hashable, so they work as dict keys and set members. Mapping fields are declaredMapping, which has no mutating methods, and hold aFrozenMapat runtime, which is adictsubclass that raises on mutation. So a write torecord.fieldsfails both in the type checker and at runtime, whiledataclasses.asdict(),json.dumps(), and comparing against a plain dict all still work.pst.entity_uids(node)returns every entity uid named anywhere under a node, at any depth, which is what you need in order to decide which entities to load before evaluating. It takes a node, or a mapping or tuple of nodes such as aPolicySet'stemplates. Anything else raisesTypeError, rather than returning an empty frozenset that reads as "this policy names no entities". That mattered: passing the engine's ownPolicySethandle instead of apst.PolicySetused to silently report nothing.__all__declares the module's public surface.Keeping up with cedar-policy
Every pst enum is
#[non_exhaustive], so no exhaustive match is possible from outside the crate and the compiler cannot tell us when a variant is added. Two things stand in for that.tests/unit/test_pst_variant_coverage.pyreads the variant names, and the cargo feature each one needs, out of thecedar-policy-coresource thatCargo.lockresolves to, and compares them againstpst_variants.jsonin the repo. A bump that adds, removes or renames a variant fails and names it. It is feature-aware, because which variants exist depends on whatCargo.tomlturns on:ResidualErrorneedstpe,VariadicOpneedsvariadic-is-in-range. The modelled side is read offcedarpy.pst.Exprrather than listed in the test, so a node type added to the union without a matching Rust variant fails too. Regenerate withpython tests/unit/test_pst_variant_coverage.pyafter deciding what to do about the new variant.The corpus suite also runs all 7,600 fuzzer-generated policies through
policies_to_pstand back throughfrom_pst. That catches a regression in what is already modelled, though not a new variant, since a new variant needs syntax the existing corpus does not contain. Today 7,551 convert and rebuild into an identical node tree. The other 49 are cedar's own PST construction rejecting extension functions called with the wrong arity (lessThanwith 5 arguments,decimalwith 4, and so on); none is the "unrepresentable variant" error this code raises for a node kind it does not model, which is what the test actually asserts.What this does not cover
Static policies and unlinked templates only. A residual from
is_authorized_partialcannot be represented this way.pst::Template's clauses arepub(crate)and can only be added through methods that validate each one, and that validation rejects any clause holding an unresolvedunknown(...)node. Every non-trivial residual fromis_authorized_partialhas one. Confirmed by callingto_pst()on real residuals for a permit and a forbid; both raised.For the same reason
Unknownis not a node type here any more, and neither isResidualError: that variant is behind cedar'stpefeature, which this PR does not enable, so it could not occur at all.ResidualErrorarrives in #109, where the feature is on and TPE really does emit it.is_authorized_partialandresidualsare untouched.Notes for review
pst::Exprand several of its operator enums are#[non_exhaustive]upstream, so the Rust match needs a wildcard arm no matter what. That arm raisesValueErrornaming the variant it could not handle. It should never fire on this cedar-policy version. It is there so a future engine bump that adds a variant fails loudly instead of quietly building the wrong thing, and the audit above is what turns that into a CI failure rather than a runtime surprise.No Cargo change is needed. There is no
pstcargo feature:cedar_policy::pstis re-exported ungated in 4.12. Addingfeatures = ["pst"]fails to resolve, and cargo helpfully points attpeas the similar name, which is #109's feature.I looked at pyo3 complex enums first (a
#[pyclass]on a Rust enum with struct variants). They work, including the recursive case withPy<T>fields, and pyo3 generates__match_args__so both keyword and positional patterns match. I did not use them: calling a plain Python constructor from Rust gives real dataclasses with no intermediate representation, andasdict/json.dumpscome for free. A pyo3 complex enum would need a hand-written dict conversion instead.One thing worth knowing if you touch the mapping fields:
types.MappingProxyTypecannot be used here, becausedataclasses.asdict()callscopy.deepcopyand amappingproxycannot be pickled. That is whyFrozenMapsubclassesdictrather than wrapping one.The audit test decodes
cargo metadataas UTF-8 explicitly rather than usingsubprocesstext=True. Cargo's metadata is UTF-8 and is not ASCII-only, andtext=Truedecodes with the locale codec, which fails partway through on a Windows runner. That is a real failure I hit on CI here, not a precaution.Testing
Built with
maturin developon macOS arm64, Python 3.12.13.pytest tests/unitpytest tests/integration/test_cedar_integration_tests.pypytest tests/integration/test_cedar_corpus_tests.pymypy --strict cedarpy/pst.pycargo clippy --all-targetsmain, none in this diffTests cover every clause and scope constraint shape, every literal kind, set and record,
has(including the multi-attribute path, checked against real parser output:resource has a.blowers to two chained single-attribute nodes, not one node holding two attrs),like,is/is in,if/then/else, operators, a template with a slot, keyword and positional pattern matching,asdict/json.dumps, and error handling. Plus the namespaced entity type,BoolLitagainstLongLit, every emitted operator name being present in itsLiteralalias, mapping fields refusing mutation, nodes working as dict keys, the emptyHasAttrpath being rejected, andentity_uidsacross scope, conditions, sets, records and template links.The new ones cover the round trip (static policies, a template with slots, a linked template, an edited node tree that authorizes differently afterwards, and both error paths),
entity_uidsrefusing what it cannot walk, andUnknownbeing rejected by cedar rather than modelled here.I checked the typing claims with mypy rather than assuming them. An exhaustive
matchoverExprending inassert_neverpasses, and each of these is an error:op="nope",Var("principle"),EntityUid(type="User", ...), alistwhere a tuple belongs,e.op == "equals", and writing to a mapping field.Not run:
make benchmark-compare. The repo's own guidance says that comparison is load-sensitive and the machine was busy.