diff --git a/Cargo.lock b/Cargo.lock index 775b1452..53058932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,7 +118,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -129,7 +129,24 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", ] [[package]] @@ -138,6 +155,176 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apl-audit-logger" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "async-trait", + "chrono", + "cpex-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "apl-cmf" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-core", + "cpex-core", + "serde_json", +] + +[[package]] +name = "apl-core" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "async-trait", + "cpex-orchestration", + "futures", + "regex", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", +] + +[[package]] +name = "apl-cpex" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-cmf", + "apl-core", + "async-trait", + "chrono", + "cpex-core", + "serde_json", + "serde_yaml", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "apl-delegator-oauth" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-core", + "async-trait", + "chrono", + "cpex-core", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "apl-identity-jwt" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-core", + "async-trait", + "base64", + "chrono", + "cpex-core", + "futures", + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "apl-pdp-cedar-direct" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-core", + "async-trait", + "cedar-policy", + "serde", + "serde_json", + "serde_yaml", + "stacker", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "apl-pdp-cel" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-core", + "async-trait", + "cel", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "apl-pii-scanner" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "async-trait", + "cpex-core", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "apl-session-valkey" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-cpex", + "async-trait", + "deadpool-redis", + "redis", + "serde", + "serde_yaml", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -147,12 +334,33 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -220,6 +428,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -317,6 +536,15 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base64" version = "0.22.1" @@ -347,6 +575,27 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -380,6 +629,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "brotli" version = "3.5.0" @@ -401,6 +660,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -480,6 +748,85 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "716a5103f447735b4cef15df847dc2a7ed8752e8a95febab5a6bfe1812054e43" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools 0.14.0", + "linked-hash-map", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror 2.0.18", +] + +[[package]] +name = "cedar-policy-core" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7935861e764efcba26f73ac4d9d284e30b2b4a9a95d3c961e908e3ed7889e391" +dependencies = [ + "chrono", + "educe", + "either", + "itertools 0.14.0", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.18", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca436a4803977e69b12a8e0e7f5d7c648e578b59fa0f2ed3bbe3c0054b9e036" +dependencies = [ + "cedar-policy-core", + "itertools 0.14.0", + "logos 0.16.1", + "miette", + "pretty", + "regex", + "smol_str", +] + +[[package]] +name = "cel" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a40f338a8c3505921000b609279775792c07cc21f97a3011578c0c5e1738ae" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "pastey", + "regex", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -499,8 +846,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -586,6 +935,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -625,12 +988,72 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpex" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "apl-audit-logger", + "apl-cmf", + "apl-core", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pdp-cel", + "apl-pii-scanner", + "apl-session-valkey", + "cpex-core", +] + +[[package]] +name = "cpex-core" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "arc-swap", + "async-trait", + "chrono", + "cpex-orchestration", + "futures", + "hashbrown 0.15.5", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wildmatch", + "zeroize", +] + +[[package]] +name = "cpex-orchestration" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" +dependencies = [ + "futures", + "tokio", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -768,6 +1191,40 @@ dependencies = [ "petgraph 0.7.1", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -787,19 +1244,49 @@ version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" +dependencies = [ + "deadpool", + "redis", ] [[package]] -name = "data-encoding" -version = "2.11.0" +name = "deadpool-runtime" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] [[package]] name = "der-parser" @@ -834,6 +1321,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derivative" @@ -880,6 +1370,24 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "either" version = "1.16.0" @@ -889,6 +1397,35 @@ dependencies = [ "serde", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -913,7 +1450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -938,6 +1475,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "evmap" version = "11.0.0" @@ -1174,8 +1721,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1185,9 +1734,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1409,6 +1960,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -1428,13 +1995,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1548,6 +2118,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1577,6 +2153,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1587,6 +2164,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1618,6 +2197,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1669,6 +2254,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -1689,6 +2298,38 @@ dependencies = [ "libc", ] +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph 0.7.1", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1751,6 +2392,24 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1866,6 +2525,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1956,7 +2621,8 @@ checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "cfg-if", "miette-derive", - "unicode-width", + "serde", + "unicode-width 0.1.14", ] [[package]] @@ -2010,6 +2676,21 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.31.3" @@ -2039,6 +2720,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + [[package]] name = "notify" version = "8.2.0" @@ -2072,7 +2762,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2119,6 +2809,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -2161,6 +2860,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ouroboros" version = "0.18.5" @@ -2224,6 +2929,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -2261,6 +2972,21 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -2422,8 +3148,11 @@ version = "0.3.1" dependencies = [ "async-trait", "bytes", + "chrono", + "cpex", "dashmap 6.2.1", "http", + "jsonwebtoken", "percent-encoding", "praxis-proxy-core", "praxis-proxy-proto", @@ -2434,6 +3163,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "thiserror 2.0.18", "tokio", "tonic", @@ -2549,6 +3279,7 @@ dependencies = [ "bytes", "futures", "http", + "jsonwebtoken", "praxis-proxy-core", "praxis-proxy-filter", "praxis-proxy-protocol", @@ -2606,6 +3337,23 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec 0.5.2", + "typed-arena", + "unicode-width 0.2.2", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2777,6 +3525,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -2812,6 +3570,61 @@ dependencies = [ "winapi", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quixotic-plecostomus-cache" version = "0.8.2" @@ -2874,7 +3687,7 @@ dependencies = [ "log", "nix", "once_cell", - "openssl-probe", + "openssl-probe 0.1.6", "ouroboros", "parking_lot", "percent-encoding", @@ -2940,7 +3753,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec55d3bcbd56e52b3424bc62739a237d43c14df8ed04aaf294ae187b60da348a" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "hashbrown 0.17.1", "parking_lot", "rand 0.8.6", @@ -3010,7 +3823,7 @@ dependencies = [ "quixotic-plecostomus-error", "ring", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.7.3", "rustls-pemfile", "rustls-pki-types", "tokio-rustls", @@ -3170,6 +3983,35 @@ dependencies = [ "yasna", ] +[[package]] +name = "redis" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fd510128eda94d1d49b9f81487744d5c451422431cce41238fe2853d29f4cc" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs 0.8.4", + "ryu", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3188,6 +4030,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" version = "1.12.4" @@ -3217,6 +4079,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "ring" version = "0.17.14" @@ -3256,10 +4156,22 @@ version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "num-traits", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc-literal-escaper" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3288,7 +4200,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3313,11 +4225,23 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", ] [[package]] @@ -3335,6 +4259,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -3380,6 +4305,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3409,7 +4358,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3477,6 +4439,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -3496,6 +4459,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3542,6 +4537,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3573,6 +4578,24 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "sketches-ddsketch" version = "0.3.1" @@ -3594,6 +4617,16 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.4" @@ -3601,7 +4634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3765,12 +4798,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.60.2", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3843,6 +4901,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -3865,7 +4926,16 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.60.2", ] [[package]] @@ -4091,6 +5161,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -4182,6 +5253,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4291,6 +5380,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -4360,12 +5455,40 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4402,6 +5525,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4473,6 +5608,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.125" @@ -4515,6 +5660,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4543,6 +5698,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + [[package]] name = "winapi" version = "0.3.9" @@ -4565,7 +5726,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4932,6 +6093,12 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yaml_serde" version = "0.10.4" @@ -4957,7 +6124,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "time", ] @@ -5030,6 +6197,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index ca1c1449..7c2a0c03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,9 +39,10 @@ async-trait = "0.1.89" benchmarks = { path = "benchmarks" } bytes = "1.12.0" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } -dashmap = "6.2.1" +cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "702ab163c64fe762318cb2bc5fb9b088869dd1af", default-features = false, features = ["jwt", "oauth", "pii", "audit", "cedar", "cel", "valkey"] } clap = { version = "4.6.1", features = ["derive"] } criterion = { version = "0.8.2", features = ["async_tokio"] } +dashmap = "6.2.1" futures = "0.3.32" h2 = "0.4.15" http = "1.4.2" diff --git a/deny.toml b/deny.toml index c76a8c1a..de410eb9 100644 --- a/deny.toml +++ b/deny.toml @@ -35,4 +35,6 @@ highlight = "all" unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] +# CPEX policy-runtime crates (feature-gated behind `--features cpex`). +# Pinned by exact rev in Cargo.toml until the crates are published. +allow-git = ["https://github.com/contextforge-org/cpex"] diff --git a/docs/filters/http/security/cpex.md b/docs/filters/http/security/cpex.md new file mode 100644 index 00000000..a2183c17 --- /dev/null +++ b/docs/filters/http/security/cpex.md @@ -0,0 +1,35 @@ + + + +# `cpex` + +Configuration block for a `cpex` filter slot in a Praxis filter chain. + +Requires Cargo feature: `cpex`. + +## Configuration Notes + +Praxis filter configs are flat: the filter's typed fields sit directly under the `- filter:` entry alongside the structural keys (`name`, `conditions`), not nested under a `config:` wrapper. See `examples/configs/security/cpex.yaml` for a runnable example. + +The referenced YAML is the CPEX policy document — plugins, routes, and identity-source declarations. The filter loads it once at construction and rejects misconfigured policy at server startup (fail-fast rather than at first request). + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `config_path` | string | yes | Filesystem path to the CPEX YAML policy document. | +| `body_access` | `read_only` \| `read_write` | no | Body-access tier. `ReadOnly` (default) lets APL inspect request and response bodies for routing / policy decisions but discards any mutations. `ReadWrite` enables the CMF → JSON-RPC re-serialization round-trip so APL field mutators (e.g. `args.ssn: redact(!perm.view_ssn)`) rewrite the upstream body and response. Pay the round-trip cost only when needed. | +| `require_mcp_metadata` | bool | no | Fail-closed policy gate for misconfigured chains. When `true` (default), `on_request_body` rejects any request that reaches it without `mcp.method` filter-metadata. The metadata is set by praxis's built-in `mcp` filter, so its absence means either (a) the `mcp` filter is missing from the chain, or (b) it is ordered AFTER `cpex` instead of before. Either is a misconfiguration that would silently bypass CMF/APL policy. Set to `false` only when intentionally fronting non-MCP traffic through `cpex` for identity-only enforcement (legacy behavior). Note: MCP methods that legitimately carry no entity (e.g. `tools/list`, `initialize`, `prompts/list`) still pass — `require_mcp_metadata` only rejects when the metadata is missing entirely. | +| `init_timeout_secs` | u64 | no | Maximum time, in seconds, to wait for `PluginManager::initialize` at filter construction. Identity plugins fetch JWKS over HTTPS during init; a reachable-but-unresponsive identity provider would otherwise hang startup or hot-reload indefinitely. On expiry, filter construction returns an error and the server fails fast. 30s is generous for legitimate cold-cache JWKS fetches over the public internet, while short enough that misbehavior is noticed during the deploy. | +| `max_buffer_bytes` | usize | no | Maximum request/response body bytes buffered in `ReadWrite` mode. `ReadWrite` uses `StreamBuffer` to accumulate the whole body before APL field mutators run; without a cap an oversized payload could exhaust memory. Ignored in `ReadOnly` mode, which streams. The pipeline rejects an unbounded buffer at config load, so this always carries a concrete ceiling. | + +## Example + +```yaml +filter: cpex +config_path: /etc/praxis/cpex.yaml +body_access: read_write # optional; default read_only +require_mcp_metadata: true # optional; default true +init_timeout_secs: 30 # optional; default 30 +max_buffer_bytes: 10485760 # optional; default 10 MiB (read_write only) +``` diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 7bbdd90e..07650d6a 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -44,6 +44,7 @@ Built-in filters organized by protocol and category. | Filter | Feature | Description | |--------|---------|-------------| | [`cors`](http/security/cors.md) | - | Spec-compliant CORS filter implementing origin validation, preflight handling, and response header injection. | +| [`cpex`](http/security/cpex.md) | `cpex` | Configuration block for a `cpex` filter slot in a Praxis filter chain. | | [`credential_injection`](http/security/credential_injection.md) | - | Injects per-cluster API credentials into upstream requests. | | [`csrf`](http/security/csrf.md) | - | CSRF protection filter that validates request origins against a trusted allowlist. | | [`forwarded_headers`](http/security/forwarded_headers.md) | - | Injects `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers into upstream requests. | diff --git a/examples/README.md b/examples/README.md index cfa8e2c3..92ad4f77 100644 --- a/examples/README.md +++ b/examples/README.md @@ -129,6 +129,7 @@ page. | File | Description | | ------ | ------------- | | [cors.yaml](configs/security/cors.yaml) | Spec-compliant CORS filter with preflight handling, origin validation, and credential support | +| [cpex.yaml](configs/security/cpex.yaml) | Embeds the CPEX policy runtime in-process to enforce multi-source JWT identity, APL route policy, RFC 8693 OAuth 2.0 token exchange, PII scanning, audit emission, and (under `body_access: read_write`) request / response body rewriting | | [csrf.yaml](configs/security/csrf.yaml) | Cross-site request forgery protection via origin validation | | [downstream-read-timeout.yaml](configs/security/downstream-read-timeout.yaml) | Protects against slow client attacks by limiting how long the proxy waits for data from downstream clients | | [forwarded-headers.yaml](configs/security/forwarded-headers.yaml) | Injects X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host into upstream requests | diff --git a/examples/configs/security/cpex.yaml b/examples/configs/security/cpex.yaml new file mode 100644 index 00000000..14d6c9c3 --- /dev/null +++ b/examples/configs/security/cpex.yaml @@ -0,0 +1,78 @@ +# CPEX Security Filter +# +# Embeds the CPEX policy runtime in-process to enforce multi-source +# JWT identity, APL route policy, RFC 8693 OAuth 2.0 token exchange, +# PII scanning, audit emission, and (under `body_access: read_write`) +# request / response body rewriting. +# +# The cpex filter consumes `mcp.method` / `mcp.name` metadata stashed +# by praxis's built-in `mcp` filter, which MUST be ordered before it +# in the chain. +# +# `config_path` points at the CPEX policy YAML (plugins + routes) that +# the filter loads once at construction. It is operator-supplied, not +# shipped here: a real policy carries your own issuers, secrets, routes, +# PDPs, and delegators. See the praxis-demos repo +# (`demos/cpex/cpex.yaml`) for a fully-featured policy, and +# `tests/integration/fixtures/cpex-policy.yaml` for the minimal +# single-HS256-identity-plugin policy the integration test loads. +# +# `require_mcp_metadata: true` (the default) fail-closes when the +# `mcp` filter is missing or ordered after `cpex` — this guards +# against a misconfigured chain silently bypassing policy. +# +# This example builds only when the `cpex` cargo feature is enabled: +# +# cargo run --features cpex -p praxis -- \ +# -c examples/configs/security/cpex.yaml +# +# Exercise (assumes the backend at :3000 echoes 200): +# +# # Missing Authorization → 401 with WWW-Authenticate and +# # X-Cpex-Violation header: +# curl -i -X POST http://localhost:8080/mcp \ +# -H "Content-Type: application/json" \ +# -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", +# "params":{"name":"echo","arguments":{}}}' +# +# # Token rejected by HS256 signature mismatch → 401: +# curl -i -X POST http://localhost:8080/mcp \ +# -H "Authorization: Bearer bogus.token.bytes" \ +# -H "Content-Type: application/json" \ +# -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", +# "params":{"name":"echo","arguments":{}}}' +# +# Supply your own policy document at `config_path` before running this +# example; the minimal test fixture uses a placeholder shared secret +# that must not be used beyond local experimentation. + +listeners: + - name: default + address: "127.0.0.1:8080" + filter_chains: + - main + +filter_chains: + - name: main + filters: + - filter: mcp + # Default mode parses MCP JSON-RPC bodies and stashes + # mcp.method / mcp.name in filter_metadata. Must precede cpex. + + - filter: cpex + config_path: /etc/praxis/cpex-policy.yaml + # Fail-closed when mcp.method is missing. Set to `false` only + # when intentionally fronting non-MCP traffic through cpex for + # identity-only enforcement. + require_mcp_metadata: true + + - filter: router + routes: + - path_prefix: "/" + cluster: backend + + - filter: load_balancer + clusters: + - name: backend + endpoints: + - "127.0.0.1:3000" diff --git a/filter/Cargo.toml b/filter/Cargo.toml index 489c7366..2898c73e 100644 --- a/filter/Cargo.toml +++ b/filter/Cargo.toml @@ -17,6 +17,10 @@ name = "praxis_filter" default = ["ai-inference"] ai-inference = ["dep:secrecy", "dep:sqlx", "dep:tokio"] ext-proc = ["dep:praxis-proto", "dep:tonic", "dep:prost-wkt-types"] +cpex = [ + "dep:cpex", + "dep:tokio", +] [package.metadata.cargo-machete] ignored = ["praxis-proto", "prost-wkt-types", "tonic"] @@ -27,6 +31,7 @@ workspace = true [dependencies] async-trait = { workspace = true } bytes = { workspace = true } +cpex = { workspace = true, optional = true } dashmap = { workspace = true } http = { workspace = true } percent-encoding = { workspace = true } @@ -41,10 +46,18 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } sqlx = { workspace = true, optional = true } thiserror = { workspace = true } -tokio = { workspace = true, optional = true } +# Optional, shared by `ai-inference` and `cpex`. The `cpex` feature +# drives plugin `initialize()` at filter construction (the praxis +# filter-factory signature is sync, so we block on a single-thread +# runtime); the time/net features let JWT identity plugins fetch JWKS +# at init time. +tokio = { workspace = true, optional = true, features = ["rt", "time", "net"] } tonic = { workspace = true, optional = true } tracing = { workspace = true } zeroize = { workspace = true } [dev-dependencies] +chrono = { workspace = true } +jsonwebtoken = "9" +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/filter/src/builtins/http/mod.rs b/filter/src/builtins/http/mod.rs index cb933519..7c97cf75 100644 --- a/filter/src/builtins/http/mod.rs +++ b/filter/src/builtins/http/mod.rs @@ -40,6 +40,8 @@ pub use ai::token_usage::{TokenUsage, TokenUsageProvider, extract_token_usage}; pub use ai::{A2aFilter, JsonRpcFilter, McpFilter, TokenUsageHeadersFilter}; pub use observability::{AccessLogFilter, RequestIdFilter}; pub use payload_processing::{CompressionFilter, JsonBodyFieldFilter}; +#[cfg(feature = "cpex")] +pub use security::CpexFilter; pub use security::{ ContainsValue, CorsFilter, CredentialInjectionFilter, CsrfFilter, DisallowedOriginMode, ForwardedHeadersFilter, GuardrailsAction, GuardrailsFilter, IpAclFilter, PiiKind, RuleTargetKind, diff --git a/filter/src/builtins/http/security/cpex/README.md b/filter/src/builtins/http/security/cpex/README.md new file mode 100644 index 00000000..e60d42f4 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/README.md @@ -0,0 +1,225 @@ +# cpex filter + +The `cpex` filter embeds the [CPEX](https://github.com/contextforge-org/cpex) +policy runtime inside a Praxis HTTP filter. It resolves identity, evaluates +policies based on routes, consults a PDP (Cedar or CEL), mints delegated tokens, +and run actions (e.g., scans for PII, emits audit records, tracks session taint, +and rewrites request and response bodies). Everything runs in-process: the CPEX +runtime are Rust crates linked into the proxy. + +It is feature-gated. Build with `--features cpex` to compile and register it. + +## Why use this filter + +A PDP or authorization rules engine answers one question: given this input, is the +action allowed? That verdict still has to be wired into something. Real authorization +resolves identity first, often consults more than one engine, mints a token for +the downstream call, strips sensitive fields from the payload, and writes an +audit record, in the right order with the right short-circuits. That +orchestration is normally bespoke code in every gateway. + +CPEX makes the orchestration declarative. A policy is a per-entity chain of +steps. The PDP is one step in the chain, and the steps around it express the +rest: cheap predicates run first, the PDP runs only for requests +that clear them, `delegate(...)` mints a downstream-scoped token, `redact(...)` +rewrites a field on the wire, `run(...)` invokes a guardrail or audit plugin, +and `taint(...)` records a session label that a later request can act on. + +## Where it sits in the chain + +The filter consumes the metadata Praxis's built-in `mcp` filter produces, so the +`mcp` filter must run before it: + +```text +mcp -> cpex -> router -> load_balancer +``` + +`mcp` parses the JSON-RPC body and writes `mcp.method` and `mcp.name` into filter +metadata. `cpex` reads that metadata to pick the matching policy route. With +`require_mcp_metadata: true` (the default) a request that reaches `cpex` without +`mcp.method` is rejected, which catches a chain that is missing `mcp` or has it +ordered after `cpex`. + +## Configuration + +The filter's fields sit directly under the `- filter:` entry. There is no +`config:` wrapper. + +```yaml +filters: + - filter: cpex + config_path: /etc/praxis/cpex.yaml # required + body_access: read_write # optional; default read_only + require_mcp_metadata: true # optional; default true + init_timeout_secs: 30 # optional; default 30 + max_buffer_bytes: 10485760 # optional; default 10 MiB (read_write only) +``` + +| Field | Default | Purpose | +|---|---|---| +| `config_path` | required | Path to the CPEX policy document (the YAML below). Read once at startup; a parse or plugin-init error fails the build. | +| `body_access` | `read_only` | `read_only` buffers the body for inspection and discards mutations. `read_write` re-serializes field mutations (`redact`, `assign`) back into the request and response. | +| `require_mcp_metadata` | `true` | Reject any request that reaches the filter without `mcp.method` metadata. Set `false` only to front non-MCP traffic for identity-only enforcement. | +| `init_timeout_secs` | `30` | Time budget for `PluginManager::initialize` at startup (identity plugins fetch JWKS over HTTPS). On expiry the build fails fast. | +| `max_buffer_bytes` | `10485760` | Max body bytes buffered in `read_write` mode (10 MiB). Bounds per-request memory against oversized payloads. Ignored in `read_only` mode. | + +## The policy document + +`config_path` points at the CPEX policy. It has three parts: a `plugins` toolbox, +a `global` block, and a `routes` block. The routes are the policy; everything +else is what they pull from. Each route's `policy` is an ordered list of APL +(Authorization Policy Logic) steps. + +```yaml +plugins: + - name: jwt-user # identity/jwt: validates X-User-Token + - name: jwt-client # identity/jwt: validates Authorization + - name: workday-oauth # delegator/oauth: RFC 8693 token exchange + - name: pii-scan # validator/pii-scan: PII detection + - name: audit-log # audit/logger: structured audit records + +global: + identity: [jwt-user, jwt-client] + pdp: + - kind: cel # inline CEL expressions; use cedar-direct for Cedar + +routes: + - tool: get_compensation + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "taint(secret, session)" + - "run(audit-log)" + args: + ssn: "str | redact(!perm.view_ssn)" + result: + ssn: "str | redact(!perm.view_ssn)" + + - tool: search_repos + policy: + - "require(team.engineering)" + - cel: + expr: | + has(role.engineer) && role.engineer && args.visibility == "internal" + on_deny: + - "deny('engineering may read internal repos only', 'cel.policy_denied')" +``` + +### APL step vocabulary + +| Step | Effect | +|---|---| +| `require(predicate)` | Deny unless the predicate holds. | +| `: deny('reason', 'code')` | Deny with a reason and violation code when the predicate holds. | +| `cedar: { ... }` / `cel: { expr: ... }` | Consult the registered PDP. `on_allow` / `on_deny` attach reactions. | +| `delegate(plugin, target:, audience:, permissions:)` | Mint an audience-scoped token (RFC 8693) and attach it as an upstream header. | +| `run(name)` | Invoke a named plugin (PII scan, audit). `plugin(name)` is the same step. | +| `taint(label, session)` | Record a session label. See Sessions and taint. | +| `args.: "... \| redact(...) \| mask(n)"` | Rewrite a request argument (needs `body_access: read_write`). | +| `result.: "... \| redact(...)"` | Rewrite a response field on the way back. | + +Steps run in order and the chain short-circuits on the first deny. Order +deliberately: place `run(audit-log)` before a step that may deny so the attempt +is recorded. + +### PDP backends + +The filter registers two PDP factories: `cedar-direct` (Cedar policy sets) and +`cel` (inline CEL boolean expressions). A route selects one with a `cedar:` or +`cel:` step; the global `pdp` block declares which engine is configured. Both are +compiled into the same binary. + +The example above uses CEL. The Cedar form declares a policy set in the global +`pdp` block and calls it from a `cedar:` step, passing the resource built from +the request: + +```yaml +global: + pdp: + - kind: cedar-direct + policy_text: | + permit(principal, action == Action::"read", resource is Repo) + when { principal.roles.contains("engineer") && resource.visibility == "internal" }; + +routes: + - tool: search_repos + policy: + - "require(team.engineering)" + - cedar: + action: 'Action::"read"' + resource: + type: Repo + id: ${args.repo_name} + attributes: + visibility: ${args.visibility} +``` + +CEL reads request attributes inline (`args.visibility`); Cedar takes them as an +explicit `resource`. Both reach the same allow or deny. + +## Identity + +Each `identity/jwt` plugin reads its own configured header (for example +`Authorization` for the client, `X-User-Token` for the user) and validates the +JWT against the issuer's live JWKS. One request can carry several identities at +once. The filter runs an early identity gate in the request phase: a request with +no valid token is rejected with HTTP 401 before the body is buffered. + +## Sessions and taint + +`taint(label, session)` records a label that persists across requests in the same +session. A later route reads it with `security.labels contains "label"` and acts +on it. This is a cross-tool, cross-request data-flow control: reading a secret in +one call can block sending mail in a later call. + +The session is identified by the `X-Session-Id` request header. The filter maps +it to `agent.session_id`, and CPEX binds it to the resolved subject as +`H(subject : session_id)`. The same session id under a different subject is a +different bucket, so taint never crosses principals. When the header is absent, +CPEX derives a session id from identity instead. + +The session store is in-memory and per process. Labels reset when the proxy +restarts or hot-reloads. + +## Request and response phases + +- Request phase: after the full body is buffered, the filter dispatches the + pre-invoke CMF hook for the route's entity. A deny becomes a rejection. On + allow, delegated tokens are attached as upstream headers, and with + `body_access: read_write` any mutated arguments are written back into the body. +- Response phase: the filter dispatches the post-invoke hook. `result.` + redactions run here, so a value the backend returns unsolicited is still + stripped for a caller without the permission. A post-phase deny replaces the + response body with a JSON-RPC error envelope. + +The response status and headers are already committed by the time the body phase +runs, so a rewritten response body is fitted to the original Content-Length: it +is padded with trailing whitespace when shorter (JSON parsers ignore it), and a +rewrite that would grow the body fails closed to a length-fitting deny envelope +rather than desyncing HTTP framing. + +## Decisions and denials + +| Outcome | Wire shape | +|---|---| +| Identity / transport failure | HTTP 401, `WWW-Authenticate: Bearer`, `X-Cpex-Violation: `. | +| Policy deny (PDP, predicate, PII, taint, delegation) | HTTP 200 with a JSON-RPC error envelope (`code -32001`), `X-Cpex-Violation: `. Per the MCP Tools spec, gateway denials are JSON-RPC errors, not HTTP 4xx. | +| Missing `mcp.method` metadata | HTTP 500 (server-side misconfiguration). | + +`X-Cpex-Violation` echoes the violation code (for example `apl.policy`, +`cedar.default_deny`, `pii.detected`, `session_tainted_secret`) so audit and +access-log pipelines can classify denials without parsing the body. + +## Runtime requirements + +The response phase drives async work with `block_in_place`, which requires a +multi-threaded tokio runtime. Run the proxy with `work_stealing: true`. On a +current-thread runtime the filter rejects every request with a clear error rather +than panicking mid-response. + +## See also + +- `examples/configs/security/cpex.yaml` for a runnable filter config. +- The CPEX HR demo in the [praxis-demos](https://github.com/praxis-proxy/demos) repository for an end-to-end walkthrough + (identity, Cedar and CEL PDPs, delegation, redaction, PII scanning, session + taint) with the Bob, Eve, and Alice personas. diff --git a/filter/src/builtins/http/security/cpex/cmf.rs b/filter/src/builtins/http/security/cpex/cmf.rs new file mode 100644 index 00000000..56b7c278 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/cmf.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! MCP method → CMF (Common Message Format) entity-coords mapping. +//! +//! Praxis's built-in `mcp` filter parses MCP JSON-RPC bodies and +//! stashes `mcp.method` / `mcp.name` in `ctx.filter_metadata`. This +//! module consumes that metadata and resolves the matching CMF +//! hook name + entity-type discriminator so APL routes annotated to +//! `tools/`, `prompts/`, or `resources/` get evaluated +//! against the right pre/post hook. +//! +//! # Scope of CMF dispatch +//! +//! CMF dispatch is intentionally narrow. Only the three MCP methods +//! that carry a routable entity participate: +//! +//! | MCP method | Entity type | Pre-hook | Post-hook | +//! |-------------------|-------------|----------------------------|-----------------------------| +//! | `tools/call` | tool | `cmf.tool.pre_invoke` | `cmf.tool.post_invoke` | +//! | `prompts/get` | prompt | `cmf.prompt.pre_invoke` | `cmf.prompt.post_invoke` | +//! | `resources/read` | resource | `cmf.resource.pre_fetch` | `cmf.resource.post_fetch` | +//! +//! Every other MCP method (`initialize`, `tools/list`, `prompts/list`, +//! `resources/list`, `resources/subscribe`, `ping`, `notifications/*`, +//! `roots/*`, `sampling/*`, plus anything an MCP extension adds) is +//! **identity-only by design**: the `on_request` identity gate still +//! runs, but `on_request_body` returns `BodyDone` without dispatching +//! CMF. The premise is that APL `route:` policy applies to entity +//! invocations, not to discovery / control-plane traffic. Operators +//! who need policy on a list operation can still gate it via praxis +//! filter `conditions:` on path / method. +//! +//! Adding a new entity-bearing MCP method here is a deliberate choice +//! (route annotations, hook constants, and identity-stripping +//! semantics all need to line up). The two `entity_for_mcp_method*` +//! functions are the closed switch — anything not listed falls +//! through to the identity-only path. + +use cpex::cpex_core::cmf::constants::{ + ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE, + HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, +}; + +// ----------------------------------------------------------------------------- +// Pre-phase +// ----------------------------------------------------------------------------- + +/// Map an MCP method to `(entity_type, pre_hook_name)` for the +/// request-phase CMF dispatch. Returns `None` for methods that don't +/// carry an entity (`tools/list`, `initialize`, `prompts/list`, etc.) — +/// in those cases identity still runs but CMF dispatch is skipped. +pub(super) fn entity_for_mcp_method(method: &str) -> Option<(&'static str, &'static str)> { + match method { + "tools/call" => Some((ENTITY_TOOL, HOOK_CMF_TOOL_PRE_INVOKE)), + "prompts/get" => Some((ENTITY_PROMPT, HOOK_CMF_PROMPT_PRE_INVOKE)), + "resources/read" => Some((ENTITY_RESOURCE, HOOK_CMF_RESOURCE_PRE_FETCH)), + _ => None, + } +} + +// ----------------------------------------------------------------------------- +// Post-phase +// ----------------------------------------------------------------------------- + +/// Post-phase mirror of [`entity_for_mcp_method`]. Maps the same +/// methods to the CMF `*_post_invoke` / `*_post_fetch` hook names so +/// `on_response_body` can dispatch APL `result:` pipelines. +/// +/// The method is read from `ctx.filter_metadata` — praxis's `mcp` +/// filter stashes it during the request phase and it persists across +/// the request/response lifecycle in the same context object. +pub(super) fn entity_for_mcp_method_post(method: &str) -> Option<(&'static str, &'static str)> { + match method { + "tools/call" => Some((ENTITY_TOOL, HOOK_CMF_TOOL_POST_INVOKE)), + "prompts/get" => Some((ENTITY_PROMPT, HOOK_CMF_PROMPT_POST_INVOKE)), + "resources/read" => Some((ENTITY_RESOURCE, HOOK_CMF_RESOURCE_POST_FETCH)), + _ => None, + } +} diff --git a/filter/src/builtins/http/security/cpex/config.rs b/filter/src/builtins/http/security/cpex/config.rs new file mode 100644 index 00000000..cb148c8b --- /dev/null +++ b/filter/src/builtins/http/security/cpex/config.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Deserialized YAML configuration for the CPEX security filter. + +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// CpexFilterConfig +// ----------------------------------------------------------------------------- + +/// Configuration block for a `cpex` filter slot in a Praxis filter chain. +/// +/// Praxis filter configs are flat: the filter's typed fields sit +/// directly under the `- filter:` entry alongside the structural keys +/// (`name`, `conditions`), not nested under a `config:` wrapper. See +/// `examples/configs/security/cpex.yaml` for a runnable example. +/// +/// ```yaml +/// filters: +/// - filter: cpex +/// config_path: /etc/praxis/cpex.yaml +/// body_access: read_write # optional; default read_only +/// require_mcp_metadata: true +/// ``` +/// +/// The referenced YAML is the CPEX policy document — plugins, routes, +/// and identity-source declarations. The filter loads it once at +/// construction and rejects misconfigured policy at server startup +/// (fail-fast rather than at first request). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpexFilterConfig { + /// Filesystem path to the CPEX YAML policy document. + pub config_path: String, + + /// Body-access tier. `ReadOnly` (default) lets APL inspect request + /// and response bodies for routing / policy decisions but discards + /// any mutations. `ReadWrite` enables the CMF → JSON-RPC + /// re-serialization round-trip so APL field mutators + /// (e.g. `args.ssn: redact(!perm.view_ssn)`) rewrite the upstream + /// body and response. Pay the round-trip cost only when needed. + #[serde(default)] + pub body_access: BodyAccessMode, + + /// Fail-closed policy gate for misconfigured chains. When `true` + /// (default), `on_request_body` rejects any request that reaches + /// it without `mcp.method` filter-metadata. The metadata is set + /// by praxis's built-in `mcp` filter, so its absence means either + /// (a) the `mcp` filter is missing from the chain, or (b) it is + /// ordered AFTER `cpex` instead of before. Either is a + /// misconfiguration that would silently bypass CMF/APL policy. + /// + /// Set to `false` only when intentionally fronting non-MCP + /// traffic through `cpex` for identity-only enforcement (legacy + /// behavior). + /// + /// Note: MCP methods that legitimately carry no entity (e.g. + /// `tools/list`, `initialize`, `prompts/list`) still pass — + /// `require_mcp_metadata` only rejects when the metadata is + /// missing entirely. + #[serde(default = "default_true")] + pub require_mcp_metadata: bool, + + /// Maximum time, in seconds, to wait for `PluginManager::initialize` + /// at filter construction. Identity plugins fetch JWKS over HTTPS + /// during init; a reachable-but-unresponsive identity provider + /// would otherwise hang startup or hot-reload indefinitely. On + /// expiry, filter construction returns an error and the server + /// fails fast. + /// + /// 30s is generous for legitimate cold-cache JWKS fetches over the + /// public internet, while short enough that misbehavior is noticed + /// during the deploy. + #[serde(default = "default_init_timeout_secs")] + pub init_timeout_secs: u64, + + /// Maximum request/response body bytes buffered in `ReadWrite` + /// mode. `ReadWrite` uses `StreamBuffer` to accumulate the whole + /// body before APL field mutators run; without a cap an oversized + /// payload could exhaust memory. Ignored in `ReadOnly` mode, which + /// streams. The pipeline rejects an unbounded buffer at config + /// load, so this always carries a concrete ceiling. + #[serde(default = "default_max_buffer_bytes")] + pub max_buffer_bytes: usize, +} + +/// `#[serde(default = ...)]` requires a free function for primitives +/// without a `Default` impl that returns the desired value. `true` is +/// the safer default for `require_mcp_metadata`. +fn default_true() -> bool { + true +} + +/// Default upper bound on `PluginManager::initialize` (seconds). +fn default_init_timeout_secs() -> u64 { + 30 +} + +/// Default `ReadWrite` body buffer ceiling. 10 MiB comfortably covers +/// JSON-RPC tool-call payloads while bounding per-request memory. +fn default_max_buffer_bytes() -> usize { + 10_485_760 // 10 MiB +} + +/// What APL field-pipeline mutators on `args.` and +/// `result.` are allowed to do to the upstream body and +/// downstream response. +/// +/// Mirrors `praxis_filter::BodyAccess` but lifts the decision to +/// operator configuration: the choice changes pipeline behavior (and +/// cost), so a per-filter knob is the right granularity. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BodyAccessMode { + /// Body is buffered for inspection / routing; mutations are + /// discarded. APL `require()` predicates over body content + /// (`args.amount > 1000`) work; `redact()` / `assign()` are + /// silently dropped at the executor's write boundary. + #[default] + ReadOnly, + + /// Body is buffered + APL mutations to `args.*` and `result.*` are + /// re-serialized back into the JSON-RPC body so the upstream and + /// the downstream client see them. Costs one JSON parse + + /// serialize per mutated request or response. + ReadWrite, +} diff --git a/filter/src/builtins/http/security/cpex/error.rs b/filter/src/builtins/http/security/cpex/error.rs new file mode 100644 index 00000000..da788a68 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/error.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Maps CPEX `PluginViolation`s to praxis `Rejection`s. + +use bytes::Bytes; +use cpex::cpex_core::error::PluginViolation; + +use crate::Rejection; + +// ----------------------------------------------------------------------------- +// auth_rejection (transport-level deny — HTTP 401) +// ----------------------------------------------------------------------------- + +/// JSON-RPC error code for gateway-side denials. Lives in the +/// implementation-defined `-32000` to `-32099` range carved out by the +/// JSON-RPC 2.0 spec for server errors. One code covers all of +/// `apl.policy`, `cedar.*`, `pii.*`, `delegation.*`, etc. — the +/// specific violation goes in `data.violation` so MCP clients can +/// switch on a single code while still seeing the underlying reason. +const MCP_GATEWAY_DENIED_CODE: i64 = -32001; + +/// **Public response-header contract.** Echoes the originating +/// `PluginViolation.code` (e.g. `auth.invalid_token`, `apl.policy`, +/// `pii.detected`) on every CPEX-emitted rejection so audit pipelines, +/// access logs, and downstream proxies can classify denials without +/// parsing the body. Sent on: +/// +/// * HTTP 401 ([`auth_rejection`]) — identity / transport-level deny. +/// * HTTP 200 ([`mcp_error_rejection`]) — application-level deny wrapped in a JSON-RPC error envelope. +/// * HTTP 500 (`missing_mcp_metadata_rejection`) — `mcp.method` missing from filter metadata. +/// +/// Operators consuming this in audit / SIEM pipelines should treat the +/// header value as a stable identifier (the code namespace is part of +/// the API contract). The codes themselves are minor information +/// disclosure — they name the rule that fired but never carry user +/// data or claims; acceptable on the deny path. +pub(super) const VIOLATION_HEADER: &str = "X-Cpex-Violation"; + +/// Build an HTTP 401 rejection for transport-level authentication +/// failures (missing / invalid / wrong-audience JWT). Per the MCP +/// Authorization spec, identity failures are reported as HTTP 401 +/// with a `WWW-Authenticate` header — clients are expected to react +/// to the status + header, not parse the body. The body is included +/// only as a short human-readable diagnostic. +/// +/// The violation's `code` is also surfaced via the +/// [`VIOLATION_HEADER`] response header so middleware (audit, logging, +/// downstream proxies) can classify denials without parsing the body. +/// +/// TODO: once the gateway exposes its own `OAuth` Protected Resource +/// Metadata document, the `WWW-Authenticate` header should point at +/// it per RFC 9728 (`Bearer resource_metadata="..."`). Today we send +/// the minimum-compliant header. +pub(super) fn auth_rejection(violation: Option<&PluginViolation>) -> Rejection { + let (code, reason) = match violation { + Some(v) => (v.code.clone(), v.reason.clone()), + None => ("auth.unknown".to_owned(), "authentication required".to_owned()), + }; + let body = format!("{code}: {reason}"); + Rejection::status(401) + .with_header("WWW-Authenticate", "Bearer") + .with_header(VIOLATION_HEADER, code) + .with_body(Bytes::from(body.into_bytes())) +} + +// ----------------------------------------------------------------------------- +// mcp_error_rejection (application-level deny — HTTP 200 + JSON-RPC error) +// ----------------------------------------------------------------------------- + +/// Build an MCP-compliant JSON-RPC error envelope for application-level +/// denials (policy / PDP / PII / delegation failure / internal errors) +/// that the gateway catches BEFORE the upstream tool runs. +/// +/// Per the MCP Tools spec ("Error Handling"), these are *protocol* +/// errors reported via JSON-RPC error envelopes inside an HTTP 200 +/// response — not HTTP 4xx — so MCP clients can correlate the failure +/// to the original request `id` and surface the violation through +/// their normal error UI. +/// +/// Shape (matches the JSON-RPC 2.0 schema referenced by MCP): +/// +/// ```json +/// { +/// "jsonrpc": "2.0", +/// "id": , +/// "error": { +/// "code": -32001, +/// "message": "", +/// "data": { "violation": "" } +/// } +/// } +/// ``` +pub(super) fn mcp_error_rejection(violation: Option<&PluginViolation>, request_id: &serde_json::Value) -> Rejection { + let bytes = mcp_error_envelope_bytes(violation, request_id); + let violation_code = violation.map_or_else(|| "gateway.unknown".to_owned(), |v| v.code.clone()); + Rejection::status(200) + .with_header("Content-Type", "application/json") + .with_header(VIOLATION_HEADER, violation_code) + .with_body(bytes) +} + +/// Build only the JSON-RPC error envelope bytes (no HTTP status, no +/// headers). Used by both: +/// +/// * [`mcp_error_rejection`] — pre-upstream denies, where we get to build a full `Rejection` including headers. +/// * `on_response_body` — post-phase denies, where the HTTP status and headers have already been sent to the client; +/// the only thing left to mutate is the body bytes. Replacing the upstream response body with this envelope is the +/// strongest enforcement available from the response body phase under the current praxis API. +pub(super) fn mcp_error_envelope_bytes(violation: Option<&PluginViolation>, request_id: &serde_json::Value) -> Bytes { + let (violation_code, reason) = match violation { + Some(v) => (v.code.clone(), v.reason.clone()), + None => ("gateway.unknown".to_owned(), "denied by gateway".to_owned()), + }; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": MCP_GATEWAY_DENIED_CODE, + "message": reason, + "data": { "violation": violation_code }, + } + }); + // The envelope above is built entirely from owned `String`s and a + // pre-parsed `request_id` Value, so `to_vec` is infallible in + // practice. Fall back to a static, valid deny envelope rather than + // an empty body if that ever changes: every caller is a deny path + // that replaces the response body, so an empty body would weaken + // (never strengthen) enforcement, and panicking mid-response-phase + // (`block_in_place`) is worse still. + Bytes::from(serde_json::to_vec(&body).unwrap_or_else(|_| FALLBACK_DENY_ENVELOPE.to_vec())) +} + +/// Static, always-valid JSON-RPC deny envelope used only if serializing +/// the dynamic envelope in [`mcp_error_envelope_bytes`] ever fails. +/// Keeps the deny path total without emitting an empty (fail-open) body. +const FALLBACK_DENY_ENVELOPE: &[u8] = + br#"{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"denied by gateway","data":{"violation":"gateway.unknown"}}}"#; diff --git a/filter/src/builtins/http/security/cpex/filter.rs b/filter/src/builtins/http/security/cpex/filter.rs new file mode 100644 index 00000000..c87a6909 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/filter.rs @@ -0,0 +1,881 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! `CpexFilter` — embeds the CPEX runtime in-process to resolve and +//! validate identity, evaluate APL routes, optionally mint delegated +//! credentials, scan for PII, emit audit records, and optionally +//! rewrite request/response bodies. + +// The orchestration functions in this module (`on_request_body`, +// `on_response_body`, `new`) coordinate identity resolution, CMF +// dispatch, delegated-token attachment, and body re-serialization in +// linear steps. Splitting them to satisfy `too_many_lines` / +// `cognitive_complexity` would obscure the request/response phase +// flow without reducing real complexity. +#![allow( + clippy::too_many_lines, + clippy::cognitive_complexity, + reason = "orchestration functions; splitting obscures phase flow" +)] + +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; + +use async_trait::async_trait; +use bytes::Bytes; +use cpex::cpex_core::{ + cmf::{CmfHook, Message, MessagePayload, Role}, + error::{PluginError, PluginViolation}, + hooks::Extensions, + identity::{HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource}, + manager::PluginManager, +}; + +use super::{ + cmf::{entity_for_mcp_method, entity_for_mcp_method_post}, + config::{BodyAccessMode, CpexFilterConfig}, + error::{VIOLATION_HEADER, auth_rejection, mcp_error_envelope_bytes, mcp_error_rejection}, + json_rpc::{ + build_content_for_method, build_response_content_for_method, json_rpc_id, json_rpc_id_value, + reserialize_json_rpc_body, reserialize_json_rpc_response_body, + }, +}; +use crate::{ + FilterAction, FilterError, Rejection, + body::{BodyAccess, BodyMode}, + factory::parse_filter_config, + filter::{HttpFilter, HttpFilterContext}, +}; + +// ----------------------------------------------------------------------------- +// CpexFilter +// ----------------------------------------------------------------------------- + +// State of the one-shot tokio runtime-flavor check performed on the +// first request. See `CpexFilter::on_request` for the rationale. + +/// Initial state — no request has been served yet. +const RUNTIME_UNCHECKED: u8 = 0; +/// First request saw a multi-thread runtime; subsequent requests skip the check. +const RUNTIME_OK: u8 = 1; +/// First request saw a current-thread runtime; all requests reject. +const RUNTIME_REJECTED: u8 = 2; + +/// Filter that runs the CPEX identity + APL pipeline against each +/// request. +/// +/// A single request can carry multiple identity sources — user JWT in +/// `Authorization`, agent JWT in `X-Agent-Token`, workload JWT in +/// `X-Workload-Token`, etc. Each registered identity plugin reads its +/// own configured header and contributes to a typed `Extensions` +/// context. +/// +/// On the body phase, the filter consumes praxis's `mcp` filter +/// metadata to dispatch the matching CMF hook chain. APL routes +/// (declared in the CPEX YAML) gate the tool/prompt/resource call by +/// role, attribute, or Cedar PDP decision. `delegate(...)` steps mint +/// audience-scoped tokens (RFC 8693) that the allow path attaches as +/// upstream headers. +/// +/// `body_access: read_write` enables the JSON-RPC re-serialization +/// round-trip so APL field mutators (`redact()`, `assign()`) rewrite +/// the upstream request body and the downstream response. +/// +/// # YAML configuration +/// +/// Filter fields sit directly under the `- filter:` entry; there is no +/// `config:` wrapper. See `examples/configs/security/cpex.yaml` for a +/// runnable example. +/// +/// ```yaml +/// filter: cpex +/// config_path: /etc/praxis/cpex.yaml +/// body_access: read_write # optional; default read_only +/// require_mcp_metadata: true # optional; default true +/// init_timeout_secs: 30 # optional; default 30 +/// max_buffer_bytes: 10485760 # optional; default 10 MiB (read_write only) +/// ``` +pub struct CpexFilter { + /// Filter-level configuration parsed from the YAML block. Held so + /// `request_body_access` / `request_body_mode` / their response + /// counterparts can branch on `body_access` per request. + cfg: CpexFilterConfig, + /// CPEX plugin manager — owns the loaded plugin instances and + /// dispatches hook chains. Wrapped in `Arc` so the post-phase + /// `block_in_place` closure can hold its own handle without + /// borrowing `&self`. + mgr: Arc, + /// One-shot runtime-flavor check. `on_response_body` drives async + /// work via `block_in_place`, which panics on a current-thread + /// runtime (praxis `work_stealing: false`). We can't query the + /// flavor from `new()` (no runtime attached yet), so we check on + /// the first request and cache the result. A fuller fix would + /// require `on_response_body` to be async upstream in praxis. + runtime_check: AtomicU8, +} + +impl CpexFilter { + /// Construct a filter from a parsed config. Loads the CPEX YAML + /// referenced by `cfg.config_path`, registers bundled plugin + /// factories, wires the APL visitor, and initializes the manager. + /// Errors abort filter chain construction at server startup — + /// failing fast is what we want for misconfigured policy. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the referenced YAML cannot be read, + /// the policy document fails to parse, or plugin initialization + /// fails (e.g., a JWKS endpoint is unreachable). + pub fn new(cfg: CpexFilterConfig) -> Result { + let yaml = std::fs::read_to_string(&cfg.config_path).map_err(|e| -> FilterError { + format!("cpex: failed to read config_path {}: {e}", cfg.config_path).into() + })?; + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + + mgr.load_config_yaml(&yaml) + .map_err(|e: Box| -> FilterError { format!("cpex: load_config_yaml failed: {e}").into() })?; + + // `initialize()` is async. The praxis filter-factory signature + // is sync, so we drive init to completion here. We spawn a + // dedicated OS thread to build a single-threaded runtime and + // call `block_on` there — running `block_on` on the current + // thread would panic if any caller (notably `#[tokio::test]`) + // already has a runtime attached. Production startup has no + // caller runtime; tests do; the thread hop is correct in both. + // + // The init future is wrapped in `tokio::time::timeout` so a + // misbehaving plugin's `initialize()` future can't hang startup + // / hot-reload indefinitely. The bundled identity-jwt plugin + // already has its own JWKS connect/request timeouts plus + // soft-fail-at-boot, so this is defense-in-depth for other + // init paths (custom plugins, future hooks) where a future + // could legitimately stall. + let mgr_for_init = Arc::clone(&mgr); + let init_timeout = std::time::Duration::from_secs(cfg.init_timeout_secs); + let init: Result<(), String> = std::thread::spawn(move || -> Result<(), String> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("cpex: failed to build init runtime: {e}"))?; + rt.block_on(async move { + match tokio::time::timeout(init_timeout, mgr_for_init.initialize()).await { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(format!("cpex: PluginManager::initialize failed: {e}")), + Err(_) => Err(format!( + "cpex: PluginManager::initialize timed out after {}s \ + (init_timeout_secs); likely a JWKS / OAuth endpoint is unreachable", + init_timeout.as_secs(), + )), + } + }) + }) + .join() + .map_err(|panic| { + let msg = panic + .downcast_ref::<&str>() + .map(|s| (*s).to_owned()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_owned()); + format!("cpex: PluginManager::initialize panicked in init thread: {msg}") + })?; + init.map_err(|s: String| -> FilterError { s.into() })?; + + Ok(Self { + cfg, + mgr, + runtime_check: AtomicU8::new(RUNTIME_UNCHECKED), + }) + } + + /// Praxis-side factory hook, wired via `register_http` in + /// `filter/src/registry.rs`. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the config block fails to parse + /// as a `CpexFilterConfig` or filter construction fails. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: CpexFilterConfig = parse_filter_config("cpex", config)?; + let filter = Self::new(cfg)?; + Ok(Box::new(filter)) + } + + /// Snapshot the request's HTTP headers into a case-normalized + /// map. Each registered identity plugin reads its own configured + /// header from this map. + /// + /// Keys are normalized to ASCII lowercase. HTTP header names are + /// case-insensitive (RFC 7230 §3.2) but the `HashMap` lookup is + /// case-sensitive; plugins lowercase their configured header + /// before lookup to match. + fn snapshot_headers(ctx: &HttpFilterContext<'_>) -> std::collections::HashMap { + ctx.request + .headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|v| (name.as_str().to_ascii_lowercase(), v.to_owned())) + }) + .collect() + } + + /// Build a fresh `IdentityPayload` from request headers. `raw_token` + /// is left empty: each registered identity plugin reads its own + /// configured header from `headers` instead. + fn identity_payload(ctx: &HttpFilterContext<'_>) -> IdentityPayload { + IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(Self::snapshot_headers(ctx)) + } + + /// Build the `Extensions` to feed CMF dispatch. Re-resolves + /// identity (cheap — the JWT verifier hits its in-process key + /// cache), applies the resolved subject / roles / claims / + /// raw-credentials, and stamps `MetaExtension.entity_type` / + /// `entity_name` so route resolution in cpex-core picks the right + /// route annotation. + #[expect( + clippy::large_stack_frames, + reason = "async handler holding large CMF Extensions across await points" + )] + async fn build_cmf_extensions( + &self, + ctx: &HttpFilterContext<'_>, + entity_type: &str, + entity_name: &str, + ) -> Result { + // Snapshot the request headers once and reuse them for both + // identity resolution and the `x-session-id` lookup below, + // rather than cloning the whole header map twice per phase. + let headers = Self::snapshot_headers(ctx); + let session_id = headers.get("x-session-id").filter(|value| !value.is_empty()).cloned(); + + let (id_result, _bg) = self + .mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(headers), + Extensions::default(), + None, + ) + .await; + if !id_result.continue_processing { + return Err(auth_rejection(id_result.violation.as_ref())); + } + + let identity = IdentityPayload::from_pipeline_result(&id_result).ok_or_else(|| { + Rejection::status(500).with_body(Bytes::from_static(b"cpex: identity result missing modified payload")) + })?; + let mut ext = identity.apply_to_extensions(Extensions::default()); + + let mut meta = ext.meta.as_ref().map(|arc| (**arc).clone()).unwrap_or_default(); + meta.entity_type = Some(entity_type.to_owned()); + meta.entity_name = Some(entity_name.to_owned()); + ext.meta = Some(Arc::new(meta)); + + // Thread a per-conversation session id from the `X-Session-Id` + // request header into `agent.session_id`. cpex's session resolver + // subject-scopes it (`H(subject:session_id)`), so session-scoped + // taint labels (`taint(label, session)`) persist across requests + // in the same conversation and stay isolated between principals. + // Absent header → cpex falls back to its identity-derived session. + if let Some(session_id) = session_id { + let mut agent = ext.agent.as_ref().map(|arc| (**arc).clone()).unwrap_or_default(); + agent.session_id = Some(session_id); + ext.agent = Some(Arc::new(agent)); + } + + Ok(ext) + } +} + +#[async_trait] +impl HttpFilter for CpexFilter { + fn name(&self) -> &'static str { + "cpex" + } + + fn request_body_access(&self) -> BodyAccess { + // `ReadOnly` is the minimum that gets us into `on_request_body` + // (we need the body phase to fire so we can dispatch CMF after + // the `mcp` filter populates its metadata). Operators opt into + // `ReadWrite` via `body_access: read_write` when they want APL + // field mutators (`redact()` / `assign()` on `args.`) to + // rewrite the upstream body. Chain-level scoping keeps non-CPEX + // traffic out of this filter so the buffering cost is bounded + // either way. + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyAccess::ReadOnly, + BodyAccessMode::ReadWrite => BodyAccess::ReadWrite, + } + } + + fn request_body_mode(&self) -> BodyMode { + // In `ReadWrite` mode we MUST buffer the whole body before the + // filter runs — otherwise praxis would stream chunks upstream + // as they arrive, and a body rewrite at end-of-stream would + // race against an already-finished upstream write. + // `StreamBuffer` accumulates chunks, calls our filter exactly + // once at EOS with the full body, and forwards whatever we put + // back into `body`. `ReadOnly` inherits the default `Stream`. + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyMode::Stream, + BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { + max_bytes: Some(self.cfg.max_buffer_bytes), + }, + } + } + + fn response_body_access(&self) -> BodyAccess { + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyAccess::ReadOnly, + BodyAccessMode::ReadWrite => BodyAccess::ReadWrite, + } + } + + fn response_body_mode(&self) -> BodyMode { + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyMode::Stream, + BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { + max_bytes: Some(self.cfg.max_buffer_bytes), + }, + } + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + // One-shot runtime-flavor check. `on_response_body` uses + // `block_in_place` to drive async work from a sync trait + // method, and that primitive panics on a current-thread + // tokio runtime (praxis `work_stealing: false`). Rather than + // crash mid-response, refuse to operate up front. After the + // first request this collapses to a single atomic load. + match self.runtime_check.load(Ordering::Acquire) { + RUNTIME_UNCHECKED => { + let flavor = tokio::runtime::Handle::current().runtime_flavor(); + if matches!(flavor, tokio::runtime::RuntimeFlavor::CurrentThread) { + self.runtime_check.store(RUNTIME_REJECTED, Ordering::Release); + return Err(current_thread_runtime_error()); + } + self.runtime_check.store(RUNTIME_OK, Ordering::Release); + }, + RUNTIME_REJECTED => return Err(current_thread_runtime_error()), + _ => {}, // RUNTIME_OK — fall through. + } + + // Early identity gate. Saves the per-request body-buffer cost + // on un-auth'd traffic — if there's no valid token, we never + // reach `on_request_body` and the body never gets buffered. + let (result, _bg) = self + .mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + Self::identity_payload(ctx), + Extensions::default(), + None, + ) + .await; + + if !result.continue_processing { + tracing::debug!(target: "cpex.filter", "identity deny (on_request)"); + return Ok(FilterAction::Reject(auth_rejection(result.violation.as_ref()))); + } + + tracing::trace!(target: "cpex.filter", "identity allow (on_request)"); + Ok(FilterAction::Continue) + } + + #[expect( + clippy::large_stack_frames, + reason = "async handler with multiple await points over large CMF types" + )] + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + // CMF dispatch only fires once the full body has been seen + // (so praxis's `mcp` filter has finished parsing and writing + // its metadata). For streaming chunks we just pass. + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + // Pull MCP-derived entity coords from durable filter_metadata. + // Missing `mcp.method` means praxis's built-in `mcp` filter + // didn't run before us — almost always a misconfigured chain + // (missing or ordered after `cpex`). Default to fail-closed + // so the misconfig is loud at first request. Operators + // fronting non-MCP traffic can opt out via + // `require_mcp_metadata: false`. + let Some(method) = ctx.get_metadata("mcp.method").map(str::to_owned) else { + if self.cfg.require_mcp_metadata { + tracing::warn!( + target: "cpex.filter", + "no mcp.method in metadata — likely the `mcp` filter is missing \ + or ordered after `cpex` in the chain; rejecting (set \ + `require_mcp_metadata: false` to disable this guard)", + ); + return Ok(FilterAction::Reject(missing_mcp_metadata_rejection())); + } + tracing::trace!(target: "cpex.filter", "no mcp.method in metadata; no CMF dispatch"); + return Ok(FilterAction::BodyDone); + }; + let Some((entity_type, hook_name)) = entity_for_mcp_method(&method) else { + tracing::trace!( + target: "cpex.filter", + mcp_method = %method, + "MCP method has no entity binding; no CMF dispatch", + ); + return Ok(FilterAction::BodyDone); + }; + let Some(entity_name) = ctx.get_metadata("mcp.name").map(str::to_owned) else { + tracing::debug!( + target: "cpex.filter", + mcp_method = %method, + "MCP method missing mcp.name metadata; skipping CMF dispatch", + ); + return Ok(FilterAction::BodyDone); + }; + + // Build `Extensions` with re-resolved identity + entity coords. + let extensions = match self.build_cmf_extensions(ctx, entity_type, &entity_name).await { + Ok(ext) => ext, + Err(rej) => return Ok(FilterAction::Reject(rej)), + }; + + // Parse the JSON-RPC body to build the typed CMF content part. + // praxis's `mcp` filter already parsed once but only stashed + // method/name in `filter_metadata`, not the `params.arguments` + // that APL `args.*` predicates need. We re-parse here. The + // body is already in memory; the duplicate parse is + // microseconds. + let body_bytes = body.as_ref().cloned().unwrap_or_else(Bytes::new); + let id = json_rpc_id(&body_bytes); + let content = build_content_for_method(&method, &entity_name, &id, &body_bytes); + + // Dispatch the CMF hook. The route annotation (installed by + // the APL visitor at config-load time) drives policy + // evaluation; if no APL route matches, the hook is a no-op. + let payload = MessagePayload { + message: Message::with_content(Role::User, content), + }; + let (cmf_result, _bg) = self + .mgr + .invoke_named::(hook_name, payload, extensions, None) + .await; + + if !cmf_result.continue_processing { + let request_id = json_rpc_id_value(&body_bytes); + tracing::debug!( + target: "cpex.filter", + hook = %hook_name, + entity = %entity_name, + "CMF deny", + ); + return Ok(FilterAction::Reject(mcp_error_rejection( + cmf_result.violation.as_ref(), + &request_id, + ))); + } + + // Allow path. If APL `delegate(...)` steps minted any outbound + // tokens, the delegators wrote them into + // `modified_extensions.raw_credentials.delegated_tokens`. + // Attach each one to the upstream request as the configured + // header. + let attached = attach_delegated_tokens(ctx, cmf_result.modified_extensions.as_ref()); + if attached > 0 { + tracing::debug!( + target: "cpex.filter", + count = attached, + "attached delegated tokens to upstream request", + ); + } + + // If body_access is ReadWrite AND APL mutated the payload + // (a `redact()` / `assign()` step fired), re-serialize the + // mutated `MessagePayload` back into the JSON-RPC body so the + // upstream service receives the rewritten args. + if matches!(self.cfg.body_access, BodyAccessMode::ReadWrite) + && let Some(mp) = cmf_result.modified_payload.as_ref() + && let Some(updated) = mp.as_any().downcast_ref::() + { + let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); + if let Some(new_bytes) = reserialize_json_rpc_body(&original, &method, &updated.message) { + // Praxis recomputes upstream `Content-Length` from the + // rewritten body via `mutated_request_body_len` → + // `apply_mutated_content_length`, so we ship the bytes + // as-is (no pad). Padding here would corrupt byte-exact + // bodies that the upstream verifies via signature / + // hash, and the response-path pad-on-shrink (where + // `Content-Length` IS frozen) is unaffected. + tracing::debug!( + target: "cpex.filter", + method = %method, + new_len = new_bytes.len(), + original_len = original.len(), + "rewriting upstream body from mutated MessagePayload", + ); + *body = Some(new_bytes); + } + } + + tracing::trace!( + target: "cpex.filter", + hook = %hook_name, + entity = %entity_name, + "CMF allow", + ); + Ok(FilterAction::BodyDone) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + // No point doing anything if the operator hasn't opted into + // response rewriting. + if !matches!(self.cfg.body_access, BodyAccessMode::ReadWrite) { + return Ok(FilterAction::Continue); + } + + // praxis's `mcp` filter stashes method/name during the request + // phase and praxis preserves `filter_metadata` across phases, + // so we can route the post-phase hook without re-parsing the + // body. + let Some(method) = ctx.get_metadata("mcp.method").map(str::to_owned) else { + return Ok(FilterAction::Continue); + }; + let Some((entity_type, hook_name)) = entity_for_mcp_method_post(&method) else { + return Ok(FilterAction::Continue); + }; + let Some(entity_name) = ctx.get_metadata("mcp.name").map(str::to_owned) else { + return Ok(FilterAction::Continue); + }; + + let body_bytes = body.as_ref().cloned().unwrap_or_else(Bytes::new); + let id_str = json_rpc_id(&body_bytes); + + // praxis's `on_response_body` is sync (the Pingora response_body + // callback can't be awaited). We're on a tokio worker so + // `block_in_place` lets us drive the async CMF dispatch without + // stalling other tasks. + let extensions = match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { self.build_cmf_extensions(ctx, entity_type, &entity_name).await }) + }) { + Ok(e) => e, + Err(_rej) => { + // Fail closed, symmetric with the request phase: a + // request whose identity can't be resolved is denied, + // so a response we can no longer attribute must be + // denied too. Passing it through would skip any + // configured response-side redaction and leak the + // upstream payload. We can't change the already-sent + // status/headers, but we can replace the body with a + // deny envelope fitted to the committed length. + tracing::warn!( + target: "cpex.filter", + method = %method, + entity = %entity_name, + "post-phase identity rebuild failed; failing closed \ + (replacing response body with deny envelope)", + ); + let request_id = json_rpc_id_value(&body_bytes); + let violation = PluginViolation::new( + "identity.post_phase_unavailable", + "identity could not be re-resolved for response processing", + ); + let envelope = mcp_error_envelope_bytes(Some(&violation), &request_id); + *body = Some(fit_to_original_length( + envelope, + body_bytes.len(), + method.as_str(), + "post-phase identity failure", + )); + return Ok(FilterAction::Continue); + }, + }; + + let content = build_response_content_for_method(&method, &entity_name, &id_str, &body_bytes); + if content.is_empty() { + return Ok(FilterAction::Continue); + } + let payload = MessagePayload { + message: Message::with_content(Role::Assistant, content), + }; + let mgr = Arc::clone(&self.mgr); + let cmf_result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let (r, _bg) = mgr.invoke_named::(hook_name, payload, extensions, None).await; + r + }) + }); + + // Post-phase deny — the upstream's response carries something + // the operator wants suppressed (output PII, late policy + // violation, etc.). We can't change the HTTP status or + // headers from `on_response_body`, but we CAN replace the + // body bytes with a JSON-RPC error envelope so the client + // sees a structured deny instead of the upstream's payload. + // Fits within the original Content-Length via the same + // pad-with-trailing-spaces trick used for ReadWrite rewrites + // (the envelope is almost always shorter than a real + // response body, so padding is the common case). + if !cmf_result.continue_processing { + tracing::warn!( + target: "cpex.filter", + method = %method, + entity = %entity_name, + violation = ?cmf_result.violation, + "post-phase deny — replacing response body with JSON-RPC error envelope", + ); + let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); + let request_id = json_rpc_id_value(&original); + let envelope = mcp_error_envelope_bytes(cmf_result.violation.as_ref(), &request_id); + *body = Some(fit_to_original_length( + envelope, + original.len(), + method.as_str(), + "post-phase deny", + )); + return Ok(FilterAction::Continue); + } + + if let Some(mp) = cmf_result.modified_payload.as_ref() + && let Some(updated) = mp.as_any().downcast_ref::() + { + let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); + if let Some(new_bytes) = reserialize_json_rpc_response_body(&original, &method, &updated.message) { + let final_bytes = if new_bytes.len() > original.len() { + // The rewrite grew the body past the committed + // response Content-Length. We can't enlarge the + // response, and truncating the redacted payload would + // ship corrupt JSON. Fail closed: replace it with a + // structured deny envelope fitted to length, so the + // client gets a clean error rather than a mangled + // (and potentially under-redacted) body. + tracing::warn!( + target: "cpex.filter", + method = %method, + new_len = new_bytes.len(), + original_len = original.len(), + "response rewrite exceeds committed Content-Length; \ + failing closed with deny envelope", + ); + let request_id = json_rpc_id_value(&original); + let violation = PluginViolation::new( + "gateway.response_rewrite_overflow", + "response rewrite exceeded the committed response length", + ); + let envelope = mcp_error_envelope_bytes(Some(&violation), &request_id); + fit_to_original_length(envelope, original.len(), method.as_str(), "response rewrite overflow") + } else { + fit_to_original_length(new_bytes, original.len(), method.as_str(), "response-side rewrite") + }; + tracing::debug!( + target: "cpex.filter", + method = %method, + new_len = final_bytes.len(), + original_len = original.len(), + "rewriting downstream response body from mutated MessagePayload", + ); + *body = Some(final_bytes); + } + } + Ok(FilterAction::Continue) + } +} + +// ----------------------------------------------------------------------------- +// runtime-flavor error +// ----------------------------------------------------------------------------- + +/// Error returned from `on_request` when the filter has been mounted +/// into a current-thread tokio runtime. Hoisted into a helper so the +/// first-request and cached-rejection branches return identical text. +fn current_thread_runtime_error() -> FilterError { + "cpex filter requires a multi-threaded tokio runtime \ + (server config `work_stealing: true`); current-thread runtime \ + is unsupported because response-phase body transformation \ + requires `block_in_place`" + .into() +} + +/// Fit a freshly-built body to the original `Content-Length`, always +/// returning **exactly** `original_len` bytes: pad with trailing ASCII +/// spaces on shrink (JSON parsers ignore them); truncate on grow. +/// +/// The downstream response `Content-Length` is committed by the time +/// `on_response_body` runs — praxis has no response-side equivalent of +/// `apply_mutated_content_length` (that path is request-only). Emitting +/// more bytes than `original_len` is therefore an HTTP/1.1 framing +/// desync: the trailing bytes would be parsed as the start of the next +/// response (a response-smuggling primitive). Truncating to +/// `original_len` corrupts the JSON the client parses but cannot smuggle +/// — it is the safe failure mode. Callers that can do better (the +/// response-rewrite path) substitute a length-fitting deny envelope +/// before reaching the grow case, so truncation is a last-resort +/// backstop, not the common path. +/// +/// Used only on the response side. The request side is unaffected: +/// praxis repairs request framing via `mutated_request_body_len` → +/// `apply_mutated_content_length` (`stream_buffer.rs` → `with_body.rs`), +/// so padding there would only corrupt byte-exact bodies the upstream +/// might verify via signature / hash. +pub(super) fn fit_to_original_length(new_bytes: Bytes, original_len: usize, method: &str, reason: &str) -> Bytes { + match new_bytes.len().cmp(&original_len) { + std::cmp::Ordering::Less => { + let mut padded = Vec::with_capacity(original_len); + padded.extend_from_slice(&new_bytes); + padded.resize(original_len, b' '); + Bytes::from(padded) + }, + std::cmp::Ordering::Equal => new_bytes, + std::cmp::Ordering::Greater => { + tracing::warn!( + target: "cpex.filter", + method = %method, + new_len = new_bytes.len(), + original_len, + "{reason}: rewritten body larger than original Content-Length; \ + truncating to preserve HTTP/1.1 framing (response Content-Length \ + is already committed and cannot grow)", + ); + new_bytes.slice(0..original_len) + }, + } +} + +/// Rejection emitted when `require_mcp_metadata` is on (default) and +/// no `mcp.method` metadata was set by an upstream filter. HTTP 500 +/// because the misconfiguration is server-side, not client-side. +fn missing_mcp_metadata_rejection() -> Rejection { + Rejection::status(500) + .with_header("Content-Type", "text/plain") + .with_header(VIOLATION_HEADER, "config.missing_mcp_metadata") + .with_body(Bytes::from_static( + b"cpex: no mcp.method in filter metadata. The `mcp` filter must \ + be present in the chain and ordered before `cpex`. Set the \ + filter's `require_mcp_metadata: false` to disable this guard \ + for non-MCP traffic.", + )) +} + +// ----------------------------------------------------------------------------- +// attach_delegated_tokens +// ----------------------------------------------------------------------------- + +/// Walk the minted delegated tokens on the resolved `Extensions` and +/// push them as upstream request headers. Returns the count attached +/// (0 when no delegation ran or no extensions were returned). Each +/// token's `outbound_header` field decides where it goes; the value +/// is `Bearer ` (RFC 6750 wire format — what every audience +/// expects). Uses `request_headers_to_set` rather than +/// `extra_request_headers` because authorization tokens are +/// overwrites, not appends. +/// +/// Multiple tokens targeting the same outbound header are a +/// configuration ambiguity — praxis's `request_headers_to_set` +/// would otherwise let the last writer silently win, with order +/// determined by `HashMap` iteration. Apply first-writer-wins keyed +/// on `(outbound_header_lc, audience)`, log a warn on each skip so +/// the operator can fix the overlapping delegators. +pub(super) fn attach_delegated_tokens(ctx: &mut HttpFilterContext<'_>, extensions: Option<&Extensions>) -> usize { + let Some(ext) = extensions else { + return 0; + }; + let Some(raw) = ext.raw_credentials.as_ref() else { + return 0; + }; + + // Stable-order the tokens before we attach. `delegated_tokens` is + // a `HashMap`, so iteration order is non-deterministic — two + // tokens targeting the same outbound header would otherwise + // produce order-dependent results (praxis's + // `request_headers_to_set` is overwrite semantics). Sorting by + // `(outbound_header_lc, audience)` gives first-writer-wins where + // "first" is alphabetically lowest audience for that header. + let mut sorted: Vec<&_> = raw.delegated_tokens.values().collect(); + sorted.sort_by(|a, b| { + a.outbound_header + .to_ascii_lowercase() + .cmp(&b.outbound_header.to_ascii_lowercase()) + .then_with(|| a.audience.cmp(&b.audience)) + }); + + let mut attached_outbound: std::collections::HashSet = std::collections::HashSet::new(); + let mut count = 0; + for tok in sorted { + let outbound_lc = tok.outbound_header.to_ascii_lowercase(); + if !attached_outbound.insert(outbound_lc.clone()) { + // A token for this outbound header was already attached + // earlier in the sorted pass — refuse to overwrite. Warn + // loudly so an operator notices the policy ambiguity + // (two delegators racing for the same header is almost + // always a mistake in route/global config layering). + tracing::warn!( + target: "cpex.filter", + outbound_header = %tok.outbound_header, + audience = %tok.audience, + "skipping delegated token: another token already targets this outbound header \ + (first-writer-wins by audience asc); fix overlapping delegators in policy", + ); + continue; + } + let Ok(name) = http::header::HeaderName::try_from(tok.outbound_header.as_str()) else { + tracing::warn!( + target: "cpex.filter", + header = %tok.outbound_header, + "delegated token outbound_header is not a valid HTTP header name; skipping", + ); + attached_outbound.remove(&outbound_lc); + continue; + }; + let Ok(value) = http::header::HeaderValue::try_from(format!("Bearer {}", tok.token.as_str())) else { + tracing::warn!( + target: "cpex.filter", + audience = %tok.audience, + "minted token bytes are not a valid HTTP header value; skipping", + ); + attached_outbound.remove(&outbound_lc); + continue; + }; + ctx.request_headers_to_set.push((name, value)); + count += 1; + } + + // Strip the inbound credential headers — but only when we + // actually attached delegated tokens, and only headers that are + // NOT also being set by an outbound (collision case — + // `request_headers_to_set` overwrites, no remove needed). + if count > 0 { + for inbound in raw.inbound_tokens.values() { + let normalized = inbound.source_header.to_ascii_lowercase(); + if attached_outbound.contains(&normalized) { + continue; + } + if let Ok(n) = http::header::HeaderName::try_from(inbound.source_header.as_str()) { + ctx.request_headers_to_remove.push(n); + } else { + tracing::warn!( + target: "cpex.filter", + header = %inbound.source_header, + "inbound source_header is not a valid HTTP header name; cannot strip", + ); + } + } + } + + count +} diff --git a/filter/src/builtins/http/security/cpex/json_rpc.rs b/filter/src/builtins/http/security/cpex/json_rpc.rs new file mode 100644 index 00000000..c3458683 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/json_rpc.rs @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! JSON-RPC body parsing + typed CMF content-part builders. +//! +//! Praxis's `mcp` filter parses JSON-RPC bodies and stashes +//! `mcp.method` / `mcp.name` in `filter_metadata`, but it doesn't +//! materialize `params.arguments` (or `result.content`) into a typed +//! form that APL `args.*` / `result.*` predicates can evaluate. This +//! module does that second parse, builds the matching `ContentPart`, +//! and re-serializes mutated payloads back into JSON-RPC envelopes +//! when `body_access: read_write` is on. + +use bytes::Bytes; +use cpex::cpex_core::cmf::{ + ContentPart, Message, PromptRequest, ResourceReference, ResourceType, ToolCall, ToolResult, +}; + +// ----------------------------------------------------------------------------- +// JSON-RPC id extraction +// ----------------------------------------------------------------------------- + +/// Read the JSON-RPC `id` field as a string for use as a CMF +/// correlation id. JSON-RPC permits string or numeric ids; we +/// stringify either to a single canonical key. Returns an empty +/// string when the body is missing or malformed — the correlation +/// id isn't load-bearing for policy, only for audit linkage. +pub(super) fn json_rpc_id(body: &Bytes) -> String { + serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("id").cloned()) + .map(|id| match id { + serde_json::Value::String(s) => s, + other => other.to_string(), + }) + .unwrap_or_default() +} + +/// Typed companion to [`json_rpc_id`]. Returns the raw `id` JSON value +/// from the request body — preserves the original shape (string or +/// number) so an MCP error envelope echoes back exactly what the +/// client sent. Returns `Value::Null` when the body is missing or +/// malformed; per JSON-RPC 2.0, an error response MAY use `null` when +/// the original id could not be determined. +pub(super) fn json_rpc_id_value(body: &Bytes) -> serde_json::Value { + serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("id").cloned()) + .unwrap_or(serde_json::Value::Null) +} + +// ----------------------------------------------------------------------------- +// Request-side: body → typed ContentPart list +// ----------------------------------------------------------------------------- + +/// Build the typed CMF `ContentPart` list for an MCP method. Parses +/// `params` out of the JSON-RPC body so APL `args.*` / `prompt.args.*` +/// / `resource.*` predicates have something to evaluate against. On +/// malformed or absent body, falls back to an empty content list — the +/// caller can still dispatch CMF (entity coords drive routing), just +/// without typed args available to predicates. +#[expect( + clippy::too_many_lines, + reason = "per-method envelope orchestration; splitting per-method obscures the JSON-RPC shape" +)] +pub(super) fn build_content_for_method( + method: &str, + entity_name: &str, + correlation_id: &str, + body: &Bytes, +) -> Vec { + let params: serde_json::Value = serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("params").cloned()) + .unwrap_or(serde_json::Value::Null); + + match method { + "tools/call" => { + let arguments = params + .get("arguments") + .and_then(|v| v.as_object()) + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: correlation_id.to_owned(), + name: entity_name.to_owned(), + arguments, + namespace: None, + }, + }] + }, + "prompts/get" => { + let arguments = params + .get("arguments") + .and_then(|v| v.as_object()) + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + vec![ContentPart::PromptRequest { + content: PromptRequest { + prompt_request_id: correlation_id.to_owned(), + name: entity_name.to_owned(), + arguments, + server_id: None, + }, + }] + }, + "resources/read" => { + // For `resources/read`, `params.uri` is the resource + // identifier; `mcp.name` is set to the same URI by praxis's + // `mcp` filter (it treats `uri` as the "selector"). Carry + // it through as the `ResourceReference`. + let uri = params + .get("uri") + .and_then(|v| v.as_str()) + .unwrap_or(entity_name) + .to_owned(); + vec![ContentPart::ResourceRef { + content: ResourceReference { + resource_request_id: correlation_id.to_owned(), + uri, + name: None, + resource_type: ResourceType::Uri, + range_start: None, + range_end: None, + selector: None, + }, + }] + }, + _ => Vec::new(), + } +} + +// ----------------------------------------------------------------------------- +// Request-side: re-serialize mutated payload back into the body +// ----------------------------------------------------------------------------- + +/// Re-serialize a JSON-RPC request body, replacing only the fields +/// APL mutated in the typed `MessagePayload`. Returns `Some(new_bytes)` +/// when the body changed, `None` when nothing needed rewriting +/// (no matching content part, malformed original, etc.). +/// +/// Touched fields by MCP method: +/// * `tools/call` → `params.arguments` (from the first `ContentPart::ToolCall.arguments`) +/// * `prompts/get` → `params.arguments` (from the first `ContentPart::PromptRequest.arguments`) +/// * `resources/read` → `params.uri` (from `ContentPart::ResourceRef.uri`) +/// +/// All other JSON-RPC envelope fields (`jsonrpc`, `id`, `method`, +/// `params.name`) pass through unchanged. This minimizes the +/// blast radius of the rewrite — operators relying on a byte-stable +/// envelope (signature validation, content-hash matching) only see +/// changes when APL actually mutated. +#[expect( + clippy::too_many_lines, + reason = "per-method envelope orchestration; splitting per-method obscures the JSON-RPC shape" +)] +pub(super) fn reserialize_json_rpc_body(original: &Bytes, method: &str, message: &Message) -> Option { + let mut envelope: serde_json::Value = serde_json::from_slice(original).ok()?; + let params = envelope.get_mut("params")?; + let params_obj = params.as_object_mut()?; + + match method { + "tools/call" | "prompts/get" => { + for part in &message.content { + let new_args = match part { + ContentPart::ToolCall { content } if method == "tools/call" => Some(&content.arguments), + ContentPart::PromptRequest { content } if method == "prompts/get" => Some(&content.arguments), + _ => None, + }; + if let Some(args) = new_args { + let new_args_value: serde_json::Value = args + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + .into(); + params_obj.insert("arguments".to_owned(), new_args_value); + return Some(Bytes::from(serde_json::to_vec(&envelope).ok()?)); + } + } + None + }, + "resources/read" => { + for part in &message.content { + if let ContentPart::ResourceRef { content } = part { + params_obj.insert("uri".to_owned(), serde_json::Value::String(content.uri.clone())); + return Some(Bytes::from(serde_json::to_vec(&envelope).ok()?)); + } + } + None + }, + _ => None, + } +} + +// ----------------------------------------------------------------------------- +// Response-side: body → typed ContentPart list (post-phase) +// ----------------------------------------------------------------------------- + +/// Build the typed CMF `ContentPart` list from a JSON-RPC *response* +/// body — the post-phase mirror of [`build_content_for_method`]. Today +/// only `tools/call` produces a structured `ToolResult`; `prompts/get` +/// and `resources/read` return TBD shapes the filter can extend later. +/// +/// The actual tool data lives in MCP's `result.content[].text` (a +/// JSON-stringified payload, per the MCP Tools spec) and/or +/// `result.structuredContent` (newer 2025-06-18 shape). We try +/// `structuredContent` first; on miss, fold **every** text block (not +/// just the first) so APL evaluates against all of the response's text +/// content. A lone text block that parses as JSON is exposed as that +/// object (so `result.` predicates resolve); otherwise the raw +/// text — or the concatenation of multiple blocks — is wrapped as +/// `{ "text": "" }` so `result.text` predicates still resolve. +/// +/// Folding all blocks is load-bearing for policy: if APL only saw the +/// first block, a later block could carry data the policy never vetted +/// and [`reserialize_json_rpc_response_body`] never rewrote, leaking it +/// downstream. The view here and the bytes emitted there are kept to +/// the same content set. +#[expect( + clippy::too_many_lines, + reason = "per-method envelope orchestration; splitting per-method obscures the JSON-RPC shape" +)] +pub(super) fn build_response_content_for_method( + method: &str, + entity_name: &str, + correlation_id: &str, + body: &Bytes, +) -> Vec { + if method != "tools/call" { + return Vec::new(); + } + let envelope: serde_json::Value = match serde_json::from_slice::(body) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let Some(result) = envelope.get("result") else { + return Vec::new(); + }; + let is_error = result + .get("isError") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + let content_value = if let Some(structured) = result.get("structuredContent") { + structured.clone() + } else { + let texts: Vec<&str> = result + .get("content") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect() + }) + .unwrap_or_default(); + match texts.as_slice() { + [] => serde_json::Value::Null, + [single] => serde_json::from_str::(single) + .unwrap_or_else(|_| serde_json::json!({ "text": single })), + // Multiple text blocks can't be merged into one JSON object + // unambiguously; expose their concatenation under `text` so + // every block is in APL's view (and gets collapsed into a + // single vetted block on re-serialization). + many => serde_json::json!({ "text": many.join("\n") }), + } + }; + + vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: correlation_id.to_owned(), + tool_name: entity_name.to_owned(), + content: content_value, + is_error, + }, + }] +} + +// ----------------------------------------------------------------------------- +// Response-side: re-serialize mutated payload back into the body +// ----------------------------------------------------------------------------- + +/// Re-serialize a JSON-RPC response body, replacing only the fields +/// the post-phase APL pipeline mutated. Mirror of +/// [`reserialize_json_rpc_body`] for the response side. +/// +/// When the original response carried a `result.content` array, the +/// **entire** array is replaced with a single canonical text block +/// holding the vetted `ToolResult.content` (JSON-stringified — the +/// legacy MCP shape every client supports). Collapsing to one block is +/// deliberate: [`build_response_content_for_method`] folds every text +/// block into the single value APL evaluates, so any *other* block left +/// in place here would be content the policy never inspected and never +/// rewrote — a redaction bypass. Dropping the extra (text and non-text) +/// blocks guarantees the bytes we emit are exactly what APL vetted. +/// +/// `result.structuredContent` is mirrored to the same value, but only +/// when the original response already had it (we don't invent fields). +pub(super) fn reserialize_json_rpc_response_body(original: &Bytes, method: &str, message: &Message) -> Option { + if method != "tools/call" { + return None; + } + let mut envelope: serde_json::Value = serde_json::from_slice(original).ok()?; + let result = envelope.get_mut("result")?; + let result_obj = result.as_object_mut()?; + + let new_content = message.content.iter().find_map(|part| match part { + ContentPart::ToolResult { content } => Some(content.content.clone()), + _ => None, + })?; + + if result_obj.contains_key("content") { + let text = serde_json::to_string(&new_content).ok()?; + result_obj.insert( + "content".to_owned(), + serde_json::json!([{ "type": "text", "text": text }]), + ); + } + + // Only mirror to structuredContent when the original had it. + if result_obj.contains_key("structuredContent") { + result_obj.insert("structuredContent".to_owned(), new_content); + } + + Some(Bytes::from(serde_json::to_vec(&envelope).ok()?)) +} diff --git a/filter/src/builtins/http/security/cpex/mod.rs b/filter/src/builtins/http/security/cpex/mod.rs new file mode 100644 index 00000000..b6cb9b2b --- /dev/null +++ b/filter/src/builtins/http/security/cpex/mod.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! The CPEX security filter. Resolves multi-source agentic identity, +//! evaluates APL route-level policy, mints RFC 8693 delegated +//! credentials, scans for PII, emits audit records, and (under +//! `body_access: read_write`) rewrites request/response bodies. + +mod cmf; +mod config; +mod error; +mod filter; +mod json_rpc; + +pub use filter::CpexFilter; + +#[cfg(test)] +#[expect( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests; diff --git a/filter/src/builtins/http/security/cpex/tests.rs b/filter/src/builtins/http/security/cpex/tests.rs new file mode 100644 index 00000000..e4238e2f --- /dev/null +++ b/filter/src/builtins/http/security/cpex/tests.rs @@ -0,0 +1,1460 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Tests for the CPEX security filter. +//! +//! Uses HMAC (HS256) JWTs throughout for setup simplicity — the +//! identity validation pipeline is symmetric across signing +//! algorithms, and HS256 lets us skip RSA keypair generation. Real +//! deployments use RS256 with JWKS endpoints; the YAML schema +//! supports both via the `decoding_key.kind` discriminant. + +use http::{HeaderValue, Method}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use serde_json::json; +use tempfile::TempDir; + +use super::{config::CpexFilterConfig, filter::CpexFilter}; +use crate::{ + FilterAction, + filter::HttpFilter as _, + test_utils::{make_filter_context, make_request}, +}; + +// ===================================================================== +// Fixtures +// ===================================================================== + +const TEST_SECRET: &str = "praxis-cpex-test-secret-not-for-production-use"; +const TEST_ISSUER: &str = "https://idp.test.local"; +const TEST_AUDIENCE: &str = "test-api"; + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should not be before unix epoch") + .as_secs() +} + +/// Mint an HS256 JWT signed with [`TEST_SECRET`]. +fn mint_jwt(claims: &serde_json::Value) -> String { + let header = Header::new(Algorithm::HS256); + let key = EncodingKey::from_secret(TEST_SECRET.as_bytes()); + encode(&header, claims, &key).expect("sign JWT") +} + +/// Standard token claims: test issuer + audience, fresh `exp`. +fn standard_claims(subject: &str) -> serde_json::Value { + json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "sub": subject, + "exp": now_unix() + 300, + "iat": now_unix(), + }) +} + +/// Claims for a workload / agent token. Includes `azp` (authorized +/// party) so the `client`-role claim mapper can populate the +/// caller-workload identity slot. +fn agent_claims(client_id: &str) -> serde_json::Value { + json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "sub": client_id, + "azp": client_id, + "exp": now_unix() + 300, + "iat": now_unix(), + }) +} + +/// Write a single-plugin CPEX YAML referencing the HS256 test secret. +fn write_single_plugin_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Write a CPEX YAML that gates the `echo` tool through a CEL PDP step. +/// Single HS256 identity plugin (so `subject.id` resolves from the JWT +/// `sub`), a `kind: cel` PDP declared globally, and a route whose `cel:` +/// expression allows only `alice`. Exercises the `apl-pdp-cel` backend +/// end-to-end through the filter's CMF dispatch. +#[expect( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] +fn write_cel_policy_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +global: + apl: + pdp: + - kind: cel +routes: + - tool: echo + apl: + policy: + - cel: + expr: | + subject.id == "alice" +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Run a `tools/call` for the `echo` tool as `subject`, returning the +/// filter's body-phase action. Shared by the CEL allow/deny cases. +async fn dispatch_echo_as(filter: &CpexFilter, subject: &str) -> FilterAction { + let token = mint_jwt(&standard_claims(subject)); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("mcp.method", "tools/call"); + ctx.set_metadata("mcp.name", "echo"); + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{}}}"#, + ); + filter + .on_request_body(&mut ctx, &mut Some(body), true) + .await + .expect("filter ran") +} + +/// Write a CPEX YAML demonstrating session tainting: a `read-secret` +/// tool taints the session, and a `send-out` tool denies when the +/// session carries that taint. Identity is the HS256 jwt plugin so +/// `subject.id` resolves; the taint persists in the in-process session +/// store keyed by the resolved session id. +#[expect( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] +fn write_taint_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +routes: + - tool: read-secret + apl: + policy: + - "taint(secret, session)" + - tool: send-out + apl: + policy: + - "security.labels contains \"secret\": deny('session accessed secret data', 'session_tainted_secret')" +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Dispatch a `tools/call` for `tool` as `subject` with the given +/// `X-Session-Id`. Returns the filter's body-phase action. Threads the +/// session header so cpex's session-scoped taint store can persist / +/// hydrate labels across calls. +async fn dispatch_tool_session(filter: &CpexFilter, subject: &str, tool: &str, session_id: &str) -> FilterAction { + let token = mint_jwt(&standard_claims(subject)); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + req.headers.insert( + "X-Session-Id", + HeaderValue::from_str(session_id).expect("session header"), + ); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("mcp.method", "tools/call"); + ctx.set_metadata("mcp.name", tool); + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"t","arguments":{}}}"#, + ); + filter + .on_request_body(&mut ctx, &mut Some(body), true) + .await + .expect("filter ran") +} + +/// Write a CPEX YAML with two identity plugins, each reading its own +/// header. Demonstrates the multi-source agentic identity story PR1 +/// targets — one request can carry user + agent JWTs simultaneously, +/// both validated, both contributing to a typed `Extensions` context. +#[expect( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] +fn write_multi_source_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + role: user + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + claim_mapper: standard + - name: jwt-agent + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 20 + on_error: fail + config: + header: X-Agent-Token + role: client + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + claim_mapper: standard +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Write a CPEX YAML selecting the Valkey-backed session store via a +/// flat `global.session_store` block. The `valkey` factory connects +/// lazily (the pool dials on first request), so this config loads +/// without a running Valkey — it pins that the factory is registered +/// and the flat `session_store` block parses and resolves. +#[expect( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] +fn write_valkey_session_store_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +global: + session_store: + kind: valkey + endpoint: localhost:6379 +routes: + - tool: read-secret + apl: + policy: + - "taint(secret, session)" +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Build a `CpexFilter` from a YAML config path. Defaults +/// `require_mcp_metadata` to true so the test surface matches the +/// production default; individual tests that want to test the +/// fail-open knob construct their own config. +fn build_filter(config_path: String) -> CpexFilter { + let cfg = CpexFilterConfig { + config_path, + body_access: super::config::BodyAccessMode::ReadOnly, + require_mcp_metadata: true, + init_timeout_secs: 30, + max_buffer_bytes: 10_485_760, + }; + CpexFilter::new(cfg).expect("filter should construct") +} + +// ===================================================================== +// Config parsing +// ===================================================================== + +/// The minimal valid config carries only `config_path:`; all other +/// fields (`body_access`, `require_mcp_metadata`, `init_timeout_secs`, +/// `max_buffer_bytes`) take their documented defaults. +#[test] +fn config_parses_minimal_yaml() { + let yaml = "config_path: /etc/praxis/cpex.yaml"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!(cfg.config_path, "/etc/praxis/cpex.yaml", "config_path round-trips",); + assert_eq!(cfg.max_buffer_bytes, 10_485_760, "max_buffer_bytes defaults to 10 MiB",); +} + +/// `max_buffer_bytes` is operator-tunable; an explicit value overrides +/// the 10 MiB default so deployments can bound `ReadWrite` buffering. +#[test] +fn config_max_buffer_bytes_override() { + let yaml = "config_path: /etc/praxis/cpex.yaml\nmax_buffer_bytes: 1048576"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!(cfg.max_buffer_bytes, 1_048_576, "explicit max_buffer_bytes wins"); +} + +/// `config_path:` is mandatory — there's no default that would let +/// the filter load a CPEX policy document, so an empty config block +/// must fail at deserialize time rather than at first request. +#[test] +fn config_requires_config_path() { + let yaml = "{}"; + let res: Result = serde_yaml::from_str(yaml); + assert!(res.is_err(), "config_path is mandatory"); +} + +// ===================================================================== +// Identity-resolution scenarios +// ===================================================================== + +/// A YAML config carrying a single identity plugin should construct +/// without error. Pins the schema we ship — any drift in the +/// identity/jwt plugin's config shape will surface here. +#[tokio::test(flavor = "multi_thread")] +async fn filter_constructs_from_valid_yaml() { + let (_dir, path) = write_single_plugin_config(); + let _filter = build_filter(path); +} + +/// A config selecting the Valkey session store (`global.session_store`, +/// flat form) loads without a running Valkey: the `valkey` factory is +/// registered and its pool dials lazily on first request. Proves the +/// factory wiring and that the flat `session_store` block resolves. +#[tokio::test(flavor = "multi_thread")] +async fn valkey_session_store_config_builds() { + let (_dir, path) = write_valkey_session_store_config(); + let _filter = build_filter(path); +} + +/// A request with no `Authorization` header has no token for the JWT +/// plugin to validate; the identity hook chain denies and the filter +/// emits HTTP 401. +#[tokio::test(flavor = "multi_thread")] +async fn request_without_auth_header_rejects_401() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + + match action { + FilterAction::Reject(rej) => assert_eq!(rej.status, 401), + other => panic!("expected Reject(401); got {other:?}"), + } +} + +/// A valid HS256 JWT in the configured header passes the identity +/// chain and the filter emits Continue. +#[tokio::test(flavor = "multi_thread")] +async fn valid_hs256_jwt_continues() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + assert!( + matches!(action, FilterAction::Continue), + "expected Continue; got {action:?}" + ); +} + +/// A JWT whose signature byte has been flipped fails verification and +/// the filter emits HTTP 401. +#[tokio::test(flavor = "multi_thread")] +async fn tampered_jwt_signature_rejects_401() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + // Flip the final character of the signature segment. + let mut token = mint_jwt(&standard_claims("alice")); + let last = token.pop().unwrap_or('A'); + token.push(if last == 'A' { 'B' } else { 'A' }); + + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + assert!( + matches!(&action, FilterAction::Reject(rej) if rej.status == 401), + "expected Reject(401); got {action:?}" + ); +} + +/// Auth rejections must carry the MCP-spec-required +/// `WWW-Authenticate: Bearer` header so MCP clients know to retry +/// with credentials, plus our `X-Cpex-Violation` diagnostic header. +#[tokio::test(flavor = "multi_thread")] +async fn auth_rejection_carries_diagnostic_headers() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + let FilterAction::Reject(rej) = action else { + panic!("expected Reject; got {action:?}"); + }; + + let www_auth = rej + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("WWW-Authenticate")); + assert!(www_auth.is_some(), "WWW-Authenticate header is required"); + + let violation = rej + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("X-Cpex-Violation")); + assert!(violation.is_some(), "X-Cpex-Violation header is expected"); +} + +/// The PR1 multi-source story: one request carries a user JWT in +/// `Authorization` and an agent JWT in `X-Agent-Token`. Both plugins +/// validate their respective headers and the request passes. +#[tokio::test(flavor = "multi_thread")] +async fn multi_source_both_identities_continue() { + let (_dir, path) = write_multi_source_config(); + let filter = build_filter(path); + + let user_token = mint_jwt(&standard_claims("alice")); + let agent_token = mint_jwt(&agent_claims("agent-007")); + + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {user_token}")).expect("header"), + ); + req.headers.insert( + "X-Agent-Token", + HeaderValue::from_str(&format!("Bearer {agent_token}")).expect("header"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + assert!( + matches!(action, FilterAction::Continue), + "expected Continue with both identities; got {action:?}" + ); +} + +/// `block_in_place` (used by the response phase) panics on a +/// current-thread runtime, so the filter refuses to run there. Pins +/// the rejection so a future change can't silently re-introduce the +/// panic path. The default `#[tokio::test]` flavor is current-thread, +/// which is exactly the runtime praxis configures when +/// `work_stealing: false`. +#[tokio::test] +async fn current_thread_runtime_is_rejected() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + + let err = filter + .on_request(&mut ctx) + .await + .expect_err("current-thread runtime must be rejected"); + let msg = format!("{err}"); + assert!( + msg.contains("multi-threaded tokio runtime"), + "error message should point at the work_stealing config; got: {msg}", + ); +} + +// ===================================================================== +// Config-schema guards +// ===================================================================== + +/// `#[serde(deny_unknown_fields)]` must reject typos like `body_acces` +/// — without this, the misspelled field is silently dropped, the +/// default `ReadOnly` mode wins, and `redact()` policies become a +/// no-op. The typo would be invisible to operators until they checked +/// upstream traffic and noticed redaction wasn't happening. +#[test] +fn config_rejects_unknown_fields() { + let yaml = " +config_path: /etc/praxis/cpex.yaml +body_acces: read_write +"; + let res: Result = serde_yaml::from_str(yaml); + assert!(res.is_err(), "deny_unknown_fields must reject `body_acces` typo",); + let msg = format!("{}", res.unwrap_err()); + assert!( + msg.contains("body_acces") || msg.contains("unknown field"), + "error should name the bad field; got: {msg}", + ); +} + +/// `require_mcp_metadata` defaults to `true` — the safer fail-closed +/// posture. Operators must explicitly opt in to identity-only +/// pass-through for non-MCP traffic. +#[test] +fn config_require_mcp_metadata_defaults_to_true() { + let yaml = "config_path: /etc/praxis/cpex.yaml"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert!(cfg.require_mcp_metadata, "default must be fail-closed"); +} + +/// `init_timeout_secs` defaults to 30s when omitted. Operators don't +/// have to think about it; the bound is just present. +#[test] +fn config_init_timeout_defaults_to_30s() { + let yaml = "config_path: /etc/praxis/cpex.yaml"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!(cfg.init_timeout_secs, 30); +} + +/// An operator-supplied `init_timeout_secs` round-trips. Pins the +/// knob exists at the YAML surface, not just in the struct. +#[test] +fn config_init_timeout_honors_override() { + let yaml = "config_path: /etc/praxis/cpex.yaml\ninit_timeout_secs: 5"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!(cfg.init_timeout_secs, 5); +} + +// End-to-end exercise of the `init_timeout_secs` knob via the JWKS +// path is intentionally NOT a unit test: the bundled identity-jwt +// plugin has its own JWKS connect/request timeouts plus soft-fail-at- +// boot, so a hung JWKS endpoint never propagates a hang through +// `PluginManager::initialize` in the first place. The wrap-timeout in +// `CpexFilter::new` is defense-in-depth for OTHER init paths (custom +// plugins, future hooks) where a future could legitimately stall. +// The unit tests above pin the surface; the timeout's behavior is +// exercised by `tokio::time::timeout` itself. + +// ===================================================================== +// Fail-closed policy gate (require_mcp_metadata) +// ===================================================================== + +/// When `require_mcp_metadata: true` (default) and `mcp.method` is +/// absent from filter metadata, `on_request_body` rejects with +/// HTTP 500 + `X-Cpex-Violation: config.missing_mcp_metadata`. This +/// catches a misconfigured chain (mcp filter missing or ordered after +/// cpex) loudly at the first body-phase request. +#[tokio::test(flavor = "multi_thread")] +async fn missing_mcp_metadata_rejects_when_required() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter + .on_request_body(&mut ctx, &mut Some(bytes::Bytes::new()), true) + .await + .expect("filter ran"); + match action { + FilterAction::Reject(rej) => { + assert_eq!(rej.status, 500); + let violation = rej + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("X-Cpex-Violation")); + assert!(violation.is_some(), "violation header expected"); + assert_eq!( + violation.unwrap().1, + "config.missing_mcp_metadata", + "violation code should name the missing metadata", + ); + }, + other => panic!("expected Reject(500); got {other:?}"), + } +} + +/// When `require_mcp_metadata: false` and `mcp.method` is absent, +/// `on_request_body` passes the request through (identity-only mode +/// for non-MCP traffic). Pins the opt-out behavior. +#[tokio::test(flavor = "multi_thread")] +async fn missing_mcp_metadata_passes_when_not_required() { + let (_dir, path) = write_single_plugin_config(); + let cfg = CpexFilterConfig { + config_path: path, + body_access: super::config::BodyAccessMode::ReadOnly, + require_mcp_metadata: false, + init_timeout_secs: 30, + max_buffer_bytes: 10_485_760, + }; + let filter = CpexFilter::new(cfg).expect("filter should construct"); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter + .on_request_body(&mut ctx, &mut Some(bytes::Bytes::new()), true) + .await + .expect("filter ran"); + assert!( + matches!(action, FilterAction::BodyDone), + "expected BodyDone passthrough; got {action:?}", + ); +} + +// ===================================================================== +// Post-phase deny envelope (mcp_error_envelope_bytes) +// ===================================================================== + +/// The post-phase deny path replaces the response body with this +/// envelope when an APL `result:` pipeline denies. The envelope shape +/// must match the MCP Tools-spec JSON-RPC error format so clients can +/// parse it the same way they parse upstream errors. +#[test] +fn mcp_error_envelope_has_expected_shape() { + use cpex::cpex_core::error::PluginViolation; + + use super::error::mcp_error_envelope_bytes; + + let violation = PluginViolation::new("test.deny", "policy says no"); + let id = serde_json::json!(42); + let bytes = mcp_error_envelope_bytes(Some(&violation), &id); + + let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("envelope must be valid JSON"); + + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["id"], 42); + assert_eq!(parsed["error"]["code"], -32001); + assert_eq!(parsed["error"]["message"], "policy says no"); + assert_eq!(parsed["error"]["data"]["violation"], "test.deny"); +} + +/// `request_id` should round-trip preserving the JSON type the client +/// sent — string id stays a string, numeric stays numeric, etc. +/// Pins compliance with the JSON-RPC 2.0 spec. +#[test] +fn mcp_error_envelope_preserves_string_request_id() { + use super::error::mcp_error_envelope_bytes; + let id = serde_json::json!("req-abc-123"); + let bytes = mcp_error_envelope_bytes(None, &id); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); + assert_eq!(parsed["id"], "req-abc-123"); +} + +/// When no violation is provided (defensive null path), the envelope +/// still parses and carries the sentinel `gateway.unknown` code. +#[test] +fn mcp_error_envelope_handles_missing_violation() { + use super::error::mcp_error_envelope_bytes; + let id = serde_json::json!(null); + let bytes = mcp_error_envelope_bytes(None, &id); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); + assert_eq!(parsed["error"]["data"]["violation"], "gateway.unknown"); + assert_eq!(parsed["error"]["message"], "denied by gateway"); +} + +// ===================================================================== +// auth_rejection (transport-level 401) +// ===================================================================== + +/// `auth_rejection` builds an HTTP 401 with `WWW-Authenticate: Bearer` +/// and `X-Cpex-Violation:` reflecting the violation code so audit / +/// middleware can classify without parsing the body. Body carries the +/// short `code: reason` diagnostic. +#[test] +fn auth_rejection_shape_when_violation_present() { + use cpex::cpex_core::error::PluginViolation; + + use super::error::auth_rejection; + + let violation = PluginViolation::new("auth.invalid_token", "bad signature"); + let rej = auth_rejection(Some(&violation)); + assert_eq!(rej.status, 401); + + let www_auth = rej + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("WWW-Authenticate")); + assert_eq!(www_auth.expect("WWW-Authenticate header").1, "Bearer"); + + let viol = rej + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("X-Cpex-Violation")); + assert_eq!(viol.expect("X-Cpex-Violation header").1, "auth.invalid_token"); + + let body_bytes = rej.body.as_ref().expect("body present"); + let body = std::str::from_utf8(body_bytes).expect("utf8 body"); + assert!( + body.contains("auth.invalid_token") && body.contains("bad signature"), + "body should surface both code and reason; got {body:?}", + ); +} + +/// No violation surfaced still produces a usable 401 with the sentinel +/// `auth.unknown` code — clients always get a structured response. +#[test] +fn auth_rejection_falls_back_to_sentinel_when_no_violation() { + use super::error::auth_rejection; + let rej = auth_rejection(None); + assert_eq!(rej.status, 401); + let viol = rej + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("X-Cpex-Violation")); + assert_eq!(viol.expect("X-Cpex-Violation header").1, "auth.unknown"); +} + +// ===================================================================== +// fit_to_original_length (request/response body framing) +// ===================================================================== + +/// On shrink, `fit_to_original_length` pads the new body with trailing +/// ASCII spaces so the wire length equals the original `Content-Length`. +/// JSON parsers ignore trailing whitespace, so a downstream consumer +/// sees the rewritten envelope without a framing desync. +#[test] +fn fit_to_original_length_pads_on_shrink() { + use super::filter::fit_to_original_length; + let new = bytes::Bytes::from_static(b"abc"); + let out = fit_to_original_length(new, 8, "tools/call", "test"); + assert_eq!(out.len(), 8, "padded length must match original"); + assert_eq!(&out[..3], b"abc"); + assert!( + out[3..].iter().all(|b| *b == b' '), + "shrink padding must be ASCII spaces; got {:?}", + &out[3..], + ); +} + +/// On equal-length rewrite, the original bytes pass through unchanged. +/// No allocation, no padding — the common steady-state case for +/// in-place mutations like `redact(value)` swapping a same-width token. +#[test] +fn fit_to_original_length_passes_through_on_equal() { + use super::filter::fit_to_original_length; + let new = bytes::Bytes::from_static(b"redacted"); + let out = fit_to_original_length(new.clone(), 8, "tools/call", "test"); + assert_eq!(out, new); +} + +/// On grow, the body is truncated to exactly the original +/// `Content-Length`. The downstream response length is already committed +/// by the time `on_response_body` runs, so emitting more bytes would let +/// the overflow be parsed as the next response (a smuggling primitive). +/// Truncation corrupts the JSON but preserves HTTP/1.1 framing — the +/// safe failure mode. +#[test] +fn fit_to_original_length_truncates_on_grow() { + use super::filter::fit_to_original_length; + let new = bytes::Bytes::from_static(b"a much longer rewritten payload"); + let out = fit_to_original_length(new.clone(), 4, "tools/call", "test"); + assert_eq!(out.len(), 4, "grow path must truncate to the original length"); + assert_eq!(&*out, &new[..4], "truncation keeps the leading bytes"); +} + +// ===================================================================== +// cmf.rs — MCP method → entity coords +// ===================================================================== + +/// Pre-phase mapping returns `(entity_type, pre_hook_name)` for the +/// MCP methods that carry an entity, `None` for the no-entity methods. +/// The set is closed; any new MCP method needs an explicit decision. +#[test] +fn entity_for_mcp_method_covers_known_methods() { + use super::cmf::entity_for_mcp_method; + assert!(entity_for_mcp_method("tools/call").is_some()); + assert!(entity_for_mcp_method("prompts/get").is_some()); + assert!(entity_for_mcp_method("resources/read").is_some()); + assert!(entity_for_mcp_method("tools/list").is_none()); + assert!(entity_for_mcp_method("initialize").is_none()); + assert!(entity_for_mcp_method("unknown/method").is_none()); +} + +/// Post-phase mirror — same set of methods, different hooks. +#[test] +fn entity_for_mcp_method_post_covers_known_methods() { + use super::cmf::entity_for_mcp_method_post; + assert!(entity_for_mcp_method_post("tools/call").is_some()); + assert!(entity_for_mcp_method_post("prompts/get").is_some()); + assert!(entity_for_mcp_method_post("resources/read").is_some()); + assert!(entity_for_mcp_method_post("tools/list").is_none()); + assert!(entity_for_mcp_method_post("initialize").is_none()); +} + +// ===================================================================== +// json_rpc.rs — id extraction + content builders + re-serializers +// ===================================================================== + +/// `json_rpc_id` returns the `id` as a string for both string and +/// numeric ids (CMF correlation needs a single canonical key), and +/// falls back to the empty string when the body is missing or malformed. +#[test] +fn json_rpc_id_handles_string_numeric_and_malformed() { + use super::json_rpc::json_rpc_id; + let str_id = bytes::Bytes::from_static(br#"{"jsonrpc":"2.0","id":"req-1","method":"x"}"#); + let num_id = bytes::Bytes::from_static(br#"{"jsonrpc":"2.0","id":42,"method":"x"}"#); + let no_id = bytes::Bytes::from_static(br#"{"jsonrpc":"2.0","method":"x"}"#); + let bad = bytes::Bytes::from_static(b"not json"); + assert_eq!(json_rpc_id(&str_id), "req-1"); + assert_eq!(json_rpc_id(&num_id), "42"); + assert_eq!(json_rpc_id(&no_id), ""); + assert_eq!(json_rpc_id(&bad), ""); +} + +/// `json_rpc_id_value` preserves the original JSON type so an error +/// envelope echoes back exactly what the client sent (string stays a +/// string; numeric stays numeric). Missing/malformed → `Value::Null`. +#[test] +fn json_rpc_id_value_preserves_json_type() { + use super::json_rpc::json_rpc_id_value; + let str_id = bytes::Bytes::from_static(br#"{"id":"req-1"}"#); + let num_id = bytes::Bytes::from_static(br#"{"id":42}"#); + let bad = bytes::Bytes::from_static(b"{"); + assert_eq!(json_rpc_id_value(&str_id), serde_json::json!("req-1")); + assert_eq!(json_rpc_id_value(&num_id), serde_json::json!(42)); + assert_eq!(json_rpc_id_value(&bad), serde_json::Value::Null); +} + +/// `tools/call` parses `params.arguments` into a `ToolCall` content +/// part so APL `args.` predicates have something to read. +#[test] +fn build_content_for_method_tools_call() { + use cpex::cpex_core::cmf::ContentPart; + + use super::json_rpc::build_content_for_method; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"echo","arguments":{"text":"hi","n":7}}}"#, + ); + let parts = build_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolCall { content } => { + assert_eq!(content.name, "echo"); + assert_eq!(content.tool_call_id, "corr-1"); + assert_eq!(content.arguments.get("text"), Some(&serde_json::json!("hi"))); + assert_eq!(content.arguments.get("n"), Some(&serde_json::json!(7))); + }, + other => panic!("expected ToolCall; got {other:?}"), + } +} + +/// `resources/read` produces a `ResourceRef` keyed off `params.uri` +/// so route resolution and APL `resource.*` predicates work. +#[test] +fn build_content_for_method_resources_read() { + use cpex::cpex_core::cmf::ContentPart; + + use super::json_rpc::build_content_for_method; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"resources/read", + "params":{"uri":"file:///etc/example"}}"#, + ); + let parts = build_content_for_method("resources/read", "file:///etc/example", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ResourceRef { content } => { + assert_eq!(content.uri, "file:///etc/example"); + assert_eq!(content.resource_request_id, "corr-1"); + }, + other => panic!("expected ResourceRef; got {other:?}"), + } +} + +/// Unknown / no-entity MCP methods produce an empty content list — +/// CMF dispatch still routes by entity coords but predicates over +/// `args.*` see nothing, which is the correct behavior. +#[test] +fn build_content_for_method_unknown_method_yields_empty() { + use super::json_rpc::build_content_for_method; + let body = bytes::Bytes::from_static(br#"{"method":"tools/list"}"#); + let parts = build_content_for_method("tools/list", "n/a", "corr-1", &body); + assert!(parts.is_empty()); +} + +/// `reserialize_json_rpc_body` mutates only `params.arguments` (for +/// `tools/call`), leaving `jsonrpc`, `id`, `method`, `params.name` +/// untouched. Operators who hash the envelope only see deltas when +/// APL actually mutated. +#[test] +fn reserialize_tools_call_round_trips_with_mutated_args() { + use cpex::cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + + use super::json_rpc::reserialize_json_rpc_body; + + let original = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"echo","arguments":{"a":1}}}"#, + ); + let mut new_args: std::collections::HashMap = std::collections::HashMap::new(); + new_args.insert("a".to_owned(), serde_json::json!("[REDACTED]")); + let message = Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: String::new(), + name: "echo".to_owned(), + arguments: new_args, + namespace: None, + }, + }], + ); + let new_bytes = reserialize_json_rpc_body(&original, "tools/call", &message).expect("rewrite Some"); + let parsed: serde_json::Value = serde_json::from_slice(&new_bytes).expect("valid JSON"); + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["id"], 1); + assert_eq!(parsed["method"], "tools/call"); + assert_eq!(parsed["params"]["name"], "echo"); + assert_eq!(parsed["params"]["arguments"]["a"], "[REDACTED]"); +} + +/// Response-side: text-only content (no `structuredContent`) is parsed +/// out of the first text block. JSON-string contents resolve to typed +/// `content`; non-JSON text wraps as `{ "text": "" }`. The +/// `isError` flag round-trips. +#[test] +fn build_response_content_for_method_text_fallback() { + use cpex::cpex_core::cmf::ContentPart; + + use super::json_rpc::build_response_content_for_method; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{ + "content":[{"type":"text","text":"{\"k\":\"v\"}"}], + "isError":false}}"#, + ); + let parts = build_response_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolResult { content } => { + assert!(!content.is_error); + assert_eq!(content.content, serde_json::json!({"k":"v"})); + }, + other => panic!("expected ToolResult; got {other:?}"), + } +} + +/// `structuredContent` takes precedence over the text-block fallback +/// when present (newer MCP shape). +#[test] +fn build_response_content_for_method_prefers_structured_content() { + use cpex::cpex_core::cmf::ContentPart; + + use super::json_rpc::build_response_content_for_method; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{ + "content":[{"type":"text","text":"ignored"}], + "structuredContent":{"hi":"there"}, + "isError":true}}"#, + ); + let parts = build_response_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolResult { content } => { + assert!(content.is_error); + assert_eq!(content.content, serde_json::json!({"hi":"there"})); + }, + other => panic!("expected ToolResult; got {other:?}"), + } +} + +/// Response-side: when `result.content` has MULTIPLE text blocks and no +/// `structuredContent`, every block must end up in APL's view — not just +/// the first. Otherwise a later block carries data the policy never +/// inspected and the re-serializer never rewrites, leaking it. The +/// folded view exposes all blocks under `text`. +#[test] +fn build_response_content_for_method_folds_all_text_blocks() { + use cpex::cpex_core::cmf::ContentPart; + + use super::json_rpc::build_response_content_for_method; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{ + "content":[ + {"type":"text","text":"first secret"}, + {"type":"text","text":"second secret"} + ], + "isError":false}}"#, + ); + let parts = build_response_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolResult { content } => { + let text = content.content["text"].as_str().expect("text field present"); + assert!( + text.contains("first secret") && text.contains("second secret"), + "folded view must include every text block; got {text:?}", + ); + }, + other => panic!("expected ToolResult; got {other:?}"), + } +} + +/// Response-side emit: when APL mutates the result, the entire +/// `result.content` array is collapsed to a single canonical text block +/// holding the vetted payload. Any other blocks (extra text, non-text) +/// are dropped so nothing the policy didn't vet survives, and +/// `structuredContent` is mirrored when the original had it. +#[test] +fn reserialize_response_collapses_to_single_vetted_block() { + use cpex::cpex_core::cmf::{ContentPart, Message, Role, ToolResult}; + + use super::json_rpc::reserialize_json_rpc_response_body; + + let original = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"old one"},{"type":"text","text":"old two"},{"type":"image","data":"B64","mimeType":"image/png"}],"structuredContent":{"ssn":"555-12-3456"},"isError":false}}"#, + ); + let vetted = serde_json::json!({ "ssn": "[REDACTED]" }); + let message = Message::with_content( + Role::Assistant, + vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: String::new(), + tool_name: "echo".to_owned(), + content: vetted.clone(), + is_error: false, + }, + }], + ); + let out = reserialize_json_rpc_response_body(&original, "tools/call", &message).expect("Some"); + let parsed: serde_json::Value = serde_json::from_slice(&out).expect("valid JSON"); + let content = parsed["result"]["content"].as_array().expect("content array"); + assert_eq!(content.len(), 1, "extra blocks must be dropped; got {content:?}"); + let inner: serde_json::Value = + serde_json::from_str(content[0]["text"].as_str().expect("text")).expect("vetted JSON"); + assert_eq!(inner, vetted, "emitted block must hold exactly the vetted value"); + assert_eq!( + parsed["result"]["structuredContent"], vetted, + "structuredContent mirrors vetted" + ); +} + +/// Fail-closed sizing: a deny envelope substituted on the +/// response-rewrite-overflow / identity-failure paths is fitted to the +/// committed `Content-Length` — never longer. Pins the composition the +/// filter relies on so an oversized rewrite can never become a framing +/// desync. +#[test] +fn deny_envelope_fits_committed_length() { + use cpex::cpex_core::error::PluginViolation; + + use super::{error::mcp_error_envelope_bytes, filter::fit_to_original_length}; + + let violation = PluginViolation::new("gateway.response_rewrite_overflow", "too large to fit"); + let envelope = mcp_error_envelope_bytes(Some(&violation), &serde_json::json!(1)); + let original_len = envelope.len() + 64; + let fitted = fit_to_original_length(envelope, original_len, "tools/call", "overflow"); + assert_eq!( + fitted.len(), + original_len, + "deny envelope must be padded to exactly the committed length", + ); +} + +// ===================================================================== +// on_request_body — CMF dispatch path (identity-only policy, no routes) +// ===================================================================== + +/// `on_request_body` happy path: valid JWT, `mcp.method=tools/call`, +/// `mcp.name` set. The identity-only policy has no APL routes, so the +/// CMF dispatch finds no matching handler and the request continues. +/// Pins the "CMF dispatch runs without crashing and returns +/// `BodyDone`" branch the reviewer flagged as untested. +#[tokio::test(flavor = "multi_thread")] +async fn on_request_body_dispatches_cmf_when_metadata_present() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("mcp.method", "tools/call"); + ctx.set_metadata("mcp.name", "echo"); + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"echo","arguments":{}}}"#, + ); + + let action = filter + .on_request_body(&mut ctx, &mut Some(body), true) + .await + .expect("filter ran"); + assert!( + matches!(action, FilterAction::BodyDone), + "no APL route should yield BodyDone; got {action:?}", + ); +} + +/// A `cel:` route step gates the call through the `apl-pdp-cel` backend. +/// `alice` satisfies `subject.id == "alice"` → Allow (`BodyDone`); any +/// other subject fails the predicate → fail-closed Deny (`Reject`). +/// Proves praxis registers `CelPdpFactory` and the CEL PDP decision +/// flows through CMF dispatch alongside Cedar. +#[tokio::test(flavor = "multi_thread")] +async fn cel_route_allows_matching_subject_and_denies_others() { + let (_dir, path) = write_cel_policy_config(); + let filter = build_filter(path); + + let allow = dispatch_echo_as(&filter, "alice").await; + assert!( + matches!(allow, FilterAction::BodyDone), + "alice satisfies the CEL predicate; expected BodyDone, got {allow:?}", + ); + + let deny = dispatch_echo_as(&filter, "eve").await; + assert!( + matches!(deny, FilterAction::Reject(_)), + "eve fails the CEL predicate; expected Reject, got {deny:?}", + ); +} + +/// Session tainting end-to-end through the filter: reading the secret +/// taints the session (`taint(secret, session)`), and a later call in +/// the SAME session is denied (`security.labels contains "secret"`). A +/// DIFFERENT session id is unaffected — taint is session-scoped. Proves +/// the `X-Session-Id` → `agent.session_id` wiring + the cpex session +/// store's hydrate/persist round-trip across requests. +#[tokio::test(flavor = "multi_thread")] +async fn session_taint_persists_and_denies_within_the_same_session() { + let (_dir, path) = write_taint_config(); + let filter = build_filter(path); + + let taint = dispatch_tool_session(&filter, "alice", "read-secret", "sess-1").await; + assert!( + matches!(taint, FilterAction::BodyDone), + "tainting call should pass; got {taint:?}", + ); + + let denied = dispatch_tool_session(&filter, "alice", "send-out", "sess-1").await; + assert!( + matches!(denied, FilterAction::Reject(_)), + "send-out in the tainted session must be denied; got {denied:?}", + ); + + let clean = dispatch_tool_session(&filter, "alice", "send-out", "sess-2").await; + assert!( + matches!(clean, FilterAction::BodyDone), + "send-out in a fresh session must pass; got {clean:?}", + ); +} + +/// Cross-principal isolation: session taint is keyed by the resolved +/// subject, so the SAME `X-Session-Id` under a different subject is a +/// different bucket. `eve` taints `shared`, but `bob` reusing `shared` +/// is unaffected — `H(eve:shared) != H(bob:shared)`. +#[tokio::test(flavor = "multi_thread")] +async fn session_taint_is_isolated_across_principals() { + let (_dir, path) = write_taint_config(); + let filter = build_filter(path); + + let taint = dispatch_tool_session(&filter, "eve", "read-secret", "shared").await; + assert!( + matches!(taint, FilterAction::BodyDone), + "eve's tainting call should pass; got {taint:?}", + ); + + let bob = dispatch_tool_session(&filter, "bob", "send-out", "shared").await; + assert!( + matches!(bob, FilterAction::BodyDone), + "bob reusing eve's session id must NOT inherit her taint; got {bob:?}", + ); +} + +/// Non-EOS chunks must pass through untouched — CMF dispatch waits +/// for the full body so praxis's `mcp` filter has finished parsing +/// and writing metadata. Pins the streaming-chunk fast path. +#[tokio::test(flavor = "multi_thread")] +async fn on_request_body_continues_on_partial_chunks() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let mut chunk = Some(bytes::Bytes::from_static(br#"{"jsonrpc":"2.0""#)); + let action = filter + .on_request_body(&mut ctx, &mut chunk, /* end_of_stream= */ false) + .await + .expect("filter ran"); + assert!( + matches!(action, FilterAction::Continue), + "non-EOS chunk must Continue without touching body; got {action:?}", + ); +} + +// ===================================================================== +// on_response_body — early returns +// ===================================================================== + +/// In default `body_access: read_only`, `on_response_body` returns +/// `Continue` without doing any work — the operator hasn't opted into +/// response rewriting, and the post-phase deny envelope path is gated +/// on `read_write`. Pins the early-return that keeps the sync hook +/// from blocking on `block_in_place` for read-only chains. +#[test] +fn on_response_body_in_read_only_is_a_no_op() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let mut body = Some(bytes::Bytes::from_static(b"some upstream body")); + let action = filter + .on_response_body(&mut ctx, &mut body, /* end_of_stream= */ true) + .expect("hook ran"); + assert!( + matches!(action, FilterAction::Continue), + "ReadOnly response phase must Continue without rewriting; got {action:?}", + ); + assert_eq!( + body.as_deref(), + Some(b"some upstream body".as_slice()), + "body bytes must be untouched in ReadOnly", + ); +} + +/// `on_response_body` returns `Continue` on non-EOS chunks regardless +/// of `body_access`. Mirror of the request-side partial-chunk test. +#[test] +fn on_response_body_continues_on_partial_chunks() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let mut chunk = Some(bytes::Bytes::from_static(b"partial")); + let action = filter + .on_response_body(&mut ctx, &mut chunk, /* end_of_stream= */ false) + .expect("hook ran"); + assert!(matches!(action, FilterAction::Continue)); +} + +// ===================================================================== +// attach_delegated_tokens — outbound header collision handling +// ===================================================================== + +/// Two delegated tokens that both target the same outbound header +/// are a policy-layering mistake (overlapping delegators). Praxis's +/// `request_headers_to_set` is overwrite-semantics and `HashMap` +/// iteration order is non-deterministic, so the naive path would +/// silently pick one. The filter applies first-writer-wins keyed by +/// `(outbound_header_lc, audience)`: only the alphabetically lowest +/// audience attaches, the other is logged and skipped, and the +/// returned count reflects what actually went on the wire. +#[test] +#[expect(clippy::too_many_lines, reason = "test fixture construction")] +fn attach_delegated_tokens_first_writer_wins_per_outbound_header() { + use std::sync::Arc; + + use chrono::{Duration, Utc}; + use cpex::cpex_core::extensions::{ + container::Extensions, + raw_credentials::{DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken}, + }; + + use super::filter::attach_delegated_tokens; + + let expires = Utc::now() + Duration::hours(1); + let tok_a = RawDelegatedToken::new("token-a", "Authorization", "aud-a", Vec::::new(), expires); + let tok_b = RawDelegatedToken::new("token-b", "Authorization", "aud-b", Vec::::new(), expires); + let key_a = DelegationKey { + subject_id: "alice".to_owned(), + audience: "aud-a".to_owned(), + scopes: Vec::new(), + mode: DelegationMode::OnBehalfOfUser, + }; + let key_b = DelegationKey { + audience: "aud-b".to_owned(), + ..key_a.clone() + }; + let mut creds = RawCredentialsExtension::default(); + creds.delegated_tokens.insert(key_a, tok_a); + creds.delegated_tokens.insert(key_b, tok_b); + let ext = Extensions { + raw_credentials: Some(Arc::new(creds)), + ..Extensions::default() + }; + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let count = attach_delegated_tokens(&mut ctx, Some(&ext)); + + assert_eq!(count, 1, "exactly one token attaches for a colliding header"); + assert_eq!(ctx.request_headers_to_set.len(), 1, "exactly one header push"); + let (name, value) = &ctx.request_headers_to_set[0]; + assert_eq!(name.as_str(), "authorization"); + assert_eq!( + value.to_str().expect("ASCII header value"), + "Bearer token-a", + "first-writer-wins by audience asc must pick aud-a", + ); +} + +/// Sanity: non-colliding tokens for distinct outbound headers all +/// attach. Pins that the collision guard doesn't drop legitimate +/// multi-audience flows (the common case for routes that delegate +/// to multiple upstream APIs simultaneously). +#[test] +#[expect(clippy::too_many_lines, reason = "test fixture construction")] +fn attach_delegated_tokens_distinct_outbound_headers_all_attach() { + use std::sync::Arc; + + use chrono::{Duration, Utc}; + use cpex::cpex_core::extensions::{ + container::Extensions, + raw_credentials::{DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken}, + }; + + use super::filter::attach_delegated_tokens; + + let expires = Utc::now() + Duration::hours(1); + let tok_auth = RawDelegatedToken::new("token-auth", "Authorization", "aud-auth", Vec::::new(), expires); + let tok_x = RawDelegatedToken::new("token-x", "X-Upstream-Token", "aud-x", Vec::::new(), expires); + let key_auth = DelegationKey { + subject_id: "alice".to_owned(), + audience: "aud-auth".to_owned(), + scopes: Vec::new(), + mode: DelegationMode::OnBehalfOfUser, + }; + let key_x = DelegationKey { + audience: "aud-x".to_owned(), + ..key_auth.clone() + }; + let mut creds = RawCredentialsExtension::default(); + creds.delegated_tokens.insert(key_auth, tok_auth); + creds.delegated_tokens.insert(key_x, tok_x); + let ext = Extensions { + raw_credentials: Some(Arc::new(creds)), + ..Extensions::default() + }; + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let count = attach_delegated_tokens(&mut ctx, Some(&ext)); + + assert_eq!(count, 2, "two distinct headers must both attach"); + assert_eq!(ctx.request_headers_to_set.len(), 2); +} diff --git a/filter/src/builtins/http/security/mod.rs b/filter/src/builtins/http/security/mod.rs index 1719a196..ce629c98 100644 --- a/filter/src/builtins/http/security/mod.rs +++ b/filter/src/builtins/http/security/mod.rs @@ -2,9 +2,12 @@ // Copyright (c) 2024 Praxis Contributors //! HTTP security filters: CORS, CSRF, IP access control, credential injection, -//! forwarded-header injection, and guardrails. +//! forwarded-header injection, guardrails, and the (feature-gated) CPEX policy +//! filter. mod cors; +#[cfg(feature = "cpex")] +mod cpex; mod credential_injection; mod csrf; mod forwarded_headers; @@ -14,6 +17,8 @@ pub(crate) mod origin_matcher; pub(crate) mod origin_normalize; pub use cors::{CorsFilter, DisallowedOriginMode}; +#[cfg(feature = "cpex")] +pub use cpex::CpexFilter; pub use credential_injection::CredentialInjectionFilter; pub use csrf::CsrfFilter; pub use forwarded_headers::ForwardedHeadersFilter; diff --git a/filter/src/builtins/mod.rs b/filter/src/builtins/mod.rs index 23c99ada..7f590905 100644 --- a/filter/src/builtins/mod.rs +++ b/filter/src/builtins/mod.rs @@ -18,6 +18,8 @@ pub use http::AnthropicStreamEventsFilter; pub use http::AnthropicToOpenaiFilter; #[cfg(feature = "ai-inference")] pub use http::AnthropicValidateFilter; +#[cfg(feature = "cpex")] +pub use http::CpexFilter; #[cfg(feature = "ai-inference")] pub use http::ModelToHeaderFilter; #[cfg(feature = "ai-inference")] diff --git a/filter/src/lib.rs b/filter/src/lib.rs index d1d6a0e8..69f91b1f 100644 --- a/filter/src/lib.rs +++ b/filter/src/lib.rs @@ -37,6 +37,8 @@ pub use builtins::AnthropicStreamEventsFilter; pub use builtins::AnthropicToOpenaiFilter; #[cfg(feature = "ai-inference")] pub use builtins::AnthropicValidateFilter; +#[cfg(feature = "cpex")] +pub use builtins::CpexFilter; #[cfg(feature = "ai-inference")] pub use builtins::OpenaiResponsesValidateFilter; #[cfg(feature = "ai-inference")] diff --git a/filter/src/registry.rs b/filter/src/registry.rs index 87d2f258..50ccfe8e 100644 --- a/filter/src/registry.rs +++ b/filter/src/registry.rs @@ -120,6 +120,8 @@ fn register_http_builtins(factories: &mut HashMap) { register_http(factories, "circuit_breaker", CircuitBreakerFilter::from_config); register_http(factories, "compression", CompressionFilter::from_config); register_http(factories, "cors", CorsFilter::from_config); + #[cfg(feature = "cpex")] + register_http(factories, "cpex", crate::CpexFilter::from_config); register_http(factories, "csrf", CsrfFilter::from_config); register_http( factories, @@ -372,6 +374,8 @@ mod tests { names.contains(&"openai_response_store"), "response_store should be registered" ); + #[cfg(feature = "cpex")] + assert!(names.contains(&"cpex"), "cpex should be registered"); } #[test] diff --git a/server/Cargo.toml b/server/Cargo.toml index d24c4007..29d58bdc 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -47,3 +47,4 @@ tempfile = { workspace = true } default = ["ai-inference"] ai-inference = ["praxis-filter/ai-inference", "praxis-protocol/ai-inference"] ext-proc = ["praxis-filter/ext-proc"] +cpex = ["praxis-filter/cpex"] diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index c983a611..26c5f30a 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -10,6 +10,7 @@ publish = false [features] default = ["ai-inference"] ai-inference = ["praxis-filter/ai-inference"] +cpex = ["praxis-filter/cpex"] no-mac-cert-rotation-tests = [] # We use this to disable testing cert rotation on macOS [lints] @@ -21,6 +22,9 @@ async-trait = { workspace = true } futures = { workspace = true } bytes = { workspace = true } http = { workspace = true } +# Used only by the cpex example test to mint HS256 JWTs for the +# happy-path case (matches the filter crate's signing dependency). +jsonwebtoken = "9" praxis-core = { workspace = true } praxis-filter = { workspace = true } praxis-protocol = { workspace = true } diff --git a/tests/integration/fixtures/cpex-policy.yaml b/tests/integration/fixtures/cpex-policy.yaml new file mode 100644 index 00000000..99872e8f --- /dev/null +++ b/tests/integration/fixtures/cpex-policy.yaml @@ -0,0 +1,35 @@ +# CPEX policy fixture for the `security/cpex.yaml` example integration +# test. The example itself points `config_path` at an operator-supplied +# deployment path; the test rewrites that to this file so the cpex +# filter constructs against a real policy without shipping one under +# examples/. +# +# Minimal shape: one HS256 JWT identity plugin, no routes. The cpex +# filter resolves identity in on_request (deny on missing/invalid +# JWT); on_request_body sees no APL route annotations, so policy +# dispatch is a no-op and the request passes through. +# +# This is enough to exercise the integration's wiring without +# requiring a Keycloak / JWKS endpoint to run the example. Production +# deployments swap HS256 for RS256+JWKS, add routes, attach +# delegators, and so on. See the CPEX demo in the praxis-demos +# repository (`demos/cpex/cpex.yaml`) for a fully-featured policy. + +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "https://idp.example.com" + audiences: ["praxis-cpex-example"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "REPLACE-WITH-A-PROPERLY-RANDOM-SHARED-SECRET-DO-NOT-COMMIT" + leeway_seconds: 60 + claim_mapper: standard diff --git a/tests/integration/tests/suite/examples/cpex.rs b/tests/integration/tests/suite/examples/cpex.rs new file mode 100644 index 00000000..ac17ed97 --- /dev/null +++ b/tests/integration/tests/suite/examples/cpex.rs @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Teryl Taylor + +//! Functional integration test for the CPEX example config. +//! +//! Exercises the `examples/configs/security/cpex.yaml` filter chain +//! end-to-end: praxis is configured with the `mcp` → `cpex` → `router` +//! → `load_balancer` chain. Two cases: +//! +//! * **Deny** — a request with no `Authorization` header is rejected with HTTP 401 (the cpex identity gate's +//! `auth_rejection` path). +//! * **Allow** — a request carrying a valid HS256 JWT (signed with the fixture's shared secret) resolves identity, +//! finds no APL route to gate it, and passes through to the backend with HTTP 200. +//! +//! Together these prove the example config loads, the filter +//! constructs from the policy YAML, and the chain both blocks +//! unauthenticated traffic and forwards authenticated traffic — the +//! CLAUDE.md "Adding a Filter" end-to-end functional requirement. + +use std::{ + collections::HashMap, + time::{SystemTime, UNIX_EPOCH}, +}; + +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use praxis_core::config::Config; +use praxis_test_utils::{ + example_config_path, free_port, http_send, parse_status, patch_yaml, start_backend_with_shutdown, start_proxy, +}; + +// Identity parameters mirrored from `tests/integration/fixtures/cpex-policy.yaml`. +// The happy-path JWT must match the fixture's trusted issuer, audience, +// algorithm, and shared secret for the `jwt-user` identity plugin to +// accept it. +const FIXTURE_ISSUER: &str = "https://idp.example.com"; +const FIXTURE_AUDIENCE: &str = "praxis-cpex-example"; +const FIXTURE_SECRET: &str = "REPLACE-WITH-A-PROPERLY-RANDOM-SHARED-SECRET-DO-NOT-COMMIT"; + +/// Mint an HS256 JWT accepted by the fixture's `jwt-user` plugin: the +/// fixture's issuer/audience, the given subject, and a fresh `exp`. +fn mint_fixture_jwt(subject: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_secs(); + let claims = serde_json::json!({ + "iss": FIXTURE_ISSUER, + "aud": FIXTURE_AUDIENCE, + "sub": subject, + "iat": now, + "exp": now + 300, + }); + encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(FIXTURE_SECRET.as_bytes()), + ) + .expect("sign fixture JWT") +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Load the CPEX praxis example, patch the relative `config_path` +/// reference into an absolute path, then patch ports. Returns a +/// fully-parsed [`Config`] ready for [`start_proxy`]. +#[expect(clippy::needless_pass_by_value, reason = "callers construct the map inline")] +fn load_cpex_example(proxy_port: u16, port_map: HashMap<&str, u16>) -> Config { + let praxis_yaml_path = example_config_path("security/cpex.yaml"); + let policy_yaml_path = format!("{}/fixtures/cpex-policy.yaml", env!("CARGO_MANIFEST_DIR")); + + let raw = std::fs::read_to_string(&praxis_yaml_path).unwrap_or_else(|e| panic!("read {praxis_yaml_path}: {e}")); + // The example points `config_path` at an operator-supplied + // deployment path (the policy is not shipped under examples/). The + // test rewrites it to the minimal in-repo fixture so the filter + // constructs regardless of the test's working directory. + let with_policy = raw.replace("/etc/praxis/cpex-policy.yaml", &policy_yaml_path); + let patched = patch_yaml(&with_policy, proxy_port, &port_map); + Config::from_yaml(&patched).unwrap_or_else(|e| panic!("parse security/cpex.yaml: {e}")) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[test] +fn cpex_example_missing_authorization_rejects_401() { + let backend_guard = start_backend_with_shutdown("ok"); + let proxy_port = free_port(); + let config = load_cpex_example(proxy_port, HashMap::from([("127.0.0.1:3000", backend_guard.port())])); + let proxy = start_proxy(&config); + + // POST with a well-formed MCP body but no Authorization header. + // The identity hook chain denies, cpex returns auth_rejection (401 + // with WWW-Authenticate + X-Cpex-Violation headers). + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{}}}"#; + let raw = http_send( + proxy.addr(), + &format!( + "POST /mcp HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len(), + ), + ); + + assert_eq!( + parse_status(&raw), + 401, + "missing Authorization should hit the cpex identity gate; raw response:\n{raw}", + ); + assert!( + raw.to_lowercase().contains("www-authenticate: bearer"), + "401 must carry WWW-Authenticate per MCP auth spec; raw response:\n{raw}", + ); + assert!( + raw.to_lowercase().contains("x-cpex-violation:"), + "rejection should surface the violation code via X-Cpex-Violation; raw response:\n{raw}", + ); +} + +#[test] +fn cpex_example_valid_jwt_passes_through() { + let backend_guard = start_backend_with_shutdown("ok"); + let proxy_port = free_port(); + let config = load_cpex_example(proxy_port, HashMap::from([("127.0.0.1:3000", backend_guard.port())])); + let proxy = start_proxy(&config); + + // Same well-formed MCP body as the deny case, but now carrying a + // valid HS256 JWT. The cpex identity gate resolves it, the fixture + // declares no APL routes so policy dispatch is a no-op, and the + // request reaches the backend (HTTP 200, body "ok"). + let token = mint_fixture_jwt("alice"); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{}}}"#; + let raw = http_send( + proxy.addr(), + &format!( + "POST /mcp HTTP/1.1\r\n\ + Host: localhost\r\n\ + Authorization: Bearer {token}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len(), + ), + ); + + assert_eq!( + parse_status(&raw), + 200, + "a valid JWT should resolve identity and pass through to the backend; raw response:\n{raw}", + ); + assert!( + raw.contains("ok"), + "the upstream backend body should reach the client on the allow path; raw response:\n{raw}", + ); +} diff --git a/tests/integration/tests/suite/examples/mod.rs b/tests/integration/tests/suite/examples/mod.rs index f814c557..77989580 100644 --- a/tests/integration/tests/suite/examples/mod.rs +++ b/tests/integration/tests/suite/examples/mod.rs @@ -17,6 +17,8 @@ mod basic_reverse_proxy; mod canary_routing; mod circuit_breaker; mod conditional_filters; +#[cfg(feature = "cpex")] +mod cpex; mod credential_injection; mod csrf; mod default_config;