Skip to content

feat: expose the PST as typed nodes via policies_to_pst - #108

Draft
h0rv wants to merge 2 commits into
k9securityio:mainfrom
h0rv:feat/pst-json
Draft

feat: expose the PST as typed nodes via policies_to_pst#108
h0rv wants to merge 2 commits into
k9securityio:mainfrom
h0rv:feat/pst-json

Conversation

@h0rv

@h0rv h0rv commented Aug 21, 2026

Copy link
Copy Markdown

This includes and is blocked by #106. It cannot merge before that one does. Base is main; the diff to review is the last commit only.

What

Adds policies_to_pst(policies) -> cedarpy.pst.PolicySet. It parses Cedar policy text and returns the PST as typed nodes. Each node kind is a frozen Python dataclass, one per cedar_policy::pst node. A consumer pattern-matches on real types:

match clause.expr:
    case BinaryOp(op="eq", left=GetAttr(base=Var(name="resource"), attr="status")):
        ...

Rust builds these nodes directly. It walks pst::Expr once and, for each variant, calls the matching Python dataclass constructor from Rust with the child nodes already built. There is no JSON step and no second walker. dataclasses.asdict() and json.dumps() already work on the result, since the nodes are plain dataclasses.

Why a typed model instead of JSON

Cedar already checked this on the Rust side. A clause cannot contain a slot. A clause cannot contain an unknown. Every expression is well formed. A JSON string throws that away and makes every consumer re-check it, usually wrong, by matching on string keys. A typed object carries the guarantee in the type, so there is nothing left to re-check.

What this does not cover

Static policies and unlinked templates only. A residual from is_authorized_partial cannot be represented this way. pst::Template's own constructor rejects any clause containing an unresolved unknown(...) node, and every non-trivial residual from is_authorized_partial has one. I confirmed this by calling to_pst() on real residuals for a permit and a forbid; both raised. The claim in the changelog that residuals convert to PST is true only for the separate tpe feature. See #107.

is_authorized_partial and residuals are untouched.

pyo3 notes

pst::Expr and several of its operator enums are #[non_exhaustive] upstream, so the Rust match on them needs a wildcard arm regardless of what this PR wants. That arm raises ValueError naming the variant. It should not fire against this cedar-policy version; it exists so a future engine bump that adds a variant fails loudly instead of building something wrong.

I looked at pyo3 complex enums first (#[pyclass] on a Rust enum with struct variants). They work, including the recursive case with Py<T> fields, and pyo3 auto-generates __match_args__ so both keyword and positional patterns work. I did not use them here: calling a plain Python class constructor from Rust gives real dataclasses with no intermediate representation, and asdict/json.dumps come for free. A pyo3 complex enum would need a hand-written dict conversion instead.

One thing I ran into: types.MappingProxyType breaks dataclasses.asdict() (it calls copy.deepcopy, which cannot pickle a mappingproxy). Mapping fields (annotations, templates, static_policies, slot values) are plain dict, typed as Mapping in the annotation to say they should not be mutated, without runtime enforcement.

Testing

Built with maturin develop --release on macOS arm64, Python 3.14.

Suite Result
pytest tests/unit 242 passed, 2 subtests (218 baseline + 24 new)
pytest tests/integration/test_cedar_integration_tests.py 74 passed
pytest tests/integration/test_cedar_corpus_tests.py 59696 passed
cargo clippy --release 10 warnings, all pre-existing on main, none in this diff

New tests 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.b lowers to two chained single-attribute nodes, not one node with two attrs), like, is/is in, if/then/else, several operators, a template with a slot, keyword and positional structural pattern matching, asdict/json.dumps, frozen enforcement, and error handling.

Not run: make benchmark-compare. The build machine was busy throughout, and the repo's own guidance says the comparison is load-sensitive.

Marked draft because it depends on #106.

Cargo.toml already allows this (caret on 4.8.2); only the lock held it
back. No source changes were needed.

Transitive churn is Cedar's own: jiff/jiff-tzdb/jiff-tzdb-platform back
the datetime extension added upstream, and bs58, itertools, syn 3,
rustc-literal-escaper, portable-atomic-util and windows-sys 0.61.2 come
with it. beef, lazy_static, rustc_lexer and rustc_version drop out.
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.
@h0rv h0rv changed the title feat: expose the PST as JSON for static policies and templates feat: expose the PST as typed nodes via policies_to_pst Aug 21, 2026
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.

1 participant