From 0fde31c1ad44d9dcffd074c136db230a7200e9be Mon Sep 17 00:00:00 2001 From: robby Date: Fri, 21 Aug 2026 13:53:31 -0400 Subject: [PATCH 1/3] deps: update cedar-policy to 4.12.0 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. --- Cargo.lock | 268 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 160 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da9d6d1..11614fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -81,7 +81,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -141,12 +141,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "beef" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" - [[package]] name = "bit-set" version = "0.8.0" @@ -186,6 +180,15 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -204,14 +207,15 @@ dependencies = [ [[package]] name = "cedar-policy" -version = "4.8.2" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8c84f46d7feed570e52cd93b0f1e3ce7f57e2ac61712fba0d1318f84c96a99b" +checksum = "f73547a0114dff845fcb8d4877548a665fb8ff949a30cb0963444d6c30ffd962" dependencies = [ "cedar-policy-core", "cedar-policy-formatter", - "itertools", + "itertools 0.15.0", "linked-hash-map", + "linked_hash_set", "miette", "ref-cast", "semver", @@ -224,9 +228,9 @@ dependencies = [ [[package]] name = "cedar-policy-cli" -version = "4.8.2" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b104955337acdc0574eee1aad08c02559706dcd5a22f94ec8c6f7b01fcd19f5f" +checksum = "7fd56e71cfb7bad516acc36499a54fa1dac39e411127f2f58f1dd8f023a69b8a" dependencies = [ "cedar-policy", "cedar-policy-formatter", @@ -241,14 +245,14 @@ dependencies = [ [[package]] name = "cedar-policy-core" -version = "4.8.2" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8011d10d2ffa8ee4497d4d7234d0d4e97f52a6e6e66a1150c5c3409ba7fe1b5c" +checksum = "c9d9ec16707c7b60aa73b922dc255ec0b97b6892e8e58e45188b15d874ca3590" dependencies = [ "chrono", "educe", "either", - "itertools", + "itertools 0.15.0", "lalrpop", "lalrpop-util", "linked-hash-map", @@ -257,7 +261,7 @@ dependencies = [ "nonempty", "ref-cast", "regex", - "rustc_lexer", + "rustc-literal-escaper", "serde", "serde_json", "serde_with", @@ -269,12 +273,12 @@ dependencies = [ [[package]] name = "cedar-policy-formatter" -version = "4.8.2" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29faad4ed540d812bc213b1228de39a34731730bca6dd51a8086796461415c0" +checksum = "2483b2fa74f74b1b3945bec59feb7a3fc0ea56b6fb35564ef300884dd8415d4b" dependencies = [ "cedar-policy-core", - "itertools", + "itertools 0.15.0", "logos", "miette", "pretty", @@ -309,9 +313,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -350,7 +354,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] @@ -392,9 +396,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -402,27 +406,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.91", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.91", ] [[package]] @@ -453,21 +456,21 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "educe" -version = "0.6.0" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +checksum = "e451fac8dd8dece16234604bf1efce6e90fddd8ab6ad4d66eec0eca5160959dd" dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "ena" @@ -480,22 +483,22 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -511,7 +514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -646,7 +649,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -670,12 +673,50 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +[[package]] +name = "jiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3590fea8e9e22d449600c9bbd481a8163bef223e4ff938e5f55899f8cf1adb93" +dependencies = [ + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.76" @@ -704,7 +745,7 @@ dependencies = [ "ascii-canvas", "bit-set", "ena", - "itertools", + "itertools 0.14.0", "lalrpop-util", "petgraph", "pico-args", @@ -727,12 +768,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.169" @@ -781,34 +816,32 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "logos" -version = "0.15.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" dependencies = [ "logos-derive", ] [[package]] name = "logos-codegen" -version = "0.15.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" dependencies = [ - "beef", "fnv", - "lazy_static", "proc-macro2", "quote", + "regex-automata", "regex-syntax", - "rustc_version", - "syn", + "syn 2.0.91", ] [[package]] name = "logos-derive" -version = "0.15.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" dependencies = [ "logos-codegen", ] @@ -856,7 +889,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] @@ -915,9 +948,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" dependencies = [ "supports-color 2.1.0", "supports-color 3.0.2", @@ -977,6 +1010,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1063,7 +1105,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.91", ] [[package]] @@ -1076,14 +1118,14 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn", + "syn 2.0.91", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1114,14 +1156,14 @@ checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1131,9 +1173,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1142,9 +1184,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc-demangle" @@ -1153,22 +1195,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] -name = "rustc_lexer" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" -dependencies = [ - "unicode-xid", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" +name = "rustc-literal-escaper" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] +checksum = "bfe6f213fb658c8fb95baabd5420393438cf5a98d707f5dd701d9197c705f71e" [[package]] name = "rustix" @@ -1180,7 +1210,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -1236,9 +1266,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1267,7 +1297,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] @@ -1286,15 +1316,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.7.0", + "jiff", "schemars 0.9.0", "schemars 1.1.0", "serde_core", @@ -1305,14 +1337,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] @@ -1355,15 +1387,15 @@ dependencies = [ [[package]] name = "stacker" -version = "0.1.22" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" dependencies = [ "cc", "cfg-if", "libc", "psm", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1427,6 +1459,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "target-lexicon" version = "0.13.3" @@ -1439,7 +1482,7 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2111ef44dae28680ae9752bb89409e7310ca33a8c621ebe7b106cf5c928b3ac0" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -1449,7 +1492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -1479,7 +1522,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] @@ -1644,7 +1687,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.91", "wasm-bindgen-shared", ] @@ -1666,7 +1709,7 @@ checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1683,7 +1726,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -1710,6 +1753,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" From 02e359f7f8f6289175dd4299601dcd1bbd0bb4d1 Mon Sep 17 00:00:00 2001 From: robby Date: Fri, 21 Aug 2026 14:54:48 -0400 Subject: [PATCH 2/3] feat: expose the PST as typed nodes via policies_to_pst 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 #107. --- CHANGELOG.md | 4 + README.md | 28 ++++ cedarpy/__init__.py | 6 + cedarpy/_internal.pyi | 3 + cedarpy/pst.py | 191 ++++++++++++++++++++++ src/lib.rs | 363 ++++++++++++++++++++++++++++++++++++++++- tests/unit/test_pst.py | 194 ++++++++++++++++++++++ 7 files changed, 788 insertions(+), 1 deletion(-) create mode 100644 cedarpy/pst.py create mode 100644 tests/unit/test_pst.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf8fcf..b42b934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `policies_to_pst(policies)` parses Cedar policy text into typed `cedarpy.pst` nodes (frozen dataclasses, one per `cedar_policy::pst` node kind), matching a real Cedar structure a consumer can pattern-match on instead of an untyped tree keyed by string operators. Static policies and unlinked templates only; a partial-evaluation residual from `is_authorized_partial` cannot be represented this way, because PST's own policy type rejects any clause containing an unresolved `unknown(...)` node ([#107](https://github.com/k9securityio/cedar-py/issues/107)). Requires cedar-policy 4.11.0+, bundled by [#106](https://github.com/k9securityio/cedar-py/pull/106). + ## [4.8.7] - 2026-07-10 ### Added diff --git a/README.md b/README.md index 172d78a..af69d6c 100644 --- a/README.md +++ b/README.md @@ -438,6 +438,34 @@ print(format_policies(policies)) # when { resource.owner == principal }; ``` +### Inspecting policies as a typed tree + +`policies_to_pst` parses policy text into typed nodes from `cedarpy.pst`, matching +`cedar_policy::pst`. Use structural pattern matching instead of dict keys. + +```python +from cedarpy import policies_to_pst +from cedarpy.pst import BinaryOp, GetAttr, Var + +policies = """ + permit(principal, action == Action::"view", resource) + when { resource.owner == principal }; +""" + +result = policies_to_pst(policies) +clause = result.static_policies["policy0"].clauses[0] +match clause.expr: + case BinaryOp(op="eq", left=GetAttr(attr="owner"), right=Var(name="principal")): + print("matched") +``` + +Static policies and unlinked templates only. A residual from `is_authorized_partial` +cannot be parsed this way: PST rejects any clause containing an unresolved +`unknown(...)` node, and every non-trivial residual has one. + +`dataclasses.asdict(result)` and `json.dumps(...)` work directly, since the nodes +are plain frozen dataclasses. + ## Developing diff --git a/cedarpy/__init__.py b/cedarpy/__init__.py index 660382f..2c3da06 100644 --- a/cedarpy/__init__.py +++ b/cedarpy/__init__.py @@ -4,6 +4,7 @@ from typing import Union, List, Optional, Any from cedarpy import _internal +from cedarpy import pst # Re-export the Rust-implemented PolicySet and Schema handles. PolicySet = _internal.PolicySet @@ -358,6 +359,11 @@ def policies_from_json_str(policies: str) -> str: return _internal.policies_from_json_str(policies) +def policies_to_pst(policies: str) -> "pst.PolicySet": + """Parse Cedar policy text into typed PST nodes from cedarpy.pst.""" + return _internal.policies_to_pst(policies) + + class PartialDiagnostics(_DiagnosticsBase): """Diagnostics for a partial-evaluation authorization decision. diff --git a/cedarpy/_internal.pyi b/cedarpy/_internal.pyi index 33b8c06..d26a34d 100644 --- a/cedarpy/_internal.pyi +++ b/cedarpy/_internal.pyi @@ -269,4 +269,7 @@ def policies_to_json_str(s: str) -> str: ... def policies_from_json_str(s: str) -> str: ... +def policies_to_pst(s: str) -> "cedarpy.pst.PolicySet": ... + + def validate_policies(policies: str, schema: Union[str, "Schema"]) -> str: ... diff --git a/cedarpy/pst.py b/cedarpy/pst.py new file mode 100644 index 0000000..07f4f58 --- /dev/null +++ b/cedarpy/pst.py @@ -0,0 +1,191 @@ +"""Typed PST nodes, mirroring cedar_policy::pst. Built by Rust, not parsed.""" +from dataclasses import dataclass +from typing import Mapping, Union + + +@dataclass(frozen=True) +class EntityUid: + type: str + id: str + + +@dataclass(frozen=True) +class Slot: + name: str # "principal" or "resource" + + +EntityOrSlot = Union[EntityUid, Slot] + + +@dataclass(frozen=True) +class Var: + name: str # "principal", "action", "resource", or "context" + + +@dataclass(frozen=True) +class Literal: + value: Union[bool, int, str, EntityUid] + + +@dataclass(frozen=True) +class UnaryOp: + op: str + arg: "Expr" + + +@dataclass(frozen=True) +class BinaryOp: + op: str + left: "Expr" + right: "Expr" + + +@dataclass(frozen=True) +class GetAttr: + base: "Expr" + attr: str + + +@dataclass(frozen=True) +class HasAttr: + base: "Expr" + attrs: tuple + + +@dataclass(frozen=True) +class Char: + value: str + + +@dataclass(frozen=True) +class Wildcard: + pass + + +PatternElem = Union[Char, Wildcard] + + +@dataclass(frozen=True) +class Like: + base: "Expr" + pattern: tuple # tuple[PatternElem, ...] + + +@dataclass(frozen=True) +class Is: + base: "Expr" + entity_type: str + in_expr: "Expr | None" + + +@dataclass(frozen=True) +class IfThenElse: + cond: "Expr" + then_expr: "Expr" + else_expr: "Expr" + + +@dataclass(frozen=True) +class Set: + elements: tuple # tuple[Expr, ...] + + +@dataclass(frozen=True) +class Record: + fields: Mapping[str, "Expr"] + + +@dataclass(frozen=True) +class Unknown: + name: str + + +@dataclass(frozen=True) +class ResidualError: + pass # TPE residual subexpression known to error if evaluated + + +Expr = Union[ + Var, Slot, Literal, UnaryOp, BinaryOp, GetAttr, HasAttr, Like, Is, + IfThenElse, Set, Record, Unknown, ResidualError, +] + + +@dataclass(frozen=True) +class When: + expr: Expr + + +@dataclass(frozen=True) +class Unless: + expr: Expr + + +Clause = Union[When, Unless] + + +@dataclass(frozen=True) +class ScopeAny: + pass + + +@dataclass(frozen=True) +class ScopeEq: + entity: EntityOrSlot + + +@dataclass(frozen=True) +class ScopeIn: + entity: EntityOrSlot + + +@dataclass(frozen=True) +class ScopeIs: + entity_type: str + + +@dataclass(frozen=True) +class ScopeIsIn: + entity_type: str + entity: EntityOrSlot + + +PrincipalOrResourceConstraint = Union[ScopeAny, ScopeEq, ScopeIn, ScopeIs, ScopeIsIn] + + +@dataclass(frozen=True) +class ActionEq: + entity: EntityUid + + +@dataclass(frozen=True) +class ActionIn: + entities: tuple # tuple[EntityUid, ...] + + +ActionConstraint = Union[ScopeAny, ActionEq, ActionIn] + + +@dataclass(frozen=True) +class Template: + id: str + effect: str # "permit" or "forbid" + principal: PrincipalOrResourceConstraint + action: ActionConstraint + resource: PrincipalOrResourceConstraint + clauses: tuple # tuple[Clause, ...] + annotations: Mapping[str, str] + + +@dataclass(frozen=True) +class TemplateLink: + template_id: str + new_id: str + values: Mapping[str, EntityUid] + + +@dataclass(frozen=True) +class PolicySet: + templates: Mapping[str, Template] + static_policies: Mapping[str, Template] + template_links: tuple # tuple[TemplateLink, ...] diff --git a/src/lib.rs b/src/lib.rs index 35299c9..3d24586 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,8 @@ use anyhow::{Context as _, Error, Result}; use cedar_policy::*; use cedar_policy_formatter::{Config, policies_str_to_pretty}; use pyo3::prelude::*; -use pyo3::types::{PyDict, PyString}; +use pyo3::types::{PyDict, PyString, PyTuple}; +use cedar_policy::pst; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -56,6 +57,365 @@ fn policies_from_json_str(s: String) -> PyResult { } } +struct PstClasses<'py> { + entity_uid: Bound<'py, PyAny>, + slot: Bound<'py, PyAny>, + var: Bound<'py, PyAny>, + literal: Bound<'py, PyAny>, + unary_op: Bound<'py, PyAny>, + binary_op: Bound<'py, PyAny>, + get_attr: Bound<'py, PyAny>, + has_attr: Bound<'py, PyAny>, + char: Bound<'py, PyAny>, + wildcard: Bound<'py, PyAny>, + like: Bound<'py, PyAny>, + is_: Bound<'py, PyAny>, + if_then_else: Bound<'py, PyAny>, + set: Bound<'py, PyAny>, + record: Bound<'py, PyAny>, + unknown: Bound<'py, PyAny>, + #[allow(dead_code)] // read once the tpe feature is on + residual_error: Bound<'py, PyAny>, + when: Bound<'py, PyAny>, + unless: Bound<'py, PyAny>, + scope_any: Bound<'py, PyAny>, + scope_eq: Bound<'py, PyAny>, + scope_in: Bound<'py, PyAny>, + scope_is: Bound<'py, PyAny>, + scope_is_in: Bound<'py, PyAny>, + action_eq: Bound<'py, PyAny>, + action_in: Bound<'py, PyAny>, + template: Bound<'py, PyAny>, + template_link: Bound<'py, PyAny>, + policy_set: Bound<'py, PyAny>, +} + +impl<'py> PstClasses<'py> { + fn load(py: Python<'py>) -> PyResult { + let m = PyModule::import(py, "cedarpy.pst")?; + let get = |name: &str| m.getattr(name); + Ok(Self { + entity_uid: get("EntityUid")?, + slot: get("Slot")?, + var: get("Var")?, + literal: get("Literal")?, + unary_op: get("UnaryOp")?, + binary_op: get("BinaryOp")?, + get_attr: get("GetAttr")?, + has_attr: get("HasAttr")?, + char: get("Char")?, + wildcard: get("Wildcard")?, + like: get("Like")?, + is_: get("Is")?, + if_then_else: get("IfThenElse")?, + set: get("Set")?, + record: get("Record")?, + unknown: get("Unknown")?, + residual_error: get("ResidualError")?, + when: get("When")?, + unless: get("Unless")?, + scope_any: get("ScopeAny")?, + scope_eq: get("ScopeEq")?, + scope_in: get("ScopeIn")?, + scope_is: get("ScopeIs")?, + scope_is_in: get("ScopeIsIn")?, + action_eq: get("ActionEq")?, + action_in: get("ActionIn")?, + template: get("Template")?, + template_link: get("TemplateLink")?, + policy_set: get("PolicySet")?, + }) + } +} + +fn pst_error(context: &str, value: T) -> PyErr { + pyo3::exceptions::PyValueError::new_err(format!( + "cedarpy: unrepresentable {context}: {value:?} (this cedar-policy release added a variant policies_to_pst does not yet handle)" + )) +} + +fn build_mapping<'py>( + py: Python<'py>, + items: impl IntoIterator)>, +) -> PyResult> { + let dict = PyDict::new(py); + for (k, v) in items { + dict.set_item(k, v)?; + } + Ok(dict.unbind().into()) +} + +fn build_entity_uid<'py>(c: &PstClasses<'py>, euid: &pst::EntityUID) -> PyResult> { + Ok(c.entity_uid.call1((euid.ty.0.to_string(), euid.eid.to_string()))?.unbind()) +} + +fn build_slot_id<'py>(c: &PstClasses<'py>, slot: pst::SlotId) -> PyResult> { + let name = match slot { + pst::SlotId::Principal => "principal", + pst::SlotId::Resource => "resource", + other => return Err(pst_error("SlotId variant", other)), + }; + Ok(c.slot.call1((name,))?.unbind()) +} + +fn build_entity_or_slot<'py>(c: &PstClasses<'py>, eos: &pst::EntityOrSlot) -> PyResult> { + match eos { + pst::EntityOrSlot::Entity(euid) => build_entity_uid(c, euid), + pst::EntityOrSlot::Slot(slot) => build_slot_id(c, *slot), + } +} + +fn build_principal_constraint<'py>( + c: &PstClasses<'py>, + constraint: &pst::PrincipalConstraint, +) -> PyResult> { + match constraint { + pst::PrincipalConstraint::Any => Ok(c.scope_any.call0()?.unbind()), + pst::PrincipalConstraint::Eq(eos) => Ok(c.scope_eq.call1((build_entity_or_slot(c, eos)?,))?.unbind()), + pst::PrincipalConstraint::In(eos) => Ok(c.scope_in.call1((build_entity_or_slot(c, eos)?,))?.unbind()), + pst::PrincipalConstraint::Is(et) => Ok(c.scope_is.call1((et.0.to_string(),))?.unbind()), + pst::PrincipalConstraint::IsIn(et, eos) => { + Ok(c.scope_is_in.call1((et.0.to_string(), build_entity_or_slot(c, eos)?))?.unbind()) + } + } +} + +fn build_resource_constraint<'py>( + c: &PstClasses<'py>, + constraint: &pst::ResourceConstraint, +) -> PyResult> { + match constraint { + pst::ResourceConstraint::Any => Ok(c.scope_any.call0()?.unbind()), + pst::ResourceConstraint::Eq(eos) => Ok(c.scope_eq.call1((build_entity_or_slot(c, eos)?,))?.unbind()), + pst::ResourceConstraint::In(eos) => Ok(c.scope_in.call1((build_entity_or_slot(c, eos)?,))?.unbind()), + pst::ResourceConstraint::Is(et) => Ok(c.scope_is.call1((et.0.to_string(),))?.unbind()), + pst::ResourceConstraint::IsIn(et, eos) => { + Ok(c.scope_is_in.call1((et.0.to_string(), build_entity_or_slot(c, eos)?))?.unbind()) + } + } +} + +fn build_action_constraint<'py>( + c: &PstClasses<'py>, + constraint: &pst::ActionConstraint, +) -> PyResult> { + match constraint { + pst::ActionConstraint::Any => Ok(c.scope_any.call0()?.unbind()), + pst::ActionConstraint::Eq(euid) => Ok(c.action_eq.call1((build_entity_uid(c, euid)?,))?.unbind()), + pst::ActionConstraint::In(euids) => { + let mut items = Vec::with_capacity(euids.len()); + for euid in euids { + items.push(build_entity_uid(c, euid)?); + } + let tuple = PyTuple::new(c.action_in.py(), items)?; + Ok(c.action_in.call1((tuple,))?.unbind()) + } + } +} + +fn build_pattern<'py>(c: &PstClasses<'py>, pattern: &[pst::PatternElem]) -> PyResult> { + let mut items = Vec::with_capacity(pattern.len()); + for elem in pattern { + items.push(match elem { + pst::PatternElem::Char(ch) => c.char.call1((ch.to_string(),))?.unbind(), + pst::PatternElem::Wildcard => c.wildcard.call0()?.unbind(), + }); + } + Ok(PyTuple::new(c.char.py(), items)?.unbind().into()) +} + +fn build_literal<'py>(c: &PstClasses<'py>, literal: &pst::Literal) -> PyResult> { + let value: Py = match literal { + pst::Literal::Bool(b) => pyo3::types::PyBool::new(c.literal.py(), *b).to_owned().into_any().unbind(), + pst::Literal::Long(n) => n.into_pyobject(c.literal.py())?.into_any().unbind(), + pst::Literal::String(s) => s.to_string().into_pyobject(c.literal.py())?.into_any().unbind(), + pst::Literal::EntityUID(euid) => build_entity_uid(c, euid)?, + other => return Err(pst_error("Literal variant", other)), + }; + Ok(c.literal.call1((value,))?.unbind()) +} + +fn build_expr<'py>(c: &PstClasses<'py>, expr: &pst::Expr) -> PyResult> { + match expr { + pst::Expr::Literal(lit) => build_literal(c, lit), + pst::Expr::Var(var) => { + let name = match var { + pst::Var::Principal => "principal", + pst::Var::Action => "action", + pst::Var::Resource => "resource", + pst::Var::Context => "context", + }; + Ok(c.var.call1((name,))?.unbind()) + } + pst::Expr::Slot(slot) => build_slot_id(c, *slot), + pst::Expr::UnaryOp { op, expr: arg } => { + let name = unary_op_name(*op)?; + Ok(c.unary_op.call1((name, build_expr(c, arg)?))?.unbind()) + } + pst::Expr::BinaryOp { op, left, right } => { + let name = binary_op_name(*op)?; + Ok(c.binary_op.call1((name, build_expr(c, left)?, build_expr(c, right)?))?.unbind()) + } + pst::Expr::GetAttr { expr: base, attr } => { + Ok(c.get_attr.call1((build_expr(c, base)?, attr.to_string()))?.unbind()) + } + pst::Expr::HasAttr { expr: base, attrs } => { + let items: Vec = attrs.iter().map(|a| a.to_string()).collect(); + let tuple = PyTuple::new(c.has_attr.py(), items)?; + Ok(c.has_attr.call1((build_expr(c, base)?, tuple))?.unbind()) + } + pst::Expr::Like { expr: base, pattern } => { + Ok(c.like.call1((build_expr(c, base)?, build_pattern(c, pattern)?))?.unbind()) + } + pst::Expr::Is { expr: base, entity_type, in_expr } => { + let in_expr = match in_expr { + Some(e) => Some(build_expr(c, e)?), + None => None, + }; + Ok(c.is_.call1((build_expr(c, base)?, entity_type.0.to_string(), in_expr))?.unbind()) + } + pst::Expr::IfThenElse { cond, then_expr, else_expr } => Ok(c + .if_then_else + .call1((build_expr(c, cond)?, build_expr(c, then_expr)?, build_expr(c, else_expr)?))? + .unbind()), + pst::Expr::Set(elements) => { + let mut items = Vec::with_capacity(elements.len()); + for e in elements { + items.push(build_expr(c, e)?); + } + let tuple = PyTuple::new(c.set.py(), items)?; + Ok(c.set.call1((tuple,))?.unbind()) + } + pst::Expr::Record(fields) => { + let mut items = Vec::with_capacity(fields.len()); + for (k, v) in fields { + items.push((k.clone(), build_expr(c, v)?)); + } + Ok(c.record.call1((build_mapping(c.record.py(), items)?,))?.unbind()) + } + pst::Expr::Unknown { name } => Ok(c.unknown.call1((name.to_string(),))?.unbind()), + other => Err(pst_error("Expr variant", other)), + } +} + +fn unary_op_name(op: pst::UnaryOp) -> PyResult<&'static str> { + use pst::UnaryOp::*; + match op { + Not => Ok("not"), Neg => Ok("neg"), IsEmpty => Ok("is_empty"), + Datetime => Ok("datetime"), Decimal => Ok("decimal"), Duration => Ok("duration"), Ip => Ok("ip"), + IsIPv4 => Ok("is_ipv4"), IsIPV6 => Ok("is_ipv6"), + IsLoopback => Ok("is_loopback"), IsMulticast => Ok("is_multicast"), + ToDate => Ok("to_date"), ToTime => Ok("to_time"), + ToMilliseconds => Ok("to_milliseconds"), ToSeconds => Ok("to_seconds"), + ToMinutes => Ok("to_minutes"), ToHours => Ok("to_hours"), ToDays => Ok("to_days"), + other => Err(pst_error("UnaryOp variant", other)), + } +} + +fn binary_op_name(op: pst::BinaryOp) -> PyResult<&'static str> { + use pst::BinaryOp::*; + match op { + Eq => Ok("eq"), NotEq => Ok("not_eq"), Less => Ok("less"), LessEq => Ok("less_eq"), + Greater => Ok("greater"), GreaterEq => Ok("greater_eq"), + And => Ok("and"), Or => Ok("or"), + Add => Ok("add"), Sub => Ok("sub"), Mul => Ok("mul"), + In => Ok("in"), Contains => Ok("contains"), ContainsAll => Ok("contains_all"), ContainsAny => Ok("contains_any"), + GetTag => Ok("get_tag"), HasTag => Ok("has_tag"), + IsInRange => Ok("is_in_range"), Offset => Ok("offset"), DurationSince => Ok("duration_since"), + DecimalLessThan => Ok("decimal_less_than"), DecimalLessEq => Ok("decimal_less_eq"), + DecimalGreater => Ok("decimal_greater"), DecimalGreaterEq => Ok("decimal_greater_eq"), + other => Err(pst_error("BinaryOp variant", other)), + } +} + +fn build_clause<'py>(c: &PstClasses<'py>, clause: &pst::Clause) -> PyResult> { + match clause { + pst::Clause::When(e) => Ok(c.when.call1((build_expr(c, e)?,))?.unbind()), + pst::Clause::Unless(e) => Ok(c.unless.call1((build_expr(c, e)?,))?.unbind()), + } +} + +fn build_template<'py>(c: &PstClasses<'py>, template: &pst::Template) -> PyResult> { + let effect = match template.effect { + pst::Effect::Permit => "permit", + pst::Effect::Forbid => "forbid", + }; + let mut clauses = Vec::with_capacity(template.clauses().len()); + for clause in template.clauses() { + clauses.push(build_clause(c, clause)?); + } + let clauses = PyTuple::new(c.template.py(), clauses)?; + let annotations: Vec<(String, Py)> = template + .annotations + .iter() + .map(|(k, v)| Ok((k.clone(), v.to_string().into_pyobject(c.template.py())?.into_any().unbind()))) + .collect::>()?; + Ok(c.template + .call1(( + template.id.0.to_string(), + effect, + build_principal_constraint(c, &template.principal)?, + build_action_constraint(c, &template.action)?, + build_resource_constraint(c, &template.resource)?, + clauses, + build_mapping(c.template.py(), annotations)?, + ))? + .unbind()) +} + +fn build_slot_values<'py>( + c: &PstClasses<'py>, + values: &std::collections::HashMap, +) -> PyResult> { + let mut items = Vec::with_capacity(values.len()); + for (slot, euid) in values { + let name = match slot { + pst::SlotId::Principal => "principal", + pst::SlotId::Resource => "resource", + other => return Err(pst_error("SlotId variant", other)), + }; + items.push((name.to_string(), build_entity_uid(c, euid)?)); + } + build_mapping(c.entity_uid.py(), items) +} + +fn build_policy_set<'py>(c: &PstClasses<'py>, policy_set: &pst::PolicySet) -> PyResult> { + let mut templates = Vec::with_capacity(policy_set.templates.len()); + for (id, template) in policy_set.templates.iter() { + templates.push((id.0.to_string(), build_template(c, template)?)); + } + let mut static_policies = Vec::with_capacity(policy_set.policies.len()); + for (id, static_policy) in policy_set.policies.iter() { + static_policies.push((id.0.to_string(), build_template(c, static_policy.body())?)); + } + let mut links = Vec::with_capacity(policy_set.template_links.len()); + for link in &policy_set.template_links { + links.push( + c.template_link + .call1(( + link.template_id.0.to_string(), + link.new_id.0.to_string(), + build_slot_values(c, &link.values)?, + ))? + .unbind(), + ); + } + let links = PyTuple::new(c.policy_set.py(), links)?; + Ok(c.policy_set + .call1((build_mapping(c.policy_set.py(), templates)?, build_mapping(c.policy_set.py(), static_policies)?, links))? + .unbind()) +} + +/// Parse Cedar policy text into typed cedarpy.pst nodes. +#[pyfunction] +#[pyo3(signature = (s))] +fn policies_to_pst(py: Python<'_>, s: String) -> PyResult> { + let policies = PolicySet::from_str(&s).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let pst = policies.to_pst().map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let classes = PstClasses::load(py)?; + build_policy_set(&classes, &pst) +} + /// An opaque, reusable handle wrapping a parsed Cedar policy set. /// /// Parsing policies is the dominant per-call cost in `is_authorized`. Callers @@ -1572,6 +1932,7 @@ fn _internal(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(format_policies, m)?)?; m.add_function(wrap_pyfunction!(policies_to_json_str, m)?)?; m.add_function(wrap_pyfunction!(policies_from_json_str, m)?)?; + m.add_function(wrap_pyfunction!(policies_to_pst, m)?)?; m.add_function(wrap_pyfunction!(validate_policies, m)?)?; Ok(()) } diff --git a/tests/unit/test_pst.py b/tests/unit/test_pst.py new file mode 100644 index 0000000..8b358e4 --- /dev/null +++ b/tests/unit/test_pst.py @@ -0,0 +1,194 @@ +"""Unit tests for policies_to_pst and cedarpy.pst.""" +import dataclasses +import json +import unittest + +from cedarpy import policies_to_pst +from cedarpy.pst import ( + ActionEq, ActionIn, BinaryOp, Char, EntityUid, GetAttr, HasAttr, + IfThenElse, Is, Like, Literal, PolicySet, Record, ScopeEq, ScopeIs, + ScopeIsIn, Set, Slot, Template, UnaryOp, Unless, Var, When, Wildcard, +) + + +def _clause(result, policy_id="policy0", index=0): + return result.static_policies[policy_id].clauses[index] + + +def _expr(result, policy_id="policy0", index=0): + return _clause(result, policy_id, index).expr + + +class TestScopeAndClauses(unittest.TestCase): + def test_when_and_unless_clauses(self): + result = policies_to_pst( + 'permit(principal, action, resource) ' + 'when { resource.public == true } ' + 'unless { resource.blocked };' + ) + policy = result.static_policies["policy0"] + self.assertEqual(policy.effect, "permit") + self.assertEqual(len(policy.clauses), 2) + self.assertIsInstance(policy.clauses[0], When) + self.assertIsInstance(policy.clauses[1], Unless) + + def test_forbid_effect(self): + result = policies_to_pst('forbid(principal, action, resource) when { resource.blocked };') + self.assertEqual(result.static_policies["policy0"].effect, "forbid") + + def test_scope_constraints(self): + result = policies_to_pst( + 'permit(principal == User::"alice", action in [Action::"view", Action::"edit"], ' + 'resource is Photo in Album::"vacation");' + ) + policy = result.static_policies["policy0"] + self.assertEqual(policy.principal, ScopeEq(entity=EntityUid("User", "alice"))) + self.assertEqual( + policy.action, ActionIn((EntityUid("Action", "view"), EntityUid("Action", "edit"))) + ) + self.assertEqual( + policy.resource, ScopeIsIn("Photo", EntityUid("Album", "vacation")) + ) + + def test_annotations(self): + result = policies_to_pst('@id("my-policy")\npermit(principal, action, resource);') + self.assertEqual(result.static_policies["policy0"].annotations, {"id": "my-policy"}) + + +class TestExprShapes(unittest.TestCase): + def test_bool_literal(self): + result = policies_to_pst('permit(principal, action, resource) when { true };') + self.assertEqual(_expr(result), Literal(True)) + + def test_long_literal(self): + result = policies_to_pst('permit(principal, action, resource) when { 42 == 42 };') + self.assertEqual(_expr(result).left, Literal(42)) + + def test_entity_literal(self): + result = policies_to_pst('permit(principal, action, resource) when { principal == User::"alice" };') + self.assertEqual(_expr(result).right, Literal(EntityUid("User", "alice"))) + + def test_set_and_record(self): + result = policies_to_pst( + 'permit(principal, action, resource) when { [1, 2].contains(1) && {"a": 1}.a == 1 };' + ) + top = _expr(result) + self.assertEqual(top.left.left, Set((Literal(1), Literal(2)))) + self.assertEqual(top.right.left.base, Record({"a": Literal(1)})) + + def test_has(self): + result = policies_to_pst('permit(principal, action, resource) when { resource has owner };') + self.assertEqual(_expr(result), HasAttr(base=Var("resource"), attrs=("owner",))) + + def test_has_nested_path_lowers_to_two_clauses(self): + # `resource has a.b` lowers to `resource has a && (resource.a) has b`, + # not one HasAttr with two attrs, confirmed against real parser output. + result = policies_to_pst('permit(principal, action, resource) when { resource has address.street };') + top = _expr(result) + self.assertEqual(top.left, HasAttr(base=Var("resource"), attrs=("address",))) + self.assertEqual(top.right.attrs, ("street",)) + self.assertIsInstance(top.right.base, GetAttr) + + def test_like(self): + result = policies_to_pst('permit(principal, action, resource) when { resource.name like "a*b" };') + expr = _expr(result) + self.assertIsInstance(expr, Like) + self.assertEqual(expr.pattern, (Char("a"), Wildcard(), Char("b"))) + + def test_is_without_in(self): + result = policies_to_pst('permit(principal, action, resource) when { resource is Photo };') + expr = _expr(result) + self.assertEqual(expr, Is(base=Var("resource"), entity_type="Photo", in_expr=None)) + + def test_is_with_in(self): + result = policies_to_pst( + 'permit(principal, action, resource) when { resource is Photo in Album::"vacation" };' + ) + expr = _expr(result) + self.assertIsInstance(expr, Is) + self.assertEqual(expr.in_expr, Literal(EntityUid("Album", "vacation"))) + + def test_if_then_else(self): + result = policies_to_pst( + 'permit(principal, action, resource) when { (if resource.flag then 1 else 2) == 1 };' + ) + cond_expr = _expr(result).left + self.assertIsInstance(cond_expr, IfThenElse) + self.assertEqual(cond_expr.then_expr, Literal(1)) + self.assertEqual(cond_expr.else_expr, Literal(2)) + + def test_unary_op(self): + result = policies_to_pst('permit(principal, action, resource) when { !resource.blocked };') + self.assertEqual(_expr(result), UnaryOp(op="not", arg=GetAttr(Var("resource"), "blocked"))) + + def test_binary_op_names(self): + cases = {"==": "eq", "!=": "not_eq", "<": "less", ">=": "greater_eq", "+": "add"} + for symbol, expected in cases.items(): + result = policies_to_pst(f'permit(principal, action, resource) when {{ 1 {symbol} 1 }};') + self.assertEqual(_expr(result).op, expected, msg=symbol) + + def test_slot_in_template(self): + result = policies_to_pst('permit(principal == ?principal, action, resource);') + self.assertNotIn("policy0", result.static_policies) + self.assertEqual(result.templates["policy0"].principal, ScopeEq(entity=Slot("principal"))) + + +class TestPatternMatching(unittest.TestCase): + def test_nested_keyword_match(self): + result = policies_to_pst( + 'permit(principal, action, resource) when { resource.status == "active" };' + ) + match _expr(result): + case BinaryOp(op="eq", left=GetAttr(base=Var(name="resource"), attr="status"), right=Literal(value="active")): + matched = True + case _: + matched = False + self.assertTrue(matched) + + def test_positional_match(self): + result = policies_to_pst('permit(principal, action, resource) when { 1 == 1 };') + match _expr(result): + case BinaryOp(op, left, right): + self.assertEqual((op, left, right), ("eq", Literal(1), Literal(1))) + case _: + self.fail("no match") + + +class TestImmutabilityAndSerialization(unittest.TestCase): + def test_frozen(self): + result = policies_to_pst('permit(principal, action, resource);') + with self.assertRaises(dataclasses.FrozenInstanceError): + result.static_policies = {} + + def test_asdict_and_json(self): + result = policies_to_pst( + 'permit(principal, action, resource) when { resource.status == "active" };' + ) + as_dict = dataclasses.asdict(result) + self.assertEqual( + as_dict["static_policies"]["policy0"]["clauses"][0]["expr"]["op"], "eq" + ) + json.dumps(as_dict) # must not raise + + +class TestErrorHandling(unittest.TestCase): + def test_unparseable_policy_raises(self): + with self.assertRaises(ValueError): + policies_to_pst("this is not cedar") + + def test_empty_policy_set(self): + result = policies_to_pst("") + self.assertEqual(result, PolicySet(templates={}, static_policies={}, template_links=())) + + +class TestResidualsOutOfScope(unittest.TestCase): + def test_is_authorized_partial_is_unaffected(self): + from cedarpy import Decision, is_authorized_partial + + result = is_authorized_partial( + {"principal": 'User::"alice"', "action": 'Action::"view"'}, + 'permit(principal, action, resource) when { resource.status == "active" };', + "[]", + ) + self.assertEqual(result.decision, Decision.NoDecision) + self.assertIn("policy0", result.residuals) From 409d6b6a0cc4006ae767972c620c6dcceff59204 Mon Sep 17 00:00:00 2001 From: robby Date: Fri, 21 Aug 2026 15:00:53 -0400 Subject: [PATCH 3/3] feat: expose type-aware partial evaluation (TPE) as tpe_authorize 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 #107. --- CHANGELOG.md | 1 + Cargo.toml | 2 +- README.md | 28 ++++++ cedarpy/__init__.py | 48 +++++++++ cedarpy/_internal.pyi | 12 +++ src/lib.rs | 168 ++++++++++++++++++++++++++++++- tests/unit/test_tpe_authorize.py | 114 +++++++++++++++++++++ 7 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_tpe_authorize.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b42b934..78c017a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - `policies_to_pst(policies)` parses Cedar policy text into typed `cedarpy.pst` nodes (frozen dataclasses, one per `cedar_policy::pst` node kind), matching a real Cedar structure a consumer can pattern-match on instead of an untyped tree keyed by string operators. Static policies and unlinked templates only; a partial-evaluation residual from `is_authorized_partial` cannot be represented this way, because PST's own policy type rejects any clause containing an unresolved `unknown(...)` node ([#107](https://github.com/k9securityio/cedar-py/issues/107)). Requires cedar-policy 4.11.0+, bundled by [#106](https://github.com/k9securityio/cedar-py/pull/106). +- `tpe_authorize(principal, action, resource, policies, entities, schema, context=None, verbose=False)` performs type-aware partial evaluation (TPE) on a request whose principal and/or resource identity is unknown. Separate entry point from `is_authorized_partial`, which it does not call or change. TPE's residuals are typed `cedarpy.pst.Template` nodes, unlike `is_authorized_partial`'s. `permits`/`forbids` stay separate, each a `TpeClassification` of residual/true/false/error policy ids. Enables the `tpe` Cargo feature; adds no dependencies, checked against the resolved lockfile. ## [4.8.7] - 2026-07-10 diff --git a/Cargo.toml b/Cargo.toml index 57bed13..7ba784e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ crate-type = ["cdylib"] [dependencies] pyo3 = "0.27.0" anyhow = "1.0" -cedar-policy = { version = "4.8.2", features = ["partial-eval"] } +cedar-policy = { version = "4.8.2", features = ["partial-eval", "tpe"] } cedar-policy-cli = "4.8.2" cedar-policy-formatter = "4.8.2" serde = { version = "1.0.0", features = ["derive", "rc"] } diff --git a/README.md b/README.md index af69d6c..ff59b5a 100644 --- a/README.md +++ b/README.md @@ -466,6 +466,34 @@ cannot be parsed this way: PST rejects any clause containing an unresolved `dataclasses.asdict(result)` and `json.dumps(...)` work directly, since the nodes are plain frozen dataclasses. +### Type-aware partial evaluation (TPE) on an unknown principal or resource + +`tpe_authorize` is for a different situation than `is_authorized_partial`: you +know the type of the principal or resource but not which one. It requires a +schema, and its residuals are `cedarpy.pst.Template` nodes, not JSON. + +```python +from cedarpy import tpe_authorize + +schema = """ + entity User; + entity Doc = { status: String }; + action "view" appliesTo { principal: [User], resource: [Doc] }; +""" +policies = """ + permit(principal, action == Action::"view", resource) + when { resource.status == "active" }; +""" + +result = tpe_authorize('User::"alice"', 'Action::"view"', "Doc", policies, "[]", schema) +print(result.decision) # None -- not enough to decide yet +print(result.permits.residual_ids) # ('policy0',) +print(result.residual_policies) # {'policy0': Template(...)} +``` + +`permits` and `forbids` are kept separate, each a `TpeClassification` of +`residual_ids`/`true_ids`/`false_ids`/`error_ids`. + ## Developing diff --git a/cedarpy/__init__.py b/cedarpy/__init__.py index 2c3da06..6554b83 100644 --- a/cedarpy/__init__.py +++ b/cedarpy/__init__.py @@ -1,6 +1,7 @@ import json from copy import copy from enum import Enum +from dataclasses import dataclass from typing import Union, List, Optional, Any from cedarpy import _internal @@ -91,6 +92,25 @@ class Decision(Enum): NoDecision = 'NoDecision' +@dataclass(frozen=True) +class TpeClassification: + """Policy ids TPE could not resolve, and the ones it did, for one effect.""" + residual_ids: tuple + true_ids: tuple + false_ids: tuple + error_ids: tuple + + +@dataclass(frozen=True) +class TpeAuthzResult: + decision: Optional[Decision] + reason: tuple + permits: TpeClassification + forbids: TpeClassification + residual_policies: dict # policy id -> cedarpy.pst.Template + metrics: dict + + class _DiagnosticsBase: """Shared backing for the public diagnostics types. Not part of the public API — type-annotate against ``Diagnostics`` or @@ -517,6 +537,34 @@ def is_authorized_partial(request: dict, return PartialAuthzResult(result_dict) +def tpe_authorize(principal: str, + action: str, + resource: str, + policies: Union[str, PolicySet], + entities: Union[str, List[dict], Entities], + schema: Union[str, dict, Schema], + context: Optional[str] = None, + verbose: bool = False) -> TpeAuthzResult: + """Type-aware partial evaluation (TPE) on a request with an unknown + principal and/or resource identity. + + principal/resource accept Type::"id" (concrete) or a bare Type (known + type, unknown id). action must be concrete. schema is required. entities + must be fully concrete. Raises ValueError on unresolvable input. + """ + internal_entities = entities + if isinstance(internal_entities, list): + internal_entities = json.dumps(internal_entities) + elif isinstance(internal_entities, Entities): + internal_entities = internal_entities._inner + + if isinstance(schema, dict): + schema = json.dumps(schema) + + return _internal.tpe_authorize( + principal, action, resource, policies, internal_entities, schema, context, verbose) + + def validate_policies(policies: str, schema: Union[str, dict, Schema]) -> ValidationResult: """Validate Cedar policies against a schema. diff --git a/cedarpy/_internal.pyi b/cedarpy/_internal.pyi index d26a34d..b0ed2b7 100644 --- a/cedarpy/_internal.pyi +++ b/cedarpy/_internal.pyi @@ -272,4 +272,16 @@ def policies_from_json_str(s: str) -> str: ... def policies_to_pst(s: str) -> "cedarpy.pst.PolicySet": ... +def tpe_authorize( + principal: str, + action: str, + resource: str, + policies: Union[str, "PolicySet"], + entities: Union[str, "Entities"], + schema: Union[str, "Schema"], + context: Optional[str] = ..., + verbose: Optional[bool] = ..., +) -> "cedarpy.TpeAuthzResult": ... + + def validate_policies(policies: str, schema: Union[str, "Schema"]) -> str: ... diff --git a/src/lib.rs b/src/lib.rs index 3d24586..432be59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,7 +74,6 @@ struct PstClasses<'py> { set: Bound<'py, PyAny>, record: Bound<'py, PyAny>, unknown: Bound<'py, PyAny>, - #[allow(dead_code)] // read once the tpe feature is on residual_error: Bound<'py, PyAny>, when: Bound<'py, PyAny>, unless: Bound<'py, PyAny>, @@ -294,6 +293,7 @@ fn build_expr<'py>(c: &PstClasses<'py>, expr: &pst::Expr) -> PyResult> Ok(c.record.call1((build_mapping(c.record.py(), items)?,))?.unbind()) } pst::Expr::Unknown { name } => Ok(c.unknown.call1((name.to_string(),))?.unbind()), + pst::Expr::ResidualError => Ok(c.residual_error.call0()?.unbind()), other => Err(pst_error("Expr variant", other)), } } @@ -406,6 +406,171 @@ fn build_policy_set<'py>(c: &PstClasses<'py>, policy_set: &pst::PolicySet) -> Py .unbind()) } +fn parse_partial_entity_uid(s: &str, field: &str) -> PyResult { + if let Ok(euid) = EntityUid::from_str(s) { + return Ok(PartialEntityUid::from_concrete(euid)); + } + match EntityTypeName::from_str(s) { + Ok(ty) => Ok(PartialEntityUid::new(ty, None)), + Err(e) => Err(pyo3::exceptions::PyValueError::new_err(format!( + "failed to parse {field} '{s}' as an entity uid (Type::\"id\") or a bare entity type (Type): {e}" + ))), + } +} + +fn resolve_policies_eager<'a>( + policies: &'a PoliciesArg, + slot: &'a mut Option, +) -> PyResult<&'a PolicySet> { + let mut errs: Vec = vec![]; + let resolved = policies.resolve(slot, &mut errs, false); + match errs.into_iter().next() { + Some(e) => Err(pyo3::exceptions::PyValueError::new_err(e.to_string())), + None => Ok(resolved), + } +} + +fn resolve_entities_eager<'a>( + entities: &'a EntitiesArg, + slot: &'a mut Option, + schema: Option<&Schema>, +) -> PyResult<&'a Entities> { + let mut errs: Vec = vec![]; + let resolved = entities.resolve(slot, schema, &mut errs); + match errs.into_iter().next() { + Some(e) => Err(pyo3::exceptions::PyValueError::new_err(e.to_string())), + None => Ok(resolved), + } +} + +fn build_classification<'py>( + py: Python<'py>, + cls: &Bound<'py, PyAny>, + residual_ids: Vec, + true_ids: Vec, + false_ids: Vec, + error_ids: Vec, +) -> PyResult> { + Ok(cls + .call1(( + PyTuple::new(py, residual_ids)?, + PyTuple::new(py, true_ids)?, + PyTuple::new(py, false_ids)?, + PyTuple::new(py, error_ids)?, + ))? + .unbind()) +} + +/// Perform type-aware partial evaluation (TPE) on a request whose principal +/// and/or resource identity is unknown. Separate from is_authorized_partial; +/// does not call, change, or share a response shape with it. +#[pyfunction] +#[pyo3(signature = (principal, action, resource, policies, entities, schema, context = None, verbose = false))] +#[allow(clippy::too_many_arguments)] +fn tpe_authorize( + py: Python<'_>, + principal: String, + action: String, + resource: String, + policies: PoliciesArg, + entities: EntitiesArg, + schema: SchemaArg, + context: Option, + verbose: Option, +) -> PyResult> { + let verbose = verbose.unwrap_or(false); + + let mut schema_slot: Option = None; + let schema_arg = Some(schema); + let schema = resolve_schema_arg_eager(&schema_arg, &mut schema_slot)? + .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("tpe_authorize requires a non-empty schema"))?; + + let mut policy_set_slot: Option = None; + let policy_set = resolve_policies_eager(&policies, &mut policy_set_slot)?; + + let mut entities_slot: Option = None; + let concrete_entities = resolve_entities_eager(&entities, &mut entities_slot, Some(schema))?; + + let action_euid = EntityUid::from_str(&action).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("failed to parse action '{action}': {e}")) + })?; + + let cedar_context = match context { + Some(ctx_json) => Context::from_json_str(&ctx_json, Some((schema, &action_euid))) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("failed to parse context: {e}")))?, + None => Context::empty(), + }; + + let t_build_request = Instant::now(); + let partial_request = PartialRequest::new( + parse_partial_entity_uid(&principal, "principal")?, + action_euid, + parse_partial_entity_uid(&resource, "resource")?, + Some(cedar_context), + schema, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("failed to build partial request: {e}")))?; + let build_request_duration = t_build_request.elapsed(); + + let partial_entities = PartialEntities::from_concrete(concrete_entities.clone(), schema) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("failed to load entities for tpe: {e}")))?; + + let t_tpe = Instant::now(); + let response = policy_set + .tpe(&partial_request, &partial_entities, schema) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("tpe evaluation failed: {e}")))?; + let tpe_duration = t_tpe.elapsed(); + + if verbose { + println!("tpe_authorize decision: {:?}", response.decision()); + } + + let cedarpy = PyModule::import(py, "cedarpy")?; + let decision_cls = cedarpy.getattr("Decision")?; + let decision = match response.decision() { + Some(Decision::Allow) => Some(decision_cls.getattr("Allow")?), + Some(Decision::Deny) => Some(decision_cls.getattr("Deny")?), + None => None, + }; + let reason_ids: Vec = response.reason().into_iter().flatten().map(|id| id.to_string()).collect(); + let reason = PyTuple::new(py, reason_ids)?; + + let classification_cls = cedarpy.getattr("TpeClassification")?; + let permits = build_classification( + py, &classification_cls, + response.residual_permits().map(|id| id.to_string()).collect(), + response.true_permits().map(|id| id.to_string()).collect(), + response.false_permits().map(|id| id.to_string()).collect(), + response.error_permits().map(|id| id.to_string()).collect(), + )?; + let forbids = build_classification( + py, &classification_cls, + response.residual_forbids().map(|id| id.to_string()).collect(), + response.true_forbids().map(|id| id.to_string()).collect(), + response.false_forbids().map(|id| id.to_string()).collect(), + response.error_forbids().map(|id| id.to_string()).collect(), + )?; + + let classes = PstClasses::load(py)?; + let mut residual_policies = Vec::new(); + for policy in response.residual_policies() { + let pid = policy.id().to_string(); + let pst_policy = policy.to_pst().map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("residual policy `{pid}` could not be converted to pst: {e}")) + })?; + residual_policies.push((pid, build_template(&classes, pst_policy.body())?)); + } + + let metrics = PyDict::new(py); + metrics.set_item("build_request_duration_micros", build_request_duration.as_micros())?; + metrics.set_item("tpe_duration_micros", tpe_duration.as_micros())?; + + let result_cls = cedarpy.getattr("TpeAuthzResult")?; + Ok(result_cls + .call1((decision, reason, permits, forbids, build_mapping(py, residual_policies)?, metrics))? + .unbind()) +} + /// Parse Cedar policy text into typed cedarpy.pst nodes. #[pyfunction] #[pyo3(signature = (s))] @@ -1933,6 +2098,7 @@ fn _internal(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(policies_to_json_str, m)?)?; m.add_function(wrap_pyfunction!(policies_from_json_str, m)?)?; m.add_function(wrap_pyfunction!(policies_to_pst, m)?)?; + m.add_function(wrap_pyfunction!(tpe_authorize, m)?)?; m.add_function(wrap_pyfunction!(validate_policies, m)?)?; Ok(()) } diff --git a/tests/unit/test_tpe_authorize.py b/tests/unit/test_tpe_authorize.py new file mode 100644 index 0000000..b198869 --- /dev/null +++ b/tests/unit/test_tpe_authorize.py @@ -0,0 +1,114 @@ +"""Unit tests for tpe_authorize.""" +import unittest + +from cedarpy import Decision, is_authorized_partial, tpe_authorize +from cedarpy.pst import BinaryOp, GetAttr, Template, Var + +SCHEMA = """ + entity User; + entity Doc = { status: String }; + action "view" appliesTo { principal: [User], resource: [Doc] }; +""" + +POLICIES = """ + permit(principal, action == Action::"view", resource) + when { resource.status == "active" }; +""" + +PERMIT_AND_FORBID = """ + permit(principal, action == Action::"view", resource); + forbid(principal, action == Action::"view", resource) + when { resource.status == "blocked" }; +""" + + +def _doc(doc_id, status): + return f'[{{"uid": {{"type": "Doc", "id": "{doc_id}"}}, "attrs": {{"status": "{status}"}}, "parents": []}}]' + + +class TestPrincipalAndResourcePartiality(unittest.TestCase): + def test_type_only_resource_produces_a_residual(self): + result = tpe_authorize('User::"alice"', 'Action::"view"', "Doc", POLICIES, "[]", SCHEMA) + self.assertIsNone(result.decision) + self.assertEqual(result.permits.residual_ids, ("policy0",)) + + def test_concrete_resource_resolves_allow(self): + result = tpe_authorize( + 'User::"alice"', 'Action::"view"', 'Doc::"d1"', POLICIES, _doc("d1", "active"), SCHEMA + ) + self.assertEqual(result.decision, Decision.Allow) + self.assertEqual(result.permits.true_ids, ("policy0",)) + self.assertEqual(result.residual_policies, {}) + + def test_concrete_resource_resolves_deny(self): + result = tpe_authorize( + 'User::"alice"', 'Action::"view"', 'Doc::"d1"', POLICIES, _doc("d1", "inactive"), SCHEMA + ) + self.assertEqual(result.decision, Decision.Deny) + self.assertEqual(result.permits.false_ids, ("policy0",)) + + +class TestPermitForbidStaySeparate(unittest.TestCase): + def test_forbid_true_overrides_permit_true(self): + result = tpe_authorize( + 'User::"alice"', 'Action::"view"', 'Doc::"d1"', PERMIT_AND_FORBID, _doc("d1", "blocked"), SCHEMA + ) + self.assertEqual(result.decision, Decision.Deny) + self.assertEqual(result.permits.true_ids, ("policy0",)) + self.assertEqual(result.forbids.true_ids, ("policy1",)) + + def test_residual_forbid_blocks_an_otherwise_true_permit(self): + result = tpe_authorize('User::"alice"', 'Action::"view"', "Doc", PERMIT_AND_FORBID, "[]", SCHEMA) + self.assertIsNone(result.decision) + self.assertEqual(result.permits.true_ids, ("policy0",)) + self.assertEqual(result.forbids.residual_ids, ("policy1",)) + + +class TestResidualPoliciesAreTypedNodes(unittest.TestCase): + def test_residual_is_a_template_matching_the_expression(self): + result = tpe_authorize('User::"alice"', 'Action::"view"', "Doc", POLICIES, "[]", SCHEMA) + residual = result.residual_policies["policy0"] + self.assertIsInstance(residual, Template) + match residual.clauses[0].expr: + case BinaryOp(op="eq", left=GetAttr(base=Var(name="resource"), attr="status")): + matched = True + case _: + matched = False + self.assertTrue(matched) + + def test_trivial_residuals_are_not_repeated(self): + result = tpe_authorize( + 'User::"alice"', 'Action::"view"', 'Doc::"d1"', POLICIES, _doc("d1", "active"), SCHEMA + ) + self.assertEqual(result.residual_policies, {}) + + +class TestIsAuthorizedPartialIsUnaffected(unittest.TestCase): + def test_untouched(self): + result = is_authorized_partial( + {"principal": 'User::"alice"', "action": 'Action::"view"'}, POLICIES, "[]" + ) + self.assertEqual(result.decision, Decision.NoDecision) + self.assertIn("policy0", result.residuals) + + +class TestErrorHandling(unittest.TestCase): + def test_missing_schema_raises(self): + with self.assertRaises(ValueError): + tpe_authorize('User::"alice"', 'Action::"view"', "Doc", POLICIES, "[]", "") + + def test_unparseable_principal_raises(self): + with self.assertRaises(ValueError): + tpe_authorize("not valid::", 'Action::"view"', "Doc", POLICIES, "[]", SCHEMA) + + def test_bare_type_action_raises(self): + with self.assertRaises(ValueError): + tpe_authorize('User::"alice"', "Action", "Doc", POLICIES, "[]", SCHEMA) + + def test_unparseable_policies_raises(self): + with self.assertRaises(ValueError): + tpe_authorize('User::"alice"', 'Action::"view"', "Doc", "not cedar", "[]", SCHEMA) + + def test_unparseable_entities_raises(self): + with self.assertRaises(ValueError): + tpe_authorize('User::"alice"', 'Action::"view"', "Doc", POLICIES, "not json", SCHEMA)