diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml new file mode 100644 index 00000000..9506d426 --- /dev/null +++ b/.github/workflows/release-ffi.yaml @@ -0,0 +1,190 @@ +# =============================================================== +# Release FFI - Build, sign, and publish libcpex_ffi.a artifacts +# =============================================================== +# +# Triggered by semver-strict tag pushes. Matrix-builds the FFI +# static library for the supported target tuples, packages each into +# a tarball with VERSION / FFI_ABI / LICENSE metadata, signs every +# tarball + the aggregate SHA256SUMS with cosign keyless (Sigstore), +# and attaches everything to the GitHub Release for the tag. +# +# See crates/cpex-ffi/RELEASE.md for the artifact schema and the +# consumer-side verify-and-unpack recipe. + +name: Release FFI + +on: + push: + tags: + # Semver-strict. Two patterns so vMAJOR.MINOR.PATCH (release) + # and vMAJOR.MINOR.PATCH- (rc / beta / ffi.test) + # both fire, while loose `v*` matches (vendor-bump, v1, v-foo) + # and the legacy non-prefixed tags (0.1.0, plugins.dev1) do not. + # Dry-run tags like v0.0.0-ffi.test.1 deliberately hit the + # prerelease branch. + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' + workflow_dispatch: + +# id-token: write is what unlocks Sigstore keyless signing (Fulcio +# reads the GHA OIDC token to issue the short-lived signing cert). +# contents: write is needed to create / upload to the GitHub Release. +permissions: + contents: write + id-token: write + +# Prevent concurrent runs on the same tag from racing the release +# creation. Tag pushes are one-shot, so this is belt-and-suspenders. +concurrency: + group: release-ffi-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false # one tuple failing should not cancel the others + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-22.04-arm + - target: x86_64-unknown-linux-musl + runner: ubuntu-latest + - target: aarch64-unknown-linux-musl + runner: ubuntu-22.04-arm + - target: aarch64-apple-darwin + runner: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + # dtolnay/rust-toolchain is the de-facto rustup action. + # `stable` picks the latest stable; pin if we need a floor. + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + # Share cache across tags (the key includes the target + + # Cargo.lock hash by default). Significant speedup on + # subsequent releases of the same target. + key: ${{ matrix.target }} + + - name: Install musl toolchain + # ring (via jsonwebtoken/rustls/quinn) compiles C + asm through + # cc-rs, which for a *-unknown-linux-musl target shells out to + # -linux-musl-gcc. The runners ship only the glibc gcc, so + # we install musl-gcc here. The matrix runs each musl target on + # its native-arch runner, so musl-gcc targets the host arch and + # the CC_/LINKER env vars below redirect cc-rs and the linker to + # it. Scoped to the musl legs; gnu/darwin use their default cc. + if: contains(matrix.target, 'musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools musl-dev + + - name: Build artifact + env: + TARGET: ${{ matrix.target }} + # VERSION drops the leading "refs/tags/" so the tarball + # name matches the tag verbatim. + VERSION: ${{ github.ref_name }} + DIST_DIR: dist + # Point cc-rs and the linker at musl-gcc for the musl targets. + # No-ops for gnu/darwin (those triples don't match these keys). + CC_x86_64_unknown_linux_musl: musl-gcc + CC_aarch64_unknown_linux_musl: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + run: bash scripts/release/build-artifact.sh + + - name: Upload tarball + sha256 + uses: actions/upload-artifact@v4 + with: + # Unique per-target name so the download step in + # sign-and-release can merge them all into one dist/. + name: cpex-ffi-${{ github.ref_name }}-${{ matrix.target }} + path: dist/cpex-ffi-* + if-no-files-found: error + retention-days: 7 + + sign-and-release: + name: Sign and publish release + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all matrix artifacts + uses: actions/download-artifact@v4 + with: + path: dist + # merge-multiple flattens the per-target subdirs into one + # dist/ so the sign script and gh release upload see a + # flat layout. + merge-multiple: true + + - name: Generate aggregate SHA256SUMS + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + cd dist + # Concat all individual .sha256 files into one signed + # integrity manifest. The per-tarball .sha256 files stay + # as convenience companions, but the SHA256SUMS file is + # what auditors care about. + : > "cpex-ffi-${VERSION}-SHA256SUMS" + for f in cpex-ffi-*.tar.gz; do + if command -v sha256sum >/dev/null; then + sha256sum "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + else + shasum -a 256 "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + fi + done + echo "--- SHA256SUMS ---" + cat "cpex-ffi-${VERSION}-SHA256SUMS" + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign artifacts + env: + DIST_DIR: dist + run: bash scripts/release/sign-artifact.sh + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + # Auto-detect prerelease by suffix so dry-run tags + # (v0.0.0-ffi.test.1) land as prereleases and don't surface + # as "latest" on the repo's Releases page. + PRERELEASE_FLAG="" + if [[ "$TAG" == *-* ]]; then + PRERELEASE_FLAG="--prerelease" + fi + + # Idempotent: if the release exists, upload --clobber the + # new files; if it doesn't, create it with the tarballs + + # SHA256SUMS + sigs in one shot. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG exists; uploading artifacts with --clobber" + gh release upload "$TAG" dist/cpex-ffi-* --clobber + else + echo "Creating release $TAG" + gh release create "$TAG" \ + --title "$TAG" \ + --notes "Automated FFI artifact release. See crates/cpex-ffi/RELEASE.md for the schema and verify-and-consume recipe." \ + $PRERELEASE_FLAG \ + dist/cpex-ffi-* + fi diff --git a/.gitignore b/.gitignore index beb86085..69b14ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -194,13 +194,29 @@ celerybeat-schedule # Environments .env +.env.* +*.env +env.bak/ +env.back +env.bak .venv env/ venv/ ENV/ -env.bak/ venv.bak/ +# Loose credential / token files — defensive net against accidental +# `git add` of dev-captured real tokens. The `.env` patterns above +# already cover the canonical case. +bearertoken* +*.token +*.tokens +*credentials*.json +*credentials*.yaml +*credentials*.yml +apikey* +*.pem + # Spyder project settings .spyderproject .spyproject diff --git a/CHANGELOG.md b/CHANGELOG.md index c0185621..cee356e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,36 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- APL (Attribute Policy Language) governance is now bundled into + `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers + the standard APL plugin/PDP factories (`validator/pii-scan`, + `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and + installs the APL config visitor on a manager. Call it after + `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use + `PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds + the Cedarling-backed identity + PDP seams (off by default; the released + `.a` stays lean). +- Publish `libcpex_ffi.a` as signed GitHub Release artifacts on + every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, + `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign + keyless signatures + SHA256 checksums; see + `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and- + consume recipe. +- FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor + exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` + and panics on mismatch. Other language bindings must replicate the + check. + +### Changed + +- FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install` + extern C function and changed `cpex_load_config` to run registered + config visitors (it now calls `load_config_yaml` internally so `apl:` + blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped + in lockstep. + ## [0.1.0] - 2026-05-05 ### Added diff --git a/Cargo.lock b/Cargo.lock index e8149dbc..36aebe0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,829 +3,4681 @@ version = 4 [[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anyhow" -version = "1.0.102" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "arc-swap" -version = "1.9.1" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "rustversion", + "cfg-if", + "once_cell", + "version_check", + "zerocopy", ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "proc-macro2", - "quote", - "syn", + "memchr", ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "bitflags" -version = "2.11.0" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] -name = "bumpalo" -version = "3.20.2" +name = "anyhow" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +name = "apl-audit-logger" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "cpex-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +name = "apl-cedarling" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "cedar-policy", + "cedarling", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "cpex-core" +name = "apl-cmf" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "cpex-core", + "serde_json", + "tokio", +] + +[[package]] +name = "apl-core" version = "0.1.0" dependencies = [ - "arc-swap", "async-trait", + "cpex-orchestration", "futures", - "hashbrown 0.15.5", + "regex", "serde", "serde_json", "serde_yaml", - "thiserror", + "thiserror 2.0.18", "tokio", - "tokio-util", - "tracing", - "uuid", - "wildmatch", ] [[package]] -name = "cpex-demo-ffi" +name = "apl-cpex" version = "0.1.0" dependencies = [ + "apl-cmf", + "apl-core", "async-trait", + "chrono", "cpex-core", - "cpex-ffi", + "serde", "serde_json", + "serde_yaml", + "sha2 0.10.9", + "tokio", "tracing", ] [[package]] -name = "cpex-ffi" +name = "apl-delegator-biscuit" version = "0.1.0" dependencies = [ + "apl-core", "async-trait", + "biscuit-auth", + "chrono", "cpex-core", - "rmp-serde", + "hex", "serde", - "serde_bytes", "serde_json", + "serde_yaml", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] -name = "cpex-sdk" +name = "apl-delegator-oauth" version = "0.1.0" dependencies = [ + "apl-core", "async-trait", + "chrono", "cpex-core", + "mockito", + "reqwest 0.12.28", "serde", "serde_json", + "serde_urlencoded", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "zeroize", ] [[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +name = "apl-identity-jwt" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "base64 0.22.1", + "chrono", + "cpex-core", + "futures", + "jsonwebtoken 9.3.1", + "mockito", + "rand 0.8.6", + "reqwest 0.12.28", + "rsa", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +name = "apl-pdp-cedar-direct" +version = "0.1.0" dependencies = [ - "libc", - "windows-sys", + "apl-cmf", + "apl-core", + "apl-cpex", + "async-trait", + "cedar-policy", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", ] [[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +name = "apl-pii-scanner" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "futures" -version = "0.3.32" +name = "ar_archive_writer" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "object", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "arc-swap" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ - "futures-core", - "futures-sink", + "rustversion", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "arraydeque" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] -name = "futures-executor" -version = "0.3.32" +name = "arrayvec" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "term", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "futures-task" -version = "0.3.32" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "futures-util" -version = "0.3.32" +name = "aws-lc-rs" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "aws-lc-sys" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "cc", + "cmake", + "dunce", + "fs_extra", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "hashbrown" -version = "0.17.0" +name = "base64" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] -name = "heck" -version = "0.5.0" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] -name = "id-arena" -version = "2.3.0" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "indexmap" -version = "2.14.0" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "biscuit-auth" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5884fc86b3e21f5649ef4326e17ef729b3096e6502deaf13db7b7fb05bb992b" dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "base64 0.13.1", + "biscuit-parser", + "biscuit-quote", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "getrandom 0.2.17", + "hex", + "nom", + "p256", + "pkcs8 0.9.0", + "prost", + "prost-types", + "rand 0.8.6", + "rand_core 0.6.4", + "regex", + "serde_json", + "sha2 0.9.9", + "thiserror 1.0.69", + "time", + "zeroize", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "biscuit-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d7cafdbc8c30e1f0fb87df7161bec77f6f00da652cc33f102b0f95bd1cbc0fa" +dependencies = [ + "hex", + "nom", + "proc-macro2", + "quote", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "biscuit-quote" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d2332c742a07a846f1fb2760e58a0ee60f2bc30987046fcea816b40630335a" +dependencies = [ + "biscuit-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cedar-policy" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674ca6ef6e44a1e29e6c07f6b2ab7e6913938d6049204d48e7849703d02443ce" +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df781c108240c3b3778c586c6a1b234355f1a2f9d35e08630b63b2590c59dbb" +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e293607563253e249d19cf4d36ac05821955170a63cf6d65deb4db60138b192e" +dependencies = [ + "cedar-policy-core", + "itertools 0.14.0", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + +[[package]] +name = "cedarling" +version = "2.1.0" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "ahash", + "async-trait", + "base64 0.22.1", + "cedar-policy", + "cedar-policy-core", + "chrono", + "config", + "derive_more", + "flate2", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.2", + "gloo-timers", + "hdrhistogram", + "http_utils", + "jsonwebtoken 10.4.0", + "rand 0.10.1", + "reqwest 0.13.3", + "semver", + "serde", + "serde_json", + "serde_yaml_ng", + "smol_str", + "sparkv", + "strum", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", + "typed-builder", + "url", + "uuid7", + "vfs", + "wasm-bindgen-futures", + "web-sys", + "zip", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "config" +version = "0.15.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml", + "winnow", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "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-core" +version = "0.1.0" +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-demo-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-ffi", + "serde_json", + "tracing", +] + +[[package]] +name = "cpex-ffi" +version = "0.1.0" +dependencies = [ + "apl-audit-logger", + "apl-cedarling", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pii-scanner", + "async-trait", + "cpex-core", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-orchestration" +version = "0.1.0" +dependencies = [ + "futures", + "tokio", +] + +[[package]] +name = "cpex-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.117", +] + +[[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.117", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dunce" +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 = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[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.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fstr" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b8f793a77bb6d48059953a3e9820fd860d19a9bed8164ed3572eb1981ec8aa" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +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]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "base64 0.21.7", + "byteorder", + "crossbeam-channel", + "flate2", + "nom", + "num-traits", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http_utils" +version = "0.1.0" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "reqwest 0.13.3", + "serde", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "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", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[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", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[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 = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rust2" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9ceaec84b54518262de7cf06b8b43e83c808349960f1610b21b0bfc9640f20" +dependencies = [ + "sha2 0.11.0", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "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 = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "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-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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", + "typed-arena", + "unicode-width 0.2.2", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71adf41db68aa0daaefc69bb30bcd68ded9b9abaad5d1fbb6304c4fb390e083e" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b670f45da57fb8542ebdbb6105a925fe571b67f9e7ed9f47a06a84e72b4e7cc" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0a014229361011dc8e69c8a1ec6c2e8d0f2af7c91e3ea3f5b2170298461e68" +dependencies = [ + "bytes", + "prost", +] + +[[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 = "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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "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 = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +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.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "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", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "serdect", + "subtle", + "zeroize", +] + +[[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", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "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.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sparkv" +version = "0.1.1" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "chrono", + "thiserror 2.0.18", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "der 0.6.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "stable_deref_trait" +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.61.2", +] + +[[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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +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" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] -name = "js-sys" -version = "0.3.95" +name = "unicode-security" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" dependencies = [ - "once_cell", - "wasm-bindgen", + "unicode-normalization", + "unicode-script", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] -name = "libc" -version = "0.2.184" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "lock_api" -version = "0.4.14" +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "log" -version = "0.4.29" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "memchr" -version = "2.8.0" +name = "unsafe-libyaml" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "mio" -version = "1.2.0" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "num-traits" -version = "0.2.19" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "autocfg", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "uuid" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "lock_api", - "parking_lot_core", + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "uuid7" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "f14c93e6dd46ded457afc647964ac685427f9f001815d07ba30398cb79d9c9ce" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "fstr", + "rand_core 0.10.1", + "rand_core 0.6.4", + "serde", + "uuid", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "prettyplease" -version = "0.2.37" +name = "vfs" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "9e723b9e1c02a3cf9f9d0de6a4ddb8cdc1df859078902fe0ae0589d615711ae6" dependencies = [ - "proc-macro2", - "syn", + "filetime", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "unicode-ident", + "same-file", + "winapi-util", ] [[package]] -name = "quote" -version = "1.0.45" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "proc-macro2", + "try-lock", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "bitflags", + "wit-bindgen", ] [[package]] -name = "rmp" -version = "0.8.15" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "num-traits", + "wit-bindgen", ] [[package]] -name = "rmp-serde" -version = "1.3.1" +name = "wasm-bindgen" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ - "rmp", - "serde", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "wasm-bindgen-futures" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "wasm-bindgen-macro" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "wasm-bindgen-macro-support" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] [[package]] -name = "semver" -version = "1.0.28" +name = "wasm-bindgen-shared" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] [[package]] -name = "serde" -version = "1.0.228" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "serde_core", - "serde_derive", + "leb128fmt", + "wasmparser", ] [[package]] -name = "serde_bytes" -version = "0.11.19" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "serde", - "serde_core", + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "serde_derive", + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "web-sys" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "webpki-root-certs" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", + "rustls-pki-types", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "webpki-roots" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ - "errno", - "libc", + "rustls-pki-types", ] [[package]] -name = "slab" -version = "0.4.12" +name = "wildmatch" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] -name = "smallvec" -version = "1.15.1" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "socket2" -version = "0.6.3" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "libc", - "windows-sys", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "syn" -version = "2.0.117" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-link", ] [[package]] -name = "tokio" -version = "1.51.1" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "windows-link", ] [[package]] -name = "tokio-macros" -version = "2.7.0" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-targets 0.52.6", ] [[package]] -name = "tokio-util" -version = "0.7.18" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", + "windows-targets 0.53.5", ] [[package]] -name = "tracing" -version = "0.1.44" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "windows-link", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "once_cell", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "uuid" -version = "1.23.0" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "windows_i686_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "wasm-bindgen" -version = "0.2.118" +name = "windows_i686_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] -name = "wasm-bindgen-macro" -version = "0.2.118" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.118" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] -name = "wasm-bindgen-shared" -version = "0.2.118" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" -dependencies = [ - "unicode-ident", -] +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "windows_x86_64_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "wasmparser" -version = "0.244.0" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] -name = "wildmatch" -version = "2.6.1" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ - "windows-link", + "memchr", ] [[package]] @@ -856,9 +4708,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -874,7 +4726,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -887,7 +4739,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -906,7 +4758,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -916,8 +4768,208 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yaml-rust2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "8.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72" +dependencies = [ + "bzip2", + "crc32fast", + "deflate64", + "flate2", + "indexmap 2.14.0", + "lzma-rust2", + "memchr", + "ppmd-rust", + "time", + "typed-path", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 62f40dac..da736d6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,46 @@ resolver = "2" members = [ "crates/cpex-core", + "crates/cpex-orchestration", "crates/cpex-sdk", "crates/cpex-ffi", + "crates/apl-core", + "crates/apl-cmf", + "crates/apl-cpex", + "crates/apl-pdp-cedar-direct", + "crates/apl-cedarling", + "crates/apl-identity-jwt", + "crates/apl-delegator-oauth", + "crates/apl-delegator-biscuit", + "crates/apl-pii-scanner", + "crates/apl-audit-logger", + "examples/go-demo/ffi", +] + +# `default-members` controls what `cargo build` / `cargo test` (with no +# `-p` or `--workspace` flag) picks up. Cedarling integration crates +# pull ~200 transitive deps (jsonwebtoken, reqwest, sparkv, datalogic-rs, +# flate2, etc.) and slow default builds significantly — excluding them +# from default-members keeps everyday iteration fast. +# +# To exercise Cedarling crates: +# cargo build --workspace # all members +# cargo build -p apl-cedarling # just this one +# cargo test --workspace # full sweep (CI) +default-members = [ + "crates/cpex-core", + "crates/cpex-orchestration", + "crates/cpex-sdk", + "crates/cpex-ffi", + "crates/apl-core", + "crates/apl-cmf", + "crates/apl-cpex", + "crates/apl-pdp-cedar-direct", + "crates/apl-identity-jwt", + "crates/apl-delegator-oauth", + "crates/apl-delegator-biscuit", + "crates/apl-pii-scanner", + "crates/apl-audit-logger", "examples/go-demo/ffi", ] @@ -37,3 +75,5 @@ arc-swap = "1.7" wildmatch = "2" rmp-serde = "1" serde_bytes = "0.11" +chrono = { version = "0.4", features = ["serde"] } +regex = "1" diff --git a/crates/apl-audit-logger/Cargo.toml b/crates/apl-audit-logger/Cargo.toml new file mode 100644 index 00000000..ad729e06 --- /dev/null +++ b/crates/apl-audit-logger/Cargo.toml @@ -0,0 +1,29 @@ +# Location: ./crates/apl-audit-logger/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-audit-logger — CMF plugin that emits a structured audit +# record for every dispatched request. Subject, client, action, +# delegation outcome, and capability-filtered context fields land +# in a single JSON line per call. Always allows; never blocks. + +[package] +name = "apl-audit-logger" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-audit-logger/src/config.rs b/crates/apl-audit-logger/src/config.rs new file mode 100644 index 00000000..168750b1 --- /dev/null +++ b/crates/apl-audit-logger/src/config.rs @@ -0,0 +1,33 @@ +// Location: ./crates/apl-audit-logger/src/config.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuditLoggerConfig { + /// Where audit records go. Stderr is the default — convenient + /// for the demo (`docker compose logs -f`) and for k8s sidecar + /// log forwarding. Tracing routes through whatever subscriber + /// the host installed. + #[serde(default)] + pub destination: AuditDestination, + + /// Optional sink name — surfaces in every record so a single + /// audit collector can distinguish multiple deployments. Free- + /// form string. + #[serde(default)] + pub source: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditDestination { + /// Write one JSON line per call to stderr. + #[default] + Stderr, + /// Emit via `tracing::info!` at target `apl.audit`. Routed by + /// the host's subscriber to wherever traces normally go. + Tracing, +} diff --git a/crates/apl-audit-logger/src/factory.rs b/crates/apl-audit-logger/src/factory.rs new file mode 100644 index 00000000..05eb90fd --- /dev/null +++ b/crates/apl-audit-logger/src/factory.rs @@ -0,0 +1,55 @@ +// Location: ./crates/apl-audit-logger/src/factory.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use cpex_core::{ + cmf::CmfHook, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::logger::AuditLogger; + +/// `kind:` string operators write in CPEX YAML to declare an audit +/// logger instance. +pub const KIND: &str = "audit/logger"; + +pub struct AuditLoggerFactory; + +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let logger = Arc::new(AuditLogger::new(config.clone())?); + + if config.hooks.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-audit-logger): `hooks:` must list at \ + least one CMF hook to audit (e.g. cmf.tool_pre_invoke)", + config.name + ), + })); + } + + let handlers: Vec<_> = config + .hooks + .iter() + .map(|h| -> (&'static str, _) { + let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&logger)), + ); + (leaked, adapter) + }) + .collect(); + + Ok(PluginInstance { + plugin: logger, + handlers, + }) + } +} diff --git a/crates/apl-audit-logger/src/lib.rs b/crates/apl-audit-logger/src/lib.rs new file mode 100644 index 00000000..5671c372 --- /dev/null +++ b/crates/apl-audit-logger/src/lib.rs @@ -0,0 +1,40 @@ +// Location: ./crates/apl-audit-logger/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-audit-logger — CMF plugin that emits one structured JSON +// audit record per dispatched request. The record captures: +// +// * timestamp + correlation id +// * subject (id, roles, teams) and client (client_id, name) +// * entity (type + name) and tool args summary +// * delegation outcomes (which audiences got tokens, which +// scopes were granted) +// +// Mode: always allow — the plugin is observation-only. Operators +// who want to halt on audit failure would compose this with a +// downstream policy step. +// +// Output: +// +// * `destination: stderr` (default) — one JSON line per call, +// handy for the demo's `docker compose logs -f` flow. +// * `destination: tracing` — emit as a structured `tracing::info!` +// so it lands in whatever the host's subscriber routes to. +// +// Capabilities the plugin declares (operator wires them in YAML +// under `capabilities:`): +// +// * `read_subject` — for sub / roles / teams / claims +// * `read_client` — for client_id / client_name +// * `read_meta` — for entity_type / entity_name +// * `read_delegated_tokens` — to surface what got minted + +pub mod config; +pub mod factory; +pub mod logger; + +pub use config::{AuditDestination, AuditLoggerConfig}; +pub use factory::{AuditLoggerFactory, KIND}; +pub use logger::AuditLogger; diff --git a/crates/apl-audit-logger/src/logger.rs b/crates/apl-audit-logger/src/logger.rs new file mode 100644 index 00000000..7f6404d7 --- /dev/null +++ b/crates/apl-audit-logger/src/logger.rs @@ -0,0 +1,254 @@ +// Location: ./crates/apl-audit-logger/src/logger.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Map, Value}; + +use cpex_core::cmf::{CmfHook, ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::config::{AuditDestination, AuditLoggerConfig}; + +/// Observation-only CMF plugin. Builds a structured audit record +/// from the request's MessagePayload + Extensions, emits to the +/// configured destination, returns `Allow`. Never blocks. +#[derive(Debug)] +pub struct AuditLogger { + cfg: PluginConfig, + typed: AuditLoggerConfig, +} + +impl AuditLogger { + pub fn new(cfg: PluginConfig) -> Result> { + let typed: AuditLoggerConfig = match cfg.config.as_ref() { + Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-audit-logger) config parse failed: {e}", + cfg.name + ), + }) + })?, + None => AuditLoggerConfig::default(), + }; + Ok(Self { cfg, typed }) + } + + fn build_record(&self, payload: &MessagePayload, ext: &Extensions) -> Value { + let mut record = Map::new(); + record.insert( + "ts".into(), + json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), + ); + record.insert("plugin".into(), json!(self.cfg.name)); + if let Some(src) = &self.typed.source { + record.insert("source".into(), json!(src)); + } + + // Subject — capability-filtered. Empty Subject means the + // plugin lacks `read_subject` cap (won't happen if the + // operator configured it correctly). + if let Some(sec) = ext.security.as_ref() { + if let Some(s) = &sec.subject { + record.insert( + "subject".into(), + json!({ + "id": s.id, + "roles": s.roles.iter().collect::>(), + "teams": s.teams.iter().collect::>(), + }), + ); + } + if let Some(c) = &sec.client { + record.insert( + "client".into(), + json!({ + "client_id": c.client_id, + "client_name": c.client_name, + }), + ); + } + } + + // Entity — the route's tool/prompt/resource coords. + if let Some(meta) = ext.meta.as_ref() { + record.insert( + "entity".into(), + json!({ + "type": meta.entity_type, + "name": meta.entity_name, + }), + ); + } + + // Tool / prompt args summary — the first structured + // content part's args, if any. Mirrors what the gateway + // would actually forward (so audit reflects post-redact + // state if a PII scanner ran ahead of us). + for part in &payload.message.content { + match part { + ContentPart::ToolCall { content } => { + record.insert( + "tool_call".into(), + json!({ + "name": content.name, + "tool_call_id": content.tool_call_id, + "args": content.arguments, + }), + ); + break; + } + ContentPart::PromptRequest { content } => { + record.insert( + "prompt_request".into(), + json!({ + "name": content.name, + "args": content.arguments, + }), + ); + break; + } + _ => {} + } + } + + // Delegation outcomes — which audiences got tokens, with + // what (effective, possibly narrowed) scopes. The whole + // point of including this: it makes the audit trail show + // "we exchanged for workday-api with scope=read_compensation", + // which is the proof that delegation enforcement happened. + if let Some(raw) = ext.raw_credentials.as_ref() { + if !raw.delegated_tokens.is_empty() { + let tokens: Vec = raw + .delegated_tokens + .iter() + .map(|(_key, tok)| { + json!({ + "audience": tok.audience, + "scopes": tok.scopes, + "outbound_header": tok.outbound_header, + "expires_at": tok.expires_at.to_rfc3339_opts( + chrono::SecondsFormat::Secs, true, + ), + }) + }) + .collect(); + record.insert("delegated_tokens".into(), json!(tokens)); + } + } + + Value::Object(record) + } + + fn emit(&self, record: &Value) { + match self.typed.destination { + AuditDestination::Stderr => { + // One JSON line — easy to grep / forward / jq through. + eprintln!("{}", record); + } + AuditDestination::Tracing => { + tracing::info!(target: "apl.audit", record = %record, "audit"); + } + } + } +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AuditLogger { + async fn handle( + &self, + payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let record = self.build_record(payload, ext); + self.emit(&record); + PluginResult::allow() + } +} + +// Silence import-unused warning if Arc isn't used elsewhere. +#[allow(dead_code)] +fn _force_link_arc(_: Arc<()>) {} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{Message, Role, ToolCall}; + use cpex_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension}; + use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + use std::collections::HashMap; + use std::sync::Arc; + + fn cfg() -> PluginConfig { + PluginConfig { + name: "audit".into(), + kind: "test".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 50, + on_error: OnError::Fail, + config: Some(serde_json::json!({ "destination": "stderr" })), + ..Default::default() + } + } + + #[tokio::test] + async fn build_record_includes_subject_entity_toolcall() { + let plugin = AuditLogger::new(cfg()).unwrap(); + let payload = MessagePayload { + message: Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "1".into(), + name: "get_compensation".into(), + arguments: HashMap::from([( + "employee_id".to_string(), + serde_json::json!("EMP-001234"), + )]), + namespace: None, + }, + }], + ), + }; + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".into()); + meta.entity_name = Some("get_compensation".into()); + let ext = Extensions { + security: Some(Arc::new(sec)), + meta: Some(Arc::new(meta)), + ..Default::default() + }; + + let record = plugin.build_record(&payload, &ext); + assert_eq!(record["subject"]["id"], "alice@corp.com"); + assert_eq!(record["entity"]["name"], "get_compensation"); + assert_eq!(record["tool_call"]["name"], "get_compensation"); + assert_eq!(record["tool_call"]["args"]["employee_id"], "EMP-001234"); + // Always-allow contract: handler returns continue_processing. + let mut ctx = PluginContext::default(); + let r = plugin.handle(&payload, &ext, &mut ctx).await; + assert!(r.continue_processing); + assert!(r.violation.is_none()); + } +} diff --git a/crates/apl-cedarling/Cargo.toml b/crates/apl-cedarling/Cargo.toml new file mode 100644 index 00000000..b0c0dc8a --- /dev/null +++ b/crates/apl-cedarling/Cargo.toml @@ -0,0 +1,64 @@ +# Location: ./crates/apl-cedarling/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-cedarling — Cedarling-backed IdentityResolveHandler and +# PdpResolver implementations. +# +# Two modules in one crate because they share the same heavy dep +# (cedarling) and almost always run in the same deployment — an +# operator using Cedarling for identity resolution invariably also +# wants it for policy decisions, and the two consume the same +# `Cedarling` instance + policy store. +# +# # Why this crate isn't in default-members +# +# Cedarling pulls ~200 transitive dependencies (jsonwebtoken, reqwest, +# sparkv, datalogic-rs, flate2, regex, ahash, time, vfs, zip, …). To +# keep `cargo build` at the workspace root fast for the majority of +# iteration, the workspace excludes this crate from `default-members`. +# Build it explicitly with `cargo build -p apl-cedarling` or with +# `cargo build --workspace` for the full sweep. + +[package] +name = "apl-cedarling" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# Cedarling lives in the Janssen Project monorepo at the path +# `jans-cedarling/cedarling/` within that repo. We pin to a release +# tag rather than a branch so the dep tree stays reproducible across +# checkouts — bump the tag deliberately when we want a new version. +# +# `package = "cedarling"` tells Cargo which named crate to pick from +# the monorepo's multiple workspaces. `default-features = false` +# disables `grpc` (tonic+prost for Lock Server); Lock Server +# integration lands behind its own feature flag if/when we wire it. +# +# First build for new collaborators clones the Janssen monorepo (~200 +# transitive deps + cedarling's vendored workspace). Cached in +# ~/.cargo/git/ afterward. +cedarling = { git = "https://github.com/JanssenProject/jans", tag = "v2.1.0", package = "cedarling", default-features = false } + +# cedar-policy is a direct dep so we can name `cedar_policy::Decision` +# etc. in the resolver. Caret spec lets Cargo dedup to the same +# version Cedarling pulls (currently 4.11.0 transitively). +cedar-policy = "4" + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-cedarling/src/error.rs b/crates/apl-cedarling/src/error.rs new file mode 100644 index 00000000..d81fab69 --- /dev/null +++ b/crates/apl-cedarling/src/error.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-cedarling/src/error.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build-time errors for constructing Cedarling-backed resolvers and +// handlers. Runtime errors flow through `PluginViolation` (for +// hook handlers) or `PdpError::Dispatch` (for the PDP path) — same +// pattern as `apl-pdp-cedar-direct`. + +use thiserror::Error; + +/// Errors that can occur while constructing a Cedarling-backed +/// resolver or handler from config. +#[non_exhaustive] +#[derive(Debug, Error)] +pub enum CedarlingPluginError { + /// The policy store file/URL couldn't be loaded. + #[error("failed to load policy store: {0}")] + PolicyStoreLoad(String), + + /// The bootstrap config was malformed or missing required fields. + #[error("invalid Cedarling bootstrap config: {0}")] + BootstrapConfig(String), + + /// Cedarling itself failed to initialize (JWKS unreachable, + /// schema validation failed, etc.). + #[error("Cedarling initialization failed: {0}")] + Init(String), +} diff --git a/crates/apl-cedarling/src/identity/mod.rs b/crates/apl-cedarling/src/identity/mod.rs new file mode 100644 index 00000000..1d7fdd8c --- /dev/null +++ b/crates/apl-cedarling/src/identity/mod.rs @@ -0,0 +1,31 @@ +// Location: ./crates/apl-cedarling/src/identity/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Cedarling-backed IdentityResolveHandler. +// +// Sub-step A scope: stub module. Actual implementation lands in +// sub-step B. +// +// # Planned shape +// +// ```ignore +// pub struct CedarlingIdentityResolver { +// cedarling: Arc, +// // optional: which sentinel action to use for identity-only +// // validation when no real policy decision is being made +// identity_action: String, +// } +// +// impl HookHandler for CedarlingIdentityResolver { +// async fn handle(&self, payload: &IdentityPayload, ...) -> ... { +// // Build TokenInputs from payload.raw_token() + headers +// // Call cedarling.authorize_multi_issuer with sentinel action +// // If decision is deny -> PluginResult::deny(violation) +// // If allow -> extract validated entities, map to +// // SubjectExtension / ClientExtension / WorkloadIdentity +// // and return modified payload +// } +// } +// ``` diff --git a/crates/apl-cedarling/src/lib.rs b/crates/apl-cedarling/src/lib.rs new file mode 100644 index 00000000..fb2ae21b --- /dev/null +++ b/crates/apl-cedarling/src/lib.rs @@ -0,0 +1,48 @@ +// Location: ./crates/apl-cedarling/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-cedarling — Cedarling-backed plugins for APL's two adjacent +// auth seams: +// +// * [`identity`] — `IdentityResolveHandler` that validates inbound +// JWTs through Cedarling and maps validated tokens into +// `SubjectExtension` / `ClientExtension`. Optionally runs an +// advisory Cedar policy check ("is this principal allowed at all") +// during the validation pass. +// * [`pdp`] — `PdpResolver` for `cedar:(...)` steps in APL routes. +// Mirrors the cedar-direct resolver but uses Cedarling's policy +// store loading + (eventually) Lock Server hooks instead of +// in-process `cedar-policy::PolicySet`. +// +// Both modules share a single `Cedarling` instance constructed from +// the same bootstrap config — operators using one almost always want +// the other, and double-loading the policy store / JWKS would be +// wasteful. +// +// # When to reach for this crate vs alternatives +// +// - **`apl-pdp-cedar-direct`** — simpler, ~5 transitive deps, +// policies as inline text. Use for tests, dev, or deployments +// that don't need policy-store signing / centralized management. +// - **`apl-identity-jwt`** (future) — JWT validation via the +// `jsonwebtoken` crate, no Cedar coupling, ~5 transitive deps. +// Use when you want lightweight identity without policy-driven +// identity decisions. +// - **`apl-cedarling`** (this crate) — heavy dep tree but gives you +// signed policy stores, Cedar-driven identity decisions, and +// (future) Lock Server fleet management. Use for production +// deployments with centralized policy management. +// +// # Sub-step A scope +// +// Module skeletons + crate wiring only. No actual Cedarling calls. +// Existence of this crate validates the dep-resolution cost honestly +// before we commit to the implementation. + +pub mod error; +pub mod identity; +pub mod pdp; + +pub use error::CedarlingPluginError; diff --git a/crates/apl-cedarling/src/pdp/mod.rs b/crates/apl-cedarling/src/pdp/mod.rs new file mode 100644 index 00000000..dad4ac3d --- /dev/null +++ b/crates/apl-cedarling/src/pdp/mod.rs @@ -0,0 +1,10 @@ +// Location: ./crates/apl-cedarling/src/pdp/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Cedarling-backed PdpResolver. + +pub mod resolver; + +pub use resolver::CedarlingPdpResolver; diff --git a/crates/apl-cedarling/src/pdp/resolver.rs b/crates/apl-cedarling/src/pdp/resolver.rs new file mode 100644 index 00000000..076f2854 --- /dev/null +++ b/crates/apl-cedarling/src/pdp/resolver.rs @@ -0,0 +1,374 @@ +// Location: ./crates/apl-cedarling/src/pdp/resolver.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CedarlingPdpResolver` — `PdpResolver` impl that delegates Cedar +// policy evaluation to a Cedarling instance. +// +// # Why Cedarling here instead of `apl-pdp-cedar-direct` +// +// Both call the same Cedar evaluator under the hood. The difference +// is the policy-store loading + management layer Cedarling provides: +// signed policy bundles, multi-policy stores keyed by ID, optional +// Lock Server integration for fleet-wide updates. Deployments that +// don't need any of that should reach for `apl-pdp-cedar-direct` +// instead — it's ~5 deps vs ~200. +// +// # Construction +// +// This resolver does NOT construct its own Cedarling instance. +// Cedarling holds shared state (JWT keys, entity store cache, +// optional Lock Server connection) that an entire deployment +// typically wants to share between identity resolution and PDP +// evaluation. The host builds one `Arc` at startup and +// hands the same handle to both this resolver and the +// (forthcoming) `CedarlingIdentityResolver`. +// +// # `authorize_unsigned` +// +// We use Cedarling's `authorize_unsigned` rather than +// `authorize_multi_issuer`. Reasoning: +// * APL has already done identity resolution by the time `cedar:` +// policy steps run — `Extensions.security.subject` / +// `.client` / `.caller_workload` are populated. +// * We build the principal entity from the `AttributeBag` directly, +// bypassing Cedarling's JWT-validation path entirely. +// * No sentinel-action workaround needed (the one we discussed for +// using `authorize_multi_issuer` purely for identity). + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use cedarling::{CedarEntityMapping, Cedarling, EntityData, RequestUnsigned}; +use serde_json::{json, Map, Value}; + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +/// `PdpResolver` that dispatches policy decisions to a Cedarling +/// instance. See module docs for when to prefer this over +/// `apl-pdp-cedar-direct`. +pub struct CedarlingPdpResolver { + /// Shared Cedarling instance — built once at host startup, + /// passed to both this resolver and the identity handler. + cedarling: Arc, + + /// The dialect this resolver registers under in the `PdpRouter`. + /// Defaults to `PdpDialect::Cedarling` (a distinct variant from + /// `Cedar`) so both `apl-pdp-cedar-direct` and this crate can + /// coexist in the same router and routes target each explicitly + /// via `cedar:(...)` vs `cedarling:(...)` step keys. + dialect: PdpDialect, + + /// Optional namespace prefix prepended to entity types built + /// from the bag (`"User"` → `"Jans::User"`). Matches the + /// `apl-pdp-cedar-direct` ergonomics; deployments with + /// namespaced schemas set this once at startup. + entity_namespace: Option, +} + +impl CedarlingPdpResolver { + /// Build a resolver around a pre-constructed Cedarling instance. + /// Cedarling construction is async and config-heavy + /// (`BootstrapConfig`, policy store loading); doing it inside + /// the resolver would force every call site into an async + /// context. The host owns the lifecycle. + pub fn new(cedarling: Arc) -> Self { + Self { + cedarling, + dialect: PdpDialect::Cedarling, + entity_namespace: None, + } + } + + pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { + self.dialect = dialect; + self + } + + pub fn with_entity_namespace(mut self, namespace: impl Into) -> Self { + self.entity_namespace = Some(namespace.into()); + self + } +} + +#[async_trait] +impl PdpResolver for CedarlingPdpResolver { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + let map = call.args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar:() args must be a mapping with action/resource keys" + .to_string(), + ) + })?; + + let action = yaml_string(map, "action").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.action missing or not a string".into()) + })?; + + let resource_value = map + .get(serde_yaml::Value::String("resource".to_string())) + .ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource missing".into()) + })?; + let resource = build_resource_entity_data(resource_value)?; + + let principal = + build_principal_entity_data(bag, self.entity_namespace.as_deref())?; + + let context = map + .get(serde_yaml::Value::String("context".to_string())) + .map(|v| serde_json::to_value(v)) + .transpose() + .map_err(|e| { + PdpError::Dispatch(format!( + "cedarling: cedar:() args.context not JSON-representable: {e}" + )) + })? + .unwrap_or(Value::Object(Map::new())); + + let request = RequestUnsigned { + principal: Some(principal), + action, + resource, + context, + }; + + let result = self.cedarling.authorize_unsigned(request).await.map_err(|e| { + PdpError::Dispatch(format!("cedarling: authorize_unsigned failed: {e}")) + })?; + + Ok(translate_authorize_result(&result)) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Build the Cedarling principal entity from the attribute bag. Same +/// claim shape as `apl-pdp-cedar-direct`: +/// +/// * `subject.id` → entity id (required) +/// * `subject.type` → entity type ("User" default) +/// * `role.=true` → attrs.roles : Set +/// * `perm.=true` → attrs.permissions : Set +/// * `claim.=v` → attrs.claims. = v +/// * `subject.teams` → attrs.teams : Set +/// +/// Returns `EntityData` (Cedarling's JSON-shaped entity carrier), +/// which Cedarling converts internally to a `cedar_policy::Entity`. +fn build_principal_entity_data( + bag: &AttributeBag, + namespace: Option<&str>, +) -> Result { + let id = bag + .get_string("subject.id") + .ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar request needs a principal but bag has no `subject.id` — \ + install an identity-hook plugin upstream of APL policy" + .to_string(), + ) + })? + .to_string(); + + let kind = bag.get_string("subject.type").unwrap_or("User"); + let entity_type = qualify_type(kind, namespace); + + let mut attributes: HashMap = HashMap::new(); + attributes.insert("id".to_string(), json!(id)); + attributes.insert("type".to_string(), json!(kind)); + + let roles = collect_prefixed_bools(bag, "role."); + attributes.insert("roles".to_string(), json!(roles)); + + let permissions = collect_prefixed_bools(bag, "perm."); + attributes.insert("permissions".to_string(), json!(permissions)); + + let teams: Vec = bag + .get_string_set("subject.teams") + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + attributes.insert("teams".to_string(), json!(teams)); + + let claims = collect_claims(bag); + attributes.insert("claims".to_string(), Value::Object(claims)); + + Ok(EntityData { + cedar_mapping: CedarEntityMapping { + entity_type, + id, + }, + attributes, + }) +} + +/// Build the resource entity from the policy author's `args.resource` +/// block: +/// +/// ```yaml +/// resource: +/// type: Document # required +/// id: doc-42 # required +/// attributes: # optional +/// classification: internal +/// ``` +fn build_resource_entity_data( + resource_args: &serde_yaml::Value, +) -> Result { + let map = resource_args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar:() args.resource must be a mapping".to_string(), + ) + })?; + let entity_type = yaml_string(map, "type").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource.type missing".to_string()) + })?; + let id = yaml_string(map, "id").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource.id missing".to_string()) + })?; + + let mut attributes: HashMap = HashMap::new(); + if let Some(attrs_value) = map.get(serde_yaml::Value::String("attributes".to_string())) + { + let attrs_json: Value = serde_json::to_value(attrs_value).map_err(|e| { + PdpError::Dispatch(format!( + "cedarling: cedar:() args.resource.attributes not JSON-representable: {e}" + )) + })?; + if let Value::Object(map) = attrs_json { + for (k, v) in map { + attributes.insert(k, v); + } + } + } + + Ok(EntityData { + cedar_mapping: CedarEntityMapping { + entity_type, + id, + }, + attributes, + }) +} + +/// Translate Cedarling's `AuthorizeResult` into APL's `PdpDecision`. +/// Mirrors `apl-pdp-cedar-direct`'s decision-translation logic since +/// both crates ultimately read the same `cedar_policy::Response`. +/// Fail-closed on diagnostic errors. +fn translate_authorize_result(result: &cedarling::AuthorizeResult) -> PdpDecision { + use cedar_policy::Decision as CedarDecision; + let response = &result.response; + let diagnostics = response.diagnostics(); + + let firing_policies: Vec = diagnostics + .reason() + .map(|pid| pid.to_string()) + .collect(); + + let errors: Vec = diagnostics.errors().map(|e| e.to_string()).collect(); + + // Cedar evaluation errors → fail-closed deny. Same rule as + // `apl-pdp-cedar-direct`: any runtime error during evaluation + // produces an untrustworthy decision, so we override to deny. + if !errors.is_empty() { + let reason = format!( + "Cedar evaluation produced errors (fail-closed): {}", + errors.join("; ") + ); + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.evaluation_error".to_string()); + return PdpDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source, + }, + diagnostics: firing_policies, + }; + } + + let decision = match response.decision() { + CedarDecision::Allow => Decision::Allow, + CedarDecision::Deny => { + let reason = if firing_policies.is_empty() { + "no Cedar permit policy matched the request".to_string() + } else { + format!("denied by Cedar policy: {}", firing_policies.join(", ")) + }; + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.default_deny".to_string()); + Decision::Deny { + reason: Some(reason), + rule_source, + } + } + }; + + PdpDecision { + decision, + diagnostics: firing_policies, + } +} + +// ----- Small helpers, mirror cedar-direct ----- + +fn qualify_type(bare: &str, namespace: Option<&str>) -> String { + match namespace { + Some(ns) if !ns.is_empty() => format!("{ns}::{bare}"), + _ => bare.to_string(), + } +} + +fn collect_prefixed_bools(bag: &AttributeBag, prefix: &str) -> Vec { + use std::collections::HashSet; + let mut out: HashSet = HashSet::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix(prefix) { + if matches!(value, AttributeValue::Bool(true)) { + out.insert(name.to_string()); + } + } + } + let mut v: Vec = out.into_iter().collect(); + v.sort(); + v +} + +fn collect_claims(bag: &AttributeBag) -> Map { + let mut out = Map::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix("claim.") { + let v = match value { + AttributeValue::Bool(b) => json!(*b), + AttributeValue::Int(i) => json!(*i), + AttributeValue::Float(f) => json!(*f), + AttributeValue::String(s) => json!(s), + AttributeValue::StringSet(set) => json!(set.iter().collect::>()), + }; + out.insert(name.to_string(), v); + } + } + out +} + +fn yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} diff --git a/crates/apl-cedarling/tests/pdp_basic.rs b/crates/apl-cedarling/tests/pdp_basic.rs new file mode 100644 index 00000000..0fee5f07 --- /dev/null +++ b/crates/apl-cedarling/tests/pdp_basic.rs @@ -0,0 +1,166 @@ +// Location: ./crates/apl-cedarling/tests/pdp_basic.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Basic e2e for `CedarlingPdpResolver`: build a Cedarling instance +// against an inline policy store, dispatch a `cedar:` call through +// the resolver, assert the allow/deny path. +// +// This test exercises the full Cedarling stack — bootstrap config +// parsing, policy store loading, schema validation, Cedar evaluation, +// response translation. The `policy-store_no_trusted_issuers.yaml` +// pattern (no trusted JWT issuers configured) is what makes +// `authorize_unsigned` viable for us — Cedarling skips its JWT +// validation path entirely when there are no trusted issuers, so we +// can drive policy decisions purely from the bag-built entities. + +use std::sync::Arc; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; + +use apl_cedarling::pdp::CedarlingPdpResolver; +use cedarling::{BootstrapConfig, Cedarling, PolicyStoreSource}; + +/// Minimal policy store: one permit policy that fires for +/// `Action::"read"` against any `Document` when the principal +/// carries `roles` containing "reader". The schema declares a +/// `Jans` namespace so policy IDs / entities resolve cleanly. +const POLICY_STORE_YAML: &str = r#" +cedar_version: v4.0.0 +policy_stores: + test-store-001: + cedar_version: v4.0.0 + name: "test" + policies: + 1: + description: reader-only read permit + creation_date: "2026-05-21T00:00:00.000000" + policy_content: + encoding: none + content_type: cedar + body: |- + permit( + principal, + action == Jans::Action::"read", + resource + )when{ + principal.roles.contains("reader") + }; + schema: + encoding: none + content_type: cedar + body: |- + namespace Jans { + entity Document = { "classification": String }; + entity User = { "roles": Set }; + action "read" appliesTo { + principal: [User], + resource: [Document], + context: {} + }; + } +"#; + +/// Build a Cedarling instance configured with the test policy store +/// and no trusted JWT issuers — so `authorize_unsigned` is the right +/// path (no token validation involved). +async fn build_cedarling() -> Arc { + let mut config = BootstrapConfig::default(); + config.application_name = "apl-cedarling-test".to_string(); + config.policy_store_config.source = + PolicyStoreSource::Yaml(POLICY_STORE_YAML.to_string()); + let cedarling = Cedarling::new(&config) + .await + .expect("Cedarling::new should succeed with valid config"); + Arc::new(cedarling) +} + +fn alice_with_reader_role() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("subject.type", "User"); + bag.set("role.reader", true); + bag +} + +fn bob_no_roles() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "bob"); + bag.set("subject.type", "User"); + bag +} + +fn read_doc_call() -> PdpCall { + PdpCall { + // Route YAML `cedarling:(...)` produces this dialect. + // `apl-pdp-cedar-direct` registers under `PdpDialect::Cedar` + // so both resolvers can coexist in one PdpRouter. + dialect: PdpDialect::Cedarling, + args: serde_yaml::from_str( + r#" +action: 'Jans::Action::"read"' +resource: + type: Jans::Document + id: doc-42 + attributes: + classification: internal +"#, + ) + .unwrap(), + } +} + +#[tokio::test] +async fn reader_role_allows() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling) + .with_entity_namespace("Jans"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_with_reader_role()) + .await + .expect("evaluate should succeed"); + assert!( + matches!(decision.decision, Decision::Allow), + "alice with role.reader should be allowed: got {:?}", + decision.decision, + ); +} + +#[tokio::test] +async fn missing_role_default_denies() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling) + .with_entity_namespace("Jans"); + let decision = resolver + .evaluate(&read_doc_call(), &bob_no_roles()) + .await + .expect("evaluate should succeed"); + match decision.decision { + Decision::Deny { rule_source, .. } => { + // No permit fired → cedar.default_deny sentinel. + assert_eq!(rule_source, "cedar.default_deny"); + } + Decision::Allow => panic!("bob without reader role should be denied"), + } +} + +#[tokio::test] +async fn missing_subject_id_errors_clearly() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling); + // Bag with no subject.id at all — resolver should fail + // construction of the principal entity with a clear error. + let bag = AttributeBag::new(); + let err = resolver + .evaluate(&read_doc_call(), &bag) + .await + .expect_err("missing subject.id should error"); + let msg = format!("{err:?}"); + assert!( + msg.contains("subject.id"), + "error should call out the missing key, got: {msg}", + ); +} diff --git a/crates/apl-cmf/Cargo.toml b/crates/apl-cmf/Cargo.toml new file mode 100644 index 00000000..141b4ea2 --- /dev/null +++ b/crates/apl-cmf/Cargo.toml @@ -0,0 +1,24 @@ +# Location: ./crates/apl-cmf/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-cmf — bridge from cpex-core typed extensions into apl-core's +# AttributeBag. The "where the policy vocabulary comes from" crate. + +[package] +name = "apl-cmf" +description = "APL ↔ CPEX bridge — extension → AttributeBag mapping" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/apl-cmf/src/agent.rs b/crates/apl-cmf/src/agent.rs new file mode 100644 index 00000000..1af89e19 --- /dev/null +++ b/crates/apl-cmf/src/agent.rs @@ -0,0 +1,68 @@ +// Location: ./crates/apl-cmf/src/agent.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AgentExtension → AttributeBag. +// +// Namespace: +// agent.input : String +// agent.session_id : String +// agent.conversation_id : String +// agent.turn : Int +// agent.agent_id : String +// agent.parent_agent_id : String +// agent.conversation.summary : String +// agent.conversation.topics : StringSet + +use apl_core::AttributeBag; +use cpex_core::extensions::AgentExtension; +use std::collections::HashSet; + +pub fn extract_agent(agent: &AgentExtension, bag: &mut AttributeBag) { + if let Some(v) = &agent.input { bag.set("agent.input", v.clone()); } + if let Some(v) = &agent.session_id { bag.set("agent.session_id", v.clone()); } + if let Some(v) = &agent.conversation_id { bag.set("agent.conversation_id", v.clone()); } + if let Some(v) = agent.turn { bag.set("agent.turn", v as i64); } + if let Some(v) = &agent.agent_id { bag.set("agent.agent_id", v.clone()); } + if let Some(v) = &agent.parent_agent_id { bag.set("agent.parent_agent_id", v.clone()); } + if let Some(conv) = &agent.conversation { + if let Some(s) = &conv.summary { bag.set("agent.conversation.summary", s.clone()); } + if !conv.topics.is_empty() { + let topics: HashSet = conv.topics.iter().cloned().collect(); + bag.set("agent.conversation.topics", topics); + } + // `history: Vec` is deliberately not flattened — too unstructured. + // Policies wanting conversation history should call a plugin. + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::agent::ConversationContext; + + #[test] + fn populates_present_fields_only() { + let agent = AgentExtension { + session_id: Some("sess-1".into()), + conversation_id: Some("conv-9".into()), + turn: Some(3), + agent_id: Some("hr-agent".into()), + parent_agent_id: None, + conversation: Some(ConversationContext { + summary: Some("hr inquiry".into()), + topics: vec!["payroll".into(), "ssn".into()], + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_agent(&agent, &mut bag); + assert_eq!(bag.get_string("agent.session_id"), Some("sess-1")); + assert_eq!(bag.get_int("agent.turn"), Some(3)); + assert_eq!(bag.get_string("agent.conversation.summary"), Some("hr inquiry")); + assert!(bag.set_contains("agent.conversation.topics", "payroll")); + assert!(!bag.contains("agent.parent_agent_id")); + } +} diff --git a/crates/apl-cmf/src/capability_namespaces.rs b/crates/apl-cmf/src/capability_namespaces.rs new file mode 100644 index 00000000..f69543fb --- /dev/null +++ b/crates/apl-cmf/src/capability_namespaces.rs @@ -0,0 +1,312 @@ +// Location: ./crates/apl-cmf/src/capability_namespaces.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Capability → bag-namespace mapping for operator visibility. +// +// cpex-core's `filter_extensions(&ext, &caps)` decides which +// `Extensions` slots a plugin sees based on its declared +// `capabilities:` list. The CMF extractors then flatten those slots +// into bag attributes under well-known prefixes. This module is the +// bridge: given a capability name, return the bag-attribute prefixes +// it unlocks. Lets operators answer "what bag keys does this plugin +// see?" without reading source. +// +// # Scope +// +// Covers the `read_*` capabilities — those map to bag namespaces +// because the corresponding Extensions slots become bag attributes +// after extraction. Write capabilities (`append_labels`, +// `append_delegation`, `write_headers`) gate WRITE tokens, not +// readable state, so they don't appear here. +// +// # Source of truth +// +// All hard-coded strings — both capability names and bag-attribute +// prefixes — live in [`crate::constants`]. The table below +// references the constants rather than inlining strings, so a typo +// surfaces at compile time and the constants file is the single +// place to update names. + +use std::collections::HashSet; + +use crate::constants::*; + +/// Prefix mapping entry. `prefixes` lists the bag-attribute +/// namespace roots this capability unlocks. A prefix ending in `.` +/// means "any key starting with that root" (e.g. `role.` matches +/// `role.hr`). A prefix without a trailing `.` means an exact-match +/// key (e.g. `authenticated`). +struct CapabilityEntry { + name: &'static str, + prefixes: &'static [&'static str], +} + +/// The mapping table — single source of truth for which bag +/// namespaces a capability unlocks. Keep in sync with cpex-core's +/// `filter_extensions` rules and the per-extension extractor +/// modules (`security.rs`, `delegation.rs`, etc.). +const TABLE: &[CapabilityEntry] = &[ + // ----- Subject identity ----- + CapabilityEntry { + name: CAP_READ_SUBJECT, + // `read_subject` exposes id + type only; `authenticated` is + // derived from those being present. + prefixes: &[BAG_SUBJECT_ID, BAG_SUBJECT_TYPE, BAG_AUTHENTICATED], + }, + CapabilityEntry { + name: CAP_READ_ROLES, + // Implies the read_subject baseline + role.* prefix. + prefixes: &[ + BAG_ROLE_PREFIX, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + CapabilityEntry { + name: CAP_READ_PERMISSIONS, + prefixes: &[ + BAG_PERM_PREFIX, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + CapabilityEntry { + name: CAP_READ_TEAMS, + prefixes: &[ + BAG_SUBJECT_TEAMS, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + CapabilityEntry { + name: CAP_READ_CLAIMS, + prefixes: &[ + BAG_CLAIM_PREFIX, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + + // ----- Security extension (non-subject) ----- + CapabilityEntry { + // Labels are not extracted into discrete bag keys today — + // they live on `Extensions.security.labels` and plugins + // read them directly. APL's BagBuilder doesn't materialize + // a bag-readable label namespace yet; if it does, add the + // prefix constant + reference here. + name: CAP_READ_LABELS, + prefixes: &[], + }, + CapabilityEntry { + name: CAP_READ_CLIENT, + prefixes: &[BAG_CLIENT_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_WORKLOAD, + // Exposes both inbound caller workload AND this-host workload. + prefixes: &[BAG_WORKLOAD_PREFIX, BAG_CALLER_WORKLOAD_PREFIX], + }, + + // ----- Credential material — payload-only, no bag prefixes ----- + CapabilityEntry { + // Gates `Extensions.raw_credentials.inbound_tokens` — those + // tokens flow through plugin payloads (IdentityPayload, + // DelegationPayload), not into the bag. + name: CAP_READ_INBOUND_CREDENTIALS, + prefixes: &[], + }, + CapabilityEntry { + name: CAP_READ_DELEGATED_TOKENS, + prefixes: &[], + }, + + // ----- Delegation chain ----- + CapabilityEntry { + name: CAP_READ_DELEGATION, + prefixes: &[BAG_DELEGATION_PREFIX, BAG_DELEGATED], + }, + + // ----- Other extensions ----- + CapabilityEntry { + name: CAP_READ_AGENT, + prefixes: &[BAG_AGENT_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_META, + prefixes: &[BAG_META_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_REQUEST, + prefixes: &[BAG_REQUEST_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_HEADERS, + prefixes: &[ + BAG_HTTP_REQUEST_HEADERS_PREFIX, + BAG_HTTP_RESPONSE_HEADERS_PREFIX, + ], + }, + CapabilityEntry { + name: CAP_READ_LLM, + prefixes: &[BAG_LLM_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_MCP, + prefixes: &[BAG_MCP_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_COMPLETION, + prefixes: &[BAG_COMPLETION_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_PROVENANCE, + prefixes: &[BAG_PROVENANCE_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_FRAMEWORK, + prefixes: &[BAG_FRAMEWORK_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_CUSTOM, + prefixes: &[BAG_CUSTOM_PREFIX], + }, +]; + +/// Bag-attribute prefixes a single capability unlocks. Returns an +/// empty slice for capabilities that don't expose bag-readable +/// state (write capabilities, or read capabilities for slots that +/// aren't extracted into the bag). Unknown capability names also +/// return empty — operators may declare custom caps the framework +/// doesn't recognize, and we don't want to imply they unlock +/// nothing in some "official" sense. +/// +/// A prefix ending in `.` matches any bag key starting with it +/// (e.g. `"role."` matches `"role.hr"`, `"role.admin"`). +/// A prefix without a trailing `.` matches the exact bag key +/// (e.g. `"authenticated"` matches only that bag key). +pub fn capability_namespaces(cap: &str) -> &'static [&'static str] { + TABLE + .iter() + .find(|e| e.name == cap) + .map(|e| e.prefixes) + .unwrap_or(&[]) +} + +/// Union of all bag-attribute prefixes unlocked by a set of +/// capabilities. Useful for operators answering "what can this +/// plugin see in the bag, given its declared caps?" without walking +/// the table per cap themselves. +pub fn unlocked_bag_prefixes(caps: &[String]) -> HashSet<&'static str> { + caps.iter() + .flat_map(|c| capability_namespaces(c).iter().copied()) + .collect() +} + +/// Every capability the framework recognizes for bag-namespace +/// purposes (excludes write caps and unknown ones). Useful for +/// completion / docs / config validation. +pub fn known_read_capabilities() -> impl Iterator { + TABLE.iter().map(|e| e.name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_subject_exposes_id_type_authenticated() { + let prefixes = capability_namespaces(CAP_READ_SUBJECT); + assert!(prefixes.contains(&BAG_SUBJECT_ID)); + assert!(prefixes.contains(&BAG_SUBJECT_TYPE)); + assert!(prefixes.contains(&BAG_AUTHENTICATED)); + } + + #[test] + fn read_roles_implies_subject_baseline_plus_role_prefix() { + let prefixes = capability_namespaces(CAP_READ_ROLES); + assert!(prefixes.contains(&BAG_ROLE_PREFIX)); + // Implied subject baseline. + assert!(prefixes.contains(&BAG_SUBJECT_ID)); + assert!(prefixes.contains(&BAG_AUTHENTICATED)); + } + + #[test] + fn read_delegation_exposes_delegation_namespace_and_delegated_flag() { + let prefixes = capability_namespaces(CAP_READ_DELEGATION); + assert!(prefixes.contains(&BAG_DELEGATION_PREFIX)); + assert!(prefixes.contains(&BAG_DELEGATED)); + } + + #[test] + fn read_headers_exposes_both_request_and_response_header_namespaces() { + let prefixes = capability_namespaces(CAP_READ_HEADERS); + assert!(prefixes.contains(&BAG_HTTP_REQUEST_HEADERS_PREFIX)); + assert!(prefixes.contains(&BAG_HTTP_RESPONSE_HEADERS_PREFIX)); + } + + #[test] + fn unknown_capability_returns_empty() { + assert!(capability_namespaces("read_nonsense").is_empty()); + } + + #[test] + fn write_capability_returns_empty() { + // Write caps don't expose bag-readable state. + assert!(capability_namespaces(CAP_APPEND_LABELS).is_empty()); + assert!(capability_namespaces(CAP_APPEND_DELEGATION).is_empty()); + assert!(capability_namespaces(CAP_WRITE_HEADERS).is_empty()); + } + + #[test] + fn payload_only_credential_caps_return_empty() { + // These caps gate Extensions slots that flow through plugin + // payloads, not bag attributes. + assert!(capability_namespaces(CAP_READ_INBOUND_CREDENTIALS).is_empty()); + assert!(capability_namespaces(CAP_READ_DELEGATED_TOKENS).is_empty()); + // read_labels too — labels aren't materialized into bag keys. + assert!(capability_namespaces(CAP_READ_LABELS).is_empty()); + } + + #[test] + fn unlocked_bag_prefixes_unions_multiple_caps() { + let caps = vec![CAP_READ_SUBJECT.to_string(), CAP_READ_ROLES.to_string()]; + let union = unlocked_bag_prefixes(&caps); + assert!(union.contains(BAG_SUBJECT_ID)); + assert!(union.contains(BAG_ROLE_PREFIX)); + // Deduplicates the shared subject baseline — only ONE + // entry for the common BAG_SUBJECT_ID even though both + // caps include it. + let baseline_count = union.iter().filter(|p| **p == BAG_SUBJECT_ID).count(); + assert_eq!(baseline_count, 1); + } + + #[test] + fn unlocked_bag_prefixes_skips_unknown_caps() { + let caps = vec![CAP_READ_SUBJECT.to_string(), "read_made_up".to_string()]; + let union = unlocked_bag_prefixes(&caps); + assert!(union.contains(BAG_SUBJECT_ID)); + // Unknown cap contributes nothing — no panic, no surprise key. + // read_subject contributes 3 entries; that's the total. + assert_eq!(union.len(), 3); + } + + #[test] + fn known_read_capabilities_returns_every_table_entry() { + let count = known_read_capabilities().count(); + // Sanity: substantial but bounded — table bloat would be + // a maintenance signal. + assert!(count > 10, "expected >10 known caps, got {count}"); + assert!(count < 50, "table grew unexpectedly to {count} entries"); + // Spot-check canonical names are present. + let names: HashSet<&str> = known_read_capabilities().collect(); + assert!(names.contains(CAP_READ_SUBJECT)); + assert!(names.contains(CAP_READ_META)); + assert!(names.contains(CAP_READ_DELEGATION)); + } +} diff --git a/crates/apl-cmf/src/completion.rs b/crates/apl-cmf/src/completion.rs new file mode 100644 index 00000000..6a8bab82 --- /dev/null +++ b/crates/apl-cmf/src/completion.rs @@ -0,0 +1,76 @@ +// Location: ./crates/apl-cmf/src/completion.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CompletionExtension → AttributeBag. +// +// Namespace: +// completion.stop_reason : String (snake_case: "end" | "return" | "call" | "max_tokens" | "stop_sequence") +// completion.model : String +// completion.raw_format : String +// completion.created_at : String +// completion.latency_ms : Int +// completion.tokens.input : Int +// completion.tokens.output : Int +// completion.tokens.total : Int + +use apl_core::AttributeBag; +use cpex_core::extensions::{CompletionExtension, StopReason}; + +pub fn extract_completion(c: &CompletionExtension, bag: &mut AttributeBag) { + if let Some(sr) = c.stop_reason { + bag.set("completion.stop_reason", stop_reason_str(sr)); + } + if let Some(tu) = &c.tokens { + bag.set("completion.tokens.input", tu.input_tokens as i64); + bag.set("completion.tokens.output", tu.output_tokens as i64); + bag.set("completion.tokens.total", tu.total_tokens as i64); + } + if let Some(v) = &c.model { bag.set("completion.model", v.clone()); } + if let Some(v) = &c.raw_format { bag.set("completion.raw_format", v.clone()); } + if let Some(v) = &c.created_at { bag.set("completion.created_at", v.clone()); } + if let Some(ms) = c.latency_ms { bag.set("completion.latency_ms", ms as i64); } +} + +fn stop_reason_str(sr: StopReason) -> &'static str { + match sr { + StopReason::End => "end", + StopReason::Return => "return", + StopReason::Call => "call", + StopReason::MaxTokens => "max_tokens", + StopReason::StopSequence => "stop_sequence", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::completion::TokenUsage; + + #[test] + fn stop_reason_serializes_as_snake_case_string() { + let c = CompletionExtension { + stop_reason: Some(StopReason::MaxTokens), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_completion(&c, &mut bag); + assert_eq!(bag.get_string("completion.stop_reason"), Some("max_tokens")); + } + + #[test] + fn tokens_flatten_to_nested_ints() { + let c = CompletionExtension { + tokens: Some(TokenUsage { input_tokens: 100, output_tokens: 50, total_tokens: 150 }), + latency_ms: Some(420), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_completion(&c, &mut bag); + assert_eq!(bag.get_int("completion.tokens.input"), Some(100)); + assert_eq!(bag.get_int("completion.tokens.output"), Some(50)); + assert_eq!(bag.get_int("completion.tokens.total"), Some(150)); + assert_eq!(bag.get_int("completion.latency_ms"), Some(420)); + } +} diff --git a/crates/apl-cmf/src/constants.rs b/crates/apl-cmf/src/constants.rs new file mode 100644 index 00000000..276fb4a6 --- /dev/null +++ b/crates/apl-cmf/src/constants.rs @@ -0,0 +1,113 @@ +// Location: ./crates/apl-cmf/src/constants.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// String constants used across apl-cmf — capability names cpex-core +// recognizes for `filter_extensions`, plus the bag-attribute +// prefixes APL extractors write under. Centralizing both makes the +// capability → bag namespace mapping in `capability_namespaces` a +// straight reference rather than a soup of inline strings, and +// gives operators / docs / tools one canonical place to read them +// from. +// +// # Source-of-truth invariants +// +// * `CAP_*` names match `cpex_core::extensions::filter::filter_extensions` +// verbatim. cpex-core is authoritative — if it changes a cap name, +// bump here and update the mapping table. +// * `BAG_*` prefixes match what the per-extension extractor modules +// (`security.rs`, `delegation.rs`, etc.) actually write into the +// bag. The extractor files still use string literals today; a +// future cleanup can refactor them to consume these constants to +// prevent drift. Tests in `capability_namespaces` flag the +// contract. + +// ===================================================================== +// Capability names — must match cpex-core's vocabulary +// ===================================================================== + +// ----- Subject identity (read) ----- +pub const CAP_READ_SUBJECT: &str = "read_subject"; +pub const CAP_READ_ROLES: &str = "read_roles"; +pub const CAP_READ_PERMISSIONS: &str = "read_permissions"; +pub const CAP_READ_TEAMS: &str = "read_teams"; +pub const CAP_READ_CLAIMS: &str = "read_claims"; + +// ----- Security extension (non-subject) ----- +pub const CAP_READ_LABELS: &str = "read_labels"; +pub const CAP_READ_CLIENT: &str = "read_client"; +pub const CAP_READ_WORKLOAD: &str = "read_workload"; + +// ----- Credential material — payload-only, no bag prefixes ----- +pub const CAP_READ_INBOUND_CREDENTIALS: &str = "read_inbound_credentials"; +pub const CAP_READ_DELEGATED_TOKENS: &str = "read_delegated_tokens"; + +// ----- Per-extension reads ----- +pub const CAP_READ_DELEGATION: &str = "read_delegation"; +pub const CAP_READ_AGENT: &str = "read_agent"; +pub const CAP_READ_META: &str = "read_meta"; +pub const CAP_READ_REQUEST: &str = "read_request"; +pub const CAP_READ_HEADERS: &str = "read_headers"; +pub const CAP_READ_LLM: &str = "read_llm"; +pub const CAP_READ_MCP: &str = "read_mcp"; +pub const CAP_READ_COMPLETION: &str = "read_completion"; +pub const CAP_READ_PROVENANCE: &str = "read_provenance"; +pub const CAP_READ_FRAMEWORK: &str = "read_framework"; +pub const CAP_READ_CUSTOM: &str = "read_custom"; + +// ----- Write tokens — don't unlock bag attributes ----- +pub const CAP_APPEND_LABELS: &str = "append_labels"; +pub const CAP_APPEND_DELEGATION: &str = "append_delegation"; +pub const CAP_WRITE_HEADERS: &str = "write_headers"; + +// ===================================================================== +// Bag-attribute prefixes (and exact-match keys) — must match what +// the apl-cmf extractor modules write. +// +// Prefixes ending in `.` match any key starting with them +// (e.g. `BAG_ROLE_PREFIX` matches `role.hr`, `role.admin`). +// Prefixes WITHOUT a trailing `.` match the exact bag key +// (e.g. `BAG_AUTHENTICATED` matches only `authenticated`). +// ===================================================================== + +// ----- Subject ----- +pub const BAG_SUBJECT_ID: &str = "subject.id"; +pub const BAG_SUBJECT_TYPE: &str = "subject.type"; +pub const BAG_SUBJECT_TEAMS: &str = "subject.teams"; +pub const BAG_AUTHENTICATED: &str = "authenticated"; +pub const BAG_ROLE_PREFIX: &str = "role."; +pub const BAG_PERM_PREFIX: &str = "perm."; +pub const BAG_TEAM_PREFIX: &str = "team."; +pub const BAG_CLAIM_PREFIX: &str = "claim."; + +// ----- Payload (args / result) ----- +// +// These are the dotted-prefix forms used when apl-cmf::payload flattens +// the request's args object and the upstream's result object into the +// bag. APL predicates / Cedar `${args.X}` substitutions / OPA `input.X` +// paths all resolve through these. +pub const BAG_ARGS_PREFIX: &str = "args."; +pub const BAG_RESULT_PREFIX: &str = "result."; + +// ----- Client + workload ----- +pub const BAG_CLIENT_PREFIX: &str = "client."; +pub const BAG_WORKLOAD_PREFIX: &str = "workload."; +pub const BAG_CALLER_WORKLOAD_PREFIX: &str = "caller_workload."; + +// ----- Delegation ----- +pub const BAG_DELEGATION_PREFIX: &str = "delegation."; +pub const BAG_DELEGATED: &str = "delegated"; + +// ----- Other extensions ----- +pub const BAG_AGENT_PREFIX: &str = "agent."; +pub const BAG_META_PREFIX: &str = "meta."; +pub const BAG_REQUEST_PREFIX: &str = "request."; +pub const BAG_HTTP_REQUEST_HEADERS_PREFIX: &str = "http.request_headers."; +pub const BAG_HTTP_RESPONSE_HEADERS_PREFIX: &str = "http.response_headers."; +pub const BAG_LLM_PREFIX: &str = "llm."; +pub const BAG_MCP_PREFIX: &str = "mcp."; +pub const BAG_COMPLETION_PREFIX: &str = "completion."; +pub const BAG_PROVENANCE_PREFIX: &str = "provenance."; +pub const BAG_FRAMEWORK_PREFIX: &str = "framework."; +pub const BAG_CUSTOM_PREFIX: &str = "custom."; diff --git a/crates/apl-cmf/src/custom.rs b/crates/apl-cmf/src/custom.rs new file mode 100644 index 00000000..a933fbc3 --- /dev/null +++ b/crates/apl-cmf/src/custom.rs @@ -0,0 +1,42 @@ +// Location: ./crates/apl-cmf/src/custom.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `Extensions.custom` (HashMap) → AttributeBag. +// +// Open-ended user namespace. Each top-level key becomes `custom.`, +// and nested objects flatten through the same JSON walker as args/result. +// Lets a host stuff arbitrary policy-relevant data into the bag without +// needing a new extension type. +// +// Namespace: +// custom. : Bool | Int | Float | String | StringSet + +use apl_core::AttributeBag; +use serde_json::Value; +use std::collections::HashMap; + +pub fn extract_custom(custom: &HashMap, bag: &mut AttributeBag) { + for (k, v) in custom { + crate::payload::walk(v, &format!("custom.{}", k), bag); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn custom_keys_flatten_under_custom_namespace() { + let mut custom = HashMap::new(); + custom.insert("feature_flag".into(), json!(true)); + custom.insert("tenant".into(), json!({ "id": "acme", "tier": "enterprise" })); + let mut bag = AttributeBag::new(); + extract_custom(&custom, &mut bag); + assert_eq!(bag.get_bool("custom.feature_flag"), Some(true)); + assert_eq!(bag.get_string("custom.tenant.id"), Some("acme")); + assert_eq!(bag.get_string("custom.tenant.tier"), Some("enterprise")); + } +} diff --git a/crates/apl-cmf/src/delegation.rs b/crates/apl-cmf/src/delegation.rs new file mode 100644 index 00000000..50d8f6b9 --- /dev/null +++ b/crates/apl-cmf/src/delegation.rs @@ -0,0 +1,88 @@ +// Location: ./crates/apl-cmf/src/delegation.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// DelegationExtension → AttributeBag. +// +// Namespace map: +// +// del.depth → delegation.depth : Int +// del.delegated → delegation.delegated, delegated : Bool +// del.origin_subject_id → delegation.origin_subject_id : String +// del.actor_subject_id → delegation.actor_subject_id : String +// del.age_seconds → delegation.age_seconds : Float +// +// Per-hop fields (scopes, audience, strategy) are not flattened into the +// bag. Policies that need that depth call out to a plugin or PDP; the +// bag stays scalar. + +use apl_core::AttributeBag; +use cpex_core::extensions::DelegationExtension; + +/// Flatten a `DelegationExtension` into the bag. +pub fn extract_delegation(del: &DelegationExtension, bag: &mut AttributeBag) { + bag.set("delegation.depth", del.depth as i64); + bag.set("delegation.delegated", del.delegated); + // Top-level alias — DSL idiom is `require(!delegated)`, unprefixed. + bag.set("delegated", del.delegated); + + if let Some(origin) = &del.origin_subject_id { + bag.set("delegation.origin_subject_id", origin.clone()); + } + if let Some(actor) = &del.actor_subject_id { + bag.set("delegation.actor_subject_id", actor.clone()); + } + bag.set("delegation.age_seconds", del.age_seconds); +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{DelegationHop, DelegationStrategy}; + + #[test] + fn empty_delegation_sets_zero_depth_and_delegated_false() { + let del = DelegationExtension::default(); + let mut bag = AttributeBag::new(); + extract_delegation(&del, &mut bag); + assert_eq!(bag.get_int("delegation.depth"), Some(0)); + assert_eq!(bag.get_bool("delegation.delegated"), Some(false)); + assert_eq!(bag.get_bool("delegated"), Some(false)); + // Optional fields stay absent. + assert!(!bag.contains("delegation.origin_subject_id")); + assert!(!bag.contains("delegation.actor_subject_id")); + } + + #[test] + fn populated_chain_produces_attributes() { + let mut del = DelegationExtension { + origin_subject_id: Some("alice".into()), + actor_subject_id: Some("service-b".into()), + age_seconds: 12.5, + ..Default::default() + }; + del.append_hop(DelegationHop { + subject_id: "alice".into(), + audience: Some("service-b".into()), + scopes_granted: vec!["read".into()], + strategy: Some(DelegationStrategy::TokenExchange), + ..Default::default() + }); + del.append_hop(DelegationHop { + subject_id: "service-b".into(), + audience: Some("service-c".into()), + scopes_granted: vec!["read".into()], + ..Default::default() + }); + + let mut bag = AttributeBag::new(); + extract_delegation(&del, &mut bag); + assert_eq!(bag.get_int("delegation.depth"), Some(2)); + assert_eq!(bag.get_bool("delegation.delegated"), Some(true)); + assert_eq!(bag.get_bool("delegated"), Some(true)); + assert_eq!(bag.get_string("delegation.origin_subject_id"), Some("alice")); + assert_eq!(bag.get_string("delegation.actor_subject_id"), Some("service-b")); + assert_eq!(bag.get_float("delegation.age_seconds"), Some(12.5)); + } +} diff --git a/crates/apl-cmf/src/extensions_bridge.rs b/crates/apl-cmf/src/extensions_bridge.rs new file mode 100644 index 00000000..6c650aca --- /dev/null +++ b/crates/apl-cmf/src/extensions_bridge.rs @@ -0,0 +1,94 @@ +// Location: ./crates/apl-cmf/src/extensions_bridge.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Unified entry point: take an `Extensions` container, dispatch each +// present slot to its per-extension extractor. +// +// This is the function `apl-cpex` will call at hook time after assembling +// `Extensions` from the request. It guarantees every slot that's present +// gets bridged, so a new extension type that adds an extractor module +// shows up in the bag automatically. + +use apl_core::AttributeBag; +use cpex_core::extensions::Extensions; + +use crate::{ + agent::extract_agent, completion::extract_completion, custom::extract_custom, + delegation::extract_delegation, framework::extract_framework, http::extract_http, + llm::extract_llm, mcp::extract_mcp, meta::extract_meta, provenance::extract_provenance, + request::extract_request, security::extract_security, +}; + +/// Flatten every present slot in `Extensions` into `bag`. +pub fn extract_extensions(ext: &Extensions, bag: &mut AttributeBag) { + if let Some(v) = &ext.security { extract_security(v, bag); } + if let Some(v) = &ext.delegation { extract_delegation(v, bag); } + if let Some(v) = &ext.agent { extract_agent(v, bag); } + if let Some(v) = &ext.meta { extract_meta(v, bag); } + if let Some(v) = &ext.request { extract_request(v, bag); } + if let Some(v) = &ext.http { extract_http(v, bag); } + if let Some(v) = &ext.llm { extract_llm(v, bag); } + if let Some(v) = &ext.mcp { extract_mcp(v, bag); } + if let Some(v) = &ext.completion { extract_completion(v, bag); } + if let Some(v) = &ext.provenance { extract_provenance(v, bag); } + if let Some(v) = &ext.framework { extract_framework(v, bag); } + if let Some(v) = &ext.custom { extract_custom(v, bag); } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{ + AgentExtension, DelegationExtension, LLMExtension, MetaExtension, SecurityExtension, + SubjectExtension, + }; + use std::collections::HashSet; + use std::sync::Arc; + + #[test] + fn dispatches_every_present_slot() { + let mut ext = Extensions::default(); + ext.security = Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + roles: HashSet::from(["hr".to_string()]), + ..Default::default() + }), + ..Default::default() + })); + ext.delegation = Some(Arc::new(DelegationExtension::default())); + ext.agent = Some(Arc::new(AgentExtension { + session_id: Some("sess-1".into()), + ..Default::default() + })); + ext.meta = Some(Arc::new(MetaExtension { + tags: HashSet::from(["pii".to_string()]), + ..Default::default() + })); + ext.llm = Some(Arc::new(LLMExtension { + model_id: Some("gpt-4".into()), + ..Default::default() + })); + + let mut bag = AttributeBag::new(); + extract_extensions(&ext, &mut bag); + + // One assertion per namespace — proves the dispatch reached each. + assert_eq!(bag.get_string("subject.id"), Some("alice")); + assert_eq!(bag.get_bool("role.hr"), Some(true)); + assert_eq!(bag.get_int("delegation.depth"), Some(0)); + assert_eq!(bag.get_string("agent.session_id"), Some("sess-1")); + assert!(bag.set_contains("meta.tags", "pii")); + assert_eq!(bag.get_string("llm.model_id"), Some("gpt-4")); + } + + #[test] + fn absent_slots_skipped_no_panic() { + let ext = Extensions::default(); + let mut bag = AttributeBag::new(); + extract_extensions(&ext, &mut bag); + assert!(bag.is_empty()); + } +} diff --git a/crates/apl-cmf/src/framework.rs b/crates/apl-cmf/src/framework.rs new file mode 100644 index 00000000..ccd39e55 --- /dev/null +++ b/crates/apl-cmf/src/framework.rs @@ -0,0 +1,55 @@ +// Location: ./crates/apl-cmf/src/framework.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// FrameworkExtension → AttributeBag. +// +// Namespace: +// framework.framework : String ("langchain", "crewai", ...) +// framework.framework_version : String +// framework.node_id : String +// framework.graph_id : String +// framework.metadata. : various (JSON walker — same as args) + +use apl_core::AttributeBag; +use cpex_core::extensions::FrameworkExtension; + +pub fn extract_framework(f: &FrameworkExtension, bag: &mut AttributeBag) { + if let Some(v) = &f.framework { bag.set("framework.framework", v.clone()); } + if let Some(v) = &f.framework_version { bag.set("framework.framework_version", v.clone()); } + if let Some(v) = &f.node_id { bag.set("framework.node_id", v.clone()); } + if let Some(v) = &f.graph_id { bag.set("framework.graph_id", v.clone()); } + // metadata is a HashMap — flatten the same way args/result do. + for (k, v) in &f.metadata { + crate::payload::walk(v, &format!("framework.metadata.{}", k), bag); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn nested_metadata_flattens() { + let f = FrameworkExtension { + framework: Some("langchain".into()), + framework_version: Some("0.1.42".into()), + node_id: Some("retriever".into()), + metadata: HashMap::from([ + ("chain_id".to_string(), json!("abc")), + ("step".to_string(), json!(7)), + ("flags".to_string(), json!({ "verbose": true })), + ]), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_framework(&f, &mut bag); + assert_eq!(bag.get_string("framework.framework"), Some("langchain")); + assert_eq!(bag.get_string("framework.metadata.chain_id"), Some("abc")); + assert_eq!(bag.get_int("framework.metadata.step"), Some(7)); + assert_eq!(bag.get_bool("framework.metadata.flags.verbose"), Some(true)); + } +} diff --git a/crates/apl-cmf/src/http.rs b/crates/apl-cmf/src/http.rs new file mode 100644 index 00000000..60d84565 --- /dev/null +++ b/crates/apl-cmf/src/http.rs @@ -0,0 +1,45 @@ +// Location: ./crates/apl-cmf/src/http.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HttpExtension → AttributeBag. +// +// Header names are lowercased in the bag (HTTP is case-insensitive). A +// policy author writing `http.request_headers.authorization` doesn't need +// to remember the original case. +// +// Namespace: +// http.request_headers. : String (lowercased name) +// http.response_headers. : String (lowercased name) + +use apl_core::AttributeBag; +use cpex_core::extensions::HttpExtension; + +pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) { + for (k, v) in &http.request_headers { + bag.set(format!("http.request_headers.{}", k.to_lowercase()), v.clone()); + } + for (k, v) in &http.response_headers { + bag.set(format!("http.response_headers.{}", k.to_lowercase()), v.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_lowercased_in_bag() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer xyz"); + http.set_request_header("X-Trace-Id", "abc-123"); + http.set_response_header("Content-Type", "application/json"); + + let mut bag = AttributeBag::new(); + extract_http(&http, &mut bag); + assert_eq!(bag.get_string("http.request_headers.authorization"), Some("Bearer xyz")); + assert_eq!(bag.get_string("http.request_headers.x-trace-id"), Some("abc-123")); + assert_eq!(bag.get_string("http.response_headers.content-type"), Some("application/json")); + } +} diff --git a/crates/apl-cmf/src/lib.rs b/crates/apl-cmf/src/lib.rs new file mode 100644 index 00000000..dcbeda91 --- /dev/null +++ b/crates/apl-cmf/src/lib.rs @@ -0,0 +1,137 @@ +// Location: ./crates/apl-cmf/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-cmf — bridges typed cpex-core extensions into apl-core's flat +// AttributeBag. This is where the *attribute vocabulary* APL policy +// authors write against gets defined. +// +// Layering (see docs/specs/apl-design.md §4): +// +// cpex-core : typed extension data (SecurityExtension, …) +// apl-cmf : ←── this crate, flat-key bridge +// apl-core : language IR + evaluator (AttributeBag, predicates, pipelines) +// apl-cpex : runtime adapter (hooks, PluginInvoker, PdpResolver) +// +// The crate is intentionally simple: each bridge is a pure function that +// reads its typed source and writes flat keys into a borrowed bag. No +// async, no I/O. Composition is via the convenience `BagBuilder`. +// +// Attribute namespace contract (each module owns the detail comment): +// SecurityExtension.subject → subject.*, role.*, perm.*, claim.*, authenticated +// SecurityExtension.client → client.*, client.role.*, client.perm.*, client.claim.* +// SecurityExtension.caller_workload → caller_workload.* (inbound attested peer) +// SecurityExtension.this_workload → this_workload.* (our own attested identity — +// not `agent.*`, which is `AgentExtension`) +// SecurityExtension → security.labels, security.classification, auth_method +// DelegationExtension → delegation.*, delegated +// AgentExtension → agent.* (session, conversation, lineage) +// MetaExtension → meta.* +// RequestExtension → request.* +// HttpExtension → http.request_headers.*, http.response_headers.* +// LLMExtension → llm.* +// MCPExtension → mcp.tool.*, mcp.resource.*, mcp.prompt.* +// CompletionExtension → completion.* +// ProvenanceExtension → provenance.* +// FrameworkExtension → framework.* (incl. framework.metadata.*) +// Extensions.custom → custom.* +// Request args object → args.* +// Response result object → result.* + +pub mod agent; +pub mod capability_namespaces; +pub mod completion; +pub mod constants; +pub mod custom; +pub mod delegation; +pub mod extensions_bridge; +pub mod framework; +pub mod http; +pub mod llm; +pub mod mcp; +pub mod meta; +pub mod payload; +pub mod provenance; +pub mod request; +pub mod security; + +pub use agent::extract_agent; +pub use capability_namespaces::{ + capability_namespaces, known_read_capabilities, unlocked_bag_prefixes, +}; +pub use completion::extract_completion; +pub use custom::extract_custom; +pub use delegation::extract_delegation; +pub use extensions_bridge::extract_extensions; +pub use framework::extract_framework; +pub use http::extract_http; +pub use llm::extract_llm; +pub use mcp::extract_mcp; +pub use meta::extract_meta; +pub use payload::{extract_args, extract_result}; +pub use provenance::extract_provenance; +pub use request::extract_request; +pub use security::{extract_client, extract_security, extract_workload}; + +use apl_core::AttributeBag; +use cpex_core::extensions::{DelegationExtension, Extensions, SecurityExtension}; + +/// Fluent builder that composes the typed sources into a single bag. +/// +/// Lets the host (apl-cpex) write: +/// ```ignore +/// let bag = BagBuilder::new() +/// .with_security(&sec) +/// .with_delegation(&del) +/// .with_args(&payload.args) +/// .build(); +/// ``` +/// +/// Order of `with_*` calls is irrelevant — keys live in disjoint namespaces. +#[derive(Default)] +pub struct BagBuilder { + bag: AttributeBag, +} + +impl BagBuilder { + pub fn new() -> Self { Self::default() } + + pub fn with_security(mut self, sec: &SecurityExtension) -> Self { + extract_security(sec, &mut self.bag); + self + } + + pub fn with_delegation(mut self, del: &DelegationExtension) -> Self { + extract_delegation(del, &mut self.bag); + self + } + + /// Bridge every present slot in an `Extensions` container at once — + /// security, delegation, agent, meta, request, http, llm, mcp, + /// completion, provenance, framework, custom. + pub fn with_extensions(mut self, ext: &Extensions) -> Self { + extract_extensions(ext, &mut self.bag); + self + } + + pub fn with_args(mut self, args: &serde_json::Value) -> Self { + extract_args(args, &mut self.bag); + self + } + + pub fn with_result(mut self, result: &serde_json::Value) -> Self { + extract_result(result, &mut self.bag); + self + } + + /// Set the route key under `route.key` for policy predicates that + /// branch on which route is running (mostly useful in default/policy + /// bundles applied across routes). + pub fn with_route_key(mut self, route_key: impl Into) -> Self { + self.bag.set("route.key", route_key.into()); + self + } + + pub fn build(self) -> AttributeBag { self.bag } +} diff --git a/crates/apl-cmf/src/llm.rs b/crates/apl-cmf/src/llm.rs new file mode 100644 index 00000000..0dbe3332 --- /dev/null +++ b/crates/apl-cmf/src/llm.rs @@ -0,0 +1,44 @@ +// Location: ./crates/apl-cmf/src/llm.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// LLMExtension → AttributeBag. +// +// Namespace: +// llm.model_id : String +// llm.provider : String +// llm.capabilities : StringSet + +use apl_core::AttributeBag; +use cpex_core::extensions::LLMExtension; +use std::collections::HashSet; + +pub fn extract_llm(llm: &LLMExtension, bag: &mut AttributeBag) { + if let Some(v) = &llm.model_id { bag.set("llm.model_id", v.clone()); } + if let Some(v) = &llm.provider { bag.set("llm.provider", v.clone()); } + if !llm.capabilities.is_empty() { + let caps: HashSet = llm.capabilities.iter().cloned().collect(); + bag.set("llm.capabilities", caps); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_model_and_capabilities() { + let llm = LLMExtension { + model_id: Some("gpt-4".into()), + provider: Some("openai".into()), + capabilities: vec!["tool_use".into(), "vision".into()], + }; + let mut bag = AttributeBag::new(); + extract_llm(&llm, &mut bag); + assert_eq!(bag.get_string("llm.model_id"), Some("gpt-4")); + assert_eq!(bag.get_string("llm.provider"), Some("openai")); + assert!(bag.set_contains("llm.capabilities", "tool_use")); + assert!(bag.set_contains("llm.capabilities", "vision")); + } +} diff --git a/crates/apl-cmf/src/mcp.rs b/crates/apl-cmf/src/mcp.rs new file mode 100644 index 00000000..9327dd1b --- /dev/null +++ b/crates/apl-cmf/src/mcp.rs @@ -0,0 +1,92 @@ +// Location: ./crates/apl-cmf/src/mcp.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MCPExtension → AttributeBag. +// +// Tool, resource, and prompt metadata each flatten under their own sub-namespace. +// Schemas and annotations are deliberately NOT flattened — they're free-form +// JSON; policies that need them should call a plugin. +// +// Namespace: +// mcp.tool.name : String (always set if tool present) +// mcp.tool.title : String +// mcp.tool.description : String +// mcp.tool.server_id : String +// mcp.tool.namespace : String +// mcp.resource.uri : String (always set if resource present) +// mcp.resource.name : String +// mcp.resource.description: String +// mcp.resource.mime_type : String +// mcp.resource.server_id : String +// mcp.prompt.name : String (always set if prompt present) +// mcp.prompt.description : String +// mcp.prompt.server_id : String + +use apl_core::AttributeBag; +use cpex_core::extensions::MCPExtension; + +pub fn extract_mcp(mcp: &MCPExtension, bag: &mut AttributeBag) { + if let Some(tool) = &mcp.tool { + bag.set("mcp.tool.name", tool.name.clone()); + if let Some(v) = &tool.title { bag.set("mcp.tool.title", v.clone()); } + if let Some(v) = &tool.description { bag.set("mcp.tool.description", v.clone()); } + if let Some(v) = &tool.server_id { bag.set("mcp.tool.server_id", v.clone()); } + if let Some(v) = &tool.namespace { bag.set("mcp.tool.namespace", v.clone()); } + } + if let Some(res) = &mcp.resource { + bag.set("mcp.resource.uri", res.uri.clone()); + if let Some(v) = &res.name { bag.set("mcp.resource.name", v.clone()); } + if let Some(v) = &res.description { bag.set("mcp.resource.description", v.clone()); } + if let Some(v) = &res.mime_type { bag.set("mcp.resource.mime_type", v.clone()); } + if let Some(v) = &res.server_id { bag.set("mcp.resource.server_id", v.clone()); } + } + if let Some(prompt) = &mcp.prompt { + bag.set("mcp.prompt.name", prompt.name.clone()); + if let Some(v) = &prompt.description { bag.set("mcp.prompt.description", v.clone()); } + if let Some(v) = &prompt.server_id { bag.set("mcp.prompt.server_id", v.clone()); } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::mcp::{ResourceMetadata, ToolMetadata}; + + #[test] + fn tool_metadata_flattens() { + let mcp = MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + description: Some("HR comp lookup".into()), + server_id: Some("hr-srv".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_mcp(&mcp, &mut bag); + assert_eq!(bag.get_string("mcp.tool.name"), Some("get_compensation")); + assert_eq!(bag.get_string("mcp.tool.description"), Some("HR comp lookup")); + assert_eq!(bag.get_string("mcp.tool.server_id"), Some("hr-srv")); + // Schemas are deliberately not in the bag. + assert!(!bag.contains("mcp.tool.input_schema")); + } + + #[test] + fn resource_uri_is_required_field() { + let mcp = MCPExtension { + resource: Some(ResourceMetadata { + uri: "hr://employees/123".into(), + mime_type: Some("application/json".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_mcp(&mcp, &mut bag); + assert_eq!(bag.get_string("mcp.resource.uri"), Some("hr://employees/123")); + assert_eq!(bag.get_string("mcp.resource.mime_type"), Some("application/json")); + } +} diff --git a/crates/apl-cmf/src/meta.rs b/crates/apl-cmf/src/meta.rs new file mode 100644 index 00000000..7f1ba17a --- /dev/null +++ b/crates/apl-cmf/src/meta.rs @@ -0,0 +1,57 @@ +// Location: ./crates/apl-cmf/src/meta.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MetaExtension → AttributeBag. +// +// Namespace: +// meta.entity_type : String ("tool" | "resource" | "prompt" | "llm") +// meta.entity_name : String +// meta.tags : StringSet ← used by spec-level tag-driven policy inheritance +// meta.scope : String +// meta.properties. : String + +use apl_core::AttributeBag; +use cpex_core::extensions::MetaExtension; +use std::collections::HashSet; + +pub fn extract_meta(meta: &MetaExtension, bag: &mut AttributeBag) { + if let Some(v) = &meta.entity_type { bag.set("meta.entity_type", v.clone()); } + if let Some(v) = &meta.entity_name { bag.set("meta.entity_name", v.clone()); } + if !meta.tags.is_empty() { + let tags: HashSet = meta.tags.iter().cloned().collect(); + bag.set("meta.tags", tags); + } + if let Some(v) = &meta.scope { bag.set("meta.scope", v.clone()); } + for (k, v) in &meta.properties { + bag.set(format!("meta.properties.{}", k), v.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn tags_and_properties_flatten() { + let meta = MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + tags: HashSet::from(["pii".to_string(), "sensitive".to_string()]), + scope: Some("hr".into()), + properties: HashMap::from([ + ("owner".to_string(), "compliance".to_string()), + ]), + }; + let mut bag = AttributeBag::new(); + extract_meta(&meta, &mut bag); + assert_eq!(bag.get_string("meta.entity_type"), Some("tool")); + assert_eq!(bag.get_string("meta.entity_name"), Some("get_compensation")); + assert!(bag.set_contains("meta.tags", "pii")); + assert!(bag.set_contains("meta.tags", "sensitive")); + assert_eq!(bag.get_string("meta.scope"), Some("hr")); + assert_eq!(bag.get_string("meta.properties.owner"), Some("compliance")); + } +} diff --git a/crates/apl-cmf/src/payload.rs b/crates/apl-cmf/src/payload.rs new file mode 100644 index 00000000..11a22f7d --- /dev/null +++ b/crates/apl-cmf/src/payload.rs @@ -0,0 +1,150 @@ +// Location: ./crates/apl-cmf/src/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// JSON args/result payload → AttributeBag. +// +// Leaf scalars at any nesting depth land in the bag under their dotted +// path, prefixed with `args.` or `result.`. Nested objects recurse; +// arrays-of-strings flatten into a StringSet; arrays of mixed/scalar +// types are skipped (no list scalar attribute in the bag). +// +// Examples: +// args = { "include_ssn": true, +// "user": { "id": "alice", "roles": ["hr", "manager"] } } +// → args.include_ssn : Bool(true) +// args.user.id : String("alice") +// args.user.roles : StringSet({"hr", "manager"}) +// +// Null values are skipped (consistent with bag's missing-key semantics). + +use apl_core::AttributeBag; +use serde_json::Value; +use std::collections::HashSet; + +use crate::constants::{BAG_ARGS_PREFIX, BAG_RESULT_PREFIX}; + +/// Flatten an args object into `args.*` keys. +pub fn extract_args(args: &Value, bag: &mut AttributeBag) { + // `walk` builds dotted paths itself; strip the trailing `.` from + // the canonical prefix to match its signature. + walk(args, BAG_ARGS_PREFIX.trim_end_matches('.'), bag); +} + +/// Flatten a result object into `result.*` keys. +pub fn extract_result(result: &Value, bag: &mut AttributeBag) { + walk(result, BAG_RESULT_PREFIX.trim_end_matches('.'), bag); +} + +pub(crate) fn walk(value: &Value, prefix: &str, bag: &mut AttributeBag) { + match value { + Value::Object(map) => { + for (key, sub) in map { + let dotted = if prefix.is_empty() { key.clone() } else { format!("{}.{}", prefix, key) }; + walk(sub, &dotted, bag); + } + } + Value::Array(items) => { + // Promote string-only arrays to StringSet — supports + // `args.tags contains "urgent"` predicates. + let mut all_strings: HashSet = HashSet::new(); + let mut ok = true; + for item in items { + if let Some(s) = item.as_str() { + all_strings.insert(s.to_string()); + } else { + ok = false; + break; + } + } + if ok && !all_strings.is_empty() { + bag.set(prefix, all_strings); + } + // Non-string arrays (mixed, numeric, nested): silently skipped + // — no list scalar in the bag for those. + } + Value::String(s) => bag.set(prefix, s.clone()), + Value::Bool(b) => bag.set(prefix, *b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + bag.set(prefix, i); + } else if let Some(f) = n.as_f64() { + bag.set(prefix, f); + } + } + Value::Null => {} // Skip — equivalent to "key not present." + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn args_scalars_at_top_level() { + let args = json!({ "include_ssn": true, "amount": 100, "name": "alice" }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert_eq!(bag.get_bool("args.include_ssn"), Some(true)); + assert_eq!(bag.get_int("args.amount"), Some(100)); + assert_eq!(bag.get_string("args.name"), Some("alice")); + } + + #[test] + fn args_nested_objects_dotted() { + let args = json!({ "user": { "id": "alice", "profile": { "tier": "gold" } } }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert_eq!(bag.get_string("args.user.id"), Some("alice")); + assert_eq!(bag.get_string("args.user.profile.tier"), Some("gold")); + } + + #[test] + fn args_string_array_becomes_string_set() { + let args = json!({ "tags": ["urgent", "audit"] }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert!(bag.set_contains("args.tags", "urgent")); + assert!(bag.set_contains("args.tags", "audit")); + assert!(!bag.set_contains("args.tags", "missing")); + } + + #[test] + fn args_mixed_array_is_skipped() { + let args = json!({ "mixed": ["a", 1, true] }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + // No `args.mixed` key — type didn't unify, so we dropped it. + assert!(!bag.contains("args.mixed")); + } + + #[test] + fn args_null_is_treated_as_missing() { + let args = json!({ "maybe": null, "yes": true }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert!(!bag.contains("args.maybe")); + assert_eq!(bag.get_bool("args.yes"), Some(true)); + } + + #[test] + fn result_uses_result_prefix() { + let result = json!({ "ssn": "123-45-6789", "salary": 50000 }); + let mut bag = AttributeBag::new(); + extract_result(&result, &mut bag); + assert_eq!(bag.get_string("result.ssn"), Some("123-45-6789")); + assert_eq!(bag.get_int("result.salary"), Some(50000)); + // No args.* keys collected. + assert!(!bag.contains("args.ssn")); + } + + #[test] + fn float_numbers_land_as_float() { + let args = json!({ "score": 0.92 }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert_eq!(bag.get_float("args.score"), Some(0.92)); + } +} diff --git a/crates/apl-cmf/src/provenance.rs b/crates/apl-cmf/src/provenance.rs new file mode 100644 index 00000000..27ddba07 --- /dev/null +++ b/crates/apl-cmf/src/provenance.rs @@ -0,0 +1,38 @@ +// Location: ./crates/apl-cmf/src/provenance.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// ProvenanceExtension → AttributeBag. +// +// Namespace: +// provenance.source : String +// provenance.message_id : String +// provenance.parent_id : String + +use apl_core::AttributeBag; +use cpex_core::extensions::ProvenanceExtension; + +pub fn extract_provenance(p: &ProvenanceExtension, bag: &mut AttributeBag) { + if let Some(v) = &p.source { bag.set("provenance.source", v.clone()); } + if let Some(v) = &p.message_id { bag.set("provenance.message_id", v.clone()); } + if let Some(v) = &p.parent_id { bag.set("provenance.parent_id", v.clone()); } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_threading_fields() { + let p = ProvenanceExtension { + source: Some("upstream-mcp".into()), + message_id: Some("msg-1".into()), + parent_id: Some("msg-0".into()), + }; + let mut bag = AttributeBag::new(); + extract_provenance(&p, &mut bag); + assert_eq!(bag.get_string("provenance.source"), Some("upstream-mcp")); + assert_eq!(bag.get_string("provenance.parent_id"), Some("msg-0")); + } +} diff --git a/crates/apl-cmf/src/request.rs b/crates/apl-cmf/src/request.rs new file mode 100644 index 00000000..7801b71f --- /dev/null +++ b/crates/apl-cmf/src/request.rs @@ -0,0 +1,54 @@ +// Location: ./crates/apl-cmf/src/request.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// RequestExtension → AttributeBag. +// +// Namespace: +// request.environment : String ("production" | "staging" | ...) +// request.request_id : String +// request.timestamp : String (ISO 8601 — bag stays scalar; predicates +// comparing timestamps would need plugins) +// request.trace_id : String +// request.span_id : String + +use apl_core::AttributeBag; +use cpex_core::extensions::RequestExtension; + +pub fn extract_request(req: &RequestExtension, bag: &mut AttributeBag) { + if let Some(v) = &req.environment { bag.set("request.environment", v.clone()); } + if let Some(v) = &req.request_id { bag.set("request.request_id", v.clone()); } + if let Some(v) = &req.timestamp { bag.set("request.timestamp", v.clone()); } + if let Some(v) = &req.trace_id { bag.set("request.trace_id", v.clone()); } + if let Some(v) = &req.span_id { bag.set("request.span_id", v.clone()); } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_all_present_fields() { + let req = RequestExtension { + environment: Some("production".into()), + request_id: Some("req-abc".into()), + timestamp: Some("2026-05-14T12:00:00Z".into()), + trace_id: Some("trace-1".into()), + span_id: Some("span-2".into()), + }; + let mut bag = AttributeBag::new(); + extract_request(&req, &mut bag); + assert_eq!(bag.get_string("request.environment"), Some("production")); + assert_eq!(bag.get_string("request.request_id"), Some("req-abc")); + assert_eq!(bag.get_string("request.trace_id"), Some("trace-1")); + } + + #[test] + fn missing_fields_skipped() { + let req = RequestExtension::default(); + let mut bag = AttributeBag::new(); + extract_request(&req, &mut bag); + assert!(bag.is_empty()); + } +} diff --git a/crates/apl-cmf/src/security.rs b/crates/apl-cmf/src/security.rs new file mode 100644 index 00000000..f23f9381 --- /dev/null +++ b/crates/apl-cmf/src/security.rs @@ -0,0 +1,525 @@ +// Location: ./crates/apl-cmf/src/security.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// SecurityExtension → AttributeBag. +// +// Namespace map (canonical — extend this comment when adding a new key): +// +// ----- Subject (user identity) ------------------------------------------ +// sec.subject.id → subject.id : String +// sec.subject.subject_type → subject.type : String +// sec.subject.roles → role. : Bool(true) +// sec.subject.permissions → perm.

: Bool(true) +// sec.subject.teams → subject.teams : StringSet +// sec.subject.claims → claim. : String +// → authenticated : Bool (iff subject.id is Some) +// +// ----- Client (OAuth application identity) ------------------------------ +// sec.client.client_id → client.client_id : String +// sec.client.client_name → client.client_name : String +// sec.client.trust_level → client.trust_level : String +// sec.client.authorized_scopes → client.authorized_scopes : StringSet +// sec.client.authorized_audiences → client.authorized_audiences : StringSet +// sec.client.roles → client.role. : Bool(true) +// sec.client.permissions → client.perm.

: Bool(true) +// sec.client.teams → client.teams : StringSet +// sec.client.claims → client.claim. : flattened JSON +// +// ----- Workload identity (SPIFFE / mTLS attestation) -------------------- +// sec.caller_workload.spiffe_id → caller_workload.spiffe_id : String +// sec.caller_workload.trust_domain → caller_workload.trust_domain : String +// sec.caller_workload.attestor → caller_workload.attestor : String +// sec.caller_workload.selectors → caller_workload.selectors : StringSet +// sec.caller_workload.client_id → caller_workload.client_id : String +// sec.this_workload.* → this_workload.* (same shape, our identity) +// +// Note: `caller_workload.*` / `this_workload.*` are separate from +// `agent.*` (the `AgentExtension` slot — session / conversation context, +// NOT a credential). Reusing `agent.*` would collide. +// +// ----- Other ----------------------------------------------------------- +// sec.auth_method → auth_method : String +// sec.labels → security.labels : StringSet +// sec.classification → security.classification : String + +use apl_core::AttributeBag; +use cpex_core::extensions::{ + ClientExtension, ClientTrustLevel, SecurityExtension, SubjectType, WorkloadIdentity, +}; +use std::collections::HashSet; + +use crate::constants::{ + BAG_AUTHENTICATED, BAG_CLAIM_PREFIX, BAG_PERM_PREFIX, BAG_ROLE_PREFIX, BAG_SUBJECT_ID, + BAG_SUBJECT_TEAMS, BAG_SUBJECT_TYPE, BAG_TEAM_PREFIX, +}; + +/// Flatten a `SecurityExtension` into the bag. +pub fn extract_security(sec: &SecurityExtension, bag: &mut AttributeBag) { + // ----- Subject (caller identity) ----- + if let Some(subject) = &sec.subject { + let mut authenticated = false; + if let Some(id) = &subject.id { + bag.set(BAG_SUBJECT_ID, id.clone()); + authenticated = true; + } + if let Some(st) = subject.subject_type { + bag.set(BAG_SUBJECT_TYPE, subject_type_str(st)); + } + for role in &subject.roles { + bag.set(format!("{}{}", BAG_ROLE_PREFIX, role), true); + } + for perm in &subject.permissions { + bag.set(format!("{}{}", BAG_PERM_PREFIX, perm), true); + } + if !subject.teams.is_empty() { + // Clone into a fresh HashSet — AttributeValue::StringSet owns its data. + let teams: HashSet = subject.teams.iter().cloned().collect(); + bag.set(BAG_SUBJECT_TEAMS, teams); + // Mirror the role.X / perm.X namespace so policies can + // gate on team membership with the same DSL shape, e.g. + // `require(team.engineering | team.security)`. + for team in &subject.teams { + bag.set(format!("{}{}", BAG_TEAM_PREFIX, team), true); + } + } + for (k, v) in &subject.claims { + bag.set(format!("{}{}", BAG_CLAIM_PREFIX, k), v.clone()); + } + // Single top-level authenticated marker — DSL idiom is `require(authenticated)`, + // unprefixed. Only set when truly authenticated (subject + id present). + if authenticated { + bag.set(BAG_AUTHENTICATED, true); + } + } + + // ----- Client (OAuth application identity) ----- + if let Some(client) = &sec.client { + extract_client(client, bag); + } + + // ----- Inbound caller's attested workload identity ----- + if let Some(caller) = &sec.caller_workload { + extract_workload("caller_workload", caller, bag); + } + + // ----- Our own attested workload identity (outbound) ----- + if let Some(this_w) = &sec.this_workload { + extract_workload("this_workload", this_w, bag); + } + + // ----- Other security fields ----- + if let Some(m) = &sec.auth_method { + bag.set("auth_method", m.clone()); + } + let labels: HashSet = sec.labels.iter().cloned().collect(); + if !labels.is_empty() { + bag.set("security.labels", labels); + } + if let Some(c) = &sec.classification { + bag.set("security.classification", c.clone()); + } +} + +/// Flatten a `ClientExtension` into the bag under the `client.*` +/// namespace. Shape is deliberately symmetric with subject — roles +/// and permissions become presence-only `client.role. = true` / +/// `client.perm.

= true` keys so policies can write +/// `require(client.role.partner)` the same way as `role.hr`. Claims +/// are flattened through the same JSON walker as `custom.*`, so +/// nested objects produce dotted-path keys. +pub fn extract_client(client: &ClientExtension, bag: &mut AttributeBag) { + bag.set("client.client_id", client.client_id.clone()); + if let Some(n) = &client.client_name { + bag.set("client.client_name", n.clone()); + } + bag.set("client.trust_level", trust_level_str(&client.trust_level)); + for role in &client.roles { + bag.set(format!("client.role.{}", role), true); + } + for perm in &client.permissions { + bag.set(format!("client.perm.{}", perm), true); + } + if !client.authorized_scopes.is_empty() { + let scopes: HashSet = client.authorized_scopes.iter().cloned().collect(); + bag.set("client.authorized_scopes", scopes); + } + if !client.authorized_audiences.is_empty() { + let auds: HashSet = client.authorized_audiences.iter().cloned().collect(); + bag.set("client.authorized_audiences", auds); + } + if !client.teams.is_empty() { + let teams: HashSet = client.teams.iter().cloned().collect(); + bag.set("client.teams", teams); + } + for (k, v) in &client.claims { + // Nested JSON claims flatten through the same walker `custom.*` + // uses — keeps semantics consistent across bridges. + crate::payload::walk(v, &format!("client.claim.{}", k), bag); + } +} + +/// Flatten a `WorkloadIdentity` into the bag under the given namespace +/// prefix — typically `"caller_workload"` or `"this_workload"`. Two +/// instances of this struct can coexist in `SecurityExtension` +/// (one inbound, one outbound) and they share the bag shape; the only +/// thing that varies is the namespace. +pub fn extract_workload(prefix: &str, w: &WorkloadIdentity, bag: &mut AttributeBag) { + if let Some(s) = &w.spiffe_id { + bag.set(format!("{}.spiffe_id", prefix), s.clone()); + } + if let Some(t) = &w.trust_domain { + bag.set(format!("{}.trust_domain", prefix), t.clone()); + } + if let Some(a) = &w.attestor { + bag.set(format!("{}.attestor", prefix), a.clone()); + } + if !w.selectors.is_empty() { + let selectors: HashSet = w.selectors.iter().cloned().collect(); + bag.set(format!("{}.selectors", prefix), selectors); + } + if let Some(id) = &w.client_id { + bag.set(format!("{}.client_id", prefix), id.clone()); + } + // `attested_at` intentionally omitted from the bag at v0 — APL + // doesn't carry DateTime as a bag value type, and policies that + // need it can opt into reading the typed extension directly. + let _ = &w.attested_at; +} + +/// Render the `ClientTrustLevel` enum as the bag string. Matches +/// `serde(rename_all = "snake_case")` on the type, with `Custom(s)` +/// rendering as `s` verbatim so policies can write +/// `client.trust_level == "partner-tier-A"`. The `_` arm exists +/// because `ClientTrustLevel` is `#[non_exhaustive]`; if a new +/// well-known variant lands upstream, this falls through to +/// "unknown" until we explicitly add a case — fail-loud rather than +/// silently picking one of the existing strings. +fn trust_level_str(level: &ClientTrustLevel) -> String { + match level { + ClientTrustLevel::FirstParty => "first_party".to_string(), + ClientTrustLevel::ThirdParty => "third_party".to_string(), + ClientTrustLevel::Internal => "internal".to_string(), + ClientTrustLevel::Custom(s) => s.clone(), + _ => "unknown".to_string(), + } +} + +fn subject_type_str(t: SubjectType) -> &'static str { + match t { + SubjectType::User => "user", + SubjectType::Agent => "agent", + SubjectType::Service => "service", + SubjectType::System => "system", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{SubjectExtension, WorkloadIdentity}; + use std::collections::HashMap; + + fn alice() -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + subject_type: Some(SubjectType::User), + roles: HashSet::from(["hr".to_string(), "manager".to_string()]), + permissions: HashSet::from(["view_ssn".to_string()]), + teams: HashSet::from(["compliance".to_string()]), + claims: HashMap::from([("iss".to_string(), "auth.corp".to_string())]), + }), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/hr-tool".into()), + trust_domain: Some("corp.com".into()), + attestor: Some("spire-agent".into()), + selectors: vec!["k8s:ns:hr".into()], + client_id: Some("hr-tool".into()), + ..Default::default() + }), + auth_method: Some("jwt".into()), + ..Default::default() + } + } + + #[test] + fn subject_id_and_authenticated_marker() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("subject.id"), Some("alice@corp.com")); + assert_eq!(bag.get_bool("authenticated"), Some(true)); + assert_eq!(bag.get_string("subject.type"), Some("user")); + } + + #[test] + fn roles_become_individual_true_keys() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + // Each role → role. = true. DSL: `require(role.hr)`. + assert_eq!(bag.get_bool("role.hr"), Some(true)); + assert_eq!(bag.get_bool("role.manager"), Some(true)); + // A role Alice doesn't have is absent (not false — missing). + assert_eq!(bag.get_bool("role.finance"), None); + } + + #[test] + fn permissions_become_individual_true_keys() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_bool("perm.view_ssn"), Some(true)); + assert_eq!(bag.get_bool("perm.delete_user"), None); + } + + #[test] + fn teams_become_string_set() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert!(bag.set_contains("subject.teams", "compliance")); + assert!(!bag.set_contains("subject.teams", "engineering")); + } + + #[test] + fn claims_become_dotted_strings() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("claim.iss"), Some("auth.corp")); + } + + #[test] + fn this_workload_identity_keys() { + // `this_workload.*` namespace — our own attested identity. + // Distinct from the `agent.*` namespace of `AgentExtension` + // (session context) and the future `caller_workload.*` + // namespace for the inbound caller's SPIFFE identity. + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("this_workload.client_id"), Some("hr-tool")); + assert_eq!( + bag.get_string("this_workload.spiffe_id"), + Some("spiffe://corp.com/hr-tool") + ); + assert_eq!(bag.get_string("this_workload.trust_domain"), Some("corp.com")); + assert_eq!(bag.get_string("this_workload.attestor"), Some("spire-agent")); + assert!(bag.set_contains("this_workload.selectors", "k8s:ns:hr")); + } + + #[test] + fn auth_method_is_top_level() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("auth_method"), Some("jwt")); + } + + #[test] + fn labels_and_classification() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + sec.add_label("financial"); + sec.classification = Some("confidential".into()); + + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert!(bag.set_contains("security.labels", "PII")); + assert!(bag.set_contains("security.labels", "financial")); + assert_eq!(bag.get_string("security.classification"), Some("confidential")); + } + + #[test] + fn no_subject_means_no_authenticated_marker() { + let sec = SecurityExtension::default(); // subject: None + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert!(!bag.contains("authenticated")); + assert!(!bag.contains("subject.id")); + } + + #[test] + fn subject_without_id_is_not_authenticated() { + // A subject record exists but has no id — represents a recognized + // but unauthenticated principal (e.g. anonymous). The marker must + // not be set. + let sec = SecurityExtension { + subject: Some(SubjectExtension { + id: None, + roles: HashSet::from(["guest".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert!(!bag.contains("authenticated")); + // But role keys still land — role.guest is true. + assert_eq!(bag.get_bool("role.guest"), Some(true)); + } + + // ----------------------------------------------------------------- + // Client (OAuth application identity) bag namespace + // ----------------------------------------------------------------- + + fn agent_client() -> ClientExtension { + ClientExtension { + client_id: "agent-app".into(), + client_name: Some("Agent App".into()), + trust_level: ClientTrustLevel::FirstParty, + authorized_scopes: vec!["read".into(), "write".into()], + authorized_audiences: vec!["https://api.example.com".into()], + roles: vec!["partner".into()], + permissions: vec!["call_tool".into()], + teams: vec!["acme".into()], + claims: HashMap::from([ + ("iss".to_string(), serde_json::json!("auth.example.com")), + ( + "scope_meta".to_string(), + serde_json::json!({ "max_calls_per_min": 60 }), + ), + ]), + } + } + + #[test] + fn client_required_id_and_trust_level() { + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert_eq!(bag.get_string("client.client_id"), Some("agent-app")); + assert_eq!(bag.get_string("client.client_name"), Some("Agent App")); + assert_eq!(bag.get_string("client.trust_level"), Some("first_party")); + } + + #[test] + fn client_roles_and_perms_become_individual_true_keys() { + // Symmetric with the subject pattern: `client.role.partner = true`. + // Lets policies write `require(client.role.partner)`. + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert_eq!(bag.get_bool("client.role.partner"), Some(true)); + assert_eq!(bag.get_bool("client.perm.call_tool"), Some(true)); + assert_eq!(bag.get_bool("client.role.nonexistent"), None); + } + + #[test] + fn client_scopes_audiences_teams_are_string_sets() { + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert!(bag.set_contains("client.authorized_scopes", "read")); + assert!(bag.set_contains("client.authorized_scopes", "write")); + assert!(bag.set_contains( + "client.authorized_audiences", + "https://api.example.com", + )); + assert!(bag.set_contains("client.teams", "acme")); + } + + #[test] + fn client_claims_flatten_nested_paths() { + // Claims are `HashMap` — nested objects must + // flatten through the same walker `custom.*` uses. Asserts the + // JSON-walker integration works for client just like custom. + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert_eq!(bag.get_string("client.claim.iss"), Some("auth.example.com")); + assert_eq!( + bag.get_int("client.claim.scope_meta.max_calls_per_min"), + Some(60), + ); + } + + #[test] + fn trust_level_custom_renders_verbatim() { + let mut client = agent_client(); + client.trust_level = ClientTrustLevel::Custom("partner-tier-A".into()); + let mut bag = AttributeBag::new(); + extract_client(&client, &mut bag); + assert_eq!(bag.get_string("client.trust_level"), Some("partner-tier-A")); + } + + // ----------------------------------------------------------------- + // Workload (extract_workload helper — both prefixes) + // ----------------------------------------------------------------- + + fn workload_fixture() -> WorkloadIdentity { + WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/svc/foo".into()), + trust_domain: Some("corp.com".into()), + attestor: Some("spire-agent".into()), + selectors: vec!["k8s:ns:foo".into(), "k8s:sa:foo-sa".into()], + client_id: Some("foo-svc".into()), + ..Default::default() + } + } + + #[test] + fn extract_workload_populates_under_caller_prefix() { + // The same WorkloadIdentity feeds two distinct bag namespaces + // depending on which slot it lives in. This test pins + // `caller_workload.*`; the next pins `this_workload.*`. + let mut bag = AttributeBag::new(); + extract_workload("caller_workload", &workload_fixture(), &mut bag); + assert_eq!( + bag.get_string("caller_workload.spiffe_id"), + Some("spiffe://corp.com/svc/foo"), + ); + assert_eq!( + bag.get_string("caller_workload.trust_domain"), + Some("corp.com"), + ); + assert!(bag.set_contains("caller_workload.selectors", "k8s:ns:foo")); + // And the `this_workload.*` namespace must stay empty in this + // case — caller-prefix call must not leak into the other slot. + assert_eq!(bag.get_string("this_workload.spiffe_id"), None); + } + + #[test] + fn extract_workload_populates_under_this_prefix() { + let mut bag = AttributeBag::new(); + extract_workload("this_workload", &workload_fixture(), &mut bag); + assert_eq!( + bag.get_string("this_workload.spiffe_id"), + Some("spiffe://corp.com/svc/foo"), + ); + assert_eq!(bag.get_string("this_workload.attestor"), Some("spire-agent")); + assert_eq!(bag.get_string("caller_workload.spiffe_id"), None); + } + + // ----------------------------------------------------------------- + // extract_security orchestrates all four identity slots + // ----------------------------------------------------------------- + + #[test] + fn extract_security_populates_all_four_identity_namespaces() { + // Single fixture exercising subject + client + caller_workload + + // this_workload. Documents that one SecurityExtension can carry + // all four principals on a single request and the bridge fans + // them out into disjoint namespaces. + let sec = SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }), + client: Some(agent_client()), + caller_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/inbound".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/gateway".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert_eq!(bag.get_string("subject.id"), Some("alice")); + assert_eq!(bag.get_string("client.client_id"), Some("agent-app")); + assert_eq!( + bag.get_string("caller_workload.spiffe_id"), + Some("spiffe://corp.com/inbound"), + ); + assert_eq!( + bag.get_string("this_workload.spiffe_id"), + Some("spiffe://corp.com/gateway"), + ); + } +} diff --git a/crates/apl-cmf/tests/end_to_end.rs b/crates/apl-cmf/tests/end_to_end.rs new file mode 100644 index 00000000..cdfa1a11 --- /dev/null +++ b/crates/apl-cmf/tests/end_to_end.rs @@ -0,0 +1,275 @@ +// Location: ./crates/apl-cmf/tests/end_to_end.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Full vertical slice: cpex-core extensions → apl-cmf bridge → apl-core +// evaluator on a YAML-compiled route. If this test breaks, the whole +// stack is misaligned (extension shape, bag vocabulary, or compiler). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use apl_cmf::BagBuilder; +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, + NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, + PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, +}; +use async_trait::async_trait; +use cpex_core::extensions::{ + DelegationExtension, DelegationHop, SecurityExtension, SubjectExtension, SubjectType, + WorkloadIdentity, +}; +use serde_json::json; + +// `evaluate_route` takes `&Arc` / `&Arc` +// so the call paths inside apl-core can `Arc::clone` an owned, 'static reference +// into each spawned branch (E3.2). All tests pass the same no-op stubs; wrap once. +fn pdp() -> Arc { + Arc::new(AllowPdp) +} +fn plugins() -> Arc { + Arc::new(NoPlugins) +} +fn delegations() -> Arc { + Arc::new(NoopDelegationInvoker) +} + +// HR route from unified-config-proposal.md §Example 1. +const HR_ROUTE_YAML: &str = r#" +routes: + get_employee: + args: + employee_id: "str" + policy: + - "require(authenticated)" + - "delegation.depth > 2: deny" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" +"#; + +// ---------- PDP / Plugin stubs ---------- + +struct AllowPdp; +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + } +} + +struct NoPlugins; +#[async_trait] +impl PluginInvoker for NoPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } +} + +// ---------- Realistic extension fixtures ---------- + +fn alice_hr() -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + subject_type: Some(SubjectType::User), + roles: HashSet::from(["hr".to_string()]), + permissions: HashSet::from(["view_ssn".to_string()]), + teams: HashSet::from(["compliance".to_string()]), + claims: HashMap::from([("iss".to_string(), "auth.corp".to_string())]), + }), + this_workload: Some(WorkloadIdentity { + client_id: Some("hr-tool".into()), + ..Default::default() + }), + auth_method: Some("jwt".into()), + ..Default::default() + } +} + +fn mallory_no_perm() -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("mallory@corp.com".into()), + subject_type: Some(SubjectType::User), + ..Default::default() + }), + auth_method: Some("jwt".into()), + ..Default::default() + } +} + +fn shallow_delegation() -> DelegationExtension { + let mut del = DelegationExtension { + origin_subject_id: Some("alice@corp.com".into()), + ..Default::default() + }; + del.append_hop(DelegationHop { + subject_id: "alice@corp.com".into(), + ..Default::default() + }); + del +} + +fn deep_delegation() -> DelegationExtension { + let mut del = DelegationExtension::default(); + for hop in ["a", "b", "c"] { + del.append_hop(DelegationHop { + subject_id: hop.into(), + ..Default::default() + }); + } + del +} + +// ---------- Tests ---------- + +#[tokio::test] +async fn alice_full_route_through_cmf_bridge() { + let mut bag = BagBuilder::new() + .with_security(&alice_hr()) + .with_delegation(&shallow_delegation()) + .with_route_key("get_employee") + .build(); + + // Sanity-check the bag came out the way we expect. + assert_eq!(bag.get_bool("authenticated"), Some(true)); + assert_eq!(bag.get_bool("role.hr"), Some(true)); + assert_eq!(bag.get_bool("perm.view_ssn"), Some(true)); + assert_eq!(bag.get_int("delegation.depth"), Some(1)); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ + "ssn": "555-12-3456", + "salary": 95000, + "employee_id": "123-45-6789", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + let result = payload.result.as_ref().unwrap(); + // view_ssn=true and role.hr=true → both fields kept; employee_id masked. + assert_eq!(result["ssn"], json!("555-12-3456")); + assert_eq!(result["salary"], json!(95000)); + assert_eq!(result["employee_id"], json!("*******6789")); +} + +#[tokio::test] +async fn mallory_gets_both_fields_redacted_through_cmf_bridge() { + let mut bag = BagBuilder::new() + .with_security(&mallory_no_perm()) + .with_delegation(&shallow_delegation()) + .build(); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "555-44-3333" }), + json!({ + "ssn": "111-22-3333", + "salary": 80000, + "employee_id": "555-44-3333", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + let result = payload.result.as_ref().unwrap(); + // Neither role.hr nor perm.view_ssn populated → both redact()s fire. + assert_eq!(result["ssn"], json!("[REDACTED]")); + assert_eq!(result["salary"], json!("[REDACTED]")); + assert_eq!(result["employee_id"], json!("*******3333")); +} + +#[tokio::test] +async fn deep_delegation_denies_through_cmf_bridge() { + let mut bag = BagBuilder::new() + .with_security(&alice_hr()) + .with_delegation(&deep_delegation()) // depth = 3 + .build(); + + assert_eq!(bag.get_int("delegation.depth"), Some(3)); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), + ); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); + // Result fields untouched — the result phase never ran. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("x")); +} + +#[tokio::test] +async fn args_attributes_flow_into_bag_for_policy_use() { + // Bridge args payload into the bag, then check that a policy + // predicate using `args.` evaluates against it. Uses an + // ad-hoc route, since the canonical HR route doesn't reference + // `args.*` in its policy block. + let yaml = r#" +routes: + guarded_route: + policy: + - "args.include_ssn == true: deny" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("guarded_route").unwrap(); + + let args = json!({ "include_ssn": true, "id": "abc" }); + let mut bag = BagBuilder::new() + .with_security(&alice_hr()) + .with_args(&args) + .build(); + assert_eq!(bag.get_bool("args.include_ssn"), Some(true)); + + let mut payload = RoutePayload::new(args); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy"), "got source {}", rule_source); + } + d => panic!("expected Deny on include_ssn, got {:?}", d), + } +} + +#[tokio::test] +async fn anonymous_user_denied_at_authenticated_check() { + // No security extension at all → no `authenticated` key in bag → + // `require(authenticated)` denies. + let mut bag = BagBuilder::new() + .with_delegation(&shallow_delegation()) + .build(); + assert!(!bag.contains("authenticated")); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), + ); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); +} diff --git a/crates/apl-core/Cargo.toml b/crates/apl-core/Cargo.toml new file mode 100644 index 00000000..b04b0951 --- /dev/null +++ b/crates/apl-core/Cargo.toml @@ -0,0 +1,32 @@ +# Location: ./crates/apl-core/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# APL core — predicate language, compiler, evaluator. +# Module structure follows docs/specs/apl-design.md §4. + +[package] +name = "apl-core" +description = "APL — Attribute Policy Language core (compiler + evaluator)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +# Plain rlib; APL is consumed by other workspace crates (apl-cmf, apl-cpex) +# and does not need cdylib for FFI. + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +regex = { workspace = true } +futures = { workspace = true } +cpex-orchestration = { path = "../cpex-orchestration" } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/apl-core/src/attributes.rs b/crates/apl-core/src/attributes.rs new file mode 100644 index 00000000..17bac0e0 --- /dev/null +++ b/crates/apl-core/src/attributes.rs @@ -0,0 +1,215 @@ +// Location: ./crates/apl-core/src/attributes.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AttributeBag — flat namespace for policy evaluation. +// +// The DSL evaluates predicates against a flat bag of named, typed values. +// Each attribute source (cpex-core extensions, route args, session context, +// custom plugin namespaces) drops keys into the bag through the +// `AttributeExtractor` trait. +// +// A flat bag (rather than nested object access) means the evaluator never +// has to know which extension a key came from — it just queries by name. +// New attribute sources are additive: implement `AttributeExtractor` for +// them and the evaluator picks them up unchanged. +// +// Mapping from cpex-core extensions into the bag lives in `apl-cmf`, not +// here. See docs/specs/apl-design.md §4 for the module layering. + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +/// A single attribute value the evaluator can compare against. +/// +/// The five variants cover every shape the DSL needs: +/// `Bool` for `authenticated` / `role.*` / `perm.*`, +/// `Int` for counts and depths, +/// `Float` for confidences and ages, +/// `String` for identifiers, +/// `StringSet` for set-membership operators (`contains`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AttributeValue { + Bool(bool), + Int(i64), + Float(f64), + String(String), + StringSet(HashSet), +} + +impl From for AttributeValue { + fn from(v: bool) -> Self { AttributeValue::Bool(v) } +} +impl From for AttributeValue { + fn from(v: i64) -> Self { AttributeValue::Int(v) } +} +impl From for AttributeValue { + fn from(v: f64) -> Self { AttributeValue::Float(v) } +} +impl From<&str> for AttributeValue { + fn from(v: &str) -> Self { AttributeValue::String(v.to_string()) } +} +impl From for AttributeValue { + fn from(v: String) -> Self { AttributeValue::String(v) } +} +impl From> for AttributeValue { + fn from(v: HashSet) -> Self { AttributeValue::StringSet(v) } +} + +/// Flat key→value namespace consumed by the evaluator. +/// +/// Populate via `set()` and/or `AttributeExtractor::extract()`; query via +/// the typed `get_*` methods. Once handed to the evaluator the bag is +/// read-only by convention (not enforced — `&mut` borrows are how you +/// build it up in the first place). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AttributeBag { + attrs: HashMap, +} + +impl AttributeBag { + pub fn new() -> Self { + Self { attrs: HashMap::new() } + } + + pub fn set(&mut self, key: impl Into, value: impl Into) { + self.attrs.insert(key.into(), value.into()); + } + + pub fn get(&self, key: &str) -> Option<&AttributeValue> { + self.attrs.get(key) + } + + pub fn contains(&self, key: &str) -> bool { + self.attrs.contains_key(key) + } + + pub fn get_bool(&self, key: &str) -> Option { + match self.get(key) { + Some(AttributeValue::Bool(v)) => Some(*v), + _ => None, + } + } + + pub fn get_int(&self, key: &str) -> Option { + match self.get(key) { + Some(AttributeValue::Int(v)) => Some(*v), + _ => None, + } + } + + pub fn get_float(&self, key: &str) -> Option { + match self.get(key) { + Some(AttributeValue::Float(v)) => Some(*v), + // Promote int → float so `depth > 2.5`-style predicates work + // when depth is stored as Int. + Some(AttributeValue::Int(v)) => Some(*v as f64), + _ => None, + } + } + + pub fn get_string(&self, key: &str) -> Option<&str> { + match self.get(key) { + Some(AttributeValue::String(v)) => Some(v.as_str()), + _ => None, + } + } + + pub fn get_string_set(&self, key: &str) -> Option<&HashSet> { + match self.get(key) { + Some(AttributeValue::StringSet(v)) => Some(v), + _ => None, + } + } + + /// DSL ` contains ` — false if the key is missing or not a set. + pub fn set_contains(&self, key: &str, value: &str) -> bool { + self.get_string_set(key) + .map(|set| set.contains(value)) + .unwrap_or(false) + } + + pub fn len(&self) -> usize { + self.attrs.len() + } + + pub fn is_empty(&self) -> bool { + self.attrs.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.attrs.iter().map(|(k, v)| (k.as_str(), v)) + } +} + +/// Source of attributes. Implementors drop keys into the bag under a +/// consistent namespace prefix: +/// +/// - cpex-core `SecurityExtension.subject` → `subject.*`, `role.*`, `perm.*` +/// - cpex-core `SecurityExtension.client` → `client.*` +/// - cpex-core `DelegationExtension` → `delegation.*`, `delegated` +/// - Route args → `args.*` +/// - Session context → `session.*` +/// +/// Implementations for the cpex-core extensions live in `apl-cmf`, not here. +pub trait AttributeExtractor { + fn extract(&self, bag: &mut AttributeBag); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_bag() { + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 2i64); + bag.set("subject.id", "alice@corp.com"); + bag.set("intent.confidence", 0.92f64); + + assert_eq!(bag.get_bool("authenticated"), Some(true)); + assert_eq!(bag.get_int("delegation.depth"), Some(2)); + assert_eq!(bag.get_string("subject.id"), Some("alice@corp.com")); + assert_eq!(bag.get_float("intent.confidence"), Some(0.92)); + } + + #[test] + fn int_to_float_promotion() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 2i64); + assert_eq!(bag.get_float("delegation.depth"), Some(2.0)); + } + + #[test] + fn string_set_contains() { + let mut bag = AttributeBag::new(); + bag.set( + "session.labels", + HashSet::from(["PII".to_string(), "financial".to_string()]), + ); + + assert!(bag.set_contains("session.labels", "PII")); + assert!(bag.set_contains("session.labels", "financial")); + assert!(!bag.set_contains("session.labels", "PHI")); + } + + #[test] + fn missing_keys() { + let bag = AttributeBag::new(); + assert_eq!(bag.get_bool("nonexistent"), None); + assert_eq!(bag.get_int("nonexistent"), None); + assert!(!bag.set_contains("nonexistent", "value")); + } + + #[test] + fn type_mismatch_returns_none() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + // Stored as String; asking for Bool returns None, not a coerced value. + assert_eq!(bag.get_bool("subject.id"), None); + assert_eq!(bag.get_int("subject.id"), None); + } +} diff --git a/crates/apl-core/src/evaluator.rs b/crates/apl-core/src/evaluator.rs new file mode 100644 index 00000000..aa5f80d5 --- /dev/null +++ b/crates/apl-core/src/evaluator.rs @@ -0,0 +1,2563 @@ +// Location: ./crates/apl-core/src/evaluator.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL evaluator — walks the IR against an AttributeBag and returns a Decision. +// +// The evaluator is sync and infallible by design. Missing attributes resolve +// to `false` (DSL spec §2.6); operator type mismatches resolve to `false`. +// The host drives the four phases separately by calling `evaluate_rules` once +// per declared phase — phase orchestration lives in `apl-cpex`. +// +// Semantics anchored in: +// - DSL spec apl-dsl-spec.md §2 (operators), §3 (actions), §8.1 (require) +// - apl-design.md §7 (native fast-path, sync inside async outer) + +use std::sync::Arc; + +use crate::attributes::{AttributeBag, AttributeValue}; +use crate::pipeline::{Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck}; +use crate::rules::{CompareOp, Condition, Effect, Expression, Literal, Rule}; +use crate::step::{PdpResolver, PluginInvocation, PluginInvoker}; + +/// Outcome of evaluating a phase's rule list. +#[derive(Debug, Clone, PartialEq)] +pub enum Decision { + /// No `deny` rule fired. Pipeline proceeds. + Allow, + /// A `deny` rule fired. Pipeline halts. + Deny { + reason: Option, + /// `Rule.source` of the rule that produced the deny — for audit logs. + rule_source: String, + }, +} + +/// Evaluate a phase's rules against the bag. +/// +/// Spec §3 semantics: +/// - First `deny` halts; subsequent rules / effects don't run. +/// - `allow` effects *do not* short-circuit — evaluation continues to +/// the next effect (then to the next rule). +/// - If no rule denies, the phase resolves to `Decision::Allow`. +/// +/// Sync fast path — only handles control effects (`Allow` / `Deny`). +/// Rules containing `Plugin` / `Delegate` / `Taint` effects must go +/// through [`evaluate_steps`] instead, which has the async invoker +/// traits wired up. This function silently skips non-control effects +/// so a rule list mixed with `Plugin` still terminates cleanly on a +/// later `Deny` — but the side effects don't fire. Caller's job to +/// pick the right entry point for the effects in the rules. +pub fn evaluate_rules(rules: &[Rule], bag: &AttributeBag) -> Decision { + for rule in rules { + if !eval_expression(&rule.condition, bag) { + continue; + } + for effect in &rule.effects { + match effect { + Effect::Allow => continue, + Effect::Deny { reason, code } => { + // `code` override on the effect takes precedence + // over the auto-generated rule source position, + // so author-stable categories survive YAML edits. + let rule_source = code + .clone() + .unwrap_or_else(|| rule.source.clone()); + return Decision::Deny { + reason: reason.clone(), + rule_source, + }; + } + // Plugin / Delegate / Taint require the async step + // path; ignore here. See doc comment above. + _ => continue, + } + } + } + Decision::Allow +} + +fn eval_expression(expr: &Expression, bag: &AttributeBag) -> bool { + match expr { + Expression::Condition(c) => eval_condition(c, bag), + Expression::And(parts) => parts.iter().all(|e| eval_expression(e, bag)), + Expression::Or(parts) => parts.iter().any(|e| eval_expression(e, bag)), + Expression::Not(inner) => !eval_expression(inner, bag), + Expression::Always => true, + } +} + +fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool { + match cond { + Condition::IsTrue { key } => bag.get_bool(key).unwrap_or(false), + Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false), + Condition::Exists { key } => bag.contains(key), + Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag), + Condition::InSet { value_key, set_key, negate } => { + let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) { + (Some(s), Some(set)) => set.contains(s), + _ => false, // missing key or wrong type → not in set + }; + if *negate { !in_set } else { in_set } + } + } +} + +fn eval_comparison(key: &str, op: CompareOp, lit: &Literal, bag: &AttributeBag) -> bool { + let attr = match bag.get(key) { + Some(v) => v, + None => return false, // missing → false (spec §2.6) + }; + + match op { + CompareOp::Contains => match (attr, lit) { + (AttributeValue::StringSet(_), Literal::String(s)) => bag.set_contains(key, s), + _ => false, + }, + CompareOp::Eq => values_eq(attr, lit), + CompareOp::NotEq => !values_eq(attr, lit), + CompareOp::Gt | CompareOp::GtEq | CompareOp::Lt | CompareOp::LtEq => { + numeric_compare(attr, lit, op) + } + } +} + +fn values_eq(attr: &AttributeValue, lit: &Literal) -> bool { + match (attr, lit) { + (AttributeValue::Bool(a), Literal::Bool(b)) => a == b, + (AttributeValue::Int(a), Literal::Int(b)) => a == b, + (AttributeValue::Float(a), Literal::Float(b)) => a == b, + (AttributeValue::String(a), Literal::String(b)) => a == b, + // Int↔Float promotion for equality (matches AttributeBag::get_float). + (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64) == *b, + (AttributeValue::Float(a), Literal::Int(b)) => *a == (*b as f64), + _ => false, + } +} + +fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool { + let (a, b) = match (attr, lit) { + (AttributeValue::Int(a), Literal::Int(b)) => (*a as f64, *b as f64), + (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64, *b), + (AttributeValue::Float(a), Literal::Int(b)) => (*a, *b as f64), + (AttributeValue::Float(a), Literal::Float(b)) => (*a, *b), + // Non-numeric operands: order operators don't apply → false (spec §2.3). + _ => return false, + }; + match op { + CompareOp::Gt => a > b, + CompareOp::GtEq => a >= b, + CompareOp::Lt => a < b, + CompareOp::LtEq => a <= b, + _ => unreachable!("numeric_compare called with non-numeric op"), + } +} + +// ===================================================================== +// Async effect evaluator (policy: / post_policy: walks Vec) +// ===================================================================== + +/// Walk an Effect list against the bag, dispatching PDP calls via `pdp` +/// and plugin invocations via `plugins`. Returns the phase's overall +/// decision. +/// +/// Semantics (DSL §3, §7.5): +/// - `Effect::When` — evaluate the condition; if true, run the body in +/// order with the same first-deny-wins logic. +/// - `Effect::Pdp` — call resolver; on Allow run `on_allow` reactions and +/// continue; on Deny run `on_deny` reactions and return the deny +/// (reactions can override with their own deny, but cannot turn a deny +/// into an allow). +/// - `Effect::Plugin` — invoke; Allow continues, Deny returns. +/// - `Effect::Delegate` — mint downstream credential; writes +/// `delegation.granted.*` keys back into the bag; deny-on-failure unless +/// the step's `on_error` overrides. +/// - `Effect::Taint` — record the label; never halts. +/// - `Effect::FieldOp` — apply a pipe chain to `args.X` / `result.X`; +/// may set `args_modified` / `result_modified`. +/// - `Effect::Sequential` — run children in order, halt on first Deny. +/// - `Effect::Parallel` — run children concurrently, abort on first Deny. +/// - `Effect::Allow` — explicit no-op; continues the phase. +/// - `Effect::Deny` — halt with the supplied reason/code. +/// +/// PDP / plugin errors map to a Deny with the error in the reason, per +/// the design's fail-closed default (DSL §8.9). Pre-E4 `evaluate_steps` +/// is preserved as a deprecated alias that forwards here. +#[allow(clippy::too_many_arguments)] +pub async fn evaluate_effects( + effects: &[Effect], + bag: &mut AttributeBag, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, + phase: crate::step::DispatchPhase, + payload: &mut crate::route::RoutePayload, +) -> StepsEvaluation { + let mut taints: Vec = Vec::new(); + let mut args_modified = false; + let mut result_modified = false; + for effect in effects { + // Each top-level effect runs against the shared mutable state. + // `Effect::When` / `Effect::Pdp` handle their own internal + // walking via dispatch_effect's recursive call. + let fallback_source = match effect { + Effect::When { source, .. } => source.as_str(), + _ => "", + }; + match Box::pin(dispatch_effect( + effect, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + &mut taints, + &mut args_modified, + &mut result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => {} + EffectOutcome::Halt(decision) => { + return StepsEvaluation::deny(decision, taints, args_modified, result_modified); + } + } + } + StepsEvaluation { + decision: Decision::Allow, + taints, + args_modified, + result_modified, + } +} + +/// Outcome of `evaluate_effects`: the phase's decision plus taints emitted +/// by any plugin steps that ran. Taints are accumulated even when the +/// phase ultimately denies — audit needs to see what the plugins +/// reported before the deny landed. Empty `taints` is the common case +/// (most steps are predicates / PDP calls, not label emitters). +/// +/// `args_modified` / `result_modified` are set when an `Effect::FieldOp` +/// inside a `do:` body successfully mutated the route payload — the +/// orchestrator uses them to OR-into the route-level "did anything +/// change" signals so the host knows to re-serialize the body. +#[derive(Debug, Clone)] +pub struct StepsEvaluation { + pub decision: Decision, + pub taints: Vec, + pub args_modified: bool, + pub result_modified: bool, +} + +impl StepsEvaluation { + fn deny( + d: Decision, + taints: Vec, + args_modified: bool, + result_modified: bool, + ) -> Self { + Self { + decision: d, + taints, + args_modified, + result_modified, + } + } +} + +/// Outcome of dispatching one effect. Internal control-flow signal — +/// never serialized, never exposed in the IR. Sits between the per- +/// effect dispatch (When / Pdp / Plugin / Delegate / Taint / Allow / +/// Deny / FieldOp / Sequential / Parallel) and the caller's "do I keep +/// walking the effects list or halt?" loop. +enum EffectOutcome { + /// Effect completed without producing a Deny — caller moves on to + /// the next effect in the surrounding list. + Continue, + /// Effect produced a Deny decision — caller halts the rest of the + /// surrounding list, the rest of the phase, and the route. + Halt(Decision), +} + +/// Run a single effect against the evaluator's state. Called by both +/// `evaluate_effects` (top-level walk of `policy:` / `post_policy:`) +/// and by recursive arms (Sequential, Parallel, When body, Pdp +/// reactions), so there's exactly one place that knows how each +/// effect kind dispatches. +/// +/// `fallback_source` is the rule-source-position string used as the +/// `rule_source` field on a `Decision::Deny` when the effect itself +/// doesn't carry an explicit code (i.e. `Effect::Deny { code: None }`, +/// or a deny coming back from a plugin / delegator without overriding +/// the default). +#[allow(clippy::too_many_arguments)] +async fn dispatch_effect( + effect: &Effect, + fallback_source: &str, + bag: &mut AttributeBag, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, + phase: crate::step::DispatchPhase, + taints: &mut Vec, + args_modified: &mut bool, + result_modified: &mut bool, + payload: &mut crate::route::RoutePayload, +) -> EffectOutcome { + match effect { + Effect::Allow => EffectOutcome::Continue, + + Effect::Deny { reason, code } => { + // Author-supplied code overrides the auto-generated source + // position. Lets MCP clients dispatch on stable categories + // (`quota.exceeded`) rather than positional codes that + // shift with YAML edits. + let rule_source = code + .clone() + .unwrap_or_else(|| fallback_source.to_string()); + EffectOutcome::Halt(Decision::Deny { + reason: reason.clone(), + rule_source, + }) + } + + Effect::Plugin { name } => { + match plugins + .invoke(name, bag, PluginInvocation::Step { phase }) + .await + { + Ok(outcome) => { + // Plugins can emit taints regardless of decision — + // collect first, then act on the decision. + taints.extend(outcome.taints); + match outcome.decision { + Decision::Allow => EffectOutcome::Continue, + deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny), + } + } + Err(e) => EffectOutcome::Halt(Decision::Deny { + reason: Some(format!("plugin `{}` error: {}", name, e)), + rule_source: format!("plugin:{}", name), + }), + } + } + + Effect::Delegate(delegate_step) => { + match delegations.delegate(delegate_step).await { + Ok(outcome) => match &outcome.decision { + Decision::Allow => { + // Surface granted_* keys into the bag so + // downstream rules in this same step list can + // read them (`require(delegation.granted.permissions + // contains "X")`, etc.). + use crate::attributes::AttributeValue; + use crate::step::delegation_bag_keys as bk; + + bag.set(bk::GRANTED, AttributeValue::Bool(true)); + if !outcome.granted_permissions.is_empty() { + let set: std::collections::HashSet = + outcome.granted_permissions.iter().cloned().collect(); + bag.set( + bk::GRANTED_PERMISSIONS, + AttributeValue::StringSet(set), + ); + } + if let Some(aud) = &outcome.granted_audience { + bag.set(bk::GRANTED_AUDIENCE, aud.clone()); + } + if let Some(exp) = &outcome.granted_expires_at { + bag.set(bk::GRANTED_EXPIRES_AT, exp.clone()); + } + EffectOutcome::Continue + } + Decision::Deny { .. } => { + // Apply the step's on_error policy. Default + // ("deny") halts; "continue" lets the pipeline + // keep going so subsequent rules can branch on + // the absent `delegation.granted` flag. + let on_error = delegate_step + .on_error + .as_deref() + .unwrap_or("deny") + .to_ascii_lowercase(); + if on_error == "continue" { + EffectOutcome::Continue + } else { + EffectOutcome::Halt(outcome.decision) + } + } + }, + Err(e) => { + // Transport / lookup failure. on_error treats this + // the same way as a plugin-side deny. + let on_error = delegate_step + .on_error + .as_deref() + .unwrap_or("deny") + .to_ascii_lowercase(); + if on_error == "continue" { + EffectOutcome::Continue + } else { + EffectOutcome::Halt(Decision::Deny { + reason: Some(format!( + "delegate `{}` error: {}", + delegate_step.plugin_name, e + )), + rule_source: delegate_step.source.clone(), + }) + } + } + } + } + + Effect::Taint { label, scopes } => { + // Emit the taint into the phase's accumulator so it flows + // into `RouteDecision.taints`. Apl-cpex's invoker handles + // the session-store persistence side at request end — here + // we only record the event. Scopes come straight from the + // parser (`taint(label, session, message)` syntax). + taints.push(crate::pipeline::TaintEvent { + label: label.clone(), + scopes: scopes.clone(), + }); + EffectOutcome::Continue + } + + Effect::FieldOp { path, stages } => { + dispatch_field_op( + path, + stages, + fallback_source, + bag, + plugins, + phase, + taints, + args_modified, + result_modified, + payload, + ) + .await + } + + Effect::Sequential(effects) => { + // Semantically the same as inlining the list into the + // enclosing scope — walk in order, stop on first Halt. + // The variant exists for explicit grouping and to pair + // with `Parallel` in the IR. + for inner in effects { + match Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + EffectOutcome::Continue + } + + Effect::Parallel(effects) => { + // `dispatch_parallel` returns an explicit `BoxFuture<'_, _>` + // (Send by construction) so the recursive + // dispatch_effect → dispatch_parallel → dispatch_effect + // chain doesn't trip the compiler's Send-inference cycle. + dispatch_parallel( + effects, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + payload, + ) + .await + } + + Effect::When { condition, body, source } => { + // Predicate-gated body — replaces the historical + // `Step::Rule`. Skip silently when the condition is false; + // otherwise walk the body in order and halt on first Deny. + if !eval_expression(condition, bag) { + return EffectOutcome::Continue; + } + for inner in body { + match Box::pin(dispatch_effect( + inner, + source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + EffectOutcome::Continue + } + + Effect::Pdp { call, on_allow, on_deny } => { + // External PDP call — replaces `Step::Pdp`. Reactions run + // through the same dispatch_effect path (recursively). + match pdp.evaluate(call, bag).await { + Ok(pdp_result) => match pdp_result.decision { + Decision::Allow => { + // Walk on_allow; if it ends without a Halt the + // PDP allow stands and we continue. + for inner in on_allow { + match Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + EffectOutcome::Continue + } + deny @ Decision::Deny { .. } => { + // Reactions can override the PDP's deny reason + // (e.g. `on_deny: [deny "..."]`) but cannot + // upgrade the deny to allow — if reactions + // walked clean, the PDP's original deny stands. + for inner in on_deny { + if let EffectOutcome::Halt(reaction_decision) = + Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + return EffectOutcome::Halt(reaction_decision); + } + } + EffectOutcome::Halt(deny) + } + }, + Err(e) => EffectOutcome::Halt(Decision::Deny { + reason: Some(format!("PDP error: {}", e)), + rule_source: format!("pdp:{:?}", call.dialect), + }), + } + } + } +} + +/// Run a list of effects concurrently. Each branch gets its own +/// cloned bag and payload — mutations inside a branch don't +/// propagate back to the shared outer state. Taints from every +/// branch are merged into the outer `taints` vec (taints are +/// append-only event logs, safe to concatenate). First Halt by +/// branch index wins; the remaining branches are aborted via +/// `cpex_orchestration::run_branches`'s `short_circuit_on_deny`. +/// +/// Config-load already rejected `FieldOp` / `Delegate` here via +/// [`Effect::validate_parallel_purity`], so at runtime we trust the +/// IR not to contain mutation effects. +/// +/// # Concurrency model (E3.2) +/// +/// Built on [`cpex_orchestration::run_branches`] — the same JoinSet +/// + abort-on-deny primitive `cpex-core`'s executor uses for its +/// concurrent phase. Each branch is `tokio::spawn`ed onto the +/// runtime, so branches get true OS-thread parallelism (vs. the v1 +/// implementation's `join_all`, which only interleaved on one +/// task). To meet the `'static + Send` bounds for spawning, the +/// invoker references are `&Arc` — we `Arc::clone` an +/// owned reference into each branch closure. +/// +/// Note: no per-branch timeout. The DSL doesn't expose one, and +/// plugin-level timeouts upstream of this call (in cpex-core's +/// executor) bound individual plugin invocations. If a route ever +/// needs a per-branch budget the orchestration crate already +/// supports `BranchConfig::timeout_per_branch` — wire it through a +/// `Effect::Parallel` extension if/when needed. +// Returns an explicit `BoxFuture` rather than `impl Future` so the +// caller (`dispatch_effect`'s `Effect::Parallel` arm, which is itself +// `async fn`) can break the Send-inference cycle this would otherwise +// introduce: dispatch_effect's opaque return type would depend on +// dispatch_parallel's, and dispatch_parallel spawns futures that +// recursively re-enter dispatch_effect. A concrete `BoxFuture` is +// `Pin>` — already Send by construction, +// no inference required. +fn dispatch_parallel<'a>( + effects: &'a [Effect], + fallback_source: &'a str, + bag: &'a AttributeBag, + pdp: &'a Arc, + plugins: &'a Arc, + delegations: &'a Arc, + phase: crate::step::DispatchPhase, + taints: &'a mut Vec, + payload: &'a crate::route::RoutePayload, +) -> futures::future::BoxFuture<'a, EffectOutcome> { + Box::pin(async move { + use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch}; + + if effects.is_empty() { + return EffectOutcome::Continue; + } + + // Build one spawn-ready branch future per effect. Each branch + // owns: + // * a cloned bag and payload — branch mutations stay local; + // * cloned Arcs to the invokers — `'static + Send`, ready for + // `tokio::spawn`; + // * an owned copy of the effect to evaluate (clone is cheap + // for the variants `Parallel` can hold: Allow, Deny, Plugin, + // Taint, Sequential, Parallel, When, Pdp). + let mut branches: Vec)>> = + Vec::with_capacity(effects.len()); + for effect in effects.iter() { + let effect = effect.clone(); + let fallback = fallback_source.to_string(); + let mut branch_bag = bag.clone(); + let mut branch_payload = payload.clone(); + let pdp = Arc::clone(pdp); + let plugins = Arc::clone(plugins); + let delegations = Arc::clone(delegations); + branches.push(Box::pin(async move { + let mut branch_taints: Vec = Vec::new(); + let mut branch_args_modified = false; + let mut branch_result_modified = false; + let outcome = Box::pin(dispatch_effect( + &effect, + &fallback, + &mut branch_bag, + &pdp, + &plugins, + &delegations, + phase, + &mut branch_taints, + &mut branch_args_modified, + &mut branch_result_modified, + &mut branch_payload, + )) + .await; + (outcome, branch_taints) + })); + } + + // `is_deny` short-circuits the moment any branch returns + // `EffectOutcome::Halt(_)`. The remaining branches get + // `BranchOutcome::Aborted` and we drop their (already-cancelled) + // futures. Taints from already-completed branches still land. + let cfg = BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: true, + }; + let outcomes = run_branches( + branches, + cfg, + |v: &(EffectOutcome, Vec)| { + matches!(v.0, EffectOutcome::Halt(_)) + }, + ) + .await; + + // Aggregate in input order: append every branch's taints; pick + // the first Halt (by branch index, not wall-clock order) as the + // overall result. Aborted / panicked branches contribute no + // taints — they didn't run to completion. A panicked branch is + // *not* converted into a Halt; we log via `tracing::warn!` and + // continue. (A misbehaving plugin shouldn't take down the + // parallel block any more than it would the host process.) + let mut first_halt: Option = None; + for (idx, outcome) in outcomes.into_iter().enumerate() { + match outcome { + BranchOutcome::Completed((effect_outcome, branch_taints)) => { + taints.extend(branch_taints); + if first_halt.is_none() { + if let EffectOutcome::Halt(d) = effect_outcome { + first_halt = Some(d); + } + } + } + BranchOutcome::Aborted => { + // Short-circuit cancelled this branch — intentional, + // no diagnostic needed. + } + BranchOutcome::TimedOut => { + // Unreachable today (no per-branch timeout + // configured). Treat as a no-op if it ever fires + // post-config-extension. + } + BranchOutcome::Panicked(msg) => { + // A panicking branch is a misbehaving plugin/effect; + // dropping its output (no Halt, no taints) keeps the + // parallel block's other branches intact rather than + // taking the whole block down. apl-core has no + // tracing dep — host integrations that care can + // surface the panic via cpex-core's plugin error + // path. `idx`/`msg` are eaten here. + let _ = (idx, msg); + } + } + } + + match first_halt { + Some(d) => EffectOutcome::Halt(d), + None => EffectOutcome::Continue, + } + }) +} + +/// Apply a `FieldOp` effect — resolve the path in args/result, run +/// the pipeline stages, write the outcome back into the payload. +/// +/// Out-of-phase ops are silent no-ops: a Pre-phase rule with +/// `result.X | redact` skips because the result hasn't been produced +/// yet; a Post-phase rule with `args.X | redact` skips because the +/// args were already sent on the wire. This is intentional so the +/// same `when:`/`do:` rule body can be reused across phases without +/// the author needing to branch on phase. +/// +/// Missing fields skip silently too (same as the args:/result: phase +/// pipelines) — a pipeline can't transform what isn't there. If the +/// author needs presence semantics, that's a `require(exists(args.X))` +/// upstream of the `do:` body. +#[allow(clippy::too_many_arguments)] +async fn dispatch_field_op( + path: &str, + stages: &[crate::pipeline::Stage], + fallback_source: &str, + bag: &mut AttributeBag, + plugins: &Arc, + phase: crate::step::DispatchPhase, + taints: &mut Vec, + args_modified: &mut bool, + result_modified: &mut bool, + payload: &mut crate::route::RoutePayload, +) -> EffectOutcome { + use crate::route::{get_dotted, remove_dotted, set_dotted}; + use crate::step::DispatchPhase; + + // Pick the right side of the payload based on the path prefix. + // Out-of-phase ops drop silently (see the doc comment). + enum Side { Args, Result } + let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") { + if !matches!(phase, DispatchPhase::Pre) { + return EffectOutcome::Continue; + } + (&mut payload.args, rest, Side::Args) + } else if let Some(rest) = path.strip_prefix("result.") { + if !matches!(phase, DispatchPhase::Post) { + return EffectOutcome::Continue; + } + let Some(result) = payload.result.as_mut() else { + return EffectOutcome::Continue; + }; + (result, rest, Side::Result) + } else { + return EffectOutcome::Halt(Decision::Deny { + reason: Some(format!( + "FieldOp path `{}` must start with `args.` or `result.`", + path + )), + rule_source: fallback_source.to_string(), + }); + }; + + let Some(current) = get_dotted(root, subpath).cloned() else { + return EffectOutcome::Continue; // missing field → silent no-op + }; + + let pipeline = crate::pipeline::Pipeline { stages: stages.to_vec() }; + let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, path, phase).await; + taints.extend(eval.taints); + let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side { + Side::Args => *args = true, + Side::Result => *result = true, + }; + match eval.outcome { + FieldOutcome::Pass => EffectOutcome::Continue, + FieldOutcome::Replace(new_val) => { + if set_dotted(root, subpath, new_val) { + mark_modified(side, args_modified, result_modified); + } + EffectOutcome::Continue + } + FieldOutcome::Omit => { + if remove_dotted(root, subpath) { + mark_modified(side, args_modified, result_modified); + } + EffectOutcome::Continue + } + FieldOutcome::Deny { reason, stage_index: _ } => EffectOutcome::Halt(Decision::Deny { + reason: Some(reason), + rule_source: fallback_source.to_string(), + }), + } +} + +// ===================================================================== +// Pipe-chain evaluator (args: / result: field pipelines) +// ===================================================================== + +/// Result of running a pipeline against one field's value. +/// +/// `Pass`: every stage succeeded; the original value should be kept. +/// `Replace`: a transform produced a new value (also covers conditional +/// `redact` firing). +/// `Omit`: an `omit` stage fired; the field should be dropped from output. +/// `Deny`: a validator failed; pipeline halted; the route should deny. +#[derive(Debug, Clone, PartialEq)] +pub enum FieldOutcome { + Pass, + Replace(serde_json::Value), + Omit, + Deny { reason: String, stage_index: usize }, +} + +/// Full result of a pipeline run: value-level outcome plus accumulated +/// taint side effects. +/// +/// `taint(...)` stages, plugin invocations, and `scan(...)` stages can all +/// emit taints; the evaluator collects them here and hands them to the host +/// (apl-cpex) for SessionStore writes. Taints accumulate even on `Replace` +/// and `Omit` outcomes; they do not accumulate past a `Deny` (the pipeline +/// halts at the failing stage). +#[derive(Debug, Clone, PartialEq)] +pub struct PipelineEvaluation { + pub outcome: FieldOutcome, + pub taints: Vec, +} + +/// Walk a pipeline against `value` and the bag, applying stages left-to-right. +/// +/// Async because pipe-chain `plugin(name)` stages dispatch through +/// `PluginInvoker`, which is async. +/// +/// `field_name` is the field this pipeline is attached to (from the wrapping +/// `FieldRule`). It's threaded into `PluginInvocation::Field` when a +/// `Stage::Plugin` fires so the invoker knows which field is in focus. +/// Pass `""` for standalone pipeline runs that aren't part of a field rule. +/// +/// `Stage::Validate { name }` is currently a no-op with a TODO — the named +/// validator registry lands in a later step. +pub async fn evaluate_pipeline( + pipeline: &Pipeline, + value: &serde_json::Value, + bag: &AttributeBag, + plugins: &Arc, + field_name: &str, + phase: crate::step::DispatchPhase, +) -> PipelineEvaluation { + let mut current = value.clone(); + let mut replaced = false; + let mut taints: Vec = Vec::new(); + + for (idx, stage) in pipeline.stages.iter().enumerate() { + match stage { + // ----- Validators ----- + Stage::Type(tc) => { + if !type_check(tc, ¤t) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("expected {:?}, got {}", tc, value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Length { min, max } => { + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("len(...) requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + let len = s.chars().count(); + if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("length {} outside [{:?}, {:?}]", len, min, max), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Range { min, max } => { + let Some(n) = current.as_i64() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("range requires integer value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("value {} outside [{:?}, {:?}]", n, min, max), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Enum { values } => { + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("enum(...) requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + if !values.iter().any(|v| v == s) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("value `{}` not in enum {:?}", s, values), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Regex { pattern } => { + // Compile-at-eval for now. A future step can swap to a + // route-level pre-compile cache keyed by pattern. + let re = match regex::Regex::new(pattern) { + Ok(r) => r, + Err(e) => { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("invalid regex `{}`: {}", pattern, e), + stage_index: idx, + }, + taints, + }; + } + }; + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("regex requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + if !re.is_match(s) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("value did not match regex `{}`", pattern), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Validate { name } => { + // Named-validator dispatch is not implemented in this + // build. The parser rejects `validate(...)` at compile + // time (parser.rs); this branch covers IR built + // programmatically bypassing the parser. Same shape + // as the parser's diagnostic — operators reach for + // `regex(...)` or `plugin(...)` instead. + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!( + "`validate({})` is not implemented; use `regex(...)` \ + or `plugin({})` instead", + name, name, + ), + stage_index: idx, + }, + taints, + }; + } + + // ----- Transforms ----- + Stage::Mask { keep_last } => { + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("mask(...) requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + let chars: Vec = s.chars().collect(); + let keep = (*keep_last).min(chars.len()); + let mask_count = chars.len() - keep; + let masked: String = std::iter::repeat('*').take(mask_count) + .chain(chars.into_iter().skip(mask_count)) + .collect(); + current = serde_json::Value::String(masked); + replaced = true; + } + Stage::Redact { condition } => { + let should_redact = match condition { + None => true, + Some(expr) => eval_expression(expr, bag), + }; + if should_redact { + current = serde_json::Value::String("[REDACTED]".into()); + replaced = true; + } + } + Stage::Omit => { + return PipelineEvaluation { outcome: FieldOutcome::Omit, taints }; + } + Stage::Hash => { + // Simple deterministic digest — DefaultHasher is fine for + // de-identification (not for cryptographic use). + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + value_for_hash(¤t).hash(&mut h); + current = serde_json::Value::String(format!("hash:{:016x}", h.finish())); + replaced = true; + } + + // ----- Effects ----- + Stage::Taint { label, scopes } => { + taints.push(TaintEvent { label: label.clone(), scopes: scopes.clone() }); + } + Stage::Plugin { name } => { + let invocation = PluginInvocation::Field { + name: field_name, + value: ¤t, + phase, + }; + match plugins.invoke(name, bag, invocation).await { + Ok(outcome) => { + // Plugins can emit taints regardless of decision. + taints.extend(outcome.taints); + match outcome.decision { + Decision::Allow => { + if let Some(new_value) = outcome.modified_value { + current = new_value; + replaced = true; + } + } + Decision::Deny { reason, rule_source: _ } => { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: reason.unwrap_or_else( + || format!("plugin `{}` denied", name), + ), + stage_index: idx, + }, + taints, + }; + } + } + } + Err(e) => { + // Fail-closed: plugin dispatch failure halts the pipeline. + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("plugin `{}` error: {}", name, e), + stage_index: idx, + }, + taints, + }; + } + } + } + Stage::Scan { kind } => { + // Spec mapping (apl-dsl-spec §4): scan stages are taint + // emitters. The actual PII detection / injection signal + // lives in plugin(...) variants of the same scanners; this + // stage just records the label so downstream policies can + // gate on it. `pii.redact` additionally rewrites the value. + let (label, redact): (&str, bool) = match kind { + ScanKind::PiiDetect => ("PII", false), + ScanKind::PiiRedact => ("PII", true), + ScanKind::InjectionScan => ("injection", false), + }; + taints.push(TaintEvent { + label: label.to_string(), + scopes: vec![TaintScope::Session], + }); + if redact { + current = serde_json::Value::String("[REDACTED]".into()); + replaced = true; + } + } + } + } + + let outcome = if replaced { + FieldOutcome::Replace(current) + } else { + FieldOutcome::Pass + }; + PipelineEvaluation { outcome, taints } +} + + +fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool { + match tc { + TypeCheck::Str => v.is_string(), + TypeCheck::Int => v.is_i64(), + TypeCheck::Bool => v.is_boolean(), + TypeCheck::Float => v.is_f64() || v.is_i64(), + TypeCheck::Email => v.as_str().map_or(false, |s| s.contains('@') && s.contains('.')), + TypeCheck::Url => v.as_str().map_or(false, |s| s.starts_with("http://") || s.starts_with("https://")), + TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape), + } +} + +fn is_uuid_shape(s: &str) -> bool { + // 8-4-4-4-12 hex with `-` separators. + let bytes = s.as_bytes(); + if bytes.len() != 36 { return false; } + for (i, &b) in bytes.iter().enumerate() { + match i { + 8 | 13 | 18 | 23 => if b != b'-' { return false; }, + _ => if !b.is_ascii_hexdigit() { return false; }, + } + } + true +} + +fn value_kind(v: &serde_json::Value) -> &'static str { + match v { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(n) if n.is_i64() => "int", + serde_json::Value::Number(_) => "float", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + +/// Stable byte representation of a value for hashing — serde_json's +/// `to_string` is canonical enough for our use. +fn value_for_hash(v: &serde_json::Value) -> String { + serde_json::to_string(v).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rules::{ Condition, Expression, Rule}; + use crate::step::{DelegationInvoker, NoopDelegationInvoker}; + use std::collections::HashSet; + use std::sync::Arc; + + fn rule(condition: Expression, effect: Effect, source: &str) -> Rule { + Rule::single(condition, effect, source) + } + + // Wrap stateless test invokers in `Arc` once per call. The + // public evaluator API takes `&Arc` so internal + // dispatch (notably `Effect::Parallel`) can `Arc::clone` an owned, + // 'static reference into each spawned branch (slice E3.2). + fn null_pipe_plugins() -> Arc { + Arc::new(NullPipelinePlugins) + } + fn null_plugins() -> Arc { + Arc::new(NullPlugins) + } + fn noop_delegations() -> Arc { + Arc::new(NoopDelegationInvoker) + } + + fn deny(reason: &str) -> Effect { + Effect::Deny { reason: Some(reason.into()), code: None } + } + + fn cond(c: Condition) -> Expression { + Expression::Condition(c) + } + + // ----- Decision-level semantics ----- + + #[test] + fn empty_rules_allow() { + let mut bag = AttributeBag::new(); + assert_eq!(evaluate_rules(&[], &bag), Decision::Allow); + } + + #[test] + fn first_deny_halts() { + let mut bag = AttributeBag::new(); + bag.set("a", true); + bag.set("b", true); + + let rules = vec![ + rule(cond(Condition::IsTrue { key: "a".into() }), deny("first"), "r0"), + rule(cond(Condition::IsTrue { key: "b".into() }), deny("second"), "r1"), + ]; + + match evaluate_rules(&rules, &bag) { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("first")); + assert_eq!(rule_source, "r0"); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + #[test] + fn allow_does_not_short_circuit() { + // Spec §3: explicit allow continues evaluation. A later deny still fires. + let mut bag = AttributeBag::new(); + bag.set("ok", true); + bag.set("bad", true); + + let rules = vec![ + rule(cond(Condition::IsTrue { key: "ok".into() }), Effect::Allow, "r0_allow"), + rule(cond(Condition::IsTrue { key: "bad".into() }), deny("later"), "r1_deny"), + ]; + + match evaluate_rules(&rules, &bag) { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"), + d => panic!("allow short-circuited; expected later deny, got {:?}", d), + } + } + + #[test] + fn unmatched_rules_dont_fire() { + let mut bag = AttributeBag::new(); // "denied" missing → false + let rules = vec![rule( + cond(Condition::IsTrue { key: "denied".into() }), + deny("shouldn't fire"), + "r0", + )]; + assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow); + } + + // ----- Predicate semantics ----- + + #[test] + fn missing_key_is_false() { + let mut bag = AttributeBag::new(); + assert!(!eval_condition(&Condition::IsTrue { key: "missing".into() }, &bag)); + assert!(eval_condition(&Condition::IsFalse { key: "missing".into() }, &bag)); + // Comparison on missing → false (spec §2.6). + assert!(!eval_condition( + &Condition::Comparison { + key: "missing".into(), + op: CompareOp::Eq, + value: 1_i64.into(), + }, + &bag, + )); + } + + #[test] + fn and_or_not_combinators() { + let mut bag = AttributeBag::new(); + bag.set("a", true); + bag.set("b", false); + + let a = cond(Condition::IsTrue { key: "a".into() }); + let b = cond(Condition::IsTrue { key: "b".into() }); + + assert!(eval_expression(&Expression::And(vec![a.clone(), a.clone()]), &bag)); + assert!(!eval_expression(&Expression::And(vec![a.clone(), b.clone()]), &bag)); + assert!(eval_expression(&Expression::Or(vec![a.clone(), b.clone()]), &bag)); + assert!(!eval_expression(&Expression::Or(vec![b.clone(), b.clone()]), &bag)); + assert!(eval_expression(&Expression::Not(Box::new(b)), &bag)); + } + + // ----- Comparison operators ----- + + #[test] + fn int_comparisons() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 3_i64); + + let cmp = |op| Condition::Comparison { + key: "delegation.depth".into(), + op, + value: 2_i64.into(), + }; + assert!(eval_condition(&cmp(CompareOp::Gt), &bag)); + assert!(eval_condition(&cmp(CompareOp::GtEq), &bag)); + assert!(!eval_condition(&cmp(CompareOp::Lt), &bag)); + assert!(!eval_condition(&cmp(CompareOp::Eq), &bag)); + assert!(eval_condition(&cmp(CompareOp::NotEq), &bag)); + } + + #[test] + fn int_to_float_promotion_in_comparison() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 2_i64); + // `delegation.depth > 2.5` — int promotes to float for the compare. + assert!(!eval_condition( + &Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: 2.5_f64.into(), + }, + &bag, + )); + assert!(eval_condition( + &Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Lt, + value: 2.5_f64.into(), + }, + &bag, + )); + } + + #[test] + fn string_equality_no_ordering() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + + assert!(eval_condition( + &Condition::Comparison { + key: "subject.id".into(), + op: CompareOp::Eq, + value: "alice".into(), + }, + &bag, + )); + // Order operators on strings → false (spec §2.3). + assert!(!eval_condition( + &Condition::Comparison { + key: "subject.id".into(), + op: CompareOp::Gt, + value: "alice".into(), + }, + &bag, + )); + } + + #[test] + fn contains_set_membership() { + let mut bag = AttributeBag::new(); + bag.set( + "session.labels", + HashSet::from(["PII".to_string(), "financial".to_string()]), + ); + + assert!(eval_condition( + &Condition::Comparison { + key: "session.labels".into(), + op: CompareOp::Contains, + value: "PII".into(), + }, + &bag, + )); + assert!(!eval_condition( + &Condition::Comparison { + key: "session.labels".into(), + op: CompareOp::Contains, + value: "PHI".into(), + }, + &bag, + )); + // Contains on a non-set attribute → false. + bag.set("subject.id", "alice"); + assert!(!eval_condition( + &Condition::Comparison { + key: "subject.id".into(), + op: CompareOp::Contains, + value: "alice".into(), + }, + &bag, + )); + } + + // ----- Realistic end-to-end ----- + + #[test] + fn hr_compensation_scenario() { + // From the HR demo: alice (hr role + view_ssn perm) requests compensation + // with delegation.depth = 1. Rules: + // 1. require(authenticated) + // 2. require(role.hr | role.finance) + // 3. delegation.depth > 2 & include_ssn: deny + // 4. !perm.view_ssn & include_ssn: deny + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("perm.view_ssn", true); + bag.set("delegation.depth", 1_i64); + bag.set("include_ssn", true); + + let rules = vec![ + // require(authenticated) → deny if !authenticated + rule( + Expression::Not(Box::new(cond(Condition::IsTrue { + key: "authenticated".into(), + }))), + deny("not authenticated"), + "r0", + ), + // require(role.hr | role.finance) → deny if neither + // Desugars to: when !(role.hr | role.finance) do deny + // = when (role.hr is false) AND (role.finance is false), deny + rule( + Expression::And(vec![ + cond(Condition::IsFalse { key: "role.hr".into() }), + cond(Condition::IsFalse { key: "role.finance".into() }), + ]), + deny("not in hr/finance"), + "r1", + ), + // delegation.depth > 2 & include_ssn: deny + rule( + Expression::And(vec![ + cond(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: 2_i64.into(), + }), + cond(Condition::IsTrue { key: "include_ssn".into() }), + ]), + deny("delegation too deep for SSN"), + "r2", + ), + ]; + + assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow); + + // Now make Alice undelegated-but-deep — should still allow at depth=1. + // Change to depth=3 and the SSN rule fires. + bag.set("delegation.depth", 3_i64); + match evaluate_rules(&rules, &bag) { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"), + d => panic!("expected r2 deny, got {:?}", d), + } + } + + // =================================================================== + // Pipe-chain evaluator tests + // =================================================================== + + use crate::pipeline::{Stage, TypeCheck}; + use serde_json::json; + + fn make_pipeline(stages: Vec) -> crate::pipeline::Pipeline { + crate::pipeline::Pipeline { stages } + } + + // Helper: a plugin invoker that's never expected to fire (pipelines + // without `plugin(...)` stages). Panics if called. Defined alongside + // the other null fixtures further down in this module. + + async fn run_pipeline( + p: &crate::pipeline::Pipeline, + v: &serde_json::Value, + bag: &AttributeBag, + ) -> FieldOutcome { + evaluate_pipeline(p, v, bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await.outcome + } + + /// Pipeline-test null invoker — distinct from the step-test `NullPlugins` + /// so each test can panic with a clearer "wrong fixture" message if it + /// ever does dispatch a plugin call by accident. + struct NullPipelinePlugins; + #[async_trait] + impl PluginInvoker for NullPipelinePlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + panic!("NullPipelinePlugins should not dispatch; got plugin({})", name); + } + } + + #[tokio::test] + async fn pipeline_empty_is_pass() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![]); + assert_eq!(run_pipeline(&p, &json!("anything"), &bag).await, FieldOutcome::Pass); + } + + #[tokio::test] + async fn pipeline_type_check_passes_and_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]); + assert_eq!(run_pipeline(&p, &json!("hello"), &bag).await, FieldOutcome::Pass); + match run_pipeline(&p, &json!(42), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert!(reason.contains("expected Str")); + assert_eq!(stage_index, 0); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_mask_preserves_last_n() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]); + match run_pipeline(&p, &json!("123-45-6789"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")), + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_mask_handles_short_strings() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]); + // keep_last >= length → no mask chars; full string preserved. + match run_pipeline(&p, &json!("ab"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")), + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_unconditional_redact() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Redact { condition: None }]); + match run_pipeline(&p, &json!("secret"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")), + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_conditional_redact_fires_when_condition_true() { + // redact(!perm.view_ssn): condition is `!perm.view_ssn`. Missing key + // → IsTrue is false → `!IsTrue` is true → redact fires. + let mut bag = AttributeBag::new(); + let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue { + key: "perm.view_ssn".into(), + }))); + let p = make_pipeline(vec![Stage::Redact { condition: Some(cond) }]); + match run_pipeline(&p, &json!("123-45-6789"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")), + other => panic!("expected Replace (redact fired), got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_conditional_redact_skips_when_condition_false() { + let mut bag = AttributeBag::new(); + bag.set("perm.view_ssn", true); + let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue { + key: "perm.view_ssn".into(), + }))); + let p = make_pipeline(vec![Stage::Redact { condition: Some(cond) }]); + // perm.view_ssn=true → !true=false → redact skipped → Pass. + assert_eq!( + run_pipeline(&p, &json!("123-45-6789"), &bag).await, + FieldOutcome::Pass, + ); + } + + #[tokio::test] + async fn pipeline_omit_short_circuits() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Omit, + // This stage should never run. + Stage::Type(TypeCheck::Int), + ]); + assert_eq!(run_pipeline(&p, &json!("anything"), &bag).await, FieldOutcome::Omit); + } + + #[tokio::test] + async fn pipeline_range_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Int), + Stage::Range { min: Some(0), max: Some(1_000_000) }, + ]); + assert_eq!(run_pipeline(&p, &json!(500_000), &bag).await, FieldOutcome::Pass); + // Above max → deny. + match run_pipeline(&p, &json!(2_000_000), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert!(reason.contains("outside")); + assert_eq!(stage_index, 1); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_length_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Length { min: None, max: Some(5) }]); + assert_eq!(run_pipeline(&p, &json!("hi"), &bag).await, FieldOutcome::Pass); + assert!(matches!( + run_pipeline(&p, &json!("too long"), &bag).await, + FieldOutcome::Deny { .. }, + )); + } + + #[tokio::test] + async fn pipeline_enum_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Enum { + values: vec!["low".into(), "medium".into(), "high".into()], + }]); + assert_eq!(run_pipeline(&p, &json!("medium"), &bag).await, FieldOutcome::Pass); + assert!(matches!( + run_pipeline(&p, &json!("extreme"), &bag).await, + FieldOutcome::Deny { .. }, + )); + } + + #[tokio::test] + async fn pipeline_uuid_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]); + assert_eq!( + run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await, + FieldOutcome::Pass, + ); + assert!(matches!( + run_pipeline(&p, &json!("not-a-uuid"), &bag).await, + FieldOutcome::Deny { .. }, + )); + } + + #[tokio::test] + async fn pipeline_hash_replaces_value() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Hash]); + match run_pipeline(&p, &json!("secret"), &bag).await { + FieldOutcome::Replace(v) => { + let s = v.as_str().unwrap(); + assert!(s.starts_with("hash:")); + assert_eq!(s.len(), "hash:".len() + 16); + } + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_validate_named_denies_at_runtime() { + // `validate(name)` is unimplemented in this build. The parser + // rejects it at compile time; this test exercises the runtime + // defense-in-depth path for IR built programmatically. The + // deny message points operators at the working alternatives + // (`regex(...)` / `plugin(...)`). + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Str), + Stage::Validate { name: "ssn_format".into() }, + Stage::Mask { keep_last: 4 }, + ]); + match run_pipeline(&p, &json!("123-45-6789"), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert_eq!(stage_index, 1, "validate stage is at index 1"); + assert!( + reason.contains("not implemented"), + "deny reason should explain that validate is unimplemented: {reason}", + ); + assert!( + reason.contains("regex") || reason.contains("plugin"), + "deny reason should point at alternatives: {reason}", + ); + } + other => panic!("expected Deny on validate(...) stage, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_validator_short_circuits_before_transform() { + // If the validator fails, the transform never runs. + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Int), // will fail on a string + Stage::Mask { keep_last: 4 }, + ]); + match run_pipeline(&p, &json!("hello"), &bag).await { + FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0), + other => panic!("expected Deny at stage 0, got {:?}", other), + } + } + + // ----- Regex stage ----- + + #[tokio::test] + async fn pipeline_regex_match_passes() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { + pattern: r"^\d{3}-\d{2}-\d{4}$".into(), + }]); + assert_eq!(run_pipeline(&p, &json!("123-45-6789"), &bag).await, FieldOutcome::Pass); + } + + #[tokio::test] + async fn pipeline_regex_no_match_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { + pattern: r"^\d{3}-\d{2}-\d{4}$".into(), + }]); + match run_pipeline(&p, &json!("not an ssn"), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert!(reason.contains("did not match")); + assert_eq!(stage_index, 0); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_regex_invalid_pattern_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { pattern: "(unclosed".into() }]); + match run_pipeline(&p, &json!("anything"), &bag).await { + FieldOutcome::Deny { reason, .. } => { + assert!(reason.contains("invalid regex")); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_regex_non_string_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { pattern: r"^\d+$".into() }]); + match run_pipeline(&p, &json!(42), &bag).await { + FieldOutcome::Deny { reason, .. } => { + assert!(reason.contains("requires string")); + } + other => panic!("expected Deny on non-string regex input, got {:?}", other), + } + } + + // ----- Taint and Scan stages ----- + + #[tokio::test] + async fn pipeline_taint_records_event() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Str), + Stage::Taint { label: "PII".into(), scopes: vec![TaintScope::Session] }, + Stage::Mask { keep_last: 4 }, + ]); + let result = evaluate_pipeline(&p, &json!("123-45-6789"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789"))); + assert_eq!(result.taints, vec![TaintEvent { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + } + + #[tokio::test] + async fn pipeline_scan_pii_detect_emits_taint() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Scan { kind: ScanKind::PiiDetect }]); + let result = evaluate_pipeline(&p, &json!("some text"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + // PII detect: value unchanged, one taint event emitted. + assert_eq!(result.outcome, FieldOutcome::Pass); + assert_eq!(result.taints, vec![TaintEvent { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + } + + #[tokio::test] + async fn pipeline_scan_pii_redact_replaces_and_taints() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Scan { kind: ScanKind::PiiRedact }]); + let result = evaluate_pipeline(&p, &json!("123-45-6789"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]"))); + assert_eq!(result.taints.len(), 1); + assert_eq!(result.taints[0].label, "PII"); + } + + #[tokio::test] + async fn pipeline_scan_injection_emits_injection_taint() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Scan { kind: ScanKind::InjectionScan }]); + let result = evaluate_pipeline(&p, &json!("user input"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Pass); + assert_eq!(result.taints[0].label, "injection"); + } + + #[tokio::test] + async fn pipeline_deny_does_not_accumulate_later_taints() { + // Pipeline halts at the first failing validator; taints emitted + // before the failure stick, taints after do not. + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Taint { label: "before".into(), scopes: vec![TaintScope::Session] }, + Stage::Type(TypeCheck::Int), // fails on string input + Stage::Taint { label: "after".into(), scopes: vec![TaintScope::Session] }, + ]); + let result = evaluate_pipeline(&p, &json!("hello"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert!(matches!(result.outcome, FieldOutcome::Deny { .. })); + assert_eq!(result.taints, vec![TaintEvent { + label: "before".into(), + scopes: vec![TaintScope::Session], + }]); + } + + // ----- Plugin stage in pipe chain ----- + + /// Pipe-context plugin invoker that returns canned outcomes by name. + struct PipePlugin { + outcomes: std::collections::HashMap, + } + #[async_trait] + impl PluginInvoker for PipePlugin { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + self.outcomes + .get(name) + .cloned() + .ok_or_else(|| PluginError::NotFound(name.into())) + } + } + + #[tokio::test] + async fn pipeline_plugin_allow_continues() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { + outcomes: std::collections::HashMap::from([ + ("noop".to_string(), PluginOutcome::allow()), + ]), + }); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Str), + Stage::Plugin { name: "noop".into() }, + Stage::Mask { keep_last: 4 }, + ]); + let result = evaluate_pipeline(&p, &json!("123-45-6789"), &bag, &plugins, "compensation", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789"))); + assert!(result.taints.is_empty()); + } + + #[tokio::test] + async fn pipeline_plugin_can_replace_value() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { + outcomes: std::collections::HashMap::from([ + ("scrubber".to_string(), PluginOutcome { + decision: Decision::Allow, + taints: vec![TaintEvent { + label: "PII".to_string(), + scopes: vec![TaintScope::Session], + }], + modified_value: Some(json!("***scrubbed***")), + }), + ]), + }); + let p = make_pipeline(vec![Stage::Plugin { name: "scrubber".into() }]); + let result = evaluate_pipeline(&p, &json!("sensitive data"), &bag, &plugins, "notes", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("***scrubbed***"))); + assert_eq!(result.taints, vec![TaintEvent { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + } + + #[tokio::test] + async fn pipeline_plugin_deny_halts() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { + outcomes: std::collections::HashMap::from([ + ("guard".to_string(), PluginOutcome { + decision: Decision::Deny { + reason: Some("policy violation".into()), + rule_source: "guard".into(), + }, + taints: vec![], + modified_value: None, + }), + ]), + }); + let p = make_pipeline(vec![ + Stage::Plugin { name: "guard".into() }, + // Should never run. + Stage::Mask { keep_last: 4 }, + ]); + let result = evaluate_pipeline(&p, &json!("data"), &bag, &plugins, "payload", crate::step::DispatchPhase::Pre).await; + match result.outcome { + FieldOutcome::Deny { reason, stage_index } => { + assert_eq!(reason, "policy violation"); + assert_eq!(stage_index, 0); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_plugin_missing_fails_closed() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { outcomes: Default::default() }); + let p = make_pipeline(vec![Stage::Plugin { name: "missing".into() }]); + let result = evaluate_pipeline(&p, &json!("data"), &bag, &plugins, "payload", crate::step::DispatchPhase::Pre).await; + match result.outcome { + FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")), + other => panic!("expected Deny on missing plugin, got {:?}", other), + } + } + + // =================================================================== + // 5c additions: Exists, InSet, Always + // =================================================================== + + #[test] + fn exists_distinguishes_missing_from_falsy() { + let mut bag = AttributeBag::new(); + bag.set("args.flag", false); + // Key is present with a falsy value — IsTrue says false, Exists says true. + assert!(!eval_condition(&Condition::IsTrue { key: "args.flag".into() }, &bag)); + assert!(eval_condition(&Condition::Exists { key: "args.flag".into() }, &bag)); + // Missing key — Exists is false. + assert!(!eval_condition(&Condition::Exists { key: "args.nonexistent".into() }, &bag)); + } + + #[test] + fn in_set_member_and_non_member() { + let mut bag = AttributeBag::new(); + bag.set("subject.type", "user"); + bag.set( + "allowed_types", + std::collections::HashSet::from(["user".to_string(), "service".to_string()]), + ); + + assert!(eval_condition(&Condition::InSet { + value_key: "subject.type".into(), + set_key: "allowed_types".into(), + negate: false, + }, &bag)); + + bag.set("subject.type", "agent"); + assert!(!eval_condition(&Condition::InSet { + value_key: "subject.type".into(), + set_key: "allowed_types".into(), + negate: false, + }, &bag)); + } + + #[test] + fn in_set_negate() { + let mut bag = AttributeBag::new(); + bag.set("subject.type", "agent"); + bag.set( + "blocked_types", + std::collections::HashSet::from(["service".to_string()]), + ); + + // agent is not in blocked_types → not in → true + assert!(eval_condition(&Condition::InSet { + value_key: "subject.type".into(), + set_key: "blocked_types".into(), + negate: true, + }, &bag)); + } + + #[test] + fn in_set_missing_keys_resolve_to_false() { + let mut bag = AttributeBag::new(); + // Both missing → in = false → not in = true (spec §2.6 missing→false + // applies to the underlying `in` lookup; negate flips it). + assert!(!eval_condition(&Condition::InSet { + value_key: "x".into(), + set_key: "y".into(), + negate: false, + }, &bag)); + assert!(eval_condition(&Condition::InSet { + value_key: "x".into(), + set_key: "y".into(), + negate: true, + }, &bag)); + } + + #[test] + fn always_evaluates_true() { + let mut bag = AttributeBag::new(); + assert!(eval_expression(&Expression::Always, &bag)); + } + + #[test] + fn always_rule_unconditional_deny() { + let mut bag = AttributeBag::new(); + let r = Rule { + condition: Expression::Always, + effects: vec![Effect::Deny { reason: Some("unconditional".into()), code: None }], + source: "test".into(), + }; + match evaluate_rules(&[r], &bag) { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")), + d => panic!("expected Deny, got {:?}", d), + } + } + + // =================================================================== + // 5c-v/vi: async step evaluator with mock resolvers + // =================================================================== + + use crate::step::{ + PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, + PluginError, PluginInvocation, PluginInvoker, PluginOutcome, + }; + use async_trait::async_trait; + + /// PDP resolver that returns the decision baked into it. Doesn't + /// inspect call.args — tests assert on call.dialect / on the decision + /// flow, not on Cedar/OPA-specific arg parsing. + struct FakePdp { + decision: Decision, + } + #[async_trait] + impl PdpResolver for FakePdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: self.decision.clone(), diagnostics: vec![] }) + } + } + + /// PDP resolver that returns an error — exercises fail-closed path. + struct ErroringPdp; + #[async_trait] + impl PdpResolver for ErroringPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Err(PdpError::Dispatch("simulated PDP outage".into())) + } + } + + /// Plugin invoker keyed by name → outcome. + struct FakePlugin { + decisions: std::collections::HashMap, + } + #[async_trait] + impl PluginInvoker for FakePlugin { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + match self.decisions.get(name) { + Some(d) => Ok(PluginOutcome { + decision: d.clone(), + taints: vec![], + modified_value: None, + }), + None => Err(PluginError::NotFound(name.into())), + } + } + } + + /// Null invoker — fails any plugin call (for PDP-only tests). + struct NullPlugins; + #[async_trait] + impl PluginInvoker for NullPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } + } + + fn pdp_step(decision_diagnostic_label: &str) -> Effect { + Effect::Pdp { + call: PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::String(decision_diagnostic_label.into()), + }, + on_deny: vec![], + on_allow: vec![], + } + } + + #[tokio::test] + async fn steps_rule_only_path() { + let mut bag = AttributeBag::new(); + let steps = vec![Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "test".into(), + }]; + let r = evaluate_effects(&steps, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await; + assert_eq!(r.decision, Decision::Allow); + } + + #[tokio::test] + async fn pdp_allow_continues() { + let mut bag = AttributeBag::new(); + let steps = vec![pdp_step("dummy")]; + let pdp: Arc = Arc::new(FakePdp { decision: Decision::Allow }); + assert_eq!( + evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + Decision::Allow, + ); + } + + #[tokio::test] + async fn pdp_deny_returns_deny() { + let mut bag = AttributeBag::new(); + let steps = vec![pdp_step("dummy")]; + let pdp: Arc = Arc::new(FakePdp { + decision: Decision::Deny { reason: Some("forbidden".into()), rule_source: "pdp".into() }, + }); + match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")), + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn pdp_on_deny_reaction_can_override_reason() { + // PDP denies, on_deny reaction includes a more specific deny rule that + // fires before the PDP's deny is returned. + let mut bag = AttributeBag::new(); + let steps = vec![Effect::Pdp { + call: PdpCall { dialect: PdpDialect::Cedar, args: serde_yaml::Value::Null }, + on_deny: vec![Effect::When { + condition: Expression::Always, + body: vec![Effect::Deny { reason: Some("reaction took over".into()), code: None }], + source: "on_deny[0]".into(), + }], + on_allow: vec![], + }]; + let pdp: Arc = Arc::new(FakePdp { + decision: Decision::Deny { reason: Some("pdp original".into()), rule_source: "p".into() }, + }); + match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("reaction took over")); + assert_eq!(rule_source, "on_deny[0]"); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn pdp_on_allow_can_deny() { + // PDP allows, but an on_allow reaction can still deny (e.g., a + // taint check that fails). Outcome: deny. + let mut bag = AttributeBag::new(); + let steps = vec![Effect::Pdp { + call: PdpCall { dialect: PdpDialect::Cedar, args: serde_yaml::Value::Null }, + on_deny: vec![], + on_allow: vec![Effect::When { + condition: Expression::Always, + body: vec![Effect::Deny { reason: Some("reaction veto".into()), code: None }], + source: "on_allow[0]".into(), + }], + }]; + let pdp: Arc = Arc::new(FakePdp { decision: Decision::Allow }); + match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")), + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn pdp_error_is_fail_closed() { + let mut bag = AttributeBag::new(); + let steps = vec![pdp_step("dummy")]; + match evaluate_effects(&steps, &mut bag, &(Arc::new(ErroringPdp) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => { + assert!(reason.unwrap().contains("PDP error")); + } + d => panic!("expected Deny on PDP error, got {:?}", d), + } + } + + #[tokio::test] + async fn plugin_allow_continues_deny_halts() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(FakePlugin { + decisions: std::collections::HashMap::from([ + ("ok_plugin".to_string(), Decision::Allow), + ("blocking_plugin".to_string(), Decision::Deny { + reason: Some("rate limit hit".into()), + rule_source: "plugin".into(), + }), + ]), + }); + + let allow_only = vec![Effect::Plugin { name: "ok_plugin".into() }]; + assert_eq!( + evaluate_effects(&allow_only, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &plugins, &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + Decision::Allow, + ); + + let with_deny = vec![ + Effect::Plugin { name: "ok_plugin".into() }, + Effect::Plugin { name: "blocking_plugin".into() }, + ]; + match evaluate_effects(&with_deny, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &plugins, &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")), + d => panic!("expected Deny from blocking_plugin, got {:?}", d), + } + } + + #[tokio::test] + async fn plugin_error_is_fail_closed() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(FakePlugin { decisions: Default::default() }); + let steps = vec![Effect::Plugin { name: "missing".into() }]; + match evaluate_effects(&steps, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &plugins, &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, rule_source } => { + assert!(reason.unwrap().contains("missing")); + assert!(rule_source.contains("missing")); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn taint_step_always_continues_and_accumulates() { + let mut bag = AttributeBag::new(); + let steps = vec![ + Effect::Taint { + label: "PII".into(), + scopes: vec![crate::pipeline::TaintScope::Session], + }, + // A later rule should still fire — taint doesn't short-circuit. + Effect::When { + condition: Expression::Always, + body: vec![Effect::Deny { reason: Some("after taint".into()), code: None }], + source: "p[1]".into(), + }, + ]; + let eval = evaluate_effects(&steps, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await; + match eval.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")), + d => panic!("expected Deny from rule after Taint, got {:?}", d), + } + // Step::Taint should have been accumulated into the phase's taints + // before the deny landed — audit needs to see what tainted before + // the policy halted. + assert_eq!(eval.taints.len(), 1); + assert_eq!(eval.taints[0].label, "PII"); + assert_eq!(eval.taints[0].scopes, vec![crate::pipeline::TaintScope::Session]); + } + + // ----- E2: FieldOp end-to-end through evaluate_steps ----- + + #[tokio::test] + async fn field_op_in_do_redacts_args_during_pre_phase() { + // Sketches the demo case: when condition holds, redact args.ssn + // — verifies the dispatcher walks effects, lifts the FieldOp + // out, and rewrites the payload. + let mut bag = AttributeBag::new(); + // Predicate is the rule's `when:`; here we make it always true. + let stages = vec![Stage::Redact { condition: None }]; + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::FieldOp { + path: "args.ssn".into(), + stages, + }], + source: "demo.policy[0]".into(), + }; + let steps = vec![Effect::from(rule)]; + let mut payload = crate::route::RoutePayload::new(json!({ + "ssn": "123-45-6789", + "name": "Jane", + })); + + let eval = evaluate_effects( + &steps, + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + assert_eq!(eval.decision, Decision::Allow); + assert!(eval.args_modified, "FieldOp should flag args_modified"); + // The ssn field should now read `[REDACTED]` (the stock value + // the Stage::Redact applier writes when no when-clause is set). + assert_eq!( + payload.args.get("ssn").and_then(|v| v.as_str()), + Some("[REDACTED]") + ); + // Other fields untouched. + assert_eq!(payload.args.get("name").and_then(|v| v.as_str()), Some("Jane")); + } + + #[tokio::test] + async fn field_op_targeting_result_in_pre_phase_is_skipped() { + // A `result.X | ...` op encountered during the Pre phase is a + // no-op — the result hasn't been produced yet. Same rule body + // can be reused across phases without branching. + let mut bag = AttributeBag::new(); + let stages = vec![Stage::Redact { condition: None }]; + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::FieldOp { + path: "result.ssn".into(), + stages, + }], + source: "demo.policy[0]".into(), + }; + let mut payload = crate::route::RoutePayload::new(json!({})); + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + assert_eq!(eval.decision, Decision::Allow); + assert!(!eval.args_modified); + assert!(!eval.result_modified); + } + + #[tokio::test] + async fn field_op_with_invalid_path_denies() { + // Path missing the `args.` / `result.` prefix is an author bug + // — fail closed with a clear violation rather than silently + // doing nothing. + let mut bag = AttributeBag::new(); + let stages = vec![Stage::Redact { condition: None }]; + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::FieldOp { + path: "ssn".into(), // missing prefix + stages, + }], + source: "demo.policy[0]".into(), + }; + let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"})); + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + match eval.decision { + Decision::Deny { reason, rule_source } => { + assert!(reason.unwrap_or_default().contains("must start with")); + assert_eq!(rule_source, "demo.policy[0]"); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + // ----- E3: Sequential / Parallel orchestration ----- + + #[tokio::test] + async fn sequential_runs_effects_in_order_until_deny() { + // A Sequential block runs each effect in order. Allow-only + // effects pass through; the first Deny halts the rest of the + // sequential body AND the parent step. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Sequential(vec![ + Effect::Allow, + Effect::Deny { + reason: Some("blocked by sequential".into()), + code: Some("seq.test".into()), + }, + Effect::Allow, // unreachable + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + match eval.decision { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("blocked by sequential")); + // The `code` override on the effect won — `seq.test` + // rather than the rule's `test.policy[0]` source. + assert_eq!(rule_source, "seq.test"); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn parallel_allows_when_no_branch_denies() { + // Both branches are no-op Allow → overall Continue → route Allow. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Parallel(vec![ + Effect::Allow, + Effect::Taint { + label: "audit_branch".into(), + scopes: vec![crate::pipeline::TaintScope::Session], + }, + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + assert_eq!(eval.decision, Decision::Allow); + // Taints from parallel branches accumulate into the outer. + assert_eq!(eval.taints.len(), 1); + assert_eq!(eval.taints[0].label, "audit_branch"); + } + + #[tokio::test] + async fn parallel_denies_when_any_branch_denies() { + // One Allow, one Deny — overall Deny. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Parallel(vec![ + Effect::Allow, + Effect::Deny { + reason: Some("branch 1 denied".into()), + code: None, + }, + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + match eval.decision { + Decision::Deny { reason, .. } => { + assert_eq!(reason.as_deref(), Some("branch 1 denied")); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn parallel_picks_first_index_halt_not_first_to_complete() { + // When two branches both deny, the one with the lower index + // in the effects list wins — not the one that physically + // finishes first. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Parallel(vec![ + Effect::Deny { + reason: Some("idx-0".into()), + code: None, + }, + Effect::Deny { + reason: Some("idx-1".into()), + code: None, + }, + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + match eval.decision { + Decision::Deny { reason, .. } => { + assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins"); + } + other => panic!("expected Deny, got {:?}", other), + } + } +} diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs new file mode 100644 index 00000000..46ee9647 --- /dev/null +++ b/crates/apl-core/src/lib.rs @@ -0,0 +1,45 @@ +// Location: ./crates/apl-core/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL core — Attribute Policy Language compiler + evaluator. +// +// This crate is the language nucleus. It does not depend on CPEX directly; +// the bridge from cpex-core extensions into the AttributeBag lives in +// `apl-cmf`, and the `PolicyEvaluator` implementation lives in `apl-cpex`. +// +// See docs/specs/apl-design.md for the full design. + +#![doc = "APL — Attribute Policy Language. See docs/specs/apl-design.md."] + +pub mod attributes; +pub mod evaluator; +pub mod parser; +pub mod pipeline; +pub mod plugin_decl; +pub mod route; +pub mod rules; +pub mod step; + +pub use attributes::{AttributeBag, AttributeExtractor, AttributeValue}; +pub use evaluator::{ + evaluate_pipeline, evaluate_rules, evaluate_effects, Decision, FieldOutcome, PipelineEvaluation, +}; +pub use parser::{ + compile_config, compile_policy_block_value, parse_pipeline, parse_predicate, parse_rule, + CompiledConfig, ConfigYaml, ParseError, RouteYaml, +}; +pub use pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck}; +pub use plugin_decl::{ + CapsView, EffectivePlugin, PluginDeclaration, PluginOverride, PluginRegistry, +}; +pub use route::{evaluate_post, evaluate_pre, evaluate_route, RouteDecision, RoutePayload}; +pub use rules::{ + CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Phase, PhaseSet, Rule, +}; +pub use step::{ + delegation_bag_keys, DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome, + DispatchPhase, NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpFactory, + PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, +}; diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs new file mode 100644 index 00000000..2a41ff86 --- /dev/null +++ b/crates/apl-core/src/parser.rs @@ -0,0 +1,3929 @@ +// Location: ./crates/apl-core/src/parser.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL parser — DSL string → IR, and YAML config → HashMap. +// +// Runs once at config load. The IR it produces is what the evaluator walks +// at request time; the parser is never on the hot path. +// +// Grammar anchored in apl-dsl-spec.md §2 (predicates) / §3 (rules) / §8 (EBNF). +// YAML shape anchored in apl-design.md §5 (`routes:` as map keyed by route_key). +// +// Step 5a scope: +// ✓ Predicate grammar: identifiers, literals, comparisons, contains, +// & | ! parens, require(...) +// ✓ Actions: deny / allow / (default deny on missing) +// ✓ YAML top-level routes: keyed map, policy: / post_policy: blocks +// ✗ Steps (cedar:(), opa(), plugin(), taint()) — rejected with clear errors +// ✗ Pipe chains in args:/result: — fields parsed, values stashed as opaque +// ✗ `in` / `not in` / `exists()` — need IR variants first; rejected +// ✗ Multi-effect do: lists, sequential:/parallel: blocks — rejected + +use std::collections::HashMap; + +use serde::Deserialize; +use thiserror::Error; + +use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck}; +use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; +use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule}; +use crate::step::{DelegateStep, PdpCall, PdpDialect, Step}; + +// ===================================================================== +// Errors +// ===================================================================== + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("YAML parse error: {0}")] + Yaml(#[from] serde_yaml::Error), + + #[error("rule '{rule}': {msg}")] + Rule { rule: String, msg: String }, + + #[error("unsupported step `{kind}` in rule '{rule}' — defer to step 5b")] + UnsupportedStep { rule: String, kind: String }, + + #[error("predicate '{predicate}': {msg}")] + Predicate { predicate: String, msg: String }, +} + +// ===================================================================== +// Lexer +// ===================================================================== + +#[derive(Debug, Clone, PartialEq)] +enum Tok { + Ident(String), // dotted: subject.id, role.hr, authenticated + StringLit(String), + IntLit(i64), + FloatLit(f64), + BoolLit(bool), + Eq, // == + NotEq, // != + Gt, // > + GtEq, // >= + Lt, // < + LtEq, // <= + And, // & (must have surrounding spaces — caller enforces) + Or, // | + Not, // ! + LParen, + RParen, + Comma, + Contains, // keyword + Require, // keyword + Exists, // keyword + In, // keyword — set membership operator +} + +struct Lexer<'a> { + src: &'a str, + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Lexer<'a> { + fn new(src: &'a str) -> Self { + Self { src, bytes: src.as_bytes(), pos: 0 } + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn bump(&mut self) -> Option { + let b = self.peek()?; + self.pos += 1; + Some(b) + } + + fn skip_ws(&mut self) { + while let Some(b) = self.peek() { + if b.is_ascii_whitespace() { self.pos += 1; } else { break; } + } + } + + fn tokenize_all(&mut self) -> Result, ParseError> { + let mut out = Vec::new(); + loop { + self.skip_ws(); + let Some(b) = self.peek() else { return Ok(out); }; + + let tok = match b { + b'(' => { self.pos += 1; Tok::LParen } + b')' => { self.pos += 1; Tok::RParen } + b',' => { self.pos += 1; Tok::Comma } + b'&' => { self.pos += 1; Tok::And } + b'|' => { self.pos += 1; Tok::Or } + b'=' => { + self.pos += 1; + if self.peek() == Some(b'=') { + self.pos += 1; Tok::Eq + } else { + return Err(self.err("expected `==`, saw `=`")); + } + } + b'!' => { + self.pos += 1; + if self.peek() == Some(b'=') { + self.pos += 1; Tok::NotEq + } else { + Tok::Not + } + } + b'>' => { + self.pos += 1; + if self.peek() == Some(b'=') { self.pos += 1; Tok::GtEq } else { Tok::Gt } + } + b'<' => { + self.pos += 1; + if self.peek() == Some(b'=') { self.pos += 1; Tok::LtEq } else { Tok::Lt } + } + b'"' | b'\'' => self.lex_string(b)?, + b'-' | b'0'..=b'9' => self.lex_number()?, + b if is_ident_start(b) => self.lex_ident_or_keyword(), + _ => return Err(self.err(&format!("unexpected char `{}`", b as char))), + }; + out.push(tok); + } + } + + fn lex_string(&mut self, quote: u8) -> Result { + self.bump(); // opening quote + let start = self.pos; + while let Some(b) = self.peek() { + if b == quote { break; } + self.pos += 1; + } + if self.peek() != Some(quote) { + return Err(self.err("unterminated string literal")); + } + let s = std::str::from_utf8(&self.bytes[start..self.pos]) + .map_err(|_| self.err("non-utf8 in string literal"))? + .to_string(); + self.bump(); // closing quote + Ok(Tok::StringLit(s)) + } + + fn lex_number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { self.pos += 1; } + while let Some(b) = self.peek() { + if b.is_ascii_digit() { self.pos += 1; } else { break; } + } + let mut is_float = false; + if self.peek() == Some(b'.') { + is_float = true; + self.pos += 1; + while let Some(b) = self.peek() { + if b.is_ascii_digit() { self.pos += 1; } else { break; } + } + } + let text = &self.src[start..self.pos]; + if is_float { + text.parse::().map(Tok::FloatLit) + .map_err(|_| self.err(&format!("bad float `{}`", text))) + } else { + text.parse::().map(Tok::IntLit) + .map_err(|_| self.err(&format!("bad int `{}`", text))) + } + } + + fn lex_ident_or_keyword(&mut self) -> Tok { + let start = self.pos; + while let Some(b) = self.peek() { + if is_ident_cont(b) { self.pos += 1; } else { break; } + } + let s = &self.src[start..self.pos]; + match s { + "true" => Tok::BoolLit(true), + "false" => Tok::BoolLit(false), + "contains" => Tok::Contains, + "require" => Tok::Require, + "exists" => Tok::Exists, + "in" => Tok::In, + // "not" is NOT a keyword — it only appears in the `not in` + // phrase. The parser handles that as an Ident("not") + Tok::In + // sequence in parse_identifier_predicate. + _ => Tok::Ident(s.to_string()), + } + } + + fn err(&self, msg: &str) -> ParseError { + ParseError::Predicate { + predicate: self.src.to_string(), + msg: format!("at byte {}: {}", self.pos, msg), + } + } +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' +} + +fn is_ident_cont(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'.' +} + +// ===================================================================== +// Predicate parser (Pratt-style; precedence () > ! > & > |) +// ===================================================================== + +struct PredParser<'a> { + src: &'a str, + toks: Vec, + pos: usize, +} + +impl<'a> PredParser<'a> { + fn parse(src: &'a str) -> Result { + let toks = Lexer::new(src).tokenize_all()?; + let mut p = Self { src, toks, pos: 0 }; + let expr = p.parse_or()?; + if p.pos < p.toks.len() { + return Err(p.err(&format!("trailing tokens after expression: {:?}", &p.toks[p.pos..]))); + } + Ok(expr) + } + + fn peek(&self) -> Option<&Tok> { self.toks.get(self.pos) } + fn bump(&mut self) -> Option { + let t = self.toks.get(self.pos).cloned()?; + self.pos += 1; + Some(t) + } + fn err(&self, msg: &str) -> ParseError { + ParseError::Predicate { + predicate: self.src.to_string(), + msg: msg.to_string(), + } + } + + fn parse_or(&mut self) -> Result { + let mut parts = vec![self.parse_and()?]; + while matches!(self.peek(), Some(Tok::Or)) { + self.bump(); + parts.push(self.parse_and()?); + } + Ok(if parts.len() == 1 { parts.pop().unwrap() } else { Expression::Or(parts) }) + } + + fn parse_and(&mut self) -> Result { + let mut parts = vec![self.parse_unary()?]; + while matches!(self.peek(), Some(Tok::And)) { + self.bump(); + parts.push(self.parse_unary()?); + } + Ok(if parts.len() == 1 { parts.pop().unwrap() } else { Expression::And(parts) }) + } + + fn parse_unary(&mut self) -> Result { + if matches!(self.peek(), Some(Tok::Not)) { + self.bump(); + let inner = self.parse_unary()?; + return Ok(Expression::Not(Box::new(inner))); + } + self.parse_atom() + } + + fn parse_atom(&mut self) -> Result { + match self.peek() { + Some(Tok::LParen) => { + self.bump(); + let inner = self.parse_or()?; + match self.bump() { + Some(Tok::RParen) => Ok(inner), + _ => Err(self.err("expected `)`")), + } + } + // `require(...)` is a rule-level shorthand per DSL §8 grammar + // (`rule = require_call | predicate ...`), not a sub-predicate. + // Trying to nest it inside `&` / `|` is a grammar error. + Some(Tok::Require) => Err(self.err( + "`require(...)` is a rule-level shorthand, not a sub-predicate \ + — use `&` / `|` / `!` over bare identifiers instead", + )), + Some(Tok::Exists) => self.parse_exists(), + Some(Tok::Ident(_)) => self.parse_identifier_predicate(), + other => Err(self.err(&format!("expected atom, got {:?}", other))), + } + } + + /// `exists()` — DSL §2.2. Returns true if the key is present + /// in the AttributeBag, regardless of value (distinct from truthiness). + fn parse_exists(&mut self) -> Result { + self.bump(); // exists + match self.bump() { + Some(Tok::LParen) => {} + _ => return Err(self.err("expected `(` after `exists`")), + } + let key = match self.bump() { + Some(Tok::Ident(s)) => s, + other => return Err(self.err(&format!( + "exists(...) expects an attribute key, got {:?}", other, + ))), + }; + match self.bump() { + Some(Tok::RParen) => {} + other => return Err(self.err(&format!( + "expected `)` after exists() argument, got {:?}", other, + ))), + } + Ok(Expression::Condition(Condition::Exists { key })) + } + + /// Parse a predicate that begins with an identifier: + /// - bare identifier: `authenticated` → IsTrue + /// - comparison: `delegation.depth > 2` + /// - contains: `session.labels contains "PII"` + /// - set membership: `subject.type in allowed_types` + /// - set non-membership: `subject.type not in blocked_types` + fn parse_identifier_predicate(&mut self) -> Result { + let key = match self.bump() { + Some(Tok::Ident(s)) => s, + _ => unreachable!("parse_atom dispatched here"), + }; + + // `in` and `not in` — two-key set membership (DSL §2.4). + if matches!(self.peek(), Some(Tok::In)) { + self.bump(); + return self.finish_in_set(key, false); + } + // `not in` shows up as Ident("not") + Tok::In. Treat that as a + // grammar phrase here; bare `not` outside this context is not a + // DSL keyword (use `!` for predicate negation). + if let Some(Tok::Ident(maybe_not)) = self.peek() { + if maybe_not == "not" { + let saved_pos = self.pos; + self.bump(); // consume "not" + if matches!(self.peek(), Some(Tok::In)) { + self.bump(); + return self.finish_in_set(key, true); + } + // Not "not in" — rewind so the downstream error reports + // the trailing-ident properly. + self.pos = saved_pos; + } + } + + let op = match self.peek() { + Some(Tok::Eq) => Some(CompareOp::Eq), + Some(Tok::NotEq) => Some(CompareOp::NotEq), + Some(Tok::Gt) => Some(CompareOp::Gt), + Some(Tok::GtEq) => Some(CompareOp::GtEq), + Some(Tok::Lt) => Some(CompareOp::Lt), + Some(Tok::LtEq) => Some(CompareOp::LtEq), + Some(Tok::Contains) => Some(CompareOp::Contains), + _ => None, + }; + + let Some(op) = op else { + // Bare identifier. + return Ok(Expression::Condition(Condition::IsTrue { key })); + }; + self.bump(); + + let value = match self.bump() { + Some(Tok::StringLit(s)) => Literal::String(s), + Some(Tok::IntLit(i)) => Literal::Int(i), + Some(Tok::FloatLit(f)) => Literal::Float(f), + Some(Tok::BoolLit(b)) => Literal::Bool(b), + Some(Tok::Ident(_)) => { + return Err(self.err( + "RHS-as-identifier on comparison operators not supported — \ + for set membership use `value_key in set_key`", + )); + } + other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))), + }; + + Ok(Expression::Condition(Condition::Comparison { key, op, value })) + } + + fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result { + let set_key = match self.bump() { + Some(Tok::Ident(s)) => s, + other => return Err(self.err(&format!( + "expected set-attribute identifier after `{}in`, got {:?}", + if negate { "not " } else { "" }, + other, + ))), + }; + Ok(Expression::Condition(Condition::InSet { value_key, set_key, negate })) + } +} + +/// Parse a predicate string into the IR. Public for tests + step-5b use. +pub fn parse_predicate(src: &str) -> Result { + PredParser::parse(src.trim()) +} + +// ===================================================================== +// Rule parser +// ===================================================================== + +/// Parse a single rule line into a `Rule`. +/// +/// Accepted forms (DSL §3.2): +/// 1. `"require(...)"` → rule-level shorthand, desugars to +/// `when: do: deny` +/// per DSL §8.1 +/// 2. `": "` → Rule { condition, action } +/// 3. `""` → Rule { condition, action: Deny } (default) +/// 4. `""` (action only) → treated as form 3 (always-true predicate) +/// +/// **Step kinds** (`plugin(...)`, `taint(...)`, `cedar:`, `opa(...)` etc.) +/// are handled by `parse_step`, not here. This function specifically parses +/// predicate-and-action rules; callers that don't know which they have +/// should use `parse_step` instead. +pub fn parse_rule(line: &str, source: &str) -> Result { + let trimmed = line.trim(); + + // require(...) shorthand — special-cased because it desugars to a + // negated predicate + Deny action, and the spec grammar (§8) puts it + // as a top-level rule alternative, not a sub-predicate. + if is_require_call(trimmed) { + let condition = parse_require_rule(trimmed)?; + return Ok(Rule::single( + condition, + Effect::Deny { reason: None, code: None }, + source, + )); + } + + // Step kinds shouldn't end up here. If they do, the caller used the + // wrong entry point — point them at parse_step. + if let Some(kind) = detect_step_kind(trimmed) { + return Err(ParseError::UnsupportedStep { + rule: trimmed.to_string(), + kind: format!("{} (use parse_step for step kinds)", kind), + }); + } + + let (predicate_str, effects) = match split_predicate_action(trimmed) { + Some((p, a)) => (p, parse_action(a, trimmed)?), + None => { + // No `:` — bare action (unconditional) or bare predicate (default deny). + if let Some(effects) = try_bare_action(trimmed) { + return Ok(Rule { + condition: Expression::Always, + effects, + source: source.to_string(), + }); + } + // DSL §2 default: bare predicate denies. + (trimmed, vec![Effect::Deny { reason: None, code: None }]) + } + }; + + let condition = parse_predicate(predicate_str) + .map_err(|e| ParseError::Rule { + rule: trimmed.to_string(), + msg: format!("{}", e), + })?; + + Ok(Rule { condition, effects, source: source.to_string() }) +} + +fn is_require_call(s: &str) -> bool { + s.trim_start().starts_with("require(") +} + +/// Parse `require(a)` / `require(a, b, ...)` / `require(a | b | ...)` and +/// return the desugared "when" expression per DSL §8.1: +/// +/// require(X) → IsFalse(X) +/// require(X, Y, ...) → Or([IsFalse(X), IsFalse(Y), ...]) (deny if any falsy) +/// require(X | Y | ...) → And([IsFalse(X), IsFalse(Y), ...]) (deny if all falsy) +/// +/// Caller wraps with `Effect::Deny`. +fn parse_require_rule(line: &str) -> Result { + let toks = Lexer::new(line).tokenize_all()?; + let mut iter = toks.into_iter().peekable(); + + let bad = |msg: &str| ParseError::Rule { + rule: line.to_string(), + msg: msg.to_string(), + }; + + match iter.next() { + Some(Tok::Require) => {} + _ => return Err(bad("expected `require`")), + } + match iter.next() { + Some(Tok::LParen) => {} + _ => return Err(bad("expected `(` after `require`")), + } + + let mut keys = Vec::new(); + let mut sep: Option = None; + + match iter.next() { + Some(Tok::Ident(s)) => keys.push(s), + _ => return Err(bad("expected identifier inside `require(...)`")), + } + + loop { + match iter.next() { + Some(Tok::RParen) => break, + Some(t @ Tok::Comma) | Some(t @ Tok::Or) => { + match &sep { + None => sep = Some(t), + Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {} + _ => return Err(bad( + "require(...) cannot mix `,` (AND) and `|` (OR) — use one or the other", + )), + } + match iter.next() { + Some(Tok::Ident(s)) => keys.push(s), + _ => return Err(bad("expected identifier after `,` or `|` in require(...)")), + } + } + Some(other) => return Err(bad(&format!( + "expected `,`, `|`, or `)` in require(...), got {:?}", other, + ))), + None => return Err(bad("unexpected end of require(...) — missing `)`")), + } + } + + if iter.peek().is_some() { + return Err(bad("trailing tokens after `require(...)` — require is a complete rule")); + } + + let falses: Vec = keys + .into_iter() + .map(|k| Expression::Condition(Condition::IsFalse { key: k })) + .collect(); + if falses.len() == 1 { + return Ok(falses.into_iter().next().unwrap()); + } + Ok(match sep { + Some(Tok::Or) => Expression::And(falses), // require(X | Y) → !X & !Y + _ => Expression::Or(falses), // require(X, Y) → !X | !Y + }) +} + +/// Detect `taint(...)` / `plugin(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(`. +fn detect_step_kind(s: &str) -> Option<&'static str> { + let s = s.trim_start(); + for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "sequential:", "parallel:"] { + if s.starts_with(prefix) { + return Some(prefix.trim_end_matches('(').trim_end_matches(':')); + } + } + None +} + +/// Split on the *last* unescaped `:` that's outside quotes and parens — this +/// is the predicate/action separator. The DSL doesn't escape colons, and `:` +/// doesn't appear in our predicate grammar, but quotes and parens can contain +/// arbitrary text. +fn split_predicate_action(s: &str) -> Option<(&str, &str)> { + let bytes = s.as_bytes(); + let mut depth: i32 = 0; + let mut in_quote: Option = None; + let mut last_colon: Option = None; + for (i, &b) in bytes.iter().enumerate() { + match (in_quote, b) { + (Some(q), c) if c == q => in_quote = None, + (Some(_), _) => {} + (None, b'"') | (None, b'\'') => in_quote = Some(b), + (None, b'(') => depth += 1, + (None, b')') => depth -= 1, + (None, b':') if depth == 0 => last_colon = Some(i), + _ => {} + } + } + last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim())) +} + +/// Parse the *right* side of a shorthand `predicate: action` rule into a +/// single-element effects vec. Recognized forms (DSL §3 + the `code` +/// extension we added in E1): +/// +/// * `deny` → `vec![Effect::Deny { reason: None, code: None }]` +/// * `deny('reason')` → `vec![Effect::Deny { reason: Some, code: None }]` +/// * `deny('reason', 'code')` → `vec![Effect::Deny { reason: Some, code: Some }]` +/// * `allow` → `vec![Effect::Allow]` +/// +/// Anything else (plugin/delegate/taint) goes through `parse_step`, not +/// here — those are sibling Steps in v0. Multi-effect `do:` lists use a +/// separate parsing path that produces `Vec` directly. +fn parse_action(s: &str, rule: &str) -> Result, ParseError> { + if let Some(effect) = try_bare_action(s) { + return Ok(effect); + } + if let Some(deny) = try_parse_deny_call(s.trim(), rule)? { + return Ok(vec![deny]); + } + Err(ParseError::Rule { + rule: rule.to_string(), + msg: format!( + "unsupported action `{}` — recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`", + s.trim() + ), + }) +} + +fn try_bare_action(s: &str) -> Option> { + match s.trim() { + "deny" => Some(vec![Effect::Deny { reason: None, code: None }]), + "allow" => Some(vec![Effect::Allow]), + _ => None, + } +} + +/// Parse `deny('reason')` or `deny('reason', 'code')`. Returns +/// `Ok(None)` when `s` doesn't start with `deny(` so the caller can +/// fall through to other action handlers. +fn try_parse_deny_call(s: &str, rule: &str) -> Result, ParseError> { + if !s.starts_with("deny(") { + return Ok(None); + } + let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule { + rule: rule.to_string(), + msg: "malformed `deny(...)`".into(), + })?; + // Two positional args max. Spec precedent: `deny('reason')` (1 arg); + // E1 extension: `deny('reason', 'code')` (2 args). Both quoted. + let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule { + rule: rule.to_string(), + msg: format!("deny(...): {}", e), + })?; + let mut iter = parts.into_iter(); + let reason = match iter.next() { + Some(p) => Some(strip_string_literal(p.trim(), rule)?), + None => None, + }; + let code = match iter.next() { + Some(p) => Some(strip_string_literal(p.trim(), rule)?), + None => None, + }; + if iter.next().is_some() { + return Err(ParseError::Rule { + rule: rule.to_string(), + msg: "deny(...) takes at most two args: deny('reason', 'code')".into(), + }); + } + Ok(Some(Effect::Deny { reason, code })) +} + +/// Strip surrounding single or double quotes from a literal. The DSL +/// uses single quotes (`'reason'`) per the spec examples, but accept +/// double quotes too so YAML escaping is forgiving. +fn strip_string_literal(s: &str, rule: &str) -> Result { + let s = s.trim(); + if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2) + || (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) + { + Ok(s[1..s.len() - 1].to_string()) + } else { + Err(ParseError::Rule { + rule: rule.to_string(), + msg: format!("expected a quoted string, got `{}`", s), + }) + } +} + + +// ===================================================================== +// Step parser (policy: / post_policy: entries — supports steps + rules) +// ===================================================================== + +/// Parse a single YAML entry from a `policy:` / `post_policy:` list. +/// +/// Two YAML shapes (DSL §3.2 + §7): +/// - **String entry** — a rule line, taint effect, or plugin call. +/// - `"require(authenticated)"` → `Step::Rule` +/// - `"delegation.depth > 2: deny"` → `Step::Rule` +/// - `"plugin(rate_limiter)"` → `Step::Plugin` +/// - `"taint(PII, session)"` → `Step::Taint` +/// - **Map entry** (single-key map) — PDP call with optional reactions. +/// - `cedar: { action: read, resource: e, on_deny: [...] }` → `Step::Pdp` +/// - `opa("path"): { on_deny: [...] }` → `Step::Pdp` +pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result { + match value { + serde_yaml::Value::String(s) => parse_step_string(s, source), + serde_yaml::Value::Mapping(m) => parse_step_map(m, source), + other => Err(ParseError::Rule { + rule: format!("{:?}", other), + msg: "step must be a string or a single-key map".into(), + }), + } +} + +fn parse_step_string(line: &str, source: &str) -> Result { + let trimmed = line.trim(); + + // taint(...) — emit as Step::Taint, reusing the pipeline parser's logic + // so the shape stays consistent with field-level taint. + if trimmed.starts_with("taint(") { + let inside = extract_call_args(trimmed, "taint") + .ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: "malformed `taint(...)`".into(), + })?; + let taint_stage = parse_taint(&inside, trimmed)?; + // parse_taint produces Stage::Taint; lift to Step::Taint. + if let Stage::Taint { label, scopes } = taint_stage { + return Ok(Step::Taint { label, scopes }); + } + unreachable!("parse_taint always returns Stage::Taint"); + } + + // plugin(name) — emit as Step::Plugin. + if trimmed.starts_with("plugin(") { + let inside = extract_call_args(trimmed, "plugin") + .ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: "malformed `plugin(...)`".into(), + })?; + let name = inside.trim(); + if name.is_empty() { + return Err(ParseError::Rule { + rule: trimmed.to_string(), + msg: "plugin name must not be empty".into(), + }); + } + return Ok(Step::Plugin { name: name.to_string() }); + } + + // delegate(name, key: value, key: [a, b], ...) — emit as Step::Delegate. + // Compact alternative to the map form (`- delegate: { plugin: ..., ... }`). + // First positional arg is the plugin name; subsequent `key: value` + // pairs become per-call config overrides (or `on_error` if the key + // is reserved). Use the map form for nested configs the kwarg + // parser doesn't handle. + if trimmed.starts_with("delegate(") { + let inside = extract_call_args(trimmed, "delegate") + .ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: "malformed `delegate(...)`".into(), + })?; + let parsed = parse_delegate_call_args(&inside, source)?; + return Ok(Step::Delegate(DelegateStep { + plugin_name: parsed.plugin_name, + config_override: parsed.config_override, + on_error: parsed.on_error, + source: source.to_string(), + })); + } + + // Otherwise fall through to the rule parser — predicate-and-action. + let rule = parse_rule(trimmed, source)?; + Ok(Step::Rule(rule)) +} + +/// Intermediate shape produced by [`parse_delegate_call_args`]. The +/// string-form parser fills this; the caller wraps into `Step::Delegate` +/// with the source path it has in scope. +struct ParsedDelegateCall { + plugin_name: String, + config_override: Option, + on_error: Option, +} + +/// Parse the inside-parens of `delegate(name, key: value, key: [a, b], ...)`. +/// +/// Grammar (informal): +/// ```text +/// delegate_args := plugin_name [, kwarg [, kwarg]*] +/// plugin_name := bare_ident_or_string +/// kwarg := key ":" value +/// value := scalar | "[" value (, value)* "]" +/// scalar := bare_word | number | "true" | "false" | quoted_string +/// ``` +/// +/// Reserved keys consumed before going into `config_override`: +/// - `on_error` — pulled out as `DelegateStep.on_error` +/// +/// Everything else lands in `config_override` as a yaml mapping. Use +/// the map form (`- delegate: { plugin: ..., config: { ... }, ... }`) +/// for nested config shapes the flat kwarg parser doesn't handle. +fn parse_delegate_call_args( + inside: &str, + source: &str, +) -> Result { + let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule { + rule: format!("delegate({inside})"), + msg: format!("{source}: {msg}"), + })?; + let mut parts_iter = parts.into_iter(); + + let plugin_name = parts_iter + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ParseError::Rule { + rule: format!("delegate({inside})"), + msg: format!( + "{source}: `delegate(...)` requires a plugin name as the first \ + positional argument" + ), + })?; + // Strip wrapping quotes if the operator wrote `delegate("workday-oauth", ...)`. + let plugin_name = strip_wrapping_quotes(&plugin_name).to_string(); + if plugin_name.is_empty() { + return Err(ParseError::Rule { + rule: format!("delegate({inside})"), + msg: format!("{source}: `delegate(...)` plugin name cannot be empty"), + }); + } + + let mut on_error: Option = None; + let mut config_map = serde_yaml::Mapping::new(); + + for raw_kwarg in parts_iter { + let kwarg = raw_kwarg.trim(); + if kwarg.is_empty() { + continue; + } + let (key, value_str) = kwarg + .split_once(':') + .ok_or_else(|| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!( + "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \ + (use the map form for richer config)" + ), + })?; + let key = key.trim(); + let value_str = value_str.trim(); + if key.is_empty() { + return Err(ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `delegate(...)` kwarg has empty key"), + }); + } + if key == "on_error" { + let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: on_error: {msg}"), + })?; + on_error = Some( + val.as_str() + .ok_or_else(|| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `on_error` must be a string"), + })? + .to_string(), + ); + continue; + } + // Reject `plugin:` as a kwarg — the plugin name is the positional + // first argument; allowing both would be ambiguous. + if key == "plugin" { + return Err(ParseError::Rule { + rule: kwarg.to_string(), + msg: format!( + "{source}: `plugin` is set as the first positional argument \ + of `delegate(...)`; don't pass it as a kwarg too" + ), + }); + } + let value = + parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `{key}`: {msg}"), + })?; + config_map.insert(serde_yaml::Value::String(key.to_string()), value); + } + + let config_override = if config_map.is_empty() { + None + } else { + Some(serde_yaml::Value::Mapping(config_map)) + }; + + Ok(ParsedDelegateCall { + plugin_name, + config_override, + on_error, + }) +} + +/// Split a `key: value, key: value` string on TOP-LEVEL commas only — +/// commas inside `[...]` or quoted strings are preserved as part of +/// the surrounding value. Returns the comma-separated pieces (trimmed +/// at boundaries; whitespace inside values preserved). +/// +/// Errors on unmatched brackets / unterminated quotes — those produce +/// confusing downstream errors otherwise. +fn split_top_level_commas(input: &str) -> Result, String> { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut bracket_depth: usize = 0; + let mut quote: Option = None; + let mut escape = false; + + for ch in input.chars() { + if escape { + current.push(ch); + escape = false; + continue; + } + if let Some(q) = quote { + current.push(ch); + if ch == '\\' { + escape = true; + } else if ch == q { + quote = None; + } + continue; + } + match ch { + '"' | '\'' => { + quote = Some(ch); + current.push(ch); + } + '[' | '(' | '{' => { + bracket_depth += 1; + current.push(ch); + } + ']' | ')' | '}' => { + bracket_depth = bracket_depth.checked_sub(1).ok_or_else(|| { + format!("unmatched `{ch}` in delegate(...) args") + })?; + current.push(ch); + } + ',' if bracket_depth == 0 => { + parts.push(std::mem::take(&mut current)); + } + _ => current.push(ch), + } + } + if quote.is_some() { + return Err("unterminated quoted string in delegate(...) args".to_string()); + } + if bracket_depth != 0 { + return Err("unbalanced brackets in delegate(...) args".to_string()); + } + parts.push(current); + Ok(parts) +} + +/// Parse a single value from the function-call form: a scalar +/// (string / number / bool) or a list literal `[a, b, c]`. Use the +/// map form for anything more complex. +fn parse_delegate_value(s: &str) -> Result { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err("empty value".to_string()); + } + // List literal — recursive scalar parse on each element. + if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) + { + let items = split_top_level_commas(stripped)?; + let mut out = Vec::with_capacity(items.len()); + for item in items { + let item = item.trim(); + if item.is_empty() { + continue; + } + out.push(parse_delegate_value(item)?); + } + return Ok(serde_yaml::Value::Sequence(out)); + } + // Quoted string — strip the surrounding quotes. + if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2) + { + return Ok(serde_yaml::Value::String( + trimmed[1..trimmed.len() - 1].to_string(), + )); + } + // Bool literals. + if trimmed == "true" { + return Ok(serde_yaml::Value::Bool(true)); + } + if trimmed == "false" { + return Ok(serde_yaml::Value::Bool(false)); + } + // Numeric literals — integer first, then float. + if let Ok(n) = trimmed.parse::() { + return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n))); + } + if let Ok(f) = trimmed.parse::() { + return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f))); + } + // Fallback: treat as bare string (e.g. `target: workday-api` → + // value is `workday-api`). Same convention as YAML scalars. + Ok(serde_yaml::Value::String(trimmed.to_string())) +} + +/// Strip a single pair of wrapping `"`/`'` if present. No-op on +/// unquoted input. Used for the positional plugin name where the +/// operator may have quoted to escape a hyphen or similar (`delegate("workday-oauth")`). +fn strip_wrapping_quotes(s: &str) -> &str { + let bytes = s.as_bytes(); + if bytes.len() >= 2 { + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return &s[1..s.len() - 1]; + } + } + s +} + +fn parse_step_map( + m: &serde_yaml::Mapping, + source: &str, +) -> Result { + // Canonical structured rule: `- when: X\n do: Y` (DSL §3.2). + // Detected by the presence of *both* `when` and `do` keys — order + // doesn't matter, and the map can carry extra keys for future + // extensions (e.g. `id:` for rule identifiers). + if has_key(m, "when") && has_key(m, "do") { + return parse_when_do_rule(m, source); + } + + if m.len() != 1 { + return Err(ParseError::Rule { + rule: format!("{:?}", m), + msg: "step map must have exactly one key (PDP call signature, \ + `when:`/`do:`, or a `predicate: [effects...]` shorthand)" + .into(), + }); + } + let (key_val, body_val) = m.iter().next().unwrap(); + let key = key_val.as_str().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", key_val), + msg: "PDP step key must be a string".into(), + })?; + + // Shorthand multi-effect map: `- "predicate": [list]` (DSL §3.1 + // multi-effect from one predicate). Detected by a single-key map + // whose value is a YAML sequence. Single-effect map shorthand + // (`- "predicate": deny`) still goes through `parse_step_string` + // via the colon-split, NOT here — by the time we land in this + // function, single-string values have already been resolved by + // the caller's `parse_step` dispatch. + if let serde_yaml::Value::Sequence(items) = body_val { + // Skip PDP keys — `cedar:` / `opa:` etc. have list bodies for + // `on_deny:` / `on_allow:` and need the existing handling. + // Also skip `sequential:` / `parallel:` orchestration keys + // since they take a list body and would otherwise be parsed + // as predicates. The shorthand recognises only predicate- + // shaped keys. + let trimmed = key.trim(); + if trimmed != "delegate" + && trimmed != "sequential" + && trimmed != "parallel" + && !is_known_pdp_dialect(trimmed) + { + return parse_shorthand_multi_effect(trimmed, items, source); + } + } + + // `delegate:` is a special non-PDP step shape — branch before the + // dialect logic. See `parse_delegate_step` for the expected body. + if key.trim() == "delegate" { + return parse_delegate_step(body_val, source); + } + + // E3: top-level `sequential:` / `parallel:` orchestration — + // wrap the resulting Effect into an unconditional Rule so the + // top-level Vec stays uniform. + match key.trim() { + "sequential" => { + let effect = parse_sequential_effect(body_val, source)?; + return Ok(Step::Rule(Rule { + condition: Expression::Always, + effects: vec![effect], + source: source.to_string(), + })); + } + "parallel" => { + let effect = parse_parallel_effect(body_val, source)?; + return Ok(Step::Rule(Rule { + condition: Expression::Always, + effects: vec![effect], + source: source.to_string(), + })); + } + _ => {} + } + + // Split the key into "dialect" + optional "(args)" portion. + let (dialect_str, paren_args) = if let Some(open) = key.find('(') { + let close = key.rfind(')').ok_or_else(|| ParseError::Rule { + rule: key.to_string(), + msg: "missing `)` in PDP call signature".into(), + })?; + let inside = key[open + 1..close].trim().to_string(); + (key[..open].trim(), Some(inside)) + } else { + (key.trim(), None) + }; + + let dialect = PdpDialect::from_key(dialect_str); + + // Extract args + on_deny/on_allow. + // Cedar: body map carries args fields directly + on_deny/on_allow. + // Others: paren_args carries the call signature; body map is reactions only. + let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", body_val), + msg: format!("`{}:` body must be a map (with on_deny / on_allow / args)", key), + })?; + + let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?; + + Ok(Step::Pdp { + call: PdpCall { dialect, args }, + on_deny, + on_allow, + }) +} + +/// Parse a `delegate:` step body into a `Step::Delegate`. Accepted +/// YAML shape: +/// +/// ```yaml +/// - delegate: +/// plugin: workday-oauth # required — TokenDelegateHook plugin name +/// config: # optional — per-call config override +/// target: workday-api +/// permissions: [read_compensation] +/// on_error: deny # optional — deny | continue (default deny) +/// ``` +/// +// ===================================================================== +// Effect / when-do parsing (E1) +// ===================================================================== + +/// Lookup helper — `serde_yaml::Mapping::contains_key` only matches when +/// the search key is a `Value`, so we wrap the string conversion. +fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool { + m.contains_key(serde_yaml::Value::String(key.to_string())) +} + +/// Whether a top-level map key is a recognized PDP dialect. Used by +/// the shorthand-list detector to avoid mis-parsing a `cedar: [...]` +/// reaction list as a predicate-with-effects map. +fn is_known_pdp_dialect(key: &str) -> bool { + let base = key.find('(').map(|i| &key[..i]).unwrap_or(key); + matches!( + base.trim(), + "cedar" | "cedarling" | "opa" | "authzen" | "nemo" + ) +} + +/// Parse the canonical `- when: X` `do: Y` rule form (DSL §3.2). `Y` +/// may be a single effect string (`do: deny`) or a list of effect +/// entries (`do: [plugin(audit), taint(X), deny('msg')]`). Map-form +/// effects (like a nested `delegate:` block) are allowed inside `do:` +/// via the same dispatch as top-level steps. +fn parse_when_do_rule( + m: &serde_yaml::Mapping, + source: &str, +) -> Result { + // Validate keys — surface a useful error if there's stray content + // beyond `when:` / `do:` (e.g. typo'd `whens:`). `id:` is reserved + // for a future rule-identifier extension; tolerate it as a + // pass-through for now. + for (k, _) in m.iter() { + let key = k.as_str().unwrap_or(""); + if !matches!(key, "when" | "do" | "id") { + return Err(ParseError::Rule { + rule: format!("{:?}", m), + msg: format!( + "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)", + key + ), + }); + } + } + + let when_val = m + .get(serde_yaml::Value::String("when".into())) + .expect("has_key verified above"); + let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", when_val), + msg: "`when:` must be a predicate string".into(), + })?; + let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule { + rule: format!("when: {}", predicate), + msg: format!("{}", e), + })?; + + let do_val = m + .get(serde_yaml::Value::String("do".into())) + .expect("has_key verified above"); + let effects = parse_do_body(do_val, source)?; + if effects.is_empty() { + return Err(ParseError::Rule { + rule: format!("{:?}", m), + msg: "`do:` produced no effects".into(), + }); + } + + Ok(Step::Rule(Rule { + condition, + effects, + source: source.to_string(), + })) +} + +/// Parse the shorthand multi-effect map form: `- "predicate": [list]` +/// (DSL §3 example at line 386). Equivalent to the canonical +/// `when: predicate` `do: [list]` shape, just terser. +fn parse_shorthand_multi_effect( + predicate: &str, + effect_list: &[serde_yaml::Value], + source: &str, +) -> Result { + let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule { + rule: predicate.to_string(), + msg: format!("{}", e), + })?; + + let mut effects = Vec::with_capacity(effect_list.len()); + for item in effect_list { + effects.push(parse_effect_value(item, source)?); + } + if effects.is_empty() { + return Err(ParseError::Rule { + rule: predicate.to_string(), + msg: "shorthand multi-effect map produced no effects".into(), + }); + } + Ok(Step::Rule(Rule { + condition, + effects, + source: source.to_string(), + })) +} + +/// Parse a `do:` body — single effect string, list of effects, or a +/// single map-shaped effect (`do: { parallel: [...] }`, +/// `do: { delegate: {...} }`, etc.). +fn parse_do_body( + val: &serde_yaml::Value, + source: &str, +) -> Result, ParseError> { + match val { + serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]), + serde_yaml::Value::Sequence(items) => items + .iter() + .map(|item| parse_effect_value(item, source)) + .collect(), + serde_yaml::Value::Mapping(_) => { + // Single map-form effect — delegate, sequential, parallel. + // Route through parse_effect_value which dispatches by key. + Ok(vec![parse_effect_value(val, source)?]) + } + other => Err(ParseError::Rule { + rule: format!("{:?}", other), + msg: "`do:` value must be a string, a list of effects, or an effect map".into(), + }), + } +} + +/// Parse one effect entry from a YAML value — string form or map form +/// (the latter for `delegate:` configs nested inside `do:`, +/// `sequential:`, and `parallel:`). +fn parse_effect_value( + val: &serde_yaml::Value, + source: &str, +) -> Result { + match val { + serde_yaml::Value::String(s) => parse_effect_string(s, source), + serde_yaml::Value::Mapping(m) => { + // E3: `sequential:` / `parallel:` map forms — a single-key + // map whose key is `sequential` / `parallel` and whose + // value is a list of effects. + if m.len() == 1 { + let (k, v) = m.iter().next().unwrap(); + if let Some(key_str) = k.as_str() { + match key_str.trim() { + "sequential" => return parse_sequential_effect(v, source), + "parallel" => return parse_parallel_effect(v, source), + _ => {} + } + } + } + // Otherwise reuse the existing step-map parser for + // `delegate:`, `cedar:` etc. and collapse the Step. + let step = parse_step(val, source)?; + step_to_effect(step, source) + } + other => Err(ParseError::Rule { + rule: format!("{:?}", other), + msg: "effect entry must be a string or a map".into(), + }), + } +} + +/// Parse a `sequential: [list]` effect value. The body MUST be a list +/// (a single effect would defeat the purpose of explicit grouping). +fn parse_sequential_effect( + body: &serde_yaml::Value, + source: &str, +) -> Result { + let items = body.as_sequence().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", body), + msg: "`sequential:` body must be a list of effects".into(), + })?; + if items.is_empty() { + return Err(ParseError::Rule { + rule: format!("{:?}", body), + msg: "`sequential:` body is empty".into(), + }); + } + let mut effects = Vec::with_capacity(items.len()); + for item in items { + effects.push(parse_effect_value(item, source)?); + } + Ok(Effect::Sequential(effects)) +} + +/// Parse a `parallel: [list]` effect value. The body MUST be a list, +/// and the parsed Effect is validated for parallel-purity (rejects +/// `FieldOp` / `Delegate` nested anywhere underneath). +fn parse_parallel_effect( + body: &serde_yaml::Value, + source: &str, +) -> Result { + let items = body.as_sequence().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", body), + msg: "`parallel:` body must be a list of effects".into(), + })?; + if items.is_empty() { + return Err(ParseError::Rule { + rule: format!("{:?}", body), + msg: "`parallel:` body is empty".into(), + }); + } + let mut effects = Vec::with_capacity(items.len()); + for item in items { + effects.push(parse_effect_value(item, source)?); + } + let parallel = Effect::Parallel(effects); + parallel + .validate_parallel_purity() + .map_err(|msg| ParseError::Rule { + rule: source.to_string(), + msg, + })?; + Ok(parallel) +} + +/// Parse one effect string. Reuses [`parse_step_string`] for forms +/// shared with top-level steps (`plugin(...)`, `taint(...)`, +/// `delegate(...)`, predicate-action rules), then collapses the +/// resulting Step into an Effect. +fn parse_effect_string(s: &str, source: &str) -> Result { + // Bare `allow` / `deny` / `deny('reason')` / `deny('reason', 'code')` + // are accepted directly — they map to control effects with no + // associated condition. Same parsing as the right-hand side of a + // shorthand `predicate: action` rule. + let trimmed = s.trim(); + if let Some(mut effects) = try_bare_action(trimmed) { + if effects.len() == 1 { + return Ok(effects.pop().unwrap()); + } + } + if let Some(effect) = try_parse_deny_call(trimmed, s)? { + return Ok(effect); + } + // Content effect — `result.salary | redact`, `args.ssn | mask(4)`, + // etc. Detected by a top-level `|` that splits a dotted path from + // a pipe chain. The pipe is at top level (depth 0); commas / + // parens inside the chain don't get confused. + if let Some(field_op) = try_parse_field_op(trimmed, s)? { + return Ok(field_op); + } + // Everything else (plugin/delegate/taint/rule) routes through the + // step parser; collapse the result. + let step = parse_step_string(s, source)?; + step_to_effect(step, source) +} + +/// Parse ` | [| ...]` into an `Effect::FieldOp`. +/// Returns `Ok(None)` when no top-level `|` is found so the caller can +/// fall through to other effect handlers. +fn try_parse_field_op(s: &str, rule: &str) -> Result, ParseError> { + let Some(pipe_idx) = find_top_level_pipe(s) else { + return Ok(None); + }; + let path = s[..pipe_idx].trim(); + let chain = s[pipe_idx + 1..].trim(); + if path.is_empty() || chain.is_empty() { + return Ok(None); + } + // The path must look like a dotted field reference. Anything else + // (e.g. `role.hr | role.security` — though that wouldn't get here + // because predicates don't appear in effect position) is a sign + // the author meant something other than a field op. + if !is_valid_field_path(path) { + return Ok(None); + } + let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { + rule: rule.to_string(), + msg: format!("field op `{}`: {}", path, e), + })?; + if pipeline.stages.is_empty() { + return Err(ParseError::Rule { + rule: rule.to_string(), + msg: format!("field op `{}` has no stages", path), + }); + } + Ok(Some(Effect::FieldOp { + path: path.to_string(), + stages: pipeline.stages, + })) +} + +/// Find the byte index of the first top-level `|` that isn't part of +/// `||` (logical-or inside a predicate). Depth-aware: skips `|` inside +/// `(...)` / `[...]` and inside single- or double-quoted strings. +fn find_top_level_pipe(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut depth: i32 = 0; + let mut quote: Option = None; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if let Some(q) = quote { + if b == b'\\' { + i += 2; + continue; + } + if b == q { + quote = None; + } + i += 1; + continue; + } + match b { + b'\'' | b'"' => quote = Some(b), + b'(' | b'[' => depth += 1, + b')' | b']' => depth -= 1, + b'|' if depth == 0 => { + // Skip `||` — never appears in effect strings today + // but defend against it anyway. + if bytes.get(i + 1) == Some(&b'|') { + i += 2; + continue; + } + return Some(i); + } + _ => {} + } + i += 1; + } + None +} + +/// A field path is a dotted identifier sequence rooted at `args.` or +/// `result.`. Reject anything else early so a stray `role.hr | …` in +/// effect position fails fast. +fn is_valid_field_path(s: &str) -> bool { + let Some(rest) = s.strip_prefix("args.").or_else(|| s.strip_prefix("result.")) else { + return false; + }; + !rest.is_empty() + && rest + .split('.') + .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_')) +} + +/// Collapse a `Step` produced by the legacy step parser into an +/// `Effect`. The legitimate inputs are `Plugin`, `Delegate`, `Taint`, +/// and `Rule` (when a control action like `deny`/`allow` was parsed). +/// Anything else (`Pdp`) is rejected — nested PDP calls inside `do:` +/// are out of scope for E1. +/// Recursively map a top-level `Step` (as produced by `parse_step`) into +/// an `Effect`. Used at compile_apl_blocks during E4 — keeps `parse_step`'s +/// internal shape for the moment while the public IR collapses to Effect. +/// All five Step variants map cleanly: Rule → When, Pdp → Pdp (recursive +/// on reactions), Plugin/Delegate/Taint pass-through. +pub(crate) fn step_to_top_level_effect(step: Step) -> Result { + match step { + Step::Rule(rule) => Ok(Effect::When { + condition: rule.condition, + body: rule.effects, + source: rule.source, + }), + Step::Pdp { call, on_allow, on_deny } => { + let on_allow = on_allow + .into_iter() + .map(step_to_top_level_effect) + .collect::, _>>()?; + let on_deny = on_deny + .into_iter() + .map(step_to_top_level_effect) + .collect::, _>>()?; + Ok(Effect::Pdp { call, on_allow, on_deny }) + } + Step::Plugin { name } => Ok(Effect::Plugin { name }), + Step::Delegate(d) => Ok(Effect::Delegate(d)), + Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + } +} + +fn step_to_effect(step: Step, source: &str) -> Result { + match step { + Step::Plugin { name } => Ok(Effect::Plugin { name }), + Step::Delegate(d) => Ok(Effect::Delegate(d)), + Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + Step::Rule(rule) => { + // Nested when/do inside a do: list isn't supported in E1 + // — only control effects (allow/deny) flatten cleanly. + if !matches!(rule.condition, Expression::Always) { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: "conditional rules nested inside `do:` are not supported in E1 \ + (use a sibling `when:`/`do:` rule instead)" + .into(), + }); + } + if rule.effects.len() != 1 { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: format!( + "unconditional rule inside `do:` must produce exactly one \ + effect, got {}", + rule.effects.len() + ), + }); + } + Ok(rule.effects.into_iter().next().unwrap()) + } + Step::Pdp { .. } => Err(ParseError::Rule { + rule: source.to_string(), + msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \ + step instead)" + .into(), + }), + } +} + +/// `config:` is opaque — the framework hands it to the named plugin +/// via the existing per-call config-override pathway. The plugin +/// owns the typed schema (target / audience / permissions / mode / +/// attenuation are conventions, not parser-enforced). +fn parse_delegate_step( + body_val: &serde_yaml::Value, + source: &str, +) -> Result { + let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate:` body must be a map with `plugin:` and optional \ + `config:` / `on_error:`" + .to_string(), + })?; + + let plugin = body + .get(serde_yaml::Value::String("plugin".to_string())) + .ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate:` requires `plugin: ` referencing a \ + top-level plugin registered under `token.delegate`" + .to_string(), + })?; + let plugin_name = plugin + .as_str() + .ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate.plugin` must be a string".to_string(), + })? + .to_string(); + if plugin_name.is_empty() { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: "`delegate.plugin` cannot be empty".to_string(), + }); + } + + let config_override = body + .get(serde_yaml::Value::String("config".to_string())) + .cloned(); + + let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) { + Some(v) => Some( + v.as_str() + .ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate.on_error` must be a string (e.g. `deny`, \ + `continue`)" + .to_string(), + })? + .to_string(), + ), + None => None, + }; + + Ok(Step::Delegate(DelegateStep { + plugin_name, + config_override, + on_error, + source: source.to_string(), + })) +} + +/// Split a PDP body into (args, on_deny, on_allow). +/// +/// If `paren_args` is `Some`, the call's args are the string inside the +/// parens (OPA-style) and the body map only carries reactions. If `None`, +/// the body map carries both args and reactions (Cedar-style); we strip +/// the reaction keys and treat what's left as args. +fn extract_pdp_body( + body: &serde_yaml::Mapping, + paren_args: Option<&str>, + source: &str, +) -> Result<(serde_yaml::Value, Vec, Vec), ParseError> { + let mut on_deny = Vec::new(); + let mut on_allow = Vec::new(); + let mut args_map = serde_yaml::Mapping::new(); + + for (k, v) in body { + match k.as_str() { + Some("on_deny") => { + on_deny = parse_reaction_list(v, source, "on_deny")?; + } + Some("on_allow") => { + on_allow = parse_reaction_list(v, source, "on_allow")?; + } + _ => { + // Non-reaction key — part of args (Cedar-style). + args_map.insert(k.clone(), v.clone()); + } + } + } + + let args = match paren_args { + Some(s) => serde_yaml::Value::String(s.to_string()), + None => serde_yaml::Value::Mapping(args_map), + }; + + Ok((args, on_deny, on_allow)) +} + +fn parse_reaction_list( + v: &serde_yaml::Value, + source: &str, + which: &str, +) -> Result, ParseError> { + let list = v.as_sequence().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", v), + msg: format!("`{}:` must be a list of steps", which), + })?; + list.iter() + .enumerate() + .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i))) + .collect() +} + +/// Extract the args inside a call like `taint(X, Y)` or `plugin(foo)`. +/// Returns the substring between the outermost matching parens. +fn extract_call_args(line: &str, name: &str) -> Option { + let line = line.trim(); + if !line.starts_with(name) { + return None; + } + let after = &line[name.len()..]; + if !after.starts_with('(') { + return None; + } + // Find the matching close paren. + let bytes = after.as_bytes(); + let mut depth = 0; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + // Anything after the close paren is invalid. + if after[i + 1..].trim().is_empty() { + return Some(after[1..i].to_string()); + } + return None; + } + } + _ => {} + } + } + None +} + +// ===================================================================== +// Pipe-chain parser (args: / result: field pipelines) +// ===================================================================== + +/// Parse a pipe-chain string into a `Pipeline`. +/// +/// Splits on `|` (outside parens/quotes), trims each stage, parses each. +/// Empty pipelines (empty string or whitespace) are valid — they produce +/// `Pipeline { stages: vec![] }`. +pub fn parse_pipeline(src: &str) -> Result { + let mut pipeline = Pipeline::new(); + for seg in split_top_level(src.trim(), b'|') { + let seg = seg.trim(); + if seg.is_empty() { + continue; + } + pipeline.push(parse_stage(seg)?); + } + Ok(pipeline) +} + +/// Split `s` on `delim` at depth 0 — respects parens and quotes. +fn split_top_level(s: &str, delim: u8) -> Vec<&str> { + let bytes = s.as_bytes(); + let mut out = Vec::new(); + let mut depth: i32 = 0; + let mut in_quote: Option = None; + let mut start = 0; + for (i, &b) in bytes.iter().enumerate() { + match (in_quote, b) { + (Some(q), c) if c == q => in_quote = None, + (Some(_), _) => {} + (None, b'"') | (None, b'\'') => in_quote = Some(b), + (None, b'(') | (None, b'[') => depth += 1, + (None, b')') | (None, b']') => depth -= 1, + (None, c) if c == delim && depth == 0 => { + out.push(&s[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&s[start..]); + out +} + +fn parse_stage(src: &str) -> Result { + let s = src.trim(); + let bad = |msg: &str| ParseError::Predicate { + predicate: src.to_string(), + msg: msg.to_string(), + }; + + // Bare range literal: starts with `-`, digit, or `..`. + if let Some(stage) = try_parse_range(s) { + return Ok(stage); + } + + // Otherwise the stage starts with an identifier (keyword) optionally + // followed by `(args)`. + let (head, args) = split_head_args(s) + .ok_or_else(|| bad("expected stage identifier"))?; + + match (head, args.as_deref()) { + // ----- Bare validators / transforms / effects ----- + ("str", None) => Ok(Stage::Type(TypeCheck::Str)), + ("int", None) => Ok(Stage::Type(TypeCheck::Int)), + ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)), + ("float", None) => Ok(Stage::Type(TypeCheck::Float)), + ("email", None) => Ok(Stage::Type(TypeCheck::Email)), + ("url", None) => Ok(Stage::Type(TypeCheck::Url)), + ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)), + ("redact", None) => Ok(Stage::Redact { condition: None }), + ("omit", None) => Ok(Stage::Omit), + ("hash", None) => Ok(Stage::Hash), + // Scan placeholders parse as bare identifiers (DSL §4.5). + ("pii.redact", None) => Ok(Stage::Scan { kind: ScanKind::PiiRedact }), + ("pii.detect", None) => Ok(Stage::Scan { kind: ScanKind::PiiDetect }), + ("injection.scan", None) => Ok(Stage::Scan { kind: ScanKind::InjectionScan }), + + // ----- Parameterized ----- + ("mask", Some(a)) => { + let n: usize = a.trim().parse() + .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?; + Ok(Stage::Mask { keep_last: n }) + } + ("redact", Some(a)) => { + // redact(!perm.view_ssn) — argument is a predicate expression. + let cond = parse_predicate(a).map_err(|e| ParseError::Predicate { + predicate: src.to_string(), + msg: format!("invalid redact() condition: {}", e), + })?; + Ok(Stage::Redact { condition: Some(cond) }) + } + ("hash", Some(_)) => Err(bad("hash takes no arguments")), + ("omit", Some(_)) => Err(bad( + "omit takes no arguments — for conditional omit, use a policy rule predicate", + )), + ("len", Some(a)) => { + let (min, max) = parse_range_inner(a) + .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?; + let to_usize = |v: i64| -> Result { + if v < 0 { Err(bad("len bounds must be non-negative")) } + else { Ok(v as usize) } + }; + Ok(Stage::Length { + min: min.map(to_usize).transpose()?, + max: max.map(to_usize).transpose()?, + }) + } + ("enum", Some(a)) => { + let values = split_top_level(a, b',') + .into_iter() + .map(|v| { + let t = v.trim(); + // Allow either bare identifier or quoted string. + if (t.starts_with('"') && t.ends_with('"')) + || (t.starts_with('\'') && t.ends_with('\'')) + { + t[1..t.len() - 1].to_string() + } else { + t.to_string() + } + }) + .filter(|s| !s.is_empty()) + .collect::>(); + if values.is_empty() { + return Err(bad("enum() requires at least one value")); + } + Ok(Stage::Enum { values }) + } + ("regex", Some(a)) => { + let pattern = a.trim(); + let pat = if (pattern.starts_with('"') && pattern.ends_with('"')) + || (pattern.starts_with('\'') && pattern.ends_with('\'')) + { + pattern[1..pattern.len() - 1].to_string() + } else { + pattern.to_string() + }; + Ok(Stage::Regex { pattern: pat }) + } + ("validate", Some(a)) => { + // Named-validator dispatch (`validate(name)`) is in the + // spec (DSL §4.2) but not implemented in this build — + // the evaluator's no-op stub would silently let invalid + // values through. Reject at compile time so operators + // notice immediately and reach for one of the working + // alternatives: + // + // * `regex("pattern")` — inline named-regex equivalent + // * `plugin(name)` — full plugin dispatch for rich + // validation (Luhn, format-with-context, etc.) + // + // When the ValidatorRegistry slice lands, this arm flips + // back to returning `Stage::Validate { name }`. + Err(bad(&format!( + "`validate({})` — named-validator dispatch is not implemented \ + in this build. Use `regex(\"pattern\")` for a named-regex \ + equivalent, or `plugin({})` for richer validation logic.", + a.trim(), + a.trim(), + ))) + } + ("plugin", Some(a)) => Ok(Stage::Plugin { name: a.trim().to_string() }), + ("taint", Some(a)) => parse_taint(a, src), + + (other, _) => Err(bad(&format!("unknown stage `{}`", other))), + } +} + +/// Try to parse `s` as a bare range literal: `0..100`, `..500`, `0..`, `0..1M`. +fn try_parse_range(s: &str) -> Option { + if !s.contains("..") { + return None; + } + // Quick reject: must not start with a letter (would be a keyword). + let first = s.as_bytes().first().copied()?; + if first.is_ascii_alphabetic() || first == b'_' { + return None; + } + let (min, max) = parse_range_inner(s)?; + Some(Stage::Range { min, max }) +} + +/// Parse the inside of a range expression: `N..M`, `..M`, `N..`. +/// Returns `Some((min, max))` if shape is valid; `None` if it's not a range. +fn parse_range_inner(s: &str) -> Option<(Option, Option)> { + let dotdot = s.find("..")?; + let left = s[..dotdot].trim(); + let right = s[dotdot + 2..].trim(); + let min = if left.is_empty() { None } else { Some(parse_numeric_with_suffix(left)?) }; + let max = if right.is_empty() { None } else { Some(parse_numeric_with_suffix(right)?) }; + if min.is_none() && max.is_none() { + return None; // `..` alone isn't a useful range + } + Some((min, max)) +} + +/// Parse a number with optional `k/K` (×1000) or `m/M` (×1_000_000) suffix. +fn parse_numeric_with_suffix(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let (num_part, mult) = match s.as_bytes().last().copied()? { + b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64), + b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64), + _ => (s, 1_i64), + }; + let n: i64 = num_part.parse().ok()?; + n.checked_mul(mult) +} + +/// Split `s` (a stage form like `mask(4)`) into `(head, Some(args_inside_parens))` +/// or `(head, None)` if there are no parens. +fn split_head_args(s: &str) -> Option<(&str, Option)> { + if let Some(open) = s.find('(') { + // Match the corresponding closing paren at depth 0. + let bytes = s.as_bytes(); + let mut depth = 0; + let mut close = None; + for (i, &b) in bytes.iter().enumerate().skip(open) { + match b { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { close = Some(i); break; } + } + _ => {} + } + } + let close = close?; + let head = s[..open].trim(); + if head.is_empty() { return None; } + let args = s[open + 1..close].to_string(); + // Reject trailing garbage after the closing paren. + if s[close + 1..].trim().is_empty() { + Some((head, Some(args))) + } else { + None + } + } else { + let head = s.trim(); + if head.is_empty() { None } else { Some((head, None)) } + } +} + +fn parse_taint(args: &str, src: &str) -> Result { + // taint(label) | taint(label, session) | taint(label, [session, message]) + let parts = split_top_level(args, b','); + if parts.is_empty() { + return Err(ParseError::Predicate { + predicate: src.to_string(), + msg: "taint() requires at least a label".into(), + }); + } + let label = parts[0].trim().to_string(); + if label.is_empty() { + return Err(ParseError::Predicate { + predicate: src.to_string(), + msg: "taint label must not be empty".into(), + }); + } + + let scopes = if parts.len() == 1 { + vec![TaintScope::Session] // default per DSL §4.6 + } else { + let scope_arg = parts[1..].join(","); + let scope_arg = scope_arg.trim(); + if scope_arg.starts_with('[') && scope_arg.ends_with(']') { + split_top_level(&scope_arg[1..scope_arg.len() - 1], b',') + .into_iter() + .map(|s| parse_taint_scope(s.trim(), src)) + .collect::, _>>()? + } else { + vec![parse_taint_scope(scope_arg, src)?] + } + }; + + Ok(Stage::Taint { label, scopes }) +} + +fn parse_taint_scope(s: &str, src: &str) -> Result { + match s { + "session" => Ok(TaintScope::Session), + "message" => Ok(TaintScope::Message), + other => Err(ParseError::Predicate { + predicate: src.to_string(), + msg: format!("unknown taint scope `{}` (expected `session` or `message`)", other), + }), + } +} + +// ===================================================================== +// YAML config +// ===================================================================== + +/// Top-level config — only the bits step 5a understands. +/// +/// `policy_evaluator:`, `imports:`, `global:`, `defaults:`, `tags:`, +/// `plugin_dirs:`, `plugin_settings:`, `version:` are all accepted and +/// stored opaquely; this struct deserializes leniently. +/// +/// `plugins:` (the root block) is parsed into [`PluginDeclaration`]s so +/// the runtime can look up hook names + capabilities per plugin without +/// going back to the raw YAML. +#[derive(Debug, Default, Deserialize)] +pub struct ConfigYaml { + #[serde(default)] + pub routes: HashMap, + + /// Root `plugins:` block — full declarations. + #[serde(default)] + pub plugins: Vec, + + /// Anything else top-level goes here — picked up by later steps. + #[serde(flatten)] + pub other: HashMap, +} + +#[derive(Debug, Default, Deserialize)] +pub struct RouteYaml { + /// Each entry is either a string (rule / plugin / taint) or a + /// single-key map (PDP call with reactions). See `parse_step`. + #[serde(default)] + pub policy: Vec, + + #[serde(default)] + pub post_policy: Vec, + + /// `args:` field → pipe-chain string. Compiled to per-field pipelines. + #[serde(default)] + pub args: HashMap, + + /// `result:` field → pipe-chain string. Compiled to per-field pipelines. + #[serde(default)] + pub result: HashMap, + + /// Per-route plugin overrides — only the spec-overridable keys + /// (config / capabilities / on_error). Merged on top of the root + /// `plugins:` declaration at dispatch time. + #[serde(default)] + pub plugins: HashMap, + + /// Anything else on the route (meta, taint, when) — stashed. + #[serde(flatten)] + pub other: HashMap, +} + +/// Output of [`compile_config`] — the routes that have APL blocks plus +/// the registry of plugin declarations from the root `plugins:` block. +/// +/// The two travel together because the evaluator needs both: the route +/// gives it the steps to run, and the registry gives the dispatcher the +/// hook name / kind for each plugin name referenced by those steps. +#[derive(Debug, Default)] +pub struct CompiledConfig { + pub routes: HashMap, + pub plugins: PluginRegistry, +} + +/// Compile a YAML config into a [`CompiledConfig`] (routes + plugin +/// registry). +/// +/// Routes with no APL fields populated (no `policy:` / `post_policy:` / +/// `args:` / `result:`) are **omitted from `routes`**, per apl-design §5 +/// "Routes without APL blocks fall back to legacy plugin-chain execution." +/// A route-level `plugins:` override block alone is not enough — overrides +/// only have meaning when the route actually dispatches plugins via APL +/// steps, so an override-only route is treated as legacy. +pub fn compile_config(yaml: &str) -> Result { + let cfg: ConfigYaml = serde_yaml::from_str(yaml)?; + let mut routes = HashMap::with_capacity(cfg.routes.len()); + for (route_key, raw) in cfg.routes { + if let Some(route) = compile_route(&route_key, raw)? { + routes.insert(route_key, route); + } + } + let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len()); + for decl in cfg.plugins { + // Duplicate plugin names: last-one-wins for v0. The spec doesn't + // currently prescribe an error here; flag if real configs hit it. + plugins.insert(decl.name.clone(), decl); + } + Ok(CompiledConfig { routes, plugins }) +} + +fn compile_route(route_key: &str, raw: RouteYaml) -> Result, ParseError> { + let has_apl = !raw.policy.is_empty() + || !raw.post_policy.is_empty() + || !raw.args.is_empty() + || !raw.result.is_empty(); + if !has_apl { + return Ok(None); + } + Ok(Some(compile_apl_blocks(route_key, raw)?)) +} + +/// Compile the APL bodies (policy/post_policy/args/result/plugins) of a +/// single block into a `CompiledRoute`. Doesn't gate on "has any APL +/// fields" — callers that need the gate (compile_config) check first. +/// `source` is the path prefix baked into rule/pipeline diagnostics +/// (e.g. `"global.policy.all"`, `"route.get_compensation"`). +fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result { + let mut route = CompiledRoute::new(source); + for (i, entry) in raw.policy.iter().enumerate() { + let step = parse_step(entry, &format!("{}.policy[{}]", source, i))?; + route.policy.push(step_to_top_level_effect(step)?); + } + for (i, entry) in raw.post_policy.iter().enumerate() { + let step = parse_step(entry, &format!("{}.post_policy[{}]", source, i))?; + route.post_policy.push(step_to_top_level_effect(step)?); + } + for (field, chain) in &raw.args { + let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { + rule: format!("args.{}: {:?}", field, chain), + msg: format!("{}", e), + })?; + route.args.push(FieldRule { + field: field.clone(), + pipeline, + source: format!("{}.args.{}", source, field), + }); + } + for (field, chain) in &raw.result { + let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { + rule: format!("result.{}: {:?}", field, chain), + msg: format!("{}", e), + })?; + route.result.push(FieldRule { + field: field.clone(), + pipeline, + source: format!("{}.result.{}", source, field), + }); + } + route.plugin_overrides = raw.plugins; + Ok(route) +} + +/// Compile a single APL policy block from a `serde_yaml::Value` whose +/// shape is the body of a route's `apl:` block: +/// +/// ```yaml +/// args: +/// employee_id: "str" +/// policy: +/// - "require(authenticated)" +/// result: +/// ssn: "redact(!perm.view_ssn)" +/// post_policy: +/// - "taint(forward)" +/// ``` +/// +/// Used by external orchestrators (apl-cpex's `AplConfigVisitor`) that +/// have already located an APL block inside a larger unified-config +/// YAML. `source` is woven into per-rule / per-pipeline diagnostic paths. +/// Returns an empty `CompiledRoute` when the value is null or contains +/// no APL fields — callers that want a "is this empty?" gate can check +/// `declared_phases().is_empty()` on the result. +pub fn compile_policy_block_value( + source: &str, + block: &serde_yaml::Value, +) -> Result { + if block.is_null() { + return Ok(CompiledRoute::new(source)); + } + let raw: RouteYaml = serde_yaml::from_value(block.clone())?; + compile_apl_blocks(source, raw) +} + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use crate::attributes::AttributeBag; + use crate::evaluator::Decision; + + // ----- Lexer ----- + + #[test] + fn lex_basic() { + let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap(); + assert_eq!(toks, vec![ + Tok::Ident("delegation.depth".into()), + Tok::Gt, + Tok::IntLit(2), + ]); + } + + #[test] + fn lex_strings_both_quotes() { + let a = Lexer::new(r#""double""#).tokenize_all().unwrap(); + let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap(); + assert_eq!(a, vec![Tok::StringLit("double".into())]); + assert_eq!(b, vec![Tok::StringLit("single".into())]); + } + + #[test] + fn lex_keywords_vs_idents() { + let toks = Lexer::new("require(role.hr) & authenticated").tokenize_all().unwrap(); + assert_eq!(toks, vec![ + Tok::Require, Tok::LParen, + Tok::Ident("role.hr".into()), + Tok::RParen, Tok::And, + Tok::Ident("authenticated".into()), + ]); + } + + #[test] + fn lex_rejects_single_equals() { + let err = Lexer::new("a = 1").tokenize_all().unwrap_err(); + assert!(format!("{}", err).contains("expected `==`")); + } + + // ----- Predicate parser ----- + + #[test] + fn pred_bare_identifier() { + let e = parse_predicate("authenticated").unwrap(); + assert_eq!(e, Expression::Condition(Condition::IsTrue { key: "authenticated".into() })); + } + + #[test] + fn pred_comparison() { + let e = parse_predicate("delegation.depth > 2").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: Literal::Int(2), + }) + ); + } + + #[test] + fn pred_contains() { + let e = parse_predicate(r#"session.labels contains "PII""#).unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::Comparison { + key: "session.labels".into(), + op: CompareOp::Contains, + value: Literal::String("PII".into()), + }) + ); + } + + #[test] + fn pred_precedence_or_lowest_and_middle_not_highest() { + // `!a & b | c` should parse as `(!a & b) | c`. + let e = parse_predicate("!a & b | c").unwrap(); + match e { + Expression::Or(parts) => { + assert_eq!(parts.len(), 2); + match &parts[0] { + Expression::And(_) => {} + other => panic!("first OR branch should be AND, got {:?}", other), + } + } + other => panic!("top-level should be OR, got {:?}", other), + } + } + + #[test] + fn pred_parens_override_precedence() { + // `(role.finance | role.admin) & !delegated` from DSL §2.5. + let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap(); + match e { + Expression::And(parts) => { + assert_eq!(parts.len(), 2); + matches!(parts[0], Expression::Or(_)); + matches!(parts[1], Expression::Not(_)); + } + other => panic!("expected top-level AND, got {:?}", other), + } + } + + #[test] + fn pred_require_rejected_as_predicate() { + // require() is a rule-level shorthand per DSL §8, not a sub-predicate. + // Trying to use it inside a predicate expression must fail clearly. + let err = parse_predicate("require(authenticated)").unwrap_err(); + assert!(format!("{}", err).contains("rule-level shorthand")); + } + + #[test] + fn rule_require_single_arg_desugars_to_isfalse_and_deny() { + // require(X) → Rule { condition: IsFalse(X), action: Deny } (DSL §8.1) + let r = parse_rule("require(authenticated)", "test").unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { reason: None, code: None }])); + assert_eq!( + r.condition, + Expression::Condition(Condition::IsFalse { key: "authenticated".into() }), + ); + } + + #[test] + fn rule_require_comma_is_and_desugars_to_or_of_isfalse() { + // require(X, Y) → Or([IsFalse(X), IsFalse(Y)]) + Deny (DSL §8.1) + // i.e., "deny if any are falsy" = "any are falsy → deny" + let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap(); + assert_eq!( + r.condition, + Expression::Or(vec![ + Expression::Condition(Condition::IsFalse { key: "role.hr".into() }), + Expression::Condition(Condition::IsFalse { key: "perm.view_ssn".into() }), + ]), + ); + } + + #[test] + fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() { + // require(X | Y) → And([IsFalse(X), IsFalse(Y)]) + Deny (DSL §8.1) + // i.e., "deny only if all are falsy" = "all are falsy → deny" + let r = parse_rule("require(role.finance | role.admin)", "test").unwrap(); + assert_eq!( + r.condition, + Expression::And(vec![ + Expression::Condition(Condition::IsFalse { key: "role.finance".into() }), + Expression::Condition(Condition::IsFalse { key: "role.admin".into() }), + ]), + ); + } + + #[test] + fn rule_require_mixed_rejected() { + let err = parse_rule("require(a, b | c)", "test").unwrap_err(); + assert!(format!("{}", err).contains("cannot mix")); + } + + #[test] + fn pred_eq_with_ident_rhs_rejected_with_in_hint() { + // `subject.type == allowed_types` — `==` doesn't take an ident RHS, + // and the error should hint at `in` for set membership. + let err = parse_predicate("subject.type == allowed_types").unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("RHS-as-identifier")); + assert!(msg.contains("set membership use")); + } + + #[test] + fn pred_in_set_basic() { + let e = parse_predicate("subject.type in allowed_types").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::InSet { + value_key: "subject.type".into(), + set_key: "allowed_types".into(), + negate: false, + }), + ); + } + + #[test] + fn pred_not_in_set() { + let e = parse_predicate("subject.type not in blocked_types").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::InSet { + value_key: "subject.type".into(), + set_key: "blocked_types".into(), + negate: true, + }), + ); + } + + #[test] + fn pred_exists_basic() { + let e = parse_predicate("exists(args.amount)").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::Exists { key: "args.amount".into() }), + ); + } + + #[test] + fn pred_exists_inside_compound() { + // exists() is a sub-predicate (unlike require) — can nest in & / |. + let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap(); + match e { + Expression::And(parts) => { + assert_eq!(parts.len(), 2); + assert_eq!( + parts[0], + Expression::Condition(Condition::Exists { key: "args.amount".into() }), + ); + } + other => panic!("expected And, got {:?}", other), + } + } + + #[test] + fn pred_exists_requires_paren_and_ident() { + assert!(parse_predicate("exists").is_err()); + assert!(parse_predicate("exists()").is_err()); + assert!(parse_predicate("exists(authenticated").is_err()); + } + + #[test] + fn pred_trailing_tokens_rejected() { + let err = parse_predicate("a b").unwrap_err(); + assert!(format!("{}", err).contains("trailing")); + } + + // ----- Rule parser ----- + + #[test] + fn rule_predicate_action_form() { + let r = parse_rule("delegation.depth > 2: deny", "test").unwrap(); + match r.effects.as_slice() { + [Effect::Deny { .. }] => {} + other => panic!("expected [Deny], got {:?}", other), + } + match r.condition { + Expression::Condition(Condition::Comparison { .. }) => {} + other => panic!("expected Comparison, got {:?}", other), + } + } + + #[test] + fn rule_predicate_only_defaults_to_deny() { + // DSL §2: missing action defaults to deny. + let r = parse_rule("!authenticated", "test").unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }])); + } + + #[test] + fn rule_explicit_allow() { + let r = parse_rule("role.admin: allow", "test").unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Allow])); + } + + #[test] + fn rule_bare_action_unconditional() { + // Bare `- deny` and `- allow` are unconditional rules with + // Expression::Always as the predicate (DSL §3.1). + let r = parse_rule("deny", "test").unwrap(); + assert_eq!(r.condition, Expression::Always); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { reason: None, code: None }])); + + let r = parse_rule("allow", "test").unwrap(); + assert_eq!(r.condition, Expression::Always); + assert!(matches!(r.effects.as_slice(), [Effect::Allow])); + } + + #[test] + fn rule_step_kinds_rejected_clearly() { + for s in ["plugin(rate_limiter)", "cedar:(action: read)", "opa(path)", "taint(audit)"] { + let err = parse_rule(s, "test").unwrap_err(); + assert!( + matches!(err, ParseError::UnsupportedStep { .. }), + "expected UnsupportedStep for `{}`, got {:?}", s, err + ); + } + } + + #[test] + fn rule_deny_with_unquoted_arg_rejected() { + // `deny "reason"` (space-separated, no parens) is not a valid + // form. The supported reason-carrying shape is + // `deny('reason')` / `deny('reason', 'code')` per DSL §3 and + // the E1 `code` extension. + let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err(); + assert!(format!("{}", err).contains("unsupported action")); + } + + #[test] + fn rule_deny_with_quoted_reason_accepted() { + // `deny('reason')` — single-arg form. Reason landing on the + // effect; code defaulting to None. + let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap(); + assert!(matches!( + r.effects.as_slice(), + [Effect::Deny { reason: Some(s), code: None }] if s == "too deep" + )); + } + + #[test] + fn rule_deny_with_reason_and_code_accepted() { + // `deny('reason', 'code')` — E1 extension. Both reason and + // author-supplied code surface in the violation. + let r = parse_rule( + r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#, + "test", + ) + .unwrap(); + match r.effects.as_slice() { + [Effect::Deny { reason: Some(reason), code: Some(code) }] => { + assert_eq!(reason, "too deep"); + assert_eq!(code, "delegation.depth_exceeded"); + } + other => panic!("expected Deny with reason+code, got {:?}", other), + } + } + + #[test] + fn rule_deny_with_too_many_args_rejected() { + // Cap on positional args — `deny(reason, code)` is the limit. + let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err(); + assert!(format!("{}", err).contains("at most two args")); + } + + #[test] + fn rule_deny_with_unquoted_args_in_call_rejected() { + // The args MUST be quoted; bare identifiers aren't legal. + let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err(); + assert!(format!("{}", err).contains("expected a quoted string")); + } + + // ----- E1: when/do canonical form ----- + + fn parse_step_yaml(yaml: &str) -> Result { + let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + parse_step(&v, "test") + } + + #[test] + fn when_do_single_effect_deny() { + // do: deny — single string value, no list. + let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap(); + match step { + Step::Rule(rule) => { + assert!(matches!( + rule.condition, + Expression::Condition(Condition::Comparison { .. }) + )); + assert!(matches!( + rule.effects.as_slice(), + [Effect::Deny { reason: None, code: None }] + )); + } + other => panic!("expected Step::Rule, got {:?}", other), + } + } + + #[test] + fn when_do_single_effect_deny_with_reason_and_code() { + // The E1 `deny('reason', 'code')` extension works inside `do:` too. + let step = parse_step_yaml( + "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')", + ) + .unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + match rule.effects.as_slice() { + [Effect::Deny { reason: Some(r), code: Some(c) }] => { + assert_eq!(r, "too deep"); + assert_eq!(c, "delegation.depth_exceeded"); + } + other => panic!("expected Deny+reason+code, got {:?}", other), + } + } + + #[test] + fn when_do_multi_effect_list() { + // The headline demo case: fan-out from one predicate. + // do: [plugin(audit_logger), taint(unauth), deny('refused')] + let yaml = r#" +when: "!role.hr" +do: + - "plugin(audit_logger)" + - "taint(unauth, session)" + - "deny('refused', 'role.hr_required')" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 3); + assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger")); + assert!(matches!( + rule.effects[1], + Effect::Taint { ref label, .. } if label == "unauth" + )); + match &rule.effects[2] { + Effect::Deny { reason: Some(r), code: Some(c) } => { + assert_eq!(r, "refused"); + assert_eq!(c, "role.hr_required"); + } + other => panic!("expected Deny+reason+code, got {:?}", other), + } + } + + #[test] + fn when_do_key_order_does_not_matter() { + // YAML maps are unordered; `do:` first should parse the same. + let step = + parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap(); + assert!(matches!(step, Step::Rule(_))); + } + + #[test] + fn when_do_with_unknown_key_rejected() { + // Typo guard — surface unknown keys instead of silently dropping. + let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err(); + assert!(format!("{}", err).contains("unexpected key")); + } + + #[test] + fn when_do_empty_do_list_rejected() { + // An empty `do:` is almost certainly an author mistake; + // require at least one effect. + let err = parse_step_yaml("when: x\ndo: []").unwrap_err(); + assert!(format!("{}", err).contains("no effects")); + } + + // ----- E1: shorthand multi-effect map (predicate: [list]) ----- + + #[test] + fn shorthand_multi_effect_map() { + // Shorthand for the canonical when/do form. The predicate is + // the map's only key, the value is a list of effects. + let yaml = r#" +"!role.hr": + - "plugin(audit_logger)" + - "deny('unauthorized')" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger")); + assert!(matches!( + rule.effects[1], + Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized" + )); + } + + #[test] + fn shorthand_multi_effect_map_with_nested_delegate() { + // Map-form effects (like `delegate:`) work inside a shorthand + // list, exercising the parse_effect_value path. + let yaml = r#" +"role.hr": + - delegate: + plugin: workday-oauth + config: + audience: workday-api + - "plugin(audit_logger)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[0], Effect::Delegate(_))); + assert!(matches!(rule.effects[1], Effect::Plugin { .. })); + } + + #[test] + fn cedar_with_list_body_still_parses_as_pdp() { + // Regression guard — `cedar:` and other PDP keys whose body + // happens to be list-shaped (e.g. when the author embeds a + // bare reaction list) must NOT be reinterpreted as a + // shorthand multi-effect map. + // + // Cedar bodies in production are maps with `action`/`resource` + // keys — we don't actually accept a Sequence body, but the + // shorthand-list detector explicitly excludes known PDP + // dialect keys so the failure mode here is the existing PDP + // body error, not a shorthand misparse. + let err = parse_step_yaml("cedar: [oh no]").unwrap_err(); + // Existing PDP body validator complains about the shape — + // proves we didn't try to read `cedar` as a predicate. + assert!(format!("{}", err).contains("body must be a map")); + } + + #[test] + fn shorthand_multi_effect_empty_list_rejected() { + let err = parse_step_yaml(r#""x": []"#).unwrap_err(); + assert!(format!("{}", err).contains("no effects")); + } + + // ----- E2: content effects in do: (field pipe chains) ----- + + #[test] + fn when_do_with_field_op_result_redact() { + // The headline E2 case: `result.salary | redact` as an effect + // inside a do: list, alongside other effect kinds. + let yaml = r#" +when: "!perm.view_ssn" +do: + - "plugin(audit_logger)" + - "result.salary | redact" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[0], Effect::Plugin { .. })); + match &rule.effects[1] { + Effect::FieldOp { path, stages } => { + assert_eq!(path, "result.salary"); + assert_eq!(stages.len(), 1, "single `redact` stage"); + } + other => panic!("expected FieldOp, got {:?}", other), + } + } + + #[test] + fn when_do_with_field_op_args_mask() { + // `args.card_number | mask(4)` — args side + parametrised stage. + let yaml = r#" +when: role.support +do: "args.card_number | mask(4)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + match &rule.effects[..] { + [Effect::FieldOp { path, stages }] => { + assert_eq!(path, "args.card_number"); + assert_eq!(stages.len(), 1); + } + other => panic!("expected single FieldOp, got {:?}", other), + } + } + + #[test] + fn when_do_with_chained_field_op() { + // Chained stages — type check + content effect. Uses stages + // the pipeline parser actually knows about (`str` and `mask`). + let yaml = r#" +when: role.support +do: "args.card_number | str | mask(4)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + match &rule.effects[..] { + [Effect::FieldOp { path, stages }] => { + assert_eq!(path, "args.card_number"); + assert_eq!(stages.len(), 2, "two-stage chain"); + } + other => panic!("expected single FieldOp, got {:?}", other), + } + } + + #[test] + fn field_op_invalid_path_falls_through() { + // `role.hr | redact` looks like a pipe chain but the path + // doesn't start with `args.` / `result.`. We refuse to treat + // it as a FieldOp; instead it falls through to the predicate + // parser, which will fail with a more specific error. + let yaml = r#"do: "role.hr | redact""#; + let _ = parse_step_yaml(&format!("when: true\n{}", yaml)); + // The exact failure mode here isn't load-bearing — what matters + // is we don't silently produce an unconditional FieldOp with a + // bogus path. So just confirm we either error or produce + // *something other than* a FieldOp. + let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\""); + match step { + Ok(Step::Rule(rule)) => { + assert!( + !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]), + "bare `role.hr` must NOT parse as a FieldOp path" + ); + } + Err(_) => {} // also fine + other => panic!("unexpected: {:?}", other), + } + } + + #[test] + fn field_op_empty_chain_rejected() { + // `args.x |` (trailing pipe with nothing after) — author bug. + let yaml = r#"when: true +do: "args.x | ""#; + let _ = parse_step_yaml(yaml); // shape varies by YAML parser, just ensure no panic + } + + #[test] + fn shorthand_multi_effect_with_field_op() { + // Shorthand `predicate: [list]` with a content effect. + let yaml = r#" +"!perm.view_ssn": + - "plugin(audit_logger)" + - "result.ssn | redact" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[1], Effect::FieldOp { .. })); + } + + #[test] + fn find_top_level_pipe_skips_inside_parens() { + // Top-level `|` between path and chain → returns its index. + // Inner `|` inside `(...)` or quotes is ignored. + assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7)); + assert_eq!(find_top_level_pipe("validate(luhn)"), None); + assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7)); + // No top-level pipe even with a `|` inside the parameter set. + assert_eq!(find_top_level_pipe("mask(a|b)"), None); + } + + // ----- E3: sequential: / parallel: parsing ----- + + #[test] + fn top_level_sequential() { + // `- sequential: [list]` as a top-level policy step. + let yaml = r#" +sequential: + - "plugin(rate_limiter)" + - "plugin(audit_logger)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + assert!(matches!(rule.condition, Expression::Always)); + match rule.effects.as_slice() { + [Effect::Sequential(inner)] => { + assert_eq!(inner.len(), 2); + assert!(matches!(inner[0], Effect::Plugin { .. })); + assert!(matches!(inner[1], Effect::Plugin { .. })); + } + other => panic!("expected single Sequential effect, got {:?}", other), + } + } + + #[test] + fn top_level_parallel() { + let yaml = r#" +parallel: + - "plugin(pii_scanner)" + - "plugin(nemo_guardrails)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + match rule.effects.as_slice() { + [Effect::Parallel(inner)] => { + assert_eq!(inner.len(), 2); + } + other => panic!("expected single Parallel effect, got {:?}", other), + } + } + + #[test] + fn parallel_inside_do_body() { + // The DSL spec's "Conditional parallel" example: a `when:` + // rule whose `do:` is a single parallel block. + let yaml = r#" +when: args.include_ssn == true +do: + parallel: + - "plugin(pii_scanner)" + - "plugin(nemo_guardrails)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + match rule.effects.as_slice() { + [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2), + other => panic!("expected Parallel in do:, got {:?}", other), + } + } + + #[test] + fn parallel_rejects_field_op_at_parse_time() { + // FieldOp inside Parallel should fail at parse, not at runtime. + let yaml = r#" +parallel: + - "plugin(audit)" + - "args.ssn | redact" +"#; + let err = parse_step_yaml(yaml).unwrap_err(); + assert!(format!("{}", err).contains("mutation"), "got: {}", err); + } + + #[test] + fn parallel_rejects_delegate_at_parse_time() { + let yaml = r#" +parallel: + - "plugin(audit)" + - "delegate(workday)" +"#; + let err = parse_step_yaml(yaml).unwrap_err(); + assert!(format!("{}", err).contains("mutation")); + } + + #[test] + fn sequential_allows_mutations() { + // The escape valve — Sequential lets mutations through. + let yaml = r#" +sequential: + - "args.ssn | redact" + - "plugin(audit)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { panic!("expected Rule") }; + match rule.effects.as_slice() { + [Effect::Sequential(inner)] => { + assert!(matches!(inner[0], Effect::FieldOp { .. })); + assert!(matches!(inner[1], Effect::Plugin { .. })); + } + other => panic!("got {:?}", other), + } + } + + #[test] + fn parallel_empty_list_rejected() { + let err = parse_step_yaml("parallel: []").unwrap_err(); + assert!(format!("{}", err).contains("empty")); + } + + #[test] + fn sequential_empty_list_rejected() { + let err = parse_step_yaml("sequential: []").unwrap_err(); + assert!(format!("{}", err).contains("empty")); + } + + #[test] + fn nested_orchestration() { + // `sequential: [plugin, parallel: [plugin, plugin]]` — the + // parser handles arbitrary nesting through parse_effect_value. + let yaml = r#" +sequential: + - "plugin(rate_limiter)" + - parallel: + - "plugin(pii_scanner)" + - "plugin(nemo)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { panic!("expected Rule") }; + let Effect::Sequential(outer) = &rule.effects[0] else { + panic!("expected Sequential"); + }; + assert_eq!(outer.len(), 2); + assert!(matches!(outer[0], Effect::Plugin { .. })); + match &outer[1] { + Effect::Parallel(inner) => assert_eq!(inner.len(), 2), + other => panic!("expected nested Parallel, got {:?}", other), + } + } + + // ----- Colon-splitting edge cases ----- + + #[test] + fn split_respects_quotes_and_parens() { + // The `:` inside parens / quotes shouldn't be the separator. + let r = parse_rule( + r#"session.labels contains "a:b": deny"#, + "test", + ).unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }])); + if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition { + assert_eq!(value, Literal::String("a:b".into())); + } else { + panic!("expected Comparison"); + } + } + + // ----- YAML compilation ----- + + #[test] + fn compile_simple_route() { + let yaml = r#" +routes: + get_compensation: + policy: + - "require(authenticated)" + - "require(role.hr | role.finance)" + - "delegation.depth > 2 & include_ssn: deny" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("get_compensation").expect("route missing"); + assert_eq!(route.policy.len(), 3); + assert!(route.declared_phases().contains(crate::rules::Phase::Policy)); + } + + #[test] + fn compile_omits_routes_without_apl_blocks() { + // A route with no APL blocks (no policy / post_policy / args / + // result) is a "legacy" route per apl-design §5 and must be + // omitted from the compiled output. Unknown route keys (e.g. + // legacy CPEX `priority`) are stashed in `other`, not errored. + let yaml = r#" +routes: + legacy: + priority: 50 + apl_route: + policy: + - "require(authenticated)" +"#; + let routes = compile_config(yaml).unwrap().routes; + assert!(routes.contains_key("apl_route")); + assert!(!routes.contains_key("legacy"), "legacy route should be omitted, not compiled"); + } + + #[test] + fn compile_unknown_top_level_keys_ignored() { + let yaml = r#" +version: "0.1" +policy_evaluator: + kind: apl +plugins: + - name: rate_limiter + kind: native +imports: + - "./shared.yaml" +routes: + ping: + policy: + - "require(authenticated)" +"#; + let routes = compile_config(yaml).unwrap().routes; + assert!(routes.contains_key("ping")); + } + + #[test] + fn compile_propagates_rule_errors_with_source() { + let yaml = r#" +routes: + bad: + policy: + - "subject.id == garbage_ident" +"#; + let err = compile_config(yaml).unwrap_err(); + // RHS-as-identifier is rejected; the error mentions the offending input. + let msg = format!("{}", err); + assert!( + msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"), + "error message should reference the failure: {}", msg, + ); + } + + #[test] + fn compile_plugin_step_string_form() { + let yaml = r#" +routes: + rate_limited: + policy: + - "plugin(rate_limiter)" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("rate_limited").unwrap(); + assert_eq!(route.policy.len(), 1); + match &route.policy[0] { + Effect::Plugin { name } => assert_eq!(name, "rate_limiter"), + other => panic!("expected Effect::Plugin, got {:?}", other), + } + } + + #[test] + fn compile_taint_step_string_form() { + let yaml = r#" +routes: + audit_marked: + policy: + - "taint(audit, session)" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("audit_marked").unwrap(); + match &route.policy[0] { + Effect::Taint { label, scopes } => { + assert_eq!(label, "audit"); + assert_eq!(scopes, &vec![TaintScope::Session]); + } + other => panic!("expected Effect::Taint, got {:?}", other), + } + } + + #[test] + fn compile_pdp_call_cedar_map_form() { + // Cedar uses the `cedar:` key with args inline + on_deny/on_allow. + let yaml = r#" +routes: + authz_check: + policy: + - cedar: + action: read + resource: employee + on_deny: + - deny + on_allow: + - "plugin(audit_logger)" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("authz_check").unwrap(); + match &route.policy[0] { + Effect::Pdp { call, on_deny, on_allow } => { + assert_eq!(call.dialect, PdpDialect::Cedar); + // Cedar args are a map: action + resource (with reaction + // keys stripped out). + let args_map = call.args.as_mapping().expect("cedar args should be a map"); + assert!(args_map.contains_key(serde_yaml::Value::String("action".into()))); + assert!(args_map.contains_key(serde_yaml::Value::String("resource".into()))); + assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into()))); + assert_eq!(on_deny.len(), 1); + assert_eq!(on_allow.len(), 1); + } + other => panic!("expected Effect::Pdp, got {:?}", other), + } + } + + #[test] + fn compile_pdp_call_cedarling_map_form() { + // `cedarling:` is its own dialect — same map shape as `cedar:` + // but routes to the Cedarling-backed resolver in the + // PdpRouter, letting cedar-direct and cedarling coexist. + let yaml = r#" +routes: + authz_check: + policy: + - cedarling: + action: read + resource: employee + on_deny: + - deny +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("authz_check").unwrap(); + match &route.policy[0] { + Effect::Pdp { call, on_deny, .. } => { + assert_eq!(call.dialect, PdpDialect::Cedarling); + let args_map = call.args.as_mapping().expect("cedarling args should be a map"); + assert!(args_map.contains_key(serde_yaml::Value::String("action".into()))); + assert!(args_map.contains_key(serde_yaml::Value::String("resource".into()))); + assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into()))); + assert_eq!(on_deny.len(), 1); + } + other => panic!("expected Effect::Pdp, got {:?}", other), + } + } + + #[test] + fn compile_pdp_call_opa_paren_form() { + // OPA uses `opa("path"):` with the path inside parens + body is reactions. + let yaml = r#" +routes: + opa_check: + policy: + - 'opa("hr/compensation/deny"):': + on_deny: + - deny +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("opa_check").unwrap(); + match &route.policy[0] { + Effect::Pdp { call, on_deny, .. } => { + assert_eq!(call.dialect, PdpDialect::Opa); + // OPA args are a string (the path). + assert!(call.args.as_str().unwrap().contains("hr/compensation/deny")); + assert_eq!(on_deny.len(), 1); + } + other => panic!("expected Effect::Pdp, got {:?}", other), + } + } + + #[test] + fn compile_pdp_unknown_dialect_becomes_custom() { + let yaml = r#" +routes: + custom_pdp: + policy: + - my_engine: + on_deny: [deny] +"#; + let routes = compile_config(yaml).unwrap().routes; + match &routes.get("custom_pdp").unwrap().policy[0] { + Effect::Pdp { call, .. } => { + assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into())); + } + other => panic!("expected Pdp, got {:?}", other), + } + } + + // ----- End-to-end with evaluator ----- + + #[tokio::test] + async fn end_to_end_hr_compensation() { + let yaml = r#" +routes: + get_compensation: + policy: + - "require(authenticated)" + - "require(role.hr | role.finance)" + - "delegation.depth > 2: deny" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("get_compensation").unwrap(); + + let pdp: std::sync::Arc = + std::sync::Arc::new(NullPdpResolver); + let plugins: std::sync::Arc = + std::sync::Arc::new(NullPluginInvoker); + let delegations: std::sync::Arc = + std::sync::Arc::new(crate::NoopDelegationInvoker); + + // Alice: authenticated, hr role, depth=1 → allow. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("delegation.depth", 1_i64); + assert_eq!( + crate::evaluate_effects(&route.policy, &mut bag, &pdp, &plugins, &delegations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + Decision::Allow, + ); + + // Same Alice but depth=3 → deny (third rule fires). + bag.set("delegation.depth", 3_i64); + match crate::evaluate_effects(&route.policy, &mut bag, &pdp, &plugins, &delegations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy[2]"), "expected policy[2], got {}", rule_source); + } + d => panic!("expected Deny, got {:?}", d), + } + + // Bob: authenticated but neither hr nor finance → deny on rule 1. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + match crate::evaluate_effects(&route.policy, &mut bag, &pdp, &plugins, &delegations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy[1]"), "expected policy[1], got {}", rule_source); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + // Test fixtures for async evaluator — null resolvers that nothing in + // a pure-rule route should ever invoke. + struct NullPdpResolver; + #[async_trait::async_trait] + impl crate::PdpResolver for NullPdpResolver { + fn dialect(&self) -> crate::PdpDialect { crate::PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &crate::PdpCall, + _bag: &crate::AttributeBag, + ) -> Result { + panic!("NullPdpResolver should not be invoked in pure-rule tests"); + } + } + + struct NullPluginInvoker; + #[async_trait::async_trait] + impl crate::PluginInvoker for NullPluginInvoker { + async fn invoke( + &self, + _name: &str, + _bag: &crate::AttributeBag, + _invocation: crate::PluginInvocation<'_>, + ) -> Result { + panic!("NullPluginInvoker should not be invoked in pure-rule tests"); + } + } + + // ----- Pipeline parsing ----- + + #[test] + fn pipeline_simple_bare_stages() { + let p = parse_pipeline("str").unwrap(); + assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]); + + let p = parse_pipeline("omit").unwrap(); + assert_eq!(p.stages, vec![Stage::Omit]); + + let p = parse_pipeline("hash").unwrap(); + assert_eq!(p.stages, vec![Stage::Hash]); + } + + #[test] + fn pipeline_chains_split_on_pipe() { + let p = parse_pipeline("str | mask(4)").unwrap(); + assert_eq!(p.stages, vec![ + Stage::Type(TypeCheck::Str), + Stage::Mask { keep_last: 4 }, + ]); + + let p = parse_pipeline("int | 0..1M").unwrap(); + assert_eq!(p.stages, vec![ + Stage::Type(TypeCheck::Int), + Stage::Range { min: Some(0), max: Some(1_000_000) }, + ]); + } + + #[test] + fn pipeline_pipe_inside_parens_does_not_split() { + // `redact(!a | b)` is one stage; the inner `|` is OR inside a + // predicate condition, not a chain separator. + let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap(); + assert_eq!(p.stages.len(), 2); + match &p.stages[1] { + Stage::Redact { condition: Some(_) } => {} + other => panic!("expected Redact with condition, got {:?}", other), + } + } + + #[test] + fn pipeline_length_constraints() { + let p = parse_pipeline("len(..500)").unwrap(); + assert_eq!(p.stages, vec![Stage::Length { min: None, max: Some(500) }]); + let p = parse_pipeline("len(10..50)").unwrap(); + assert_eq!(p.stages, vec![Stage::Length { min: Some(10), max: Some(50) }]); + let p = parse_pipeline("len(8..)").unwrap(); + assert_eq!(p.stages, vec![Stage::Length { min: Some(8), max: None }]); + } + + #[test] + fn pipeline_range_with_suffixes() { + let p = parse_pipeline("0..10k").unwrap(); + assert_eq!(p.stages, vec![Stage::Range { min: Some(0), max: Some(10_000) }]); + let p = parse_pipeline("0..1M").unwrap(); + assert_eq!(p.stages, vec![Stage::Range { min: Some(0), max: Some(1_000_000) }]); + let p = parse_pipeline("..500").unwrap(); + assert_eq!(p.stages, vec![Stage::Range { min: None, max: Some(500) }]); + } + + #[test] + fn pipeline_enum_unquoted_and_quoted() { + let p = parse_pipeline("enum(low, medium, high)").unwrap(); + assert_eq!(p.stages, vec![Stage::Enum { + values: vec!["low".into(), "medium".into(), "high".into()], + }]); + let p = parse_pipeline(r#"enum("a", "b")"#).unwrap(); + assert_eq!(p.stages, vec![Stage::Enum { + values: vec!["a".into(), "b".into()], + }]); + } + + #[test] + fn pipeline_redact_with_predicate_condition() { + let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap(); + assert_eq!(p.stages.len(), 2); + match &p.stages[1] { + Stage::Redact { condition: Some(Expression::Not(inner)) } => { + match inner.as_ref() { + Expression::Condition(Condition::IsTrue { key }) => { + assert_eq!(key, "perm.view_ssn"); + } + other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other), + } + } + other => panic!("expected Redact with Not condition, got {:?}", other), + } + } + + #[test] + fn pipeline_taint_scopes() { + let p = parse_pipeline("taint(PII)").unwrap(); + assert_eq!(p.stages, vec![Stage::Taint { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + let p = parse_pipeline("taint(PII, message)").unwrap(); + assert_eq!(p.stages, vec![Stage::Taint { + label: "PII".into(), + scopes: vec![TaintScope::Message], + }]); + let p = parse_pipeline("taint(PII, [session, message])").unwrap(); + assert_eq!(p.stages, vec![Stage::Taint { + label: "PII".into(), + scopes: vec![TaintScope::Session, TaintScope::Message], + }]); + } + + #[test] + fn pipeline_unknown_stage_rejected() { + let err = parse_pipeline("nonsense").unwrap_err(); + assert!(format!("{}", err).contains("unknown stage")); + } + + #[test] + fn pipeline_omit_with_args_rejected() { + // omit has no conditional form per DSL §4.1. + let err = parse_pipeline("omit(!perm.x)").unwrap_err(); + assert!(format!("{}", err).contains("omit takes no arguments")); + } + + // ----- YAML compilation with pipelines ----- + + #[test] + fn compile_route_with_args_and_result() { + let yaml = r#" +routes: + get_compensation: + args: + employee_id: "uuid" + amount: "int | 0..1M" + result: + ssn: "str | redact(!perm.view_ssn)" + employee_id: "str | mask(4)" + internal_notes: "omit" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("get_compensation").expect("missing route"); + assert_eq!(route.args.len(), 2); + assert_eq!(route.result.len(), 3); + + // Pull out the ssn pipeline and confirm shape. + let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap(); + assert_eq!(ssn.pipeline.stages.len(), 2); + assert!(matches!(ssn.pipeline.stages[0], Stage::Type(TypeCheck::Str))); + assert!(matches!(ssn.pipeline.stages[1], Stage::Redact { condition: Some(_) })); + + // declared_phases should include Result and Args now. + let phases = route.declared_phases(); + assert!(phases.contains(crate::rules::Phase::Args)); + assert!(phases.contains(crate::rules::Phase::Result)); + } + + #[test] + fn compile_route_with_only_args_still_compiles() { + // A route with no `policy:` but with `args:` validators is still + // an APL route (declared_phases is non-empty). + let yaml = r#" +routes: + validate_only: + args: + employee_id: "uuid" +"#; + let routes = compile_config(yaml).unwrap().routes; + assert!(routes.contains_key("validate_only")); + } + + #[test] + fn compile_propagates_pipeline_parse_errors() { + let yaml = r#" +routes: + bad: + result: + x: "nonsense" +"#; + let err = compile_config(yaml).unwrap_err(); + assert!(format!("{}", err).contains("unknown stage")); + } + + // ----- plugins: block + route-level overrides ----- + + #[test] + fn compile_captures_root_plugins_block_into_registry() { + let yaml = r#" +plugins: + - name: rate_limiter + kind: native + hooks: [tool_pre_invoke] + capabilities: [read_subject] + config: + max_requests: 100 + - name: audit + kind: native + hooks: [tool_post_invoke] +routes: + get_compensation: + policy: + - "plugin(rate_limiter)" +"#; + let cfg = compile_config(yaml).unwrap(); + assert_eq!(cfg.plugins.len(), 2); + let rl = cfg.plugins.get("rate_limiter").unwrap(); + assert_eq!(rl.kind, "native"); + assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]); + assert_eq!(rl.capabilities, vec!["read_subject".to_string()]); + // The route should still compile (uses plugin(rate_limiter)). + assert!(cfg.routes.contains_key("get_compensation")); + } + + #[test] + fn compile_captures_route_level_plugin_overrides() { + let yaml = r#" +plugins: + - name: rate_limiter + kind: native + hooks: [tool_pre_invoke] + config: + max_requests: 100 +routes: + hot_path: + policy: + - "plugin(rate_limiter)" + plugins: + rate_limiter: + config: + max_requests: 10 + on_error: ignore +"#; + let cfg = compile_config(yaml).unwrap(); + let route = cfg.routes.get("hot_path").unwrap(); + let ovr = route.plugin_overrides.get("rate_limiter").unwrap(); + assert_eq!(ovr.on_error.as_deref(), Some("ignore")); + let cfg_yaml = ovr.config.as_ref().unwrap(); + assert_eq!(cfg_yaml["max_requests"], serde_yaml::from_str::("10").unwrap()); + + // Verify EffectivePlugin::resolve sees the override. + let eff = crate::plugin_decl::EffectivePlugin::resolve( + "rate_limiter", + &cfg.plugins, + &route.plugin_overrides, + ) + .unwrap(); + assert_eq!(eff.on_error, Some("ignore")); + // Hooks NOT overridable — still from the global declaration. + assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); + } + + // ----- compile_policy_block_value (single-block compiler for visitors) ----- + + #[test] + fn compile_policy_block_value_parses_apl_body() { + let yaml = r#" +policy: + - "require(authenticated)" +result: + ssn: "redact(!perm.view_ssn)" +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let compiled = + compile_policy_block_value("global.policy.all", &value).expect("compile block"); + assert_eq!(compiled.route_key, "global.policy.all"); + assert_eq!(compiled.policy.len(), 1); + assert_eq!(compiled.result.len(), 1); + assert_eq!(compiled.result[0].field, "ssn"); + } + + #[test] + fn compile_policy_block_value_null_is_empty_route() { + let value = serde_yaml::Value::Null; + let compiled = + compile_policy_block_value("global.defaults.tool", &value).expect("compile null"); + assert!(compiled.declared_phases().is_empty()); + assert_eq!(compiled.route_key, "global.defaults.tool"); + } + + #[test] + fn compile_policy_block_value_threads_source_into_rule_paths() { + let yaml = r#" +policy: + - "require(authenticated)" +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let compiled = + compile_policy_block_value("global.policies.hr", &value).expect("compile"); + match &compiled.policy[0] { + crate::rules::Effect::When { source, .. } => { + assert_eq!(source, "global.policies.hr.policy[0]"); + } + other => panic!("expected When, got {:?}", other), + } + } + + // ----- delegate: step parsing ----- + + #[test] + fn parse_delegate_step_with_only_plugin() { + let yaml = r#" +- delegate: + plugin: workday-oauth +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert!(ds.config_override.is_none()); + assert!(ds.on_error.is_none()); + assert_eq!(ds.source, "test.policy[0]"); + } + + #[test] + fn parse_delegate_step_with_config_and_on_error() { + let yaml = r#" +- delegate: + plugin: workday-oauth + config: + target: workday-api + permissions: [read_compensation] + on_error: deny +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[1]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert_eq!(ds.on_error.as_deref(), Some("deny")); + let cfg = ds.config_override.as_ref().expect("config_override set"); + let target = cfg + .as_mapping() + .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) + .and_then(|v| v.as_str()); + assert_eq!(target, Some("workday-api")); + } + + #[test] + fn parse_delegate_step_missing_plugin_errors() { + let yaml = r#" +- delegate: + config: { target: workday-api } +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin"); + let msg = format!("{err}"); + assert!(msg.contains("requires `plugin:"), "got: {msg}"); + } + + #[test] + fn parse_delegate_step_empty_plugin_errors() { + let yaml = r#" +- delegate: + plugin: "" +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin"); + let msg = format!("{err}"); + assert!(msg.contains("cannot be empty"), "got: {msg}"); + } + + #[test] + fn parse_delegate_step_non_string_on_error_errors() { + let yaml = r#" +- delegate: + plugin: workday-oauth + on_error: 42 +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error"); + let msg = format!("{err}"); + assert!(msg.contains("on_error"), "got: {msg}"); + } + + #[test] + fn parse_delegate_step_non_map_body_errors() { + let yaml = r#" +- delegate: workday-oauth +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body"); + let msg = format!("{err}"); + assert!(msg.contains("must be a map"), "got: {msg}"); + } + + // ----- delegate(...) function-call string form ----- + + #[test] + fn parse_delegate_string_bare_plugin_name() { + let yaml = r#"- "delegate(workday-oauth)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert!(ds.config_override.is_none()); + assert!(ds.on_error.is_none()); + assert_eq!(ds.source, "test.policy[0]"); + } + + #[test] + fn parse_delegate_string_with_string_kwargs() { + let yaml = r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + assert_eq!( + cfg.get(serde_yaml::Value::String("target".into())) + .and_then(|v| v.as_str()), + Some("workday-api"), + ); + assert_eq!( + cfg.get(serde_yaml::Value::String("audience".into())) + .and_then(|v| v.as_str()), + Some("https://workday.com"), + ); + } + + #[test] + fn parse_delegate_string_with_list_kwarg() { + let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + let perms = cfg + .get(serde_yaml::Value::String("permissions".into())) + .and_then(|v| v.as_sequence()) + .expect("permissions sequence"); + let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(names, vec!["read_compensation", "write_notes"]); + } + + #[test] + fn parse_delegate_string_on_error_pulled_out() { + let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + assert_eq!(ds.on_error.as_deref(), Some("continue")); + // on_error must NOT also leak into config_override. + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + assert!( + cfg.get(serde_yaml::Value::String("on_error".into())).is_none(), + "on_error must not appear in config_override" + ); + } + + #[test] + fn parse_delegate_string_quoted_plugin_name() { + // Quoting the plugin name is harmless — the parser strips + // the wrapping quotes. Useful when the name contains + // characters the bare-ident reader doesn't like. + let yaml = r#"- 'delegate("workday-oauth")'"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + } + + #[test] + fn parse_delegate_string_quoted_value_preserves_internal_commas() { + let yaml = r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + assert_eq!( + cfg.get(serde_yaml::Value::String("audience".into())) + .and_then(|v| v.as_str()), + Some("https://workday.com,backup.workday.com"), + ); + } + + #[test] + fn parse_delegate_string_empty_args_errors() { + let yaml = r#"- "delegate()""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("empty args"); + let msg = format!("{err}"); + assert!(msg.contains("plugin name"), "got: {msg}"); + } + + #[test] + fn parse_delegate_string_plugin_kwarg_rejected() { + // `plugin:` as a kwarg is ambiguous when the plugin name is + // also the positional first arg — reject loudly. + let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg"); + let msg = format!("{err}"); + assert!(msg.contains("positional"), "got: {msg}"); + } + + #[test] + fn parse_delegate_string_kwarg_missing_colon_errors() { + let yaml = r#"- "delegate(workday-oauth, target workday-api)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("missing colon"); + let msg = format!("{err}"); + assert!(msg.contains("key: value"), "got: {msg}"); + } + + #[test] + fn parse_delegate_string_unbalanced_brackets_errors() { + let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced"); + let msg = format!("{err}"); + assert!(msg.contains("unmatched") || msg.contains("unbalanced"), "got: {msg}"); + } + + #[test] + fn compile_route_mixed_string_and_map_delegate_forms() { + // Both forms coexist in the same policy block — string form + // for the compact case, map form for richer config. + let yaml = r#" +routes: + get_compensation: + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - delegate: + plugin: audit-receipt + on_error: continue + config: + mode: trace +"#; + let cfg = compile_config(yaml).expect("compile"); + let route = cfg.routes.get("get_compensation").expect("route"); + assert_eq!(route.policy.len(), 3); + + // Step [1] is the string-form delegate. + let crate::rules::Effect::Delegate(s1) = &route.policy[1] else { + panic!("expected Delegate at policy[1]"); + }; + assert_eq!(s1.plugin_name, "workday-oauth"); + assert!(s1.on_error.is_none()); + + // Step [2] is the map-form delegate. + let crate::rules::Effect::Delegate(s2) = &route.policy[2] else { + panic!("expected Delegate at policy[2]"); + }; + assert_eq!(s2.plugin_name, "audit-receipt"); + assert_eq!(s2.on_error.as_deref(), Some("continue")); + } + + #[test] + fn compile_route_with_delegate_in_policy_and_post_policy() { + // End-to-end: delegate() lands in the right phase with the + // right source path for diagnostics. Mixed with normal rules + // to prove it doesn't perturb existing step parsing. + let yaml = r#" +routes: + get_compensation: + policy: + - "require(role.hr)" + - delegate: + plugin: workday-oauth + config: + target: workday-api + permissions: [read_compensation] + - "require(authenticated)" + post_policy: + - delegate: + plugin: audit-biscuit + on_error: continue +"#; + let cfg = compile_config(yaml).expect("compile"); + let route = cfg.routes.get("get_compensation").expect("route present"); + assert_eq!(route.policy.len(), 3); + + // Policy step [1] is the delegate. + let crate::rules::Effect::Delegate(ds) = &route.policy[1] else { + panic!("expected Delegate at policy[1], got {:?}", route.policy[1]); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert_eq!(ds.source, "get_compensation.policy[1]"); + + // post_policy[0] is the audit-biscuit delegate. + let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else { + panic!("expected Delegate at post_policy[0]"); + }; + assert_eq!(post_ds.plugin_name, "audit-biscuit"); + assert_eq!(post_ds.on_error.as_deref(), Some("continue")); + assert_eq!(post_ds.source, "get_compensation.post_policy[0]"); + } + + // ----- validate(name) compile-time rejection (DSL spec §4.2) ----- + + #[test] + fn parse_pipeline_rejects_validate_stage_at_compile_time() { + // Named-validator dispatch isn't implemented; the parser + // rejects `validate(...)` rather than letting it through to + // a runtime stub that silently passes. Diagnostic points the + // operator at the working alternatives. + let err = parse_pipeline("str | validate(ssn_format) | mask(4)") + .expect_err("validate(name) should fail to parse"); + let msg = format!("{err}"); + assert!( + msg.contains("not implemented"), + "diagnostic should explain that validate is unimplemented: {msg}", + ); + assert!( + msg.contains("regex") && msg.contains("plugin"), + "diagnostic should suggest regex(...) and plugin(...): {msg}", + ); + assert!( + msg.contains("ssn_format"), + "diagnostic should echo the rejected validator name: {msg}", + ); + } + + #[test] + fn parse_pipeline_does_not_reject_other_stages() { + // Sanity: the validate rejection doesn't catch unrelated + // stages. A pipeline with no validate stage parses cleanly. + let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)") + .expect("non-validate pipeline parses"); + assert_eq!(p.stages.len(), 4); + } +} diff --git a/crates/apl-core/src/pipeline.rs b/crates/apl-core/src/pipeline.rs new file mode 100644 index 00000000..0eae7b1c --- /dev/null +++ b/crates/apl-core/src/pipeline.rs @@ -0,0 +1,134 @@ +// Location: ./crates/apl-core/src/pipeline.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Pipe-chain IR for APL `args:` and `result:` phases. +// +// A field-level pipeline is a sequence of `Stage`s separated by `|` in the +// DSL. Validators (str/int/range/...) check the field's value and can fail +// the request; transforms (mask/redact/omit/hash) modify the value; effects +// (taint) record side information. +// +// Grounded in apl-dsl-spec.md §4. +// +// Stages whose evaluator behavior is deferred to step 5c (taint dispatch, +// plugin invocation, regex/named validators, scan placeholders) are still +// represented in the IR so the parser can produce them — the evaluator +// recognizes them and returns a clear "deferred" signal rather than crashing. + +use serde::{Deserialize, Serialize}; + +use crate::rules::Expression; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TypeCheck { + Str, + Int, + Bool, + Float, + Email, + Url, + Uuid, +} + +/// Scope at which a taint applies. Marked `#[non_exhaustive]` so new +/// variants (e.g. `Request`, `Pipeline`, conversation-level) can be +/// added without breaking downstream exhaustive matches. v0 emits only +/// `Session` and `Message`; plugin-extracted taints (from +/// `extensions.security.labels` diffs in `CmfPluginInvoker`) default to +/// `Session` because cpex-core's label monotonicity is session-semantic. +/// Config-side `Step::Taint`/`Stage::Taint` declares scopes explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum TaintScope { + Session, + Message, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScanKind { + PiiRedact, + PiiDetect, + InjectionScan, +} + +/// One stage in a pipe chain. +/// +/// Stages execute left-to-right against a single field value. Validators +/// halt the pipeline on failure; transforms produce a new value; effects +/// (taint) annotate without changing the value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + // ----- Validators (halt with deny on failure) ----- + Type(TypeCheck), + /// `regex("pattern")` — parser captures the pattern; evaluator stubbed + /// until we add the `regex` crate dependency. + Regex { pattern: String }, + /// `validate(name)` — named validator dispatch; evaluator stubbed. + Validate { name: String }, + /// `len(..N)`, `len(N..M)`, `len(N..)` — string length bounds. + Length { min: Option, max: Option }, + /// Bare range literal `N..M`, `..M`, `N..`, with optional `k`/`K`/`m`/`M` + /// numeric suffixes. Integer-only per DSL §4.3. + Range { min: Option, max: Option }, + /// `enum(a, b, c)` — value must equal one of the listed strings. + Enum { values: Vec }, + + // ----- Transforms (produce a new value) ----- + /// `mask(N)` — replace all but last N chars with `*`. + Mask { keep_last: usize }, + /// `redact` (unconditional) or `redact(!condition)` (conditional). + /// Replaces value with `[REDACTED]` when condition is true (or always, + /// if no condition). + Redact { condition: Option }, + /// `omit` — drop the field from output entirely. No conditional form + /// per DSL §4.1 — use a policy rule for conditional omit. + Omit, + /// `hash` — replace value with a hash digest. + Hash, + + // ----- Effects (deferred to step 5c — IR captured, eval stubbed) ----- + Taint { label: String, scopes: Vec }, + Plugin { name: String }, + Scan { kind: ScanKind }, +} + +/// Sequence of stages applied to one field's value. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Pipeline { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stages: Vec, +} + +impl Pipeline { + pub fn new() -> Self { Self::default() } + pub fn push(&mut self, stage: Stage) { self.stages.push(stage); } + pub fn is_empty(&self) -> bool { self.stages.is_empty() } +} + +/// Attaches a pipeline to a specific field name in the args or result phase. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldRule { + pub field: String, + pub pipeline: Pipeline, + /// Source location (e.g., `"get_compensation.result.ssn"`) for audit. + pub source: String, +} + +/// A taint label produced as a side effect of running a pipeline. +/// +/// The evaluator accumulates these in `PipelineEvaluation.taints`; the host +/// (apl-cpex) drains them and writes to the actual SessionStore. Same shape +/// as `Stage::Taint`'s fields, but lives at the evaluator boundary because +/// it also carries taints emitted by plugin invocations and scan stages +/// — not just literal `taint(...)` stages. +#[derive(Debug, Clone, PartialEq)] +pub struct TaintEvent { + pub label: String, + pub scopes: Vec, +} diff --git a/crates/apl-core/src/plugin_decl.rs b/crates/apl-core/src/plugin_decl.rs new file mode 100644 index 00000000..1b64fc88 --- /dev/null +++ b/crates/apl-core/src/plugin_decl.rs @@ -0,0 +1,290 @@ +// Location: ./crates/apl-core/src/plugin_decl.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin declarations — the parsed shape of the `plugins:` block in a +// unified-config YAML document, plus the per-route override block and +// the 2-layer resolver that merges them. +// +// Spec: `contextforge-plugins-framework-apl/docs/specs/unified-config-proposal.md`, +// §"Plugin Declaration" (lines 173+) +// §"Route-Level Plugin Config Overrides" (lines 360+) +// +// Layering, per spec: +// - Global declaration (root `plugins:`) — full shape +// - Route-level override (`routes..plugins.

:`) — `config`, +// `capabilities`, `on_error` only; hooks/kind/source NOT overridable +// - `EffectivePlugin::resolve(name, registry, route)` merges them. +// +// v0 enforcement: hooks are read from the resolved view (which equals +// the global view since hooks aren't overridable). Config + capability +// overrides are parsed and stored so they survive in the IR for later +// consumers, but not propagated to dispatch yet — capability gating +// and per-call config-override plumbing are tracked separately in the +// APL implementation memory. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// One entry from the root `plugins:` block. The minimal shape apl-core +/// needs to make routing + dispatch decisions; richer CPEX fields +/// (`source`, `priority`, `mode`, transport blocks, `description`, +/// `version`) are captured opaquely under `extra` so the round-trip +/// preserves them without us modeling every variant for v0. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginDeclaration { + /// Plugin name — referenced from routes by `plugin(name)` and used + /// as the key in [`PluginRegistry`]. + pub name: String, + + /// Implementation kind. Spec defines a closed set (`builtin`, + /// `native`, `wasm`, FQN, `external`, `isolated_venv`, PDP kinds) + /// but we parse as a free string so configs using future kinds + /// the runtime understands aren't rejected at the apl-core layer. + pub kind: String, + + /// CPEX hook names this plugin implements. Invokers pick which + /// hook to dispatch based on this list; v0 uses the first entry, + /// future versions will choose by invocation context (policy vs + /// post_policy vs pipe-chain). + /// + /// Per spec §"Hook dispatch": NOT overridable per-route. + #[serde(default)] + pub hooks: Vec, + + /// Attribute-extension capabilities (`read_subject`, `read_labels`, + /// `append_labels`, `read_headers`, …). The runtime uses these for + /// extension filtering before dispatch. v0: parsed but not yet + /// enforced (capability gating is a separately tracked item). + #[serde(default)] + pub capabilities: Vec, + + /// Opaque per-plugin config. Passed to the plugin verbatim by the + /// CPEX runtime; apl-core doesn't interpret it. + #[serde(default)] + pub config: Option, + + /// `fail | ignore | disable`. Defaults to `fail` per spec when None. + #[serde(default)] + pub on_error: Option, + + /// Catch-all for `source`, `priority`, `mode`, transport blocks, + /// `description`, `version`, etc. Preserved so a future loader can + /// read them without re-parsing the YAML. + #[serde(flatten)] + pub extra: HashMap, +} + +/// Per-route override block — only the spec-overridable keys. Bare +/// key-value pairs are NOT merged into `config` implicitly (spec line +/// 399): "The override object always uses the same keys as a plugin +/// declaration (`config:`, `capabilities:`, `on_error:`); bare +/// key-value pairs are not merged into `config` implicitly." +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginOverride { + #[serde(default)] + pub config: Option, + + #[serde(default)] + pub capabilities: Option>, + + #[serde(default)] + pub on_error: Option, +} + +/// Registry of plugin declarations, keyed by name. Built by the parser +/// from the root `plugins:` block. Type alias — no methods — so callers +/// can wrap it in `Arc<_>` or borrow it directly without ceremony. +pub type PluginRegistry = HashMap; + +/// Plugin shape after layering route-level overrides on top of the +/// global declaration. This is what invokers should consume — calling +/// `EffectivePlugin::resolve` (rather than reading the global directly) +/// ensures future override enforcement lands without re-walking the +/// dispatch sites. +#[derive(Debug, Clone)] +pub struct EffectivePlugin<'a> { + pub name: &'a str, + pub kind: &'a str, + /// NOT overridable per spec — always from the global declaration. + pub hooks: &'a [String], + /// Capabilities: route override wins if present, else global. + /// Borrowed when no override applies; owned (cloned) when override + /// present. Use [`capabilities`] to read regardless. + pub capabilities: CapsView<'a>, + /// Config: route override wins if present, else global. Borrowed + /// directly; callers that need to own it call `.cloned()`. + pub config: Option<&'a serde_yaml::Value>, + /// on_error: route override wins if present, else global. + pub on_error: Option<&'a str>, +} + +/// Internal helper that holds either a borrowed slice from the global +/// declaration or an owned override vec; callers see a slice either way. +#[derive(Debug, Clone)] +pub enum CapsView<'a> { + /// Cheap path — no override; point at the global's slice. + Global(&'a [String]), + /// Override applied — caller-owned copy from the override block. + Override(&'a [String]), +} + +impl<'a> CapsView<'a> { + pub fn as_slice(&self) -> &'a [String] { + match self { + Self::Global(s) | Self::Override(s) => s, + } + } +} + +impl<'a> EffectivePlugin<'a> { + /// Merge a global declaration with a per-route override and return + /// the effective view. Returns `None` if `name` isn't in the + /// registry — caller decides whether that's an error. + /// + /// Spec §"Route-Level Plugin Config Overrides": + /// - Override `config` replaces the global `config` entirely. + /// - Override `capabilities` replaces global capabilities. + /// - Override `on_error` replaces global on_error. + /// - Everything else inherits unchanged from the global. + pub fn resolve( + name: &str, + registry: &'a PluginRegistry, + route_overrides: &'a HashMap, + ) -> Option { + let global = registry.get(name)?; + let ovr = route_overrides.get(name); + + let capabilities = match ovr.and_then(|o| o.capabilities.as_deref()) { + Some(c) => CapsView::Override(c), + None => CapsView::Global(global.capabilities.as_slice()), + }; + let config = ovr + .and_then(|o| o.config.as_ref()) + .or(global.config.as_ref()); + let on_error = ovr + .and_then(|o| o.on_error.as_deref()) + .or(global.on_error.as_deref()); + + Some(Self { + name: global.name.as_str(), + kind: global.kind.as_str(), + hooks: global.hooks.as_slice(), + capabilities, + config, + on_error, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn yaml(s: &str) -> serde_yaml::Value { + serde_yaml::from_str(s).unwrap() + } + + fn registry_with(decl: PluginDeclaration) -> PluginRegistry { + let mut r = PluginRegistry::new(); + r.insert(decl.name.clone(), decl); + r + } + + #[test] + fn resolve_with_no_override_returns_global_values() { + let registry = registry_with(PluginDeclaration { + name: "rate_limiter".into(), + kind: "native".into(), + hooks: vec!["tool_pre_invoke".into()], + capabilities: vec!["read_subject".into()], + config: Some(yaml("max_requests: 100")), + on_error: Some("fail".into()), + extra: HashMap::new(), + }); + let overrides = HashMap::new(); + + let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap(); + assert_eq!(eff.name, "rate_limiter"); + assert_eq!(eff.kind, "native"); + assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); + assert_eq!(eff.capabilities.as_slice(), &["read_subject".to_string()]); + assert_eq!(eff.on_error, Some("fail")); + assert!(matches!(eff.capabilities, CapsView::Global(_))); + } + + #[test] + fn resolve_with_override_replaces_config_and_capabilities_and_on_error() { + let registry = registry_with(PluginDeclaration { + name: "rate_limiter".into(), + kind: "native".into(), + hooks: vec!["tool_pre_invoke".into()], + capabilities: vec!["read_subject".into()], + config: Some(yaml("max_requests: 100")), + on_error: Some("fail".into()), + extra: HashMap::new(), + }); + let mut overrides = HashMap::new(); + overrides.insert( + "rate_limiter".to_string(), + PluginOverride { + config: Some(yaml("max_requests: 10")), + capabilities: Some(vec!["read_subject".into(), "read_labels".into()]), + on_error: Some("ignore".into()), + }, + ); + + let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap(); + // Hooks NOT overridable — still the global value. + assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); + // Capabilities/config/on_error — overridden. + assert_eq!( + eff.capabilities.as_slice(), + &["read_subject".to_string(), "read_labels".to_string()] + ); + assert!(matches!(eff.capabilities, CapsView::Override(_))); + assert_eq!(eff.on_error, Some("ignore")); + let cfg = eff.config.expect("config present"); + assert_eq!(cfg["max_requests"], yaml("10")); + } + + #[test] + fn resolve_with_partial_override_only_replaces_present_keys() { + // Per spec line 399: only keys present in the override replace + // inherited values. An override with just `on_error` inherits + // config + capabilities from the global. + let registry = registry_with(PluginDeclaration { + name: "audit".into(), + kind: "native".into(), + hooks: vec!["tool_post_invoke".into()], + capabilities: vec!["read_labels".into()], + config: Some(yaml("log_level: info")), + on_error: Some("ignore".into()), + extra: HashMap::new(), + }); + let mut overrides = HashMap::new(); + overrides.insert( + "audit".to_string(), + PluginOverride { + config: None, + capabilities: None, + on_error: Some("fail".into()), + }, + ); + + let eff = EffectivePlugin::resolve("audit", ®istry, &overrides).unwrap(); + assert_eq!(eff.on_error, Some("fail")); // overridden + assert_eq!(eff.capabilities.as_slice(), &["read_labels".to_string()]); // inherited + let cfg = eff.config.expect("config inherited"); + assert_eq!(cfg["log_level"], yaml("info")); // inherited + } + + #[test] + fn resolve_returns_none_for_unknown_plugin() { + let registry = PluginRegistry::new(); + let overrides = HashMap::new(); + assert!(EffectivePlugin::resolve("missing", ®istry, &overrides).is_none()); + } +} diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs new file mode 100644 index 00000000..30dff509 --- /dev/null +++ b/crates/apl-core/src/route.rs @@ -0,0 +1,739 @@ +// Location: ./crates/apl-core/src/route.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Phase orchestration: runs `args → policy → result → post_policy` against a +// `CompiledRoute` and a mutable payload, returning a unified decision plus +// accumulated taints. +// +// This is the entry point apl-cpex calls into. Each phase has its own +// evaluator (see `evaluator.rs`); this module's job is to drive them in +// the right order with the right transitions (apply field mutations, halt +// on deny, thread taints across phases). +// +// Phase semantics (anchored in apl-dsl-spec.md §3): +// - args: walk field rules; Replace/Omit mutate `payload.args`; Deny halts +// - policy: walk steps; Deny halts +// - result: only runs if `payload.result.is_some()`; same as args +// - post_policy: walks steps; the spec leaves room for "observed only" +// handling, but apl-core surfaces the deny — the host (apl-cpex) chooses +// whether to enforce it +// +// Missing fields are skipped silently — a pipeline can't transform what +// isn't there. If a route needs to require presence, that's a policy-phase +// `require(exists(args.X))` rule. + +use std::sync::Arc; + +use crate::attributes::AttributeBag; +use crate::evaluator::{evaluate_pipeline, evaluate_effects, Decision, FieldOutcome}; +use crate::pipeline::TaintEvent; +use crate::rules::CompiledRoute; +use crate::step::{DelegationInvoker, DispatchPhase, PdpResolver, PluginInvoker}; + +/// Mutable payload for a route invocation. `args` is the request arguments +/// object; `result` is the response object (`None` on the inbound path, +/// `Some` once the tool/resource has produced a value). +#[derive(Debug, Clone)] +pub struct RoutePayload { + pub args: serde_json::Value, + pub result: Option, +} + +impl RoutePayload { + pub fn new(args: serde_json::Value) -> Self { + Self { args, result: None } + } + + pub fn with_result(args: serde_json::Value, result: serde_json::Value) -> Self { + Self { args, result: Some(result) } + } +} + +/// Full outcome of running all four phases for a route. +#[derive(Debug, Clone)] +pub struct RouteDecision { + pub decision: Decision, + /// Taints accumulated from any phase. Empty unless a pipeline emitted them. + pub taints: Vec, + /// True if any args field was rewritten or omitted. + pub args_modified: bool, + /// True if any result field was rewritten or omitted. + pub result_modified: bool, +} + +/// Run the **pre-invocation** phases: `args` then `policy`. Used by +/// orchestrators bound to a `tool_pre_invoke`-style hook — by the time +/// post-invoke fires, the tool has produced a response, so result/ +/// post_policy belong to [`evaluate_post`]. +/// +/// On a phase Deny, halts and returns immediately. `args_modified` is +/// set if any args field was rewritten or omitted; `result_modified` is +/// always `false` (post hasn't run). Taints emitted during args/policy +/// land in the returned `taints` vec — survive even on a Deny so audit +/// sees what fired before the halt. +pub async fn evaluate_pre( + route: &CompiledRoute, + bag: &mut AttributeBag, + payload: &mut RoutePayload, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, +) -> RouteDecision { + let mut taints: Vec = Vec::new(); + let mut args_modified = false; + + // ----- args ----- + for rule in &route.args { + let Some(current) = get_dotted(&payload.args, &rule.field).cloned() else { + continue; // missing field → no pipeline to run + }; + let eval = evaluate_pipeline( + &rule.pipeline, + ¤t, + bag, + plugins, + &rule.field, + DispatchPhase::Pre, + ) + .await; + taints.extend(eval.taints); + match eval.outcome { + FieldOutcome::Pass => {} + FieldOutcome::Replace(new_val) => { + if set_dotted(&mut payload.args, &rule.field, new_val) { + args_modified = true; + } + } + FieldOutcome::Omit => { + if remove_dotted(&mut payload.args, &rule.field) { + args_modified = true; + } + } + FieldOutcome::Deny { reason, stage_index: _ } => { + return RouteDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source: rule.source.clone(), + }, + taints, + args_modified, + result_modified: false, + }; + } + } + } + + // ----- policy ----- + let policy_eval = evaluate_effects( + &route.policy, + bag, + pdp, + plugins, + delegations, + DispatchPhase::Pre, + payload, + ) + .await; + // FieldOps inside `do:` may have rewritten args during policy — + // surface that to the host the same way as an `args:` pipeline. + args_modified |= policy_eval.args_modified; + taints.extend(policy_eval.taints); + RouteDecision { + decision: policy_eval.decision, + taints, + args_modified, + result_modified: false, + } +} + +/// Run the **post-invocation** phases: `result` (if a response payload +/// is present) then `post_policy`. Used by orchestrators bound to a +/// `tool_post_invoke`-style hook. +/// +/// On a phase Deny, halts. `result_modified` is set if any result field +/// was rewritten or omitted; `args_modified` is always `false` (this +/// function doesn't touch args). +pub async fn evaluate_post( + route: &CompiledRoute, + bag: &mut AttributeBag, + payload: &mut RoutePayload, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, +) -> RouteDecision { + let mut taints: Vec = Vec::new(); + let mut result_modified = false; + + // ----- result (only when a response payload is present) ----- + if let Some(result) = payload.result.as_mut() { + for rule in &route.result { + let Some(current) = get_dotted(result, &rule.field).cloned() else { + continue; + }; + let eval = evaluate_pipeline( + &rule.pipeline, + ¤t, + bag, + plugins, + &rule.field, + DispatchPhase::Post, + ) + .await; + taints.extend(eval.taints); + match eval.outcome { + FieldOutcome::Pass => {} + FieldOutcome::Replace(new_val) => { + if set_dotted(result, &rule.field, new_val) { + result_modified = true; + } + } + FieldOutcome::Omit => { + if remove_dotted(result, &rule.field) { + result_modified = true; + } + } + FieldOutcome::Deny { reason, stage_index: _ } => { + return RouteDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source: rule.source.clone(), + }, + taints, + args_modified: false, + result_modified, + }; + } + } + } + } + + // ----- post_policy ----- + let post_eval = evaluate_effects( + &route.post_policy, + bag, + pdp, + plugins, + delegations, + DispatchPhase::Post, + payload, + ) + .await; + // Same reason as the policy phase: a `do:`-embedded FieldOp may + // have rewritten result fields during post_policy. + result_modified |= post_eval.result_modified; + taints.extend(post_eval.taints); + + RouteDecision { + decision: post_eval.decision, + taints, + args_modified: false, + result_modified, + } +} + +/// Run all four phases against `payload`, mutating it in place. +/// Convenience wrapper for callers that don't need the pre/post split +/// (tests, single-hook hosts). Calls [`evaluate_pre`] then [`evaluate_post`], +/// skipping post entirely on a pre-side Deny. Taints from both halves +/// concatenate; `args_modified` and `result_modified` carry their +/// respective flags independently. +/// +/// Orchestrators that need to fire on distinct pre/post hooks should +/// call [`evaluate_pre`] and [`evaluate_post`] separately so the post +/// half sees the payload after the tool has produced its response. +pub async fn evaluate_route( + route: &CompiledRoute, + bag: &mut AttributeBag, + payload: &mut RoutePayload, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, +) -> RouteDecision { + let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations).await; + if matches!(pre.decision, Decision::Deny { .. }) { + return pre; + } + let post = evaluate_post(route, bag, payload, pdp, plugins, delegations).await; + let mut taints = pre.taints; + taints.extend(post.taints); + RouteDecision { + decision: post.decision, + taints, + args_modified: pre.args_modified, + result_modified: post.result_modified, + } +} + +// ===================================================================== +// Dotted-path JSON helpers +// ===================================================================== + +/// Read `root.a.b.c` from a JSON value via dot-separated path. Returns +/// `None` if any segment is missing or the path crosses a non-object. +pub(crate) fn get_dotted<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + let mut cur = root; + for seg in path.split('.') { + cur = cur.get(seg)?; + } + Some(cur) +} + +/// Write to `root.a.b.c` via dot-separated path. Returns true on success; +/// false if the parent path doesn't exist or doesn't resolve to an object. +/// Does not create missing parent objects — that'd hide schema bugs. +pub(crate) fn set_dotted(root: &mut serde_json::Value, path: &str, value: serde_json::Value) -> bool { + let parts: Vec<&str> = path.split('.').collect(); + let (leaf, parents) = match parts.split_last() { + Some(x) => x, + None => return false, + }; + let mut cur = root; + for seg in parents { + let Some(next) = cur.get_mut(*seg) else { return false; }; + if !next.is_object() { return false; } + cur = next; + } + if let serde_json::Value::Object(map) = cur { + map.insert((*leaf).to_string(), value); + true + } else { + false + } +} + +/// Remove `root.a.b.c` from a JSON value. Returns true if removal happened. +pub(crate) fn remove_dotted(root: &mut serde_json::Value, path: &str) -> bool { + let parts: Vec<&str> = path.split('.').collect(); + let (leaf, parents) = match parts.split_last() { + Some(x) => x, + None => return false, + }; + let mut cur = root; + for seg in parents { + let Some(next) = cur.get_mut(*seg) else { return false; }; + if !next.is_object() { return false; } + cur = next; + } + if let serde_json::Value::Object(map) = cur { + map.remove(*leaf).is_some() + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::{FieldRule, Pipeline, Stage, TaintScope, TypeCheck}; + use crate::rules::{Effect, Expression, Rule}; + use crate::step::{ + NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PluginError, + PluginInvocation, PluginOutcome, + }; + use async_trait::async_trait; + use serde_json::json; + + // ----- Fixtures ----- + + struct AllowPdp; + #[async_trait] + impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + } + } + + struct NoPlugins; + #[async_trait] + impl PluginInvoker for NoPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } + } + + // `evaluate_route` takes `&Arc` / `&Arc` + // so the path through `dispatch_parallel` can `Arc::clone` into each + // spawned branch. These helpers wrap the no-op test stubs once per call. + fn pdp_arc() -> Arc { + Arc::new(AllowPdp) + } + fn plugins() -> Arc { + Arc::new(NoPlugins) + } + fn delegations() -> Arc { + Arc::new(NoopDelegationInvoker) + } + + fn field_rule(field: &str, stages: Vec) -> FieldRule { + FieldRule { + field: field.into(), + pipeline: Pipeline { stages }, + source: format!("test.{}", field), + } + } + + fn deny_rule(source: &str, reason: &str) -> Rule { + Rule::single( + Expression::Always, + Effect::Deny { reason: Some(reason.into()), code: None }, + source, + ) + } + + // ----- Tests ----- + + #[tokio::test] + async fn empty_route_allows() { + let route = CompiledRoute::new("noop"); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({})); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified); + assert!(!r.result_modified); + assert!(r.taints.is_empty()); + } + + #[tokio::test] + async fn args_pipeline_mutates_payload() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "ssn": "123-45-6789" })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified); + assert_eq!(payload.args["ssn"], json!("*******6789")); + } + + #[tokio::test] + async fn args_deny_halts_route() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule( + "amount", + vec![ + Stage::Type(TypeCheck::Int), + Stage::Range { min: Some(0), max: Some(100) }, + ], + )); + // Also has a policy rule that would deny — should NOT be reached + // (args deny short-circuits). If reached, source would be "policy[0]" + // instead of the args rule's source. + route.policy.push(Effect::from(deny_rule("policy[0]", "policy denied too"))); + + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "amount": 200 })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("amount"), "expected args rule source, got {}", rule_source); + } + d => panic!("expected Deny from args phase, got {:?}", d), + } + } + + #[tokio::test] + async fn args_missing_field_is_skipped() { + // Pipeline references `compensation`, payload doesn't have it → + // missing-field rule is skipped silently, route allows. + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("compensation", vec![Stage::Type(TypeCheck::Int)])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "other_field": 5 })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified); + } + + #[tokio::test] + async fn args_omit_drops_field() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("secret", vec![Stage::Omit])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "secret": "xyz", "keep": 1 })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified); + assert!(payload.args.get("secret").is_none()); + assert_eq!(payload.args["keep"], json!(1)); + } + + #[tokio::test] + async fn policy_deny_halts_before_result() { + let mut route = CompiledRoute::new("ping"); + route.policy.push(Effect::from(deny_rule("policy[0]", "blocked"))); + // Result rule should never run. + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "policy[0]"), + d => panic!("expected policy deny, got {:?}", d), + } + assert!(!r.result_modified); + // Result payload not mutated — redact didn't run. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("123")); + } + + #[tokio::test] + async fn result_phase_skipped_when_no_response() { + let mut route = CompiledRoute::new("ping"); + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({})); // no result + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.result_modified); + } + + #[tokio::test] + async fn result_pipeline_redacts_field() { + let mut route = CompiledRoute::new("ping"); + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result( + json!({}), + json!({ "ssn": "123-45-6789", "name": "alice" }), + ); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.result_modified); + let result = payload.result.as_ref().unwrap(); + assert_eq!(result["ssn"], json!("[REDACTED]")); + assert_eq!(result["name"], json!("alice")); + } + + #[tokio::test] + async fn taints_accumulate_across_phases() { + let mut route = CompiledRoute::new("ping"); + // args emits a taint + route.args.push(field_rule( + "input", + vec![Stage::Taint { label: "args_seen".into(), scopes: vec![TaintScope::Session] }], + )); + // result emits a different taint + route.result.push(field_rule( + "output", + vec![Stage::Taint { label: "result_seen".into(), scopes: vec![TaintScope::Message] }], + )); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result( + json!({ "input": "hello" }), + json!({ "output": "world" }), + ); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + let labels: Vec<&str> = r.taints.iter().map(|t| t.label.as_str()).collect(); + assert_eq!(labels, vec!["args_seen", "result_seen"]); + } + + #[tokio::test] + async fn nested_field_path_resolves_and_writes() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule( + "user.profile.ssn", + vec![Stage::Mask { keep_last: 4 }], + )); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ + "user": { "profile": { "ssn": "123-45-6789", "name": "alice" } } + })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified); + assert_eq!(payload.args["user"]["profile"]["ssn"], json!("*******6789")); + assert_eq!(payload.args["user"]["profile"]["name"], json!("alice")); + } + + #[tokio::test] + async fn nested_field_missing_intermediate_is_skipped() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("user.profile.ssn", vec![Stage::Mask { keep_last: 4 }])); + let mut bag = AttributeBag::new(); + // `profile` segment is missing → get_dotted returns None → skip. + let mut payload = RoutePayload::new(json!({ "user": { "name": "alice" } })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified); + } + + #[tokio::test] + async fn post_policy_runs_after_result() { + let mut route = CompiledRoute::new("ping"); + // Result mutates a field, then post_policy denies. + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + route.post_policy.push(Effect::from(deny_rule("post_policy[0]", "after-the-fact"))); + + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "post_policy[0]"), + d => panic!("expected post_policy deny, got {:?}", d), + } + // Result was still mutated before the post_policy deny fired. + assert!(r.result_modified); + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]")); + } + + // ----- Helper unit tests ----- + + #[test] + fn dotted_get_simple_and_nested() { + let v = json!({ "a": { "b": { "c": 7 } } }); + assert_eq!(get_dotted(&v, "a.b.c"), Some(&json!(7))); + assert_eq!(get_dotted(&v, "a.b"), Some(&json!({ "c": 7 }))); + assert!(get_dotted(&v, "a.b.x").is_none()); + assert!(get_dotted(&v, "missing").is_none()); + } + + #[test] + fn dotted_set_overwrites_leaf() { + let mut v = json!({ "a": { "b": 1 } }); + assert!(set_dotted(&mut v, "a.b", json!(99))); + assert_eq!(v["a"]["b"], json!(99)); + } + + #[test] + fn dotted_set_does_not_create_missing_parents() { + // Strict: if `a.b` parent doesn't exist, set fails (no auto-vivify). + let mut v = json!({}); + assert!(!set_dotted(&mut v, "a.b", json!(1))); + assert_eq!(v, json!({})); + } + + #[test] + fn dotted_remove_leaf() { + let mut v = json!({ "a": { "b": 1, "c": 2 } }); + assert!(remove_dotted(&mut v, "a.b")); + assert_eq!(v, json!({ "a": { "c": 2 } })); + // Removing a missing leaf returns false. + assert!(!remove_dotted(&mut v, "a.b")); + } + + // ----- evaluate_pre / evaluate_post (phase split) ----- + + #[tokio::test] + async fn evaluate_pre_runs_args_and_policy_only() { + // Route with both args validators + result transforms. evaluate_pre + // should run args (mutating payload.args), policy (allow here), + // but NOT result — payload.result stays exactly as given. + let mut route = CompiledRoute::new("test"); + route.args.push(field_rule("id", vec![ + Stage::Mask { keep_last: 2 }, + ])); + route.result.push(field_rule("ssn", vec![ + Stage::Redact { condition: None }, + ])); + + let mut payload = RoutePayload::with_result( + json!({ "id": "ABCDEFGH" }), + json!({ "ssn": "555-12-3456" }), + ); + let mut bag = AttributeBag::new(); + let r = evaluate_pre(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified, "args mask stage should have rewritten the field"); + assert!(!r.result_modified, "evaluate_pre must not touch result"); + // Args was rewritten by mask(2). + assert_eq!(payload.args["id"], json!("******GH")); + // Result is untouched — post hasn't run. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("555-12-3456")); + } + + #[tokio::test] + async fn evaluate_post_runs_result_and_post_policy_only() { + // Route with args + result. evaluate_post skips args entirely + // (no mutation), runs result + post_policy. + let mut route = CompiledRoute::new("test"); + route.args.push(field_rule("id", vec![ + Stage::Mask { keep_last: 2 }, + ])); + route.result.push(field_rule("ssn", vec![ + Stage::Redact { condition: None }, + ])); + + let mut payload = RoutePayload::with_result( + json!({ "id": "ABCDEFGH" }), + json!({ "ssn": "555-12-3456" }), + ); + let mut bag = AttributeBag::new(); + let r = evaluate_post(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified, "evaluate_post must not touch args"); + assert!(r.result_modified, "result redact should have fired"); + // Args is untouched by evaluate_post. + assert_eq!(payload.args["id"], json!("ABCDEFGH")); + // Result was redacted. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]")); + } + + #[tokio::test] + async fn evaluate_pre_deny_halts_before_policy() { + // Args has a type validator that fails → pre denies before policy runs. + let mut route = CompiledRoute::new("test"); + route.args.push(field_rule("id", vec![Stage::Type(TypeCheck::Uuid)])); + // Policy that would always deny if it ran — assert it doesn't. + route.policy.push(Effect::from(Rule::single( + Expression::Always, + Effect::Deny { reason: Some("policy_should_not_run".into()), code: None }, + "test.policy[0]", + ))); + + let mut payload = RoutePayload::new(json!({ "id": "not-a-uuid" })); + let mut bag = AttributeBag::new(); + let r = evaluate_pre(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("test.id"), "args denial got source {}", rule_source); + } + d => panic!("expected args-side Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn evaluate_route_skips_post_on_pre_deny() { + // Wrapper preserves "deny halts before post" — proves the + // refactor didn't regress evaluate_route's semantics. + let mut route = CompiledRoute::new("test"); + route.policy.push(Effect::from(Rule::single( + Expression::Always, + Effect::Deny { reason: Some("policy_deny".into()), code: None }, + "test.policy[0]", + ))); + route.result.push(field_rule("ssn", vec![ + Stage::Redact { condition: None }, + ])); + route.post_policy.push(Effect::Taint { + label: "should_not_emit".into(), + scopes: vec![TaintScope::Session], + }); + + let mut payload = RoutePayload::with_result( + json!({}), + json!({ "ssn": "555-12-3456" }), + ); + let mut bag = AttributeBag::new(); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); + assert!(!r.result_modified, "post must be skipped on pre-side Deny"); + // post_policy never ran, so its taint never landed. + assert!(r.taints.is_empty()); + // Result untouched. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("555-12-3456")); + } +} diff --git a/crates/apl-core/src/rules.rs b/crates/apl-core/src/rules.rs new file mode 100644 index 00000000..7fd707aa --- /dev/null +++ b/crates/apl-core/src/rules.rs @@ -0,0 +1,840 @@ +// Location: ./crates/apl-core/src/rules.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL intermediate representation. +// +// The compiler (later) produces a `CompiledRoute` per route_key from +// YAML / database / any other ConfigSource. The evaluator (later) +// consumes the IR plus an AttributeBag and returns a decision. +// +// IR types are kept small and pure-data — no dependencies on cpex-core +// extensions, no evaluation logic. See docs/specs/apl-design.md §7. + +use serde::{Deserialize, Serialize}; + +/// Comparison operators in DSL predicates. +/// +/// `In` / `NotIn` are intentionally absent: the DSL spec §2.4 has them as +/// `value_key in set_key` — both sides are attribute references, not a +/// key-vs-literal shape. They'll land as a dedicated `Condition` variant +/// when the parser arrives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompareOp { + Eq, + NotEq, + Gt, + GtEq, + Lt, + LtEq, + /// ` contains ` — left is a StringSet attribute, + /// right is a string literal. + Contains, +} + +/// Right-hand side of a comparison. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Literal { + Bool(bool), + Int(i64), + Float(f64), + String(String), +} + +impl From for Literal { fn from(v: bool) -> Self { Literal::Bool(v) } } +impl From for Literal { fn from(v: i64) -> Self { Literal::Int(v) } } +impl From for Literal { fn from(v: f64) -> Self { Literal::Float(v) } } +impl From<&str> for Literal { fn from(v: &str) -> Self { Literal::String(v.to_string()) } } +impl From for Literal { fn from(v: String) -> Self { Literal::String(v) } } + +/// Leaf predicate. +/// +/// `Comparison` covers `key op value`. The truthiness checks are split out +/// (`IsTrue` / `IsFalse`) because they're the most common form — `authenticated`, +/// `role.hr`, `delegated`. +/// +/// The DSL's `require(...)` keyword is **not** represented here — it's a +/// rule-level shorthand for "deny when the condition fails," and the parser +/// desugars it into `Not` / `And` / `Or` over `IsFalse` expressions plus +/// an `Action::Deny`. See DSL spec §8.1 desugarings. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Condition { + Comparison { key: String, op: CompareOp, value: Literal }, + IsTrue { key: String }, + IsFalse { key: String }, + /// DSL `exists(key)` — true iff the key is present in the + /// AttributeBag, regardless of its value. Distinct from `IsTrue` + /// (which only succeeds for truthy values). Per DSL §2.2. + Exists { key: String }, + /// DSL `value_key in set_key` (negate=false) / `value_key not in set_key` + /// (negate=true). Both operands are attribute keys, not literals — the + /// scalar at `value_key` is checked for membership in the StringSet at + /// `set_key`. Per DSL §2.4. Returns `false` if either key is missing or + /// the types don't match (scalar must resolve to a string). + InSet { value_key: String, set_key: String, negate: bool }, +} + +/// Compound predicate. +/// +/// `Always` is the implicit-true predicate for bare-effect rules +/// (DSL §3.1): `- plugin(rate_limiter)` / `- taint(audit)` / unconditional +/// `- deny` / `- allow`. It's never produced by predicate-string parsing +/// — only by rule-level forms where no `when:` is supplied. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Expression { + Condition(Condition), + And(Vec), + Or(Vec), + Not(Box), + Always, +} + +/// One thing a matching rule does. Mirrors DSL spec §3 effect classes: +/// +/// * Control — `Allow`, `Deny` +/// * Label — `Taint` +/// * Host — `Plugin`, `Delegate` +/// +/// Content effects (`redact`, `mask`, `omit`, `hash`) and orchestration +/// (`Sequential`, `Parallel`) land in later slices (E2 / E3). +/// +/// PDP calls (`cedar:(…)`, `opa(…)`, …) remain top-level [`Step`] +/// variants for now; folding them into `Effect` is an E4 cleanup. +/// +/// # Inside a `Vec` (a rule's `effects` body) +/// +/// * `Allow` is a no-op — lets evaluation continue to the next effect +/// in the list, then to the next step in `policy:`. +/// * `Deny` short-circuits the rest of the list, the rest of the +/// `policy:` block, and the route. The `reason` propagates into +/// the violation message. +/// * `Plugin` / `Delegate` dispatch identically to their top-level +/// `Step` counterparts (same invoker traits). +/// * `Taint` accumulates into the phase's taint events. +/// +/// [`Step`]: crate::step::Step +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Effect { + Allow, + Deny { + reason: Option, + /// Author-supplied stable violation code. When `Some`, it + /// overrides the rule's auto-generated source-position code + /// (`routes.tool:X.apl.policy[N]`) downstream. Useful when + /// MCP clients want to dispatch on category (`quota.exceeded`, + /// `delegation.depth_exceeded`) rather than position, or when + /// multiple routes share a deny category that should + /// aggregate consistently in audit dashboards. When `None`, + /// the evaluator falls back to `rule.source` as the code — + /// matches the historical behavior. + /// + /// Parser shape: `deny('reason', 'code')` (two positional + /// arguments) or the structured `deny: { reason: ..., code: ... }` + /// map form. + code: Option, + }, + Plugin { + name: String, + }, + Delegate(crate::step::DelegateStep), + Taint { + label: String, + scopes: Vec, + }, + /// Content effect (DSL §3) — apply a pipe chain (`redact`, `mask`, + /// `omit`, `hash`, validators, transforms) to a field in the + /// route's args or result. The author writes + /// `result.salary | redact` inside a `do:` body; the parser + /// splits the dotted path from the pipeline. + /// + /// `path` must start with `args.` or `result.` — the evaluator + /// dispatches the lookup against `RoutePayload.args` or + /// `RoutePayload.result`. A FieldOp inside a Pre-phase route's + /// `do:` that targets `result.X` is a no-op (the result hasn't + /// been produced yet); same goes for a Post-phase rule that + /// targets `args.X` (the args are already on the wire). The + /// evaluator silently skips out-of-phase ops so the same + /// `when:`/`do:` shape can describe both phases without + /// branching. + FieldOp { + path: String, + stages: Vec, + }, + /// Run a list of effects in declaration order, stopping on the + /// first Deny. Semantically equivalent to inlining the list into + /// the enclosing scope; the variant exists to make grouping + /// explicit and to pair with `Parallel`. + Sequential(Vec), + /// Run a list of effects concurrently. Any Deny → overall Deny. + /// Taints from all branches accumulate. Bag and payload mutations + /// inside parallel branches are **discarded** when the branch + /// completes — each branch gets a clone of the state, never the + /// shared mutable original. Plugins inside `Parallel` can still + /// emit taints (those merge); any other mutation they try to make + /// (bag writes, args/result rewrites) vanishes. + /// + /// Config-load rejects `FieldOp` and `Delegate` directly inside + /// `Parallel` (recursively), since both would silently drop their + /// effect. The escape valve is `Sequential`. + Parallel(Vec), + /// Predicate-gated body. `body` runs in order when `condition` + /// evaluates to true; any Deny in the body halts the surrounding + /// phase. Replaces the historical `Step::Rule(Rule)` shape — + /// `when:` / `do:` directly desugars to this. A bare `require(X)` + /// or `deny(X)` shorthand compiles to `When { condition: X, + /// body: vec![Effect::Allow / Deny] }`. + /// + /// `source` is the human-readable origin (e.g. `"routes.X.policy[2]"`) + /// surfaced in `Decision::Deny.rule_source` when the body denies + /// without supplying its own code. + When { + condition: Expression, + body: Vec, + source: String, + }, + /// External PDP call. `on_allow` / `on_deny` are reaction effect + /// lists fired against the PDP's decision (DSL §7.5). Replaces + /// `Step::Pdp { ... }` — `args`-shape stays identical. + Pdp { + call: crate::step::PdpCall, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_allow: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_deny: Vec, + }, +} + +impl Effect { + /// Walk this effect (and any nested effects) checking whether any + /// node would mutate route state. Used by the config-load + /// validator to reject `FieldOp` / `Delegate` inside `Parallel` + /// since both would silently drop their effect in a discarded + /// branch. + pub fn contains_mutation(&self) -> bool { + match self { + Effect::FieldOp { .. } | Effect::Delegate(_) => true, + Effect::Sequential(effects) | Effect::Parallel(effects) => { + effects.iter().any(Effect::contains_mutation) + } + Effect::When { body, .. } => body.iter().any(Effect::contains_mutation), + Effect::Pdp { on_allow, on_deny, .. } => { + on_allow.iter().any(Effect::contains_mutation) + || on_deny.iter().any(Effect::contains_mutation) + } + Effect::Allow + | Effect::Deny { .. } + | Effect::Plugin { .. } + | Effect::Taint { .. } => false, + } + } + + /// Walk the effect tree rejecting any `FieldOp` / `Delegate` that + /// lives directly or transitively under a `Parallel` node. Returns + /// the path string of the first violation found (or `Ok(())` if + /// the tree is clean). Run at config-load. + pub fn validate_parallel_purity(&self) -> Result<(), String> { + match self { + Effect::Parallel(effects) => { + for e in effects { + if e.contains_mutation() { + return Err(format!( + "`parallel:` contains a mutation effect ({:?}); \ + use `sequential:` for ordered mutations", + e + )); + } + // Still validate nested parallels even if this one + // is "clean at the top" — e.g. parallel → sequential + // → parallel(field_op) is still illegal. + e.validate_parallel_purity()?; + } + Ok(()) + } + Effect::Sequential(effects) => { + for e in effects { + e.validate_parallel_purity()?; + } + Ok(()) + } + Effect::When { body, .. } => { + for e in body { + e.validate_parallel_purity()?; + } + Ok(()) + } + Effect::Pdp { on_allow, on_deny, .. } => { + for e in on_allow.iter().chain(on_deny.iter()) { + e.validate_parallel_purity()?; + } + Ok(()) + } + _ => Ok(()), + } + } +} + +/// One compiled rule: a predicate plus the effects to fire when it +/// matches. +/// +/// `effects` is always non-empty for parser-produced rules. The +/// historical "single Allow/Deny" cases are represented by a one-element +/// `Vec` — slightly more allocation than a flat enum, but keeps one +/// dispatch path instead of two and eliminates the ambiguity of having +/// both `Action::Allow` and `Effect::Allow` in the IR. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rule { + pub condition: Expression, + pub effects: Vec, + /// Human-readable source (original YAML line, file path, etc.). + /// Surfaces in audit logs and policy violation diagnostics. + pub source: String, +} + +impl Rule { + /// Construct a single-effect rule. Convenience for the common + /// `Allow` / `Deny` shapes that don't need a `vec![]` at the + /// call site. + pub fn single(condition: Expression, effect: Effect, source: impl Into) -> Self { + Self { + condition, + effects: vec![effect], + source: source.into(), + } + } +} + +/// `Rule` is structurally identical to `Effect::When`. The From impl lets +/// callers that already hold a `Rule` (notably the parser's inner helpers +/// and the test fixtures) drop a `.into()` instead of re-spelling all +/// three fields. Bridges the few remaining producers while the migration +/// completes; will probably stay long-term because the parser still +/// builds Rule incrementally before deciding it's an Effect::When. +impl From for Effect { + fn from(r: Rule) -> Effect { + Effect::When { + condition: r.condition, + body: r.effects, + source: r.source, + } + } +} + +/// One of the four lifecycle phases the evaluator runs per route. +/// +/// See docs/specs/apl-design.md §3 — the `PolicyEvaluator` trait has one +/// async method per phase. `declared_phases()` lets the host skip phases +/// the route doesn't use. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Args, + Policy, + Result, + PostPolicy, +} + +/// Bit-packed set of phases a route declared. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PhaseSet(u8); + +impl PhaseSet { + pub fn new() -> Self { Self(0) } + + pub fn insert(&mut self, p: Phase) { + self.0 |= Self::bit(p); + } + + pub fn contains(&self, p: Phase) -> bool { + self.0 & Self::bit(p) != 0 + } + + pub fn is_empty(&self) -> bool { self.0 == 0 } + + fn bit(p: Phase) -> u8 { + match p { + Phase::Args => 0b0001, + Phase::Policy => 0b0010, + Phase::Result => 0b0100, + Phase::PostPolicy => 0b1000, + } + } +} + +/// Compiler output for a single route. +/// +/// One `CompiledRoute` per route_key. The compiler merges global / default / +/// tag / route-specific rules from the config hierarchy down into these four +/// phase lists before the evaluator sees them — the IR has no notion of +/// "tag rules" or "route overrides," only "steps that fire in phase P." +/// +/// `args` and `result` are per-field pipelines (validators + transforms). +/// `policy` and `post_policy` are step lists — predicate-and-action rules +/// plus PDP calls, plugin invocations, and taint effects. See +/// apl-dsl-spec §1.2 / §4 / §7. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CompiledRoute { + pub route_key: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub policy: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub result: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub post_policy: Vec, + /// Per-plugin overrides declared on this route's `plugins:` block. + /// Keyed by plugin name; merged at dispatch time via + /// `EffectivePlugin::resolve(name, registry, &this.plugin_overrides)`. + /// Per spec only `config`, `capabilities`, `on_error` are overridable; + /// hooks/kind/source always come from the global declaration. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub plugin_overrides: std::collections::HashMap, +} + +impl CompiledRoute { + pub fn new(route_key: impl Into) -> Self { + Self { route_key: route_key.into(), ..Default::default() } + } + + /// Which phases this route uses. Empty phases are not declared. + pub fn declared_phases(&self) -> PhaseSet { + let mut set = PhaseSet::new(); + if !self.args.is_empty() { set.insert(Phase::Args); } + if !self.policy.is_empty() { set.insert(Phase::Policy); } + if !self.result.is_empty() { set.insert(Phase::Result); } + if !self.post_policy.is_empty() { set.insert(Phase::PostPolicy); } + set + } + + /// Apply a more-specific policy layer on top of this one. Used by + /// orchestrators (apl-cpex's visitor) to stack the unified-config + /// hierarchy least-to-most-specific: + /// + /// ```text + /// effective = CompiledRoute::default() + /// effective.apply_layer(global_block) + /// effective.apply_layer(default_block) + /// effective.apply_layer(tag_block) + /// effective.apply_layer(route_block) + /// ``` + /// + /// Each call adds the parameter on top of what's already there; + /// `more_specific` wins on collisions because it represents a + /// later/narrower layer in the inheritance chain. + /// + /// Merge semantics: + /// - **`policy` / `post_policy`**: `more_specific`'s steps append + /// *after* self's. Earlier layers run first — globals deny before + /// route-specific rules get a chance. + /// - **`args` / `result`**: per-field; if both layers declare the + /// same field, `more_specific`'s rule replaces self's. Fields + /// only in self stay; fields only in `more_specific` are added. + /// - **`plugin_overrides`**: HashMap merge; `more_specific` wins + /// on key collisions, otherwise prefix's entries fill gaps. + /// + /// `self.route_key` is preserved — apply_layer doesn't overwrite + /// identity, just policy content. + pub fn apply_layer(&mut self, more_specific: CompiledRoute) { + // policy / post_policy: more_specific's steps append AFTER self. + // Order of accumulated calls = order of evaluation. + self.policy.extend(more_specific.policy); + self.post_policy.extend(more_specific.post_policy); + + // args: more_specific wins on field collision — drop any self.args + // entries the new layer redefines, then push the new layer's. + let ms_fields: std::collections::HashSet = + more_specific.args.iter().map(|f| f.field.clone()).collect(); + self.args.retain(|f| !ms_fields.contains(&f.field)); + self.args.extend(more_specific.args); + + // result: same shape as args. + let ms_result_fields: std::collections::HashSet = + more_specific.result.iter().map(|f| f.field.clone()).collect(); + self.result.retain(|f| !ms_result_fields.contains(&f.field)); + self.result.extend(more_specific.result); + + // plugin_overrides: HashMap::extend overwrites on key collision, + // which is exactly the more_specific-wins semantic. + self.plugin_overrides.extend(more_specific.plugin_overrides); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_set_basic() { + let mut set = PhaseSet::new(); + assert!(set.is_empty()); + set.insert(Phase::Policy); + set.insert(Phase::Result); + assert!(set.contains(Phase::Policy)); + assert!(set.contains(Phase::Result)); + assert!(!set.contains(Phase::Args)); + assert!(!set.contains(Phase::PostPolicy)); + assert!(!set.is_empty()); + } + + #[test] + fn compiled_route_declared_phases() { + let mut route = CompiledRoute::new("get_compensation"); + assert!(route.declared_phases().is_empty()); + + route.policy.push(Effect::When { + condition: Expression::Condition(Condition::IsTrue { + key: "authenticated".into(), + }), + body: vec![Effect::Allow], + source: "policy[0]".into(), + }); + let phases = route.declared_phases(); + assert!(phases.contains(Phase::Policy)); + assert!(!phases.contains(Phase::Args)); + } + + #[test] + fn literal_from_impls() { + // From impls keep test/builder code readable. + let r = Rule { + condition: Expression::Condition(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: 2_i64.into(), + }), + effects: vec![Effect::Deny { reason: Some("too deep".into()), code: None }], + source: "policy[0]".into(), + }; + if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition { + assert_eq!(value, Literal::Int(2)); + } else { + panic!("expected Comparison"); + } + } + + #[test] + fn rule_serde_roundtrip() { + let r = Rule { + condition: Expression::And(vec![ + Expression::Condition(Condition::IsTrue { key: "authenticated".into() }), + Expression::Condition(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::LtEq, + value: 3_i64.into(), + }), + ]), + effects: vec![Effect::Allow], + source: "policy[1]".into(), + }; + let json = serde_json::to_string(&r).unwrap(); + let back: Rule = serde_json::from_str(&json).unwrap(); + // No PartialEq on Rule (would force PartialEq on Action's variants + // with floats etc.); spot-check the discriminator path instead. + assert!(matches!(back.effects.as_slice(), [Effect::Allow])); + assert_eq!(back.source, "policy[1]"); + } + + #[test] + fn compiled_route_serde_skips_empty_phases() { + let route = CompiledRoute::new("ping"); + let json = serde_json::to_string(&route).unwrap(); + // Empty phase vecs should not serialize — keeps audit logs clean. + assert_eq!(json, r#"{"route_key":"ping"}"#); + } + + #[test] + fn apply_layer_appends_policy_and_post_policy_in_evaluation_order() { + // Start with global (least specific), then layer route on top. + // After: global.policy[0] runs first, route.policy[0] runs second. + let mut effective = CompiledRoute::new("route.get_compensation"); + // Seed effective with global content (simulating having already + // applied the global layer once). + effective.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "global.policy[0]".into(), + }); + effective.post_policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "global.post_policy[0]".into(), + }); + + // Now apply the route-specific layer on top. + let mut route_layer = CompiledRoute::new("ignored"); + route_layer.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "route.policy[0]".into(), + }); + route_layer.post_policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "route.post_policy[0]".into(), + }); + + effective.apply_layer(route_layer); + + // global ran first, route ran second — first-deny-wins respects + // the hierarchy. + assert_eq!(effective.policy.len(), 2); + match &effective.policy[0] { + Effect::When { source, .. } => assert_eq!(source, "global.policy[0]"), + _ => panic!(), + } + match &effective.policy[1] { + Effect::When { source, .. } => assert_eq!(source, "route.policy[0]"), + _ => panic!(), + } + assert_eq!(effective.post_policy.len(), 2); + + // route_key preserved (apply_layer doesn't touch identity). + assert_eq!(effective.route_key, "route.get_compensation"); + } + + #[test] + fn apply_layer_args_more_specific_wins_on_field_collision() { + use crate::pipeline::{FieldRule, Pipeline, Stage, TypeCheck}; + + // Start with the default (less specific) layer. + let mut effective = CompiledRoute::new("route.X"); + effective.args.push(FieldRule { + field: "id".into(), + pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Str)] }, + source: "default.args.id".into(), + }); + effective.args.push(FieldRule { + field: "trace_id".into(), + pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Str)] }, + source: "default.args.trace_id".into(), + }); + + // Layer route (more specific) on top — it redefines `id`. + let mut route_layer = CompiledRoute::new("ignored"); + route_layer.args.push(FieldRule { + field: "id".into(), + pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Uuid)] }, + source: "route.args.id".into(), + }); + + effective.apply_layer(route_layer); + + assert_eq!(effective.args.len(), 2); + // `id` is now the route's (Uuid), not the default's (Str). + let id_rule = effective.args.iter().find(|f| f.field == "id").unwrap(); + assert!(matches!(id_rule.pipeline.stages[0], Stage::Type(TypeCheck::Uuid))); + assert_eq!(id_rule.source, "route.args.id"); + // `trace_id` survives from the default — route didn't touch it. + let trace = effective.args.iter().find(|f| f.field == "trace_id").unwrap(); + assert_eq!(trace.source, "default.args.trace_id"); + } + + #[test] + fn apply_layer_plugin_overrides_more_specific_wins() { + use crate::plugin_decl::PluginOverride; + + // Default (less specific) layer. + let mut effective = CompiledRoute::new("route.X"); + effective.plugin_overrides.insert( + "rate_limiter".into(), + PluginOverride { on_error: Some("ignore".into()), ..Default::default() }, + ); + effective.plugin_overrides.insert( + "audit_logger".into(), + PluginOverride { on_error: Some("ignore".into()), ..Default::default() }, + ); + + // Route (more specific) layer overrides rate_limiter. + let mut route_layer = CompiledRoute::new("ignored"); + route_layer.plugin_overrides.insert( + "rate_limiter".into(), + PluginOverride { on_error: Some("fail".into()), ..Default::default() }, + ); + + effective.apply_layer(route_layer); + + assert_eq!(effective.plugin_overrides.len(), 2); + assert_eq!( + effective.plugin_overrides["rate_limiter"].on_error.as_deref(), + Some("fail"), + "route's override wins on collision", + ); + // audit_logger untouched — route didn't redefine it. + assert_eq!( + effective.plugin_overrides["audit_logger"].on_error.as_deref(), + Some("ignore"), + ); + } + + #[test] + fn apply_layer_chained_walks_hierarchy_in_specificity_order() { + // Build effective policy by applying layers least-to-most-specific. + // Mirrors how AplConfigVisitor will compose global/default/tag/route. + let mut effective = CompiledRoute::new("route.get_compensation"); + + let mut global = CompiledRoute::default(); + global.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "global.policy[0]".into(), + }); + + let mut default = CompiledRoute::default(); + default.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "default.policy[0]".into(), + }); + + let mut tag = CompiledRoute::default(); + tag.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "tag.hr.policy[0]".into(), + }); + + let mut route = CompiledRoute::default(); + route.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "route.policy[0]".into(), + }); + + effective.apply_layer(global); + effective.apply_layer(default); + effective.apply_layer(tag); + effective.apply_layer(route); + + // Order of calls = order of evaluation. global runs first, + // route runs last (first-deny-wins lets globals deny early). + let sources: Vec<&str> = effective + .policy + .iter() + .map(|s| match s { + Effect::When { source, .. } => source.as_str(), + _ => "", + }) + .collect(); + assert_eq!( + sources, + vec![ + "global.policy[0]", + "default.policy[0]", + "tag.hr.policy[0]", + "route.policy[0]", + ] + ); + } + + // ----- E3: parallel-purity validation ----- + + #[test] + fn validate_parallel_pure_block_passes() { + // A parallel block of read-only effects validates clean. + let effect = Effect::Parallel(vec![ + Effect::Plugin { name: "rate_limiter".into() }, + Effect::Plugin { name: "audit".into() }, + Effect::Allow, + ]); + assert!(effect.validate_parallel_purity().is_ok()); + } + + #[test] + fn validate_parallel_rejects_field_op() { + // FieldOp would silently lose its mutation in a discarded + // branch — config-load surfaces this loudly. + let effect = Effect::Parallel(vec![ + Effect::Plugin { name: "audit".into() }, + Effect::FieldOp { + path: "args.ssn".into(), + stages: vec![], + }, + ]); + let err = effect.validate_parallel_purity().unwrap_err(); + assert!(err.contains("mutation"), "got: {}", err); + assert!(err.contains("FieldOp"), "should name the offender: {}", err); + } + + #[test] + fn validate_parallel_rejects_delegate() { + // Same reason as FieldOp — the minted token would land in a + // bag that gets discarded. + let delegate = Effect::Delegate(crate::step::DelegateStep { + plugin_name: "workday".into(), + config_override: None, + on_error: None, + source: "test".into(), + }); + let effect = Effect::Parallel(vec![Effect::Allow, delegate]); + let err = effect.validate_parallel_purity().unwrap_err(); + assert!(err.contains("mutation")); + } + + #[test] + fn validate_parallel_recurses_into_nested_parallel() { + // `parallel → sequential → parallel(field_op)` — the inner + // parallel still illegal. Recursion must catch it. + let inner_parallel = Effect::Parallel(vec![Effect::FieldOp { + path: "args.x".into(), + stages: vec![], + }]); + let outer = Effect::Parallel(vec![ + Effect::Sequential(vec![Effect::Allow, inner_parallel]), + ]); + assert!(outer.validate_parallel_purity().is_err()); + } + + #[test] + fn validate_top_level_sequential_allows_mutations() { + // FieldOp / Delegate are allowed under Sequential (or at top + // level) — only Parallel rejects them. + let effect = Effect::Sequential(vec![ + Effect::FieldOp { + path: "args.ssn".into(), + stages: vec![], + }, + Effect::Allow, + ]); + assert!(effect.validate_parallel_purity().is_ok()); + } + + #[test] + fn validate_contains_mutation_classifies_each_variant() { + // White-box check on the helper so future Effect additions + // get flagged here when they should be classified. + assert!(!Effect::Allow.contains_mutation()); + assert!(!Effect::Deny { reason: None, code: None }.contains_mutation()); + assert!(!Effect::Plugin { name: "x".into() }.contains_mutation()); + assert!(!Effect::Taint { + label: "x".into(), + scopes: vec![], + } + .contains_mutation()); + + assert!(Effect::FieldOp { + path: "args.x".into(), + stages: vec![], + } + .contains_mutation()); + assert!(Effect::Delegate(crate::step::DelegateStep { + plugin_name: "x".into(), + config_override: None, + on_error: None, + source: "x".into(), + }) + .contains_mutation()); + + // Composite — mutates iff any child mutates. + let pure_seq = Effect::Sequential(vec![Effect::Allow]); + assert!(!pure_seq.contains_mutation()); + let dirty_seq = Effect::Sequential(vec![Effect::FieldOp { + path: "args.x".into(), + stages: vec![], + }]); + assert!(dirty_seq.contains_mutation()); + } +} diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs new file mode 100644 index 00000000..dca1921e --- /dev/null +++ b/crates/apl-core/src/step.rs @@ -0,0 +1,506 @@ +// Location: ./crates/apl-core/src/step.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Policy-phase Step IR and async dispatch traits. +// +// The DSL allows policy:/post_policy: lists to contain three kinds of +// entries beyond predicate-and-action rules: +// +// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)` +// with optional `on_deny:` / `on_allow:` reaction blocks +// - Plugin invocations: `plugin(name)` +// - Taint effects: `taint(label[, scope])` +// +// `Step` is the union over these forms plus the existing `Rule`. The async +// `evaluate_steps` function walks a Step list, dispatching PDP calls via +// `PdpResolver` and plugin calls via `PluginInvoker`. Taint dispatch is +// recognized but no-op in apl-core — actual SessionStore writes happen in +// `apl-cpex`, which has access to that machinery. +// +// Grounded in apl-dsl-spec.md §3 (effects) / §7 (PDP integration) and +// apl-design.md §8.1 (PdpResolver seam). + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::evaluator::Decision; +use crate::pipeline::{TaintEvent, TaintScope}; +use crate::rules::Rule; + +/// Parser-internal intermediate IR. After the parser builds a Step +/// tree, `parser::step_to_top_level_effect` converts it into the +/// unified [`crate::rules::Effect`] used by the evaluator + every +/// public entry point. +/// +/// `Step` exists only because `parse_step` builds its nodes +/// incrementally and the conversion to `Effect::When` / +/// `Effect::Pdp` happens at the top of `compile_apl_blocks` once +/// the source position is known. Not part of the public API as of +/// E4 — external code dispatches on `Effect` everywhere. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Step { + /// Predicate-and-action rule (the existing 5a/5b/5c case). + Rule(Rule), + + /// External PDP call. `on_deny` / `on_allow` are reaction Step lists + /// that fire based on the PDP's decision (DSL §7.5). + Pdp { + call: PdpCall, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_deny: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_allow: Vec, + }, + + /// `plugin(name)` — invoke a CPEX-registered plugin. The plugin's + /// `PluginResult` decision becomes the step's outcome. + Plugin { name: String }, + + /// `delegate: { plugin: ..., ... }` — mint a downstream delegation + /// token via a TokenDelegateHook plugin. Populates + /// `delegation.granted_*` attributes in the bag so subsequent + /// rules in the same step list can read them. See + /// `docs/apl-identity-delegation-design.md`. + Delegate(DelegateStep), + + /// `taint(label[, scope])` — apply a taint label. Always succeeds; + /// never produces a Deny. SessionStore dispatch happens in apl-cpex. + Taint { label: String, scopes: Vec }, +} + +/// One delegation invocation inside `policy:` or `post_policy:`. +/// +/// At runtime the apl-cpex `DelegationInvoker` constructs a +/// `cpex_core::delegation::DelegationPayload` from +/// * the inbound bearer token (pulled from +/// `Extensions.raw_credentials.inbound_tokens`), +/// * this step's `args` (target / audience / permissions / mode / +/// attenuation, layered over the plugin's configured defaults), +/// * extensions-derived context (subject, prior delegation chain), +/// +/// then calls `manager.invoke_entries::(...)`. On +/// success the resulting `delegated_token` is written into +/// `Extensions.raw_credentials.delegated_tokens.*` and the granted +/// scopes / audience surface as `delegation.granted.*` attributes +/// in the policy bag for downstream rules to inspect. +/// +/// `args` is a free-form map because each delegation backend has its +/// own typed config shape; apl-core treats it as opaque and hands it +/// to the plugin via the existing per-call config-override pathway. +/// +/// # Multiple `delegate(...)` in one phase (most-recent-wins) +/// +/// Multiple `delegate(...)` steps in the same phase are supported — +/// each fires independently, each contributes to `Extensions` +/// (`raw_credentials.delegated_tokens` is a HashMap keyed on +/// audience+scope+mode so tokens accumulate; `delegation.chain` +/// grows with each hop). But the `delegation.granted.*` bag keys +/// are **overwritten** on each call — only the most recent +/// delegate's grants are queryable from downstream `require(...)` +/// rules. +/// +/// For fan-out flows that need multiple independently-queryable +/// grants, split into `policy:` + `post_policy:` or reach for a +/// future per-step `as:` alias (not in v0; see the design doc's +/// "Open design questions" section). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DelegateStep { + /// Plugin name — must reference an entry in the top-level + /// `plugins:` block that registers under the `token.delegate` + /// hook. + pub plugin_name: String, + + /// Per-call config overrides applied for this delegation only. + /// Layered on top of the plugin's default config; the framework's + /// `build_override_entries` plumbing handles the merge. + /// Common keys: `target`, `audience`, `permissions`, `mode`, + /// `attenuation`. Schema is plugin-defined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_override: Option, + + /// `deny | continue` — what to do when the plugin returns a + /// deny (e.g. IdP refusal, network error). `None` defaults to + /// `"deny"` (fail-closed; matches PDP step semantics). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_error: Option, + + /// Human-readable source path (e.g. + /// `"route.get_compensation.policy[2]"`) — used in audit and + /// `Decision::Deny.rule_source` when the step denies. + pub source: String, +} + +/// A PDP invocation, opaque-args style. Resolvers parse `args` based on +/// the dialect they handle — apl-core doesn't impose a Cedar/OPA/AuthZen +/// schema on `args`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PdpCall { + pub dialect: PdpDialect, + /// Dialect-specific call arguments — typically a map for Cedar + /// (`action`, `resource`, …) or a string for OPA/AuthZen/NeMo + /// (a path or query). Resolvers parse this; apl-core treats it + /// as opaque. + pub args: serde_yaml::Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum PdpDialect { + /// Bare Cedar policy evaluation (`apl-pdp-cedar-direct`). + Cedar, + /// Cedarling-mediated Cedar evaluation — same language but + /// adds signed policy stores, multi-issuer JWT validation, and + /// (with Lock Server) centralized policy management. Distinct + /// from `Cedar` so both can coexist in a single `PdpRouter`; + /// route YAML can target either with `cedar:(...)` or + /// `cedarling:(...)` keys. + Cedarling, + Opa, + AuthZen, + NeMo, + #[serde(untagged)] + Custom(String), +} + +impl PdpDialect { + /// Parse a YAML key prefix like `cedar`, `cedarling`, `opa`, + /// `authzen`, `nemo` into the matching `PdpDialect`. Unknown + /// dialects become `Custom`. + pub fn from_key(key: &str) -> Self { + match key { + "cedar" => Self::Cedar, + "cedarling" => Self::Cedarling, + "opa" => Self::Opa, + "authzen" => Self::AuthZen, + "nemo" => Self::NeMo, + other => Self::Custom(other.to_string()), + } + } +} + +// ===================================================================== +// Resolver traits +// ===================================================================== + +/// External policy-decision dispatch. Implemented by Cedar/Cedarling, OPA +/// HTTP clients, AuthZen clients, NeMo Guardrails — anything that can +/// answer "given this call, allow or deny?" against a request context. +/// +/// `apl-cpex` provides the bridge from CPEX plugins (e.g. `cedar-direct`) +/// to this trait so the host doesn't have to know about the plugin types. +#[async_trait] +pub trait PdpResolver: Send + Sync { + /// What dialect this resolver handles. The evaluator routes PDP steps + /// to the resolver whose `dialect()` matches `Step::Pdp.call.dialect`. + fn dialect(&self) -> PdpDialect; + + async fn evaluate( + &self, + call: &PdpCall, + bag: &crate::attributes::AttributeBag, + ) -> Result; +} + +/// Build a [`PdpResolver`] from a unified-config block. Implemented per +/// PDP backend (cedar-direct, cedarling, opa, …) and registered with +/// the apl-cpex visitor so unified-config YAML can declare PDPs +/// without the host pre-constructing them in code. +/// +/// Hosts register a factory by handing it to apl-cpex's +/// `AplOptions.pdp_factories`. When the visitor walks the unified +/// config and finds a `global.apl.pdp[].kind` matching the factory's +/// reported `kind()`, it calls `build` with the rest of that block. +/// +/// The error type is `Box` to keep this trait +/// in apl-core (which has no cpex deps). apl-cpex's visitor wraps +/// the boxed error into `VisitorError` → `PluginError::Config` at the +/// manager boundary. +pub trait PdpFactory: Send + Sync { + /// Identifies which `kind:` in a config block this factory handles. + /// Convention: kebab-case matching the published PDP product name + /// (`"cedar-direct"`, `"cedarling"`, `"opa"`, …). + fn kind(&self) -> &str; + + /// Build a resolver from the rest of the PDP config block (everything + /// under the same map level as `kind`). Implementations parse their + /// own config shape; missing or malformed fields surface here. + fn build( + &self, + config: &serde_yaml::Value, + ) -> Result, Box>; +} + +/// Where in the request lifecycle a plugin dispatch is happening. +/// Threads through `PluginInvocation` so the invoker can select the +/// right hook entry from a plugin that registered for both pre and +/// post phases (e.g. `cmf.tool_pre_invoke` AND `cmf.tool_post_invoke`). +/// +/// APL's four phases map to two dispatch phases: +/// * `args:` field stages → `Pre` +/// * `policy:` steps → `Pre` +/// * `result:` field stages → `Post` +/// * `post_policy:` steps → `Post` +/// +/// Plugins that need to discriminate `args` vs `policy` (same `Pre` +/// from the dispatcher's perspective) inspect `PluginContext::hook_name()` +/// inside their handler — the hook-routing layer doesn't slice phase +/// finer than Pre/Post. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DispatchPhase { + Pre, + Post, +} + +/// Context for one plugin invocation: tells the invoker the *intent* of +/// the call so it can dispatch to the right CPEX hook contract. +/// +/// `Step` is the policy / post_policy case — the invoker (apl-cpex side) +/// already holds a typed payload reference; APL doesn't need to pass one. +/// +/// `Field` is the pipe-chain case — APL is focused on a specific field +/// value mid-transform and the plugin may rewrite that value via +/// `PluginOutcome.modified_value`. +/// +/// Both variants carry a `DispatchPhase` so the invoker can resolve the +/// right hook entry against the cpex-core hook routing table when the +/// plugin registered for multiple hooks. +#[derive(Debug, Clone, Copy)] +pub enum PluginInvocation<'a> { + /// Called from a `policy:` or `post_policy:` step. The plugin operates + /// on whatever typed payload the invoker was bound to. + Step { phase: DispatchPhase }, + /// Called inside an `args:` / `result:` pipe chain on one field. + Field { + name: &'a str, + value: &'a serde_json::Value, + phase: DispatchPhase, + }, +} + +impl<'a> PluginInvocation<'a> { + /// Convenience: the dispatch phase carried by this invocation. + pub fn phase(&self) -> DispatchPhase { + match self { + PluginInvocation::Step { phase } => *phase, + PluginInvocation::Field { phase, .. } => *phase, + } + } +} + +/// Plugin invocation dispatch. apl-cpex wraps the CPEX `PluginManager` +/// behind this trait so the apl-core evaluator stays free of cpex-core +/// dependencies. +#[async_trait] +pub trait PluginInvoker: Send + Sync { + /// Invoke the named plugin against the current request context. The + /// `invocation` discriminates step vs pipe-chain call. + async fn invoke( + &self, + name: &str, + bag: &crate::attributes::AttributeBag, + invocation: PluginInvocation<'_>, + ) -> Result; +} + +/// Delegation dispatch — invokes a `TokenDelegateHook` plugin to mint +/// a downstream credential. apl-cpex implements this against +/// `cpex_core::PluginManager::invoke_entries::`. +/// +/// The invoker holds the request-scoped `Extensions` internally +/// (same pattern as `CmfPluginInvoker`), so the trait method doesn't +/// need to pass them — the invoker uses its own snapshot to construct +/// the `DelegationPayload` (inbound bearer token, subject, prior +/// delegation chain). +#[async_trait] +pub trait DelegationInvoker: Send + Sync { + /// Run one delegation step. Returns a `DelegationOutcome` carrying + /// the granted permissions / audience / expiry the IdP issued; the + /// evaluator writes those into the bag as `delegation.granted_*` + /// attributes so subsequent rules in the same step list can + /// inspect them via `require(delegation.granted_permissions + /// contains "X")` etc. + /// + /// `step.config_override` is layered on top of the plugin's + /// default config and threaded through the standard per-call + /// override pathway. + async fn delegate( + &self, + step: &DelegateStep, + ) -> Result; +} + +/// What a delegation invocation returned. +/// +/// On success, `decision` is `Allow` and the granted_* fields reflect +/// what the IdP actually issued (which may be narrower than what the +/// route asked for — `granted_permissions` is the source of truth for +/// what the downstream tool will accept). The evaluator surfaces these +/// into the bag under the `delegation.granted.*` sub-namespace plus a +/// `delegation.granted = true` flag. +/// +/// On `Deny`, granted_* fields are empty / `None` and the +/// `delegation.granted` flag is not set (absent → falsy). +#[derive(Debug, Clone)] +pub struct DelegationOutcome { + pub decision: Decision, + /// Permissions the IdP actually granted on the minted token. Empty + /// when the call failed or the plugin returned no token. + pub granted_permissions: Vec, + /// Audience the minted token is valid for. `None` when no token + /// was produced. + pub granted_audience: Option, + /// Token expiry (RFC 3339 string for bag-friendly representation). + /// `None` when no token was produced. + pub granted_expires_at: Option, +} + +impl DelegationOutcome { + /// Convenience for the "deny, nothing granted" case. + pub fn deny(decision: Decision) -> Self { + Self { + decision, + granted_permissions: Vec::new(), + granted_audience: None, + granted_expires_at: None, + } + } +} + +#[derive(Debug, Error)] +pub enum DelegationError { + #[error("no delegation invoker available for plugin `{0}`")] + NotFound(String), + + #[error("delegation dispatch failed: {0}")] + Dispatch(String), +} + +/// `DelegationInvoker` impl that returns `NotFound` for every call. +/// Useful as the default for evaluator callers that don't run any +/// `delegate(...)` steps — they need to pass *something* implementing +/// the trait, but the noop never actually gets invoked. Tests and +/// hosts that haven't wired a real delegation backend pass this. +pub struct NoopDelegationInvoker; + +#[async_trait] +impl DelegationInvoker for NoopDelegationInvoker { + async fn delegate( + &self, + step: &DelegateStep, + ) -> Result { + Err(DelegationError::NotFound(step.plugin_name.clone())) + } +} + +// ===================================================================== +// Resolver results +// ===================================================================== + +/// What a PDP returned. +#[derive(Debug, Clone, PartialEq)] +pub struct PdpDecision { + pub decision: Decision, + /// Optional diagnostic info: matched policy IDs, error codes, etc. + /// Surfaces in audit logs; not used for control flow. + pub diagnostics: Vec, +} + +/// What a plugin returned. +#[derive(Debug, Clone)] +pub struct PluginOutcome { + pub decision: Decision, + /// Plugins may apply taint labels as a side effect. Same shape as + /// config-emitted taints (`Step::Taint` / `Stage::Taint`) so the + /// downstream evaluator can append both into a single + /// `Vec` without converting. Each event may carry + /// multiple scopes — `CmfPluginInvoker` uses single-scope + /// (`Session`) for v0 but future invokers and plugins that emit + /// directly are free to span scopes. + pub taints: Vec, + /// Pipe-context return: when a plugin runs as a stage inside an + /// args/result chain, it may rewrite the field value (e.g., a PII + /// scrubber producing a redacted string). `None` means "leave value + /// unchanged"; always `None` for policy / post_policy invocations. + pub modified_value: Option, +} + +impl PluginOutcome { + /// Convenience for the common "allow, no taints, no value change" case. + pub fn allow() -> Self { + Self { decision: Decision::Allow, taints: vec![], modified_value: None } + } +} + +// ===================================================================== +// Errors +// ===================================================================== + +#[derive(Debug, Error)] +pub enum PdpError { + #[error("no PDP resolver registered for dialect {0:?}")] + NoResolver(PdpDialect), + + #[error("PDP dispatch failed: {0}")] + Dispatch(String), +} + +#[derive(Debug, Error)] +pub enum PluginError { + #[error("no plugin invoker available for `{0}`")] + NotFound(String), + + #[error("plugin dispatch failed: {0}")] + Dispatch(String), +} + +// ===================================================================== +// Convenience +// ===================================================================== + +impl Step { + /// Wrap a `Rule` as a `Step`. Saves typing in tests and parser code. + pub fn rule(r: Rule) -> Self { Step::Rule(r) } + + /// Returns true if this step is a plain rule (no async dispatch needed). + pub fn is_rule(&self) -> bool { matches!(self, Step::Rule(_)) } +} + +/// Bag keys the delegation step writes after a successful dispatch. +/// Centralized here so the evaluator (writer) and policy authors +/// (readers, via `require(delegation.granted.*)`) agree on the +/// canonical names — typos in either place silently break the +/// IdP-as-PDP pattern. +/// +/// # Namespace +/// +/// The `delegation.*` namespace at the top level carries INBOUND +/// chain attributes (`delegation.depth`, `delegation.origin`, +/// `delegation.chain`, ...) populated by identity resolver plugins +/// via `IdentityPayload.delegation` + apply-to-extensions, then +/// surfaced through apl-cmf's BagBuilder. See +/// `docs/specs/delegation-hooks-rust-spec.md` §6.3 for that mapping. +/// +/// The `delegation.granted.*` sub-namespace defined here is for +/// OUTBOUND results — what came back from a `delegate(...)` step +/// the framework just ran. Two writers (identity plugin for inbound, +/// `delegate(...)` for outbound), distinct sub-trees, no collision. +pub mod delegation_bag_keys { + /// `StringSet` — permissions actually granted by the IdP on the + /// minted token. May be narrower than `required_permissions`. + pub const GRANTED_PERMISSIONS: &str = "delegation.granted.permissions"; + /// `String` — audience the minted token is valid for. + pub const GRANTED_AUDIENCE: &str = "delegation.granted.audience"; + /// `String` — token expiry as RFC 3339. + pub const GRANTED_EXPIRES_AT: &str = "delegation.granted.expires_at"; + /// `Bool` — set to `true` after a successful `delegate(...)` + /// step. Lets policy branch on success without inspecting the + /// granted_permissions set: `require(delegation.granted)`. Absent + /// (i.e. evaluates to false) when no delegate step has run OR + /// when the most recent one denied. + pub const GRANTED: &str = "delegation.granted"; +} diff --git a/crates/apl-core/tests/yaml_end_to_end.rs b/crates/apl-core/tests/yaml_end_to_end.rs new file mode 100644 index 00000000..6227aaab --- /dev/null +++ b/crates/apl-core/tests/yaml_end_to_end.rs @@ -0,0 +1,266 @@ +// Location: ./crates/apl-core/tests/yaml_end_to_end.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: YAML config → compiled IR → evaluated against a +// realistic AttributeBag and payload. This exercises the public crate API +// only (`compile_config` + `evaluate_route` + traits) and serves as the +// authoritative "if this passes, apl-core works as a unit" check. +// +// The fixture follows Example 1 from unified-config-proposal.md, adapted to +// the map-keyed `routes:` shape that the parser actually accepts (the spec's +// list-with-matchers form is a deferred shape). + +use std::sync::Arc; + +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, FieldOutcome, + NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, + PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, +}; +use async_trait::async_trait; +use serde_json::json; + +// Test fixtures: every scenario passes the same no-op plugin invoker and +// no-op delegation invoker, so wrap them once in the `Arc` shape +// `evaluate_route` expects and let each call borrow. +fn pdp() -> Arc { + Arc::new(AllowPdp) +} +fn plugins() -> Arc { + Arc::new(NoPlugins) +} +fn delegations() -> Arc { + Arc::new(NoopDelegationInvoker) +} + +// ----- Fixtures: a baseline route used by every scenario below. ----- + +const HR_ROUTE_YAML: &str = r#" +routes: + get_employee: + args: + employee_id: "str" + policy: + - "require(authenticated)" + - "delegation.depth > 2: deny" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" +"#; + +struct AllowPdp; +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + } +} + +struct NoPlugins; +#[async_trait] +impl PluginInvoker for NoPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } +} + +// ----- Scenarios ----- + +#[tokio::test] +async fn alice_full_access_sees_unredacted_result_with_masked_id() { + // Alice: authenticated HR with view_ssn permission, depth=1. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("perm.view_ssn", true); + bag.set("delegation.depth", 1_i64); + + let routes = compile_config(HR_ROUTE_YAML).expect("YAML compiles").routes; + let route = routes.get("get_employee").expect("route present"); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ + "ssn": "555-12-3456", + "salary": 95000, + "employee_id": "123-45-6789", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified == false, "args has only a `str` validator, no mutation"); + assert!(r.result_modified, "result has mask + redact stages"); + + let result = payload.result.as_ref().unwrap(); + // view_ssn=true → redact(!view_ssn) skipped → ssn intact. + assert_eq!(result["ssn"], json!("555-12-3456")); + // role.hr=true → redact(!role.hr) skipped → salary intact. + assert_eq!(result["salary"], json!(95000)); + // mask(4) always applies → keeps last 4 chars. + assert_eq!(result["employee_id"], json!("*******6789")); +} + +#[tokio::test] +async fn mallory_no_perm_no_role_gets_both_fields_redacted() { + // Mallory: authenticated but no role, no perm, shallow delegation. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + // role.hr and perm.view_ssn are absent → IsTrue=false → !IsTrue=true → redact fires. + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "555-44-3333" }), + json!({ + "ssn": "111-22-3333", + "salary": 80000, + "employee_id": "555-44-3333", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + + let result = payload.result.as_ref().unwrap(); + assert_eq!(result["ssn"], json!("[REDACTED]")); + assert_eq!(result["salary"], json!("[REDACTED]")); + assert_eq!(result["employee_id"], json!("*******3333")); +} + +#[tokio::test] +async fn deep_delegation_denies_at_policy() { + // Authenticated user but delegation.depth=3 > 2 → policy deny. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("perm.view_ssn", true); + bag.set("delegation.depth", 3_i64); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy"), "got source: {}", rule_source); + } + d => panic!("expected policy deny, got {:?}", d), + } + // Result phase never ran → no result mutation. + assert!(!r.result_modified); + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("x")); + assert_eq!(payload.result.as_ref().unwrap()["employee_id"], json!("123-45-6789")); +} + +#[tokio::test] +async fn unauthenticated_user_is_denied_before_args_mutate_result() { + // No `authenticated` key → require(authenticated) fails → deny. + let mut bag = AttributeBag::new(); + bag.contains("authenticated"); // sanity: confirm we built an empty bag. + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "999-99-9999", "salary": 50000, "employee_id": "123-45-6789" }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); + assert!(!r.result_modified); +} + +#[tokio::test] +async fn args_validator_rejects_wrong_type() { + // args.employee_id is declared `str` — an integer value violates that + // and should produce a deny during the args phase, before policy runs. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": 42 }), // ← wrong type + json!({ "ssn": "x", "salary": 1, "employee_id": "x" }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!( + rule_source.contains("employee_id"), + "expected args field source, got {}", + rule_source, + ); + } + d => panic!("expected args-phase deny, got {:?}", d), + } + // Result phase didn't run. + assert!(!r.result_modified); +} + +#[tokio::test] +async fn inbound_only_evaluation_skips_result_phase() { + // Simulates the inbound path: payload has no result yet. Args + policy + // run; result phase is skipped; post_policy runs (none defined here). + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::new(json!({ "employee_id": "123-45-6789" })); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.result_modified); + assert!(payload.result.is_none()); + // Args field is untouched — `str` is validator-only, no transform. + assert_eq!(payload.args["employee_id"], json!("123-45-6789")); +} + +// ----- Smoke test: phase-existence reporting matches what's in the YAML. ----- + +#[test] +fn compiled_route_phase_set_reflects_yaml_blocks() { + use apl_core::Phase; + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + let phases = route.declared_phases(); + assert!(phases.contains(Phase::Args)); + assert!(phases.contains(Phase::Policy)); + assert!(phases.contains(Phase::Result)); + assert!(!phases.contains(Phase::PostPolicy)); +} + +// Marker so the file isn't all `_` — sanity check that `FieldOutcome` is +// reachable as part of the public surface alongside the orchestrator's +// `RouteDecision`. Removing this when downstream consumers exist. +#[test] +fn public_surface_includes_field_outcome() { + let _: FieldOutcome = FieldOutcome::Pass; +} diff --git a/crates/apl-cpex/Cargo.toml b/crates/apl-cpex/Cargo.toml new file mode 100644 index 00000000..b0b50f4c --- /dev/null +++ b/crates/apl-cpex/Cargo.toml @@ -0,0 +1,43 @@ +# Location: ./crates/apl-cpex/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-cpex — the bridge between APL's evaluator (`apl-core`) and CPEX's +# runtime (`cpex-core`). Provides per-hook-type implementations of +# `apl-core::PluginInvoker` that translate APL plugin dispatch into typed +# `cpex-core::PluginManager::invoke_named::` calls. +# +# Design constraints inherited from `apl-core`: +# - `apl-core` has zero CPEX deps; cross-crate boundary lives here. +# - The PluginInvoker trait is string-typed; the typed boundary lives +# INSIDE each impl (one impl per HookTypeDef, e.g. CmfPluginInvoker +# for CMF, future DelegationPluginInvoker for delegation hooks). +# - Payload is built ONCE by the host and threaded through the invoker +# for the full request lifetime — never reconstructed from the bag. + +[package] +name = "apl-cpex" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +apl-cmf = { path = "../apl-cmf" } +cpex-core = { path = "../cpex-core" } +async-trait = { workspace = true } +chrono = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +# Stable hash for tier-3 (identity-derived) session id. `DefaultHasher` +# is explicitly documented as not-stable-across-Rust-versions; session +# keys persist across process restarts (SessionStore), so we need an +# algorithmically fixed hash. +sha2 = "0.10" + +[dev-dependencies] +serde = { workspace = true } diff --git a/crates/apl-cpex/src/cmf_invoker.rs b/crates/apl-cpex/src/cmf_invoker.rs new file mode 100644 index 00000000..6abb3cac --- /dev/null +++ b/crates/apl-cpex/src/cmf_invoker.rs @@ -0,0 +1,410 @@ +// Location: ./crates/apl-cpex/src/cmf_invoker.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CmfPluginInvoker` — `apl-core::PluginInvoker` impl bound to the CMF +// hook family. Drives dispatch off a pre-resolved [`RouteDispatchPlan`] +// (from [`DispatchCache`]) and forwards entries to +// `PluginManager::invoke_entries::(...)`, which runs the full +// executor pipeline (sequential / transform / audit / concurrent / +// fire-and-forget; on_error / timeouts / mode / write tokens all +// honored). Compile-time payload type safety is provided by the +// `CmfHook: HookTypeDef` bound on `invoke_entries`. +// +// # Request-scoped vs session-scoped state +// +// The invoker carries **request-scoped** state — payload + extensions +// — under interior mutability (`Arc>`) so mutations +// from one plugin call accumulate for the next call in the same +// request. **Session-scoped** state (labels that survive across requests +// in the same session) goes through the pluggable [`SessionStore`] +// trait: hydrated at `for_request` start, persisted via +// [`persist_session`] after route evaluation. Session ID is pulled from +// `extensions.agent.session_id`; absent → both ops are no-ops. +// +// # Per-call taint extraction +// +// Each plugin invocation diffs `result.modified_extensions.security.labels` +// against the labels visible to *that call*. New labels become +// `PluginOutcome.taints` as `TaintEvent { scopes: vec![Session] }` — +// CMF's monotonic label channel is session-semantic by design, so +// Session is the natural default. Multi-scope plugin emissions (or +// `Message` scope) require either a future second label channel in +// Extensions or explicit config-side `Step::Taint { scopes: [...] }` / +// `Stage::Taint`. +// +// # Lifetime model +// +// One invoker instance per request. Host pre-builds the +// `MessagePayload`, hydrates session-scoped state via `for_request` +// (which is async because it awaits `SessionStore::load_labels`), then +// drives `evaluate_route`. After evaluation, host calls +// [`current_payload`] for body re-serialization and +// [`persist_session`] to commit accumulated session state. +// +// Background tasks returned by `invoke_entries` are dropped for v0; +// when audit/fire-and-forget plugin support is wired into APL's +// lifecycle, we'll thread a `BackgroundTasks` aggregator through the +// invoker. + +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::HookPhase; +use cpex_core::manager::PluginManager; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::pipeline::{TaintEvent, TaintScope}; +use apl_core::step::{ + DispatchPhase, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, +}; + +use crate::dispatch_plan::RouteDispatchPlan; +use crate::session_store::SessionStore; + +/// Bridges APL plugin dispatch to CMF-family CPEX hooks. +/// +/// Carries the request's `MessagePayload` and `Extensions` for its +/// entire lifetime so plugin mutations accumulate (one plugin's +/// `[REDACTED]` output is visible to the next plugin in the same +/// route; one plugin's added label seeds the next plugin's filter view). +pub struct CmfPluginInvoker { + manager: Arc, + /// Per-request extensions under interior mutability. Locked across + /// awaits — `tokio::sync::Mutex` is required because the executor's + /// `invoke_entries` is async. + extensions: Arc>, + /// Per-request payload under interior mutability. Same reasoning as + /// `extensions` — accumulated text rewrites have to be visible to + /// the next dispatch in the same request. + payload: Arc>, + /// Pre-resolved per-route plugin lineup. Built (or fetched from a + /// shared `DispatchCache`) at request start by the host. + plan: Arc, + /// Session ID resolved at request start by the 4-tier + /// [`session_resolver::resolve_session`] (token claim → header → + /// identity-derived → none). `None` for fully-anonymous traffic + /// (no claim, no header, no subject id) — hydration + persistence + /// become no-ops in that case. + session_id: Option, + /// Pluggable session-scoped state backend. `Arc` + /// rather than a generic so a single invoker type works for memory / + /// Redis / future-distributed stores without monomorphization churn. + session_store: Arc, + /// Labels present in `extensions.security.labels` immediately after + /// `SessionStore` hydration but before any plugins have run. Used + /// by `persist_session` to diff against final labels and append only + /// the additions to the session store. Empty when there was no + /// session_id (so no hydration happened). + initial_labels: HashSet, +} + +impl CmfPluginInvoker { + /// Construct an invoker bound to one request's payload + extensions + /// and the pre-resolved dispatch plan for the request's route. + /// Hydrates accumulated session-scoped labels into + /// `extensions.security.labels` before returning, so the first + /// plugin sees the full session-monotonic view. + pub async fn for_request( + manager: Arc, + mut extensions: Extensions, + payload: MessagePayload, + plan: Arc, + session_store: Arc, + ) -> Self { + // Resolve session id via the 4-tier resolver (token claim → + // header → identity-derived → none). Snapshotted before + // hydration so the lookup is independent of the COW write + // that hydration performs. + let session_id: Option = crate::session_resolver::resolve_session(&extensions) + .map(|(sid, _src)| sid); + + // Hydration: union the session's accumulated labels into the + // request's security labels. Skipped when there's no session_id + // OR no stored labels (avoid the COW clone for nothing). + if let Some(sid) = &session_id { + let stored = session_store.load_labels(sid).await; + if !stored.is_empty() { + extensions = hydrate_labels(extensions, &stored); + } + } + + let initial_labels = snapshot_labels(&extensions); + + Self { + manager, + extensions: Arc::new(Mutex::new(extensions)), + payload: Arc::new(Mutex::new(payload)), + plan, + session_id, + session_store, + initial_labels, + } + } + + /// Snapshot the current payload. Call after route evaluation to + /// extract the final (possibly-mutated) `MessagePayload` for body + /// re-serialization. + pub async fn current_payload(&self) -> MessagePayload { + self.payload.lock().await.clone() + } + + /// Snapshot the current extensions. Useful for hosts that need to + /// inspect the post-evaluation extension state (audit, telemetry). + pub async fn current_extensions(&self) -> Extensions { + self.extensions.lock().await.clone() + } + + /// Shared `Arc>` handle. Used by collaborators + /// (notably `DelegationPluginInvoker`) that need to mutate the + /// same request-scoped extensions this invoker sees — e.g. a + /// `delegate(...)` step minting a token needs to write + /// `raw_credentials.delegated_tokens.*` into the same Extensions + /// the next CMF plugin will read. + pub fn extensions_arc(&self) -> Arc> { + Arc::clone(&self.extensions) + } + + /// Shared `Arc` handle. Collaborators (e.g. + /// `DelegationPluginInvoker`) need this to look up their own + /// entries in the same per-route plan the CMF invoker uses. + pub fn plan_arc(&self) -> Arc { + Arc::clone(&self.plan) + } + + /// Drain APL-emitted session-scoped taints into the request's + /// `security.labels` so the existing label-monotonic flow + /// ([`persist_session`] below) picks them up. Filters by + /// `TaintScope::Session` — Message-scoped taints (and any future + /// scope) are deliberately ignored here; they have their own + /// destination (TBD: TS2 — a labels slot on `MessagePayload`). + /// + /// Host (`AplRouteHandler`) calls this once per request after + /// `evaluate_pre` / `evaluate_post` returns, with the + /// `RouteDecision.taints` slice. No-op when the slice has no + /// Session-scoped entries — common for routes that don't taint. + pub async fn apply_session_taints(&self, taints: &[apl_core::pipeline::TaintEvent]) { + use apl_core::pipeline::TaintScope; + use cpex_core::extensions::SecurityExtension; + + let session_labels: Vec<&str> = taints + .iter() + .filter(|t| t.scopes.contains(&TaintScope::Session)) + .map(|t| t.label.as_str()) + .collect(); + if session_labels.is_empty() { + return; + } + let mut current = self.extensions.lock().await; + // `Extensions.security` is `Option>`. + // Initialize the slot if absent; `Arc::make_mut` gives us a + // mutable reference to the underlying value, cloning when + // other Arc holders exist (e.g., a downstream snapshot reader). + let arc = current + .security + .get_or_insert_with(|| Arc::new(SecurityExtension::default())); + let sec = Arc::make_mut(arc); + for label in session_labels { + sec.add_label(label); + } + } + + /// Persist session-scoped state added during this request. Diffs + /// current `security.labels` against the post-hydration snapshot + /// and appends new labels to the session store. No-op when there + /// was no session ID. Host calls this exactly once after route + /// evaluation completes. + pub async fn persist_session(&self) { + let Some(sid) = &self.session_id else { return }; + let current = self.extensions.lock().await; + let Some(security) = current.security.as_ref() else { return }; + let new_labels: Vec = security + .labels + .iter() + .filter(|l| !self.initial_labels.contains(l.as_str())) + .cloned() + .collect(); + drop(current); // release the lock before the await + if !new_labels.is_empty() { + self.session_store.append_labels(sid, &new_labels).await; + } + } +} + +#[async_trait] +impl PluginInvoker for CmfPluginInvoker { + async fn invoke( + &self, + plugin_name: &str, + _bag: &AttributeBag, + invocation: PluginInvocation<'_>, + ) -> Result { + let resolved = self + .plan + .get(plugin_name) + .ok_or_else(|| PluginError::NotFound(plugin_name.to_string()))?; + + // Snapshot extensions to read entity_type — the dispatcher + // needs it for hook routing. Dropped immediately so we don't + // hold the lock across the per-entry payload clone. + let request_entity_type: Option = { + let ext = self.extensions.lock().await; + ext.meta.as_ref().and_then(|m| m.entity_type.clone()) + }; + + // Pick the entry whose registered hook matches the current + // dispatch context via cpex-core's hook metadata table. + // Replaces the prior naming heuristic. + let dispatch_phase = match invocation.phase() { + DispatchPhase::Pre => HookPhase::Pre, + DispatchPhase::Post => HookPhase::Post, + }; + let entry = resolved + .pick_entry(request_entity_type.as_deref(), dispatch_phase) + .ok_or_else(|| { + PluginError::Dispatch(format!( + "plugin '{plugin_name}' has no hook matching dispatch \ + context (entity_type={:?}, phase={:?}); declared hooks: {:?}", + request_entity_type, + dispatch_phase, + resolved.entries_by_hook.keys().collect::>(), + )) + })?; + + // Snapshot the current payload + extensions — `invoke_entries` + // consumes by-value, so we clone for the call and keep the + // canonical copies in shared state for the next dispatch. + let current_payload = self.payload.lock().await.clone(); + let current_extensions = self.extensions.lock().await.clone(); + + // Per-call taint diff baseline. New labels in `result` minus + // these become `PluginOutcome.taints`. + let before_labels = snapshot_labels(¤t_extensions); + + let (result, _bg) = self + .manager + .invoke_entries::( + std::slice::from_ref(entry), + current_payload, + current_extensions, + None, + ) + .await; + + // Map deny: violation reason → APL deny reason; plugin code → + // rule_source for audit attribution. + let decision = if result.is_denied() { + let (reason, rule_source) = match result.violation { + Some(v) => (Some(v.reason), v.code), + None => (None, "policy.forbidden".to_string()), + }; + Decision::Deny { reason, rule_source } + } else { + Decision::Allow + }; + + // Persist any plugin-side payload mutation back into the shared + // request payload. `PluginPayload` only exposes `as_any`, so we + // downcast-ref and clone. `MessagePayload: Clone` makes this + // cheap relative to the FFI/invoke cost. + let modified_value = if let Some(mp_boxed) = result.modified_payload.as_ref() { + match mp_boxed.as_any().downcast_ref::() { + Some(modified) => { + *self.payload.lock().await = modified.clone(); + match invocation { + PluginInvocation::Field { .. } => { + Some(serde_json::Value::String( + modified.message.get_text_content(), + )) + } + PluginInvocation::Step { .. } => None, + } + } + None => { + tracing::warn!( + plugin = %plugin_name, + "CmfPluginInvoker: modified_payload was not MessagePayload \ + (downcast failed) — dropping the mutation" + ); + None + } + } + } else { + None + }; + + // Promote modified extensions back into shared state + extract + // newly-added labels as taints. The executor returns + // `Option` for the modified view — `Some` only when + // a plugin actually changed extensions. The executor has + // already validated label monotonicity on the way out. + let taints = if let Some(modified_ext) = result.modified_extensions { + let after_labels = snapshot_labels(&modified_ext); + let new_labels: Vec = after_labels + .difference(&before_labels) + .cloned() + .collect(); + *self.extensions.lock().await = modified_ext; + new_labels + .into_iter() + .map(|label| TaintEvent { + label, + // v0: CMF's `security.labels` is session-semantic by + // design (monotonic accumulation). Plugins that need + // Message-scoped taints emit them via config-side + // `Step::Taint`/`Stage::Taint` for now. + scopes: vec![TaintScope::Session], + }) + .collect() + } else { + Vec::new() + }; + + Ok(PluginOutcome { + decision, + taints, + modified_value, + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Snapshot `extensions.security.labels` as an owned `HashSet`. +/// Empty when security is absent. +fn snapshot_labels(extensions: &Extensions) -> HashSet { + extensions + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default() +} + +/// Add `labels` to `extensions.security.labels` (monotonic union). +/// Creates a security extension if absent. Used at hydration time — +/// merges the SessionStore's accumulated labels into the request view +/// so the first plugin sees the full picture. +fn hydrate_labels(mut extensions: Extensions, labels: &[String]) -> Extensions { + // Clone the Arc'd security into an owned struct so we can mutate. + // Most slots stay refcount-shared; only security is materialized. + let mut security = extensions + .security + .as_ref() + .map(|s| (**s).clone()) + .unwrap_or_default(); + for l in labels { + security.add_label(l.clone()); + } + extensions.security = Some(Arc::new(security)); + extensions +} + diff --git a/crates/apl-cpex/src/delegation_invoker.rs b/crates/apl-cpex/src/delegation_invoker.rs new file mode 100644 index 00000000..c4447d47 --- /dev/null +++ b/crates/apl-cpex/src/delegation_invoker.rs @@ -0,0 +1,269 @@ +// Location: ./crates/apl-cpex/src/delegation_invoker.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `DelegationPluginInvoker` — `apl-core::DelegationInvoker` impl +// bound to the `TokenDelegateHook` family. Drives dispatch off a +// pre-resolved [`RouteDispatchPlan::delegation_entries`] and forwards +// to `PluginManager::invoke_entries::(...)`. +// +// # When this runs +// +// The apl-core evaluator calls +// `DelegationInvoker::delegate(&DelegateStep)` once per `Step::Delegate` +// it encounters in a `policy:` / `post_policy:` block. The invoker: +// +// 1. Looks up the resolved `token.delegate` entry for the step's +// plugin name in the dispatch plan. +// 2. Constructs a `cpex_core::delegation::DelegationPayload` from +// the inbound bearer token (from +// `Extensions.raw_credentials.inbound_tokens[User]`) plus the +// step's `config_override` (target / audience / permissions / +// attenuation — schema is plugin-defined; we map a few +// well-known keys onto the typed payload builders and stash +// everything else as metadata for plugin-specific consumption). +// 3. Calls `mgr.invoke_entries::(&[entry], ...)`. +// 4. Pulls the resulting `DelegationPayload` from the +// `PipelineResult`, applies it to the shared `Extensions` (via +// `apply_to_extensions`), and returns a `DelegationOutcome` with +// the granted_* fields extracted from the minted token. +// +// # Shared extensions +// +// This invoker shares the same `Arc>` as +// `CmfPluginInvoker` for the same request. That means when +// `delegate(...)` writes `raw_credentials.delegated_tokens.*`, the +// next CMF plugin in the chain (or downstream evaluator phases) sees +// it. Get the shared handle via `CmfPluginInvoker::extensions_arc()`. + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::SecondsFormat; +use tokio::sync::Mutex; + +use cpex_core::delegation::{ + payload::{AuthEnforcedBy, TargetType}, + DelegationPayload, TokenDelegateHook, +}; +use cpex_core::extensions::raw_credentials::TokenRole; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; + +use apl_core::evaluator::Decision; +use apl_core::step::{DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome}; + +use crate::dispatch_plan::RouteDispatchPlan; + +/// Bridges APL `delegate(...)` step dispatch to CPEX +/// `TokenDelegateHook` plugins. +/// +/// Carries the request's shared `Extensions` so mutations from a +/// `delegate(...)` step (minted token, updated delegation chain) +/// land in the same `Extensions` the CMF invoker is reading. +pub struct DelegationPluginInvoker { + manager: Arc, + /// Same `Arc>` as the CMF invoker for this + /// request — sharing this handle is what makes minted tokens + /// visible to downstream CMF plugins. + extensions: Arc>, + /// Pre-resolved per-route delegation lineup. Built at request + /// start by the host (or fetched from a shared `DispatchCache`). + plan: Arc, +} + +impl DelegationPluginInvoker { + /// Construct an invoker bound to the request's shared extensions + /// and the route's pre-resolved dispatch plan. Take the + /// extensions Arc from `CmfPluginInvoker::extensions_arc()` so + /// the two invokers see the same mutable Extensions. + pub fn new( + manager: Arc, + extensions: Arc>, + plan: Arc, + ) -> Self { + Self { + manager, + extensions, + plan, + } + } +} + +#[async_trait] +impl DelegationInvoker for DelegationPluginInvoker { + async fn delegate( + &self, + step: &DelegateStep, + ) -> Result { + // 1. Resolve the plugin's token.delegate entry from the plan. + // Routes that don't reference this plugin in `policy:` / + // `post_policy:` at compile time won't have it in the plan + // — surface that as NotFound so the evaluator's on_error + // semantics kick in. + let entry = self + .plan + .delegation_entries + .get(&step.plugin_name) + .ok_or_else(|| DelegationError::NotFound(step.plugin_name.clone()))? + .clone(); + + // 2. Snapshot extensions to construct the payload + pass into + // invoke_entries. We keep the canonical copy under the + // Mutex; this snapshot is the per-call working copy. + let current_extensions = self.extensions.lock().await.clone(); + + // 3. Pull the inbound bearer token from raw_credentials. v0 + // looks for the User-role token; future iterations can + // surface multi-token selection (Client / Workload) via + // step config. + let bearer_token = current_extensions + .raw_credentials + .as_ref() + .and_then(|rc| rc.inbound_tokens.get(&TokenRole::User)) + .map(|tok| (*tok.token).clone()) + .unwrap_or_default(); + + // 4. Read step args. Step `config_override` is a yaml map per + // the IR — we extract a few well-known keys onto the typed + // DelegationPayload builders. Unknown keys still flow + // through to the plugin via the per-call config-override + // pathway at registration time (already applied when the + // plan was built — plugins consume them from their + // `cfg.config`). For Slice B we keep this mapping minimal: + // `target` is required (delegation needs to know who the + // downstream call is for); `audience`, `permissions`, + // `mode`, `auth_enforced_by` are recognized; everything + // else stays opaque. + let cfg = step + .config_override + .as_ref() + .and_then(|v| v.as_mapping()); + + let target_name: String = cfg + .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) + .and_then(|v| v.as_str()) + .unwrap_or(&step.plugin_name) + .to_string(); + + let mut payload = DelegationPayload::new(bearer_token, target_name); + + if let Some(audience) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("audience".into()))) + .and_then(|v| v.as_str()) + { + payload = payload.with_target_audience(audience); + } + if let Some(perms) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("permissions".into()))) + .and_then(|v| v.as_sequence()) + { + let list: Vec = perms + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + if !list.is_empty() { + payload = payload.with_required_permissions(list); + } + } + if let Some(t_kind) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("target_type".into()))) + .and_then(|v| v.as_str()) + { + payload = payload.with_target_type(target_type_from_str(t_kind)); + } + if let Some(enforcer) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("auth_enforced_by".into()))) + .and_then(|v| v.as_str()) + { + payload = payload.with_auth_enforced_by(auth_enforced_by_from_str(enforcer)); + } + + // 5. Dispatch. The plan's pre-resolved entry already has any + // per-route config override merged into the plugin's + // instance config; what we're passing on this call is the + // typed payload (target / audience / permissions / etc.). + let (result, _bg) = self + .manager + .invoke_entries::( + std::slice::from_ref(&entry), + payload, + current_extensions, + None, + ) + .await; + + // 6. Translate the result. + if !result.continue_processing { + // Plugin denied (IdP refusal, validation failure, etc.). + let decision = match result.violation { + Some(v) => Decision::Deny { + reason: Some(v.reason), + rule_source: v.code, + }, + None => Decision::Deny { + reason: Some(format!( + "delegate `{}` denied without violation detail", + step.plugin_name + )), + rule_source: step.source.clone(), + }, + }; + return Ok(DelegationOutcome::deny(decision)); + } + + // 7. Pull the resolved DelegationPayload and apply to shared + // extensions so downstream code sees the minted token / + // updated chain. + let resolved = DelegationPayload::from_pipeline_result(&result).ok_or_else(|| { + DelegationError::Dispatch(format!( + "plugin `{}` returned allow but no DelegationPayload", + step.plugin_name, + )) + })?; + + { + let mut ext_lock = self.extensions.lock().await; + let merged = resolved.clone().apply_to_extensions(ext_lock.clone()); + *ext_lock = merged; + } + + // 8. Extract granted_* for the evaluator to surface into the bag. + let (granted_permissions, granted_audience, granted_expires_at) = + match resolved.delegated_token { + Some(tok) => ( + tok.scopes, + Some(tok.audience), + Some(tok.expires_at.to_rfc3339_opts(SecondsFormat::Secs, true)), + ), + None => (Vec::new(), None, None), + }; + + Ok(DelegationOutcome { + decision: Decision::Allow, + granted_permissions, + granted_audience, + granted_expires_at, + }) + } +} + +fn target_type_from_str(s: &str) -> TargetType { + match s.to_ascii_lowercase().as_str() { + "tool" => TargetType::Tool, + "agent" => TargetType::Agent, + "resource" => TargetType::Resource, + "service" => TargetType::Service, + other => TargetType::Custom(other.to_string()), + } +} + +fn auth_enforced_by_from_str(s: &str) -> AuthEnforcedBy { + match s.to_ascii_lowercase().as_str() { + "caller" => AuthEnforcedBy::Caller, + "target" => AuthEnforcedBy::Target, + // Unknown values default to Caller — matches DelegationPayload::new's default. + _ => AuthEnforcedBy::Caller, + } +} diff --git a/crates/apl-cpex/src/dispatch_plan.rs b/crates/apl-cpex/src/dispatch_plan.rs new file mode 100644 index 00000000..3fbafdb9 --- /dev/null +++ b/crates/apl-cpex/src/dispatch_plan.rs @@ -0,0 +1,461 @@ +// Location: ./crates/apl-cpex/src/dispatch_plan.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `RouteDispatchPlan` + `DispatchCache` — pre-resolved per-route plugin +// lineup that lets APL bypass cpex-core's hook-name + condition routing +// while still going through the executor's full 5-phase pipeline. +// +// # Why pre-resolve? +// +// cpex-core's `invoke_named(hook_name, ...)` resolves the lineup on +// every call: hook lookup → route/condition filter → group by mode → +// dispatch. APL routes are already authoritative lineups (the YAML's +// `routes..policy: [plugin(x), plugin(y)]` IS the plan). Re-resolving +// per call wastes work and lets cpex-core's parallel routing model +// (entity-based conditions) override APL's intent. +// +// Building once per `(route_key, snapshot_generation)` and caching turns +// dispatch into: cache lookup → pick handler by invocation context → +// call `manager.invoke_entries::(&[entry], ...)`. +// +// # Override materialization +// +// When APL declares a route-level `plugins.:` block that narrows +// `capabilities` or changes `on_error`, the plan creates a derived +// `PluginRef` wrapping the same plugin `Arc` with a merged +// `TrustedConfig`. Per `feedback_override_isolation.md`: each derived +// PluginRef gets a fresh `AtomicBool` circuit breaker — failures in the +// override-context plugin don't disable the base, and vice versa. +// +// # Hook-context classification (v0) +// +// A plugin may register handlers for multiple hooks (e.g. both +// `cmf.tool_pre_invoke` for policy steps and `cmf.field_redact` for +// args/result pipelines). The plan picks one handler per invocation +// context (Step vs Field) by a naming heuristic — hook names containing +// `field`, `redact`, `scan`, or `validate` are treated as field +// handlers. When the heuristic stops being sufficient, the plugin +// declaration will gain an explicit `{step: ..., field: ...}` mapping +// form alongside the flat hook list. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; + +use cpex_core::delegation::HOOK_TOKEN_DELEGATE; +use cpex_core::hooks::{lookup_hook_metadata, HookPhase}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::OnError; +use cpex_core::registry::HookEntry; + +use apl_core::pipeline::Stage; +use apl_core::plugin_decl::{EffectivePlugin, PluginRegistry}; +use apl_core::rules::{CompiledRoute, Effect}; + +/// Per-plugin pre-resolved entries for one route. Stores ALL hook +/// entries the plugin registered (keyed by hook name) so the +/// dispatcher can pick the right one for the current context via the +/// cpex-core hook routing table (`hooks::metadata::lookup`). +/// +/// Replaces the prior `step_entry` / `field_entry` slot model, which +/// used a brittle naming heuristic to classify hooks and silently +/// collapsed plugins with multiple step-context hooks (e.g. both +/// `tool_pre_invoke` and `tool_post_invoke`) to a single entry. +#[derive(Clone)] +pub struct RoutePluginEntry { + pub plugin_name: String, + /// All hook entries the plugin registered, keyed by hook name. + /// Per-call overrides (route-level config / caps / on_error) are + /// already applied via `build_override_entries` before being + /// stored here. + pub entries_by_hook: HashMap, +} + +impl RoutePluginEntry { + /// Pick the entry whose registered hook matches the current + /// dispatch context. Walks `entries_by_hook`, consults the + /// cpex-core hook metadata table for each, returns the first + /// matching entry. + /// + /// `requested_entity_type` comes from the request's + /// `MetaExtension.entity_type` (or `None` if the dispatcher + /// doesn't have one — in which case any hook's entity_type + /// matches). `requested_phase` comes from the APL invocation + /// context — `Pre` for `args:` / `policy:`, `Post` for + /// `result:` / `post_policy:`, `Unphased` for unphased + /// dispatchers (rare in APL). + /// + /// Returns `None` when the plugin has no hook matching the + /// context — caller surfaces this as `PluginError::Dispatch` + /// with the requested context in the message. + pub fn pick_entry( + &self, + requested_entity_type: Option<&str>, + requested_phase: HookPhase, + ) -> Option<&HookEntry> { + self.entries_by_hook + .iter() + .find(|(hook_name, _)| { + lookup_hook_metadata(hook_name) + .matches(requested_entity_type, requested_phase) + }) + .map(|(_, entry)| entry) + } +} + +/// A route's resolved plugin lineup. One per `(route_key, generation)` +/// in the cache. +/// +/// `plugins` holds entries for CMF-family dispatch (policy steps, +/// pipe-chain stages). `delegation_entries` holds entries for the +/// `token.delegate` hook used by `Step::Delegate` — kept separate +/// because the hook family is different and the dispatch is +/// per-call rather than per-route-chain. +#[derive(Clone, Default)] +pub struct RouteDispatchPlan { + pub plugins: HashMap, + /// Plugin name → resolved `token.delegate` hook entry for routes + /// that declared `delegate(...)` steps. Empty when the route has + /// no delegation. Built at plan time to avoid per-request + /// `find_plugin_entries` lookups in the hot path. + pub delegation_entries: HashMap, +} + +impl RouteDispatchPlan { + /// Build a plan for the given route. Walks all steps + pipeline + /// stages, collects the unique set of plugin names, resolves each + /// against cpex-core, and applies any APL route-level overrides. + /// + /// Plugins referenced by APL but absent from cpex-core's registry + /// (or absent from the APL `plugins:` block) are logged at `warn` + /// and excluded — dispatch then fails with `PluginError::NotFound` + /// when those plugins are invoked, which is the right behavior for + /// surfacing config drift. + pub async fn build( + route: &CompiledRoute, + registry: &PluginRegistry, + manager: &PluginManager, + ) -> Self { + let mut plan = Self::default(); + for name in collect_plugin_names(route) { + let eff = match EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { + Some(e) => e, + None => { + tracing::warn!( + plugin = %name, + route = %route.route_key, + "APL route references plugin not in `plugins:` block — skipping", + ); + continue; + } + }; + + // Pull the three overrideable values off the effective view. + // `EffectivePlugin` borrows from the registry / route overrides, + // so the captures here are slice / Option<&Value> refs. + let override_block = route.plugin_overrides.get(&name); + let config_override = override_block.and_then(|o| o.config.as_ref()); + let caps_override: Option> = + if matches!(eff.capabilities, apl_core::plugin_decl::CapsView::Override(_)) { + Some(eff.capabilities.as_slice().iter().cloned().collect()) + } else { + None + }; + let on_error_override = override_block + .and_then(|o| o.on_error.as_deref()) + .and_then(parse_on_error); + + // Hand the override decision to cpex-core. When no overrides + // are declared, this returns the base entries unchanged + // (no allocation, no factory call). When only caps/on_error + // differ, it wraps the shared base plugin in a fresh + // `PluginRef` with merged trusted config. When config + // differs, it invokes the factory + initializes a brand-new + // instance with its own circuit breaker. + let entries = manager + .build_override_entries( + &name, + config_override, + caps_override.as_ref(), + on_error_override, + ) + .await; + if entries.is_empty() { + tracing::warn!( + plugin = %name, + route = %route.route_key, + "APL plugin not resolvable (not registered, factory missing, \ + or override construction failed) — skipping", + ); + continue; + } + + // Store every (hook_name, HookEntry) pair the plugin + // registered. Dispatch-time entry selection (pick_entry) + // consults cpex-core's hook routing table per hook name. + // Replaces the prior naming heuristic. + let mut entries_by_hook: HashMap = HashMap::new(); + for (hook_name, entry) in entries { + entries_by_hook.insert(hook_name, entry); + } + + plan.plugins.insert( + name.clone(), + RoutePluginEntry { + plugin_name: name, + entries_by_hook, + }, + ); + } + + // Resolve token.delegate entries for any plugins the route + // calls via `Step::Delegate`. These don't go through the + // step/field classification — they're a separate hook family. + // We still apply per-call config overrides via the existing + // `build_override_entries` pathway, threading the step's + // `config_override` as the only override surface (Slice B + // doesn't expose per-step caps or on_error overrides on + // delegation entries — the on_error lives in the IR step + // itself and is honored by the evaluator). + for name in collect_delegate_plugin_names(route) { + let entries = manager + .build_override_entries(&name, None, None, None) + .await; + // Pick the first token.delegate entry. Per delegation-hooks + // spec, plugins typically register one handler under the + // single `token.delegate` hook name; multiple handlers + // would be unusual. + let delegate_entry = entries + .into_iter() + .find(|(hook_name, _)| hook_name == HOOK_TOKEN_DELEGATE); + if let Some((_, entry)) = delegate_entry { + plan.delegation_entries.insert(name, entry); + } else { + tracing::warn!( + plugin = %name, + route = %route.route_key, + "APL route references delegate plugin not registered under \ + token.delegate hook — `delegate(...)` step will fail at dispatch", + ); + } + } + + plan + } + + /// Look up the resolved entries for a plugin by name. None when the + /// plugin wasn't referenced by the route (or was skipped during + /// build due to config drift). + pub fn get(&self, plugin_name: &str) -> Option<&RoutePluginEntry> { + self.plugins.get(plugin_name) + } + + /// Resolve a single plugin's entries straight off cpex-core, with + /// no APL route-level overrides. Convenience for tests and for hosts + /// that wire the invoker without a `CompiledRoute` in scope (e.g. + /// adapters that invoke a single plugin imperatively). Returns + /// `None` if cpex-core has no entries for the plugin. + pub fn resolve_plugin( + manager: &PluginManager, + plugin_name: &str, + ) -> Option { + let base_entries = manager.find_plugin_entries(plugin_name); + if base_entries.is_empty() { + return None; + } + let mut entries_by_hook: HashMap = HashMap::new(); + for (hook_name, entry) in base_entries { + entries_by_hook.insert(hook_name, entry); + } + Some(RoutePluginEntry { + plugin_name: plugin_name.to_string(), + entries_by_hook, + }) + } +} + +fn parse_on_error(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "fail" => Some(OnError::Fail), + "ignore" => Some(OnError::Ignore), + "disable" => Some(OnError::Disable), + _ => None, + } +} + +/// Recursively walk every effect node in an `Effect` tree, invoking +/// `visit` on each. Used by `collect_*_names` below to find Plugin / +/// Delegate references that may be nested inside `Effect::When`, +/// `Effect::Sequential`, `Effect::Parallel`, or `Effect::Pdp` reaction +/// lists. Pre-E4 these were flat — Step::Plugin lived directly under +/// policy: — so a simple iter() was enough; after E4 the IR is tree- +/// shaped and the same scan needs recursion. +fn walk_effects(effects: &[Effect], visit: &mut F) { + for e in effects { + visit(e); + match e { + Effect::When { body, .. } => walk_effects(body, visit), + Effect::Sequential(inner) | Effect::Parallel(inner) => walk_effects(inner, visit), + Effect::Pdp { on_allow, on_deny, .. } => { + walk_effects(on_allow, visit); + walk_effects(on_deny, visit); + } + _ => {} + } + } +} + +/// Walk a `CompiledRoute` and return the unique delegate-plugin names +/// referenced by any `Effect::Delegate` anywhere in `policy` / +/// `post_policy` (including effects nested inside When / Sequential / +/// Parallel / Pdp reactions). Insertion-ordered for build determinism. +/// Separate from [`collect_plugin_names`] because delegate plugins +/// resolve under a different hook family (`token.delegate`) and the +/// dispatch plan keeps them in a separate map. +pub(crate) fn collect_delegate_plugin_names(route: &CompiledRoute) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut visit = |e: &Effect| { + if let Effect::Delegate(ds) = e { + if seen.insert(ds.plugin_name.clone()) { + out.push(ds.plugin_name.clone()); + } + } + }; + walk_effects(&route.policy, &mut visit); + walk_effects(&route.post_policy, &mut visit); + out +} + +/// Walk a `CompiledRoute` and return the unique plugin names referenced +/// by any `Effect::Plugin` anywhere in `policy` / `post_policy` (including +/// nested) or `Stage::Plugin` (in `args` / `result` pipelines). +/// Insertion-ordered for build determinism. +pub(crate) fn collect_plugin_names(route: &CompiledRoute) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut visit = |e: &Effect| { + if let Effect::Plugin { name } = e { + if seen.insert(name.clone()) { + out.push(name.clone()); + } + } + }; + walk_effects(&route.policy, &mut visit); + walk_effects(&route.post_policy, &mut visit); + for fr in route.args.iter().chain(route.result.iter()) { + for stage in &fr.pipeline.stages { + if let Stage::Plugin { name } = stage { + if seen.insert(name.clone()) { + out.push(name.clone()); + } + } + } + } + out +} + +/// Compute the union of capabilities declared by every plugin a +/// `CompiledRoute` can dispatch to (with per-route overrides applied). +/// +/// This is what the synthetic `AplRouteHandler`'s `PluginConfig.capabilities` +/// must be set to: cpex-core's executor filters the `Extensions` view +/// before invoking every plugin (including the synthetic one), so if +/// the handler has fewer capabilities than its inner plugins need, +/// downstream views get doubly-filtered and label/delegation mutations +/// fail monotonicity checks on the way back out. +/// +/// Plugins missing from the registry are silently skipped — the +/// dispatch plan will log a `warn!` and surface a `NotFound` at +/// invocation time, so config drift surfaces in the right place +/// rather than as a confusing capability gap. +pub(crate) fn route_capability_union( + route: &CompiledRoute, + registry: &PluginRegistry, +) -> std::collections::HashSet { + let mut caps: std::collections::HashSet = std::collections::HashSet::new(); + // Plugin steps (`plugin(name)` in policy / `plugin: name` in + // args / result pipelines). + for name in collect_plugin_names(route) { + if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { + for cap in eff.capabilities.as_slice() { + caps.insert(cap.clone()); + } + } + } + // Delegate steps (`delegate(name, ...)`). Without this, a + // delegator plugin that declares `capabilities: + // [read_inbound_credentials, write_delegated_tokens]` in YAML + // gets those stripped at the AplRouteHandler boundary — the + // synthetic handler doesn't union its caps in, so the executor + // filters out the inbound bearer before DelegationPluginInvoker + // dispatches, and the delegator handler sees an empty token. + // Hosts WANT to express per-plugin caps in YAML rather than + // widening the AplRouteHandler's baseline (which would leak + // those creds to every other step in the route). + for name in collect_delegate_plugin_names(route) { + if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { + for cap in eff.capabilities.as_slice() { + caps.insert(cap.clone()); + } + } + } + caps +} + +/// Host-owned dispatch cache. Construct once, share via `Arc` +/// across all `CmfPluginInvoker::for_request` calls so plans built for +/// one request can be reused by the next. +/// +/// Cache key is the APL `route_key`. Entries pair with the cpex-core +/// snapshot generation observed at build time; a mismatch on lookup +/// triggers eviction and rebuild. v0 keys on `route_key` only — +/// entity-aware caching (entity_type/entity_name from `MetaExtension`) +/// is a follow-up when per-tenant lineup variation lands. +#[derive(Default)] +pub struct DispatchCache { + inner: RwLock)>>, +} + +impl DispatchCache { + pub fn new() -> Self { + Self::default() + } + + /// Get-or-build a plan for the route. Read-locked fast path returns + /// the cached plan when the generation matches; otherwise drop the + /// read lock, rebuild, and write-lock-insert. The brief window + /// between read-miss and write-insert may let two concurrent + /// builders race — both produce identical plans and the second + /// insert just overwrites the first. Cheap relative to the cost of + /// the build itself, and avoids holding a write lock across the + /// build call. + /// + /// Async because `RouteDispatchPlan::build` may invoke + /// `PluginManager::build_override_entries`, which calls plugin + /// factories and `initialize()` for routes that declare `config:` + /// overrides. Routes with no overrides take a synchronous path + /// inside the manager (no `.await` does any real work), so the + /// async cost is zero for the common case. + pub async fn get_or_build( + &self, + route: &CompiledRoute, + registry: &PluginRegistry, + manager: &PluginManager, + ) -> Arc { + let current_gen = manager.config_generation(); + { + let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); + if let Some((stored_gen, plan)) = r.get(&route.route_key) { + if *stored_gen == current_gen { + return Arc::clone(plan); + } + } + } + let plan = Arc::new(RouteDispatchPlan::build(route, registry, manager).await); + let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner()); + w.insert(route.route_key.clone(), (current_gen, Arc::clone(&plan))); + plan + } +} diff --git a/crates/apl-cpex/src/lib.rs b/crates/apl-cpex/src/lib.rs new file mode 100644 index 00000000..b5f6aaef --- /dev/null +++ b/crates/apl-cpex/src/lib.rs @@ -0,0 +1,52 @@ +// Location: ./crates/apl-cpex/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-cpex — bridge between APL evaluator (`apl-core`) and CPEX runtime +// (`cpex-core`). +// +// `apl-core::PluginInvoker` is string-typed by design (so `apl-core` +// stays free of CPEX deps). The actual typed boundary lives in this +// crate: one `PluginInvoker` implementation per `HookTypeDef`. The +// payload type is locked at the impl level — e.g. [`CmfPluginInvoker`] +// can only dispatch to CMF hooks because every internal call goes +// through `invoke_named::`, and the compiler enforces that +// the payload is `MessagePayload`. +// +// # v0 simplification — single-view-per-Message +// +// CMF spec §4.2 distinguishes two messaging patterns: +// - LLM wire format — bundled multi-part Messages (thinking + text + +// tool_call(s)) — many MessageViews per Message. +// - Framework/protocol format (MCP, A2A, LangGraph) — single +// ContentPart per Message — one view per Message. +// +// v0 only handles request-side flows (outbound LLM call from the user, +// outbound MCP tools/call from the agent). Both are single-part, so the +// route → MessageView matching collapses to "one route fires per +// Message." When response-side handling lands, this assumption breaks +// and apl-core's route-matching layer needs to switch from +// routes-as-map to routes-as-list with a `match:` block filtering on +// MessageView attributes. See the APL implementation memory's +// "list-with-matchers" deferred item. + +pub mod cmf_invoker; +pub mod delegation_invoker; +pub mod dispatch_plan; +pub mod parallel_safety; +pub mod pdp_router; +pub mod register; +pub mod route_handler; +pub mod session_resolver; +pub mod session_store; +pub mod visitor; + +pub use cmf_invoker::CmfPluginInvoker; +pub use delegation_invoker::DelegationPluginInvoker; +pub use dispatch_plan::{DispatchCache, RouteDispatchPlan, RoutePluginEntry}; +pub use pdp_router::PdpRouter; +pub use register::{register_apl, AplOptions}; +pub use route_handler::{AplRouteHandler, Phase}; +pub use session_store::{MemorySessionStore, SessionStore}; +pub use visitor::AplConfigVisitor; diff --git a/crates/apl-cpex/src/parallel_safety.rs b/crates/apl-cpex/src/parallel_safety.rs new file mode 100644 index 00000000..2550927b --- /dev/null +++ b/crates/apl-cpex/src/parallel_safety.rs @@ -0,0 +1,343 @@ +// Location: ./crates/apl-cpex/src/parallel_safety.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Route-compile-time plugin-mode validation for APL `parallel:` blocks. +// +// `apl-core::Effect::validate_parallel_purity` already rejects FieldOp / +// Delegate at the IR level — those are statically detectable without +// any plugin knowledge. Plugin calls (`Effect::Plugin { name }`) need +// a second pass because their concurrency-safety depends on each +// plugin's registered `PluginMode` — information that lives in the +// PluginManager, not the IR. +// +// Lives in apl-cpex because: +// * apl-core can't see plugin modes (plugin-agnostic by design) +// * The PluginManager is constructed in the host integration, not in +// apl-core's compiler +// * The visitor that turns YAML routes into `CompiledRoute`s is the +// natural place to run all post-IR-level validations together +// +// # Mode rules +// +// Allowed inside `parallel:`: +// - `Audit` — read-only by declaration +// - `Concurrent` — explicitly designed for parallel execution +// - `FireAndForget` — side-effects only, no return value to merge +// - `Disabled` — skipped at runtime anyway +// +// Rejected inside `parallel:`: +// - `Sequential` — `can_modify() == true`, would silently lose its mutation +// - `Transform` — same as Sequential for our purposes +// +// The asymmetry exists because parallel branches each get a *cloned* +// bag and payload; any mutation a branch makes lives only inside its +// clone. Plugins authored under Sequential / Transform semantics +// reasonably assume their writes persist. Detecting the misuse at +// route-compile means the operator sees a clear error instead of a +// confusing "but my plugin ran and the bag didn't change" runtime +// surprise. + +use apl_core::rules::{CompiledRoute, Effect}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::PluginMode; + +/// Read-only "what mode is plugin X registered with" lookup, used by +/// the validator. A trait (rather than a `&PluginManager`) so: +/// +/// * Tests can pass a small HashMap-backed mock without constructing +/// a real `PluginManager` (which requires plugin registration and +/// a bunch of cpex-core internal types). +/// * Future consumers that store plugin modes in a different shape +/// (e.g. a separate config catalogue) plug in without forcing them +/// to back the lookup with a full PluginManager. +pub trait PluginModeLookup { + /// Returns the mode for `name`, or `None` if no plugin by that + /// name is registered. + fn mode_for(&self, name: &str) -> Option; +} + +impl PluginModeLookup for PluginManager { + fn mode_for(&self, name: &str) -> Option { + self.get_plugin(name).map(|p| p.mode()) + } +} + +/// Walk a compiled route looking for `Effect::Plugin` calls nested +/// inside any `Effect::Parallel` block, and check that each named +/// plugin's registered mode is safe for parallel execution. +/// +/// Returns `Ok(())` if all plugins inside parallel blocks have safe +/// modes (or the route has no parallel blocks). On failure, returns a +/// `;`-separated list of every violation found — running a single pass +/// over the route surfaces all problems at once instead of stopping +/// at the first. +pub fn validate_parallel_plugin_modes( + route: &CompiledRoute, + registry: &L, +) -> Result<(), String> { + let mut errors: Vec = Vec::new(); + for (phase_name, effects) in [ + ("policy", route.policy.as_slice()), + ("post_policy", route.post_policy.as_slice()), + ] { + for (idx, effect) in effects.iter().enumerate() { + walk_effect( + effect, + &format!("routes.{}.{}[{}]", route.route_key, phase_name, idx), + false, + registry, + &mut errors, + ); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +/// Recursive traversal. `under_parallel` is true once we've descended +/// into a `Parallel` node; from then on every `Plugin` we hit gets +/// checked against the mode allowlist. Nested `Parallel`/`Sequential` +/// both keep the flag true (a sequential block inside a parallel one +/// is still ultimately running in the parallel branch's cloned state). +fn walk_effect( + effect: &Effect, + location: &str, + under_parallel: bool, + registry: &L, + errors: &mut Vec, +) { + match effect { + Effect::Plugin { name } if under_parallel => { + check_plugin_mode(name, location, registry, errors); + } + Effect::Parallel(inner) => { + for e in inner { + walk_effect(e, location, true, registry, errors); + } + } + Effect::Sequential(inner) => { + for e in inner { + walk_effect(e, location, under_parallel, registry, errors); + } + } + Effect::When { body, .. } => { + // A `when:` body inherits the parallel context of its + // enclosing scope. Plugin calls inside `when:` under a + // `parallel:` are still subject to the mode check. + for e in body { + walk_effect(e, location, under_parallel, registry, errors); + } + } + Effect::Pdp { on_allow, on_deny, .. } => { + for e in on_allow.iter().chain(on_deny.iter()) { + walk_effect(e, location, under_parallel, registry, errors); + } + } + // Other variants (Allow/Deny/Plugin-not-in-parallel/Delegate/ + // Taint/FieldOp) don't carry nested effects today. Note that + // `Delegate` / `FieldOp` inside Parallel was already rejected + // by `apl-core::Effect::validate_parallel_purity` at parse + // time — no need to re-check here. + _ => {} + } +} + +fn check_plugin_mode( + name: &str, + location: &str, + registry: &L, + errors: &mut Vec, +) { + let mode = match registry.mode_for(name) { + Some(m) => m, + None => { + errors.push(format!( + "{}: `parallel:` references unknown plugin `{}`", + location, name + )); + return; + } + }; + if !is_safe_in_parallel(mode) { + errors.push(format!( + "{}: plugin `{}` has mode `{}` which can modify state; parallel \ + branches discard mutations, so this would silently lose its effect. \ + Use `sequential:` for ordered mutations or change the plugin's mode.", + location, name, mode, + )); + } +} + +/// Allowlist check. Centralised so the rule is documented in one +/// place and easy to find if `PluginMode` gains a new variant. +fn is_safe_in_parallel(mode: PluginMode) -> bool { + matches!( + mode, + PluginMode::Audit + | PluginMode::Concurrent + | PluginMode::FireAndForget + | PluginMode::Disabled + ) +} + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use apl_core::rules::Expression; + use std::collections::HashMap; + + /// Test mock — a plain `HashMap`. Implements the + /// lookup trait without needing the real cpex-core registry's + /// plugin / hook registration machinery. + struct MockLookup(HashMap); + + impl MockLookup { + fn new() -> Self { + Self(HashMap::new()) + } + fn with(mut self, name: &str, mode: PluginMode) -> Self { + self.0.insert(name.to_string(), mode); + self + } + } + + impl PluginModeLookup for MockLookup { + fn mode_for(&self, name: &str) -> Option { + self.0.get(name).copied() + } + } + + fn route_with_policy(effects: Vec) -> CompiledRoute { + let mut r = CompiledRoute::new("test_route"); + r.policy = effects; + r + } + + fn rule(effects: Vec) -> Effect { + Effect::When { + condition: Expression::Always, + body: effects, + source: "test".into(), + } + } + + fn parallel_plugin(name: &str) -> Effect { + Effect::Parallel(vec![Effect::Plugin { name: name.into() }]) + } + + // --- Allowed modes --- + + #[test] + fn audit_plugin_in_parallel_is_accepted() { + let reg = MockLookup::new().with("audit_logger", PluginMode::Audit); + let route = route_with_policy(vec![rule(vec![parallel_plugin("audit_logger")])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + #[test] + fn concurrent_plugin_in_parallel_is_accepted() { + let reg = MockLookup::new().with("pii_scanner", PluginMode::Concurrent); + let route = route_with_policy(vec![rule(vec![parallel_plugin("pii_scanner")])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + #[test] + fn fire_and_forget_in_parallel_is_accepted() { + let reg = MockLookup::new().with("metrics", PluginMode::FireAndForget); + let route = route_with_policy(vec![rule(vec![parallel_plugin("metrics")])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + // --- Rejected modes --- + + #[test] + fn sequential_plugin_in_parallel_is_rejected() { + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let route = route_with_policy(vec![rule(vec![parallel_plugin("mutator")])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("mutator"), "names plugin: {}", err); + assert!(err.contains("sequential"), "names mode: {}", err); + assert!(err.contains("`sequential:`"), "suggests fix: {}", err); + } + + #[test] + fn transform_plugin_in_parallel_is_rejected() { + let reg = MockLookup::new().with("redactor", PluginMode::Transform); + let route = route_with_policy(vec![rule(vec![parallel_plugin("redactor")])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("transform")); + } + + #[test] + fn unknown_plugin_in_parallel_is_rejected() { + let reg = MockLookup::new(); + let route = route_with_policy(vec![rule(vec![parallel_plugin("ghost")])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("unknown plugin")); + assert!(err.contains("ghost")); + } + + // --- Scoping: only mismatches INSIDE a parallel block are caught --- + + #[test] + fn sequential_plugin_outside_parallel_is_allowed() { + // The same Sequential-mode plugin is fine at the top level — + // only its appearance INSIDE a parallel block is the problem. + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let route = route_with_policy(vec![rule(vec![Effect::Plugin { + name: "mutator".into(), + }])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + #[test] + fn nested_sequential_inside_parallel_still_validates_plugins() { + // `parallel: [sequential: [plugin(seq_mode)]]` — the sequential + // is just a grouping construct; the plugin still runs inside + // the parallel branch's cloned state. + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![ + Effect::Sequential(vec![Effect::Plugin { + name: "mutator".into(), + }]), + ])])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("mutator")); + } + + // --- Diagnostics: every violation, both phases --- + + #[test] + fn multiple_violations_all_reported() { + // Surface every violation in one pass so the operator can fix + // them all at once instead of one error per build cycle. + let reg = MockLookup::new() + .with("a", PluginMode::Sequential) + .with("b", PluginMode::Transform); + let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![ + Effect::Plugin { name: "a".into() }, + Effect::Plugin { name: "b".into() }, + ])])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("`a`"), "names a: {}", err); + assert!(err.contains("`b`"), "names b: {}", err); + } + + #[test] + fn post_policy_phase_is_validated_too() { + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let mut route = CompiledRoute::new("test_route"); + route.post_policy = vec![rule(vec![parallel_plugin("mutator")])]; + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("post_policy")); + } +} diff --git a/crates/apl-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs new file mode 100644 index 00000000..bbfa12fa --- /dev/null +++ b/crates/apl-cpex/src/pdp_router.rs @@ -0,0 +1,207 @@ +// Location: ./crates/apl-cpex/src/pdp_router.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `PdpRouter` — composite `PdpResolver` that dispatches each call to the +// resolver matching the requested `PdpDialect`. Lets a single host (or a +// single `AplRouteHandler`) carry resolvers for Cedar **and** OPA **and** +// NeMo at the same time without having to pick one at construction. +// +// Routing is by dialect equality. The first registered resolver for a +// given dialect wins on duplicate registration — registering Cedar twice +// keeps the original and logs a warning. Unknown-dialect calls return +// `PdpError::NoResolver(dialect)`. +// +// `PdpRouter` is itself a `PdpResolver`, so it slots straight into +// `AplRouteHandler::with_pdp`. Its own `dialect()` method returns +// `PdpDialect::Custom("router")` — a sentinel the evaluator doesn't +// branch on; only inner resolvers' dialects matter. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; + +use apl_core::attributes::AttributeBag; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +/// Dispatches PDP calls to the right resolver based on +/// `Step::Pdp.call.dialect`. Construct with `new()`, add resolvers via +/// `register`, then hand the router to a route handler. +/// +/// Cloning is cheap (refcount bumps on each resolver `Arc`) — the +/// `AplConfigVisitor` snapshots its accumulated router into an `Arc` +/// for every installed route handler so a config reload that mutates +/// the visitor state doesn't tear in-flight handlers. +#[derive(Clone)] +pub struct PdpRouter { + resolvers: HashMap>, +} + +impl PdpRouter { + pub fn new() -> Self { + Self { + resolvers: HashMap::new(), + } + } + + /// Register a resolver for its declared dialect. If a resolver is + /// already registered for that dialect the new one is dropped and a + /// warning is logged — explicit replacement should go through + /// `replace` instead so the intent is visible at call sites. + pub fn register(&mut self, resolver: Arc) -> &mut Self { + let dialect = resolver.dialect(); + if self.resolvers.contains_key(&dialect) { + tracing::warn!( + dialect = ?dialect, + "PdpRouter: resolver for dialect already registered — keeping existing", + ); + return self; + } + self.resolvers.insert(dialect, resolver); + self + } + + /// Replace any existing resolver for the new resolver's dialect. + /// Use this when the host genuinely wants to swap in a different + /// implementation (testing, A/B rollout). + pub fn replace(&mut self, resolver: Arc) -> &mut Self { + let dialect = resolver.dialect(); + self.resolvers.insert(dialect, resolver); + self + } + + /// Number of registered resolvers. Useful for tests. + pub fn len(&self) -> usize { + self.resolvers.len() + } + + pub fn is_empty(&self) -> bool { + self.resolvers.is_empty() + } +} + +impl Default for PdpRouter { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl PdpResolver for PdpRouter { + fn dialect(&self) -> PdpDialect { + // Sentinel — evaluator routes per `Step::Pdp.call.dialect`, not + // the resolver's own declared dialect. The router never claims to + // be one of the real dialects so a stray equality check can't + // accidentally pick it. + PdpDialect::Custom("router".to_string()) + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + let resolver = self + .resolvers + .get(&call.dialect) + .ok_or_else(|| PdpError::NoResolver(call.dialect.clone()))?; + resolver.evaluate(call, bag).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use apl_core::evaluator::Decision; + + struct FakePdp { + dialect: PdpDialect, + decision: Decision, + } + + #[async_trait] + impl PdpResolver for FakePdp { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { + decision: self.decision.clone(), + diagnostics: Vec::new(), + }) + } + } + + #[tokio::test] + async fn routes_by_dialect() { + let mut router = PdpRouter::new(); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Cedar, + decision: Decision::Allow, + })); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Opa, + decision: Decision::Deny { + reason: Some("opa says no".into()), + rule_source: "opa".into(), + }, + })); + + let bag = AttributeBag::default(); + let cedar_call = PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::Null, + }; + let opa_call = PdpCall { + dialect: PdpDialect::Opa, + args: serde_yaml::Value::Null, + }; + + let cedar_res = router.evaluate(&cedar_call, &bag).await.unwrap(); + assert!(matches!(cedar_res.decision, Decision::Allow)); + + let opa_res = router.evaluate(&opa_call, &bag).await.unwrap(); + assert!(matches!(opa_res.decision, Decision::Deny { .. })); + } + + #[tokio::test] + async fn missing_dialect_returns_no_resolver() { + let router = PdpRouter::new(); + let bag = AttributeBag::default(); + let call = PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::Null, + }; + let err = router.evaluate(&call, &bag).await.unwrap_err(); + assert!(matches!(err, PdpError::NoResolver(_))); + } + + #[tokio::test] + async fn duplicate_register_keeps_first() { + let mut router = PdpRouter::new(); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Cedar, + decision: Decision::Allow, + })); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Cedar, + decision: Decision::Deny { + reason: Some("shouldn't fire".into()), + rule_source: "test".into(), + }, + })); + let call = PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::Null, + }; + let res = router.evaluate(&call, &AttributeBag::default()).await.unwrap(); + assert!(matches!(res.decision, Decision::Allow)); + } +} diff --git a/crates/apl-cpex/src/register.rs b/crates/apl-cpex/src/register.rs new file mode 100644 index 00000000..ab6becf2 --- /dev/null +++ b/crates/apl-cpex/src/register.rs @@ -0,0 +1,185 @@ +// Location: ./crates/apl-cpex/src/register.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `register_apl` — sugar function that bundles "construct +// `AplConfigVisitor` + register it with the manager" into one call. +// +// Hosts that just want APL governance with sensible defaults call this +// instead of building the visitor by hand. The lower-level +// `PluginManager::register_visitor` API stays available for custom +// orchestrators (future Rego, Cedar-direct, hand-rolled audit visitors) +// that don't fit the APL setup. +// +// # Two ways to supply PDPs +// +// PDP resolvers can reach the visitor's internal `PdpRouter` via two +// channels, and `AplOptions` exposes both: +// +// * `pdps` — code-supplied resolvers. The host built them +// in Rust (e.g. a hand-rolled audit resolver, +// a test fake) and hands them in directly. +// * `pdp_factories` — factories the visitor consults when it sees a +// `global.apl.pdp[]` entry in the unified +// config. Each factory advertises a `kind()` +// string that matches the YAML block's `kind:` +// field. +// +// Both channels feed the same `PdpRouter` inside the visitor, so a +// host can mix the two freely — code-supplied Cedar for tests plus a +// config-declared OPA in prod, say. + +use std::collections::HashSet; +use std::sync::Arc; + +use cpex_core::manager::PluginManager; +use cpex_core::visitor::ConfigVisitor; + +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::dispatch_plan::DispatchCache; +use crate::session_store::SessionStore; +use crate::visitor::AplConfigVisitor; + + +/// Configuration for [`register_apl`]. All runtime collaborators APL +/// needs to do its work are funneled through here so the call site +/// reads as a single block instead of a multi-step builder. +pub struct AplOptions { + /// Shared dispatch-plan cache. One `Arc` per host + /// instance — clones are cheap (refcount bump) and the cache is + /// internally synchronized. + pub dispatch_cache: Arc, + + /// Pluggable session-scoped state. `MemorySessionStore` is the + /// default in-process backend; production hosts swap in Redis / + /// DynamoDB-backed impls. + pub session_store: Arc, + + /// Zero or more code-supplied PDP resolvers. Each is registered + /// into the visitor's internal `PdpRouter`, so `pdp(...)` steps + /// dispatch by dialect across this list **and** any resolvers the + /// visitor builds from `global.apl.pdp[]` config entries. An empty + /// list combined with empty `pdp_factories` means no PDP is wired + /// — routes that call `pdp(...)` surface `PdpError::NoResolver` at + /// evaluation time, which is the correct behavior for "you forgot + /// to configure your policy decision point." + pub pdps: Vec>, + + /// PDP factories the visitor consults when it encounters a + /// `global.apl.pdp[]` entry. Each factory advertises a `kind()` + /// string that matches the YAML block's `kind:` field — e.g. + /// `cedar-direct`, `cedarling`, `opa`. An empty list disables + /// config-driven PDP wiring; hosts can still supply resolvers via + /// `pdps`. + pub pdp_factories: Vec>, + + /// Override the visitor's baseline capabilities for installed + /// `AplRouteHandler`s. `None` uses the visitor's default + /// (read-only across the common attribute namespaces); `Some(set)` + /// replaces it entirely. The per-route plugin capability union is + /// added on top regardless — this only controls the baseline. + /// + /// Set to `Some(HashSet::new())` for strict deployments where + /// only plugin-declared caps should be granted. + pub base_capabilities: Option>, +} + +impl AplOptions { + /// Minimal options — in-process dispatch cache + memory session + /// store, no PDP, default baseline capabilities. Useful for tests + /// and single-process demos. + pub fn in_process() -> Self { + Self { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(crate::session_store::MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + } + } +} + +/// Build an [`AplConfigVisitor`] from the supplied options and register +/// it on the manager. Returns the `Arc` so the caller +/// can stash it for later inspection (or call `register_pdp` on it +/// after the fact for late-bound resolvers) — but in the typical case +/// the return value is dropped and the visitor lives inside the +/// manager's visitor list. +/// +/// After this call, the next `mgr.load_config_yaml(yaml)` invocation +/// will walk the visitor: cpex-core's [`visit_plugins`][vp] populates +/// the APL plugin registry from `&[PluginConfig]`; `visit_global` +/// processes any `global.apl.pdp[]` entries by dispatching to the +/// registered `pdp_factories`; the hierarchy walk stacks `global.apl` +/// / `defaults..apl` / `policies..apl` / route-level +/// `apl:` into compiled routes; one `AplRouteHandler` is installed +/// per route per phase via [`PluginManager::annotate_route`][ar]. +/// +/// [vp]: cpex_core::visitor::ConfigVisitor::visit_plugins +/// [ar]: cpex_core::manager::PluginManager::annotate_route +/// +/// # Example +/// +/// ```ignore +/// use std::sync::Arc; +/// use cpex_core::manager::PluginManager; +/// use apl_cpex::{register_apl, AplOptions}; +/// use apl_pdp_cedar_direct::CedarDirectPdpFactory; +/// +/// let mgr = Arc::new(PluginManager::default()); +/// mgr.register_factory("scope-gate", Box::new(ScopeGateFactory)); +/// +/// apl_cpex::register_apl(&mgr, AplOptions { +/// dispatch_cache: dispatch_cache.clone(), +/// session_store: session_store.clone(), +/// pdps: vec![], // none code-supplied +/// pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], +/// base_capabilities: None, +/// }); +/// +/// mgr.load_config_yaml(&yaml_string)?; +/// mgr.initialize().await?; +/// ``` +pub fn register_apl( + mgr: &Arc, + opts: AplOptions, +) -> Arc { + let AplOptions { + dispatch_cache, + session_store, + pdps, + pdp_factories, + base_capabilities, + } = opts; + + // Build the visitor and apply consuming builders first (these take + // `self` by value), then mutating registrations (`&mut self` for + // factories), and finally wrap in `Arc` so we can hand the shared + // handle to the manager. Code-supplied PDPs go through + // `register_pdp(&self, ...)` which uses interior mutability, so + // they're registered after the `Arc` wrap. + let mut visitor = AplConfigVisitor::new( + dispatch_cache, + session_store, + Arc::downgrade(mgr), + ); + + if let Some(caps) = base_capabilities { + visitor = visitor.with_base_capabilities(caps); + } + + for factory in pdp_factories { + visitor.register_pdp_factory(factory); + } + + let arc = Arc::new(visitor); + + for pdp in pdps { + arc.register_pdp(pdp); + } + + mgr.register_visitor(Arc::clone(&arc) as Arc); + arc +} diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs new file mode 100644 index 00000000..e941e881 --- /dev/null +++ b/crates/apl-cpex/src/route_handler.rs @@ -0,0 +1,569 @@ +// Location: ./crates/apl-cpex/src/route_handler.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `AplRouteHandler` — synthetic plugin that drives APL evaluation when +// cpex-core's `filter_entries_by_route` matches an annotated route. Each +// instance is bound to ONE phase (Pre or Post) so the unified-config +// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry +// distinct handler logic without an in-handler hook-name discriminator. +// +// # Why a phase-bound handler +// +// The CPEX manager's annotation table is keyed on +// `(entity_type, entity_name, scope, hook_name)`. The visitor registers +// one handler per route per phase; the manager picks the right one based +// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is +// needed — the handler already knows which phase it's running. +// +// # Lifetime / weak manager handle +// +// The handler holds `Weak` because the manager owns the +// snapshot that owns the annotation that owns the handler — a strong +// reference would create a cycle. Each `invoke` upgrades to `Arc` for +// the duration of the call. If the upgrade fails (manager has been +// dropped) the call returns a configuration error. + +use std::sync::{Arc, Weak}; + +use async_trait::async_trait; +use serde_json::Value; + +use cpex_core::cmf::MessagePayload; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::executor::ErasedResultFields; +use cpex_core::extensions::Extensions; +use cpex_core::hooks::PluginPayload; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use apl_cmf::{extract_args, extract_result, BagBuilder}; +use apl_core::evaluator::Decision; +use apl_core::plugin_decl::PluginRegistry; +use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload}; +use apl_core::rules::CompiledRoute; +use apl_core::step::PdpResolver; + +use crate::cmf_invoker::CmfPluginInvoker; +use crate::delegation_invoker::DelegationPluginInvoker; +use crate::dispatch_plan::DispatchCache; +use crate::pdp_router::PdpRouter; +use crate::session_store::SessionStore; + +/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post +/// covers `result` + `post_policy`. Set once at construction and never +/// changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Pre, + Post, +} + +/// Synthetic plugin that drives APL evaluation for one route + one phase. +/// +/// Implements `Plugin` (so cpex-core treats it like any other plugin — +/// mode/capabilities/on_error come from the `PluginConfig` the visitor +/// supplied at `annotate_route` time) and `AnyHookHandler` (so the +/// executor dispatches into it through the normal type-erased path). +pub struct AplRouteHandler { + config: PluginConfig, + route: Arc, + phase: Phase, + plugin_registry: Arc, + dispatch_cache: Arc, + session_store: Arc, + /// Weak handle to the manager so we can resolve plugin entries + + /// dispatch into them by-name. `Weak` avoids the + /// manager↔snapshot↔annotation↔handler cycle. + manager: Weak, + /// PDP resolver. APL routes that don't use `pdp(...)` steps never + /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)` + /// step against an unregistered dialect returns + /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc. + /// install resolvers via [`Self::with_pdp`] or + /// [`Self::with_pdp_router`]. + pdp: Arc, +} + +impl AplRouteHandler { + /// Build a handler. Visitor calls this twice per route — once for + /// each phase — and passes the resulting `Arc` to `annotate_route`. + pub fn new( + config: PluginConfig, + route: Arc, + phase: Phase, + plugin_registry: Arc, + dispatch_cache: Arc, + session_store: Arc, + manager: Weak, + ) -> Self { + Self { + config, + route, + phase, + plugin_registry, + dispatch_cache, + session_store, + manager, + pdp: Arc::new(PdpRouter::new()), + } + } + + /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs + /// to support multiple dialects (Cedar + OPA + NeMo) on the same + /// route — the router dispatches each `pdp(...)` step by dialect. + /// Pass a single resolver when only one dialect is in use; APL + /// steps for any other dialect will then return + /// `PdpError::NoResolver` at evaluation time. + pub fn with_pdp(mut self, pdp: Arc) -> Self { + self.pdp = pdp; + self + } + + /// Sugar for the common "register many resolvers" path. Builds a + /// [`PdpRouter`], registers each resolver into it, then installs the + /// router. Equivalent to constructing a `PdpRouter` by hand and + /// passing it to [`Self::with_pdp`]. + pub fn with_pdp_router( + mut self, + resolvers: impl IntoIterator>, + ) -> Self { + let mut router = PdpRouter::new(); + for r in resolvers { + router.register(r); + } + self.pdp = Arc::new(router); + self + } +} + +#[async_trait] +impl Plugin for AplRouteHandler { + fn config(&self) -> &PluginConfig { + &self.config + } +} + +#[async_trait] +impl AnyHookHandler for AplRouteHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + // Downcast to the CMF payload — this handler only registers for + // cmf.* hook names, so the executor should always hand us a + // MessagePayload. A mismatch indicates a framework wiring bug. + let msg_payload = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "AplRouteHandler '{}': payload was not MessagePayload", + self.route.route_key + ), + }) + })?; + + let manager = self.manager.upgrade().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "AplRouteHandler '{}': PluginManager dropped before invoke", + self.route.route_key + ), + }) + })?; + + // Build (or reuse) the dispatch plan for this route. Cache keyed + // by `(route_key, manager.config_generation())` — if the manager + // has reloaded since the last invoke, the next lookup rebuilds. + let plan = self + .dispatch_cache + .get_or_build(&self.route, &self.plugin_registry, &manager) + .await; + + // CmfPluginInvoker carries the request-scoped payload + extensions + // under interior mutability so successive plugin calls accumulate + // mutations. Hydration + persistence are no-ops when there's no + // session id (the common case for the first request in a session). + // Wrapped in Arc so it can be erased to `Arc` + // for the apl-core entry points (which take `&Arc` + // so `dispatch_parallel` can clone an owned, 'static reference into + // each spawned branch). Inherent-method calls on `CmfPluginInvoker` + // (e.g. `extensions_arc`, `persist_session`) deref through the Arc. + let invoker = Arc::new( + CmfPluginInvoker::for_request( + Arc::clone(&manager), + extensions.clone(), + msg_payload.clone(), + plan, + Arc::clone(&self.session_store), + ) + .await, + ); + + // Build the attribute bag. APL predicates read flat keys; the + // BagBuilder bridges typed CPEX extensions into that namespace. + // `route.key` lets default/policy-bundle predicates branch on + // which route they're attached to. + let post_extensions = invoker.current_extensions().await; + let mut bag = BagBuilder::new() + .with_extensions(&post_extensions) + .with_route_key(&self.route.route_key) + .build(); + + // Build `RoutePayload.args` from the message. Per-content shape: + // * ToolCall → arguments map (JSON Object) + // * PromptRequest → arguments map (JSON Object) + // * Text-only → JSON String of concatenated text content + // + // Field pipelines operate on `args.` paths. Result starts + // as Null on Pre (no upstream response yet); the Post phase + // would extract from a ToolResult / PromptResult — deferred + // until result-side handling lands. + let args_value = extract_args_from_message(&msg_payload.message); + let mut route_payload = match self.phase { + Phase::Pre => RoutePayload::new(args_value), + Phase::Post => { + // Pull the upstream result out of the message so APL + // `result.` predicates and the `result:` + // pipeline have something to operate on. Falls back to + // `Value::Null` when the message has no ToolResult / + // PromptResult / Resource content (e.g. for hooks that + // fire on entities without a structured result). + let result_value = extract_result_from_message(&msg_payload.message); + RoutePayload::with_result(args_value, result_value) + } + }; + + // Flatten the call args into the bag under `args.`. APL's + // own args pipelines read from `route_payload.args` directly, + // but PDP steps and predicates that reference `${args.X}` / + // `args.X` resolve through the bag. Mirroring the args here + // makes both consumers see the same vocabulary the + // `MessageView` exposes. (Bag-mutation via redact during the + // args pipeline isn't reflected back into the bag; that's fine + // — args predicates today read from `route_payload.args`, and + // the cedar substitution snapshots the pre-args view, which is + // what an author writing `cedar:(resource.id: ${args.X})` would + // expect.) + extract_args(&route_payload.args, &mut bag); + // Post phase: also project the upstream result into the bag + // under `result.`. This is what enables predicates like + // `redact(result.ssn) when !perm.view_ssn` and `require(...)` + // gates that branch on the result. Pre phases skip this — the + // result is `None` by construction. + if matches!(self.phase, Phase::Post) { + if let Some(result_value) = route_payload.result.as_ref() { + extract_result(result_value, &mut bag); + } + } + + // Slice B: real delegation invoker, sharing the CMF invoker's + // extensions Mutex so a `delegate(...)` step's writes to + // raw_credentials / delegation are visible to downstream CMF + // plugins and to the post phase. Routes that don't declare + // any `Step::Delegate` won't have entries in the plan's + // `delegation_entries` map; if such a route accidentally hits + // `delegate(...)`, the invoker returns `NotFound` and the + // evaluator translates it via the step's `on_error`. + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&manager), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + // Unsized coercion: `Arc` → `Arc`. The + // erased forms get borrowed into `evaluate_pre`/`evaluate_post`; + // `dispatch_parallel` can then `Arc::clone` an owned 'static + // reference into each branch closure. + let invoker_dyn: Arc = invoker.clone(); + let delegations_dyn: Arc = delegations.clone(); + + let decision = match self.phase { + Phase::Pre => { + evaluate_pre( + &self.route, + &mut bag, + &mut route_payload, + &self.pdp, + &invoker_dyn, + &delegations_dyn, + ) + .await + } + Phase::Post => { + evaluate_post( + &self.route, + &mut bag, + &mut route_payload, + &self.pdp, + &invoker_dyn, + &delegations_dyn, + ) + .await + } + }; + + // Drain Session-scoped taints (from `taint(label, session)` / + // pipeline `Stage::Taint`) into `extensions.security.labels` + // so the existing label-diff flow inside `persist_session` + // picks them up. Message-scoped taints are filtered out by + // `apply_session_taints` — they need their own destination + // (see TS2). No-op when no taints emitted. + invoker.apply_session_taints(&decision.taints).await; + + // Commit any session-scoped labels accumulated during this + // request. No-op when there was no session id. + invoker.persist_session().await; + + // Surface the final mutated payload + extensions back into the + // PipelineResult the executor returns to the host. The host's + // body re-serialization picks up edits made by APL pipelines + // (e.g. a redact stage that rewrote args.text). + let final_payload = invoker.current_payload().await; + let final_extensions = invoker.current_extensions().await; + + // Detect whether the args pipeline mutated the payload by + // re-extracting from the pre-eval message (msg_payload is + // still borrowed) and comparing against the post-eval + // route_payload.args. Re-extraction allocates but mirrors the + // surrounding pattern and avoids holding a pre-eval clone. + let pre_args = extract_args_from_message(&msg_payload.message); + // For Post phase, also detect result mutations from `result:` + // pipelines. Pre routes don't carry a result so this is None. + let pre_result = match self.phase { + Phase::Pre => None, + Phase::Post => Some(extract_result_from_message(&msg_payload.message)), + }; + let modified_payload: Option> = + if route_payload.args != pre_args { + // An args pipeline (Pre) rewrote a field. Fold the new + // args back into a fresh MessagePayload so downstream + // readers (the host's body re-serializer) see the + // change. + let mut updated = final_payload.clone(); + write_args_back_to_message(&mut updated.message, &route_payload.args); + Some(Box::new(updated) as Box) + } else if matches!(self.phase, Phase::Post) + && pre_result + .as_ref() + .zip(route_payload.result.as_ref()) + .map(|(prev, current)| prev != current) + .unwrap_or(false) + { + // A `result:` pipeline rewrote a field in the upstream + // response. Fold the new result back into the message + // so the host's response body re-serializer can write + // it out before forwarding downstream. + let mut updated = final_payload.clone(); + if let Some(result_value) = route_payload.result.as_ref() { + write_result_back_to_message(&mut updated.message, result_value); + } + Some(Box::new(updated) as Box) + } else if msg_payload.message.get_text_content() + != final_payload.message.get_text_content() + { + // A `policy:` plugin mutated the message directly via + // `modify_payload` (not through a field pipeline). Pass + // the invoker's view through unchanged. + Some(Box::new(final_payload) as Box) + } else { + None + }; + + let modified_extensions = if extensions_changed(extensions, &final_extensions) { + Some(final_extensions.cow_copy()) + } else { + None + }; + + let (continue_processing, violation) = match decision.decision { + Decision::Allow => (true, None), + Decision::Deny { reason, rule_source } => { + let code = if rule_source.is_empty() { + "policy.deny".to_string() + } else { + rule_source + }; + let reason = reason.unwrap_or_else(|| "denied by APL".to_string()); + (false, Some(PluginViolation::new(code, reason))) + } + }; + + Ok(Box::new(ErasedResultFields { + continue_processing, + modified_payload, + modified_extensions, + violation, + })) + } + + fn hook_type_name(&self) -> &'static str { + // CmfHook::NAME — kept as a literal here to avoid pulling in the + // HookTypeDef trait just for the constant. + "cmf" + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Rewrite the first text part of `msg` with `new_text`. If there is no +/// text part, append one. Mirrors what `MessagePayload`'s normal +/// modify-path does for single-view v0. +fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) { + for part in msg.content.iter_mut() { + if let cpex_core::cmf::ContentPart::Text { text } = part { + *text = new_text.to_string(); + return; + } + } + msg.content.push(cpex_core::cmf::ContentPart::Text { + text: new_text.to_string(), + }); +} + +/// Extract `RoutePayload.args` from a CMF message. v0 maps: +/// * First `ContentPart::ToolCall` → `arguments` map (Object) +/// * First `ContentPart::PromptRequest` → `arguments` map (Object) +/// * Else (text / no entity parts) → JSON String of text content +/// +/// `args.` APL paths target tool / prompt arguments directly. +/// For text-only messages we fall back to the v0 "args = whole text" +/// shape so `args.text` predicates keep working. +fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value { + use cpex_core::cmf::ContentPart; + for part in &msg.content { + match part { + ContentPart::ToolCall { content } => { + return Value::Object( + content + .arguments + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ); + } + ContentPart::PromptRequest { content } => { + return Value::Object( + content + .arguments + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ); + } + _ => {} + } + } + Value::String(msg.get_text_content()) +} + +/// Inverse of [`extract_args_from_message`]: write `args` back into +/// `msg`'s first ToolCall / PromptRequest argument map, or — for +/// text payloads — into the first text part. +/// +/// Silently no-ops when the args shape doesn't match the message +/// content shape (e.g. operator pipeline produced a String for what +/// was originally a ToolCall). The mismatch path is recoverable — +/// the upstream just sees the original unmodified content rather +/// than a malformed rewrite. +fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) { + use cpex_core::cmf::ContentPart; + for part in msg.content.iter_mut() { + match part { + ContentPart::ToolCall { content } => { + if let Some(obj) = args.as_object() { + content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + } + return; + } + ContentPart::PromptRequest { content } => { + if let Some(obj) = args.as_object() { + content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + } + return; + } + _ => {} + } + } + // Fall through: no structured entity part — treat as text. + if let Some(text) = args.as_str() { + rewrite_message_text(msg, text); + } +} + +/// Extract `RoutePayload.result` from a CMF message. Mirror of +/// [`extract_args_from_message`] for the Post phase. v0 maps: +/// * First `ContentPart::ToolResult` → its `content` JSON value +/// * Else (text / no structured result part) → JSON String of text +/// +/// `result.` APL paths target the structured result directly. +fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value { + use cpex_core::cmf::ContentPart; + for part in &msg.content { + if let ContentPart::ToolResult { content } = part { + return content.content.clone(); + } + } + Value::String(msg.get_text_content()) +} + +/// Inverse of [`extract_result_from_message`]: write a mutated +/// `result` back into the message's first `ContentPart::ToolResult.content`, +/// or — for text-only messages — into the first text part. The praxis +/// filter's response-body re-serializer then lifts the new content +/// out of the ContentPart and folds it back into the JSON-RPC +/// `result.content[*].text` payload. +fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) { + use cpex_core::cmf::ContentPart; + for part in msg.content.iter_mut() { + if let ContentPart::ToolResult { content } = part { + content.content = result.clone(); + return; + } + } + if let Some(text) = result.as_str() { + rewrite_message_text(msg, text); + } +} + +/// Cheap pointer-equality check across the few mutable extension slots +/// the executor would care about. False positives (claiming a change +/// when there isn't one) are cheap — the executor re-validates anyway. +fn extensions_changed(before: &Extensions, after: &Extensions) -> bool { + let security_changed = match (before.security.as_ref(), after.security.as_ref()) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + // `delegate(...)` steps write minted tokens into + // `raw_credentials.delegated_tokens` via the shared Mutex — + // without this check, a route whose only Extensions mutation is + // a delegate (no security / delegation chain edit) looks + // unchanged, so the executor never merges the minted token back + // and downstream readers (our HttpFilter attaching the token to + // the upstream request) see nothing. + let raw_creds_changed = match ( + before.raw_credentials.as_ref(), + after.raw_credentials.as_ref(), + ) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + security_changed || delegation_changed || raw_creds_changed +} + diff --git a/crates/apl-cpex/src/session_resolver.rs b/crates/apl-cpex/src/session_resolver.rs new file mode 100644 index 00000000..77615c96 --- /dev/null +++ b/crates/apl-cpex/src/session_resolver.rs @@ -0,0 +1,432 @@ +// Location: ./crates/apl-cpex/src/session_resolver.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// 3-tier session-id resolver. The Python apl-plugins `SessionResolver` +// (cpex/framework/session.py) shipped a 4-tier version including a +// client-supplied `X-CPEX-Session-Id` header tier. **That tier is +// excluded by design here**: an authenticated client can set the +// header to another subject's known session id and inherit their +// accumulated taint labels, or to a new value and escape their own +// tainted session — defeating `session.labels`-based deny policies +// entirely. The Python comment framed the header as a feature ("lets +// a smart client maintain its own session boundary"); under threat +// modeling it is a privilege-escalation channel with no surviving +// use case the other tiers don't cover. If a future deployment needs +// client-supplied session grouping, the right shape is a subject- +// bound hash (`sha256(subject_id : client_value)`), not the raw +// header value. +// +// The resolver walks these tiers in order, returning the first hit: +// +// 0. `agent` — `AgentExtension.session_id`. A *pre-resolved* +// value: an upstream plugin or middleware decided what the +// session is and wrote it here. Highest priority because it +// represents authority, not derivation — overriding this with a +// derived value would discard that upstream decision. Plugins +// that need bespoke session resolution (e.g., reading from a +// separate session-management service) write here and let the +// resolver pick it up. +// +// 1. `token_claim` — explicit `session_id` claim in the inbound JWT. +// Strongest binding among the *derived* tiers: the auth issuer +// chose this session and signed it into the token. Read from +// `SecurityExtension.subject.claims["session_id"]`. +// +// 2. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16]. +// No special infrastructure needed; the triple is already populated +// by `apl-identity-jwt`'s claim mapping. Same user + same agent + +// same gateway = same session, stable across token refresh (the +// claims are stable even when the token string isn't). +// +// 3. `none` — no usable identifier; caller (CmfPluginInvoker) +// skips hydration / persistence. Returns `Ok(None)` so the caller +// can distinguish "no session" from "resolver error" if we ever +// add an error variant. +// +// Each tier reads from a typed `Extensions` field, not raw JWT/HTTP +// payloads — those have already been mapped by upstream identity +// plugins (apl-identity-jwt). The resolver stays free of crypto / +// parsing logic. + +use cpex_core::extensions::Extensions; +use sha2::{Digest, Sha256}; + +/// Which tier produced the session id. Useful for diagnostics / audit +/// and to let downstream code branch on binding strength (e.g., only +/// trust `token_claim`-derived sessions for the highest-stakes +/// operations). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionSource { + /// Pre-resolved by an upstream plugin via `AgentExtension.session_id`. + /// Highest priority — represents an authoritative decision. + Agent, + /// JWT `session_id` claim — strongest binding among derived tiers. + TokenClaim, + /// Derived from the identity triple. Stable across token refresh. + Identity, +} + +impl SessionSource { + pub fn as_str(self) -> &'static str { + match self { + SessionSource::Agent => "agent", + SessionSource::TokenClaim => "token_claim", + SessionSource::Identity => "identity", + } + } +} + +/// Resolve a session id from the request's `Extensions`. Returns +/// `Some((id, source))` on the first tier that hits, or `None` when +/// every tier comes up empty (anonymous request, no claims, no +/// header, no identity). +/// +/// Identity-tier (2) requires at minimum `security.subject.id` to be +/// populated — without an end-user identifier there's no meaningful +/// session boundary to hash against. The other two identity-triple +/// components (caller_workload, this_workload) fall back to the +/// `"-"` sentinel when absent, which keeps the hash defined but +/// degrades to a (sub, *, *) session — usually fine for demos with +/// a single gateway and single agent. +pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> { + // Tier 0: pre-resolved by an upstream plugin. Authoritative — + // wins over every derived tier so plugin-supplied custom session + // resolution isn't silently overridden by a derived hash. + if let Some(agent) = ext.agent.as_deref() { + if let Some(sid) = agent.session_id.as_deref() { + if !sid.is_empty() { + return Some((sid.to_string(), SessionSource::Agent)); + } + } + } + + // Tier 1: explicit JWT claim. + if let Some(sec) = ext.security.as_deref() { + if let Some(subj) = sec.subject.as_ref() { + if let Some(sid) = subj.claims.get("session_id") { + if !sid.is_empty() { + return Some((sid.clone(), SessionSource::TokenClaim)); + } + } + } + } + + // Tier 2: identity-derived. Hash the triple + // (end-user : calling agent : our gateway) — stable across token + // refresh because all three components survive token rotation. + if let Some(sec) = ext.security.as_deref() { + let sub = sec.subject.as_ref().and_then(|s| s.id.as_deref()); + if let Some(sub) = sub { + // Fall back to `-` so a missing component degrades the + // session to (sub, *, *) rather than the resolver silently + // returning None. Important for demos where the gateway + // hasn't yet attested its own `this_workload` identity. + let actor = sec + .caller_workload + .as_ref() + .and_then(|w| w.client_id.as_deref()) + .unwrap_or("-"); + let aud = sec + .this_workload + .as_ref() + .and_then(|w| w.client_id.as_deref()) + .unwrap_or("-"); + let raw = format!("{}:{}:{}", sub, actor, aud); + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + // 16 hex chars = 64 bits — plenty for the workload sizes + // CPEX targets, matches the Python implementation's + // `hexdigest()[:16]`. + let digest = hasher.finalize(); + let hex: String = digest + .iter() + .take(8) + .map(|b| format!("{:02x}", b)) + .collect(); + return Some((hex, SessionSource::Identity)); + } + } + + // Tier 3: no session. + None +} + +// ===================================================================== +// Tests — one scenario per tier, plus tier-priority assertions. +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{ + AgentExtension, Extensions, HttpExtension, SecurityExtension, SubjectExtension, + WorkloadIdentity, + }; + use std::sync::Arc; + + fn extensions_with_security(sec: SecurityExtension) -> Extensions { + Extensions { + security: Some(Arc::new(sec)), + ..Default::default() + } + } + + fn subject_with_claims(id: Option<&str>, claims: &[(&str, &str)]) -> SubjectExtension { + SubjectExtension { + id: id.map(String::from), + claims: claims + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ..Default::default() + } + } + + // --- Tier 0: agent (pre-resolved) --- + + #[test] + fn tier0_agent_session_id_hits_first() { + let mut agent = AgentExtension::default(); + agent.session_id = Some("sess-upstream".into()); + let ext = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let (sid, src) = resolve_session(&ext).expect("should resolve"); + assert_eq!(sid, "sess-upstream"); + assert_eq!(src, SessionSource::Agent); + } + + #[test] + fn tier0_skips_empty_agent_session_id() { + // Empty agent.session_id should fall through, otherwise an + // upstream that accidentally cleared the slot aliases every + // such request to "". + let mut agent = AgentExtension::default(); + agent.session_id = Some("".into()); + let ext = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + assert!(resolve_session(&ext).is_none()); + } + + #[test] + fn tier0_wins_over_token_claim() { + // Pre-resolved value beats a JWT claim — upstream authority. + let mut agent = AgentExtension::default(); + agent.session_id = Some("from-agent".into()); + let sec = SecurityExtension { + subject: Some(subject_with_claims( + Some("alice"), + &[("session_id", "from-token")], + )), + ..Default::default() + }; + let ext = Extensions { + agent: Some(Arc::new(agent)), + security: Some(Arc::new(sec)), + ..Default::default() + }; + + let (sid, src) = resolve_session(&ext).unwrap(); + assert_eq!(sid, "from-agent"); + assert_eq!(src, SessionSource::Agent); + } + + // --- Tier 1: token_claim --- + + #[test] + fn tier1_token_claim_hits_when_session_id_claim_present() { + let sec = SecurityExtension { + subject: Some(subject_with_claims( + Some("alice@corp.com"), + &[("session_id", "sess-from-token-789")], + )), + ..Default::default() + }; + let ext = extensions_with_security(sec); + + let (sid, src) = resolve_session(&ext).expect("should resolve"); + assert_eq!(sid, "sess-from-token-789"); + assert_eq!(src, SessionSource::TokenClaim); + } + + #[test] + fn tier1_skips_empty_session_id_claim() { + // Empty claim values should NOT win tier 1 — they degrade to + // identity-derived. Otherwise an issuer accidentally putting + // an empty string in the claim would yield "" as the session + // key, which would alias every such request. + let sec = SecurityExtension { + subject: Some(subject_with_claims( + Some("alice"), + &[("session_id", "")], + )), + ..Default::default() + }; + let ext = extensions_with_security(sec); + + let (_, src) = resolve_session(&ext).expect("should fall through to identity"); + assert_eq!(src, SessionSource::Identity); + } + + // --- Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent --- + // + // The Python `SessionResolver` included a header tier; cpex Rust + // does not. See the module-level doc comment for the threat model. + // A spoofing-regression guard lives below in + // `header_x_cpex_session_id_is_ignored`. + + // --- Tier 2: identity --- + + #[test] + fn tier2_identity_derived_when_no_claim() { + let sec = SecurityExtension { + subject: Some(subject_with_claims(Some("alice@corp.com"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some("agent-007".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + client_id: Some("praxis-gateway".into()), + ..Default::default() + }), + ..Default::default() + }; + let ext = extensions_with_security(sec); + + let (sid, src) = resolve_session(&ext).expect("should resolve"); + assert_eq!(src, SessionSource::Identity); + // 16 hex chars (matches Python `sha256(...)[:16]`). + assert_eq!(sid.len(), 16); + assert!(sid.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn tier2_identity_is_stable_across_calls() { + // Same triple → same session id. Property guarantees that + // a token refresh (which doesn't change sub/caller/this) keeps + // the session intact. + let mk = || -> SecurityExtension { + SecurityExtension { + subject: Some(subject_with_claims(Some("alice@corp.com"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some("agent-007".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + client_id: Some("praxis-gateway".into()), + ..Default::default() + }), + ..Default::default() + } + }; + let ext1 = extensions_with_security(mk()); + let ext2 = extensions_with_security(mk()); + let (sid1, _) = resolve_session(&ext1).unwrap(); + let (sid2, _) = resolve_session(&ext2).unwrap(); + assert_eq!(sid1, sid2); + } + + #[test] + fn tier2_distinguishes_different_users() { + let alice = SecurityExtension { + subject: Some(subject_with_claims(Some("alice"), &[])), + ..Default::default() + }; + let bob = SecurityExtension { + subject: Some(subject_with_claims(Some("bob"), &[])), + ..Default::default() + }; + let (sid_a, _) = resolve_session(&extensions_with_security(alice)).unwrap(); + let (sid_b, _) = resolve_session(&extensions_with_security(bob)).unwrap(); + assert_ne!(sid_a, sid_b); + } + + #[test] + fn tier2_distinguishes_different_agents() { + // Same user, two different agents → different sessions. + // Important so a malicious agent's accumulated taints don't + // affect a different agent that user runs. + let mk = |agent: &str| -> SecurityExtension { + SecurityExtension { + subject: Some(subject_with_claims(Some("alice"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some(agent.into()), + ..Default::default() + }), + ..Default::default() + } + }; + let (sid1, _) = resolve_session(&extensions_with_security(mk("agent-a"))).unwrap(); + let (sid2, _) = resolve_session(&extensions_with_security(mk("agent-b"))).unwrap(); + assert_ne!(sid1, sid2); + } + + // --- Tier 3: none --- + + #[test] + fn tier3_no_session_when_no_data() { + let ext = Extensions::default(); + assert!(resolve_session(&ext).is_none()); + } + + #[test] + fn tier3_no_session_when_no_subject_id() { + // Security exists but no subject id → identity can't hash. + // Claim is absent too. Should be None. + let sec = SecurityExtension { + subject: Some(SubjectExtension::default()), // id = None + ..Default::default() + }; + let ext = extensions_with_security(sec); + assert!(resolve_session(&ext).is_none()); + } + + // --- Spoofing guard (regression test for P0-2) --- + + #[test] + fn header_x_cpex_session_id_is_ignored() { + // The Python apl-plugins resolver honored an `X-CPEX-Session-Id` + // header tier between token_claim and identity. We deliberately + // dropped it: an authenticated client could set the header to + // another subject's session id and inherit their accumulated + // taints, or to a random unused value and escape their own + // tainted session. This test pins that behaviour: the header is + // present, no token claim exists, and the resolver still falls + // through to identity-derived (or none) rather than honoring + // the header. If a future PR adds a header tier without + // subject binding, this test fails. + let sec = SecurityExtension { + subject: Some(subject_with_claims(Some("alice"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some("agent-007".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut http = HttpExtension::default(); + http.request_headers + .insert("X-CPEX-Session-Id".into(), "sess-bob-stolen".into()); + let ext = Extensions { + security: Some(Arc::new(sec)), + http: Some(Arc::new(http)), + ..Default::default() + }; + + let (sid, src) = resolve_session(&ext).expect("identity should still hit"); + assert_eq!( + src, + SessionSource::Identity, + "header tier was removed; resolver must NOT honor X-CPEX-Session-Id", + ); + assert_ne!( + sid, "sess-bob-stolen", + "header value must never become the session id", + ); + } +} diff --git a/crates/apl-cpex/src/session_store.rs b/crates/apl-cpex/src/session_store.rs new file mode 100644 index 00000000..54f70378 --- /dev/null +++ b/crates/apl-cpex/src/session_store.rs @@ -0,0 +1,157 @@ +// Location: ./crates/apl-cpex/src/session_store.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `SessionStore` — pluggable backend for cross-request session state. +// v0 surface is intentionally tiny: monotonic label append + load. That +// covers `extensions.security.labels` persistence, which is the only +// session-scoped state APL needs today. +// +// # Why a trait +// +// State that survives between requests in the same session (accumulated +// taint labels, delegation history, conversation context) needs to be +// pluggable: in-memory for tests and single-process deployments, Redis +// or DynamoDB for distributed ones. The previous Python implementation +// had a `SessionState` abstraction with the same shape; this is the +// Rust port. Only the labels surface lands in v0 — delegation hops, +// conversation history, and arbitrary KV come when their consumers do. +// +// # String-typed deliberately +// +// The trait stays string-typed (`Vec` for labels) rather than +// reaching into cpex-core's `MonotonicSet` so non-CMF bridges +// (future apl-mcp, apl-langgraph, etc.) can reuse it without dragging +// CPEX types into their surface. `CmfPluginInvoker` does the +// hydration/persistence into/out of `Extensions.security.labels`. + +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; + +use async_trait::async_trait; + +/// Pluggable session-state backend. Implementations must be `Send + Sync` +/// — the same store is shared across all concurrent requests. +/// +/// Invariants: +/// - `append_labels` is **monotonic** — labels added to a session never +/// come back out. Removal (declassification) is a separate operation +/// not covered by v0. +/// - Empty `load_labels` for an unknown `session_id` is the right +/// response — non-session traffic shouldn't fail, it just sees no +/// accumulated state. +#[async_trait] +pub trait SessionStore: Send + Sync { + /// Load the union of labels accumulated for the session. Empty for + /// new or unknown sessions. + async fn load_labels(&self, session_id: &str) -> Vec; + + /// Append labels to the session. Existing labels are kept; new ones + /// are unioned in. Caller has already deduped against `load_labels` + /// in the hot path, but the store re-dedups defensively. + async fn append_labels(&self, session_id: &str, labels: &[String]); +} + +/// In-process `SessionStore` backed by a `HashMap` of `HashSet`s. Suitable +/// for tests, single-process deployments, and as the default when no +/// distributed store is configured. Cloning the store via `Arc` shares +/// state across all consumers. +#[derive(Default)] +pub struct MemorySessionStore { + /// `RwLock` because reads (load_labels at request start) outnumber + /// writes (append at request end) in steady state — and lock + /// contention is bounded by the per-session level of concurrency, + /// not request volume. + inner: RwLock>>, +} + +impl MemorySessionStore { + pub fn new() -> Self { + Self::default() + } + + /// Snapshot the entire store. Test/diagnostic helper — production + /// callers should go through the trait so the backing implementation + /// stays swappable. + pub fn snapshot(&self) -> HashMap> { + self.inner + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } +} + +#[async_trait] +impl SessionStore for MemorySessionStore { + async fn load_labels(&self, session_id: &str) -> Vec { + let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); + r.get(session_id) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + } + + async fn append_labels(&self, session_id: &str, labels: &[String]) { + if labels.is_empty() { + return; + } + let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner()); + let entry = w.entry(session_id.to_string()).or_default(); + for l in labels { + entry.insert(l.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn load_for_unknown_session_is_empty() { + let store = MemorySessionStore::new(); + assert!(store.load_labels("nonexistent").await.is_empty()); + } + + #[tokio::test] + async fn append_then_load_roundtrips() { + let store = MemorySessionStore::new(); + store + .append_labels("sess-1", &["PII".to_string(), "INTERNAL".to_string()]) + .await; + let mut labels = store.load_labels("sess-1").await; + labels.sort(); + assert_eq!(labels, vec!["INTERNAL".to_string(), "PII".to_string()]); + } + + #[tokio::test] + async fn append_is_monotonic_dedupes() { + let store = MemorySessionStore::new(); + store.append_labels("sess-1", &["PII".to_string()]).await; + store + .append_labels("sess-1", &["PII".to_string(), "PII".to_string()]) + .await; + let labels = store.load_labels("sess-1").await; + assert_eq!(labels.len(), 1); + assert_eq!(labels[0], "PII"); + } + + #[tokio::test] + async fn sessions_are_isolated() { + let store = MemorySessionStore::new(); + store.append_labels("a", &["X".to_string()]).await; + store.append_labels("b", &["Y".to_string()]).await; + assert_eq!(store.load_labels("a").await, vec!["X".to_string()]); + assert_eq!(store.load_labels("b").await, vec!["Y".to_string()]); + } + + #[tokio::test] + async fn shared_arc_observes_writes() { + let store: Arc = Arc::new(MemorySessionStore::new()); + let c1 = Arc::clone(&store); + let c2 = Arc::clone(&store); + c1.append_labels("sess", &["Z".to_string()]).await; + assert_eq!(c2.load_labels("sess").await, vec!["Z".to_string()]); + } +} diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs new file mode 100644 index 00000000..cf9eea71 --- /dev/null +++ b/crates/apl-cpex/src/visitor.rs @@ -0,0 +1,680 @@ +// Location: ./crates/apl-cpex/src/visitor.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `AplConfigVisitor` — the cpex-core `ConfigVisitor` implementation that +// stacks the unified-config hierarchy (global → defaults → tag bundles +// → routes) into a single `CompiledRoute` per route and installs an +// [`AplRouteHandler`] for each phase via `PluginManager::annotate_route`. +// +// # Hierarchy stacking +// +// Each `visit_*` call carries a single block of raw YAML. The visitor +// finds the `apl:` sub-block (if any), compiles it to a `CompiledRoute`, +// and stashes it in interior state: +// +// visit_global → state.global_layer +// visit_default → state.default_layers[entity_type] +// visit_policy_bundle → state.tag_layers[tag] +// visit_route → build effective route by layering and annotate. +// +// At `visit_route` we layer least-to-most-specific: +// +// effective = global +// effective.apply_layer(default_layer_for(entity_type)) +// for tag in route.meta.tags { effective.apply_layer(tag_layer(tag)) } +// effective.apply_layer(route_apl_block) +// +// then construct one `AplRouteHandler` per phase (Pre, Post) and call +// `annotate_route` for each `(entity_type, entity_name, scope, hook)`. +// +// # Hook names per entity type +// +// Each entity type binds to its own CMF hook pair: +// +// * `tool:` → `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` +// * `llm:` → `cmf.llm_input` / `cmf.llm_output` +// * `prompt:` → `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke` +// * `resource:` → `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` +// +// The mapping lives in [`hook_pair_for_entity`]. Hosts fire +// `mgr.invoke_named::("cmf.llm_input", ...)` for LLM +// invocations; the visitor's annotation on `cmf.llm_input` for the +// matching route's entity_name is what AplRouteHandler intercepts. +// +// `tool_pre_invoke` / `tool_post_invoke` are exposed as legacy +// re-exports for callers that wired against the v0 constants — the +// per-entity dispatch is the load-bearing path now. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock, Weak}; + +use cpex_core::cmf::constants::{ + ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT, + HOOK_CMF_LLM_OUTPUT, 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, +}; +use cpex_core::config::RouteEntry; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::PluginConfig; +use cpex_core::visitor::{ConfigVisitor, VisitorError}; + +use apl_core::parser::compile_policy_block_value; +use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry}; +use apl_core::rules::CompiledRoute; +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::dispatch_plan::DispatchCache; +use crate::pdp_router::PdpRouter; +use crate::route_handler::{AplRouteHandler, Phase}; +use crate::session_store::SessionStore; + +/// Legacy alias for the tool-family pre hook. Kept exported for +/// callers that wired against the v0 visitor constants — the +/// per-entity-type dispatch via `hook_pair_for_entity` is the +/// load-bearing path now. +pub const HOOK_PRE: &str = HOOK_CMF_TOOL_PRE_INVOKE; +/// Legacy alias for the tool-family post hook. See `HOOK_PRE`. +pub const HOOK_POST: &str = HOOK_CMF_TOOL_POST_INVOKE; + +/// Resolve the (pre, post) CMF hook pair for an entity_type. Drives +/// per-entity `annotate_route` calls so an `llm:` route annotates on +/// `cmf.llm_input` / `cmf.llm_output` rather than the tool-family +/// hooks. Returns `None` for unknown entity types — the visitor logs +/// + skips those routes. +fn hook_pair_for_entity(entity_type: &str) -> Option<(&'static str, &'static str)> { + match entity_type { + ENTITY_TOOL => Some((HOOK_CMF_TOOL_PRE_INVOKE, HOOK_CMF_TOOL_POST_INVOKE)), + ENTITY_LLM => Some((HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT)), + ENTITY_PROMPT => Some((HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_PROMPT_POST_INVOKE)), + ENTITY_RESOURCE => Some((HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_RESOURCE_POST_FETCH)), + _ => None, + } +} + +/// Interior state accumulated as the manager walks the visitor. +/// `plugin_registry` is populated by `visit_plugins` (called once per +/// load); the layer fields are populated as the visitor walks +/// `global` / `defaults` / `policies` / `routes`; `pdp_router` is +/// populated by both code-supplied resolvers (`register_pdp`) and +/// unified-config-driven entries under `global.apl.pdp[]` (built +/// during `visit_global`). +#[derive(Default)] +struct VisitorState { + plugin_registry: PluginRegistry, + global_layer: Option, + default_layers: HashMap, + tag_layers: HashMap, + pdp_router: PdpRouter, +} + +/// APL implementation of [`cpex_core::visitor::ConfigVisitor`]. Construct +/// once per host with the shared infrastructure (dispatch cache, session +/// store, manager handle) and register with `PluginManager::register_visitor` +/// before calling `load_config_yaml`. +/// +/// PDPs come from two sources, both feeding the same internal +/// [`PdpRouter`]: +/// +/// 1. **Code-supplied** via `register_pdp` (or `AplOptions.pdps`) — +/// the host built the resolver in code and hands it in. +/// 2. **Config-supplied** via `global.apl.pdp[]` blocks in the unified +/// config — the visitor sees the block, looks up a factory by +/// `kind`, and constructs the resolver during `visit_global`. +/// +/// Factories are registered up front by `kind` name (`"cedar-direct"`, +/// `"cedarling"`, …). The visitor knows nothing about specific PDP +/// backends; everything dispatches through `PdpFactory`. +pub struct AplConfigVisitor { + state: RwLock, + dispatch_cache: Arc, + session_store: Arc, + manager: Weak, + /// Baseline capabilities granted to every synthetic `AplRouteHandler` + /// the visitor installs. Unioned with the per-route plugin + /// capability set so APL predicates that touch extensions + /// (`require(authenticated)` needs `read_subject`, etc.) work even + /// when no plugins are referenced. Hosts that want strict gating + /// can set this to an empty set. + base_capabilities: std::collections::HashSet, + /// Factories the visitor consults when it encounters a + /// `global.apl.pdp[]` entry. Keyed by the factory's `kind()` — + /// matches the `kind:` field in the YAML block. + pdp_factories: HashMap>, +} + +impl AplConfigVisitor { + pub fn new( + dispatch_cache: Arc, + session_store: Arc, + manager: Weak, + ) -> Self { + Self { + state: RwLock::new(VisitorState::default()), + dispatch_cache, + session_store, + manager, + base_capabilities: default_base_capabilities(), + pdp_factories: HashMap::new(), + } + } + + /// Register a code-supplied PDP resolver. Equivalent to declaring a + /// PDP in the unified config but for hosts that prefer wiring + /// resolvers in Rust. Resolvers are pushed into the internal + /// `PdpRouter`; the first registration per dialect wins (matches + /// `PdpRouter::register` semantics). + pub fn register_pdp(&self, resolver: Arc) { + let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); + state.pdp_router.register(resolver); + } + + /// Register a PDP factory by its `kind()`. Called during + /// `register_apl` setup; the visitor uses these to instantiate + /// resolvers from `global.apl.pdp[]` config blocks. + pub fn register_pdp_factory(&mut self, factory: Arc) { + self.pdp_factories.insert(factory.kind().to_string(), factory); + } + + /// Replace the baseline capability set granted to every installed + /// `AplRouteHandler`. Default covers read-only attributes APL + /// predicates commonly touch (subject, role, labels, delegation, + /// agent). Tighten this when the deployment's policy plugins + /// don't need broad reads — every cap removed is one fewer + /// extension slot a buggy predicate can leak through. + pub fn with_base_capabilities( + mut self, + caps: std::collections::HashSet, + ) -> Self { + self.base_capabilities = caps; + self + } + + /// Parse one entry from `global.apl.pdp[]`. Reads `kind`, dispatches + /// to the matching factory, installs the resulting resolver into + /// the internal `PdpRouter`. Called per entry during `visit_global`. + /// + /// `index` is used only for diagnostics — operators see "the third + /// pdp entry failed" rather than a generic "a pdp entry failed." + fn build_pdp_from_config( + &self, + entry: &serde_yaml::Value, + index: usize, + ) -> Result<(), VisitorError> { + let map = entry.as_mapping().ok_or_else(|| { + format!( + "global.apl.pdp[{}] must be a mapping with a `kind:` field", + index + ) + })?; + let kind = map + .get(serde_yaml::Value::String("kind".to_string())) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + format!( + "global.apl.pdp[{}] missing required `kind:` field", + index + ) + })?; + let factory = self.pdp_factories.get(kind).ok_or_else(|| { + format!( + "global.apl.pdp[{}] declared kind='{}' but no factory is registered for that kind — \ + host must call register_pdp_factory(...) before load_config_yaml", + index, kind + ) + })?; + let resolver = factory.build(entry).map_err(|e| { + format!( + "global.apl.pdp[{}] (kind='{}') failed to build: {}", + index, kind, e + ) + })?; + let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); + state.pdp_router.register(resolver); + Ok(()) + } +} + +/// Read-only baseline for APL predicates: enough to make +/// `authenticated`, `role.*`, `perm.*`, `subject.*`, `claim.*`, +/// `subject.teams`, `security.labels`, `delegated`, `delegation.*`, +/// and `agent.*` evaluate correctly. Excludes all *write* capabilities +/// — those are granted on demand by the per-route plugin union when a +/// plugin declares `append_labels` / `append_delegation` / +/// `write_headers`. +/// +/// `read_subject` alone unlocks only `subject.id` / `subject.type`; +/// roles, permissions, teams, and claims are each gated by their own +/// capability (`read_roles` / `read_permissions` / `read_teams` / +/// `read_claims`). PDP-driven policies routinely read principal.roles / +/// principal.claims, so the baseline grants all four — tightening +/// further would surprise APL authors whose `cedar:` policies suddenly +/// see empty role sets in deployments with no plugin-declared caps. +/// Hosts that want strict subject access override this via +/// `AplOptions.base_capabilities`. +fn default_base_capabilities() -> std::collections::HashSet { + [ + "read_subject", + "read_roles", + "read_permissions", + "read_teams", + "read_claims", + "read_labels", + "read_delegation", + "read_agent", + "read_meta", + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + +impl ConfigVisitor for AplConfigVisitor { + fn name(&self) -> &str { + "apl" + } + + fn visit_plugins( + &self, + _mgr: &Arc, + plugins: &[PluginConfig], + ) -> Result<(), VisitorError> { + // Translate cpex-core's typed PluginConfig into apl-core's + // PluginDeclaration. Field-for-field except `capabilities` is a + // `HashSet` on the cpex side and a `Vec` on the apl side, and + // `config` is wrapped in `serde_yaml::Value::Mapping` to match + // apl-core's opaque shape. cpex-core has already validated + // uniqueness by this point so we don't re-check. + let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); + state.plugin_registry.clear(); + for cfg in plugins { + let decl = PluginDeclaration { + name: cfg.name.clone(), + kind: cfg.kind.clone(), + hooks: cfg.hooks.clone(), + capabilities: cfg.capabilities.iter().cloned().collect(), + config: plugin_config_to_yaml(&cfg.config), + on_error: Some(on_error_to_string(&cfg.on_error)), + extra: HashMap::new(), + }; + state.plugin_registry.insert(cfg.name.clone(), decl); + } + Ok(()) + } + + fn visit_global( + &self, + _mgr: &Arc, + yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + let Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + + // Process `apl.pdp[]` before stacking the policy/post_policy + // layer — route handlers that reference PDPs need them + // resolvable by the time `visit_route` runs. + if let Some(pdp_entries) = apl_block.get("pdp").and_then(|v| v.as_sequence()) { + for (i, entry) in pdp_entries.iter().enumerate() { + self.build_pdp_from_config(entry, i)?; + } + } + + // The `pdp:` sub-key isn't an APL DSL field; strip it before + // handing the block to `compile_policy_block_value` so the + // compiler doesn't see an unknown key. `compile_policy_block_value` + // accepts maps with `policy:` / `post_policy:` / `args:` / + // `result:` / `plugins:` (and inert fields it ignores), so a + // shallow strip on a clone is enough. + let policy_only = strip_pdp_key(apl_block); + let compiled = compile_policy_block_value("global.apl", &policy_only) + .map_err(|e| Box::new(e) as VisitorError)?; + self.state + .write() + .unwrap_or_else(|p| p.into_inner()) + .global_layer = Some(compiled); + Ok(()) + } + + fn visit_default( + &self, + _mgr: &Arc, + entity_type: &str, + yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + let Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + let source = format!("global.defaults.{}.apl", entity_type); + let compiled = compile_policy_block_value(&source, apl_block) + .map_err(|e| Box::new(e) as VisitorError)?; + self.state + .write() + .unwrap_or_else(|p| p.into_inner()) + .default_layers + .insert(entity_type.to_string(), compiled); + Ok(()) + } + + fn visit_policy_bundle( + &self, + _mgr: &Arc, + tag: &str, + yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + let Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + let source = format!("global.policies.{}.apl", tag); + let compiled = compile_policy_block_value(&source, apl_block) + .map_err(|e| Box::new(e) as VisitorError)?; + self.state + .write() + .unwrap_or_else(|p| p.into_inner()) + .tag_layers + .insert(tag.to_string(), compiled); + Ok(()) + } + + fn visit_route( + &self, + mgr: &Arc, + yaml: &serde_yaml::Value, + parsed: &RouteEntry, + ) -> Result<(), VisitorError> { + // Extract the route's APL block (if any) and the entity identity + // we need for annotate_route. A route without an APL block AND + // without inherited layers contributes nothing — skip. + let route_apl = apl_subblock(yaml); + let (entity_type, entity_names) = match entity_identity(parsed) { + Some(e) => e, + None => { + tracing::warn!( + "APL visitor: route has no tool/resource/prompt/llm match — skipping", + ); + return Ok(()); + } + }; + let scope = parsed.meta.as_ref().and_then(|m| m.scope.clone()); + let tags: Vec = parsed + .meta + .as_ref() + .map(|m| m.tags.clone()) + .unwrap_or_default(); + + // Snapshot the plugin registry + PDP router once outside the + // per-entity loop. `visit_plugins` populated the registry + // before any `visit_route` call; the router has been populated + // by code-supplied `register_pdp` calls + `visit_global` + // factory dispatch. Routes share both, so cloning each into an + // `Arc` once and handing clones to each handler is cheaper than + // re-reading the RwLock per entity. Cloning `PdpRouter` is + // refcount bumps on each inner resolver — cheap. + let (plugin_registry, pdp_router_arc) = { + let state = self.state.read().unwrap_or_else(|p| p.into_inner()); + ( + Arc::new(state.plugin_registry.clone()), + Arc::new(state.pdp_router.clone()) as Arc, + ) + }; + + for entity_name in &entity_names { + // route_key is what `DispatchCache` keys on, so it must + // disambiguate scoped vs unscoped routes for the same + // entity — otherwise two same-named annotations share one + // cached plan and the second's overrides leak into the first. + let route_key = match &scope { + Some(s) => format!("{}:{}@{}", entity_type, entity_name, s), + None => format!("{}:{}", entity_type, entity_name), + }; + let state = self.state.read().unwrap_or_else(|p| p.into_inner()); + + // Stack least-to-most-specific. Each apply_layer call appends + // policy/post_policy steps and merges args/result/plugin_overrides + // by field; the resulting CompiledRoute represents the route's + // effective policy in evaluation order. + let mut effective = CompiledRoute::new(&route_key); + if let Some(layer) = state.global_layer.clone() { + effective.apply_layer(layer); + } + if let Some(layer) = state.default_layers.get(entity_type).cloned() { + effective.apply_layer(layer); + } + for tag in &tags { + if let Some(layer) = state.tag_layers.get(tag).cloned() { + effective.apply_layer(layer); + } + } + drop(state); + + if let Some(block) = route_apl { + let source = format!("routes.{}.apl", route_key); + let route_layer = compile_policy_block_value(&source, block) + .map_err(|e| Box::new(e) as VisitorError)?; + effective.apply_layer(route_layer); + } + + // No layers contributed anything? Don't install a handler — the + // route falls back to cpex-core's plugin-chain execution. + if effective.declared_phases().is_empty() { + continue; + } + + // E3.1 — plugin-mode validation for `parallel:` blocks. + // `apl-core::Effect::validate_parallel_purity` already rejected + // FieldOp / Delegate at parse time; this pass checks that every + // `plugin(X)` inside a `parallel:` references a plugin whose + // mode is safe for concurrent execution (Audit / Concurrent / + // FireAndForget). Sequential / Transform plugins would silently + // lose their mutations inside cloned branches. + // + // Looks up modes through the cpex-core PluginManager (it has + // the authoritative registration state). The lookup trait + // is `parallel_safety::PluginModeLookup`, which + // `PluginManager` implements. + if let Err(msg) = crate::parallel_safety::validate_parallel_plugin_modes( + &effective, + mgr.as_ref(), + ) { + let err_msg = format!("route '{}': parallel-safety: {}", route_key, msg); + return Err(err_msg.into()); + } + + let route_arc = Arc::new(effective); + + // Resolve the entity-specific CMF hook pair. The visitor's + // entity_identity() already filtered out unknown types, but + // hook_pair_for_entity returning None would just skip the + // annotation rather than crash — defense in depth. + let (hook_pre, hook_post) = match hook_pair_for_entity(entity_type) { + Some(pair) => pair, + None => { + tracing::warn!( + entity_type, + entity_name, + "APL visitor: no CMF hook pair for entity_type — skipping route", + ); + continue; + } + }; + + // Install Pre + Post handlers. Each handler instance is bound to + // ONE phase so the executor can pick the right entry-point off + // the (entity_type, entity_name, scope, hook_name) key. + install_handler( + mgr, + entity_type, + entity_name, + scope.clone(), + hook_pre, + Phase::Pre, + Arc::clone(&route_arc), + &plugin_registry, + &self.dispatch_cache, + &self.session_store, + &self.manager, + Some(Arc::clone(&pdp_router_arc)), + &self.base_capabilities, + ); + install_handler( + mgr, + entity_type, + entity_name, + scope.clone(), + hook_post, + Phase::Post, + route_arc, + &plugin_registry, + &self.dispatch_cache, + &self.session_store, + &self.manager, + Some(Arc::clone(&pdp_router_arc)), + &self.base_capabilities, + ); + } + + Ok(()) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +#[allow(clippy::too_many_arguments)] +fn install_handler( + mgr: &Arc, + entity_type: &str, + entity_name: &str, + scope: Option, + hook_name: &str, + phase: Phase, + route: Arc, + plugin_registry: &Arc, + dispatch_cache: &Arc, + session_store: &Arc, + manager: &Weak, + pdp: Option>, + base_capabilities: &std::collections::HashSet, +) { + // Capability gating at the synthetic-handler boundary. cpex-core's + // executor calls `filter_extensions(&ext, &caps)` before every + // handler invoke — including this one. If the synthetic handler + // has fewer capabilities than its downstream plugins need, the + // executor strips extensions on the way in (so APL predicates and + // downstream plugins see empty views) and rejects mutations on the + // way out (label / delegation appends fail monotonicity checks). + // + // Granted caps = union of every plugin's caps (with per-route + // overrides applied) ∪ host-supplied baseline. The baseline + // typically covers read-only attributes APL predicates touch + // (`subject.*`, `role.*`, `delegated`, …) even when no plugins are + // referenced. + let mut capabilities = base_capabilities.clone(); + capabilities.extend(crate::dispatch_plan::route_capability_union(&route, plugin_registry)); + + let plugin_config = PluginConfig { + name: format!( + "apl::{}::{}::{}", + entity_type, + entity_name, + if phase == Phase::Pre { "pre" } else { "post" } + ), + kind: "builtin".to_string(), + // The annotated handler covers exactly one CMF hook name. + hooks: vec![hook_name.to_string()], + capabilities, + ..Default::default() + }; + let mut handler = + AplRouteHandler::new( + plugin_config.clone(), + route, + phase, + Arc::clone(plugin_registry), + Arc::clone(dispatch_cache), + Arc::clone(session_store), + manager.clone(), + ); + if let Some(pdp) = pdp { + handler = handler.with_pdp(pdp); + } + mgr.annotate_route( + entity_type.to_string(), + entity_name.to_string(), + scope, + hook_name.to_string(), + Arc::new(handler), + plugin_config, + ); +} + +/// Pick the route's entity identities from the first non-None match +/// field. v0: tool > resource > prompt > llm precedence. A list-form +/// match (`tool: [a, b]`) yields one annotation per element so each +/// request gets routed by its specific name. +fn entity_identity(route: &RouteEntry) -> Option<(&'static str, Vec)> { + if let Some(t) = &route.tool { + return Some(("tool", names_of(t))); + } + if let Some(r) = &route.resource { + return Some(("resource", names_of(r))); + } + if let Some(p) = &route.prompt { + return Some(("prompt", names_of(p))); + } + if let Some(l) = &route.llm { + return Some(("llm", names_of(l))); + } + None +} + +fn names_of(sol: &cpex_core::config::StringOrList) -> Vec { + match sol { + cpex_core::config::StringOrList::Single(p) => vec![p.as_str().to_string()], + cpex_core::config::StringOrList::List(v) => v.clone(), + } +} + +/// Strip the `pdp` sub-key from an `apl:` mapping so the remainder can +/// be handed to `compile_policy_block_value` (which doesn't model PDP +/// declarations — those are CPEX wiring concerns). Returns a clone of +/// the mapping with `pdp` removed; the original is left intact. +fn strip_pdp_key(apl_block: &serde_yaml::Value) -> serde_yaml::Value { + let Some(map) = apl_block.as_mapping() else { + return apl_block.clone(); + }; + let mut cloned = map.clone(); + cloned.remove(&serde_yaml::Value::String("pdp".to_string())); + serde_yaml::Value::Mapping(cloned) +} + +/// Bridge cpex-core's JSON-based `Option` config slot +/// into apl-core's `Option` shape. JSON is a strict +/// subset of YAML's value model so this is round-trip safe; failure +/// here would only happen if `serde_yaml::to_value` rejects a value +/// `serde_json::Value` already accepted (in practice: never). +fn plugin_config_to_yaml(cfg: &Option) -> Option { + cfg.as_ref().and_then(|v| serde_yaml::to_value(v).ok()) +} + +/// Map cpex-core's `OnError` enum onto the string shape apl-core's +/// `PluginDeclaration` carries (kept stringly-typed there because the +/// APL spec also allows custom orchestrator-defined error modes). +fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String { + on_err.to_string() +} + +/// Pull the `apl:` sub-block out of a section's raw YAML. Returns `None` +/// when absent or null — callers treat that as "no contribution from +/// this section" and move on. +fn apl_subblock(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> { + let block = yaml.get("apl")?; + if block.is_null() { + None + } else { + Some(block) + } +} diff --git a/crates/apl-cpex/tests/capability_gating.rs b/crates/apl-cpex/tests/capability_gating.rs new file mode 100644 index 00000000..d03656e2 --- /dev/null +++ b/crates/apl-cpex/tests/capability_gating.rs @@ -0,0 +1,446 @@ +// Location: ./crates/apl-cpex/tests/capability_gating.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Capability-gating end-to-end. cpex-core's executor calls +// `filter_extensions(&ext, &caps)` before every handler invoke — so the +// synthetic `AplRouteHandler` must declare a capability set wide enough +// to cover every downstream plugin it dispatches, otherwise: +// +// - APL predicates read from a stripped attribute bag (silently wrong +// policy decisions). +// - Downstream plugins receive a doubly-filtered view (their own caps +// applied on top of an already-stripped one). +// - Write attempts (append_labels, append_delegation, write_headers) +// fail the monotonicity check on the way back out of the handler. +// +// These tests verify the visitor computes +// `base_capabilities ∪ per-route plugin union` and sets it on the +// synthetic `PluginConfig`. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError as CoreError; +use cpex_core::extensions::{MetaExtension, SecurityExtension}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +// ===================================================================== +// Fixtures +// ===================================================================== + +/// Plugin that records whether it saw `security.labels` populated. +/// Used to verify that `read_labels` capability propagates through the +/// synthetic handler so the inner plugin's filtered view actually +/// contains labels. +struct LabelReader { + cfg: PluginConfig, + observed_labels: Arc>>, +} + +#[async_trait] +impl Plugin for LabelReader { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for LabelReader { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let seen: Vec = extensions + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(); + *self.observed_labels.lock().unwrap() = seen; + PluginResult::allow() + } +} + +struct LabelReaderFactory { + observed_labels: Arc>>, +} + +impl PluginFactory for LabelReaderFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(LabelReader { + cfg: config.clone(), + observed_labels: Arc::clone(&self.observed_labels), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Plugin that appends a label via `modify_extensions`. Used to verify +/// write-cap propagation: requires both an `append_labels` declaration +/// on the plugin AND the synthetic handler to also be granted +/// `append_labels` so the executor accepts the mutation on the way +/// back out. +struct LabelWriter { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for LabelWriter { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for LabelWriter { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let mut owned = extensions.cow_copy(); + let security = owned.security.get_or_insert_with(Default::default); + security.add_label("APPENDED"); + PluginResult::modify_extensions(owned) + } +} + +struct LabelWriterFactory; +impl PluginFactory for LabelWriterFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(LabelWriter { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn cmf_payload(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +fn extensions_with_label(label: &str) -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label(label.to_string()); + Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + security: Some(Arc::new(security)), + ..Default::default() + } +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Plugin declares `read_labels`; route references it; pre-existing +/// label `EXISTING` is set on the request extensions. The plugin must +/// observe the label — proving the synthetic `AplRouteHandler` got +/// `read_labels` from the per-route plugin union (cpex-core's filter +/// would otherwise strip security.labels at the handler boundary). +#[tokio::test] +async fn plugin_with_read_labels_sees_labels_through_apl_handler() { + const YAML: &str = r#" +plugins: + - name: label-reader + kind: label-reader + hooks: [cmf.tool_pre_invoke] + capabilities: [read_labels] +routes: + - tool: get_weather + apl: + policy: + - "plugin(label-reader)" +"#; + + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "label-reader", + Box::new(LabelReaderFactory { + observed_labels: Arc::clone(&observed), + }), + ); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + let ext = extensions_with_label("EXISTING"); + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + result.continue_processing, + "plugin shouldn't deny: {:?}", + result.violation + ); + + let seen = observed.lock().unwrap().clone(); + assert_eq!( + seen, + vec!["EXISTING".to_string()], + "plugin must observe the EXISTING label that the request carried; \ + empty means the synthetic AplRouteHandler stripped security.labels \ + because its cap union didn't include read_labels" + ); +} + +/// Same plugin shape, but DON'T declare `read_labels` on the plugin +/// and set an empty `base_capabilities` so neither the per-route +/// union nor the baseline grants the cap. The plugin must NOT see +/// labels — confirms the negative case (capability gating actually +/// hides things when caps are missing). +#[tokio::test] +async fn plugin_without_read_labels_sees_stripped_view() { + const YAML: &str = r#" +plugins: + - name: label-reader + kind: label-reader + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(label-reader)" +"#; + + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "label-reader", + Box::new(LabelReaderFactory { + observed_labels: Arc::clone(&observed), + }), + ); + // Strict mode: empty baseline → only per-plugin caps grant + // anything, and the plugin declared none. + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: Some(std::collections::HashSet::new()), + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + let ext = extensions_with_label("EXISTING"); + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!(result.continue_processing); + + let seen = observed.lock().unwrap().clone(); + assert!( + seen.is_empty(), + "plugin should see no labels when neither it nor the baseline \ + grants read_labels — got: {:?}", + seen + ); +} + +/// Plugin declares `append_labels` and emits a new label via +/// `modify_extensions`. The synthetic `AplRouteHandler` must also be +/// granted `append_labels` (from the per-route union) so its outer +/// modify_extensions write doesn't get rejected on the way back out. +/// After the invoke, the appended label must be visible in the final +/// extensions. +#[tokio::test] +async fn write_capabilities_propagate_through_apl_handler() { + const YAML: &str = r#" +plugins: + - name: label-writer + kind: label-writer + hooks: [cmf.tool_pre_invoke] + capabilities: [append_labels, read_labels] +routes: + - tool: get_weather + apl: + policy: + - "plugin(label-writer)" +"#; + + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory("label-writer", Box::new(LabelWriterFactory)); + register_apl(&mgr, AplOptions::in_process()); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + result.continue_processing, + "label-writer should allow: {:?}", + result.violation + ); + + // The appended label should be visible on the way out via + // `modified_extensions` — None means no plugin wrote anything, + // which would be a failure here. + let modified = result + .modified_extensions + .expect("label-writer should have modified extensions"); + let labels: Vec = modified + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(); + assert!( + labels.contains(&"APPENDED".to_string()), + "expected APPENDED to land in final security.labels — \ + a missing label means the executor rejected the write on the \ + way out of AplRouteHandler (no append_labels cap on the synthetic). \ + Got: {:?}", + labels + ); +} + +/// Predicate-only route: no plugins, just `require(authenticated)`. +/// APL evaluates this against the attribute bag built from the +/// (capability-filtered) Extensions view the handler sees. Default +/// baseline grants `read_subject`, so `authenticated` evaluates to +/// `true` when subject is present. +#[tokio::test] +async fn predicate_only_route_uses_baseline_capabilities() { + const YAML: &str = r#" +plugins: [] +routes: + - tool: get_weather + apl: + policy: + - "require(authenticated)" +"#; + let mgr = Arc::new(PluginManager::default()); + register_apl(&mgr, AplOptions::in_process()); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + // Set subject id so `authenticated` derives true via apl-cmf. + let mut security = SecurityExtension::default(); + security.subject = Some(cpex_core::extensions::SubjectExtension { + id: Some("alice".to_string()), + ..Default::default() + }); + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + security: Some(Arc::new(security)), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + result.continue_processing, + "require(authenticated) should pass with subject.id set: violation = {:?}", + result.violation + ); +} + +/// Same predicate-only route but baseline is forcibly empty AND no +/// subject is set. With empty baseline the synthetic handler has no +/// caps, so security.subject is stripped → `authenticated` evaluates +/// false → `require(authenticated)` denies. Confirms the baseline +/// actually controls what predicates can read. +#[tokio::test] +async fn empty_baseline_strips_predicate_view() { + const YAML: &str = r#" +plugins: [] +routes: + - tool: get_weather + apl: + policy: + - "require(authenticated)" +"#; + let mgr = Arc::new(PluginManager::default()); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: Some(std::collections::HashSet::new()), + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + // Even though subject.id IS set, the empty baseline means the + // synthetic handler can't read subject — predicate sees missing → + // false → require denies. + let mut security = SecurityExtension::default(); + security.subject = Some(cpex_core::extensions::SubjectExtension { + id: Some("alice".to_string()), + ..Default::default() + }); + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + security: Some(Arc::new(security)), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + !result.continue_processing, + "empty baseline should cause require(authenticated) to deny \ + even with subject set — capability gating proves it can't see" + ); +} diff --git a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs new file mode 100644 index 00000000..c95788b3 --- /dev/null +++ b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs @@ -0,0 +1,699 @@ +// Location: ./crates/apl-cpex/tests/cmf_invoker_dispatch.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Integration tests for `CmfPluginInvoker` — exercises the typed +// dispatch path end-to-end against a real `cpex-core::PluginManager` +// with hand-rolled test plugins. v0 coverage: +// - `Step` invocation against an allow-plugin → `Decision::Allow` +// - `Step` invocation against a deny-plugin → `Decision::Deny` with +// reason + rule_source pulled from the CPEX `PluginViolation` +// - `Field` invocation against a modify-plugin → `Decision::Allow` +// with `modified_value` populated from the rewritten text content +// - Payload mutation persists across invocations (one modifying +// plugin's output is visible to the next). + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; +use cpex_core::cmf::enums::Role; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::extensions::{SecurityExtension, SubjectExtension}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::{HookEntry, PluginRef}; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PluginInvocation, PluginInvoker}; + +use apl_cpex::{CmfPluginInvoker, MemorySessionStore, RouteDispatchPlan}; + +/// Build a single-plugin RouteDispatchPlan straight off the cpex-core +/// registry — no APL CompiledRoute involved. Used by the invoker-primitive +/// tests below to exercise the plan-based dispatch path without standing +/// up a full route. +fn plan_for(manager: &cpex_core::manager::PluginManager, plugin_name: &str) -> Arc { + let entry = RouteDispatchPlan::resolve_plugin(manager, plugin_name) + .expect("plugin must be registered with the manager"); + let mut plugins = std::collections::HashMap::new(); + plugins.insert(plugin_name.to_string(), entry); + Arc::new(RouteDispatchPlan { plugins, delegation_entries: Default::default() }) +} + +// --------------------------------------------------------------------- +// Test plugins — minimal CMF handlers with hard-coded behavior so the +// dispatch path is exercised without external state. +// --------------------------------------------------------------------- + +struct AllowPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +struct AllowPluginFactory; +impl PluginFactory for AllowPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +struct DenyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "policy.forbidden", + "test-fixture denied this call", + )) + } +} + +struct DenyPluginFactory; +impl PluginFactory for DenyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Modify plugin — rewrites every Text part by appending `" [MODIFIED]"` +/// so the test can assert mutation propagation deterministically. +struct ModifyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for ModifyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for ModifyPlugin { + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let new_content: Vec = payload + .message + .content + .iter() + .map(|part| match part { + ContentPart::Text { text } => ContentPart::Text { + text: format!("{} [MODIFIED]", text), + }, + other => other.clone(), + }) + .collect(); + PluginResult::modify_payload(MessagePayload { + message: Message { + schema_version: payload.message.schema_version.clone(), + role: payload.message.role, + content: new_content, + channel: payload.message.channel, + }, + }) + } +} + +struct ModifyPluginFactory; +impl PluginFactory for ModifyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(ModifyPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.field_redact", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------- + +fn payload_with_text(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn empty_bag() -> AttributeBag { + AttributeBag::new() +} + +/// Build a manager, register one factory + one plugin under the given +/// kind, and return the wired manager ready for invocation. +async fn build_manager( + factory_kind: &str, + factory: Box, +) -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory(factory_kind, factory); + + let yaml = format!( + "plugins:\n - name: {0}\n kind: {0}\n", + factory_kind + ); + let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +// --------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------- + +#[tokio::test] +async fn step_invocation_allow_returns_decision_allow() { + let mgr = build_manager("allow-plugin", Box::new(AllowPluginFactory)).await; + let plan = plan_for(&mgr, "allow-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let outcome = invoker + .invoke("allow-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .await + .expect("invoke"); + + assert_eq!(outcome.decision, Decision::Allow); + assert!(outcome.modified_value.is_none()); +} + +#[tokio::test] +async fn step_invocation_deny_surfaces_violation_reason_and_code() { + let mgr = build_manager("deny-plugin", Box::new(DenyPluginFactory)).await; + let plan = plan_for(&mgr, "deny-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let outcome = invoker + .invoke("deny-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .await + .expect("invoke"); + + match outcome.decision { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("test-fixture denied this call")); + assert_eq!(rule_source, "policy.forbidden"); + } + other => panic!("expected Decision::Deny, got {:?}", other), + } +} + +#[tokio::test] +async fn field_invocation_modify_surfaces_modified_value_and_persists_payload() { + let mgr = build_manager("modify-plugin", Box::new(ModifyPluginFactory)).await; + let plan = plan_for(&mgr, "modify-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let bag = empty_bag(); + let value = serde_json::Value::String("hello".to_string()); + let outcome = invoker + .invoke( + "modify-plugin", + &bag, + PluginInvocation::Field { + name: "content", + value: &value, + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("invoke"); + + assert_eq!(outcome.decision, Decision::Allow); + assert_eq!( + outcome.modified_value, + Some(serde_json::Value::String("hello [MODIFIED]".to_string())) + ); + + // Payload mutation persisted: a second invocation sees the updated + // text as input (modifier appends [MODIFIED] each pass). + let outcome2 = invoker + .invoke( + "modify-plugin", + &bag, + PluginInvocation::Field { + name: "content", + value: &value, + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("invoke"); + assert_eq!( + outcome2.modified_value, + Some(serde_json::Value::String( + "hello [MODIFIED] [MODIFIED]".to_string() + )) + ); +} + +#[tokio::test] +async fn current_payload_reflects_accumulated_mutations() { + let mgr = build_manager("modify-plugin", Box::new(ModifyPluginFactory)).await; + let plan = plan_for(&mgr, "modify-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let bag = empty_bag(); + let value = serde_json::Value::String("ignored".to_string()); + let _ = invoker + .invoke( + "modify-plugin", + &bag, + PluginInvocation::Field { + name: "content", + value: &value, + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("invoke"); + + let final_payload = invoker.current_payload().await; + assert_eq!( + final_payload.message.get_text_content(), + "hello [MODIFIED]" + ); +} + +// --------------------------------------------------------------------- +// Capability gating — APL route override of `capabilities:` materializes +// a derived PluginRef wrapping the same plugin Arc with a merged +// TrustedConfig. cpex-core's executor then enforces the narrower caps +// in its single per-entry `filter_extensions` pass — no double filter, +// no second clone of security. The base plugin's circuit breaker stays +// isolated per `feedback_override_isolation.md`. +// --------------------------------------------------------------------- + +/// Capture-plugin fixture — records the Extensions it actually receives +/// from the executor so the test can assert what survived filtering. +struct CapturePlugin { + cfg: PluginConfig, + captured: Arc>>, +} + +#[async_trait] +impl Plugin for CapturePlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for CapturePlugin { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + *self.captured.lock().await = Some(extensions.clone()); + PluginResult::allow() + } +} + +struct CapturePluginFactory { + slot: Arc>>, +} + +impl PluginFactory for CapturePluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(CapturePlugin { + cfg: config.clone(), + captured: self.slot.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Build a manager whose registered plugin holds the given capability +/// set (wide caps in this test — the override is supposed to narrow +/// what these caps would have allowed). +async fn build_manager_with_caps( + factory_kind: &str, + factory: Box, + cpex_caps: &[&str], +) -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory(factory_kind, factory); + let caps_yaml = if cpex_caps.is_empty() { + String::new() + } else { + format!(" capabilities: [{}]\n", cpex_caps.join(", ")) + }; + let yaml = format!( + "plugins:\n - name: {0}\n kind: {0}\n{1}", + factory_kind, caps_yaml, + ); + let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +fn extensions_with_subject_and_labels() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.subject = Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + Extensions { + security: Some(Arc::new(security)), + ..Default::default() + } +} + +/// Build a RoutePluginEntry that wraps the base plugin's handler with a +/// derived PluginRef carrying narrower caps — same plugin Arc, fresh +/// circuit breaker, smaller cap set. Mirrors what +/// `RouteDispatchPlan::build` does when APL declares a route-level +/// `plugins..capabilities:` override. +fn plan_with_narrowed_caps( + manager: &PluginManager, + plugin_name: &str, + narrowed_caps: &[&str], +) -> Arc { + let base = manager + .find_plugin_entries(plugin_name) + .into_iter() + .next() + .expect("plugin registered"); + let (_hook_name, base_entry) = base; + let mut merged = base_entry.plugin_ref.trusted_config().clone(); + merged.capabilities = narrowed_caps.iter().map(|s| s.to_string()).collect(); + let override_ref = Arc::new(PluginRef::new( + Arc::clone(base_entry.plugin_ref.plugin()), + merged, + )); + let entry = HookEntry { + plugin_ref: override_ref, + handler: Arc::clone(&base_entry.handler), + }; + let mut plugins = std::collections::HashMap::new(); + let mut entries_by_hook = std::collections::HashMap::new(); + entries_by_hook.insert("cmf.tool_pre_invoke".to_string(), entry); + plugins.insert( + plugin_name.to_string(), + apl_cpex::RoutePluginEntry { + plugin_name: plugin_name.to_string(), + entries_by_hook, + }, + ); + Arc::new(apl_cpex::RouteDispatchPlan { plugins, delegation_entries: Default::default() }) +} + +#[tokio::test] +async fn route_override_caps_narrow_what_plugin_sees() { + // cpex-core registers the plugin with WIDE caps: read_subject AND + // read_labels. Without an override, the plugin would see both. + let captured = Arc::new(tokio::sync::Mutex::new(None)); + let factory = CapturePluginFactory { + slot: captured.clone(), + }; + let mgr = build_manager_with_caps( + "capture-plugin", + Box::new(factory), + &["read_subject", "read_labels"], + ) + .await; + + // APL route override narrows to ONLY read_subject — labels should + // be stripped despite cpex-core having registered them. + let plan = plan_with_narrowed_caps(&mgr, "capture-plugin", &["read_subject"]); + + let invoker = CmfPluginInvoker::for_request( + mgr, + extensions_with_subject_and_labels(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let outcome = invoker + .invoke("capture-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .await + .expect("invoke"); + assert_eq!(outcome.decision, Decision::Allow); + + let captured = captured.lock().await.clone().expect("handler ran"); + let security = captured.security.expect("security extension present"); + + // read_subject is in the narrowed set → subject still visible. + assert!( + security.subject.is_some(), + "route override declared read_subject; plugin should see subject" + ); + assert_eq!( + security.subject.as_ref().unwrap().id.as_deref(), + Some("alice") + ); + + // read_labels is NOT in the narrowed set → labels stripped, even + // though cpex-core's registration would have allowed them through. + assert!( + security.labels.is_empty(), + "route override dropped read_labels; labels should be empty (got {:?})", + security.labels, + ); +} + +// --------------------------------------------------------------------- +// Slice 101 — hook routing table regression +// --------------------------------------------------------------------- +// +// Multi-hook plugin selection bug regression: a plugin registered +// under BOTH `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` must +// dispatch to the right entry per phase. Before Slice 101 the +// dispatch plan classified both as "step" and arbitrary "first +// non-field wins" picked one for every dispatch — silent wrong +// routing when policy and post_policy needed different handlers. + +/// Pre-side handler — returns Allow with no modification. +struct PreSideHandler { + cfg: PluginConfig, +} +#[async_trait] +impl Plugin for PreSideHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} +impl HookHandler for PreSideHandler { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +/// Post-side handler — returns Deny with a distinctive violation +/// code so the test can assert "which handler fired" from the +/// outcome alone. +struct PostSideHandler { + cfg: PluginConfig, +} +#[async_trait] +impl Plugin for PostSideHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} +impl HookHandler for PostSideHandler { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(cpex_core::error::PluginViolation::new( + "test.multi_hook.post_fired", + "post handler fired", + )) + } +} + +/// Marker plugin held by the PluginInstance (handlers are +/// independent structs — the marker satisfies the +/// `PluginInstance.plugin` field). +struct MultiHookMarker { + cfg: PluginConfig, +} +#[async_trait] +impl Plugin for MultiHookMarker { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +struct MultiHookPluginFactory; +impl PluginFactory for MultiHookPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let marker = Arc::new(MultiHookMarker { cfg: config.clone() }); + let pre = Arc::new(PreSideHandler { cfg: config.clone() }); + let post = Arc::new(PostSideHandler { cfg: config.clone() }); + Ok(PluginInstance { + plugin: marker as Arc, + handlers: vec![ + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(pre)), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(post)), + ), + ], + }) + } +} + +/// Plugin registered under both `cmf.tool_pre_invoke` and +/// `cmf.tool_post_invoke`. `PluginInvocation::Step { phase: Pre }` +/// must pick the pre-side handler; `Step { phase: Post }` must pick +/// the post-side handler. The post handler emits a distinctive +/// violation code so we can prove WHICH handler fired from the +/// outcome alone — not just that "a handler" fired. +#[tokio::test] +async fn multi_hook_plugin_dispatches_per_phase_via_routing_table() { + let mgr = build_manager("multi-hook-plugin", Box::new(MultiHookPluginFactory)).await; + let plan = plan_for(&mgr, "multi-hook-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + // Pre phase — should hit pre handler → Allow. + let pre_outcome = invoker + .invoke( + "multi-hook-plugin", + &empty_bag(), + PluginInvocation::Step { + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("pre invoke"); + assert_eq!(pre_outcome.decision, Decision::Allow); + + // Post phase — should hit post handler → Deny with the + // distinctive code. Proves the post handler ran, not the pre + // handler (which would have returned Allow). + let post_outcome = invoker + .invoke( + "multi-hook-plugin", + &empty_bag(), + PluginInvocation::Step { + phase: apl_core::step::DispatchPhase::Post, + }, + ) + .await + .expect("post invoke"); + match post_outcome.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!( + rule_source, "test.multi_hook.post_fired", + "Post phase should dispatch to the post-side handler", + ); + } + d => panic!("expected Deny from post handler, got {d:?}"), + } +} diff --git a/crates/apl-cpex/tests/config_override.rs b/crates/apl-cpex/tests/config_override.rs new file mode 100644 index 00000000..3bfb705d --- /dev/null +++ b/crates/apl-cpex/tests/config_override.rs @@ -0,0 +1,519 @@ +// Location: ./crates/apl-cpex/tests/config_override.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Route-level `config:` override propagation. The unified-config spec +// allows a route to declare `plugins..config: { ... }` that +// REPLACES (not merges) the plugin's base config for THIS route only. +// +// Under the hood: +// +// 1. `AplConfigVisitor` parses the override into `CompiledRoute.plugin_overrides`. +// 2. `RouteDispatchPlan::build` calls `manager.build_override_entries(name, config, caps, on_error)`. +// 3. cpex-core's `build_override_entries` invokes the plugin factory +// with the merged `PluginConfig`, calls `initialize()` on the +// result, and wraps every returned handler in a fresh `PluginRef` +// with an independent circuit breaker. +// +// These tests prove the value the route declared actually reaches the +// plugin's `Plugin::config()` (factory was called with the override) +// and that the base instance is unaffected when a *separate* route +// uses the base config. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::extensions::MetaExtension; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +// ===================================================================== +// Fixtures +// ===================================================================== + +/// Plugin that reads its OWN `config.allowlist` (a list of strings) and +/// denies the request unless `"open"` is in the list. The point is that +/// each instance (base vs override) reads from its own +/// `Plugin::config()` — which is set at factory-construction time. +/// If the route override never reaches the factory, the override +/// instance has the base config and the gate behaves the same as base. +struct AllowlistGate { + cfg: PluginConfig, +} + +impl AllowlistGate { + fn allowlist(&self) -> Vec { + self.cfg + .config + .as_ref() + .and_then(|v| v.get("allowlist")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } +} + +#[async_trait] +impl Plugin for AllowlistGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowlistGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if self.allowlist().iter().any(|s| s == "open") { + PluginResult::allow() + } else { + PluginResult::deny(PluginViolation::new( + "policy.config_gate", + format!( + "allowlist does not include 'open' — saw {:?}", + self.allowlist() + ), + )) + } + } +} + +/// Counter so we can prove the factory was invoked again for the +/// override route (i.e. a *new* instance, not a shared one). Two +/// `mgr.invoke_named` calls against two different routes should +/// trigger exactly two factory calls: one at `load_config` for the +/// base, one at `build_override_entries` for the override route. The +/// dispatch cache memoizes the override entry, so subsequent invokes +/// against the same route don't re-instantiate. +struct AllowlistGateFactory { + instance_count: Arc, +} + +impl PluginFactory for AllowlistGateFactory { + fn create(&self, config: &PluginConfig) -> Result> { + self.instance_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let plugin = Arc::new(AllowlistGate { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn cmf_payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "x"), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +async fn build_manager(yaml: &str) -> (Arc, Arc) { + let instance_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "allowlist-gate", + Box::new(AllowlistGateFactory { + instance_count: Arc::clone(&instance_count), + }), + ); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + }, + ); + mgr.load_config_yaml(yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + (mgr, instance_count) +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Base config: `allowlist: ["closed"]` → plugin denies. Route +/// `tool_a` doesn't override, so it uses the base — should deny. +/// Route `tool_b` overrides `allowlist: ["open"]` → factory builds a +/// new instance with that config → plugin allows. Proves the override +/// reaches the factory and the new instance reads it. +#[tokio::test] +async fn config_override_replaces_base_config_for_route() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["closed"] +routes: + - tool: tool_a + apl: + policy: + - "plugin(gate)" + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + // tool_a uses the base config → denies. + let ext_a = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_a"))), + ..Default::default() + }; + let (res_a, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_a, None) + .await; + let v = res_a + .violation + .expect("base config has no 'open' — should deny"); + assert_eq!(v.code, "policy.config_gate"); + assert!( + v.reason.contains("\"closed\""), + "violation should report the base allowlist, got: {}", + v.reason + ); + + // tool_b uses the override → allows. + let ext_b = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_b"))), + ..Default::default() + }; + let (res_b, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_b, None) + .await; + assert!( + res_b.continue_processing, + "override should allow — violation: {:?}", + res_b.violation + ); + + // Factory invoked exactly twice: once at load_config for the base, + // once at build_override_entries for tool_b. tool_a doesn't override, + // so no extra call. Subsequent invokes hit the dispatch cache. + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 2, + "expected one factory call for base + one for override; \ + a different count means caching / override path is wrong" + ); +} + +/// Run tool_b twice. The dispatch cache must memoize the override +/// instance built on the first call so the second call doesn't trigger +/// another factory invocation. Two routes with overrides should still +/// produce exactly 1 + N instances (base + one per overriding route), +/// regardless of how many invokes hit each route. +#[tokio::test] +async fn dispatch_cache_memoizes_override_instances() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["closed"] +routes: + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_b"))), + ..Default::default() + }; + + // Three invokes against tool_b. The factory should fire once for + // the base (at load_config) and once for the override (at the + // first dispatch); the second + third dispatches hit the cache. + for _ in 0..3 { + let (res, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext.clone(), None) + .await; + assert!(res.continue_processing, "{:?}", res.violation); + } + + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 2, + "factory should be called exactly twice across three invokes — \ + the dispatch cache must reuse the override instance after the \ + first build" + ); +} + +/// Override only `on_error` (no `config:`). Per the spec, this should +/// take the fast path inside `build_override_entries`: shared base +/// plugin Arc, fresh `PluginRef` with merged `TrustedConfig`. The +/// factory must NOT be re-invoked. +#[tokio::test] +async fn caps_only_override_does_not_reinstantiate() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["open"] +routes: + - tool: tool_c + apl: + plugins: + gate: + on_error: ignore + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_c"))), + ..Default::default() + }; + let (res, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) + .await; + assert!(res.continue_processing); + + // Only the base instantiation happened at load_config. The override + // only changes on_error, so the shared-base PluginRef path fires + // and no factory call is made for the route variant. + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 1, + "caps/on_error-only override must NOT re-invoke the factory; \ + doing so would burn resources for a trivial config diff" + ); +} + +// --------------------------------------------------------------------- +// Extended coverage — two routes with distinct config overrides for +// the same plugin, and on_error-override plumbing verification. +// --------------------------------------------------------------------- + +/// Two routes (`tool_a`, `tool_b`) reference the same plugin (`gate`) +/// with DIFFERENT config overrides. The dispatch cache must produce +/// two independent instances, one per route, each carrying its own +/// override config — proves the cache key (`route_key`) keeps the +/// instances separate. Verified by the per-route runtime behavior +/// AND by the factory-call count: base + override_a + override_b = 3. +#[tokio::test] +async fn two_routes_with_distinct_overrides_produce_distinct_instances() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["closed"] +routes: + - tool: tool_a + apl: + plugins: + gate: + config: + allowlist: ["alpha"] + policy: + - "plugin(gate)" + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + // tool_a override: allowlist=["alpha"] — gate denies (no "open"). + let ext_a = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_a"))), + ..Default::default() + }; + let (res_a, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_a, None) + .await; + let v_a = res_a + .violation + .expect("tool_a override has no 'open' — should deny"); + assert!( + v_a.reason.contains("\"alpha\""), + "tool_a violation should report its own override allowlist (alpha), got: {}", + v_a.reason, + ); + + // tool_b override: allowlist=["open"] — gate allows. + let ext_b = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_b"))), + ..Default::default() + }; + let (res_b, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_b, None) + .await; + assert!( + res_b.continue_processing, + "tool_b override has 'open' — should allow: {:?}", + res_b.violation, + ); + + // Factory invocation count: base (at load_config) + tool_a + // override (at first tool_a dispatch) + tool_b override (at first + // tool_b dispatch). Three total — proves the cache holds two + // distinct instances rather than collapsing them. + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 3, + "expected 3 factory calls (base + tool_a override + tool_b override); \ + a smaller count means overrides collapsed across routes", + ); +} + +/// Override changes `on_error` only — sanity-check that the override +/// VALUE actually lands on the per-route plugin entry's trusted_config, +/// not just that the factory wasn't re-invoked. +/// +/// Counterpart to `caps_only_override_does_not_reinstantiate` (which +/// only checks the perf optimization). This test verifies the +/// PLUMBING: build the plan with and without an on_error override, +/// then read the resolved entry's trusted_config to confirm the +/// override actually flowed through (`Ignore`) vs the base default +/// (`Fail`). +#[tokio::test] +async fn on_error_override_plumbs_through_to_trusted_config() { + use std::collections::HashMap; + + use apl_cpex::{DispatchCache, RouteDispatchPlan}; + use apl_core::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; + use apl_core::rules::{CompiledRoute, Effect}; + use cpex_core::plugin::OnError; + + // Single-plugin cpex-core config — load it via the manager so the + // plugin is registered. No APL visitor / routes wiring needed — + // we'll build the routes manually below to focus on what the + // dispatch plan does with overrides. + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["open"] +"#; + let (mgr, _) = build_manager(YAML).await; + + // Construct the APL plugin registry by hand to match what + // `compile_config` would have produced for the YAML's `plugins:` + // block. `RouteDispatchPlan::build` consults this to know which + // plugins to resolve through cpex-core. + let mut registry = PluginRegistry::new(); + registry.insert( + "gate".to_string(), + PluginDeclaration { + name: "gate".to_string(), + kind: "allowlist-gate".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + capabilities: Vec::new(), + config: None, + on_error: None, + extra: HashMap::new(), + }, + ); + + let cache = DispatchCache::new(); + + // Override route — sets `on_error: ignore` only. + let mut route_override = CompiledRoute::default(); + route_override.route_key = "override-route".into(); + route_override.policy.push(Effect::Plugin { name: "gate".into() }); + let mut override_block = PluginOverride::default(); + override_block.on_error = Some("ignore".into()); + route_override + .plugin_overrides + .insert("gate".to_string(), override_block); + let plan_override: std::sync::Arc = + cache.get_or_build(&route_override, ®istry, &mgr).await; + let entry_override = plan_override + .plugins + .get("gate") + .expect("gate must resolve on override route") + .entries_by_hook + .values() + .next() + .expect("override route entry present"); + assert_eq!( + entry_override.plugin_ref.trusted_config().on_error, + OnError::Ignore, + "override route should carry on_error=Ignore on its entry", + ); + + // Base-config route — no overrides; should carry default Fail. + let mut route_base = CompiledRoute::default(); + route_base.route_key = "base-route".into(); + route_base.policy.push(Effect::Plugin { name: "gate".into() }); + let plan_base = cache.get_or_build(&route_base, ®istry, &mgr).await; + let entry_base = plan_base + .plugins + .get("gate") + .expect("gate must resolve on base route") + .entries_by_hook + .values() + .next() + .expect("base route entry present"); + assert_eq!( + entry_base.plugin_ref.trusted_config().on_error, + OnError::Fail, + "base route should carry the default on_error=Fail", + ); +} diff --git a/crates/apl-cpex/tests/delegate_step_e2e.rs b/crates/apl-cpex/tests/delegate_step_e2e.rs new file mode 100644 index 00000000..ffd973ac --- /dev/null +++ b/crates/apl-cpex/tests/delegate_step_e2e.rs @@ -0,0 +1,913 @@ +// Location: ./crates/apl-cpex/tests/delegate_step_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for `Step::Delegate` dispatch (Slice B). +// +// Verifies the full flow: +// * APL parser produces a `Step::Delegate` from policy YAML. +// * apl-cpex's `RouteDispatchPlan::build` resolves the plugin's +// `token.delegate` entry into `plan.delegation_entries`. +// * apl-cpex's `DelegationPluginInvoker` constructs a +// `DelegationPayload`, dispatches via +// `invoke_entries::(...)`, applies the +// resulting payload to extensions, and surfaces granted_* +// attributes for downstream rules. +// * Downstream `require(delegation.granted.* ...)` predicates see +// the populated bag attributes (IdP-as-PDP path). +// * `on_error: deny` (the default) halts the route on plugin deny; +// `on_error: continue` lets the pipeline keep going. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::{Duration, Utc}; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{ + DelegationPayload, TokenDelegateHook, HOOK_TOKEN_DELEGATE, +}; +use cpex_core::error::PluginViolation; +use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, PdpCall, PdpDecision, PdpDialect, + PdpError, PdpResolver, RoutePayload, +}; + +use apl_cpex::{ + CmfPluginInvoker, DelegationPluginInvoker, DispatchCache, MemorySessionStore, SessionStore, +}; + +// --------------------------------------------------------------------- +// Fake TokenDelegateHook plugin — records every call and produces a +// configurable response (grant scopes / deny). +// --------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct DelegateCallRecord { + plugin_name: String, + target_name: String, + target_audience: Option, + required_permissions: Vec, +} + +struct RecordingDelegate { + cfg: PluginConfig, + /// Shared ledger — tests assert on what the plugin saw. + ledger: Arc>>, + /// `Some` → mint a token with these scopes; `None` → deny with + /// the supplied violation code. + grant_scopes: Option>, + grant_audience: String, + deny_code: Option, + /// Snapshot of what extensions the plugin observed when invoked. + /// Used by capability-gating tests to verify the executor's + /// per-entry filter narrowed the view to declared caps. + observed_extensions: Arc>>, +} + +/// Compact summary of what a delegate plugin saw in `Extensions` — +/// just the slots cap-gating tests care about. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ExtensionsObservation { + saw_subject_id: Option, + saw_labels: Vec, + saw_inbound_token_for_user: bool, + saw_delegation_chain_present: bool, +} + +#[async_trait] +impl Plugin for RecordingDelegate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RecordingDelegate { + async fn handle( + &self, + payload: &DelegationPayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.ledger.lock().unwrap().push(DelegateCallRecord { + plugin_name: self.cfg.name.clone(), + target_name: payload.target_name().to_string(), + target_audience: payload.target_audience().map(str::to_string), + required_permissions: payload.required_permissions().to_vec(), + }); + + // Snapshot what this plugin sees in Extensions — the executor's + // per-entry capability filter narrows the view BEFORE handing + // it to the handler, so any slot we see proves the cap that + // gates it was declared. + *self.observed_extensions.lock().unwrap() = Some(ExtensionsObservation { + saw_subject_id: ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()), + saw_labels: ext + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(), + saw_inbound_token_for_user: ext + .raw_credentials + .as_ref() + .map(|rc| rc.inbound_tokens.contains_key(&TokenRole::User)) + .unwrap_or(false), + saw_delegation_chain_present: ext.delegation.is_some(), + }); + + if let Some(code) = &self.deny_code { + return PluginResult::deny(PluginViolation::new( + code.clone(), + format!("recording-delegate `{}` denied", self.cfg.name), + )); + } + + // Grant case — mint a fake token. + let scopes = self.grant_scopes.clone().unwrap_or_default(); + let token = RawDelegatedToken::new( + format!("fake.token.for.{}", self.cfg.name), + "Authorization", + self.grant_audience.clone(), + scopes, + Utc::now() + Duration::seconds(300), + ); + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + PluginResult::modify_payload(updated) + } +} + +fn delegate_cfg(name: &str) -> PluginConfig { + delegate_cfg_with_caps(name, &[]) +} + +/// Same as `delegate_cfg` but with declared capabilities. Capability +/// names map to cpex-core's `filter_extensions` rules — e.g. +/// `read_subject`, `read_labels`, `read_inbound_credentials`, +/// `read_delegation`. Used by cap-gating tests. +fn delegate_cfg_with_caps(name: &str, caps: &[&str]) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: caps.iter().map(|s| s.to_string()).collect(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +// --------------------------------------------------------------------- +// Stub PDP — apl-core's evaluator requires `&dyn PdpResolver`; no +// scenario here exercises a PDP step, so an always-allow stub is +// enough. +// --------------------------------------------------------------------- + +struct AllowPdp; +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) + } +} + +// --------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------- + +/// Build request-level Extensions with a fake inbound bearer token so +/// `DelegationPluginInvoker` has something to put in the +/// DelegationPayload's bearer slot. +fn ext_with_bearer(token: &str) -> Extensions { + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token, "Authorization", TokenKind::Jwt), + ); + Extensions { + raw_credentials: Some(Arc::new(raw)), + ..Default::default() + } +} + +/// Build Extensions populated with a subject + label so cap-gating +/// tests can verify what a delegate plugin actually sees after the +/// executor's per-entry filter narrows the view to declared caps. +fn ext_with_subject_and_label( + token: &str, + subject_id: &str, + label: &str, +) -> Extensions { + use cpex_core::extensions::{SecurityExtension, SubjectExtension}; + + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token, "Authorization", TokenKind::Jwt), + ); + + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some(subject_id.to_string()), + ..Default::default() + }); + sec.add_label(label); + + Extensions { + raw_credentials: Some(Arc::new(raw)), + security: Some(Arc::new(sec)), + ..Default::default() + } +} + +/// Wire up a PluginManager with one or more TokenDelegate plugins, +/// run the route YAML through apl-core's compile, and return the +/// pieces a test needs to invoke a route. +async fn build_setup( + yaml: &str, + plugins: Vec<(String, Arc, PluginConfig)>, +) -> (Arc, apl_core::CompiledConfig, Arc) { + let mgr = Arc::new(PluginManager::default()); + for (_, plugin, cfg) in plugins { + mgr.register_handler::(plugin, cfg) + .expect("register delegate plugin"); + } + mgr.initialize().await.expect("initialize"); + let cfg = compile_config(yaml).expect("compile route YAML"); + let cache = Arc::new(DispatchCache::new()); + (mgr, cfg, cache) +} + +// --------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------- + +/// Baseline: a route with one `delegate(...)` step. The plugin is +/// called with the args from the step, mints a token, and the +/// resulting `delegation.granted.*` bag attributes are visible to +/// downstream `require(...)` rules. +#[tokio::test] +async fn delegate_step_grants_visible_to_downstream_require() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let plugin = Arc::new(RecordingDelegate { + cfg: delegate_cfg("workday-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::new(Mutex::new(None)), + }); + + // APL semantics: `allow` rules don't short-circuit — only `deny` + // halts (spec §3). So the assertion shape is "deny if NOT granted", + // which falls through to the implicit allow at end-of-steps when + // the delegate succeeded. + let yaml = r#" +plugins: + - name: workday-oauth + kind: test + hooks: [token.delegate] +routes: + get_compensation: + policy: + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "!delegation.granted: deny" + - "!(delegation.granted.permissions contains 'read_compensation'): deny" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![( + "workday-oauth".to_string(), + Arc::clone(&plugin), + delegate_cfg("workday-oauth"), + )], + ) + .await; + + let route = cfg.routes.get("get_compensation").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fetch compensation", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + // Inspect the bag directly — this proves the evaluator wrote + // granted_* keys, giving us specific diagnostics if the route + // fails for some other reason. + assert!( + matches!( + bag.get("delegation.granted"), + Some(apl_core::attributes::AttributeValue::Bool(true)) + ), + "delegation.granted should be true; bag has: {:?}", + bag.get("delegation.granted"), + ); + let perms = bag + .get_string_set("delegation.granted.permissions") + .expect("granted.permissions present"); + assert!(perms.contains("read_compensation")); + + assert_eq!( + decision.decision, + Decision::Allow, + "route should allow; got: {:?}", + decision.decision, + ); + + // Plugin was called with the right args. + let calls = ledger.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].plugin_name, "workday-oauth"); + assert_eq!(calls[0].target_name, "workday-api"); + assert_eq!(calls[0].required_permissions, vec!["read_compensation"]); + + // Extensions now carry the minted token under raw_credentials. + let final_ext = invoker.current_extensions().await; + let raw = final_ext + .raw_credentials + .as_ref() + .expect("raw_credentials present"); + assert_eq!(raw.delegated_tokens.len(), 1, "one minted token"); + let token = raw.delegated_tokens.values().next().unwrap(); + assert_eq!(token.audience, "workday-api"); + assert_eq!(token.scopes, vec!["read_compensation"]); +} + +/// IdP-as-PDP: when the plugin denies (e.g. simulating IdP refusal), +/// the route halts with the plugin's violation code — `on_error: deny` +/// is the default and translates the delegate's deny into a route +/// deny. +#[tokio::test] +async fn delegate_step_default_on_error_denies_route() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let plugin = Arc::new(RecordingDelegate { + cfg: delegate_cfg("workday-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: None, + grant_audience: String::new(), + deny_code: Some("delegation.idp_rejected".to_string()), + observed_extensions: Arc::new(Mutex::new(None)), + }); + + // Plugin denies. Default on_error: deny → route halts at the + // delegate step itself with the plugin's violation code. No + // downstream rule needed for the test. + let yaml = r#" +plugins: + - name: workday-oauth + kind: test + hooks: [token.delegate] +routes: + get_compensation: + policy: + - "delegate(workday-oauth, target: workday-api, permissions: [write_everything])" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![( + "workday-oauth".to_string(), + Arc::clone(&plugin), + delegate_cfg("workday-oauth"), + )], + ) + .await; + + let route = cfg.routes.get("get_compensation").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fetch comp", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + match decision.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!( + rule_source, "delegation.idp_rejected", + "rule_source should carry the plugin's violation code", + ); + } + d => panic!("expected Deny on plugin deny, got {d:?}"), + } + assert_eq!( + ledger.lock().unwrap().len(), + 1, + "delegate plugin was called once", + ); +} + +/// `on_error: continue` — even on plugin deny, the route keeps +/// going. Downstream rules can branch on `delegation.granted` being +/// absent. Useful for "try delegation, fall back to a different +/// flow" patterns. +#[tokio::test] +async fn delegate_step_on_error_continue_lets_pipeline_proceed() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let plugin = Arc::new(RecordingDelegate { + cfg: delegate_cfg("audit-receipt"), + ledger: Arc::clone(&ledger), + grant_scopes: None, + grant_audience: String::new(), + deny_code: Some("audit.unavailable".to_string()), + observed_extensions: Arc::new(Mutex::new(None)), + }); + + let yaml = r#" +plugins: + - name: audit-receipt + kind: test + hooks: [token.delegate] +routes: + any: + policy: + - "delegate(audit-receipt, target: audit, on_error: continue)" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![( + "audit-receipt".to_string(), + Arc::clone(&plugin), + delegate_cfg("audit-receipt"), + )], + ) + .await; + + let route = cfg.routes.get("any").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "any", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + assert_eq!( + decision.decision, + Decision::Allow, + "on_error: continue → route allows despite plugin deny", + ); +} + +/// Most-recent-wins semantics for multiple `delegate(...)` calls in +/// one phase. Two delegates in a row both succeed; the +/// `delegation.granted.*` bag keys reflect the LAST one. +/// Extensions-side carries BOTH minted tokens (`raw_credentials.delegated_tokens`). +#[tokio::test] +async fn multiple_delegates_most_recent_wins_in_bag_extensions_accumulate() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let workday = Arc::new(RecordingDelegate { + cfg: delegate_cfg("workday-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::new(Mutex::new(None)), + }); + let payroll = Arc::new(RecordingDelegate { + cfg: delegate_cfg("payroll-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_salary".to_string()]), + grant_audience: "payroll-api".to_string(), + deny_code: None, + observed_extensions: Arc::new(Mutex::new(None)), + }); + + // After both delegates run, the bag reflects payroll's grants + // (most recent). The contains-check on 'read_salary' succeeds + // (because payroll's grant is what's currently in + // `delegation.granted.permissions`); a check for + // 'read_compensation' would FAIL even though workday minted a + // token with that permission, because the bag key is + // overwritten. Extensions-side accumulation (both tokens + // present) is verified separately below. + let yaml = r#" +plugins: + - name: workday-oauth + kind: test + hooks: [token.delegate] + - name: payroll-oauth + kind: test + hooks: [token.delegate] +routes: + fanout: + policy: + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "delegate(payroll-oauth, target: payroll-api, permissions: [read_salary])" + - "!(delegation.granted.permissions contains 'read_salary'): deny" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![ + ( + "workday-oauth".to_string(), + Arc::clone(&workday), + delegate_cfg("workday-oauth"), + ), + ( + "payroll-oauth".to_string(), + Arc::clone(&payroll), + delegate_cfg("payroll-oauth"), + ), + ], + ) + .await; + + let route = cfg.routes.get("fanout").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fanout", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + assert_eq!(decision.decision, Decision::Allow); + + // Both plugins fired, in order. + let calls = ledger.lock().unwrap().clone(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].plugin_name, "workday-oauth"); + assert_eq!(calls[1].plugin_name, "payroll-oauth"); + + // Extensions accumulate — BOTH minted tokens are stashed. + let final_ext = invoker.current_extensions().await; + let raw = final_ext.raw_credentials.as_ref().unwrap(); + assert_eq!(raw.delegated_tokens.len(), 2); + let auds: std::collections::HashSet<&str> = raw + .delegated_tokens + .values() + .map(|t| t.audience.as_str()) + .collect(); + assert!(auds.contains("workday-api")); + assert!(auds.contains("payroll-api")); +} + +// --------------------------------------------------------------------- +// Capability gating on the delegate() step path. +// +// The executor calls `filter_extensions(&ext, &caps)` per entry before +// each handler runs (executor.rs:440 in cpex-core). These tests pin +// that behavior end-to-end for the `Step::Delegate` dispatch path — +// proves that what an operator declares as `capabilities:` on a +// `token.delegate` plugin is enforced exactly the same way it is for +// CMF plugins. +// --------------------------------------------------------------------- + +/// Delegate plugin declaring `read_subject` AND `read_inbound_credentials` +/// (the inbound-credentials cap is needed because the bearer token +/// arrives via raw_credentials and the invoker passes Extensions +/// through unmodified beyond the per-entry filter). Plugin sees the +/// subject, sees the inbound bearer token, but NOT the security label +/// (no read_labels cap). +#[tokio::test] +async fn delegate_with_read_subject_sees_subject_but_not_labels() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let observed: Arc>> = Arc::new(Mutex::new(None)); + + let plugin_cfg = delegate_cfg_with_caps( + "scoped-delegate", + &["read_subject", "read_inbound_credentials"], + ); + let plugin = Arc::new(RecordingDelegate { + cfg: plugin_cfg.clone(), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::clone(&observed), + }); + + let yaml = r#" +plugins: + - name: scoped-delegate + kind: test + hooks: [token.delegate] +routes: + get_compensation: + policy: + - "delegate(scoped-delegate, target: workday-api, permissions: [read_compensation])" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![("scoped-delegate".to_string(), Arc::clone(&plugin), plugin_cfg)], + ) + .await; + + let route = cfg.routes.get("get_compensation").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + // Extensions with BOTH subject (id=alice) AND a label (pii) — + // proves the cap filter is selective. + let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fetch compensation", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let _ = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + let obs = observed + .lock() + .unwrap() + .clone() + .expect("plugin should have run and recorded its view"); + + assert_eq!( + obs.saw_subject_id.as_deref(), + Some("alice"), + "read_subject cap should expose subject.id", + ); + assert!( + obs.saw_inbound_token_for_user, + "read_inbound_credentials cap should expose the inbound user token", + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, the label should NOT leak — saw: {:?}", + obs.saw_labels, + ); +} + +/// Delegate plugin declaring NO capabilities. Should see NOTHING in +/// security or raw_credentials — the executor strips both slots +/// because no relevant cap is held. Verifies the negative case: +/// failure to declare a cap actually does hide the slot. +#[tokio::test] +async fn delegate_without_caps_sees_stripped_extensions() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let observed: Arc>> = Arc::new(Mutex::new(None)); + + // Empty caps array — plugin opts into nothing. + let plugin_cfg = delegate_cfg_with_caps("capless-delegate", &[]); + let plugin = Arc::new(RecordingDelegate { + cfg: plugin_cfg.clone(), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::clone(&observed), + }); + + let yaml = r#" +plugins: + - name: capless-delegate + kind: test + hooks: [token.delegate] +routes: + any: + policy: + - "delegate(capless-delegate, target: workday-api)" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![("capless-delegate".to_string(), Arc::clone(&plugin), plugin_cfg)], + ) + .await; + + let route = cfg.routes.get("any").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "any", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let _ = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + let obs = observed + .lock() + .unwrap() + .clone() + .expect("plugin should have run"); + + // Load-bearing negative assertions — no cap → no slot. + assert!( + obs.saw_subject_id.is_none(), + "without read_subject, subject must be hidden — saw: {:?}", + obs.saw_subject_id, + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, labels must be hidden", + ); + assert!( + !obs.saw_inbound_token_for_user, + "without read_inbound_credentials, inbound token must be hidden", + ); +} + diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs new file mode 100644 index 00000000..184c8149 --- /dev/null +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -0,0 +1,551 @@ +// Location: ./crates/apl-cpex/tests/end_to_end_route.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: APL YAML config → `compile_config` → +// `evaluate_route` → `CmfPluginInvoker::invoke` → typed CPEX dispatch +// via `invoke_named::` → real plugin handler → result mapped +// back through apl-core's `Decision`. +// +// This is the load-bearing test for v0 — it proves apl-core + +// apl-cpex + cpex-core compose through their public surfaces. +// +// The earlier `cmf_invoker_dispatch.rs` exercised the invoker +// directly. This file goes one layer up: the host writes a tiny APL +// route YAML, the evaluator drives the route, and the invoker is the +// only thing that translates plugin-named steps into CMF hook calls. + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_core::pipeline::TaintScope; +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, NoopDelegationInvoker, PdpCall, + PdpDecision, PdpDialect, PdpError, PdpResolver, RoutePayload, +}; + +use apl_cpex::{CmfPluginInvoker, DispatchCache, MemorySessionStore, SessionStore}; + +// --------------------------------------------------------------------- +// Stub PDP — apl-core requires `&dyn PdpResolver`, but no scenario in +// this file exercises a PDP step, so an always-allow stub is enough. +// --------------------------------------------------------------------- + +struct AllowPdp; + +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) + } +} + +// --------------------------------------------------------------------- +// Test CMF plugins — minimal handlers registered on `cmf.tool_pre_invoke` +// (the hook `CmfPluginInvoker` dispatches `PluginInvocation::Step` to +// by default). Duplicated from `cmf_invoker_dispatch.rs` because cargo +// test files don't share modules without a `tests/common/` layout, and +// the fixtures are tiny enough that mild duplication beats the layout +// churn for v0. +// --------------------------------------------------------------------- + +struct AllowPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +struct AllowPluginFactory; +impl PluginFactory for AllowPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +struct DenyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "policy.forbidden", + "scope-gate fixture denied this call", + )) + } +} + +struct DenyPluginFactory; +impl PluginFactory for DenyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +async fn manager_with( + kind: &str, + factory: Box, +) -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory(kind, factory); + let yaml = format!("plugins:\n - name: {0}\n kind: {0}\n", kind); + let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +fn empty_payload() -> RoutePayload { + RoutePayload::new(serde_json::json!({})) +} + +fn cmf_payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "irrelevant for v0 step-only test"), + } +} + +// --------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------- + +/// Route with one policy step `plugin(scope-gate)`. The CPEX plugin +/// registered under that name returns `allow()`. `evaluate_route` must +/// therefore return `Decision::Allow` end-to-end. The hook name is now +/// resolved from the root `plugins:` block in YAML — no hardcoded +/// defaults on the invoker. +#[tokio::test] +async fn route_with_allow_plugin_evaluates_allow() { + const YAML: &str = r#" +plugins: + - name: scope-gate + kind: scope-gate + hooks: [cmf.tool_pre_invoke] +routes: + get_weather: + policy: + - "plugin(scope-gate)" +"#; + + let mgr = manager_with("scope-gate", Box::new(AllowPluginFactory)).await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("get_weather").expect("route present"); + let cache = DispatchCache::new(); + let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; + let invoker = Arc::new(CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + cmf_payload(), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + + assert_eq!(decision.decision, Decision::Allow); + assert!(decision.taints.is_empty()); + assert!(!decision.args_modified); + assert!(!decision.result_modified); +} + +/// Same route shape, but the CPEX plugin denies. `evaluate_route` must +/// surface that as `Decision::Deny` with the violation reason + code +/// flowed through `CmfPluginInvoker`. +#[tokio::test] +async fn route_with_deny_plugin_surfaces_violation_through_route_decision() { + const YAML: &str = r#" +plugins: + - name: scope-gate + kind: scope-gate + hooks: [cmf.tool_pre_invoke] +routes: + get_weather: + policy: + - "plugin(scope-gate)" +"#; + + let mgr = manager_with("scope-gate", Box::new(DenyPluginFactory)).await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("get_weather").expect("route present"); + let cache = DispatchCache::new(); + let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; + let invoker = Arc::new(CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + cmf_payload(), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + + match decision.decision { + Decision::Deny { + reason, + rule_source, + } => { + assert_eq!( + reason.as_deref(), + Some("scope-gate fixture denied this call"), + "violation reason should flow back through CmfPluginInvoker → \ + PluginOutcome → evaluate_steps → RouteDecision" + ); + assert_eq!(rule_source, "policy.forbidden"); + } + other => panic!("expected Decision::Deny, got {:?}", other), + } +} + +// --------------------------------------------------------------------- +// Taint extraction — plugin adds a security label via cow_copy + +// modify_extensions; invoker diffs labels, surfaces the new ones as +// TaintEvent in PluginOutcome.taints. evaluate_steps accumulates them +// into RouteDecision.taints. SessionStore receives the new label via +// persist_session. +// --------------------------------------------------------------------- + +struct TaintingPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for TaintingPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for TaintingPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // cow_copy gives an OwnedExtensions handle inheriting any write + // tokens the executor set up (append_labels grants the + // labels_write_token automatically because the registration + // declares the capability). + let mut owned = extensions.cow_copy(); + let security = owned + .security + .get_or_insert_with(Default::default); + security.add_label("PII"); + PluginResult::modify_extensions(owned) + } +} + +struct TaintingPluginFactory; +impl PluginFactory for TaintingPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(TaintingPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Build a manager whose registered plugin has `append_labels` capability, +/// without which the executor would refuse the modified labels on the way +/// out (label monotonicity is enforced under the write-token system). +async fn tainting_manager() -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory("tagger", Box::new(TaintingPluginFactory)); + let yaml = "plugins:\n - name: tagger\n kind: tagger\n capabilities: [append_labels, read_labels]\n"; + let cfg = cpex_core::config::parse_config(yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +#[tokio::test] +async fn route_plugin_emitting_label_surfaces_taint_and_persists_to_session() { + const YAML: &str = r#" +plugins: + - name: tagger + kind: tagger + hooks: [cmf.tool_pre_invoke] + capabilities: [append_labels, read_labels] +routes: + classify: + policy: + - "plugin(tagger)" +"#; + + let mgr = tainting_manager().await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("classify").expect("route present"); + let cache = DispatchCache::new(); + let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; + + // Session id pinned via tier-0 (agent.session_id) — lets the test + // specify an exact value without faking the identity hash. + let mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-taint-test".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let session_store = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + mgr, + extensions, + cmf_payload(), + plan, + session_store.clone(), + ) + .await); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + + // Decision flows through allow (plugin's modify_extensions doesn't + // halt the pipeline). + assert_eq!(decision.decision, Decision::Allow); + + // The label-emit traveled the full path: + // plugin.handle → modify_extensions → + // PipelineResult.modified_extensions → + // CmfPluginInvoker.invoke (label diff) → + // PluginOutcome.taints → + // evaluate_steps_inner accumulator → + // StepsEvaluation.taints → + // evaluate_route → RouteDecision.taints + assert_eq!(decision.taints.len(), 1, "expected one taint event from tagger plugin"); + let event = &decision.taints[0]; + assert_eq!(event.label, "PII"); + assert_eq!(event.scopes, vec![TaintScope::Session]); + + // SessionStore persistence — host calls persist_session after route + // evaluation; new labels (vs the post-hydration snapshot) land in + // the store under the request's session_id. + invoker.persist_session().await; + let stored = session_store.load_labels("sess-taint-test").await; + assert_eq!(stored, vec!["PII".to_string()]); +} + +#[tokio::test] +async fn session_store_hydrates_labels_at_request_start() { + // Pre-seed the session store with a label, then verify the invoker + // hydrates it into extensions.security.labels at for_request time + // (so the first plugin call sees the accumulated session state). + let session_store = Arc::new(MemorySessionStore::new()); + session_store + .append_labels("sess-existing", &["PRIOR".to_string()]) + .await; + + let mgr = tainting_manager().await; + let yaml = r#" +plugins: + - name: tagger + kind: tagger + hooks: [cmf.tool_pre_invoke] + capabilities: [append_labels, read_labels] +routes: + classify: + policy: + - "plugin(tagger)" +"#; + let cfg = compile_config(yaml).expect("compile_config"); + let route = cfg.routes.get("classify").unwrap(); + let plan = DispatchCache::new().get_or_build(route, &cfg.plugins, &mgr).await; + + let mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-existing".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let invoker = Arc::new( + CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) + .await, + ); + + // Hydrated labels should be observable on the invoker's extensions. + let snapshot = invoker.current_extensions().await; + let security = snapshot.security.expect("hydration creates security extension"); + assert!(security.has_label("PRIOR"), "hydration should pull PRIOR from session store"); + + // Now drive a route — tagger adds PII. After persist, the store has + // both PRIOR (from hydration) and PII (newly emitted). + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + assert_eq!(decision.decision, Decision::Allow); + + // Only the NEW label (PII) shows up as a taint — PRIOR was already + // present before the plugin ran, so it's not a fresh emission. + assert_eq!(decision.taints.len(), 1); + assert_eq!(decision.taints[0].label, "PII"); + + invoker.persist_session().await; + let mut stored = session_store.load_labels("sess-existing").await; + stored.sort(); + assert_eq!(stored, vec!["PII".to_string(), "PRIOR".to_string()]); +} + +/// Slice TS1 proof: an APL `taint(audit, session)` step lands the +/// label in `security.labels` (via `apply_session_taints`) AND the +/// SessionStore (via `persist_session`). No plugin is involved — the +/// taint comes from the YAML, not from any handler's modify_extensions. +/// This is the load-bearing end-to-end test for the +/// "policy with side-effects" pitch: writing `taint(...)` in YAML +/// actually causes the session to be permanently labelled. +#[tokio::test] +async fn apl_taint_step_lands_in_security_labels_and_persists() { + const YAML: &str = r#" +routes: + classify: + policy: + - "taint(audit, session)" +"#; + + let mgr = manager_with("noop", Box::new(AllowPluginFactory)).await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("classify").expect("route present"); + let plan = DispatchCache::new().get_or_build(route, &cfg.plugins, &mgr).await; + + let mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-apl-taint".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let session_store = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new( + CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) + .await, + ); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(Arc::new(NoopDelegationInvoker) as Arc), + ) + .await; + assert_eq!(decision.decision, Decision::Allow); + + // Evaluator surfaced the YAML taint into the decision. + assert_eq!(decision.taints.len(), 1, "expected one taint from `taint(...)` step"); + assert_eq!(decision.taints[0].label, "audit"); + assert!(decision.taints[0] + .scopes + .contains(&TaintScope::Session)); + + // This is the new wiring: drain Session-scoped taints into + // `security.labels` exactly as `AplRouteHandler::invoke` does. + invoker.apply_session_taints(&decision.taints).await; + + let snapshot = invoker.current_extensions().await; + let security = snapshot + .security + .as_ref() + .expect("apply_session_taints should have created the security ext"); + assert!( + security.has_label("audit"), + "session-scoped taint should land in security.labels", + ); + + // And `persist_session` should pick up the label via the diff + // against `initial_labels` (which was empty here). + invoker.persist_session().await; + let stored = session_store.load_labels("sess-apl-taint").await; + assert_eq!(stored, vec!["audit".to_string()]); +} diff --git a/crates/apl-cpex/tests/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs new file mode 100644 index 00000000..c3ca4cde --- /dev/null +++ b/crates/apl-cpex/tests/visitor_e2e.rs @@ -0,0 +1,705 @@ +// Location: ./crates/apl-cpex/tests/visitor_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: unified-config YAML → cpex-core +// `load_config_yaml` → `AplConfigVisitor` walks global / defaults / tags +// / routes → `PluginManager::annotate_route` installs phase-bound +// `AplRouteHandler`s → host calls `invoke_named::` with meta → +// route-annotation short-circuit fires the handler → APL evaluator runs +// the layered route → real CPEX plugins dispatch through +// `CmfPluginInvoker` inside the handler. +// +// This is the load-bearing test for the visitor + annotation flow. It +// proves the whole hierarchy collapses into per-route handlers exactly +// once at load time, and that dispatch into those handlers behaves like +// any other plugin entry (mode, on_error, capabilities all honored +// because the synthetic plugin's `PluginConfig` flows through the same +// executor path). + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::extensions::MetaExtension; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +// ===================================================================== +// Test plugins — `allow-gate` (passes through) and `deny-gate` (denies). +// Both register on `cmf.tool_pre_invoke`. APL routes reference them by +// name via `plugin()` in the YAML; the visitor stacks them into +// the route's compiled steps; the handler dispatches into them through +// CmfPluginInvoker. +// ===================================================================== + +struct AllowGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +struct AllowGateFactory; +impl PluginFactory for AllowGateFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AllowGate { + cfg: config.clone(), + }); + // Register the handler under every hook the operator declared + // in `hooks: [...]`. Lets tests pin the plugin to llm / prompt + // / resource hooks via YAML without per-entity factory copies. + let handlers = hooks_for(config, plugin.clone()); + Ok(PluginInstance { + plugin, + handlers, + }) + } +} + +/// Build the adapter list for a plugin from the operator-declared +/// `hooks:` config. Falls back to `cmf.tool_pre_invoke` when nothing +/// is declared (matches v0 default for routes that don't specify). +fn hooks_for( + config: &PluginConfig, + plugin: Arc, +) -> Vec<( + &'static str, + Arc, +)> +where + H: HookHandler + Plugin + 'static, +{ + let hook_names: Vec<&'static str> = if config.hooks.is_empty() { + vec!["cmf.tool_pre_invoke"] + } else { + config + .hooks + .iter() + .map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str) + .collect() + }; + hook_names + .into_iter() + .map(|name| { + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + (name, adapter) + }) + .collect() +} + +struct DenyGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "policy.forbidden", + "deny-gate fired", + )) + } +} + +struct DenyGateFactory; +impl PluginFactory for DenyGateFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(DenyGate { + cfg: config.clone(), + }); + let handlers = hooks_for(config, plugin.clone()); + Ok(PluginInstance { + plugin, + handlers, + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn cmf_payload(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +/// Build a manager with `allow-gate` and `deny-gate` factories registered, +/// then wire the APL visitor in via `register_apl`. Returns +/// `Arc` so the caller can dispatch through +/// `invoke_named`. The visitor self-populates its plugin registry from +/// cpex-core's parsed `Vec` via `visit_plugins` — no host +/// pre-parse needed. +async fn build_manager_with_visitor(yaml: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory("allow-gate", Box::new(AllowGateFactory)); + mgr.register_factory("deny-gate", Box::new(DenyGateFactory)); + + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + }, + ); + + mgr.load_config_yaml(yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + mgr +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Route declares an `apl.policy: [plugin(allow-gate)]`. After the +/// visitor walks the config, `cmf.tool_pre_invoke` for tool `get_weather` +/// must short-circuit to the APL handler, which dispatches the policy +/// step into the registered `allow-gate` plugin → allow. +#[tokio::test] +async fn visitor_route_with_allow_plugin_returns_allow() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + assert!( + result.continue_processing, + "allow path should continue: violation = {:?}", + result.violation + ); +} + +/// Same shape but with `deny-gate`. The visitor compiles the route, +/// annotates the manager, dispatch goes through the handler, the handler +/// calls into deny-gate via CmfPluginInvoker, the violation propagates +/// out as `PipelineResult.violation` with the original code + reason. +#[tokio::test] +async fn visitor_route_with_deny_plugin_propagates_violation() { + const YAML: &str = r#" +plugins: + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(deny-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + assert!(!result.continue_processing, "deny path should halt"); + let violation = result.violation.expect("deny path must surface a violation"); + assert_eq!( + violation.reason, "deny-gate fired", + "violation reason must propagate from the plugin through the handler" + ); + assert_eq!(violation.code, "policy.forbidden"); +} + +/// Hierarchy: global APL policy step runs FIRST, then route APL policy. +/// Tests apply_layer ordering — global's `plugin(allow-gate)` runs and +/// passes, then route's `plugin(deny-gate)` fires and denies. If the +/// global layer had been appended after instead of before, the deny +/// would have run first and we'd see the deny path; the order assertion +/// is implicit in the violation reason coming from deny-gate. +#[tokio::test] +async fn visitor_stacks_global_then_route_in_order() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +global: + apl: + policy: + - "plugin(allow-gate)" +routes: + - tool: get_weather + apl: + policy: + - "plugin(deny-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + let violation = result.violation.expect("route-level deny must fire"); + assert_eq!(violation.reason, "deny-gate fired"); +} + +/// Tag bundle stacks on top of global. A route tagged `pii` inherits +/// `plugin(deny-gate)` from the tag bundle even though the route itself +/// declares no APL block — proves tag layers are applied without the +/// route having to redeclare anything. +#[tokio::test] +async fn visitor_applies_tag_bundle_to_tagged_route() { + const YAML: &str = r#" +plugins: + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +global: + policies: + pii: + apl: + policy: + - "plugin(deny-gate)" +routes: + - tool: get_weather + meta: + tags: [pii] +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + let violation = result + .violation + .expect("tag bundle's deny-gate should propagate"); + assert_eq!(violation.reason, "deny-gate fired"); +} + +/// Scope routing: a scoped annotation overrides the unscoped default for +/// the matching scope, while requests in other scopes fall back to the +/// unscoped annotation. Proves the visitor's `meta.scope` propagation is +/// keying annotations correctly through cpex-core's annotation table. +#[tokio::test] +async fn visitor_scoped_annotation_overrides_unscoped() { + // Two routes for the same tool: one scoped to `vs-a`, one unscoped. + // The scoped route denies; the unscoped route allows. A request in + // scope `vs-a` must hit the scoped annotation (deny); a request in + // scope `vs-b` falls back to the unscoped default (allow). + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + meta: + scope: vs-a + apl: + policy: + - "plugin(deny-gate)" + - tool: get_weather + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + // Scope vs-a → scoped annotation → deny. + let mut meta_a = meta_for_tool("get_weather"); + meta_a.scope = Some("vs-a".to_string()); + let ext_a = Extensions { + meta: Some(Arc::new(meta_a)), + ..Default::default() + }; + let (res_a, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext_a, None) + .await; + let v = res_a.violation.expect("scoped annotation should deny"); + assert_eq!(v.reason, "deny-gate fired"); + + // Scope vs-b → no scoped match → fall back to unscoped annotation → allow. + let mut meta_b = meta_for_tool("get_weather"); + meta_b.scope = Some("vs-b".to_string()); + let ext_b = Extensions { + meta: Some(Arc::new(meta_b)), + ..Default::default() + }; + let (res_b, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext_b, None) + .await; + assert!( + res_b.continue_processing, + "unscoped fall-back should allow (got violation: {:?})", + res_b.violation + ); +} + +/// Sanity-check: an empty plugin registry + no APL blocks anywhere +/// means the visitor installs zero annotations and the manager behaves +/// exactly as if no visitor was registered. Smokes the no-op path. +#[tokio::test] +async fn visitor_with_no_apl_blocks_installs_nothing() { + // No `apl:` blocks anywhere — just a route + plugin that wouldn't + // be referenced from any APL step. + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: anything +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("anything"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + // Without APL annotations the route resolves through the legacy + // chain. allow-gate is registered but the route doesn't reference + // it, so it doesn't fire either. The pipeline returns allow. + assert!(result.continue_processing); + assert!(result.violation.is_none()); +} + +/// Smoke test that the visitor surfaces a compile error from a malformed +/// APL block as a `PluginError::Config` out of `load_config_yaml`. Catches +/// regressions where visitor errors swallow into Ok(_) or panic. +// --------------------------------------------------------------------- +// Slice 102 — multi-entity-type route support (llm / prompt / resource) +// --------------------------------------------------------------------- +// +// Pre-Slice-102, the visitor hardcoded annotation on +// `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` regardless of route +// entity_type — so an `llm:` route would silently bind to the tool +// hooks and never fire when the host called `invoke_named::("cmf.llm_input", ...)`. +// These tests pin per-entity routing. + +fn meta_for_entity(entity_type: &str, entity_name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some(entity_type.to_string()); + meta.entity_name = Some(entity_name.to_string()); + meta +} + +/// `llm:` route → annotation lands on `cmf.llm_input`. Host calling +/// `invoke_named::("cmf.llm_input", ...)` with matching meta +/// fires the AplRouteHandler. +#[tokio::test] +async fn llm_route_annotates_on_llm_input_hook() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.llm_input] +routes: + - llm: gpt-4 + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.llm_input", cmf_payload("hi"), ext, None) + .await; + + assert!( + result.continue_processing, + "llm route should fire on cmf.llm_input: violation = {:?}", + result.violation + ); +} + +/// Same llm route but post — annotation lands on `cmf.llm_output`. +/// Pre-Slice-102, this would have annotated on `cmf.tool_post_invoke` +/// and never matched. +#[tokio::test] +async fn llm_route_annotates_on_llm_output_hook_for_post_phase() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.llm_output] +routes: + - llm: gpt-4 + apl: + post_policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.llm_output", cmf_payload("response"), ext, None) + .await; + + assert!( + result.continue_processing, + "llm route post-phase should fire on cmf.llm_output: violation = {:?}", + result.violation + ); +} + +/// `prompt:` route → annotation lands on `cmf.prompt_pre_invoke`. +#[tokio::test] +async fn prompt_route_annotates_on_prompt_pre_invoke_hook() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.prompt_pre_invoke] +routes: + - prompt: summarize_email + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("prompt", "summarize_email"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.prompt_pre_invoke", cmf_payload("hi"), ext, None) + .await; + + assert!( + result.continue_processing, + "prompt route should fire on cmf.prompt_pre_invoke: violation = {:?}", + result.violation + ); +} + +/// `resource:` route → annotation lands on `cmf.resource_pre_fetch`. +#[tokio::test] +async fn resource_route_annotates_on_resource_pre_fetch_hook() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.resource_pre_fetch] +routes: + - resource: hr://employees/* + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("resource", "hr://employees/E001234"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.resource_pre_fetch", cmf_payload("hi"), ext, None) + .await; + + assert!( + result.continue_processing, + "resource route should fire on cmf.resource_pre_fetch: violation = {:?}", + result.violation + ); +} + +/// Cross-check: an llm route's APL annotation MUST NOT install on +/// `cmf.tool_pre_invoke`. Pre-Slice-102, the visitor would have +/// annotated llm routes on the tool hook by mistake; this test pins +/// that the bug is gone. +/// +/// Setup: plugin registered ONLY under `cmf.llm_input`. The llm +/// route's APL annotation lands (post-Slice-102) on `cmf.llm_input`. +/// Calling `invoke_named::("cmf.tool_pre_invoke", ...)` +/// finds no APL annotation for that hook AND no plugin chain entry +/// for it → returns `continue_processing=true` with no violations. +/// Calling `cmf.llm_input` DOES fire the annotation and the deny. +#[tokio::test] +async fn llm_route_does_not_fire_on_tool_hook() { + const YAML: &str = r#" +plugins: + - name: deny-gate + kind: deny-gate + hooks: [cmf.llm_input] +routes: + - llm: gpt-4 + apl: + policy: + - "plugin(deny-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), + ..Default::default() + }; + + // Calling cmf.tool_pre_invoke must NOT trigger the llm route's + // APL annotation. With no annotation AND no plugin registered on + // cmf.tool_pre_invoke, dispatch returns continue. + let (tool_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext.clone(), + None, + ) + .await; + assert!( + tool_result.continue_processing, + "llm route MUST NOT bind to cmf.tool_pre_invoke (pre-Slice-102 bug); \ + violation = {:?}", + tool_result.violation, + ); + + // Sanity: calling the RIGHT hook (cmf.llm_input) DOES fire the + // annotation, hits deny-gate, denies — proves the route is wired + // correctly on the llm hook side. + let (llm_result, _bg) = mgr + .invoke_named::("cmf.llm_input", cmf_payload("hi"), ext, None) + .await; + assert!( + !llm_result.continue_processing, + "cmf.llm_input dispatch should hit the deny-gate via the llm route", + ); +} + +#[tokio::test] +async fn visitor_compile_error_propagates_from_load_config_yaml() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "this-is-not-a-valid-step ::: $$$" +"#; + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory("allow-gate", Box::new(AllowGateFactory)); + register_apl(&mgr, AplOptions::in_process()); + + let err = mgr.load_config_yaml(YAML).expect_err("malformed APL block must error"); + let msg = format!("{}", err); + assert!( + msg.contains("visitor 'apl'"), + "expected visitor error context, got: {}", + msg + ); +} diff --git a/crates/apl-delegator-biscuit/Cargo.toml b/crates/apl-delegator-biscuit/Cargo.toml new file mode 100644 index 00000000..f040f614 --- /dev/null +++ b/crates/apl-delegator-biscuit/Cargo.toml @@ -0,0 +1,67 @@ +# Location: ./crates/apl-delegator-biscuit/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-delegator-biscuit — `TokenDelegateHandler` that performs +# biscuit-auth capability-token attenuation. +# +# # Why this exists +# +# Slice 3's `TokenDelegateHook` defines the surface; this crate is +# the *decentralized* backend — cryptographic scope attenuation +# without an IdP roundtrip. Reads the inbound biscuit, appends a +# delegation block narrowing the capabilities (resource / operation +# / time-bound checks), produces a new biscuit base64-encoded as +# the outbound credential. +# +# # AIP alignment +# +# The IETF draft `draft-prakash-aip-00` (Agent Identity Protocol) +# defines a "Chained Mode" using biscuit tokens with +# authority/delegation/completion blocks. This crate produces the +# delegation-block half of that flow; the authority block is the +# inbound biscuit; completion blocks land in a future post-result +# audit hook. +# +# # When to reach for this vs `apl-delegator-oauth` +# +# - **`apl-delegator-biscuit`** — capability tokens, cryptographic +# attenuation, no IdP roundtrip. Use for federated agent +# ecosystems where there's no shared IdP, or for performance- +# sensitive paths where the IdP roundtrip cost matters. +# - **`apl-delegator-oauth`** (slice 6) — RFC 8693 against an +# OAuth IdP. Use when centralized audit/revocation matters more +# than roundtrip cost. + +[package] +name = "apl-delegator-biscuit" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# biscuit-auth v6 — current major. Maintained by Clever Cloud + +# community. Ed25519 + Datalog. No default-features off needed; the +# default feature set is reasonable (no Tonic/network deps). +biscuit-auth = "6" + +# `hex` for parsing raw 32-byte Ed25519 public keys from config. +# biscuit-auth doesn't re-export hex parsing. +hex = "0.4" + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-delegator-biscuit/src/config.rs b/crates/apl-delegator-biscuit/src/config.rs new file mode 100644 index 00000000..16c3ac0d --- /dev/null +++ b/crates/apl-delegator-biscuit/src/config.rs @@ -0,0 +1,161 @@ +// Location: ./crates/apl-delegator-biscuit/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `BiscuitDelegator`. + +use std::path::PathBuf; + +use biscuit_auth::PublicKey; +use serde::{Deserialize, Serialize}; + +/// Plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BiscuitDelegatorConfig { + /// The root public key the inbound biscuit was signed against. + /// Verification fails if the inbound's authority-block signature + /// doesn't validate under this key. + pub root_public_key: PublicKeySource, + + /// Header name the forwarding plugin should attach the minted + /// token under. Most downstream services expect + /// `Authorization` or a custom `X-AIP-Token`-style header. + #[serde(default = "default_outbound_header")] + pub default_outbound_header: String, + + /// Default TTL for the appended delegation block, in seconds. + /// Per-call overrides come from `AttenuationConfig.ttl_seconds` + /// on the `DelegationPayload`. + #[serde(default = "default_ttl_seconds")] + pub default_ttl_seconds: u64, +} + +/// Where the root public key is loaded from. Three modes: +/// +/// * **`hex`** — 32-byte Ed25519 public key encoded as 64 hex +/// characters. Convenient for testing and dev configs. +/// * **`file`** — path to a file containing the raw 32-byte key +/// (binary) or its hex encoding (with optional newline). The +/// resolver auto-detects which. +/// * **`bytes`** — inline 32-byte raw key. Rarely used directly +/// in YAML (operators prefer hex or file) but available for +/// programmatic construction. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PublicKeySource { + Hex { hex: String }, + File { path: PathBuf }, + Bytes { bytes: Vec }, +} + +fn default_outbound_header() -> String { + "Authorization".to_string() +} + +fn default_ttl_seconds() -> u64 { + 300 +} + +impl PublicKeySource { + /// Turn the serializable source into a runtime `PublicKey`. + /// Returns a string error so the caller wraps in + /// `PluginError::Config` with context. + pub fn resolve(&self) -> Result { + match self { + Self::Hex { hex } => { + let bytes = hex::decode(hex.trim()) + .map_err(|e| format!("public_key.hex isn't valid hex: {e}"))?; + Self::bytes_to_public_key(&bytes) + } + Self::Bytes { bytes } => Self::bytes_to_public_key(bytes), + Self::File { path } => { + let raw = std::fs::read(path).map_err(|e| { + format!("public_key file '{}' unreadable: {e}", path.display()) + })?; + // File might be raw 32 bytes OR a hex string (with + // optional whitespace). Try raw first; fall back to + // hex if the length doesn't match. + if raw.len() == 32 { + Self::bytes_to_public_key(&raw) + } else { + // Treat as hex with possible whitespace. + let as_str = std::str::from_utf8(&raw).map_err(|e| { + format!( + "public_key file '{}' isn't 32 raw bytes or valid \ + UTF-8 hex: {e}", + path.display() + ) + })?; + let trimmed = as_str.trim(); + let bytes = hex::decode(trimmed).map_err(|e| { + format!( + "public_key file '{}' isn't valid hex: {e}", + path.display() + ) + })?; + Self::bytes_to_public_key(&bytes) + } + } + } + } + + fn bytes_to_public_key(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(format!( + "Ed25519 public key must be 32 bytes; got {}", + bytes.len() + )); + } + PublicKey::from_bytes(bytes, biscuit_auth::Algorithm::Ed25519) + .map_err(|e| format!("public key bytes not a valid Ed25519 key: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use biscuit_auth::KeyPair; + + #[test] + fn hex_source_resolves() { + let kp = KeyPair::new(); + let pub_hex = hex::encode(kp.public().to_bytes()); + let src = PublicKeySource::Hex { hex: pub_hex }; + assert!(src.resolve().is_ok()); + } + + #[test] + fn hex_source_rejects_wrong_length() { + let src = PublicKeySource::Hex { + hex: "deadbeef".into(), // 4 bytes — wrong length + }; + let err = src.resolve().unwrap_err(); + assert!(err.contains("32 bytes")); + } + + #[test] + fn hex_source_rejects_garbage() { + let src = PublicKeySource::Hex { + hex: "not hex".into(), + }; + let err = src.resolve().unwrap_err(); + assert!(err.contains("hex")); + } + + #[test] + fn config_deserializes() { + let kp = KeyPair::new(); + let pub_hex = hex::encode(kp.public().to_bytes()); + let raw = serde_json::json!({ + "root_public_key": { "kind": "hex", "hex": pub_hex }, + "default_outbound_header": "X-AIP-Token", + "default_ttl_seconds": 60, + }); + let cfg: BiscuitDelegatorConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.default_outbound_header, "X-AIP-Token"); + assert_eq!(cfg.default_ttl_seconds, 60); + assert!(cfg.root_public_key.resolve().is_ok()); + } +} diff --git a/crates/apl-delegator-biscuit/src/delegator.rs b/crates/apl-delegator-biscuit/src/delegator.rs new file mode 100644 index 00000000..9961886d --- /dev/null +++ b/crates/apl-delegator-biscuit/src/delegator.rs @@ -0,0 +1,279 @@ +// Location: ./crates/apl-delegator-biscuit/src/delegator.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `BiscuitDelegator` — `HookHandler` that +// performs biscuit-auth capability-token attenuation. +// +// # Flow +// +// 1. Decode `payload.bearer_token()` as base64 → biscuit bytes. +// 2. Parse + verify the inbound biscuit against the configured +// root public key (`Biscuit::from(bytes, root_public_key)`). +// 3. Build a delegation block carrying the route's narrowing +// constraints: +// * `delegated_to("")` fact +// * `audience("")` fact +// * `check if operation("")` for each required permission +// * `check if time($t), $t <= ` time-bound +// 4. Append the block via `biscuit.append(block_builder)`. Biscuit +// generates an ephemeral signing keypair internally — the +// verifier walks the chain to validate. +// 5. Serialize the new biscuit (now with one more block) to +// base64 → `RawDelegatedToken`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime errors → `PluginResult::deny(PluginViolation::new(code, +// reason))`: +// * `delegation.bad_request` — missing bearer token / target audience +// * `delegation.token_invalid` — base64 decode failed or biscuit +// verification failed (wrong key, +// tampered signature, malformed) +// * `delegation.attenuation_failed` — block construction failed +// (Datalog syntax error) + +use async_trait::async_trait; +use biscuit_auth::builder::BlockBuilder; +use biscuit_auth::{Biscuit, PublicKey}; +use chrono::Utc; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{DelegationPayload, TokenDelegateHook}; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use super::config::BiscuitDelegatorConfig; + +/// Biscuit-mediated `TokenDelegate` handler. +pub struct BiscuitDelegator { + cfg: PluginConfig, + typed: BiscuitDelegatorConfig, + /// Pre-resolved root public key — verifying every inbound + /// biscuit's authority block. + root_public_key: PublicKey, +} + +impl std::fmt::Debug for BiscuitDelegator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BiscuitDelegator") + .field("cfg", &self.cfg.name) + .field("default_outbound_header", &self.typed.default_outbound_header) + .field("default_ttl_seconds", &self.typed.default_ttl_seconds) + .field("root_public_key", &"") + .finish() + } +} + +impl BiscuitDelegator { + /// Build from `PluginConfig`. Parses `cfg.config` into + /// [`BiscuitDelegatorConfig`] and resolves the root public key. + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) requires a `config:` block", + cfg.name + ), + }) + })?; + let typed: BiscuitDelegatorConfig = serde_json::from_value(raw.clone()) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) config parse failed: {e}", + cfg.name + ), + }) + })?; + + let root_public_key = typed.root_public_key.resolve().map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) root_public_key: {e}", + cfg.name + ), + }) + })?; + + Ok(Self { + cfg, + typed, + root_public_key, + }) + } + + /// Resolve the effective TTL — route hint wins if shorter than + /// the configured default. + fn effective_ttl_seconds(&self, payload: &DelegationPayload) -> u64 { + match payload.route_attenuation().and_then(|a| a.ttl_seconds) { + Some(hint) => hint.min(self.typed.default_ttl_seconds), + None => self.typed.default_ttl_seconds, + } + } +} + +#[async_trait] +impl Plugin for BiscuitDelegator { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for BiscuitDelegator { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let bearer = payload.bearer_token(); + if bearer.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "DelegationPayload carried an empty bearer_token", + )); + } + let audience = payload.target_audience().unwrap_or("").to_string(); + if audience.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "target_audience missing — biscuit attenuation requires \ + an audience to scope the delegation block", + )); + } + + // 1. Decode + parse + verify inbound biscuit. + // `Biscuit::from_base64` handles both URL-safe and + // standard base64 variants internally. + let biscuit = match Biscuit::from_base64(bearer, self.root_public_key) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.token_invalid", + format!( + "inbound biscuit verification failed against configured \ + root public key: {e}" + ), + )); + } + }; + + // 2. Build the delegation block. + let ttl_secs = self.effective_ttl_seconds(payload); + let expires_at_unix = (Utc::now() + + chrono::Duration::seconds(ttl_secs as i64)) + .timestamp(); + + // Build the delegation block as a Datalog string. biscuit + // parses + validates the Datalog at parse time. Building + // the source as a single string and parsing once is simpler + // than the typed Fact/Term builder API. + // + // Quote-escape any embedded `"` in user-supplied values so a + // malicious target_name or required_permission can't escape + // the Datalog string literal and inject extra clauses. + let mut datalog = String::new(); + datalog.push_str(&format!( + r#"delegated_to("{}");"#, + escape_datalog_string(payload.target_name()) + )); + datalog.push_str(&format!( + r#"audience("{}");"#, + escape_datalog_string(&audience) + )); + for perm in payload.required_permissions() { + datalog.push_str(&format!( + r#"check if operation("{}");"#, + escape_datalog_string(perm) + )); + } + // Time-bound check — token unusable past expires_at. + datalog.push_str(&format!( + "check if time($t), $t <= {expires_at_unix};" + )); + + // biscuit-auth 6's `BlockBuilder::code` consumes the + // builder and returns a new one on success (or an error if + // the Datalog source is malformed). + let builder = match BlockBuilder::new().code(datalog.as_str()) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("delegation block Datalog parse failed: {e}"), + )); + } + }; + + // 3. Append the block. Biscuit generates an ephemeral + // Ed25519 keypair internally for the new block; the + // verifier walks the chain to validate. + let attenuated = match biscuit.append(builder) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("biscuit append failed: {e}"), + )); + } + }; + + // 4. Serialize. + let new_bytes = match attenuated.to_base64() { + Ok(s) => s, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("could not serialize attenuated biscuit: {e}"), + )); + } + }; + + // 5. Build RawDelegatedToken. + let scopes: Vec = { + let mut s: Vec = payload.required_permissions().to_vec(); + if let Some(att) = payload.route_attenuation() { + for cap in &att.capabilities { + if !s.contains(cap) { + s.push(cap.clone()); + } + } + } + s + }; + let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs as i64); + let token = RawDelegatedToken::new( + new_bytes, + self.typed.default_outbound_header.clone(), + audience, + scopes, + expires_at, + ); + + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + updated.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + updated.minted_at = Some(Utc::now()); + updated.metadata.insert( + "delegator".into(), + serde_json::Value::String("biscuit".into()), + ); + + PluginResult::modify_payload(updated) + } +} + +/// Escape `"` and `\` in a Datalog string literal so user-supplied +/// values (target name, requested scopes) can't break out of the +/// surrounding `"..."` and inject extra Datalog clauses. Belt-and- +/// suspenders — biscuit's parser would likely reject malformed +/// output but the explicit escape avoids relying on parser behavior. +fn escape_datalog_string(s: &str) -> String { + s.replace('\\', r"\\").replace('"', "\\\"") +} diff --git a/crates/apl-delegator-biscuit/src/lib.rs b/crates/apl-delegator-biscuit/src/lib.rs new file mode 100644 index 00000000..fa5b3e9a --- /dev/null +++ b/crates/apl-delegator-biscuit/src/lib.rs @@ -0,0 +1,33 @@ +// Location: ./crates/apl-delegator-biscuit/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-delegator-biscuit — `TokenDelegateHandler` backed by biscuit +// capability-token attenuation. +// +// The host registers this against `token.delegate`; outbound +// forwarding plugins invoke `mgr.invoke_named::(...)` +// with a `DelegationPayload` whose `bearer_token` is a base64- +// encoded biscuit. This handler parses + verifies the inbound +// biscuit against the configured root public key, appends a +// delegation block that narrows the capabilities per the route's +// requested permissions + audience + TTL, and returns the new +// base64-encoded biscuit as the `RawDelegatedToken`. +// +// # AIP Chained Mode +// +// The output of this delegator is structurally what +// `draft-prakash-aip-00` calls a "Chained Mode" token — authority +// block (the inbound) + one delegation block (our attenuation). +// Subsequent hops can each append further blocks. Completion blocks +// (post-execution audit) are a future hook family. +// +// Sub-step A scope: module structure only. Real implementation in +// sub-step B; integration tests in sub-step C. + +pub mod config; +pub mod delegator; + +pub use config::{BiscuitDelegatorConfig, PublicKeySource}; +pub use delegator::BiscuitDelegator; diff --git a/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs b/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs new file mode 100644 index 00000000..7230da62 --- /dev/null +++ b/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs @@ -0,0 +1,316 @@ +// Location: ./crates/apl-delegator-biscuit/tests/biscuit_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for `BiscuitDelegator`. Generates a root keypair +// in-process, mints an authority-only biscuit (the "inbound"), runs +// the delegator's `handle()`, and verifies that the resulting +// attenuated biscuit is well-formed: the root key still verifies +// the chain, and the new delegation block carries the expected +// `delegated_to` / `audience` / `operation` checks. + +use std::sync::Arc; + +use biscuit_auth::{ + builder::{AuthorizerBuilder, BlockBuilder}, + Biscuit, KeyPair, +}; + +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +use cpex_core::extensions::raw_credentials::DelegationMode; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_delegator_biscuit::BiscuitDelegator; + +use serde_json::json; + +// ===================================================================== +// Fixtures +// ===================================================================== + +struct Roots { + keypair: KeyPair, +} + +fn roots() -> &'static Roots { + use std::sync::OnceLock; + static ROOTS: OnceLock = OnceLock::new(); + ROOTS.get_or_init(|| Roots { + keypair: KeyPair::new(), + }) +} + +/// Mint a fresh authority-only biscuit carrying the given Datalog +/// (capabilities the principal holds). Returns base64-encoded +/// biscuit ready to hand to the delegator as `bearer_token`. +fn mint_inbound_biscuit(authority_datalog: &str) -> String { + let builder = BlockBuilder::new() + .code(authority_datalog) + .expect("authority Datalog parses"); + Biscuit::builder() + .merge(builder) + .build(&roots().keypair) + .expect("biscuit builds") + .to_base64() + .expect("biscuit serializes") +} + +fn plugin_config() -> PluginConfig { + let pub_hex = hex::encode(roots().keypair.public().to_bytes()); + PluginConfig { + name: "biscuit-delegator".into(), + kind: "test".into(), + hooks: vec![HOOK_TOKEN_DELEGATE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "root_public_key": { "kind": "hex", "hex": pub_hex }, + "default_outbound_header": "Authorization", + "default_ttl_seconds": 300, + })), + ..Default::default() + } +} + +async fn build_manager() -> Arc { + let cfg = plugin_config(); + let delegator = BiscuitDelegator::new(cfg.clone()).expect("delegator constructs"); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::new(delegator), + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +fn build_payload(inbound: String, target: &str, audience: &str, perms: &[&str]) -> DelegationPayload { + DelegationPayload::new(inbound, target) + .with_target_type(TargetType::Tool) + .with_target_audience(audience) + .with_required_permissions(perms.iter().map(|s| s.to_string()).collect()) + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["audit".into()], + resource_template: None, + actions: Vec::new(), + ttl_seconds: Some(120), + }) +} + +async fn invoke( + mgr: &Arc, + payload: DelegationPayload, +) -> cpex_core::executor::PipelineResult { + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + ) + .await; + result +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Happy path: inbound biscuit + delegation request → attenuated +/// biscuit that still verifies against the root key and carries +/// the expected facts/checks in the new block. +#[tokio::test] +async fn happy_path_attenuates_biscuit() { + let inbound = mint_inbound_biscuit( + r#" + right("read"); + right("audit"); + "#, + ); + + let mgr = build_manager().await; + let payload = build_payload( + inbound.clone(), + "get_compensation", + "https://hr.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "happy path should mint a token: violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result) + .expect("delegation payload should be present"); + let minted = final_payload + .delegated_token + .as_ref() + .expect("delegated_token populated"); + + assert_eq!(minted.audience, "https://hr.example.com"); + assert_eq!(minted.outbound_header, "Authorization"); + // The minted bytes are a NEW (longer) biscuit — appending a + // block grows the serialized form. + assert_ne!(&*minted.token, &inbound); + assert!(minted.token.len() > inbound.len()); + + // Verify the chain: the attenuated biscuit must still validate + // against our root public key. + let attenuated = Biscuit::from_base64(&*minted.token, roots().keypair.public()) + .expect("attenuated biscuit verifies against root"); + + // The new biscuit should have one more block than the original. + let original = Biscuit::from_base64(&inbound, roots().keypair.public()) + .expect("inbound verifies"); + assert_eq!(attenuated.block_count(), original.block_count() + 1); + + // Authorize against the matching operation — should succeed + // because the delegation block adds `check if operation("read")` + // and the verifier provides that fact. The Datalog `time(...)` + // fact must be in the past relative to our `check if time(...) + // <= expires_at` predicate, so we pick a tiny value. + let mut authorizer = AuthorizerBuilder::new() + .code(r#"operation("read"); time(0); allow if true;"#) + .expect("authorizer policy parses") + .build(&attenuated) + .expect("authorizer builds against attenuated biscuit"); + authorizer + .authorize() + .expect("authorizer should allow with matching operation"); + + // Mode = OnBehalfOfUser per the biscuit attenuation convention. + assert!(matches!( + final_payload.delegation_mode, + Some(DelegationMode::OnBehalfOfUser), + )); + + // Metadata records the delegator family — useful for audit. + assert_eq!( + final_payload.metadata.get("delegator"), + Some(&json!("biscuit")), + ); +} + +/// Verifier presents a non-matching operation → the +/// `check if operation("read")` from our delegation block fails +/// → authorizer denies. Pins the scope-narrowing invariant: the +/// downstream service can't use the minted token for operations +/// it wasn't granted. +#[tokio::test] +async fn attenuated_token_denies_wrong_operation() { + let inbound = mint_inbound_biscuit(r#"right("read");"#); + let mgr = build_manager().await; + let payload = build_payload( + inbound, + "get_compensation", + "https://hr.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(result.continue_processing); + let final_payload = DelegationPayload::from_pipeline_result(&result).unwrap(); + let minted = final_payload.delegated_token.as_ref().unwrap(); + + let attenuated = Biscuit::from_base64(&*minted.token, roots().keypair.public()).unwrap(); + // Verifier presents `operation("write")` — should fail because + // our delegation block checks for `operation("read")`. + let mut authorizer = AuthorizerBuilder::new() + .code(r#"operation("write"); time(0); allow if true;"#) + .unwrap() + .build(&attenuated) + .unwrap(); + let res = authorizer.authorize(); + assert!( + res.is_err(), + "attenuated token should deny `write` when delegation only allows `read`", + ); +} + +/// Inbound biscuit signed by a DIFFERENT root key than our config +/// trusts → verification fails at parse time → `delegation.token_invalid`. +#[tokio::test] +async fn wrong_root_key_rejects() { + // Mint with a foreign keypair — NOT the one our delegator trusts. + let foreign = KeyPair::new(); + let foreign_biscuit = Biscuit::builder() + .merge(BlockBuilder::new().code(r#"right("read");"#).unwrap()) + .build(&foreign) + .unwrap() + .to_base64() + .unwrap(); + + let mgr = build_manager().await; + let payload = build_payload( + foreign_biscuit, + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.token_invalid"); +} + +/// Empty bearer token → fast-fail input validation, no biscuit +/// parsing attempted. +#[tokio::test] +async fn empty_bearer_token_rejects() { + let mgr = build_manager().await; + let payload = DelegationPayload::new("", "tool") + .with_target_audience("https://downstream.example.com"); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.bad_request"); + assert!(v.reason.contains("empty bearer_token")); +} + +/// Missing target audience — biscuit attenuation needs an audience +/// to scope the delegation block. +#[tokio::test] +async fn missing_audience_rejects() { + let inbound = mint_inbound_biscuit(r#"right("read");"#); + let mgr = build_manager().await; + let payload = DelegationPayload::new(inbound, "tool"); // no audience + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.bad_request"); + assert!(v.reason.contains("target_audience")); +} + +/// Garbage (non-biscuit) bearer token → parse / verify fails → +/// `delegation.token_invalid`. +#[tokio::test] +async fn malformed_bearer_token_rejects() { + let mgr = build_manager().await; + let payload = build_payload( + "this-is-not-a-biscuit".to_string(), + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.token_invalid"); +} diff --git a/crates/apl-delegator-oauth/Cargo.toml b/crates/apl-delegator-oauth/Cargo.toml new file mode 100644 index 00000000..f4956282 --- /dev/null +++ b/crates/apl-delegator-oauth/Cargo.toml @@ -0,0 +1,69 @@ +# Location: ./crates/apl-delegator-oauth/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-delegator-oauth — `TokenDelegateHandler` that performs RFC 8693 +# OAuth 2.0 token exchange against any compliant IdP. +# +# # Why this exists +# +# Slice 3's `TokenDelegateHook` defines the surface (`DelegationPayload` +# in, `RawDelegatedToken` out via `apply_to_extensions`); this crate +# is the *backend* — the part that actually mints the downstream +# credential. The IdP-mediated path: POST to the IdP's `/token` +# endpoint with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, +# parse the JSON response, build a `RawDelegatedToken`. +# +# # When to reach for this vs `apl-delegator-biscuit` +# +# - **`apl-delegator-oauth`** (this crate) — IdP-mediated. Use when +# the deployment already runs an OAuth server (Keycloak, Auth0, +# Hydra, Zitadel, Janssen Jans Auth Server) and wants centralized +# audit/revocation. Every delegation costs an IdP roundtrip; +# gateway must hold IdP client credentials. +# - **`apl-delegator-biscuit`** (slice 7) — decentralized capability +# tokens via biscuit attenuation. No IdP roundtrip. Use for +# federated agent ecosystems where there's no shared IdP. +# +# Both implement `HookHandler` and are +# swappable at config time. + +[package] +name = "apl-delegator-oauth" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# `reqwest` for the HTTP POST to the IdP token endpoint. Default +# features pull `rustls` for TLS — we explicitly disable the +# default `default-tls` (native-tls) and pick `rustls-tls` instead +# for a cleaner build on macOS/Linux (no openssl link). +# `json` feature for `.json()` response parsing; we send +# application/x-www-form-urlencoded ourselves (RFC 8693 wants form). +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +serde_urlencoded = "0.7" +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } + +# Secret-clearing wrapper for client credentials in memory. +zeroize = { version = "1.8", features = ["zeroize_derive"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +# `mockito` stands up an HTTP server in the test process so we can +# verify the request body shape + simulate IdP responses without +# touching the network. +mockito = "1" diff --git a/crates/apl-delegator-oauth/src/config.rs b/crates/apl-delegator-oauth/src/config.rs new file mode 100644 index 00000000..0a4856b2 --- /dev/null +++ b/crates/apl-delegator-oauth/src/config.rs @@ -0,0 +1,158 @@ +// Location: ./crates/apl-delegator-oauth/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `OAuthDelegator`. Deserializes from the +// plugin's `PluginConfig.config: Option` field; the +// delegator's constructor reads this and builds the runtime state +// (the `reqwest::Client`, the loaded client secret). +// +// Serializable intermediate representations stand in for non- +// serializable runtime types (e.g., the secret is loaded from +// env-var / file / literal at construction time, never serialized +// back out). + +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Top-level plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthDelegatorConfig { + /// IdP's token endpoint URL — where the token-exchange POST + /// lands (e.g., `https://auth.example.com/oauth/token`). + pub token_endpoint: String, + + /// OAuth `client_id` identifying our gateway to the IdP. The + /// IdP authenticates us with `(client_id, client_secret)` over + /// HTTP Basic / form-body before honoring the exchange request. + pub client_id: String, + + /// Where to load the client secret from. See [`ClientSecretSource`]. + pub client_secret_source: ClientSecretSource, + + /// What `subject_token_type` we tell the IdP the inbound token + /// is. RFC 8693 defines `access_token`, `refresh_token`, + /// `id_token`, `jwt`, `saml1`, `saml2`. Most deployments use + /// access_token — that's the default. + #[serde(default = "default_subject_token_type")] + pub subject_token_type: String, + + /// Request timeout. The exchange is on the request hot path — + /// a 5s default keeps requests bounded if the IdP is slow. + #[serde(default = "default_timeout_seconds")] + pub timeout_seconds: u64, + + /// Header name the forwarding plugin should attach the minted + /// token under when calling the downstream service. + /// Most targets expect `Authorization`; some bespoke services + /// want a different header (`X-Service-Token`, etc.). + #[serde(default = "default_outbound_header")] + pub default_outbound_header: String, + + /// Explicitly allow `http://` for `token_endpoint`. By default, + /// the constructor rejects non-https URLs because the + /// token-exchange POST sends `client_id:client_secret` and the + /// inbound user JWT — leaking either over plaintext defeats the + /// whole exchange. Set to `true` ONLY for `http://localhost` + /// development against a docker-compose IdP. Production + /// deployments must leave this at the default (`false`). + #[serde(default)] + pub insecure_http: bool, +} + +/// Where the gateway's OAuth client secret is loaded from. Three +/// modes covering the common deployment patterns: +/// +/// * **`env_var`** — read from a named environment variable at +/// resolver construction. Production-friendly; secret lives in +/// the host's environment, not in committed config. +/// * **`file`** — read from a file path at construction. Useful +/// for Kubernetes secret volumes (`/var/run/secrets/...`) or +/// similar mounted-secret patterns. +/// * **`literal`** — inline secret string. Convenient for tests +/// and dev configs; **never** for production (secret ends up +/// in committed YAML). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ClientSecretSource { + EnvVar { name: String }, + File { path: PathBuf }, + Literal { secret: String }, +} + +fn default_subject_token_type() -> String { + "urn:ietf:params:oauth:token-type:access_token".to_string() +} + +fn default_timeout_seconds() -> u64 { + 5 +} + +fn default_outbound_header() -> String { + "Authorization".to_string() +} + +impl OAuthDelegatorConfig { + /// Helper used by the constructor — exposed for tests. + pub fn timeout(&self) -> Duration { + Duration::from_secs(self.timeout_seconds) + } +} + +impl ClientSecretSource { + /// Resolve the secret at runtime, returning the raw bytes. + /// Errors as a string so the caller wraps in `PluginError::Config` + /// with context. + pub fn resolve(&self) -> Result { + match self { + Self::EnvVar { name } => std::env::var(name) + .map_err(|e| format!("env var '{name}' unavailable: {e}")), + Self::File { path } => std::fs::read_to_string(path) + .map(|s| s.trim().to_string()) + .map_err(|e| { + format!("secret file '{}' unreadable: {e}", path.display()) + }), + Self::Literal { secret } => Ok(secret.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn config_deserializes_from_json() { + let raw = json!({ + "token_endpoint": "https://auth.example.com/oauth/token", + "client_id": "gateway", + "client_secret_source": { "kind": "literal", "secret": "dev-only" }, + }); + let cfg: OAuthDelegatorConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.token_endpoint, "https://auth.example.com/oauth/token"); + assert_eq!(cfg.client_id, "gateway"); + assert_eq!(cfg.timeout_seconds, 5); + assert_eq!(cfg.default_outbound_header, "Authorization"); + } + + #[test] + fn literal_secret_resolves() { + let src = ClientSecretSource::Literal { + secret: "hush".into(), + }; + assert_eq!(src.resolve().unwrap(), "hush"); + } + + #[test] + fn missing_env_var_errors() { + let src = ClientSecretSource::EnvVar { + name: "_THIS_VAR_DEFINITELY_NOT_SET_FOR_TESTS_".into(), + }; + assert!(src.resolve().is_err()); + } +} diff --git a/crates/apl-delegator-oauth/src/delegator.rs b/crates/apl-delegator-oauth/src/delegator.rs new file mode 100644 index 00000000..09508483 --- /dev/null +++ b/crates/apl-delegator-oauth/src/delegator.rs @@ -0,0 +1,474 @@ +// Location: ./crates/apl-delegator-oauth/src/delegator.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `OAuthDelegator` — `HookHandler` that performs +// RFC 8693 OAuth 2.0 Token Exchange against the configured IdP. +// +// # Flow +// +// 1. Read `payload.bearer_token()` (caller's current credential) +// and `payload.target_audience()` / `required_permissions()` / +// `route_attenuation` (the narrowing config). +// 2. Build the form-encoded body per RFC 8693: +// grant_type=urn:ietf:params:oauth:grant-type:token-exchange +// subject_token= +// subject_token_type= +// audience= +// scope= +// 3. POST to the IdP's token endpoint with HTTP Basic auth +// (client_id / client_secret). +// 4. Parse the JSON response: `{ access_token, token_type, +// expires_in, scope, issued_token_type }`. +// 5. Construct a `RawDelegatedToken` with the minted credential + +// computed expiry + effective scopes. +// 6. Return updated payload via `PluginResult::modify_payload`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime errors → `PluginResult::deny(PluginViolation::new(code, +// reason))`: +// * `delegation.idp_unreachable` — network failure +// * `delegation.idp_timeout` — exceeded `timeout_seconds` +// * `delegation.idp_rejected` — IdP returned 4xx/5xx +// * `delegation.bad_response` — response not valid JSON or +// missing required fields +// * `delegation.scope_too_broad` — IdP returned a token whose +// scopes don't include all +// requested permissions + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use serde::Deserialize; +use zeroize::Zeroizing; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{DelegationPayload, TokenDelegateHook}; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use super::config::OAuthDelegatorConfig; + +/// RFC 8693 token-exchange grant type — the value of +/// `grant_type` in the form-encoded request body. +const GRANT_TYPE_TOKEN_EXCHANGE: &str = + "urn:ietf:params:oauth:grant-type:token-exchange"; + +/// Default issued-token-type RFC 8693 returns. We don't rely on it +/// for behavior — it's reported back to operators in audit logs +/// only. +const DEFAULT_ISSUED_TOKEN_TYPE: &str = + "urn:ietf:params:oauth:token-type:access_token"; + +/// OAuth-mediated `TokenDelegate` handler. +pub struct OAuthDelegator { + cfg: PluginConfig, + typed: OAuthDelegatorConfig, + /// Loaded client secret, zeroized on drop. + client_secret: Zeroizing, + /// Shared HTTP client. Pre-built so repeated invocations + /// reuse connections / TLS sessions. + http: reqwest::Client, +} + +impl std::fmt::Debug for OAuthDelegator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthDelegator") + .field("cfg", &self.cfg.name) + .field("token_endpoint", &self.typed.token_endpoint) + .field("client_id", &self.typed.client_id) + .field("client_secret", &"") + .finish() + } +} + +impl OAuthDelegator { + /// Build a delegator from a `PluginConfig`. Reads `cfg.config` + /// into [`OAuthDelegatorConfig`], resolves the client secret, + /// constructs the shared `reqwest::Client`. + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) requires a `config:` block", + cfg.name + ), + }) + })?; + let typed: OAuthDelegatorConfig = serde_json::from_value(raw.clone()) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) config parse failed: {e}", + cfg.name + ), + }) + })?; + + if typed.token_endpoint.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth): token_endpoint must be non-empty", + cfg.name + ), + })); + } + // Reject http:// for token_endpoint by default. The exchange + // POST sends client_id:client_secret + inbound user JWT; + // sending these over plaintext defeats the whole flow. + // `insecure_http: true` is the conscious opt-out for + // localhost docker-compose demos. + if let Err(e) = require_https(&typed.token_endpoint, typed.insecure_http) { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth): token_endpoint {e}", + cfg.name, + ), + })); + } + if typed.client_id.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth): client_id must be non-empty", + cfg.name + ), + })); + } + + let secret = typed.client_secret_source.resolve().map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) client secret resolve failed: {e}", + cfg.name + ), + }) + })?; + + let http = reqwest::Client::builder() + .timeout(typed.timeout()) + .build() + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) HTTP client build failed: {e}", + cfg.name + ), + }) + })?; + + Ok(Self { + cfg, + typed, + client_secret: Zeroizing::new(secret), + http, + }) + } + + /// Compose the requested scope set: the target's required + /// permissions plus any extra capabilities from + /// `route_attenuation`. Returns a space-separated string per + /// OAuth conventions. + fn requested_scopes(payload: &DelegationPayload) -> String { + let mut scopes: Vec = payload.required_permissions().to_vec(); + if let Some(att) = payload.route_attenuation() { + for cap in &att.capabilities { + if !scopes.contains(cap) { + scopes.push(cap.clone()); + } + } + } + scopes.join(" ") + } +} + +/// Subset of the RFC 8693 response we care about. +#[derive(Debug, Deserialize)] +struct TokenExchangeResponse { + access_token: String, + /// Optional per RFC — defaults to `access_token` issued type. + #[serde(default)] + issued_token_type: Option, + /// Optional in RFC; many IdPs send it. + #[serde(default)] + expires_in: Option, + /// Space-separated effective scopes the IdP actually granted. + /// May be narrower than what we requested. + #[serde(default)] + scope: Option, +} + +/// Subset of the standard OAuth error response — `error` is the +/// machine-readable code (`invalid_grant`, `invalid_scope`, …). +#[derive(Debug, Deserialize)] +struct TokenErrorResponse { + error: String, + #[serde(default)] + error_description: Option, +} + +#[async_trait] +impl Plugin for OAuthDelegator { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for OAuthDelegator { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let bearer = payload.bearer_token(); + if bearer.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "DelegationPayload carried an empty bearer_token — outbound \ + caller didn't populate the credential before invoking the hook", + )); + } + let audience = payload.target_audience().unwrap_or(""); + if audience.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "target_audience missing — RFC 8693 token exchange requires \ + an audience to scope the minted credential", + )); + } + + let scope = Self::requested_scopes(payload); + + // Build the form-encoded body. RFC 8693 §2.1. + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), + ("subject_token", bearer), + ("subject_token_type", &self.typed.subject_token_type), + ("audience", audience), + ]; + if !scope.is_empty() { + form.push(("scope", &scope)); + } + + // POST to the IdP. Basic auth carries our client credentials. + let response = match self + .http + .post(&self.typed.token_endpoint) + .basic_auth(&self.typed.client_id, Some(self.client_secret.as_str())) + .form(&form) + .send() + .await + { + Ok(r) => r, + Err(e) if e.is_timeout() => { + return PluginResult::deny(PluginViolation::new( + "delegation.idp_timeout", + format!("token-exchange to {} timed out", self.typed.token_endpoint), + )); + } + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.idp_unreachable", + format!( + "token-exchange POST to {} failed: {e}", + self.typed.token_endpoint, + ), + )); + } + }; + + let status = response.status(); + if !status.is_success() { + // Try to surface the standard `error` / `error_description` + // fields from the IdP. Fall back to status code. + let body = response.text().await.unwrap_or_default(); + let (code, reason) = match serde_json::from_str::(&body) + { + Ok(err) => { + let mut reason = err.error.clone(); + if let Some(desc) = err.error_description { + reason.push_str(": "); + reason.push_str(&desc); + } + ("delegation.idp_rejected", reason) + } + Err(_) => ( + "delegation.idp_rejected", + format!("IdP returned {status}: {body}"), + ), + }; + return PluginResult::deny(PluginViolation::new(code, reason)); + } + + let parsed = match response.json::().await { + Ok(p) => p, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_response", + format!("IdP response wasn't valid token-exchange JSON: {e}"), + )); + } + }; + + // Compute effective scopes. IdP's `scope` field wins (it + // reflects what was actually granted, possibly narrower + // than what we asked for); fall back to the requested set + // if the IdP didn't send one. + let effective_scopes: Vec = if let Some(s) = &parsed.scope { + s.split_whitespace().map(String::from).collect() + } else if !scope.is_empty() { + scope.split_whitespace().map(String::from).collect() + } else { + Vec::new() + }; + + // Enforce requested ⊆ effective. Without this check, a route + // that asked for `read write` and got back `read` would + // proceed as if the broader grant had succeeded — downstream + // calls would fail in policy-author-unobservable ways. We + // compare only when the IdP explicitly sent a `scope` field + // (otherwise we just used the requested set above, so the + // subset relationship is trivially true). The required + // permissions come straight off the DelegationPayload; route + // attenuation capabilities are advisory extras and not + // checked here. + if parsed.scope.is_some() { + let granted: std::collections::HashSet<&str> = + effective_scopes.iter().map(String::as_str).collect(); + let missing: Vec<&str> = payload + .required_permissions() + .iter() + .filter(|req| !granted.contains(req.as_str())) + .map(String::as_str) + .collect(); + if !missing.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.scope_too_broad", + format!( + "IdP granted narrower scopes than requested. \ + requested=[{}] granted=[{}] missing=[{}]", + payload.required_permissions().join(" "), + effective_scopes.join(" "), + missing.join(" "), + ), + )); + } + } + + // Compute expiry. Most IdPs send `expires_in` (seconds); + // if missing, default to 5 minutes — short enough that a + // misconfigured-but-no-expiry IdP doesn't mint long-lived + // tokens by accident. + let ttl_secs = parsed.expires_in.unwrap_or(300); + // Route attenuation may shorten further. + let ttl_secs = if let Some(att) = payload.route_attenuation() { + if let Some(hint) = att.ttl_seconds { + ttl_secs.min(hint as i64) + } else { + ttl_secs + } + } else { + ttl_secs + }; + let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs); + + let token = RawDelegatedToken::new( + parsed.access_token, + self.typed.default_outbound_header.clone(), + audience.to_string(), + effective_scopes, + expires_at, + ); + + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + updated.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + updated.minted_at = Some(Utc::now()); + if let Some(issued) = parsed.issued_token_type { + updated.metadata.insert( + "issued_token_type".into(), + serde_json::Value::String(issued), + ); + } else { + updated.metadata.insert( + "issued_token_type".into(), + serde_json::Value::String(DEFAULT_ISSUED_TOKEN_TYPE.into()), + ); + } + + PluginResult::modify_payload(updated) + } +} + +// Silence unused-import warning when only a subset of these is +// reached in any given config path. Kept as a single place so the +// crate's surface is visible at a glance. +#[allow(dead_code)] +fn _force_link(_: Arc<()>) {} + +/// Reject `http://` for endpoints that carry credentials. Allows +/// `https://` unconditionally and `http://` only when the operator +/// explicitly set `insecure_http: true`. Empty / un-parseable URLs +/// are returned as-is to whatever validator already exists upstream +/// — this helper only owns the scheme check. +/// +/// Returns a short fragment ("must use https://…") that the caller +/// prepends with the field name + plugin name for the full error +/// message. +fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { + let lowered = url.trim_start().to_ascii_lowercase(); + if lowered.starts_with("https://") { + return Ok(()); + } + if lowered.starts_with("http://") { + if insecure_http { + return Ok(()); + } + return Err(format!( + "must use https:// (got '{url}'). Set `insecure_http: true` \ + to allow plaintext for localhost/dev only — never production." + )); + } + // Anything else (missing scheme, bad scheme): defer to the + // upstream URL parser. We're not the URL validator, just the + // scheme gate. + Ok(()) +} + +#[cfg(test)] +mod scheme_tests { + use super::require_https; + + #[test] + fn https_always_ok() { + assert!(require_https("https://idp.example/oauth/token", false).is_ok()); + assert!(require_https("HTTPS://IDP.EXAMPLE/", false).is_ok()); + } + + #[test] + fn http_default_rejected() { + let err = require_https("http://localhost:8081/oauth/token", false).unwrap_err(); + assert!(err.contains("must use https"), "{}", err); + assert!(err.contains("insecure_http"), "mentions opt-out: {}", err); + } + + #[test] + fn http_with_explicit_opt_in_allowed() { + assert!(require_https("http://localhost:8081/oauth/token", true).is_ok()); + } + + #[test] + fn http_with_leading_whitespace_still_rejected() { + // A trailing newline or leading whitespace from sloppy YAML + // shouldn't smuggle a plaintext URL past the gate. + let err = require_https(" http://idp/", false).unwrap_err(); + assert!(err.contains("must use https")); + } +} diff --git a/crates/apl-delegator-oauth/src/factory.rs b/crates/apl-delegator-oauth/src/factory.rs new file mode 100644 index 00000000..b6a6c167 --- /dev/null +++ b/crates/apl-delegator-oauth/src/factory.rs @@ -0,0 +1,59 @@ +// Location: ./crates/apl-delegator-oauth/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `PluginFactory` impl for the OAuth 2.0 (RFC 8693) token-exchange +// delegator. Lives here (alongside the delegator) so every host — +// Praxis filter, Envoy bridge, CLI runner, test harness — wires it +// up the same way. +// +// Operators declare it in CPEX YAML as: +// +// plugins: +// - name: workday-oauth +// kind: delegator/oauth +// hooks: [token.delegate] +// config: +// token_endpoint: https://idp.example.com/token +// client_id: praxis-cpex +// client_secret_source: { kind: env, var: OAUTH_CLIENT_SECRET } +// +// The `kind: delegator/oauth` string is part of this crate's public +// API. Hosts call +// `mgr.register_factory("delegator/oauth", Box::new(OAuthDelegatorFactory))` +// before `load_config_yaml`. + +use std::sync::Arc; + +use cpex_core::{ + delegation::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::OAuthDelegator; + +/// The plugin `kind:` string operators write in CPEX YAML to declare +/// an OAuth RFC 8693 token-exchange delegator. +pub const KIND: &str = "delegator/oauth"; + +/// Factory for `kind: delegator/oauth` plugins. Instantiates an +/// `OAuthDelegator` from the `config:` block and registers it on the +/// `token.delegate` hook. +pub struct OAuthDelegatorFactory; + +impl PluginFactory for OAuthDelegatorFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let delegator = Arc::new(OAuthDelegator::new(config.clone())?); + let handler = Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&delegator), + )); + Ok(PluginInstance { + plugin: delegator, + handlers: vec![(HOOK_TOKEN_DELEGATE, handler)], + }) + } +} diff --git a/crates/apl-delegator-oauth/src/lib.rs b/crates/apl-delegator-oauth/src/lib.rs new file mode 100644 index 00000000..4e81c1e1 --- /dev/null +++ b/crates/apl-delegator-oauth/src/lib.rs @@ -0,0 +1,29 @@ +// Location: ./crates/apl-delegator-oauth/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-delegator-oauth — `TokenDelegateHandler` backed by RFC 8693 +// OAuth 2.0 Token Exchange. +// +// The host registers this handler against `token.delegate`; outbound +// forwarding plugins invoke `mgr.invoke_named::(...)` +// with a `DelegationPayload` (caller's bearer token + target +// audience + required scopes); this handler POSTs to the configured +// OAuth server's token endpoint with `grant_type=urn:ietf:params: +// oauth:grant-type:token-exchange` and the appropriate +// `subject_token` / `audience` / `scope` parameters; the response's +// `access_token` becomes the `RawDelegatedToken` the framework +// stashes under `Extensions.raw_credentials.delegated_tokens`. +// +// Sub-step A scope: data shapes + module structure only. Actual +// HTTP exchange logic in sub-step B; mock-IdP integration tests in +// sub-step C. + +pub mod config; +pub mod delegator; +pub mod factory; + +pub use config::{ClientSecretSource, OAuthDelegatorConfig}; +pub use delegator::OAuthDelegator; +pub use factory::{OAuthDelegatorFactory, KIND}; diff --git a/crates/apl-delegator-oauth/tests/oauth_e2e.rs b/crates/apl-delegator-oauth/tests/oauth_e2e.rs new file mode 100644 index 00000000..ff10351b --- /dev/null +++ b/crates/apl-delegator-oauth/tests/oauth_e2e.rs @@ -0,0 +1,382 @@ +// Location: ./crates/apl-delegator-oauth/tests/oauth_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for `OAuthDelegator` against a `mockito`-backed +// fake IdP. Exercises the full handler path: +// `mgr.invoke_named::(...)` → delegator builds +// RFC 8693 form body → POSTs to mock IdP → mock returns response +// → delegator translates into a `RawDelegatedToken` → host +// extracts via `from_pipeline_result`. +// +// Scenarios: +// * happy path — minted token populated with audience + scopes + expiry +// * IdP returns 400 with `invalid_grant` — surfaces `delegation.idp_rejected` +// * IdP unreachable — surfaces `delegation.idp_unreachable` +// * Request body shape — mockito's matcher verifies we send the +// correct RFC 8693 fields + +use std::sync::Arc; + +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +use cpex_core::extensions::raw_credentials::DelegationMode; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_delegator_oauth::OAuthDelegator; + +use mockito::{Matcher, Server}; +use serde_json::json; + +// ===================================================================== +// Fixtures +// ===================================================================== + +fn plugin_config(token_endpoint: &str) -> PluginConfig { + PluginConfig { + name: "oauth-delegator".into(), + kind: "test".into(), + hooks: vec![HOOK_TOKEN_DELEGATE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "token_endpoint": token_endpoint, + "client_id": "gateway-client", + "client_secret_source": { + "kind": "literal", + "secret": "test-secret", + }, + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "timeout_seconds": 2, + "default_outbound_header": "Authorization", + // wiremock binds to http://127.0.0.1 — opt in to plaintext + // for the test. Production deployments must omit this. + "insecure_http": true, + })), + ..Default::default() + } +} + +fn build_payload(target: &str, audience: &str, scopes: &[&str]) -> DelegationPayload { + DelegationPayload::new("caller-bearer-token-bytes", target) + .with_target_type(TargetType::Tool) + .with_target_audience(audience) + .with_required_permissions(scopes.iter().map(|s| s.to_string()).collect()) + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["audit".into()], + resource_template: None, + actions: Vec::new(), + ttl_seconds: Some(120), + }) +} + +async fn build_manager(token_endpoint: &str) -> Arc { + let cfg = plugin_config(token_endpoint); + let delegator = OAuthDelegator::new(cfg.clone()).expect("delegator constructs"); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::new(delegator), + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +async fn invoke( + mgr: &Arc, + payload: DelegationPayload, +) -> cpex_core::executor::PipelineResult { + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + ) + .await; + result +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Happy path: mock IdP responds with a fresh access_token; the +/// delegator translates it into a `RawDelegatedToken` populated +/// with the requested audience, the effective scopes, and an +/// expiry derived from `expires_in`. +#[tokio::test] +async fn happy_path_mints_delegated_token() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_header("content-type", "application/x-www-form-urlencoded") + // Expect the form fields RFC 8693 requires. + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded( + "grant_type".into(), + "urn:ietf:params:oauth:grant-type:token-exchange".into(), + ), + Matcher::UrlEncoded( + "subject_token".into(), + "caller-bearer-token-bytes".into(), + ), + Matcher::UrlEncoded( + "audience".into(), + "https://hr.example.com".into(), + ), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "access_token": "minted-downstream-jwt", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 300, + "scope": "read:compensation audit", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "happy path should mint a token: violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result) + .expect("delegation payload should be present"); + let token = final_payload + .delegated_token + .as_ref() + .expect("delegated_token populated"); + + assert_eq!(&*token.token, "minted-downstream-jwt"); + assert_eq!(token.audience, "https://hr.example.com"); + assert_eq!(token.outbound_header, "Authorization"); + // Effective scopes come from the IdP's `scope` field. + assert!(token.scopes.contains(&"read:compensation".to_string())); + assert!(token.scopes.contains(&"audit".to_string())); + + // Mode is OnBehalfOfUser by default for RFC 8693 exchange. + assert!(matches!( + final_payload.delegation_mode, + Some(DelegationMode::OnBehalfOfUser), + )); + + // TTL respects the route hint (120s) — IdP's expires_in was 300, + // but the route asked to cap at 120, so effective is 120. + let ttl_left = (token.expires_at - chrono::Utc::now()).num_seconds(); + assert!( + ttl_left <= 120 && ttl_left > 100, + "ttl should reflect min(idp_ttl, route_hint); got {ttl_left}s", + ); + + mock.assert_async().await; +} + +/// IdP returns a 400 with the standard `error` / `error_description` +/// shape — delegator surfaces `delegation.idp_rejected` carrying the +/// IdP's machine-readable code. +#[tokio::test] +async fn idp_rejection_surfaces_error_code() { + let mut server = Server::new_async().await; + server + .mock("POST", "/oauth/token") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": "invalid_grant", + "error_description": "subject_token is not active", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.idp_rejected"); + assert!( + violation.reason.contains("invalid_grant"), + "reason should include IdP's error code; got: {}", + violation.reason, + ); + assert!( + violation.reason.contains("not active"), + "reason should include the error_description; got: {}", + violation.reason, + ); +} + +/// IdP unreachable (mockito server stopped) — delegator surfaces +/// `delegation.idp_unreachable` rather than panicking. +#[tokio::test] +async fn idp_unreachable_surfaces_violation() { + // Use a localhost URL that should be unreachable (no listener + // on that port). The `127.0.0.1:1` port-1 trick: port 1 isn't + // bound by typical systems and connection refusal is fast. + let mgr = build_manager("http://127.0.0.1:1/oauth/token").await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + // Either `idp_unreachable` (connection refused) or `idp_timeout` + // (if the OS decides to slow-fail) — both are valid outcomes + // for "IdP isn't there." The test accepts either. + assert!( + violation.code == "delegation.idp_unreachable" + || violation.code == "delegation.idp_timeout", + "expected idp_unreachable or idp_timeout; got {}", + violation.code, + ); +} + +/// Empty bearer token — fails fast at the handler entry before +/// touching the network. Verifies the input-validation path. +#[tokio::test] +async fn empty_bearer_token_rejects_without_network() { + let mgr = build_manager("http://this-must-not-be-called/oauth/token").await; + let payload = DelegationPayload::new("", "tool") + .with_target_audience("https://downstream.example.com"); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.bad_request"); + assert!(violation.reason.contains("empty bearer_token")); +} + +/// Missing target audience — fails fast (RFC 8693 requires +/// `audience` for downstream scoping). +#[tokio::test] +async fn missing_audience_rejects_without_network() { + let mgr = build_manager("http://this-must-not-be-called/oauth/token").await; + let payload = DelegationPayload::new("some-token", "tool"); // no audience + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.bad_request"); + assert!(violation.reason.contains("target_audience")); +} + +/// IdP grants narrower scopes than requested — delegator emits the +/// documented `delegation.scope_too_broad` code rather than silently +/// proceeding. Without this check, a route that requested +/// `read+write` and got back only `read` would mint a token the +/// downstream call can't actually use, leaving the policy author +/// with no observable signal about *why* the call failed downstream. +#[tokio::test] +async fn idp_narrower_scope_surfaces_scope_too_broad() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "access_token": "narrower-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 300, + // Asked for both, got only `read`. + "scope": "read", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read", "write"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + !result.continue_processing, + "narrower IdP grant must NOT silently succeed", + ); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.scope_too_broad"); + assert!( + violation.reason.contains("write"), + "reason should name the missing scope: {}", + violation.reason, + ); + + mock.assert_async().await; +} + +/// Sanity check: when the IdP grants exactly the requested set, the +/// scope check passes. Pins the "no false positive" half of the +/// scope_too_broad behaviour. +#[tokio::test] +async fn idp_exact_scope_match_succeeds() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "access_token": "ok-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 300, + "scope": "read write", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read", "write"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "exact scope match should mint a token; violation = {:?}", + result.violation, + ); + mock.assert_async().await; +} diff --git a/crates/apl-identity-jwt/Cargo.toml b/crates/apl-identity-jwt/Cargo.toml new file mode 100644 index 00000000..65dcf09b --- /dev/null +++ b/crates/apl-identity-jwt/Cargo.toml @@ -0,0 +1,92 @@ +# Location: ./crates/apl-identity-jwt/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-identity-jwt — JWT-based `IdentityResolveHandler`. +# +# Validates inbound JWTs against configured trusted issuers +# (signature + exp + aud + iss claims) and maps the validated claims +# into `SubjectExtension` / `ClientExtension` via a configurable +# claim-mapper. The raw token is stashed in +# `RawCredentialsExtension.inbound_tokens` for forwarding plugins +# downstream. +# +# # Why this exists alongside `apl-cedarling` +# +# Cedarling's JWT validation is bundled with Cedar policy +# evaluation — it doesn't expose validated identity as a separate +# data product. For deployments that want JWT validation without +# (or before) the Cedar policy step, this crate fills the gap. +# Lightweight (~5-15 transitive deps) vs Cedarling (~200). +# +# # Default-members +# +# This crate IS in the workspace's default-members — the dep tree +# is small enough that it doesn't slow down default builds. Compare +# to `apl-cedarling`, which is excluded. + +[package] +name = "apl-identity-jwt" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# `jsonwebtoken` is the de facto JWT library for Rust. ~5 transitive +# deps (ring, base64, serde, pem). Supports RS256/RS384/RS512, +# ES256/ES384, EdDSA, HS256/HS384/HS512. Default features include +# `use_pem` (load DecodingKey from PEM strings) which we want. +jsonwebtoken = "9" + +# `base64` for the peek-at-iss helper (split + URL_SAFE_NO_PAD decode +# of the middle JWT segment). Pinned to 0.22 to match what +# jsonwebtoken 9 pulls — Cargo dedups to a single version. +base64 = "0.22" + +# `chrono` for the `resolved_at` timestamp on IdentityPayload. Comes +# in transitively via cpex-core already; redeclaring keeps the +# direct-dep relationship visible. +chrono = { workspace = true } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +# Async HTTP — used by `DecodingKeySource::build_async()` to fetch +# IdP JWKS during `Plugin::initialize()`. We default the rustls-tls +# backend (no OpenSSL system dep) and turn off any features we don't +# use to keep the dep tree lean. apl-delegator-oauth pulls reqwest +# too, so cargo dedups to one copy. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# `futures::join_all` so multiple resolvers' JWKS endpoints fetch +# concurrently at initialize time rather than one-at-a-time. +futures = { workspace = true } + +# `tokio::spawn` + `tokio::time::interval` for the background JWKS +# refresh tasks introduced in Slice B. The runtime is already in +# the workspace dep tree via cpex-core / apl-cpex, so this just +# makes the existing types directly nameable here. We only need +# the runtime + time features at runtime; full "macros / rt / rt-multi-thread" +# was test-only previously. +tokio = { workspace = true, features = ["rt", "time"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +# Mock JWKS endpoint for the async `JwksUrl` resolution tests. +mockito = "1" +# RSA keypair + JWK fixtures for tests. `rsa` generates keypairs; +# `pkcs8` encodes them in the PEM format jsonwebtoken accepts. +rsa = { version = "0.9", features = ["pem"] } +# rsa 0.9's `RsaPrivateKey::new(&mut rng, bits)` takes an rng +# implementing `rand_core::CryptoRngCore` — `rand::thread_rng()` +# satisfies that. Test-only dep. +rand = "0.8" diff --git a/crates/apl-identity-jwt/src/claim_map.rs b/crates/apl-identity-jwt/src/claim_map.rs new file mode 100644 index 00000000..f1fbd5e2 --- /dev/null +++ b/crates/apl-identity-jwt/src/claim_map.rs @@ -0,0 +1,401 @@ +// Location: ./crates/apl-identity-jwt/src/claim_map.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `ClaimMapper` — converts validated JWT claims into a populated +// `SubjectExtension`. +// +// Different IdPs use different claim shapes: +// +// * Keycloak — `realm_access.roles` (nested array), `email`, +// `preferred_username`, custom `groups` array +// * Auth0 — flat `permissions` array, `https://my-app/roles` +// (namespaced custom claims), `email` +// * Cognito — `cognito:groups`, `cognito:username`, +// `cognito:roles` +// * Standard OIDC — `sub`, `email`, `name`, `groups`, … +// +// `StandardClaimMap` covers the OIDC-standard shape; deployments +// with bespoke IdPs implement `ClaimMapper` themselves and inject +// at resolver construction. + +use std::collections::HashMap; + +use serde_json::Value; + +use cpex_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity}; + +/// Convert a validated JWT's claim map into the typed identity slot +/// for the resolver's configured role. +/// +/// Implementations supply one method per role they understand: +/// +/// * [`map_subject`] — `sub` plus subject-shaped fields, for +/// `TokenRole::User`. +/// * [`map_client`] — `client_id` plus client-shaped fields, for +/// `TokenRole::Client`. +/// * [`map_workload`] — SPIFFE-style identity, for `TokenRole::Workload`. +/// +/// Each defaults to `None` so existing custom mappers stay valid — +/// they get implicit "this mapper doesn't know how to do that role," +/// which the resolver surfaces as `auth.mapping_failed` when an +/// operator wires a role the mapper can't fill. +/// +/// `Debug` is a supertrait so structs holding `Arc` +/// (notably `JwtIdentityResolver`) can themselves derive `Debug`. +/// +/// [`map_subject`]: ClaimMapper::map_subject +/// [`map_client`]: ClaimMapper::map_client +/// [`map_workload`]: ClaimMapper::map_workload +pub trait ClaimMapper: std::fmt::Debug + Send + Sync { + /// Map JWT claims into a `SubjectExtension` (for `role: user`). + fn map_subject(&self, claims: &HashMap) -> Option { + let _ = claims; + None + } + + /// Map JWT claims into a `ClientExtension` (for `role: client`). + /// Default returns `None` — implementations that handle client + /// tokens override this. + fn map_client(&self, claims: &HashMap) -> Option { + let _ = claims; + None + } + + /// Map JWT claims into a `WorkloadIdentity` (for `role: workload`). + /// Default returns `None` — implementations that handle SPIFFE / + /// SPIFFE-JWT-SVID tokens override this. + fn map_workload(&self, claims: &HashMap) -> Option { + let _ = claims; + None + } +} + +/// Type alias matching what `jsonwebtoken::decode::(...)` +/// produces — a JSON object's key/value pairs. +pub type ClaimMap = HashMap; + +/// Default `ClaimMapper` covering the OIDC-standard claim shape: +/// +/// * `sub` → `subject.id` (required) +/// * `roles` → `subject.roles` (string array) +/// * `permissions` / `scope` → `subject.permissions` (array or +/// space-separated string) +/// * `groups` / `teams` → `subject.teams` (string array) +/// * Every other claim → `subject.claims.` (stringified) +/// +/// Implementations with non-standard IdPs (Keycloak's nested +/// `realm_access.roles`, AWS Cognito's `cognito:*` prefixed claims) +/// write their own `ClaimMapper`; this struct is for the common +/// vanilla-OIDC case. +#[derive(Debug, Clone, Default)] +pub struct StandardClaimMap; + +impl ClaimMapper for StandardClaimMap { + fn map_client(&self, claims: &ClaimMap) -> Option { + // `client_id` is required for ClientExtension — it's the anchor + // identifier policy authors gate on. Falls back to `azp` + // (authorized party, OIDC §2 for the "client_id of the party + // to which the token was issued") which Keycloak and several + // OPs send in place of `client_id`. + let client_id = claims + .get("client_id") + .or_else(|| claims.get("azp")) + .and_then(Value::as_str)? + .to_string(); + + let mut client = ClientExtension { + client_id, + ..Default::default() + }; + + if let Some(name) = claims.get("client_name").and_then(Value::as_str) { + client.client_name = Some(name.to_string()); + } + + // Scopes — array OR space-separated string. + if let Some(arr) = claims.get("authorized_scopes").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + client.authorized_scopes.push(s.to_string()); + } + } + } else if let Some(s) = claims.get("scope").and_then(Value::as_str) { + for scope in s.split_whitespace() { + if !scope.is_empty() { + client.authorized_scopes.push(scope.to_string()); + } + } + } + + // Audiences — single string or array (RFC 7519 §4.1.3). + match claims.get("aud") { + Some(Value::String(s)) => client.authorized_audiences.push(s.clone()), + Some(Value::Array(arr)) => { + for v in arr { + if let Some(s) = v.as_str() { + client.authorized_audiences.push(s.to_string()); + } + } + } + _ => {} + } + + // Platform-native roles. + if let Some(arr) = claims.get("roles").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + client.roles.push(s.to_string()); + } + } + } + + // Remaining claims — keyed by name with full Value preserved + // (ClientExtension.claims is HashMap, + // unlike SubjectExtension.claims which stringifies). + const RESERVED: &[&str] = &[ + "client_id", + "azp", + "client_name", + "authorized_scopes", + "scope", + "aud", + "roles", + "iss", + "exp", + "nbf", + "iat", + "jti", + "sub", + ]; + for (k, v) in claims { + if RESERVED.contains(&k.as_str()) { + continue; + } + client.claims.insert(k.clone(), v.clone()); + } + + Some(client) + } + + fn map_workload(&self, claims: &ClaimMap) -> Option { + // SPIFFE JWT-SVID convention: the SPIFFE ID lives in `sub` + // (per the SPIFFE JWT-SVID spec). We look there first, then + // fall back to an explicit `spiffe_id` claim for IdPs that + // surface it separately. + let spiffe_id = claims + .get("sub") + .and_then(Value::as_str) + .filter(|s| s.starts_with("spiffe://")) + .or_else(|| claims.get("spiffe_id").and_then(Value::as_str)) + .map(str::to_string)?; + + // Trust domain — pull from the SPIFFE-ID host part. + let trust_domain = spiffe_id + .strip_prefix("spiffe://") + .and_then(|rest| rest.split('/').next()) + .map(str::to_string); + + Some(WorkloadIdentity { + spiffe_id: Some(spiffe_id), + trust_domain, + attested_at: None, + attestor: Some("jwt".to_string()), + ..Default::default() + }) + } + + fn map_subject(&self, claims: &ClaimMap) -> Option { + // `sub` is required — RFC 7519 §4.1.2 makes it optional in + // the spec but it's effectively mandatory for identity flows. + let sub = claims.get("sub").and_then(Value::as_str)?.to_string(); + + let mut subject = SubjectExtension { + id: Some(sub), + ..Default::default() + }; + + // `roles` — array of strings. + if let Some(arr) = claims.get("roles").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.roles.insert(s.to_string()); + } + } + } + + // `permissions` (array) OR `scope` (space-separated string, + // OAuth-style). Either populates `subject.permissions`. + if let Some(arr) = claims.get("permissions").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.permissions.insert(s.to_string()); + } + } + } else if let Some(s) = claims.get("scope").and_then(Value::as_str) { + for scope in s.split_whitespace() { + if !scope.is_empty() { + subject.permissions.insert(scope.to_string()); + } + } + } + + // `teams` (explicit) preferred; fall back to `groups` (OIDC + // conventional name for the same concept). + if let Some(arr) = claims.get("teams").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.teams.insert(s.to_string()); + } + } + } else if let Some(arr) = claims.get("groups").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.teams.insert(s.to_string()); + } + } + } + + // Every other claim → `subject.claims.`. + // SubjectExtension.claims is HashMap, so + // non-string values get stringified (JSON-serialized). The + // reserved-claim set is the ones we already mapped to + // structured fields, plus the JWT standard registered + // claims (iss/aud/exp/nbf/iat/jti) which aren't useful as + // policy-visible claims. + const RESERVED: &[&str] = &[ + "sub", + "roles", + "permissions", + "scope", + "teams", + "groups", + "iss", + "aud", + "exp", + "nbf", + "iat", + "jti", + ]; + for (k, v) in claims { + if RESERVED.contains(&k.as_str()) { + continue; + } + let stringified = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + subject.claims.insert(k.clone(), stringified); + } + + Some(subject) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_claims(json: Value) -> ClaimMap { + json.as_object().unwrap().clone().into_iter().collect() + } + + #[test] + fn sub_becomes_subject_id() { + let claims = make_claims(json!({"sub": "alice@corp.com"})); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); + } + + #[test] + fn missing_sub_returns_none() { + // No `sub` claim → mapper rejects. Caller will surface + // this as `auth.mapping_failed`. + let claims = make_claims(json!({"email": "alice@corp.com"})); + assert!(StandardClaimMap.map_subject(&claims).is_none()); + } + + #[test] + fn roles_array_becomes_subject_roles() { + let claims = make_claims(json!({ + "sub": "alice", + "roles": ["hr", "admin"], + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.roles.contains("hr")); + assert!(subject.roles.contains("admin")); + } + + #[test] + fn scope_string_splits_into_permissions() { + // OAuth-style space-separated scope claim — `scope: "read write"`. + let claims = make_claims(json!({ + "sub": "alice", + "scope": "read write delete", + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.permissions.contains("read")); + assert!(subject.permissions.contains("write")); + assert!(subject.permissions.contains("delete")); + } + + #[test] + fn permissions_array_preferred_over_scope() { + // If both are present, `permissions` (array) wins. Most + // modern IdPs send arrays; OAuth-1-era `scope` is a fallback. + let claims = make_claims(json!({ + "sub": "alice", + "permissions": ["call_tool", "list_tools"], + "scope": "read write", + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.permissions.contains("call_tool")); + // `scope` ignored when `permissions` is present. + assert!(!subject.permissions.contains("read")); + } + + #[test] + fn groups_fallback_when_teams_absent() { + let claims = make_claims(json!({ + "sub": "alice", + "groups": ["engineering", "platform"], + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.teams.contains("engineering")); + assert!(subject.teams.contains("platform")); + } + + #[test] + fn teams_preferred_over_groups() { + let claims = make_claims(json!({ + "sub": "alice", + "teams": ["explicit-team"], + "groups": ["fallback-group"], + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.teams.contains("explicit-team")); + assert!(!subject.teams.contains("fallback-group")); + } + + #[test] + fn unmapped_claims_land_in_subject_claims_map() { + let claims = make_claims(json!({ + "sub": "alice", + "email": "alice@corp.com", + "preferred_username": "alice", + "iat": 1700000000, // reserved, should be skipped + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert_eq!(subject.claims.get("email"), Some(&"alice@corp.com".to_string())); + assert_eq!( + subject.claims.get("preferred_username"), + Some(&"alice".to_string()), + ); + // Reserved JWT claims aren't propagated as policy-visible + // subject claims. + assert!(!subject.claims.contains_key("iat")); + assert!(!subject.claims.contains_key("sub")); + } +} diff --git a/crates/apl-identity-jwt/src/config.rs b/crates/apl-identity-jwt/src/config.rs new file mode 100644 index 00000000..37a70da7 --- /dev/null +++ b/crates/apl-identity-jwt/src/config.rs @@ -0,0 +1,511 @@ +// Location: ./crates/apl-identity-jwt/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `JwtIdentityResolver`. Deserializes from +// the plugin's `PluginConfig.config: Option` field; the +// resolver's constructor reads this and builds the runtime state +// (DecodingKey instances, claim mapper selection). +// +// Serializable intermediate representations (`DecodingKeySource`) +// stand in for non-serializable runtime types (`DecodingKey`). The +// build step on each type turns the config representation into the +// runtime form. + +use std::path::PathBuf; + +use cpex_core::extensions::raw_credentials::TokenRole; +use jsonwebtoken::{Algorithm, DecodingKey}; +use serde::{Deserialize, Serialize}; + +use super::trusted_issuer::{KeyStore, TrustedIssuer}; + +/// Top-level plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +/// +/// One instance of this plugin handles ONE inbound credential +/// (one header, one role). Wire multiple instances if a deployment +/// expects multiple inbound tokens — e.g. user JWT in +/// `X-User-Token`, OAuth client token in `Authorization`, and a +/// SPIFFE JWT-SVID in `X-Workload-Token`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtIdentityResolverConfig { + /// One or more trusted issuers. At least one required. + pub trusted_issuers: Vec, + + /// Which identity slot this resolver fills. Determines: + /// + /// * Which `TokenRole` key the raw token gets stashed under in + /// `RawCredentialsExtension.inbound_tokens`. + /// * Which `SecurityExtension` slot the mapped identity writes + /// into — `User` → `security.subject`, `Client` → + /// `security.client`, `Workload` → `security.caller_workload`. + /// + /// Default `User` keeps single-resolver deployments backwards- + /// compatible. Custom roles aren't supported yet — the resolver + /// errors at construction. + #[serde(default = "default_role")] + pub role: TokenRole, + + /// HTTP header name this resolver reads its token from + /// (e.g. `"Authorization"`, `"X-User-Token"`). The `Bearer ` + /// prefix is stripped if present. Recorded on + /// `RawInboundToken.source_header` so forwarding plugins can + /// re-attach (or strip) the credential under the same name. + /// Default `Authorization` matches the most common case. + #[serde(default = "default_header")] + pub header: String, + + /// Which claim mapper to use. `"standard"` is the OIDC default; + /// future named mappers (e.g., `"keycloak"`, `"cognito"`) plug + /// in via the registry pattern in `resolver.rs`. Omitted → + /// `StandardClaimMap`. + #[serde(default)] + pub claim_mapper: Option, +} + +fn default_role() -> TokenRole { + TokenRole::User +} + +/// Default JWKS refresh interval — 10 minutes. High enough that a +/// fleet of gateways isn't constantly hammering the IdP; low enough +/// that a routine key rotation propagates within a normal change +/// window. Operators with stricter or laxer needs override per +/// `JwksUrl` via the `refresh_secs` field. +fn default_refresh_secs() -> u64 { + 600 +} + +fn default_header() -> String { + "Authorization".to_string() +} + +/// One issuer's config — issuer URL, audiences, decoding key +/// source, accepted algorithms. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustedIssuerConfig { + /// Expected `iss` claim value. + pub issuer: String, + + /// Expected audience(s). Empty list disables `aud` validation. + #[serde(default)] + pub audiences: Vec, + + /// Algorithms accepted for signature verification (e.g., + /// `RS256`, `ES256`). At least one required. + pub algorithms: Vec, + + /// Source of the decoding key. See [`DecodingKeySource`]. + pub decoding_key: DecodingKeySource, + + /// Clock-skew tolerance for `exp` / `nbf` validation, in + /// seconds. `0` (default) means "use resolver default" — the + /// constructor applies a sensible value (currently 60s). + #[serde(default)] + pub leeway_seconds: u64, +} + +/// Where the JWT signing key material comes from. Serializable +/// intermediate; the resolver builds a runtime `DecodingKey` from +/// it at construction time. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DecodingKeySource { + /// Inline PEM-encoded public key (RSA / EC). Useful for tests + /// and dev configs; production deployments usually prefer + /// `pem_file` so keys don't appear in checked-in configs. + Pem { pem: String }, + + /// Path to a PEM file. Read at construction time. Path is + /// resolved relative to the host's working directory unless + /// absolute. + PemFile { path: PathBuf }, + + /// Inline JWK (JSON Web Key) — full JWK structure as JSON. + Jwk { jwk: serde_json::Value }, + + /// OIDC JWKS endpoint — the standard way to wire to a real IdP + /// (Keycloak / Auth0 / Cognito / Okta / Authentik …). Fetched + /// at plugin `initialize()` and re-fetched every `refresh_secs` + /// thereafter so IdP key rolls don't require a gateway + /// restart. Each fetched signature-use key is indexed by its + /// `kid` so the verify path can select the right one per + /// token (overlapping rotation windows work). + /// + /// **`insecure_http`** defaults to `false` — `build_async` + /// rejects `http://` URLs. With JWKS over plaintext, anyone on + /// the network path can swap the key material and forge JWTs + /// the gateway accepts. Set to `true` only for `http://localhost` + /// docker-compose development; production must always use https. + /// + /// **`refresh_secs`** controls how often the background + /// refresh task re-fetches the JWKS. Default 600 (10 minutes) + /// — high enough that a fleet of gateways doesn't hammer the + /// IdP, low enough that a routine key roll propagates within + /// the same business hour. A failed refresh logs a warning + /// and keeps the previous KeyStore — verification continues + /// to work as long as one of the previously-fetched keys + /// matches the inbound token's `kid`. + JwksUrl { + url: String, + #[serde(default)] + insecure_http: bool, + #[serde(default = "default_refresh_secs")] + refresh_secs: u64, + }, + + /// Symmetric HMAC secret (HS256 / HS384 / HS512 only). Not + /// recommended for production; signature verifiers need the + /// same secret, which makes key distribution painful. + Secret { secret: String }, +} + +impl DecodingKeySource { + /// Whether this source needs network I/O to resolve. Used by + /// `JwtIdentityResolver` to decide between eager (sync) build at + /// `new()` and deferred (async) build at `Plugin::initialize()`. + pub fn needs_async(&self) -> bool { + matches!(self, Self::JwksUrl { .. }) + } + + /// How often the background refresh task should re-fetch this + /// source. `Some(_)` for `JwksUrl` (the only refreshable + /// variant), `None` for inline sources whose key material is + /// static for the resolver's lifetime. + pub fn refresh_interval(&self) -> Option { + match self { + Self::JwksUrl { refresh_secs, .. } => { + Some(std::time::Duration::from_secs(*refresh_secs)) + } + _ => None, + } + } + + /// Synchronously turn the source into a [`KeyStore`]. Works for + /// inline / on-disk sources; **errors for `JwksUrl`** — use + /// [`build_async`] for those. Returns a string error so callers + /// can wrap into `PluginError::Config` with context. + /// + /// Inline sources have no `kid` context, so the resulting store + /// has a single `fallback` entry usable for any token whose + /// header omits `kid`. Tokens that DO carry a `kid` against an + /// inline source resolve to `auth.unknown_kid` at verify time — + /// the JWKS spec is the source of truth for which kids exist. + /// + /// [`build_async`]: Self::build_async + pub fn build(&self) -> Result { + let key = match self { + Self::Pem { pem } => build_from_pem_bytes(pem.as_bytes(), "inline PEM")?, + Self::PemFile { path } => { + let bytes = std::fs::read(path) + .map_err(|e| format!("decoding-key file '{}' unreadable: {e}", path.display()))?; + build_from_pem_bytes(&bytes, &format!("file '{}'", path.display()))? + } + Self::Jwk { jwk } => build_from_jwk_value(jwk)?, + Self::JwksUrl { url, .. } => { + return Err(format!( + "JwksUrl source '{url}' requires async resolution — call build_async()" + )) + } + Self::Secret { secret } => DecodingKey::from_secret(secret.as_bytes()), + }; + Ok(KeyStore::single_fallback(key)) + } + + /// Asynchronously resolve the source into a [`KeyStore`] — + /// handles every variant including `JwksUrl` (which does an + /// async HTTP GET against the IdP's JWKS endpoint and indexes + /// every signature-use key by its `kid`). + /// + /// Called from `JwtIdentityResolver::initialize()` so the host's + /// PluginManager can drive multiple resolvers' JWKS fetches + /// concurrently via `futures::join_all`. + /// + /// The fetch is bounded by `JWKS_FETCH_TIMEOUT` to prevent a + /// slow or hostile JWKS endpoint from hanging gateway startup + /// indefinitely. A timed-out fetch surfaces as an error string + /// the caller can soft-fail on (Slice B). + /// + /// **v0 caveat (still open after Slice A):** + /// + /// * No automatic rotation — the store is bound at initialize + /// time. Slice B adds a background refresh task so IdP key + /// rolls don't require a gateway restart. + pub async fn build_async(&self) -> Result { + match self { + Self::JwksUrl { url, insecure_http, .. } => { + // Reject http:// by default. Fetching JWKS over + // plaintext lets anyone on the network path swap the + // signing keys and forge JWTs the gateway accepts. + require_https(url, *insecure_http)?; + + // Build a Client with both a connect timeout and an + // overall request timeout. Without these a slow or + // half-open JWKS endpoint hangs the initialize() call + // indefinitely. The defaults are conservative; if a + // future config wants per-issuer override, add a + // `jwks_timeout_secs` field on `JwksUrl`. + let client = reqwest::Client::builder() + .timeout(JWKS_FETCH_TIMEOUT) + .connect_timeout(JWKS_CONNECT_TIMEOUT) + .build() + .map_err(|e| format!("JWKS client construction failed: {e}"))?; + + let body = client + .get(url) + .send() + .await + .map_err(|e| format!("JWKS GET {url} failed: {e}"))? + .error_for_status() + .map_err(|e| format!("JWKS GET {url} returned non-2xx: {e}"))? + .text() + .await + .map_err(|e| format!("JWKS GET {url} body read failed: {e}"))?; + + let jwks: jsonwebtoken::jwk::JwkSet = serde_json::from_str(&body) + .map_err(|e| format!("JWKS {url} body is not a JWKSet: {e}"))?; + + // Iterate every signature-use key (or every key, if + // none declared `use: sig`) and index by `kid`. + // OIDC spec requires JWKS entries to carry a `kid`; + // any entry missing one is dropped with a clear + // diagnostic appended to the error string. If NO + // usable keys remain, treat that as a config error. + let mut entries: Vec<(String, DecodingKey)> = Vec::new(); + let mut skipped_no_kid: usize = 0; + let mut skipped_unusable: Vec = Vec::new(); + for k in &jwks.keys { + // Filter to sig-use when the IdP labels it; if no + // key declares `use`, accept everything (some + // older IdPs publish JWKS without the field). + let use_field = k.common.public_key_use.as_ref(); + if use_field + .map(|u| *u != jsonwebtoken::jwk::PublicKeyUse::Signature) + .unwrap_or(false) + { + continue; + } + let kid = match k.common.key_id.as_deref() { + Some(kid) if !kid.is_empty() => kid.to_string(), + _ => { + skipped_no_kid += 1; + continue; + } + }; + match DecodingKey::from_jwk(k) { + Ok(key) => entries.push((kid, key)), + Err(e) => skipped_unusable.push(format!("{kid}: {e}")), + } + } + if entries.is_empty() { + return Err(format!( + "JWKS at {url} contained no usable signature keys \ + (skipped {skipped_no_kid} entries with no kid; \ + {} entries failed to parse: [{}])", + skipped_unusable.len(), + skipped_unusable.join(", "), + )); + } + Ok(KeyStore::from_jwks_entries(entries)) + } + // Non-network variants delegate to the sync path; they + // don't await anything, so the cost is zero vs. a direct + // sync call. + other => other.build(), + } + } +} + +/// Overall request timeout on the JWKS HTTP GET (includes connect + +/// TLS + response body). 5s is a forgiving upper bound for a healthy +/// IdP; anything slower than that is operationally indistinguishable +/// from "JWKS is down." +const JWKS_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// TCP-connect timeout for the JWKS HTTP GET. Separate from the +/// overall timeout so a hostile JWKS endpoint that accepts the +/// connection and then stalls on the response still fails fast. +const JWKS_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// PEM helper used by both `Pem` and `PemFile`. Tries RSA, then EC, +/// then EdDSA — covers the algorithms `jsonwebtoken` supports. +fn build_from_pem_bytes(bytes: &[u8], origin: &str) -> Result { + DecodingKey::from_rsa_pem(bytes) + .or_else(|_| DecodingKey::from_ec_pem(bytes)) + .or_else(|_| DecodingKey::from_ed_pem(bytes)) + .map_err(|e| format!("{origin} PEM key failed to parse: {e}")) +} + +fn build_from_jwk_value(jwk: &serde_json::Value) -> Result { + let parsed: jsonwebtoken::jwk::Jwk = serde_json::from_value(jwk.clone()) + .map_err(|e| format!("JWK is not well-formed: {e}"))?; + DecodingKey::from_jwk(&parsed).map_err(|e| format!("JWK not usable: {e}")) +} + +impl TrustedIssuerConfig { + /// Validate shape (non-empty issuer, at least one algorithm) + /// without resolving the key. Used at construction time as a + /// fast-fail gate so misshapen YAML is rejected before any + /// network I/O is attempted. + pub fn validate(&self) -> Result<(), String> { + if self.issuer.trim().is_empty() { + return Err("trusted_issuer.issuer must be non-empty".into()); + } + if self.algorithms.is_empty() { + return Err(format!( + "trusted_issuer '{}' must list at least one algorithm", + self.issuer + )); + } + Ok(()) + } + + /// Synchronously build a runtime `TrustedIssuer`. Works for + /// inline / on-disk `decoding_key` sources; **errors when + /// `decoding_key.kind == jwks_url`** — use [`build_async`] for + /// those. + /// + /// [`build_async`]: Self::build_async + pub fn build(self) -> Result { + self.validate()?; + let keys = self.decoding_key.build().map_err(|e| { + format!( + "trusted_issuer '{}' decoding_key build failed: {e}", + self.issuer + ) + })?; + Ok(TrustedIssuer { + issuer: self.issuer, + audiences: self.audiences, + keys: std::sync::Arc::new(std::sync::RwLock::new(keys)), + algorithms: self.algorithms, + leeway_seconds: self.leeway_seconds, + }) + } + + /// Asynchronously build a `TrustedIssuer`, handling every + /// `decoding_key` variant including `JwksUrl`. Called from + /// `JwtIdentityResolver::initialize()` for sources that deferred + /// resolution past construction. + pub async fn build_async(self) -> Result { + self.validate()?; + let keys = self.decoding_key.build_async().await.map_err(|e| { + format!( + "trusted_issuer '{}' decoding_key build failed: {e}", + self.issuer + ) + })?; + Ok(TrustedIssuer { + issuer: self.issuer, + audiences: self.audiences, + keys: std::sync::Arc::new(std::sync::RwLock::new(keys)), + algorithms: self.algorithms, + leeway_seconds: self.leeway_seconds, + }) + } +} + +/// Reject `http://` URLs for endpoints that carry trust-establishing +/// material. `https://` is always allowed; `http://` is allowed only +/// when `insecure_http` is `true`. Anything else (missing scheme, +/// data URLs, ...) returns Ok and lets the underlying parser surface +/// its own error. +fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { + let lowered = url.trim_start().to_ascii_lowercase(); + if lowered.starts_with("https://") { + return Ok(()); + } + if lowered.starts_with("http://") { + if insecure_http { + return Ok(()); + } + return Err(format!( + "JWKS URL must use https:// (got '{url}'). Set `insecure_http: true` \ + to allow plaintext for localhost/dev only — never production." + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn jwks_https_accepted() { + assert!(require_https("https://idp.example/realms/x/jwks", false).is_ok()); + } + + #[test] + fn jwks_http_rejected_by_default() { + let err = require_https("http://localhost:8081/jwks", false).unwrap_err(); + assert!(err.contains("https"), "{}", err); + assert!(err.contains("insecure_http"), "{}", err); + } + + #[test] + fn jwks_http_with_explicit_opt_in_allowed() { + assert!(require_https("http://localhost:8081/jwks", true).is_ok()); + } + + #[tokio::test] + async fn jwks_http_url_rejected_at_build_async() { + let src = DecodingKeySource::JwksUrl { + url: "http://idp.example/jwks".into(), + insecure_http: false, + refresh_secs: 3600, + }; + match src.build_async().await { + Err(e) => assert!(e.contains("https"), "{}", e), + Ok(_) => panic!("http:// JWKS URL must not build by default"), + } + } + + #[test] + fn decoding_key_source_secret_builds() { + let src = DecodingKeySource::Secret { + secret: "test-secret".into(), + }; + assert!(src.build().is_ok()); + } + + #[test] + fn decoding_key_source_pem_rejects_garbage() { + // `DecodingKey` doesn't implement Debug (it carries key + // material), so `expect_err` won't compile here — match + // the Err arm directly instead. + let src = DecodingKeySource::Pem { + pem: "not actually pem".into(), + }; + match src.build() { + Err(msg) => assert!(msg.contains("failed to parse")), + Ok(_) => panic!("garbage PEM should have failed"), + } + } + + #[test] + fn config_deserializes_from_json() { + // The shape operators write in unified-config YAML, just + // serialized as JSON for the test. + let raw = json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "audiences": ["my-api"], + "algorithms": ["HS256"], + "decoding_key": { + "kind": "secret", + "secret": "test-secret", + }, + "leeway_seconds": 30, + }], + "claim_mapper": "standard", + }); + let cfg: JwtIdentityResolverConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.trusted_issuers.len(), 1); + assert_eq!(cfg.trusted_issuers[0].issuer, "https://idp.example.com"); + assert_eq!(cfg.claim_mapper.as_deref(), Some("standard")); + } +} diff --git a/crates/apl-identity-jwt/src/factory.rs b/crates/apl-identity-jwt/src/factory.rs new file mode 100644 index 00000000..3306c4db --- /dev/null +++ b/crates/apl-identity-jwt/src/factory.rs @@ -0,0 +1,60 @@ +// Location: ./crates/apl-identity-jwt/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `PluginFactory` impl for the JWT identity resolver. Lives in this +// crate (not in any consuming integration) so that every host — +// Praxis filter, Envoy bridge, CLI test harness — wires it up the +// same way. +// +// Operators declare it in CPEX YAML as: +// +// plugins: +// - name: jwt-resolver +// kind: identity/jwt +// hooks: [identity.resolve] +// config: +// trusted_issuers: +// - issuer: https://idp.example.com +// audiences: [my-api] +// algorithms: [RS256] +// decoding_key: { kind: jwks_url, url: ... } +// +// The `kind: identity/jwt` string is part of this crate's public API. +// Hosts call `mgr.register_factory("identity/jwt", Box::new(JwtIdentityFactory))` +// before `load_config_yaml`. + +use std::sync::Arc; + +use cpex_core::{ + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + identity::{IdentityHook, HOOK_IDENTITY_RESOLVE}, + plugin::PluginConfig, +}; + +use crate::JwtIdentityResolver; + +/// The plugin `kind:` string operators write in CPEX YAML to declare +/// a JWT identity resolver. +pub const KIND: &str = "identity/jwt"; + +/// Factory for `kind: identity/jwt` plugins. Instantiates a +/// `JwtIdentityResolver` from the `config:` block and registers it on +/// the `identity.resolve` hook. +pub struct JwtIdentityFactory; + +impl PluginFactory for JwtIdentityFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let resolver = Arc::new(JwtIdentityResolver::new(config.clone())?); + let handler = Arc::new(TypedHandlerAdapter::::new(Arc::clone( + &resolver, + ))); + Ok(PluginInstance { + plugin: resolver, + handlers: vec![(HOOK_IDENTITY_RESOLVE, handler)], + }) + } +} diff --git a/crates/apl-identity-jwt/src/lib.rs b/crates/apl-identity-jwt/src/lib.rs new file mode 100644 index 00000000..2dff2158 --- /dev/null +++ b/crates/apl-identity-jwt/src/lib.rs @@ -0,0 +1,61 @@ +// Location: ./crates/apl-identity-jwt/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-identity-jwt — JWT-based `IdentityResolveHandler` for APL. +// +// Validates inbound JWTs against configured trusted issuers and +// maps validated claims into the request's `IdentityPayload` +// (subject / client / raw_credentials slots). Designed as the +// lightweight identity path that pairs with `apl-cedarling`'s +// PDP role — operators wanting both run identity here, policy +// gating through `cedarling:` steps. +// +// Sub-step A scope: data shapes + module structure only. Actual +// validation logic in sub-step B; multi-issuer + key rotation in +// sub-step C; integration tests in sub-step D. +// +// # Error handling +// +// No bespoke error type. Two surfaces: +// +// * **Build / config errors** — constructors return +// `Result>`. Bad PEM, missing issuer +// URL, etc. surface as `PluginError::Config { message }`. +// * **Runtime token-rejection errors** — handler returns +// `PluginResult::deny(PluginViolation::new(code, reason))`. +// `code` is a stable identifier the host can map to HTTP +// status (`auth.token_expired`, `auth.signature_invalid`, +// `auth.untrusted_issuer`, …); `reason` is the operator- +// readable message. +// +// # When to use this vs alternatives +// +// - **`apl-identity-jwt`** (this crate) — JWT-only flow. +// Lightweight, ~5-15 transitive deps. The default choice for +// "validate a Bearer token, extract identity." +// - **`apl-cedarling`** as identity (deferred) — Cedarling's API +// doesn't expose validated entities to callers, so we deferred +// wiring it as an IdentityResolveHandler. Use this crate for +// validation + a `cedarling:` step early in the route policy +// block if you want policy-driven identity gating. +// - **Custom resolver** — anyone with bespoke identity flows +// (mTLS-only, opaque tokens with introspection, capability +// tokens) writes their own `HookHandler`. This +// crate's API surface is the reference shape but nothing +// prevents other resolvers from coexisting. + +pub mod claim_map; +pub mod config; +pub mod factory; +pub mod resolver; +pub mod trusted_issuer; + +pub use claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; +pub use config::{ + DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig, +}; +pub use factory::{JwtIdentityFactory, KIND}; +pub use resolver::JwtIdentityResolver; +pub use trusted_issuer::TrustedIssuer; diff --git a/crates/apl-identity-jwt/src/resolver.rs b/crates/apl-identity-jwt/src/resolver.rs new file mode 100644 index 00000000..b7c517f1 --- /dev/null +++ b/crates/apl-identity-jwt/src/resolver.rs @@ -0,0 +1,834 @@ +// Location: ./crates/apl-identity-jwt/src/resolver.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `JwtIdentityResolver` — `HookHandler` that validates +// inbound JWTs and populates the request's `IdentityPayload`. +// +// # Construction +// +// Single entry point: `JwtIdentityResolver::new(cfg: PluginConfig)`. +// Reads `cfg.config` (the typed plugin-specific config field) and +// deserializes it into [`JwtIdentityResolverConfig`], builds the +// runtime `TrustedIssuer` list and the `ClaimMapper`. No alternate +// constructors that bypass the config-driven path — tests +// construct a `PluginConfig` with the right `config` value and go +// through `new` like production code does. +// +// # Runtime flow +// +// 1. Peek at the `iss` claim *without* validating to pick the +// right trusted issuer config. +// 2. Validate the token (signature + exp + nbf + aud + iss) using +// that issuer's `DecodingKey`. `iss` is re-checked here as +// defense-in-depth. +// 3. Map validated claims to a `SubjectExtension` via the +// configured claim mapper. +// 4. Stash the raw token in `RawCredentialsExtension.inbound_tokens` +// under `TokenRole::User` for forwarding plugins downstream. +// 5. Return the updated payload via `PluginResult::modify_payload`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime token rejections → `PluginResult::deny(PluginViolation::new(code, reason))`. +// Stable codes for runtime denials: +// +// * `auth.malformed_header` — JWT structure wrong / empty token +// * `auth.untrusted_issuer` — `iss` not in trusted list +// * `auth.signature_invalid` — signature failed +// * `auth.token_expired` — `exp` in the past +// * `auth.token_not_yet_valid` — `nbf` in the future +// * `auth.audience_mismatch` — `aud` didn't include any configured aud +// * `auth.algorithm_mismatch` — token uses unaccepted algo +// * `auth.mapping_failed` — claim mapper rejected the claims +// * `auth.token_invalid` — any other validation failure + +use std::sync::Arc; + +use async_trait::async_trait; +use base64::Engine; +use jsonwebtoken::{decode, Validation}; +use serde_json::Value; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use super::claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; +use super::config::{JwtIdentityResolverConfig, TrustedIssuerConfig}; +use super::trusted_issuer::{KeyStore, TrustedIssuer}; + +/// Default clock-skew tolerance, in seconds. Matches what most OIDC +/// clients use as a sane default for `exp` / `nbf`. +const DEFAULT_LEEWAY_SECONDS: u64 = 60; + +/// JWT-based identity resolver. See module docs. +/// +/// # Async key resolution +/// +/// Trusted-issuer keys come in two flavors: +/// +/// * **Inline / on-disk** (`Pem`, `PemFile`, `Jwk`, `Secret`) — built +/// eagerly during `new()`. They appear in `trusted_issuers` +/// immediately after construction. +/// * **`JwksUrl`** — deferred to `Plugin::initialize()`. The configs +/// sit in `pending_jwks` until `initialize()` runs; that hook +/// fetches all pending JWKS endpoints **concurrently** via +/// `futures::join_all` and merges the resolved issuers into the +/// `trusted_issuers` vec under the `RwLock`. +/// +/// The split keeps construction synchronous (matches the existing +/// `PluginFactory::create` trait surface across the workspace) while +/// putting the network I/O on the natural async hook the host +/// already drives via `PluginManager::initialize().await`. +#[derive(Debug)] +pub struct JwtIdentityResolver { + cfg: PluginConfig, + trusted_issuers: std::sync::RwLock>, + /// Issuer configs whose `decoding_key` is a `JwksUrl` — + /// resolved during `initialize()`. Empty in deployments with + /// only inline sources. + pending_jwks: Vec, + claim_mapper: Arc, + /// Which identity slot this resolver fills. Drives + /// `IdentityPayload` slot selection and the `TokenRole` key under + /// which the raw token gets stashed in + /// `RawCredentialsExtension.inbound_tokens`. + role: TokenRole, + /// HTTP header this resolver reads its token from + /// (e.g. `X-User-Token`). Plugins that share a request extract + /// from different headers; the value lands on + /// `RawInboundToken.source_header` so forwarding plugins know + /// where to put it (or strip it) on the upstream call. + header: String, + /// Background JWKS-refresh tasks, one per JwksUrl issuer. + /// Spawned during `initialize()`. Aborted in the resolver's + /// `Drop` impl — without that, tokio JoinHandles silently + /// detach the task and the refresh loop runs forever (until + /// the runtime shuts down or it panics). + refresh_tasks: std::sync::Mutex>>, +} + +impl JwtIdentityResolver { + /// Build a resolver from a `PluginConfig`. Reads `cfg.config` + /// (the plugin-specific config field — `Option`), + /// deserializes it into [`JwtIdentityResolverConfig`], builds + /// the runtime `TrustedIssuer` list, and resolves the claim + /// mapper by name. + /// + /// Returns `PluginError::Config` for any config-time failure: + /// missing config block, malformed JSON, no trusted issuers, + /// unparseable decoding key, unknown claim mapper, etc. + pub fn new(cfg: PluginConfig) -> Result> { + let raw_config = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt) requires a `config:` block — \ + missing trusted_issuers etc.", + cfg.name + ), + }) + })?; + + let typed: JwtIdentityResolverConfig = serde_json::from_value(raw_config.clone()) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt) config parse failed: {e}", + cfg.name + ), + }) + })?; + + if typed.trusted_issuers.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt) requires at least one \ + entry in `trusted_issuers`", + cfg.name + ), + })); + } + + // Partition issuer configs: + // * Inline / on-disk decoding keys (Pem, PemFile, Jwk, + // Secret) → eagerly built into TrustedIssuers here. + // * JwksUrl decoding keys → deferred to initialize() so + // the host's PluginManager can drive the HTTP fetches + // concurrently across all resolvers. + let mut trusted_issuers: Vec = Vec::new(); + let mut pending_jwks: Vec = Vec::new(); + for raw in typed.trusted_issuers { + // Validate shape eagerly so bad YAML fails at load_config + // rather than at the async initialize() boundary. + raw.validate().map_err(|e| { + Box::new(PluginError::Config { + message: format!("plugin '{}' (apl-identity-jwt): {e}", cfg.name), + }) + })?; + if raw.decoding_key.needs_async() { + pending_jwks.push(raw); + } else { + let built = raw.build().map_err(|e| { + Box::new(PluginError::Config { + message: format!("plugin '{}' (apl-identity-jwt): {e}", cfg.name), + }) + })?; + trusted_issuers.push(built); + } + } + + // Resolve the claim mapper by name. Unknown names are a + // config error rather than a silent fallback — fail fast + // so operators notice typos. + let claim_mapper: Arc = match typed.claim_mapper.as_deref() { + None | Some("standard") => Arc::new(StandardClaimMap), + Some(other) => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt): unknown claim_mapper \ + '{other}'; valid: [standard]", + cfg.name + ), + })); + } + }; + + // Reject `role: Custom(...)` at construction — the framework + // has slots for User / Client / Workload (the three named + // entries on SecurityExtension). Custom roles would write to + // `inbound_tokens` only, with no SecurityExtension home, so + // downstream `subject.*` / `client.*` predicates wouldn't see + // them. If we ever want custom slots, that's its own slice. + if matches!(typed.role, TokenRole::Custom(_)) { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt): role: Custom(...) is not \ + yet supported — pick one of `user`, `client`, `workload`", + cfg.name + ), + })); + } + if typed.header.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt): `header:` must be a \ + non-empty HTTP header name", + cfg.name + ), + })); + } + + Ok(Self { + cfg, + trusted_issuers: std::sync::RwLock::new(trusted_issuers), + pending_jwks, + claim_mapper, + role: typed.role, + header: typed.header, + refresh_tasks: std::sync::Mutex::new(Vec::new()), + }) + } +} + +impl Drop for JwtIdentityResolver { + /// Stop every background refresh task when the resolver drops. + /// Without this, `tokio::task::JoinHandle` *detaches* on drop + /// — the refresh loop keeps running until the tokio runtime + /// shuts down. That's harmless for the program-lifetime + /// singleton case but creates orphan tasks during plugin + /// hot-reload or in tests that construct/discard resolvers + /// repeatedly. + fn drop(&mut self) { + let mut tasks = match self.refresh_tasks.lock() { + Ok(t) => t, + Err(poisoned) => poisoned.into_inner(), + }; + for handle in tasks.drain(..) { + handle.abort(); + } + } +} + +#[async_trait] +impl Plugin for JwtIdentityResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } + + /// Resolve any `JwksUrl` decoding keys deferred at construction, + /// then spawn a background task per JwksUrl issuer to refresh + /// the KeyStore on a periodic schedule (default 10 min, + /// configurable per-issuer via `refresh_secs`). + /// + /// **Soft-fail semantics (Slice B):** an unreachable / slow / + /// malformed JWKS at startup logs a warning and leaves the + /// issuer's KeyStore *empty*. The plugin still loads, the + /// gateway still boots, and the background refresh task gets + /// spawned anyway — so a transient IdP outage during boot + /// recovers on its own as soon as refresh succeeds. Verify-time + /// requests against an issuer with an empty KeyStore receive + /// `auth.jwks_unavailable` rather than crashing the request. + /// + /// Initial fetches happen concurrently — N pending issuers + /// → one `join_all`, not N sequential round-trips — so the + /// time-to-ready scales with the slowest IdP, not the sum. + /// + /// The `PluginManager` drives this once per plugin lifetime + /// (before any hooks fire). Idempotent: if `pending_jwks` is + /// empty (no JwksUrl sources) this is a free no-op. + async fn initialize(&self) -> Result<(), Box> { + if self.pending_jwks.is_empty() { + return Ok(()); + } + + // 1. Initial concurrent fetch. Each result is (config, + // outcome) — we keep the config alongside the result + // so the soft-fail path can construct an empty + // KeyStore *and* still spawn refresh for that issuer. + let fetches = self.pending_jwks.iter().cloned().map(|cfg| async move { + let outcome = cfg.clone().build_async().await; + (cfg, outcome) + }); + let resolved: Vec<(TrustedIssuerConfig, Result)> = + futures::future::join_all(fetches).await; + + let mut issuers = self + .trusted_issuers + .write() + .unwrap_or_else(|p| p.into_inner()); + let mut new_tasks: Vec> = Vec::new(); + + for (cfg, outcome) in resolved { + // Get the shared store: from the successful fetch's + // TrustedIssuer if we have one, else an empty store + // bound to a freshly-constructed TrustedIssuer shell. + // Either way we end up with one TrustedIssuer in + // `issuers` and a clone of its `Arc>` + // captured by the refresh task. + let (shared, plugin_name) = (self.cfg.name.clone(), cfg.issuer.clone()); + let issuer = match outcome { + Ok(iss) => iss, + Err(e) => { + tracing::warn!( + plugin = %shared, + issuer = %plugin_name, + error = %e, + "initial JWKS fetch failed; soft-fail. Verify requests \ + against this issuer will receive auth.jwks_unavailable \ + until refresh succeeds." + ); + // Build a TrustedIssuer with an empty KeyStore + // so the refresh task can swap a fresh store in + // without re-running validation logic. + TrustedIssuer { + issuer: cfg.issuer.clone(), + audiences: cfg.audiences.clone(), + keys: Arc::new(std::sync::RwLock::new(KeyStore::empty())), + algorithms: cfg.algorithms.clone(), + leeway_seconds: cfg.leeway_seconds, + } + } + }; + + // Spawn refresh task. The closure owns: + // - a clone of the source (cfg.decoding_key) for + // re-fetching + // - a clone of the Arc> for atomic + // whole-store replacement on success + // - plugin / issuer names for diagnostic logging + if let Some(interval) = cfg.decoding_key.refresh_interval() { + let source = cfg.decoding_key.clone(); + let shared_store = Arc::clone(&issuer.keys); + let plugin_label = self.cfg.name.clone(); + let issuer_label = cfg.issuer.clone(); + let handle = tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // Skip the first immediate tick — the initial + // fetch already ran synchronously above. The + // first refresh fires at `now + interval`. + ticker.tick().await; + loop { + ticker.tick().await; + match source.build_async().await { + Ok(new_store) => { + // Whole-store replacement. The + // old store drops when the write + // completes — bounded steady-state + // memory regardless of how many + // rotations have happened. + match shared_store.write() { + Ok(mut g) => *g = new_store, + Err(poisoned) => *poisoned.into_inner() = new_store, + } + tracing::info!( + plugin = %plugin_label, + issuer = %issuer_label, + "JWKS refresh succeeded" + ); + } + Err(e) => { + tracing::warn!( + plugin = %plugin_label, + issuer = %issuer_label, + error = %e, + "JWKS refresh failed; keeping previous KeyStore" + ); + } + } + } + }); + new_tasks.push(handle); + } + + issuers.push(issuer); + } + + // Park the handles so Drop can abort them. Held under a + // std::sync::Mutex because the resolver's outer methods are + // a mix of sync and async; we don't await while holding it. + let mut tasks = self + .refresh_tasks + .lock() + .unwrap_or_else(|p| p.into_inner()); + tasks.extend(new_tasks); + + Ok(()) + } +} + +impl HookHandler for JwtIdentityResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Read OUR configured header from the request's full header + // map. HTTP headers are case-insensitive (RFC 7230 §3.2); + // we lowercase the configured name to match the canonical + // form hosts use when populating the map. Fall back to + // `payload.raw_token()` only when no header map is populated + // — covers single-resolver back-compat for hosts that still + // pre-extract one token. + let header_lc = self.header.to_ascii_lowercase(); + let header_value = payload.headers().get(header_lc.as_str()); + let raw_token: String = match header_value { + Some(v) => v.strip_prefix("Bearer ").unwrap_or(v).to_string(), + None if !payload.raw_token().is_empty() => payload.raw_token().to_string(), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.malformed_header", + format!( + "header '{}' missing from request (resolver '{}' / role '{:?}')", + self.header, self.cfg.name, self.role + ), + )); + } + }; + if raw_token.is_empty() { + return PluginResult::deny(PluginViolation::new( + "auth.malformed_header", + format!("header '{}' is present but empty", self.header), + )); + } + + // 1. Peek at `iss` to find the matching TrustedIssuer config. + let iss = match peek_issuer(&raw_token) { + Some(iss) => iss, + None => { + return PluginResult::deny(PluginViolation::new( + "auth.malformed_header", + "JWT not well-formed or missing `iss` claim", + )); + } + }; + // Read-lock the issuer list. After `initialize()` it's + // immutable for the resolver's lifetime; reads are cheap. + // Recover from a poisoned lock (a panic somewhere else + // while holding the write lock) — the data is still valid. + let issuers = self + .trusted_issuers + .read() + .unwrap_or_else(|p| p.into_inner()); + let issuer = match issuers.iter().find(|i| i.issuer == iss) { + Some(i) => i, + None => { + return PluginResult::deny(PluginViolation::new( + "auth.untrusted_issuer", + format!("issuer '{iss}' is not in the trusted-issuer list"), + )); + } + }; + + // 2. Validate signature + standard claims, after kid-driven + // key selection. Three distinct deny codes so operators + // can tell: + // - rotation lag (`auth.unknown_kid`): the IdP rolled + // and our refresh hasn't yet pulled the new key. + // - JWKS-unavailable (`auth.jwks_unavailable`): the + // initial fetch failed and refresh hasn't recovered + // — the gateway didn't crash by design, but it + // also can't verify tokens for this issuer right now. + // - forgery / corruption (`auth.signature_invalid` and + // friends): the standard jsonwebtoken outcomes. + let token_data = match validate_token(&raw_token, issuer) { + Ok(td) => td, + Err(ValidateError::KeysUnavailable) => { + return PluginResult::deny(PluginViolation::new( + "auth.jwks_unavailable", + format!( + "issuer '{iss}' has no signing keys available — \ + initial JWKS fetch failed and refresh has not \ + yet succeeded; check upstream IdP reachability" + ), + )); + } + Err(ValidateError::UnknownKid(kid)) => { + let reason = match kid { + Some(k) => format!( + "token's header `kid` = '{k}' did not match any key in issuer's JWKS" + ), + None => "token has no `kid` header; issuer's JWKS keys all require kid match" + .to_string(), + }; + return PluginResult::deny(PluginViolation::new("auth.unknown_kid", reason)); + } + Err(ValidateError::Jwt(e)) => { + let (code, reason) = classify_jwt_error(&e); + return PluginResult::deny(PluginViolation::new(code, reason)); + } + }; + + // 3. Build the updated payload by mapping claims into the + // typed slot for our configured role. + let mut updated = payload.clone(); + match &self.role { + TokenRole::User => match self.claim_mapper.map_subject(&token_data.claims) { + Some(s) => updated.subject = Some(s), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.mapping_failed", + "claim mapper produced no subject — required `sub` \ + claim missing or wrong shape", + )); + } + }, + TokenRole::Client => match self.claim_mapper.map_client(&token_data.claims) { + Some(c) => updated.client = Some(c), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.mapping_failed", + "claim mapper produced no client — required `client_id` \ + / `azp` claim missing", + )); + } + }, + TokenRole::Workload => match self.claim_mapper.map_workload(&token_data.claims) { + Some(w) => updated.caller_workload = Some(w), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.mapping_failed", + "claim mapper produced no workload — token doesn't look \ + like a SPIFFE-JWT-SVID (sub doesn't start with `spiffe://`)", + )); + } + }, + TokenRole::Custom(_) => { + // Filtered out at construction; defense in depth. + return PluginResult::deny(PluginViolation::new( + "auth.misconfigured", + "role: Custom(...) is not supported", + )); + } + // TokenRole is #[non_exhaustive]; future variants must be + // explicitly handled. Until then, treat unknown roles the + // same as Custom — surface as misconfigured rather than + // silently dropping the token. + _ => { + return PluginResult::deny(PluginViolation::new( + "auth.misconfigured", + "unsupported TokenRole variant", + )); + } + } + + // 4. Stash the raw token for forwarding plugins. Key the + // stash by the resolver's configured role so multi-token + // deployments (user + client + workload) keep each + // credential addressable. + let mut raw_creds = updated + .raw_credentials + .clone() + .unwrap_or_else(RawCredentialsExtension::default); + raw_creds.inbound_tokens.insert( + self.role.clone(), + RawInboundToken::new(raw_token, self.header.clone(), TokenKind::Jwt), + ); + updated.raw_credentials = Some(raw_creds); + updated.resolved_at = Some(chrono::Utc::now()); + // Pass the full claim map through `raw_claims` so audit / + // downstream policy that wants uncategorized claims has them. + // For multi-resolver chains, the last resolver wins; if + // operators need per-role raw claims they should read from + // the typed slots (subject.claims / client.claims) instead. + updated.raw_claims = token_data.claims; + + PluginResult::modify_payload(updated) + } +} + +// ===================================================================== +// Internal helpers +// ===================================================================== + +/// Pull the `iss` claim out of a JWT *without* verifying the +/// signature. Used purely to look up which trusted issuer config +/// to validate against next. +/// +/// **Security note:** the value returned here is untrusted until +/// the subsequent validation pass succeeds. We use it only to +/// select the right `DecodingKey`; validation re-enforces `iss` +/// against the matched config. +fn peek_issuer(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .ok()?; + let value: Value = serde_json::from_slice(&payload_bytes).ok()?; + value.get("iss")?.as_str().map(String::from) +} + +/// Reason `validate_token` couldn't verify the JWT. Wraps the +/// usual `jsonwebtoken::errors::Error` plus the kid-selection +/// and JWKS-availability cases introduced by Slice A / B. +enum ValidateError { + /// The JWT's header `kid` didn't match any key the issuer's + /// KeyStore knows about. Distinct from `InvalidSignature` so + /// the verify path can surface `auth.unknown_kid` with the + /// specific kid that was missing — operators can match this + /// against their IdP's currently-published JWKS to confirm + /// rotation propagated. + UnknownKid(Option), + /// The issuer's KeyStore is empty: initial JWKS fetch failed + /// at `initialize()`, refresh task hasn't yet succeeded. The + /// gateway didn't crash (soft-fail by design), but it also + /// can't verify any token from this issuer until refresh + /// catches up. Surfaces as `auth.jwks_unavailable` so + /// operators see "JWKS issue at IdP X" rather than the more + /// alarming `auth.signature_invalid` they'd see if we + /// silently fell back to e.g. an empty key. + KeysUnavailable, + /// jsonwebtoken's own validation outcome (signature, exp, + /// nbf, iss, aud, algorithm). + Jwt(jsonwebtoken::errors::Error), +} + +/// Validate the token against the matched issuer's config: +/// `kid`-driven key selection, then signature, exp, nbf, aud, iss. +/// +/// Two-step lookup: +/// 1. Decode just the JWT header (no signature check yet) to +/// read the `kid` claim. We don't trust the result for +/// authorization decisions — we use it only to pick a +/// candidate key from the issuer's `KeyStore`. +/// 2. If a key is found, run jsonwebtoken's full validation +/// against it. Failure modes (bad sig, expired, etc.) flow +/// through unchanged. +/// 3. If no key matches, return `UnknownKid` — distinct from +/// `InvalidSignature` so operators can tell rotation lag +/// from a forgery attempt at the audit layer. +fn validate_token( + token: &str, + issuer: &TrustedIssuer, +) -> Result, ValidateError> { + let header = jsonwebtoken::decode_header(token).map_err(ValidateError::Jwt)?; + let kid = header.kid.as_deref(); + + // Acquire a read guard on the issuer's KeyStore. The guard is + // held for the duration of `decode()` below — sync, no .await + // between acquire and release, so no risk of deadlock against + // the refresh task's write lock. Refresh writes block until + // outstanding readers release; a verify in flight when refresh + // fires waits a few µs at most. + let keys = issuer + .keys + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if keys.is_empty() { + return Err(ValidateError::KeysUnavailable); + } + + let key = match keys.select(kid) { + Some(k) => k, + None => return Err(ValidateError::UnknownKid(kid.map(String::from))), + }; + + let primary = issuer.algorithms[0]; + let mut validation = Validation::new(primary); + validation.algorithms = issuer.algorithms.clone(); + validation.set_issuer(&[&issuer.issuer]); + validation.leeway = if issuer.leeway_seconds == 0 { + DEFAULT_LEEWAY_SECONDS + } else { + issuer.leeway_seconds + }; + if issuer.audiences.is_empty() { + validation.validate_aud = false; + } else { + let aud_refs: Vec<&str> = issuer.audiences.iter().map(String::as_str).collect(); + validation.set_audience(&aud_refs); + } + decode::(token, key, &validation).map_err(ValidateError::Jwt) +} + +/// Map jsonwebtoken errors to stable violation codes. +fn classify_jwt_error(e: &jsonwebtoken::errors::Error) -> (&'static str, String) { + use jsonwebtoken::errors::ErrorKind; + let code = match e.kind() { + ErrorKind::ExpiredSignature => "auth.token_expired", + ErrorKind::InvalidSignature => "auth.signature_invalid", + ErrorKind::ImmatureSignature => "auth.token_not_yet_valid", + ErrorKind::InvalidAudience => "auth.audience_mismatch", + ErrorKind::InvalidIssuer => "auth.untrusted_issuer", + ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => { + "auth.algorithm_mismatch" + } + ErrorKind::Base64(_) | ErrorKind::Json(_) => "auth.malformed_header", + _ => "auth.token_invalid", + }; + (code, e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use serde_json::json; + + fn jwt_with_payload(payload_json: &str) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + let sig = URL_SAFE_NO_PAD.encode(b"fake-signature"); + format!("{header}.{payload}.{sig}") + } + + fn cfg_with_config(name: &str, config: Value) -> PluginConfig { + PluginConfig { + name: name.into(), + config: Some(config), + ..Default::default() + } + } + + #[test] + fn new_rejects_missing_config_block() { + let cfg = PluginConfig { + name: "jwt".into(), + config: None, + ..Default::default() + }; + let err = JwtIdentityResolver::new(cfg).expect_err("missing config should fail"); + assert!(format!("{err}").contains("config")); + } + + #[test] + fn new_rejects_empty_trusted_issuers() { + let cfg = cfg_with_config("jwt", json!({ "trusted_issuers": [] })); + let err = JwtIdentityResolver::new(cfg) + .expect_err("empty trusted_issuers should fail"); + assert!(format!("{err}").contains("trusted_issuers")); + } + + #[test] + fn new_rejects_unknown_claim_mapper() { + let cfg = cfg_with_config( + "jwt", + json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "algorithms": ["HS256"], + "decoding_key": { "kind": "secret", "secret": "x" }, + }], + "claim_mapper": "made-up-mapper", + }), + ); + let err = JwtIdentityResolver::new(cfg) + .expect_err("unknown mapper should fail"); + assert!(format!("{err}").contains("claim_mapper")); + } + + #[test] + fn new_accepts_well_formed_config() { + let cfg = cfg_with_config( + "jwt", + json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "audiences": ["my-api"], + "algorithms": ["HS256"], + "decoding_key": { "kind": "secret", "secret": "test-secret" }, + "leeway_seconds": 30, + }], + "claim_mapper": "standard", + }), + ); + let resolver = JwtIdentityResolver::new(cfg).expect("should construct"); + let issuers = resolver.trusted_issuers.read().unwrap(); + assert_eq!(issuers.len(), 1); + assert_eq!(issuers[0].issuer, "https://idp.example.com"); + // Secret source resolves eagerly — no pending JWKS work. + assert!(resolver.pending_jwks.is_empty()); + } + + #[test] + fn peek_issuer_extracts_iss() { + let token = jwt_with_payload(r#"{"sub":"alice","iss":"https://idp.example.com"}"#); + assert_eq!( + peek_issuer(&token), + Some("https://idp.example.com".to_string()), + ); + } + + #[test] + fn peek_issuer_returns_none_for_malformed_token() { + assert!(peek_issuer("not.a-jwt").is_none()); + assert!(peek_issuer("a.b.c.d").is_none()); + assert!(peek_issuer("").is_none()); + } + + #[test] + fn peek_issuer_returns_none_when_iss_missing() { + let token = jwt_with_payload(r#"{"sub":"alice"}"#); + assert!(peek_issuer(&token).is_none()); + } + + #[test] + fn classify_picks_expected_codes() { + use jsonwebtoken::errors::{Error, ErrorKind}; + let cases = [ + (ErrorKind::ExpiredSignature, "auth.token_expired"), + (ErrorKind::InvalidSignature, "auth.signature_invalid"), + (ErrorKind::ImmatureSignature, "auth.token_not_yet_valid"), + (ErrorKind::InvalidAudience, "auth.audience_mismatch"), + (ErrorKind::InvalidIssuer, "auth.untrusted_issuer"), + ]; + for (kind, expected_code) in cases { + let err = Error::from(kind); + let (code, _reason) = classify_jwt_error(&err); + assert_eq!(code, expected_code); + } + } +} diff --git a/crates/apl-identity-jwt/src/trusted_issuer.rs b/crates/apl-identity-jwt/src/trusted_issuer.rs new file mode 100644 index 00000000..a5acf6d4 --- /dev/null +++ b/crates/apl-identity-jwt/src/trusted_issuer.rs @@ -0,0 +1,198 @@ +// Location: ./crates/apl-identity-jwt/src/trusted_issuer.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `TrustedIssuer` — config for one OIDC issuer the resolver trusts, +// plus the `KeyStore` that holds its (possibly-multiple) JWKS keys +// indexed by `kid` for token-header-driven key selection. + +use std::collections::HashMap; + +use jsonwebtoken::{Algorithm, DecodingKey}; + +/// A bundle of decoding keys for one trust anchor, supporting +/// `kid`-driven selection at verify time. +/// +/// JWKS endpoints commonly publish more than one key (rotation grace +/// windows, multi-algo deployments). The standard OIDC pattern is +/// for each token to declare which `kid` it was signed with in its +/// header; verifiers select the matching key from the JWKS rather +/// than picking the first-listed entry and hoping. +/// +/// Two slots: +/// - `by_kid`: keys with a JWKS-declared `kid`. The verify path +/// looks here first using the inbound token's header `kid`. +/// - `fallback`: a single key for the kid-less case. Populated +/// for inline sources (`Pem`/`PemFile`/`Jwk`/`Secret`) which +/// have no JWKS context. JWKS-sourced KeyStores leave this +/// `None` — every JWKS key carries a `kid` by spec. +/// +/// A KeyStore with no entries at all (`by_kid.is_empty() && fallback.is_none()`) +/// is a valid runtime state — it represents "JWKS fetch failed, +/// retry pending" in the soft-fail design (Slice B). Today every +/// construction path populates at least one slot before the store +/// is reachable from the resolver. +/// +/// # Update discipline (Slice B refresh) +/// +/// When the periodic refresh task lands, the intended pattern is +/// **whole-store replacement** — the refresh fetches a fresh JWKS, +/// builds a new `KeyStore`, and replaces the old one atomically +/// (`*shared.write().await = new_store`). Do **not** merge new +/// keys into the existing `by_kid` map: that grows unbounded as +/// the IdP rotates kids in and out over the deployment's lifetime +/// (every kid the IdP ever published stays in our map forever). +/// Whole-store replacement bounds the live key count to the +/// IdP's current JWKS size and lets dropped DecodingKeys release. +/// `RwLock` semantics make this race-free: in-flight verifies +/// holding `&DecodingKey` keep the old store alive until they +/// release, at which point the swap completes and the old store +/// drops. +pub struct KeyStore { + by_kid: HashMap, + fallback: Option, +} + +impl KeyStore { + /// Empty store. Only useful for the soft-fail placeholder path + /// (Slice B); current code always populates before exposing. + pub fn empty() -> Self { + Self { + by_kid: HashMap::new(), + fallback: None, + } + } + + /// Single-key store with no `kid`. Used by inline sources (Pem, + /// PemFile, Jwk, Secret) — they have no JWKS context to provide + /// a kid, so the key serves every token regardless of header. + pub fn single_fallback(key: DecodingKey) -> Self { + Self { + by_kid: HashMap::new(), + fallback: Some(key), + } + } + + /// Construct from a JWKS — every key gets indexed by its `kid`. + /// JWKS entries without a `kid` are silently dropped (the OIDC + /// spec requires them to carry one; an entry missing `kid` is + /// an IdP misconfiguration we'd rather surface as + /// `auth.unknown_kid` at verify time than as a silent + /// fallback-wins behaviour). + pub fn from_jwks_entries(entries: I) -> Self + where + I: IntoIterator, + { + Self { + by_kid: entries.into_iter().collect(), + fallback: None, + } + } + + /// Look up the key for a token's header `kid`. Returns: + /// - the matching kid'd key if `kid` is Some and present + /// - the fallback if `kid` is None and a fallback exists + /// - None otherwise (caller surfaces `auth.unknown_kid`) + /// + /// Deliberately does NOT silently fall back to `fallback` when + /// a kid'd lookup misses. With both behaviours mixed, an + /// attacker who controls JWKS body order could downgrade a + /// kid'd token to a fallback key. The kid'ed lookup is exact; + /// only kid-absent tokens may use the fallback. + pub fn select(&self, kid: Option<&str>) -> Option<&DecodingKey> { + match kid { + Some(k) => self.by_kid.get(k), + None => self.fallback.as_ref(), + } + } + + /// Diagnostic: how many keys this store knows about. Used in + /// log lines and the `Debug` impl below; not for control flow. + pub fn len(&self) -> usize { + self.by_kid.len() + usize::from(self.fallback.is_some()) + } + + /// Whether the store has any usable key. False only on the + /// Slice-B soft-fail placeholder path. + pub fn is_empty(&self) -> bool { + self.by_kid.is_empty() && self.fallback.is_none() + } +} + +// `DecodingKey` doesn't derive Debug (it carries key bytes; the lib +// avoids accidental log leakage). We elide every key value; only +// the count and kid set surface. +impl std::fmt::Debug for KeyStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let kids: Vec<&str> = self.by_kid.keys().map(String::as_str).collect(); + f.debug_struct("KeyStore") + .field("kids", &kids) + .field("has_fallback", &self.fallback.is_some()) + .finish() + } +} + +/// One issuer's trust config — `iss` value to match against, +/// audience to require, decoding key(s), and acceptable algorithms. +/// +/// Deployments with multiple IdPs construct one of these per IdP +/// and hand the list to `JwtIdentityResolver::new`. The resolver +/// picks the matching issuer based on the inbound token's `iss` +/// claim. +#[non_exhaustive] +pub struct TrustedIssuer { + /// Expected `iss` claim value — the resolver rejects tokens + /// whose `iss` doesn't match. + pub issuer: String, + + /// Expected audience(s). Tokens must carry at least one matching + /// `aud` value. Empty vec means "don't check audience" + /// (only acceptable for trusted-internal flows). + pub audiences: Vec, + + /// Decoding keys for this issuer, indexed by `kid`. For inline + /// sources (Pem/Jwk/Secret) this is a single-entry store with + /// no kid; for JWKS sources every advertised signature key + /// lands here so the verify path can pick the one matching the + /// inbound token's header. + /// + /// Wrapped in `Arc>` so the background JWKS + /// refresh task can atomically swap in a fresh KeyStore + /// without blocking concurrent verifies (read guards are held + /// for the duration of one `decode()`, which is sync — no + /// `.await` between acquisition and release, so no deadlock + /// risk and no contention beyond a few µs per request). + /// + /// Empty during the soft-fail boot path (initial JWKS fetch + /// failed, refresh task will retry). Verify checks for this + /// and returns `auth.jwks_unavailable` rather than the + /// `auth.unknown_kid` it would otherwise produce. + pub keys: std::sync::Arc>, + + /// Algorithms accepted for signature verification. Most + /// deployments stick to one (RS256 most commonly), but + /// supporting multiple lets the IdP rotate to a new algo + /// without us redeploying. + pub algorithms: Vec, + + /// Clock-skew tolerance for `exp` / `nbf` claims, in seconds. + /// Defaults applied in `JwtIdentityResolver::new`. + pub leeway_seconds: u64, +} + +// Manual `Debug` impl — `jsonwebtoken::DecodingKey` doesn't derive +// `Debug` (presumably to avoid leaking key material into logs). +// We elide the key entirely; the issuer URL + algorithms are +// enough for diagnostic output. +impl std::fmt::Debug for TrustedIssuer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrustedIssuer") + .field("issuer", &self.issuer) + .field("audiences", &self.audiences) + .field("algorithms", &self.algorithms) + .field("leeway_seconds", &self.leeway_seconds) + .field("keys", &self.keys) + .finish() + } +} diff --git a/crates/apl-identity-jwt/tests/jwks_url_e2e.rs b/crates/apl-identity-jwt/tests/jwks_url_e2e.rs new file mode 100644 index 00000000..b06e4518 --- /dev/null +++ b/crates/apl-identity-jwt/tests/jwks_url_e2e.rs @@ -0,0 +1,750 @@ +// Location: ./crates/apl-identity-jwt/tests/jwks_url_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for `DecodingKeySource::JwksUrl` + the async +// resolution path: +// +// 1. Construct a JwtIdentityResolver with `decoding_key.kind: +// jwks_url` pointing at a mockito server. The resolver carries +// the issuer config in `pending_jwks`; `trusted_issuers` is +// empty (no inline keys). +// 2. Call `plugin.initialize().await` — this is the async hook the +// host's `PluginManager::initialize()` drives. It triggers the +// JWKS HTTP fetch. +// 3. Mint a JWT with the corresponding private key, hand it to the +// resolver, assert the subject is populated. Proves the +// fetched JWKS key was wired into the trusted-issuer list. +// +// Also covers: missing-initialize sad path (the resolver returns +// `untrusted_issuer` because the JwksUrl-deferred issuer never made +// it into `trusted_issuers`). + +use std::sync::Arc; + +use cpex_core::hooks::payload::Extensions; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_identity_jwt::{DecodingKeySource, JwtIdentityResolver}; + +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use mockito::Server; +use rsa::pkcs1::EncodeRsaPublicKey; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +use rsa::traits::PublicKeyParts; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde_json::{json, Value}; + +const ISS: &str = "https://idp.test.local"; +const AUD: &str = "test-api"; + +/// Build a JWKS JSON document from a single RSA public key. The +/// `kid` is fixed and the key declares `use=sig, alg=RS256` so the +/// resolver picks it via the "first signing-use key" rule. +fn build_jwks(public: &RsaPublicKey) -> Value { + use base64::Engine; + let n_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(public.n().to_bytes_be()); + let e_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(public.e().to_bytes_be()); + json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "test-key-1", + "n": n_b64, + "e": e_b64, + }] + }) +} + +fn mint_jwt(private_pem: &str, claims: Value) -> String { + // Set `kid` so the resolver's KeyStore lookup hits — the JWKS + // entry exposed by the mock server uses the same kid value + // ("test-key-1", see `jwks_body`). + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("test-key-1".into()); + let key = EncodingKey::from_rsa_pem(private_pem.as_bytes()) + .expect("build EncodingKey from RSA PEM"); + encode(&header, &claims, &key).expect("sign JWT") +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +fn resolver_config(jwks_url: &str) -> PluginConfig { + PluginConfig { + name: "jwt-via-jwks".into(), + kind: "test".into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "role": "user", + "header": "Authorization", + "trusted_issuers": [{ + "issuer": ISS, + "audiences": [AUD], + "algorithms": ["RS256"], + // mockito serves over http://127.0.0.1 — opt in to + // plaintext for this test. Production deployments + // must omit `insecure_http`. + "decoding_key": { "kind": "jwks_url", "url": jwks_url, "insecure_http": true }, + "leeway_seconds": 60, + }], + "claim_mapper": "standard", + })), + ..Default::default() + } +} + +/// Verify that a JWT signed by the JWKS-published key validates +/// after `initialize()` resolves the JWKS URL. +#[tokio::test(flavor = "multi_thread")] +async fn initialize_fetches_jwks_and_validates_token() { + // 1. Generate a keypair and serve its public key as a JWKS. + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + let jwks_body = build_jwks(&pub_key).to_string(); + // Suppress unused-import warning on EncodeRsaPublicKey — only + // exists to keep the trait in scope for callers that want + // alternate PEM exports. + let _ = pub_key.to_pkcs1_pem(LineEnding::LF); + + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(jwks_body) + .expect(1) + .create_async() + .await; + + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + // 2. Build the resolver. JwksUrl source → trusted_issuers is + // empty until initialize() runs. + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + + // 3. Wire into a PluginManager and call initialize. The + // manager's initialize() drives plugin.initialize(), which + // triggers the async JWKS fetch. + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize succeeds"); + + // 4. Mint a JWT, dispatch, assert subject populated. + let token = mint_jwt( + &priv_pem, + json!({ + "sub": "alice@corp.com", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + "roles": ["hr"], + }), + ); + + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".to_string(), format!("Bearer {token}")); + + let payload = IdentityPayload::new(token.clone(), TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + + let (result, _bg) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!( + result.continue_processing, + "valid JWT (JWKS-resolved key) should pass: violation = {:?}", + result.violation + ); + let identity = + IdentityPayload::from_pipeline_result(&result).expect("identity payload present"); + let subject = identity.subject.as_ref().expect("subject populated"); + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); + assert!(subject.roles.contains("hr")); + + // 5. The mock recorded one (and only one) GET — proves we did + // a real network fetch. + mock.assert_async().await; +} + +/// Without `initialize()`, the issuer config sits in `pending_jwks` +/// and `trusted_issuers` is empty — a token signed by the JWKS key +/// gets `auth.untrusted_issuer` rather than silently passing. This +/// is the deliberate fail-loud mode: hosts must call +/// `PluginManager::initialize()`. +#[tokio::test(flavor = "multi_thread")] +async fn skipping_initialize_rejects_with_untrusted_issuer() { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(build_jwks(&pub_key).to_string()) + // We expect ZERO calls — the test never calls initialize. + .expect(0) + .create_async() + .await; + + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + // Deliberately SKIP mgr.initialize() — we want to prove the + // pending JwksUrl issuer never made it into trusted_issuers. + + let token = mint_jwt( + &priv_pem, + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".to_string(), format!("Bearer {token}")); + + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _bg) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!( + !result.continue_processing, + "no initialize() should yield deny (JWKS issuer never wired)", + ); + let v = result.violation.expect("violation should be reported"); + assert_eq!(v.code, "auth.untrusted_issuer"); +} + +// ===================================================================== +// P0-5 Slice A: kid-based key selection + JWKS fetch timeout +// ===================================================================== + +/// Build a JWKS containing two RSA keys with distinct `kid`s. Used by +/// the rotation / kid-selection tests below to prove the resolver +/// picks the key matching the inbound token's header, not the first +/// listed. +fn build_jwks_two_keys( + pub_a: &RsaPublicKey, + kid_a: &str, + pub_b: &RsaPublicKey, + kid_b: &str, +) -> Value { + use base64::Engine; + let make_entry = |k: &RsaPublicKey, kid: &str| { + json!({ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(k.n().to_bytes_be()), + "e": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(k.e().to_bytes_be()), + }) + }; + json!({ + "keys": [ + make_entry(pub_a, kid_a), + make_entry(pub_b, kid_b), + ] + }) +} + +/// Mint a JWT with a specific `kid` in the header. Distinct from +/// `mint_jwt` (which uses the default test kid) so the kid-selection +/// tests can control which key the resolver should select. +fn mint_jwt_with_kid(private_pem: &str, kid: &str, claims: Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.into()); + let key = EncodingKey::from_rsa_pem(private_pem.as_bytes()) + .expect("build EncodingKey from RSA PEM"); + encode(&header, &claims, &key).expect("sign JWT") +} + +/// JWKS publishes two keys with distinct kids. A token signed by +/// key B with header `kid=key-b` must validate against key B, not +/// against the first-listed key A. Pre-Slice-A code would pick the +/// first key (A) and reject the valid token as signature_invalid. +#[tokio::test(flavor = "multi_thread")] +async fn kid_selects_correct_key_when_jwks_has_multiple() { + let mut rng = rand::thread_rng(); + let priv_a = RsaPrivateKey::new(&mut rng, 2048).expect("rsa a"); + let priv_b = RsaPrivateKey::new(&mut rng, 2048).expect("rsa b"); + let pub_a = RsaPublicKey::from(&priv_a); + let pub_b = RsaPublicKey::from(&priv_b); + let priv_pem_b = priv_b + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM b") + .to_string(); + + let jwks_body = build_jwks_two_keys(&pub_a, "key-a", &pub_b, "key-b").to_string(); + + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(jwks_body) + .create_async() + .await; + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize"); + + // Token signed by B, with kid=key-b. The resolver must select + // key B from the JWKS (not first-listed key A). + let token = mint_jwt_with_kid( + &priv_pem_b, + "key-b", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!( + result.continue_processing, + "kid-matched token must verify: violation = {:?}", + result.violation, + ); +} + +/// Token's `kid` header doesn't match any key the JWKS knows about. +/// Must yield `auth.unknown_kid` — distinct from +/// `auth.signature_invalid` so operators can tell rotation lag +/// from forgery at the audit layer. +#[tokio::test(flavor = "multi_thread")] +async fn unknown_kid_yields_unknown_kid_violation() { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("rsa"); + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + + // JWKS publishes a single key with kid=test-key-1. + let jwks_body = build_jwks(&pub_key).to_string(); + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(jwks_body) + .create_async() + .await; + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize"); + + // Token signed by the right private key, but its header + // declares `kid=stale-key` — which is what the IdP would do + // post-rotation if we haven't refreshed yet. + let token = mint_jwt_with_kid( + &priv_pem, + "stale-key", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!(!result.continue_processing); + let v = result.violation.expect("violation reported"); + assert_eq!(v.code, "auth.unknown_kid"); + assert!( + v.reason.contains("stale-key"), + "reason should name the missing kid: {}", + v.reason, + ); +} + +/// JWKS endpoint accepts the TCP connection but stalls indefinitely +/// on the HTTP response — the kind of slow-loris pattern a hostile +/// or simply broken IdP could exhibit. The fetch must time out +/// rather than hanging `initialize()` forever. +#[tokio::test(flavor = "multi_thread")] +async fn jwks_fetch_times_out_when_endpoint_stalls() { + use std::time::Duration; + use tokio::io::AsyncWriteExt; + + // Stand up a tiny TCP listener that accepts connections, reads + // the request headers, and then deliberately never sends a + // response body. The JWKS fetch should give up after the + // configured timeout (~5s) rather than waiting forever. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral"); + let addr = listener.local_addr().expect("listener addr"); + tokio::spawn(async move { + while let Ok((mut sock, _)) = listener.accept().await { + tokio::spawn(async move { + // Drain a bit of request data, then send a partial + // status line and stop. Reqwest will sit waiting + // for body bytes that never arrive. + let mut buf = [0u8; 512]; + let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await; + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n") + .await; + // Hold the connection open without writing the + // 100-byte body. Sleep beyond the resolver's + // overall timeout to confirm timeout-not-receive. + tokio::time::sleep(Duration::from_secs(15)).await; + }); + } + }); + + let url = format!("http://{addr}/jwks"); + let src = DecodingKeySource::JwksUrl { + url: url.clone(), + insecure_http: true, + refresh_secs: 3600, + }; + + let started = std::time::Instant::now(); + let outcome = src.build_async().await; + let elapsed = started.elapsed(); + + // The wall-clock bound is the load-bearing assertion: a slow + // / hostile JWKS must not hang `build_async` indefinitely. The + // exact error string reqwest surfaces for a deadline elapsed + // varies across platforms and reqwest versions — sometimes + // "timeout", sometimes "body read failed: error decoding + // response body" (when the body stream gets cut by the + // deadline). We accept any Err outcome and rely on elapsed + // time as the contract. + match outcome { + Err(_e) => {} + Ok(_store) => panic!("stalled JWKS must not produce a KeyStore"), + } + // 5s overall timeout + 2s margin for setup / scheduler jitter. + assert!( + elapsed < Duration::from_secs(8), + "fetch should have given up promptly; took {elapsed:?}", + ); +} + +// ===================================================================== +// P0-5 Slice B: soft-fail at boot + periodic JWKS refresh +// ===================================================================== + +/// JWKS endpoint is unreachable at gateway boot. The plugin must +/// `initialize()` cleanly (no Err — soft-fail) so the gateway +/// doesn't crash on a transient IdP outage. Subsequent verify +/// calls against tokens for that issuer must surface +/// `auth.jwks_unavailable` — a clear, distinct code so operators +/// see "JWKS issue at IdP X" rather than the alarming +/// `auth.signature_invalid` they'd see if we silently used an +/// empty key. +#[tokio::test(flavor = "multi_thread")] +async fn jwks_unreachable_at_initialize_soft_fails() { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("rsa"); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + + // Point at 127.0.0.1:1 — port 1 isn't bound by typical systems, + // so the TCP connect fails fast. The fetch timeout would also + // catch a slow endpoint; here we just want "unreachable." + let jwks_url = "http://127.0.0.1:1/jwks".to_string(); + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + + // The gateway boots — initialize returns Ok even though the + // JWKS fetch failed. This is the soft-fail invariant. + mgr.initialize().await.expect("initialize must NOT propagate JWKS failure"); + + // A token signed by the right key fails verify with + // `auth.jwks_unavailable` rather than crashing or returning + // the wrong code. The resolver's KeyStore is empty until + // refresh succeeds (which it won't, in this test). + let token = mint_jwt_with_kid( + &priv_pem, + "test-key-1", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!(!result.continue_processing); + let v = result.violation.expect("violation reported"); + assert_eq!(v.code, "auth.jwks_unavailable"); + assert!( + v.reason.contains(ISS), + "reason should name the affected issuer: {}", + v.reason, + ); +} + +/// Initial JWKS publishes key A; the mock then rotates to key B. +/// A token signed by B with `kid=key-b` is initially rejected +/// (KeyStore only knows A). After the refresh interval ticks, +/// the resolver's KeyStore swaps in B and the same token +/// validates. Pins both: +/// - that refresh runs without restart +/// - that whole-store replacement actually swaps (not merges, +/// not silently drops the update) +#[tokio::test(flavor = "multi_thread")] +async fn jwks_refresh_picks_up_rotated_key() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + let mut rng = rand::thread_rng(); + let priv_a = RsaPrivateKey::new(&mut rng, 2048).expect("rsa a"); + let priv_b = RsaPrivateKey::new(&mut rng, 2048).expect("rsa b"); + let pub_a = RsaPublicKey::from(&priv_a); + let pub_b = RsaPublicKey::from(&priv_b); + let priv_pem_b = priv_b + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM b") + .to_string(); + + let jwks_a = build_jwks(&pub_a).to_string(); + let jwks_b = { + use base64::Engine; + json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "key-b", + "n": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(pub_b.n().to_bytes_be()), + "e": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(pub_b.e().to_bytes_be()), + }] + }) + .to_string() + }; + + // Track how many times the JWKS endpoint has been hit so we + // can flip the response body after the first fetch. + let fetch_count = Arc::new(AtomicUsize::new(0)); + + let mut server = Server::new_async().await; + let count_for_mock = Arc::clone(&fetch_count); + let jwks_b_clone = jwks_b.clone(); + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body_from_request(move |_req| { + let n = count_for_mock.fetch_add(1, Ordering::SeqCst); + if n == 0 { + jwks_a.clone().into_bytes() + } else { + jwks_b_clone.clone().into_bytes() + } + }) + .expect_at_least(2) + .create_async() + .await; + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + // Resolver config with a short refresh — 1 second keeps the + // test wall-clock low. The default 600s wouldn't fire inside + // the test window. Built inline rather than via + // `resolver_config(...)` because we need the `refresh_secs` + // field which the shared helper doesn't expose. + let cfg = PluginConfig { + name: "jwt-via-jwks".into(), + kind: "test".into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "role": "user", + "header": "Authorization", + "trusted_issuers": [{ + "issuer": ISS, + "audiences": [AUD], + "algorithms": ["RS256"], + "decoding_key": { + "kind": "jwks_url", + "url": jwks_url, + "insecure_http": true, + "refresh_secs": 1, + }, + "leeway_seconds": 60, + }], + "claim_mapper": "standard", + })), + ..Default::default() + }; + + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize"); + + // Token signed by B, with kid=key-b. Pre-refresh, the + // resolver only knows key A → `auth.unknown_kid`. + let make_payload = || { + let token = mint_jwt_with_kid( + &priv_pem_b, + "key-b", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers) + }; + + let (pre, _) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + make_payload(), + Extensions::default(), + None, + ) + .await; + assert!(!pre.continue_processing, "key-b token should not validate before refresh"); + assert_eq!( + pre.violation.expect("violation").code, + "auth.unknown_kid", + "pre-refresh: kid mismatch should report unknown_kid", + ); + + // Wait long enough for the refresh task to fire at least once. + // 1s refresh interval + a generous margin for scheduler jitter. + // Poll the same verify in a loop until it succeeds or we time + // out — avoids a flaky fixed sleep. + let deadline = std::time::Instant::now() + Duration::from_secs(8); + let mut succeeded = false; + while std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(200)).await; + let (r, _) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + make_payload(), + Extensions::default(), + None, + ) + .await; + if r.continue_processing { + succeeded = true; + break; + } + } + assert!( + succeeded, + "refresh task should have swapped in key-b within 8s of a 1s-interval refresh", + ); +} diff --git a/crates/apl-identity-jwt/tests/jwt_e2e.rs b/crates/apl-identity-jwt/tests/jwt_e2e.rs new file mode 100644 index 00000000..b18a6c3c --- /dev/null +++ b/crates/apl-identity-jwt/tests/jwt_e2e.rs @@ -0,0 +1,298 @@ +// Location: ./crates/apl-identity-jwt/tests/jwt_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for `JwtIdentityResolver` against a real RSA +// keypair + signed JWTs. Exercises the full handler path: +// `mgr.invoke_named::(...)` → resolver decodes / +// validates / maps claims → host extracts the populated +// `IdentityPayload` via `from_pipeline_result`. +// +// Scenarios: +// * happy path: valid signed token resolves to a populated subject +// * untrusted issuer (token signed correctly but `iss` not in config) +// * expired token (`exp` in the past) +// * audience mismatch +// * signature tamper +// +// Keypair is generated once per test process (RSA 2048 takes +// ~50-100ms; one-time cost) and shared across tests via OnceLock. + +use std::sync::Arc; +use std::sync::OnceLock; + +use cpex_core::extensions::raw_credentials::{TokenKind, TokenRole}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_identity_jwt::JwtIdentityResolver; + +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; +use rsa::{RsaPrivateKey, RsaPublicKey}; + +use serde_json::{json, Value}; + +const TEST_ISSUER: &str = "https://idp.test.local"; +const TEST_AUDIENCE: &str = "test-api"; + +// ===================================================================== +// Test fixtures +// ===================================================================== + +struct Keypair { + private_pem: String, + public_pem: String, +} + +/// Process-global keypair. Generated once on first access; RSA 2048 +/// is ~50-100ms which we don't want to pay per-test. +fn keypair() -> &'static Keypair { + static KP: OnceLock = OnceLock::new(); + KP.get_or_init(|| { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + Keypair { + private_pem: priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(), + public_pem: pub_key + .to_public_key_pem(LineEnding::LF) + .expect("encode public PEM"), + } + }) +} + +/// Sign `claims` as an RS256 JWT using the test private key. JWT +/// payload is whatever JSON the caller hands in. +fn mint_jwt(claims: Value) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let header = Header::new(Algorithm::RS256); + let key = EncodingKey::from_rsa_pem(keypair().private_pem.as_bytes()) + .expect("build EncodingKey from test private PEM"); + encode(&header, &claims, &key).expect("sign JWT") +} + +/// Construct a `PluginConfig` whose `config:` block declares the +/// test public key as the trusted-issuer signing material. Mirrors +/// what an operator writes in unified-config YAML. +fn resolver_plugin_config() -> PluginConfig { + let plugin_config = json!({ + "trusted_issuers": [{ + "issuer": TEST_ISSUER, + "audiences": [TEST_AUDIENCE], + "algorithms": ["RS256"], + "decoding_key": { + "kind": "pem", + "pem": keypair().public_pem, + }, + "leeway_seconds": 60, + }], + "claim_mapper": "standard", + }); + PluginConfig { + name: "jwt-resolver".into(), + kind: "test".into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(plugin_config), + ..Default::default() + } +} + +/// Build the PluginManager + register the resolver + initialize. +/// All four scenarios share this skeleton. +async fn build_manager() -> Arc { + let cfg = resolver_plugin_config(); + let resolver = JwtIdentityResolver::new(cfg.clone()).expect("resolver should construct"); + + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::new(resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +/// Run a token through the full handler pipeline. +async fn invoke(token: String) -> cpex_core::executor::PipelineResult { + let mgr = build_manager().await; + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + IdentityPayload::new(token, TokenSource::Bearer), + Extensions::default(), + None, + ) + .await; + result +} + +fn now_unix() -> i64 { + chrono::Utc::now().timestamp() +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Happy path: valid signed token resolves to a populated subject, +/// raw token lands in `raw_credentials.inbound_tokens[User]`. +#[tokio::test] +async fn valid_jwt_resolves_subject() { + let token = mint_jwt(json!({ + "sub": "alice@corp.com", + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + "iat": now_unix(), + "roles": ["hr", "reader"], + "email": "alice@corp.com", + })); + + let result = invoke(token.clone()).await; + assert!( + result.continue_processing, + "valid token should resolve: violation = {:?}", + result.violation, + ); + + let identity = IdentityPayload::from_pipeline_result(&result) + .expect("payload should be present"); + let subject = identity.subject.as_ref().expect("subject populated"); + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); + assert!(subject.roles.contains("hr")); + assert!(subject.roles.contains("reader")); + // `email` was not a reserved claim, lands under subject.claims + assert_eq!( + subject.claims.get("email"), + Some(&"alice@corp.com".to_string()), + ); + + // Raw token stashed for forwarding plugins. + let raw = identity + .raw_credentials + .as_ref() + .expect("raw_credentials populated"); + let user_token = raw + .inbound_tokens + .get(&TokenRole::User) + .expect("user-role token present"); + assert_eq!(&*user_token.token, &token); + assert!(matches!(user_token.kind, TokenKind::Jwt)); +} + +/// Token correctly signed by the test key but its `iss` doesn't +/// match any trusted issuer in our config → `auth.untrusted_issuer`. +/// This is the path where the peek-at-iss step does its job. +#[tokio::test] +async fn untrusted_issuer_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + "iss": "https://hacker.example.com", // not in trusted_issuers list + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.untrusted_issuer"); +} + +/// `exp` claim is one hour in the past → `auth.token_expired`. +/// Leeway is 60s so a 1h-stale token is unambiguously rejected. +#[tokio::test] +async fn expired_token_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() - 3600, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.token_expired"); +} + +/// `aud` doesn't match the configured audience → `auth.audience_mismatch`. +#[tokio::test] +async fn wrong_audience_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + "iss": TEST_ISSUER, + "aud": "some-other-api", // not the configured TEST_AUDIENCE + "exp": now_unix() + 300, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.audience_mismatch"); +} + +/// Tamper with the signature bytes → signature verification fails → +/// `auth.signature_invalid`. The load-bearing test for the security +/// story; if this passes, the cryptographic validation is wired +/// correctly through the whole pipeline. +#[tokio::test] +async fn tampered_signature_rejects() { + let valid = mint_jwt(json!({ + "sub": "alice", + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + })); + // Flip a char in the middle of the signature segment. We + // can't tamper with the *last* char because base64url + // encoding of a 256-byte RSA-2048 signature requires its last + // char to encode 4 trailing-bit zeros — only `{A, Q, g, w}` + // satisfy that. A naive flip to an out-of-set char produces + // invalid base64 (decoder error → `auth.malformed_header`) + // rather than valid bytes that fail signature verification. + // Middle-segment chars don't have the trailing-bit constraint. + let parts: Vec<&str> = valid.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have three segments"); + let sig = parts[2]; + let mut sig_chars: Vec = sig.chars().collect(); + let target_idx = sig_chars.len() / 2; // well into the middle + let original = sig_chars[target_idx]; + // Pick a replacement that's different but in the same charset. + let replacement = if original == 'A' { 'B' } else { 'A' }; + sig_chars[target_idx] = replacement; + let new_sig: String = sig_chars.into_iter().collect(); + let tampered = format!("{}.{}.{}", parts[0], parts[1], new_sig); + + let result = invoke(tampered).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.signature_invalid"); +} + +/// Token with no `iss` claim at all → `auth.malformed_header` from +/// the peek step (we can't pick a trusted issuer without `iss`). +#[tokio::test] +async fn missing_iss_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + // no iss + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.malformed_header"); +} diff --git a/crates/apl-pdp-cedar-direct/Cargo.toml b/crates/apl-pdp-cedar-direct/Cargo.toml new file mode 100644 index 00000000..4072a665 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/Cargo.toml @@ -0,0 +1,63 @@ +# Location: ./crates/apl-pdp-cedar-direct/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-pdp-cedar-direct — a `PdpResolver` implementation that wraps the bare +# `cedar-policy` crate (Amazon's Cedar engine, no JWT validation, no policy +# store loading, no Lock Server integration). +# +# When to use this crate vs `apl-pdp-cedarling`: +# +# - **cedar-direct** — host already has identity validated (via gateway, +# SPIFFE, prior plugin, or hand-rolled JWT validation); policies are +# loaded as text/files at startup and don't change at runtime; smallest +# dep tree; ~5 transitive crates instead of 200+. +# - **cedarling** — host wants JWT validation + claims-to-entity mapping +# + centralized policy management (Janssen Lock Server) all in one +# library. +# +# Both crates speak Cedar 4.x; their decisions on identical policy + entity +# + request inputs are byte-identical. The difference is what's around the +# Cedar engine. + +[package] +name = "apl-pdp-cedar-direct" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +# Permissive caret spec — `"4"` means "any 4.x that Cargo can find." +# We rely on Cargo's standard version resolution to dedup with +# cedarling's `cedar-policy = "4.9.0"` (also caret), so both crates +# end up compiling against the same `cedar-policy` version (currently +# 4.11 — bumps automatically when either side allows a newer 4.x). +# This matters because mixing `cedar_policy@4.9::Decision` and +# `cedar_policy@4.11::Decision` in the same workspace would produce +# distinct types Rust treats as incompatible. +# +# Code-side note: we use `Request::new(...)` (added in 4.11 alongside +# the deprecated builder; still available in older 4.x via the +# constructor form). Tracked separately if we ever need to support +# pre-4.x or post-5.x. +cedar-policy = "4" +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# End-to-end integration tests wire the cedar-direct factory through the +# apl-cpex visitor and exercise it against a real `PluginManager`. These +# dev-dep edges only exist for tests — the crate itself stays +# apl-core-only at compile time so it can be used standalone (e.g. in a +# custom orchestrator that doesn't go through apl-cpex at all). +apl-cmf = { path = "../apl-cmf" } +apl-cpex = { path = "../apl-cpex" } +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs b/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs new file mode 100644 index 00000000..ad91dd76 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs @@ -0,0 +1,61 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/cedar_attrs.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Canonical Cedar entity attribute names. +// +// Cedar policy authors write `principal.roles.contains("hr")`, +// `principal.permissions.contains("view_ssn")`, etc. — the strings on +// the right side of `principal.` are *Cedar entity attribute names* +// that this crate produces when it builds the principal entity from the +// `AttributeBag`. Author-facing vocabulary, distinct from the +// `apl-cmf::constants::BAG_*` bag-key vocabulary even when the words +// happen to match. +// +// Keeping these constants in one module means a rename ripples to a +// single file. The entity builder in `entities.rs` and any future +// schema generator both reference them by symbol. +// +// Pair this list with the schema published to Cedar authors — every +// constant here should appear in any official entity schema. + +/// `id` — the entity's identifier attribute (we emit it inside `attrs` +/// for legibility even though Cedar also has it in the `uid` slot). +pub const ATTR_ID: &str = "id"; + +/// `type` — the entity's type name as a string, for policies that +/// branch on subject kind (`principal.type == "agent"` etc.). +pub const ATTR_TYPE: &str = "type"; + +/// `roles` — `Set` of role names the principal holds. +/// Filled from `apl-cmf`'s `role.*` bag keys. +pub const ATTR_ROLES: &str = "roles"; + +/// `permissions` — `Set` of permission names. +/// Filled from `apl-cmf`'s `perm.*` bag keys. +pub const ATTR_PERMISSIONS: &str = "permissions"; + +/// `teams` — `Set` of team / group memberships. +/// Filled from `apl-cmf`'s `subject.teams` bag key. +pub const ATTR_TEAMS: &str = "teams"; + +/// `claims` — `Record` of arbitrary JWT-style claims. Filled from +/// `apl-cmf`'s `claim.*` bag keys. +pub const ATTR_CLAIMS: &str = "claims"; + +// ----- JSON wrapping keys (Cedar's entity-from-JSON shape) --------- +// +// These aren't entity attributes per se — they're the top-level +// keys of the JSON shape Cedar expects when reading an entity from +// `Entity::from_json_value`. Kept here so the entity-builder code +// stays free of magic strings. + +/// `uid` — the {type, id} envelope at the top of an entity JSON. +pub const KEY_UID: &str = "uid"; + +/// `attrs` — the attribute bag inside an entity JSON. +pub const KEY_ATTRS: &str = "attrs"; + +/// `parents` — the optional parents list inside an entity JSON. +pub const KEY_PARENTS: &str = "parents"; diff --git a/crates/apl-pdp-cedar-direct/src/decision.rs b/crates/apl-pdp-cedar-direct/src/decision.rs new file mode 100644 index 00000000..4391f98c --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/decision.rs @@ -0,0 +1,127 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/decision.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Translation from `cedar_policy::Response` into `apl_core::PdpDecision`. +// +// What we preserve: +// +// - `decision` — Allow ↔ Deny. One-to-one. +// - `diagnostics` — the set of policy IDs that *determined* the +// decision (not "matched" — Cedar's `reason()` is +// the policies whose effect produced the outcome). +// Operators who annotated their policies with +// `@id("...")` get meaningful identifiers; without +// annotations they get `policy0`, `policy1`, …. +// - `rule_source` — first policy ID from `diagnostics`. Becomes the +// violation code on Deny so audit logs / wire +// errors say "denied via owner-override" rather +// than "cedar.deny." +// +// What we drop (for now): +// +// - Obligations — Cedar 4.10 doesn't have first-class obligations. +// Policy annotations could carry them (`@obligation(...)`) but +// wiring the annotation vocabulary is deferred — see +// `docs/specs/cedar-context-contract.md`. +// +// # Fail-closed on evaluation errors +// +// Cedar's `Response::diagnostics().errors()` lists policies that errored +// during runtime evaluation (e.g. type errors in a `when` clause that +// only manifest with certain entity data). If ANY policy errored, we +// return Deny regardless of what `decision()` says — an untrusted +// decision is worse than a closed gate. The error messages flow into +// the Deny reason so operators see why. + +use apl_core::evaluator::Decision; +use apl_core::step::PdpDecision; +use cedar_policy::{Decision as CedarDecision, PolicySet}; + +/// Translate a `cedar_policy::Response` into the APL-side `PdpDecision`. +/// Captures policy-ID attribution into `diagnostics` and, on Deny, +/// surfaces the first firing policy as the `rule_source`. +/// +/// # `@id` annotation lookup +/// +/// `PolicySet::from_str` assigns auto-IDs (`policy0`, `policy1`, ...); +/// authors get *meaningful* identifiers by annotating each policy with +/// `@id("my-rule")`. We resolve auto-IDs to annotation values here so +/// the rest of the system sees the names operators chose. Policies +/// without `@id` annotations keep their auto-IDs — explicit-is-better +/// fallback rather than silent translation. +pub fn translate(response: &cedar_policy::Response, policy_set: &PolicySet) -> PdpDecision { + let diagnostics = response.diagnostics(); + + let firing_policies: Vec = diagnostics + .reason() + .map(|pid| { + // Prefer the operator-supplied `@id("...")` annotation; + // fall back to Cedar's auto-generated id when the policy + // is unannotated. + policy_set + .policy(pid) + .and_then(|p| p.annotation("id")) + .map(|s| s.to_string()) + .unwrap_or_else(|| pid.to_string()) + }) + .collect(); + + let errors: Vec = diagnostics + .errors() + .map(|e| e.to_string()) + .collect(); + + // Fail-closed: any runtime evaluation error → Deny with the error + // text so the operator sees what went wrong. Cedar's own + // `decision()` may still say Allow when errors occurred; we override + // because an Allow on a partially-failed evaluation isn't + // trustworthy. + if !errors.is_empty() { + let reason = format!( + "Cedar evaluation produced errors (fail-closed): {}", + errors.join("; ") + ); + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.evaluation_error".to_string()); + return PdpDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source, + }, + diagnostics: firing_policies, + }; + } + + let decision = match response.decision() { + CedarDecision::Allow => Decision::Allow, + CedarDecision::Deny => { + // Build a human-readable reason from the firing policies so + // wire errors and audit logs carry attribution. First + // policy ID becomes the violation code. + let reason = if firing_policies.is_empty() { + // Cedar deny with no firing policy means no `permit` + // matched — the "default deny" case. + "no Cedar permit policy matched the request".to_string() + } else { + format!("denied by Cedar policy: {}", firing_policies.join(", ")) + }; + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.default_deny".to_string()); + Decision::Deny { + reason: Some(reason), + rule_source, + } + } + }; + + PdpDecision { + decision, + diagnostics: firing_policies, + } +} diff --git a/crates/apl-pdp-cedar-direct/src/entities.rs b/crates/apl-pdp-cedar-direct/src/entities.rs new file mode 100644 index 00000000..3ae3cfcc --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/entities.rs @@ -0,0 +1,253 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/entities.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build a `cedar_policy::Entities` set from: +// +// - The `AttributeBag` — APL's view of `SecurityExtension` etc. +// populated upstream by apl-cmf. Source of the **principal** entity. +// - `PdpCall.args.resource` — the resource description the policy +// author wrote in the `cedar:(...)` step. Source of the **resource** +// entity. +// +// v0 builds a minimum-viable entity set: just principal + resource, +// no hierarchy (no `User in Team`, no `Document in Folder`). Operators +// who need that plug an `EntityProvider` trait we'll add later — when +// there's a real use case driving the design. +// +// # Why JSON-shaped construction +// +// Cedar's `Entity::from_json_value(json, schema)` accepts a record +// with `uid`, `attrs`, `parents` keys. We build that record from the +// bag / args and let Cedar's parser handle the attribute-value +// translation (string → String, JSON array of strings → Set, +// nested object → Record, etc.). Avoids fighting with +// `RestrictedExpression` directly. + +use std::collections::HashSet; + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::step::PdpError; +use cedar_policy::{Entities, Entity, Schema}; +use serde_json::{json, Map, Value}; + +use crate::cedar_attrs::{ + ATTR_CLAIMS, ATTR_ID, ATTR_PERMISSIONS, ATTR_ROLES, ATTR_TEAMS, ATTR_TYPE, KEY_ATTRS, + KEY_PARENTS, KEY_UID, +}; + +/// Build the entity set for one Cedar request. Returns owned +/// `Entities` (Cedar takes them by reference at authorization time). +pub fn build( + bag: &AttributeBag, + resource_args: &serde_yaml::Value, + schema: Option<&Schema>, + entity_namespace: Option<&str>, +) -> Result { + let principal = build_principal(bag, schema, entity_namespace)?; + let resource = build_resource(resource_args, schema)?; + Entities::from_entities([principal, resource], schema).map_err(|e| { + PdpError::Dispatch(format!("failed to assemble Cedar entity set: {}", e)) + }) +} + +/// Build the principal `Entity` from the bag. Reads: +/// +/// - `subject.id` → entity id (required) +/// - `subject.type` → entity type ("User" | "Agent" | "Service" | +/// "System"); defaults to "User" when absent +/// - `role.=true` → `attrs.roles : Set` +/// - `perm.=true` → `attrs.permissions : Set` +/// - `claim.=v` → `attrs.claims.` (record) +/// - `subject.teams` → `attrs.teams : Set` +/// +/// Operators with custom claim attributes write their Cedar policies +/// against `principal.claims.foo` — those land via the `claim.foo` bag +/// key, populated upstream by apl-cmf from `SubjectExtension.claims`. +pub fn build_principal( + bag: &AttributeBag, + schema: Option<&Schema>, + entity_namespace: Option<&str>, +) -> Result { + let id = bag + .get_string("subject.id") + .ok_or_else(|| { + PdpError::Dispatch( + "Cedar request needs a principal but bag has no `subject.id` — \ + install an identity-hook plugin upstream of APL policy" + .to_string(), + ) + })? + .to_string(); + + let kind = bag.get_string("subject.type").unwrap_or("User"); + let entity_type = qualify_type(kind, entity_namespace); + + // Collect attributes from the bag. We pick the well-known shapes; + // arbitrary `subject.*` keys beyond these are intentionally NOT + // surfaced — operators with custom shapes use `claim.*` or extend + // the bridge. + // + // Empty defaults matter: Cedar's strict-evaluation mode raises a + // runtime error when a policy probes a missing attribute + // (`principal.roles.contains(...)` against a principal without + // `roles`). The resolver's fail-closed logic would then deny — + // surprising for policy authors who expect missing-attribute → + // empty-set semantics. Populating empty sets / records by default + // gives clean "attribute exists, just empty" behavior. + let mut attrs = Map::new(); + attrs.insert(ATTR_ID.to_string(), json!(id)); + attrs.insert(ATTR_TYPE.to_string(), json!(kind)); + + // TODO(vocab consolidation, Phase C): `"role."`, `"perm."`, and + // `"subject.teams"` are apl-cmf bag-key conventions. The cedar + // crate would need a dependency on apl-cmf (or the BAG_* constants + // need to move into apl-core / a shared crate) before we can + // reference them by symbol here. Left literal for now — the gap is + // tracked in the `project_vocab_consolidation` memory. + let roles = collect_prefixed_bools(bag, "role."); + attrs.insert(ATTR_ROLES.to_string(), json!(roles)); + + let permissions = collect_prefixed_bools(bag, "perm."); + attrs.insert(ATTR_PERMISSIONS.to_string(), json!(permissions)); + + let teams: Vec = bag + .get_string_set("subject.teams") + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + attrs.insert(ATTR_TEAMS.to_string(), json!(teams)); + + let claims = collect_claims(bag); + attrs.insert(ATTR_CLAIMS.to_string(), Value::Object(claims)); + + let mut uid_obj = Map::new(); + uid_obj.insert(ATTR_TYPE.to_string(), json!(entity_type)); + uid_obj.insert(ATTR_ID.to_string(), json!(id)); + let mut entity_obj = Map::new(); + entity_obj.insert(KEY_UID.to_string(), Value::Object(uid_obj)); + entity_obj.insert(KEY_ATTRS.to_string(), Value::Object(attrs)); + entity_obj.insert(KEY_PARENTS.to_string(), Value::Array(vec![])); + let entity_json = Value::Object(entity_obj); + + Entity::from_json_value(entity_json, schema).map_err(|e| { + PdpError::Dispatch(format!( + "failed to construct principal entity '{}::\"{}\"': {}", + entity_type, id, e + )) + }) +} + +/// Build the resource `Entity` from the policy author's `args.resource` +/// block. Shape: +/// +/// ```yaml +/// resource: +/// type: Document # required, Cedar entity type +/// id: doc-42 # required, entity id (string) +/// attributes: # optional, key → JSON value +/// classification: internal +/// owner: 'User::"alice"' +/// ``` +pub fn build_resource( + resource_args: &serde_yaml::Value, + schema: Option<&Schema>, +) -> Result { + let map = resource_args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedar:() `resource` must be a mapping with `type` and `id` keys".to_string(), + ) + })?; + + let entity_type = yaml_string(map, "type").ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource.type` missing or not a string".to_string()) + })?; + let id = yaml_string(map, "id").ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource.id` missing or not a string".to_string()) + })?; + + let attrs_value = map + .get(serde_yaml::Value::String("attributes".to_string())) + .cloned() + .unwrap_or(serde_yaml::Value::Mapping(Default::default())); + let attrs_json: Value = serde_json::to_value(&attrs_value).map_err(|e| { + PdpError::Dispatch(format!( + "cedar:() `resource.attributes` not JSON-representable: {}", + e + )) + })?; + + let mut uid_obj = Map::new(); + uid_obj.insert(ATTR_TYPE.to_string(), json!(entity_type)); + uid_obj.insert(ATTR_ID.to_string(), json!(id)); + let mut entity_obj = Map::new(); + entity_obj.insert(KEY_UID.to_string(), Value::Object(uid_obj)); + entity_obj.insert(KEY_ATTRS.to_string(), attrs_json); + entity_obj.insert(KEY_PARENTS.to_string(), Value::Array(vec![])); + let entity_json = Value::Object(entity_obj); + + Entity::from_json_value(entity_json, schema).map_err(|e| { + PdpError::Dispatch(format!( + "failed to construct resource entity '{}::\"{}\"': {}", + entity_type, id, e + )) + }) +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Apply the optional namespace to a bare entity type. `Some("Acme")` + +/// `"User"` → `"Acme::User"`. `None` → `"User"`. Lets operators with +/// namespaced schemas (`Acme::User`, `Acme::Document`) work without +/// each policy author having to hand-prefix everywhere. +fn qualify_type(bare: &str, namespace: Option<&str>) -> String { + match namespace { + Some(ns) if !ns.is_empty() => format!("{}::{}", ns, bare), + _ => bare.to_string(), + } +} + +/// Read every `X = true` key from the bag and return `[X, ...]`. +/// Used for `role.*` → roles and `perm.*` → permissions, matching +/// apl-cmf's presence-only encoding for role / permission membership. +fn collect_prefixed_bools(bag: &AttributeBag, prefix: &str) -> Vec { + let mut out: HashSet = HashSet::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix(prefix) { + if matches!(value, AttributeValue::Bool(true)) { + out.insert(name.to_string()); + } + } + } + let mut v: Vec = out.into_iter().collect(); + v.sort(); + v +} + +/// Read every `claim.` key and assemble a JSON record of the +/// values. Each claim's value type comes through as JSON (`Bool`, +/// `String`, etc.) so Cedar's record-of-records story works. +fn collect_claims(bag: &AttributeBag) -> Map { + let mut out = Map::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix("claim.") { + let v = match value { + AttributeValue::Bool(b) => json!(*b), + AttributeValue::Int(i) => json!(*i), + AttributeValue::Float(f) => json!(*f), + AttributeValue::String(s) => json!(s), + AttributeValue::StringSet(set) => json!(set.iter().collect::>()), + }; + out.insert(name.to_string(), v); + } + } + out +} + +fn yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} diff --git a/crates/apl-pdp-cedar-direct/src/error.rs b/crates/apl-pdp-cedar-direct/src/error.rs new file mode 100644 index 00000000..3b640fc2 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/error.rs @@ -0,0 +1,67 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/error.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build-time errors for `CedarDirectResolver`. All variants fire at +// construction (parse, validate, load); never at request time. +// +// Request-time errors flow through `apl_core::PdpError` because that's +// the trait's return type. The two error stories are deliberately +// separate — build errors are config faults the operator fixes once; +// request errors are per-evaluation issues the host has to handle +// continuously. +// +// `BuildError` implements `std::error::Error` (via thiserror), so it +// boxes cleanly into `apl_cpex::visitor::VisitorError` when the +// AplConfigVisitor builds a resolver from a unified-config block. The +// visitor then wraps that into `cpex_core::PluginError::Config` on its +// way out of `load_config_yaml`. Each layer wraps the layer below using +// its own native error type — no dep inversion required to make the +// error flow work. + +use thiserror::Error; + +/// Error returned at resolver construction. +#[derive(Debug, Error)] +pub enum BuildError { + /// The policy text didn't parse as Cedar. Carries the underlying + /// parser message verbatim so operators can see exactly which + /// `permit`/`forbid` line broke. + #[error("failed to parse Cedar policy set: {0}")] + PolicyParse(String), + + /// Cedar accepted the policy text but the schema (if supplied) + /// rejected one or more policies as invalid against the declared + /// entity / action shape. + #[error("policy set failed schema validation: {0}")] + PolicyValidation(String), + + /// I/O failure reading a policy file from disk. Distinct variant + /// from `PolicyParse` so operators can tell "file not found" from + /// "file found but unparseable" without grepping the message. + #[error("failed to read Cedar policy file '{path}': {source}")] + PolicyFile { + path: String, + #[source] + source: std::io::Error, + }, + + /// Schema text didn't parse as Cedar schema. + #[error("failed to parse Cedar schema: {0}")] + SchemaParse(String), + + /// I/O failure reading a schema file from disk. + #[error("failed to read Cedar schema file '{path}': {source}")] + SchemaFile { + path: String, + #[source] + source: std::io::Error, + }, + + /// Config block missing required fields, or fields had the wrong + /// shape. Fired by `from_config(&serde_yaml::Value)` when the + /// operator's YAML doesn't match the expected layout. + #[error("invalid Cedar PDP config: {0}")] + ConfigShape(String), +} diff --git a/crates/apl-pdp-cedar-direct/src/factory.rs b/crates/apl-pdp-cedar-direct/src/factory.rs new file mode 100644 index 00000000..dd5c4ba3 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/factory.rs @@ -0,0 +1,54 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CedarDirectPdpFactory` — the `PdpFactory` implementation that lets +// the apl-cpex visitor instantiate `CedarDirectResolver` from a +// unified-config YAML block: +// +// ```yaml +// global: +// apl: +// pdp: +// - kind: cedar-direct +// dialect: cedar # optional, defaults to PdpDialect::Cedar +// policy_text: | # required (or policy_file) +// @id("owner-override") +// permit(...); +// ``` +// +// Hosts register an instance of this factory in `AplOptions.pdp_factories`; +// the visitor matches it to the block by `kind` and dispatches. + +use std::sync::Arc; + +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::resolver::CedarDirectResolver; + +/// Factory for `CedarDirectResolver`. Reports `kind() = "cedar-direct"`; +/// builds resolvers from the unified-config block via +/// [`CedarDirectResolver::from_config`]. +#[derive(Default)] +pub struct CedarDirectPdpFactory; + +impl CedarDirectPdpFactory { + pub fn new() -> Self { + Self + } +} + +impl PdpFactory for CedarDirectPdpFactory { + fn kind(&self) -> &str { + "cedar-direct" + } + + fn build( + &self, + config: &serde_yaml::Value, + ) -> Result, Box> { + let resolver = CedarDirectResolver::from_config(config)?; + Ok(Arc::new(resolver)) + } +} diff --git a/crates/apl-pdp-cedar-direct/src/lib.rs b/crates/apl-pdp-cedar-direct/src/lib.rs new file mode 100644 index 00000000..606576aa --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/lib.rs @@ -0,0 +1,114 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-pdp-cedar-direct — `PdpResolver` over the bare `cedar-policy` crate. +// +// # Where this lives in the stack +// +// APL evaluator (apl-core) +// │ `cedar:(action:..., resource:..., context:...)` step +// ▼ +// PdpRouter (apl-cpex) — dispatches by dialect +// │ resolver.evaluate(call, bag) +// ▼ +// CedarDirectResolver — THIS CRATE +// │ translate to cedar_policy::Request + Entities +// ▼ +// cedar_policy::Authorizer — Amazon's official Cedar evaluator +// +// # Inputs (`PdpCall.args`) +// +// APL routes call cedar like: +// +// ```yaml +// policy: +// - cedar: +// action: 'Action::"read"' +// resource: +// type: Document +// id: doc-42 +// attributes: +// classification: internal +// owner: 'User::"alice"' +// context: +// request_time: "2026-05-18T10:00:00Z" +// ``` +// +// Required keys: `action`, `resource.type`, `resource.id`. Optional: +// `resource.attributes`, `context`. Principal is NOT in `args` — see +// below. +// +// # Principal +// +// The principal entity is built from the `AttributeBag` that apl-cmf +// populated from `SecurityExtension.subject`: +// +// - `subject.id` → entity id (required; missing → request-time error) +// - `subject.type` → entity type ("User", "Agent", "Service", "System"); +// defaults to "User" when absent +// - `role.=true` → principal.roles : Set +// - `perm.=true` → principal.permissions : Set +// - `claim.=v` → principal.claims. = v +// - `subject.teams` → principal.teams : Set +// - `subject.id` → principal.id : String +// +// Operators with richer principal shapes (custom JWT claims, workload +// trust domains) populate them upstream via identity-hook plugins; this +// crate just reads what the bag carries. +// +// # CPEX-provided context +// +// In addition to whatever the policy author put in `args.context`, the +// resolver merges in well-known CPEX context paths so policies can +// reason about them with a stable schema: +// +// - `context.delegation` — `{ chain: [...], depth: N }` from +// `DelegationExtension` (via bag's `delegation.*`). +// - `context.meta` — `{ entity_type, entity_name, scope, tags }` +// from `MetaExtension`. +// - `context.security` — `{ labels: [...], classification }`. +// +// Operators document this layout in their Cedar schema; policy authors +// rely on it. See `docs/specs/cedar-context-contract.md` for the +// authoritative shape. +// +// # Schema (optional) +// +// Cedar schemas validate policies at load time and requests at +// evaluation time. Recommended for production deployments; skipped here +// by default to keep the construction surface simple. Add via +// `CedarDirectResolver::with_schema(schema)`. +// +// # Decision attribution +// +// Cedar's `Response::diagnostics().reason()` returns the policy IDs of +// every policy that determined the decision. These flow back through +// `PdpDecision.diagnostics`, and the first one becomes the +// `rule_source` on Deny — so APL violations carry "denied via +// owner-override" instead of an opaque "cedar.deny." +// +// Policy authors should annotate every policy with `@id("...")`: +// +// ``` +// @id("owner-override") +// permit(principal, action == Action::"read", resource) +// when { principal == resource.owner }; +// ``` +// +// Without `@id` annotations, Cedar generates `policy0`, `policy1`, … +// which is stable but meaningless. Worth documenting as best practice. + +pub mod cedar_attrs; +pub mod decision; +pub mod entities; +pub mod error; +pub mod factory; +pub mod request; +pub mod resolver; +pub mod template; + +pub use error::BuildError; +pub use factory::CedarDirectPdpFactory; +pub use resolver::CedarDirectResolver; diff --git a/crates/apl-pdp-cedar-direct/src/request.rs b/crates/apl-pdp-cedar-direct/src/request.rs new file mode 100644 index 00000000..4c952aed --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/request.rs @@ -0,0 +1,216 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/request.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build a `cedar_policy::Request` from a `PdpCall` + `AttributeBag`. +// The resolver constructs Cedar's three required parts (principal, +// action, resource) plus the merged context, then hands them to +// Cedar's `Request::builder()`. +// +// # Principal / resource / action +// +// - **Principal:** built from the bag (see `entities::build_principal`). +// Its `EntityUid` is what we hand to `Request::principal()`. +// - **Resource:** built from `args.resource` (see `entities::build_resource`). +// - **Action:** parsed from `args.action` — must be a fully-qualified +// Cedar `EntityUid` literal like `Action::"read"` or +// `Acme::Action::"approve"`. The policy author writes this verbatim +// in their APL `cedar:(...)` step. +// +// # Context +// +// `args.context` is the operator-supplied context from the APL step. We +// merge in CPEX-provided keys at well-known paths: +// +// - `context.delegation.{chain, depth}` ← from bag's `delegation.*` +// - `context.meta.{entity_type, entity_name, scope, tags}` ← from bag's `meta.*` +// - `context.security.{labels, classification}` ← from bag's `security.*` +// +// Operators write Cedar policies against these stable paths. Any keys +// the operator put in `args.context` win over CPEX-provided defaults on +// conflict — operator intent first. +// +// # Schema +// +// When a schema is supplied, Cedar's `Context::from_json_value` validates +// the context's record shape against the action's declared context type. +// Without a schema, Cedar accepts any record. + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::step::{PdpCall, PdpError}; +use cedar_policy::{EntityUid, Schema}; +use serde_json::{json, Map, Value}; + +/// Parsed pieces of a `PdpCall` ready to feed into +/// `cedar_policy::Request::builder()`. We pull this into its own +/// struct so the resolver can sequence "build entities → build request" +/// without a giant function signature. +pub struct ParsedCall<'a> { + pub action: EntityUid, + pub context: cedar_policy::Context, + pub resource_args: &'a serde_yaml::Value, +} + +/// Parse the args + bag into the pieces a Cedar request builder needs. +/// Schema is optional; when present, the context block is validated +/// against the action's declared context shape. +pub fn parse<'a>( + call: &'a PdpCall, + bag: &AttributeBag, + schema: Option<&Schema>, +) -> Result, PdpError> { + let map = call.args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedar:() args must be a mapping with `action` and `resource` keys".to_string(), + ) + })?; + + let action_str = map + .get(serde_yaml::Value::String("action".to_string())) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + PdpError::Dispatch( + "cedar:() `action` missing — provide a fully-qualified UID \ + like 'Action::\"read\"'" + .to_string(), + ) + })?; + let action: EntityUid = action_str.parse().map_err(|e| { + PdpError::Dispatch(format!( + "cedar:() `action` '{}' not a valid EntityUid: {}", + action_str, e + )) + })?; + + let resource_args = map + .get(serde_yaml::Value::String("resource".to_string())) + .ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource` missing".to_string()) + })?; + + // Build the merged context: operator-supplied `args.context` keys, + // overlaid on top of CPEX-derived context (delegation, meta, + // security). On collision, the operator's value wins — they + // explicitly wrote it. + let cpex_ctx = build_cpex_context(bag); + let operator_ctx = map + .get(serde_yaml::Value::String("context".to_string())) + .cloned() + .unwrap_or(serde_yaml::Value::Null); + let mut merged = cpex_ctx; + if !operator_ctx.is_null() { + let op_json: Value = serde_json::to_value(&operator_ctx).map_err(|e| { + PdpError::Dispatch(format!( + "cedar:() `context` not JSON-representable: {}", + e + )) + })?; + merge_into(&mut merged, op_json); + } + + let cedar_context = cedar_policy::Context::from_json_value(merged, None).map_err(|e| { + PdpError::Dispatch(format!("failed to construct Cedar context: {}", e)) + })?; + // Note: schema-validated context construction takes an + // (action_schema, action) pair via Cedar's `from_json_value`. For + // v0 we skip schema-side validation of the context shape — the + // request builder still applies whole-request validation when a + // schema is wired into the resolver. Adding context-level schema + // validation is a polish item; doesn't change decision semantics + // when the policies are well-formed. + let _ = schema; // schema currently used at request-build time, not here + + Ok(ParsedCall { + action, + context: cedar_context, + resource_args, + }) +} + +/// Build the CPEX-provided context block (everything under +/// `context.delegation`, `context.meta`, `context.security`) from the +/// `AttributeBag`. Operators reason about these in Cedar policies via +/// the well-known paths documented in `docs/specs/cedar-context-contract.md`. +fn build_cpex_context(bag: &AttributeBag) -> Value { + let mut root = Map::new(); + + let mut delegation = Map::new(); + if let Some(depth) = bag.get_int("delegation.depth") { + delegation.insert("depth".to_string(), json!(depth)); + } + // The full chain isn't currently in a flat bag key; apl-cmf + // exposes presence-only `delegated=true` plus per-attribute hops. + // When apl-cmf grows a structured `delegation.chain` shape we'll + // forward it here. For now, the depth + delegated bool let policies + // do basic chain-depth bounds checks. + if let Some(delegated) = bag.get_bool("delegated") { + delegation.insert("delegated".to_string(), json!(delegated)); + } + if !delegation.is_empty() { + root.insert("delegation".to_string(), Value::Object(delegation)); + } + + let mut meta = Map::new(); + if let Some(et) = bag.get_string("meta.entity_type") { + meta.insert("entity_type".to_string(), json!(et)); + } + if let Some(en) = bag.get_string("meta.entity_name") { + meta.insert("entity_name".to_string(), json!(en)); + } + if let Some(scope) = bag.get_string("meta.scope") { + meta.insert("scope".to_string(), json!(scope)); + } + if let Some(tags) = bag.get_string_set("meta.tags") { + meta.insert("tags".to_string(), json!(tags.iter().collect::>())); + } + if !meta.is_empty() { + root.insert("meta".to_string(), Value::Object(meta)); + } + + let mut security = Map::new(); + if let Some(labels) = bag.get_string_set("security.labels") { + security.insert("labels".to_string(), json!(labels.iter().collect::>())); + } + if let Some(cls) = bag.get_string("security.classification") { + security.insert("classification".to_string(), json!(cls)); + } + if !security.is_empty() { + root.insert("security".to_string(), Value::Object(security)); + } + + // Pass `authenticated` through as a top-level convenience for + // policies that want `context.authenticated` shorthand. + if let Some(auth) = bag.get_bool("authenticated") { + root.insert("authenticated".to_string(), json!(auth)); + } + + Value::Object(root) +} + +/// Shallow merge `overlay` into `target`. Operator-supplied keys win on +/// conflict at the top level; we don't try to deep-merge nested +/// records (operator says `context.meta = {custom: "x"}` and CPEX- +/// provided context.meta is fully replaced). Keeps the semantics +/// predictable. +fn merge_into(target: &mut Value, overlay: Value) { + let (Value::Object(target_map), Value::Object(overlay_map)) = (target, overlay) else { + return; + }; + for (k, v) in overlay_map { + target_map.insert(k, v); + } +} + +#[allow(dead_code)] +fn _bag_typed_value(v: &AttributeValue) -> Value { + // Reserved for future use — keeps the import alive while parts of + // the bag→JSON translation are stubbed. + match v { + AttributeValue::Bool(b) => json!(*b), + AttributeValue::Int(i) => json!(*i), + AttributeValue::Float(f) => json!(*f), + AttributeValue::String(s) => json!(s), + AttributeValue::StringSet(set) => json!(set.iter().collect::>()), + } +} diff --git a/crates/apl-pdp-cedar-direct/src/resolver.rs b/crates/apl-pdp-cedar-direct/src/resolver.rs new file mode 100644 index 00000000..348ed2ff --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/resolver.rs @@ -0,0 +1,301 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/resolver.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CedarDirectResolver` — the `PdpResolver` implementation. Wraps a +// loaded `PolicySet`, an `Authorizer`, and an optional `Schema`, and +// translates each APL `PdpCall` into a Cedar request → decision. +// +// # Construction surface +// +// Three constructors covering the typical sources of Cedar policy: +// +// - `from_policy_text(text)` — for inline policy in code or +// unified-config YAML. +// - `from_policy_file(path)` — for ops-managed policy files. +// - `from_config(value)` — for the unified-config block the +// `AplConfigVisitor` parses. Accepts +// either `policy_text` or +// `policy_file` (or both — policy_text +// wins). Also accepts `schema_text` / +// `schema_file` for optional schema +// loading, plus `entity_namespace` +// and `dialect`. +// +// Construction errors carry rich Cedar-specific messages via +// [`BuildError`]; the visitor wraps these into `VisitorError` → +// `PluginError::Config` at the manager boundary. + +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use cedar_policy::{Authorizer, PolicySet, Schema}; + +use apl_core::attributes::AttributeBag; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +use crate::decision::translate; +use crate::entities::build as build_entities; +use crate::error::BuildError; +use crate::request::parse as parse_call; + +/// PdpResolver wrapping a bare `cedar-policy` engine. Constructed from +/// policy text / file / config block at startup; evaluates each call +/// against the loaded `PolicySet`. +pub struct CedarDirectResolver { + policies: Arc, + schema: Option>, + authorizer: Authorizer, + dialect: PdpDialect, + /// Optional namespace applied to subject types: `Some("Acme")` + /// turns "User" into "Acme::User" when building the principal + /// entity. Lets schemas that namespace their entity types work + /// without policy authors having to hand-prefix every reference. + entity_namespace: Option, +} + +impl CedarDirectResolver { + /// Build a resolver from inline Cedar policy text. Use this for + /// tests, demos, and configs where the policy is small enough to + /// embed in YAML. + pub fn from_policy_text(policies: &str) -> Result { + let policy_set: PolicySet = policies + .parse() + .map_err(|e: cedar_policy::ParseErrors| BuildError::PolicyParse(e.to_string()))?; + Ok(Self { + policies: Arc::new(policy_set), + schema: None, + authorizer: Authorizer::new(), + dialect: PdpDialect::Cedar, + entity_namespace: None, + }) + } + + /// Build a resolver from a Cedar policy file on disk. Convenience + /// over `from_policy_text` for the production layout where policies + /// live in their own versioned files. + pub fn from_policy_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(|source| BuildError::PolicyFile { + path: path.display().to_string(), + source, + })?; + Self::from_policy_text(&text) + } + + /// Build a resolver from a unified-config block. Shape: + /// + /// ```yaml + /// dialect: cedar # optional; default PdpDialect::Cedar + /// entity_namespace: Acme # optional; prefixes subject types + /// policy_text: | # required (or policy_file) + /// @id("owner-override") + /// permit(...); + /// policy_file: /etc/... # alternative to policy_text + /// schema_text: | # optional + /// ... + /// schema_file: /etc/... # alternative to schema_text + /// ``` + /// + /// `policy_text` wins over `policy_file` when both are present. + /// Same for `schema_text` over `schema_file`. Called by + /// `AplConfigVisitor` when it sees a Cedar PDP block in the + /// unified-config YAML. + pub fn from_config(value: &serde_yaml::Value) -> Result { + let map = value + .as_mapping() + .ok_or_else(|| BuildError::ConfigShape("Cedar PDP config must be a mapping".into()))?; + + // ----- policy source ----- + let policy_text = read_yaml_string(map, "policy_text"); + let policy_file = read_yaml_string(map, "policy_file"); + let policies = match (policy_text, policy_file) { + (Some(text), _) => text, + (None, Some(path)) => { + std::fs::read_to_string(&path).map_err(|source| BuildError::PolicyFile { + path: path.clone(), + source, + })? + } + (None, None) => { + return Err(BuildError::ConfigShape( + "Cedar PDP config requires `policy_text` or `policy_file`".into(), + )); + } + }; + let policy_set: PolicySet = policies + .parse() + .map_err(|e: cedar_policy::ParseErrors| BuildError::PolicyParse(e.to_string()))?; + + // ----- optional schema ----- + let schema_text = read_yaml_string(map, "schema_text"); + let schema_file = read_yaml_string(map, "schema_file"); + let schema = match (schema_text, schema_file) { + (Some(text), _) => Some(parse_schema(&text)?), + (None, Some(path)) => { + let text = std::fs::read_to_string(&path).map_err(|source| BuildError::SchemaFile { + path: path.clone(), + source, + })?; + Some(parse_schema(&text)?) + } + (None, None) => None, + }; + + // ----- optional dialect override ----- + let dialect = match read_yaml_string(map, "dialect").as_deref() { + None | Some("cedar") => PdpDialect::Cedar, + Some(other) => PdpDialect::Custom(other.to_string()), + }; + + let entity_namespace = read_yaml_string(map, "entity_namespace"); + + Ok(Self { + policies: Arc::new(policy_set), + schema: schema.map(Arc::new), + authorizer: Authorizer::new(), + dialect, + entity_namespace, + }) + } + + /// Override the resolver's dialect. Lets operators register a Cedar + /// engine under a custom name (e.g. `PdpDialect::Custom("workload")`) + /// so they can coexist with another Cedar engine on the same + /// `PdpRouter`. + pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { + self.dialect = dialect; + self + } + + /// Attach an `entity_namespace`. Applied at request time to + /// subject types: `Some("Acme")` + bag `subject.type=User` → + /// principal UID `Acme::User::""`. + pub fn with_entity_namespace(mut self, namespace: impl Into) -> Self { + self.entity_namespace = Some(namespace.into()); + self + } + + /// Attach a schema after construction. Useful when the schema + /// comes from a separate source than the policy text. + pub fn with_schema(mut self, schema: Schema) -> Self { + self.schema = Some(Arc::new(schema)); + self + } +} + +#[async_trait] +impl PdpResolver for CedarDirectResolver { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + // Resolve `${bag-key}` placeholders in the call's args against + // the bag before any parsing. The author writes things like + // `id: ${args.repo_name}`; this pass turns them into concrete + // values so downstream entity / UID builders can stay literal. + let resolved_args = crate::template::resolve_refs(&call.args, bag)?; + let resolved_call = PdpCall { + dialect: call.dialect.clone(), + args: resolved_args, + }; + + let parsed = parse_call(&resolved_call, bag, self.schema.as_deref())?; + let entities = build_entities( + bag, + parsed.resource_args, + self.schema.as_deref(), + self.entity_namespace.as_deref(), + )?; + + let principal_uid = build_principal_uid(bag, self.entity_namespace.as_deref())?; + let resource_uid = build_resource_uid(parsed.resource_args)?; + + let request = cedar_policy::Request::new( + principal_uid, + parsed.action, + resource_uid, + parsed.context, + self.schema.as_deref(), + ) + .map_err(|e| PdpError::Dispatch(format!("Cedar request validation failed: {}", e)))?; + + let response = self + .authorizer + .is_authorized(&request, &self.policies, &entities); + + Ok(translate(&response, &self.policies)) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn parse_schema(text: &str) -> Result { + Schema::from_cedarschema_str(text) + .map(|(schema, _warnings)| schema) + .map_err(|e| BuildError::SchemaParse(e.to_string())) +} + +fn read_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} + +/// Build the principal `EntityUid` for the Cedar request. Returns the +/// SAME UID that `entities::build_principal` produces; both have to +/// agree on type + id since Cedar resolves the request's principal +/// reference into the entity set by UID equality. +fn build_principal_uid( + bag: &AttributeBag, + namespace: Option<&str>, +) -> Result { + let id = bag + .get_string("subject.id") + .ok_or_else(|| PdpError::Dispatch("bag missing `subject.id`".to_string()))?; + let kind = bag.get_string("subject.type").unwrap_or("User"); + let entity_type = match namespace { + Some(ns) if !ns.is_empty() => format!("{}::{}", ns, kind), + _ => kind.to_string(), + }; + let uid_str = format!("{}::\"{}\"", entity_type, escape_id(id)); + uid_str.parse().map_err(|e| { + PdpError::Dispatch(format!( + "failed to parse principal UID '{}': {}", + uid_str, e + )) + }) +} + +fn build_resource_uid(resource_args: &serde_yaml::Value) -> Result { + let map = resource_args.as_mapping().ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource` must be a mapping".to_string()) + })?; + let type_name = read_yaml_string(map, "type") + .ok_or_else(|| PdpError::Dispatch("cedar:() `resource.type` missing".to_string()))?; + let id = read_yaml_string(map, "id") + .ok_or_else(|| PdpError::Dispatch("cedar:() `resource.id` missing".to_string()))?; + let uid_str = format!("{}::\"{}\"", type_name, escape_id(&id)); + uid_str.parse().map_err(|e| { + PdpError::Dispatch(format!( + "failed to parse resource UID '{}': {}", + uid_str, e + )) + }) +} + +/// Cedar identifiers in double-quoted form need backslash + quote +/// escaping. Most subject IDs are well-behaved (UUIDs, JWT sub +/// claims) — escape defensively for the cases that aren't. +fn escape_id(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/crates/apl-pdp-cedar-direct/src/template.rs b/crates/apl-pdp-cedar-direct/src/template.rs new file mode 100644 index 00000000..1479661e --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/template.rs @@ -0,0 +1,281 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/template.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `${bag-key}` substitution for `cedar:(...)` step args. +// +// APL authors write Cedar requests like: +// +// - cedar: +// action: 'Action::"read"' +// resource: +// type: Repo +// id: ${args.repo_name} +// attributes: +// visibility: ${args.visibility} +// owner_id: ${subject.id} +// +// This module walks the YAML and rewrites any **scalar string** equal to +// `${}` by reading the value from the `AttributeBag`. Strings +// without the `${...}` wrapper pass through unchanged, so policy authors +// can still write literals like `'Action::"read"'` or `User::"alice"` +// without surprise rewrites. +// +// # Why this looks like template substitution and not magic prefixes +// +// An earlier sketch let bare `args.X` strings substitute implicitly. +// That was load-bearing on a single hardcoded namespace and conflated +// "the author meant a placeholder" with "the author meant a string that +// happens to start with `args.`". The `${...}` form is explicit and +// generalizes to any bag key: +// +// ${subject.id} ${subject.type} +// ${role.engineer} ${perm.view_ssn} +// ${claim.email} ${args.repo_name} ${args.user.id} +// ${delegation.granted.audience} ${meta.entity_name} +// +// The vocabulary mirrors the `MessageView` projection (the bag is +// populated by apl-cmf's `extract_security` / `extract_args` from the +// same source data the view sees), so a Cedar resource template and an +// OPA `input.X` rego path can name the same attribute the same way. +// When (in a separate refactor) `AttributeBag` becomes a derived +// projection of `MessageView`, this substitution layer doesn't change — +// it's already reading the normalized vocabulary. +// +// # What gets substituted +// +// - Whole-string match: `${args.repo_name}` → value at `args.repo_name`. +// - Embedded placeholders (`prefix-${args.X}-suffix`) are NOT supported +// in v0; whole-string only. Easy to extend later, but YAGNI today — +// Cedar entity IDs / attrs almost always want the raw value. +// - Missing bag key → loud `PdpError::Dispatch`. Falling back to the +// literal would mask author bugs. +// - Mappings + sequences recurse into their members. + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::step::PdpError; + +/// Recursively walk `value`, substituting any `${}` scalar with +/// the corresponding bag value. Mappings and sequences recurse. Other +/// scalars pass through unchanged. +pub fn resolve_refs( + value: &serde_yaml::Value, + bag: &AttributeBag, +) -> Result { + match value { + serde_yaml::Value::String(s) => { + if let Some(key) = parse_placeholder(s) { + substitute(key, s, bag) + } else { + Ok(value.clone()) + } + } + serde_yaml::Value::Mapping(map) => { + let mut out = serde_yaml::Mapping::new(); + for (k, v) in map { + out.insert(k.clone(), resolve_refs(v, bag)?); + } + Ok(serde_yaml::Value::Mapping(out)) + } + serde_yaml::Value::Sequence(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(resolve_refs(item, bag)?); + } + Ok(serde_yaml::Value::Sequence(out)) + } + _ => Ok(value.clone()), + } +} + +/// Return the inner bag key when `s` is exactly `${}` (whole-string +/// placeholder). Returns `None` for any other shape — including +/// `prefix-${args.X}` (embedded), `$args.X` (no braces), or stray `${` +/// without a matching `}`. +fn parse_placeholder(s: &str) -> Option<&str> { + let inner = s.strip_prefix("${")?.strip_suffix('}')?; + let trimmed = inner.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn substitute( + key: &str, + original: &str, + bag: &AttributeBag, +) -> Result { + let value = bag.get(key).ok_or_else(|| { + PdpError::Dispatch(format!( + "cedar:() references `{}` but the bag has no key `{}` — \ + check the spelling against the projection vocabulary \ + populated by apl-cmf (security / payload extractors)", + original, key + )) + })?; + + Ok(match value { + AttributeValue::String(v) => serde_yaml::Value::String(v.clone()), + AttributeValue::Bool(v) => serde_yaml::Value::Bool(*v), + AttributeValue::Int(v) => serde_yaml::Value::Number((*v).into()), + AttributeValue::Float(v) => serde_yaml::Value::Number(serde_yaml::Number::from(*v)), + AttributeValue::StringSet(set) => { + let items: Vec = set + .iter() + .map(|s| serde_yaml::Value::String(s.clone())) + .collect(); + serde_yaml::Value::Sequence(items) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bag_with(kvs: &[(&str, &str)]) -> AttributeBag { + let mut bag = AttributeBag::new(); + for (k, v) in kvs { + bag.set(*k, *v); + } + bag + } + + #[test] + fn substitutes_args_inside_mapping() { + let bag = bag_with(&[ + ("args.repo_name", "web-app"), + ("args.visibility", "internal"), + ]); + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +type: Repo +id: ${args.repo_name} +attributes: + visibility: ${args.visibility} +"#, + ) + .unwrap(); + + let resolved = resolve_refs(&yaml, &bag).unwrap(); + let map = resolved.as_mapping().unwrap(); + assert_eq!( + map.get(serde_yaml::Value::String("id".into())) + .and_then(|v| v.as_str()), + Some("web-app") + ); + let attrs = map + .get(serde_yaml::Value::String("attributes".into())) + .unwrap() + .as_mapping() + .unwrap(); + assert_eq!( + attrs + .get(serde_yaml::Value::String("visibility".into())) + .and_then(|v| v.as_str()), + Some("internal") + ); + } + + #[test] + fn substitutes_across_namespaces() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("args.repo_name", "core"); + bag.set("claim.email", "alice@corp.com"); + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +owner: ${subject.id} +target: ${args.repo_name} +email: ${claim.email} +"#, + ) + .unwrap(); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + let map = resolved.as_mapping().unwrap(); + assert_eq!( + map.get(serde_yaml::Value::String("owner".into())) + .and_then(|v| v.as_str()), + Some("alice") + ); + assert_eq!( + map.get(serde_yaml::Value::String("target".into())) + .and_then(|v| v.as_str()), + Some("core") + ); + assert_eq!( + map.get(serde_yaml::Value::String("email".into())) + .and_then(|v| v.as_str()), + Some("alice@corp.com") + ); + } + + #[test] + fn passes_through_literal_strings() { + let bag = bag_with(&[("args.x", "ignored")]); + // No `${...}` wrapper → literal. + let yaml = serde_yaml::Value::String("User::\"alice\"".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + assert_eq!(resolved.as_str(), Some("User::\"alice\"")); + // Even bare `args.x` is now a literal — the explicit `${...}` + // form is the only thing that triggers substitution. + let yaml = serde_yaml::Value::String("args.x".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + assert_eq!(resolved.as_str(), Some("args.x")); + } + + #[test] + fn missing_bag_key_errors_loudly() { + let bag = AttributeBag::new(); + let yaml = serde_yaml::Value::String("${args.missing}".into()); + let err = resolve_refs(&yaml, &bag).unwrap_err(); + let msg = format!("{:?}", err); + assert!(msg.contains("args.missing"), "error mentions the key: {}", msg); + } + + #[test] + fn substitutes_typed_values() { + let mut bag = AttributeBag::new(); + bag.set("args.flag", true); + bag.set("args.count", 42i64); + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +flag: ${args.flag} +count: ${args.count} +"#, + ) + .unwrap(); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + let map = resolved.as_mapping().unwrap(); + assert_eq!( + map.get(serde_yaml::Value::String("flag".into())) + .and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + map.get(serde_yaml::Value::String("count".into())) + .and_then(|v| v.as_i64()), + Some(42) + ); + } + + #[test] + fn embedded_placeholders_not_supported_in_v0() { + let bag = bag_with(&[("args.x", "hello")]); + let yaml = serde_yaml::Value::String("prefix-${args.x}-suffix".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + // Whole-string only — embedded `${...}` is left alone. + assert_eq!(resolved.as_str(), Some("prefix-${args.x}-suffix")); + } + + #[test] + fn empty_placeholder_is_literal() { + let bag = AttributeBag::new(); + let yaml = serde_yaml::Value::String("${}".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + assert_eq!(resolved.as_str(), Some("${}")); + } +} diff --git a/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs b/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs new file mode 100644 index 00000000..05400a4b --- /dev/null +++ b/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs @@ -0,0 +1,220 @@ +// Location: ./crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Smoke tests for `CedarDirectResolver`. Cover the canonical +// allow/deny paths, the role-driven case (proves bag attributes reach +// the principal entity), and the policy-id attribution that operators +// rely on for audit logs. + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; + +use apl_pdp_cedar_direct::CedarDirectResolver; + +/// Build a `PdpCall` against `Action::"read"` on a `Document::"doc-1"`. +/// Used across the test cases so the request side stays constant and +/// only the policy + bag varies. +fn read_doc_call() -> PdpCall { + PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::from_str( + r#" +action: 'Action::"read"' +resource: + type: Document + id: doc-1 +"#, + ) + .unwrap(), + } +} + +fn alice_bag() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("subject.type", "User"); + bag +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// One unconditional `permit` policy → request → Allow. Confirms the +/// happy path end-to-end: parse, build entities, build request, +/// authorize, translate decision back. +#[tokio::test] +async fn unconditional_permit_returns_allow() { + const POLICY: &str = r#" + @id("allow-all") + permit(principal, action, resource); + "#; + + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + + assert_eq!(decision.decision, Decision::Allow); + assert_eq!(decision.diagnostics, vec!["allow-all".to_string()]); +} + +/// No policies → default-deny. Confirms the fail-closed default that +/// drops out of Cedar's semantics (no `permit` matches, so the request +/// denies). +#[tokio::test] +async fn empty_policy_set_denies_by_default() { + let resolver = CedarDirectResolver::from_policy_text("").expect("empty policy set is valid"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + + match decision.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!(rule_source, "cedar.default_deny"); + } + other => panic!("expected Deny on empty policy set, got {:?}", other), + } + assert!(decision.diagnostics.is_empty(), "no policies fired"); +} + +/// A policy that requires `principal.roles.contains("hr")`. Bag has +/// `role.hr=true` → reaches principal.roles → Allow. Proves the +/// bag-attribute-to-entity-attribute translation works end-to-end: +/// apl-cmf would normally populate `role.hr` from +/// `SecurityExtension.subject.roles`, but the bag works the same way +/// however it got there. +#[tokio::test] +async fn role_in_bag_reaches_principal_attributes() { + const POLICY: &str = r#" + @id("hr-only") + permit(principal, action == Action::"read", resource) + when { principal.roles.contains("hr") }; + "#; + + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + + // Alice has role.hr → policy permits. + let mut bag = alice_bag(); + bag.set("role.hr", true); + let decision = resolver.evaluate(&read_doc_call(), &bag).await.expect("evaluate"); + assert_eq!(decision.decision, Decision::Allow); + assert_eq!(decision.diagnostics, vec!["hr-only".to_string()]); + + // Bob has no roles → policy doesn't match → default-deny. + let mut bob_bag = AttributeBag::new(); + bob_bag.set("subject.id", "bob"); + bob_bag.set("subject.type", "User"); + let decision = resolver + .evaluate(&read_doc_call(), &bob_bag) + .await + .expect("evaluate"); + match decision.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!( + rule_source, "cedar.default_deny", + "no permit matched → default-deny, not policy-attributed" + ); + } + other => panic!("expected Deny for bob, got {:?}", other), + } +} + +/// A policy with `@id("blocklist")` that forbids access for a specific +/// principal. When the forbid fires, the violation's `rule_source` +/// should carry the policy id so wire errors / audit logs say +/// "denied via blocklist" instead of "denied by Cedar." +#[tokio::test] +async fn forbid_attribution_carries_policy_id() { + const POLICY: &str = r#" + @id("permit-all") + permit(principal, action, resource); + + @id("blocklist") + forbid(principal == User::"alice", action, resource); + "#; + + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + + match decision.decision { + Decision::Deny { rule_source, reason } => { + assert_eq!( + rule_source, "blocklist", + "violation should be attributed to the forbid policy by id" + ); + assert!( + reason.as_deref().unwrap_or("").contains("blocklist"), + "reason should mention the firing policy: {:?}", + reason + ); + } + other => panic!("expected Deny via blocklist, got {:?}", other), + } + assert!(decision.diagnostics.iter().any(|d| d == "blocklist")); +} + +/// Missing `subject.id` in the bag is a configuration fault (identity +/// hook didn't populate it). Resolver returns a Dispatch error rather +/// than silently building a malformed Cedar request. +#[tokio::test] +async fn missing_subject_id_errors_clearly() { + const POLICY: &str = "permit(principal, action, resource);"; + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + + // Empty bag → no subject.id. + let bag = AttributeBag::new(); + let err = resolver + .evaluate(&read_doc_call(), &bag) + .await + .expect_err("should fail with no subject.id"); + + let msg = format!("{}", err); + assert!( + msg.contains("subject.id"), + "error should mention the missing key: {}", + msg + ); +} + +/// Construction from a config block — the path the visitor uses when +/// it sees a Cedar PDP block in unified-config YAML. +#[tokio::test] +async fn from_config_builds_resolver_from_yaml_block() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +dialect: cedar +policy_text: | + @id("from-config") + permit(principal, action, resource); +"#, + ) + .expect("yaml parses"); + + let resolver = CedarDirectResolver::from_config(&yaml).expect("config valid"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + assert_eq!(decision.decision, Decision::Allow); + assert_eq!(decision.diagnostics, vec!["from-config".to_string()]); +} + +/// Operators can register the resolver under a custom dialect to +/// coexist with another Cedar engine on the same PdpRouter. +#[tokio::test] +async fn with_dialect_overrides_default() { + let resolver = CedarDirectResolver::from_policy_text("permit(principal, action, resource);") + .expect("policy parses") + .with_dialect(PdpDialect::Custom("workload".to_string())); + + assert_eq!(resolver.dialect(), PdpDialect::Custom("workload".to_string())); +} diff --git a/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs new file mode 100644 index 00000000..76ec2c11 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs @@ -0,0 +1,166 @@ +// Location: ./crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: a unified-config YAML that +// +// 1. declares a `cedar-direct` PDP under `global.apl.pdp[]`, +// 2. embeds Cedar policy text inline in that declaration, +// 3. attaches a `cedar:(...)` policy step to a route, +// +// must flow a real authorization decision from the cpex-core dispatcher +// through `AplConfigVisitor` → `PdpFactory` → `CedarDirectResolver` → +// Cedar's `Authorizer` → back into the route handler's deny/allow split. +// +// This proves the *wiring* end-to-end. The cedar-direct unit tests in +// `basic_allow_deny.rs` already cover the resolver in isolation; what's +// special here is that the resolver was never instantiated in Rust by +// the test — the visitor built it from YAML at `load_config_yaml` time +// because the host registered `CedarDirectPdpFactory` via +// `AplOptions.pdp_factories`. If this test passes, an operator who +// drops a `cedar-direct` block into their config gets the same behavior +// without writing any glue. + +use std::collections::HashSet; +use std::sync::Arc; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::{ + MetaExtension, SecurityExtension, SubjectExtension, SubjectType, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; +use apl_pdp_cedar_direct::CedarDirectPdpFactory; + +// The configuration the visitor walks. Single Cedar permit policy that +// only fires for principals carrying the `reader` role; everything else +// hits Cedar's default-deny path. +const YAML: &str = r#" +global: + apl: + pdp: + - kind: cedar-direct + policy_text: | + @id("reader-permit") + permit(principal, action == Action::"read", resource) + when { principal.roles.contains("reader") }; +routes: + - tool: get_document + apl: + policy: + - cedar: + action: 'Action::"read"' + resource: + type: Document + id: doc-42 +"#; + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut m = MetaExtension::default(); + m.entity_type = Some("tool".to_string()); + m.entity_name = Some(name.to_string()); + m +} + +/// Build a `SecurityExtension` with the given subject id and roles. The +/// bag-builder lifts these into `subject.id` / `role.` keys, which +/// `entities.rs` reads when constructing the Cedar principal. Anything +/// the policy needs about the principal must come through this surface. +fn security_with_roles(id: &str, roles: &[&str]) -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some(id.to_string()), + subject_type: Some(SubjectType::User), + roles: roles.iter().map(|r| r.to_string()).collect::>(), + ..Default::default() + }), + ..Default::default() + } +} + +async fn build_manager() -> Arc { + let mgr = Arc::new(PluginManager::default()); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + // The factory is the load-bearing wiring under test: the + // visitor sees `kind: cedar-direct` in YAML and finds this + // factory by key. + pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], + base_capabilities: None, + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + mgr +} + +fn payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "fetch doc-42"), + } +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Principal `alice` carries `role.reader=true`, which the permit policy +/// requires. End-to-end: visitor built the resolver from YAML, route +/// handler dispatched the `cedar:` step into that resolver, Cedar +/// returned Allow, the pipeline continues. +#[tokio::test] +async fn config_declared_cedar_pdp_allows_reader() { + let mgr = build_manager().await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &["reader"]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + result.continue_processing, + "reader-permit should allow alice; got violation = {:?}", + result.violation + ); +} + +/// Principal `bob` carries no roles, so the permit's guard +/// (`principal.roles.contains("reader")`) is false and no other policy +/// fires. Cedar default-denies; the route handler maps that to a +/// pipeline-halting violation with `code = cedar.default_deny`. +#[tokio::test] +async fn config_declared_cedar_pdp_denies_non_reader() { + let mgr = build_manager().await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("bob", &[]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + !result.continue_processing, + "missing reader role should default-deny", + ); + let v = result.violation.expect("deny path must surface a violation"); + assert_eq!( + v.code, "cedar.default_deny", + "default-deny path should use the cedar-direct sentinel code; got {}", + v.code + ); +} diff --git a/crates/apl-pii-scanner/Cargo.toml b/crates/apl-pii-scanner/Cargo.toml new file mode 100644 index 00000000..89369aae --- /dev/null +++ b/crates/apl-pii-scanner/Cargo.toml @@ -0,0 +1,28 @@ +# Location: ./crates/apl-pii-scanner/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-pii-scanner — CMF plugin that detects PII patterns (SSN, +# credit card, email) in tool/prompt/resource args and either denies +# the call, taints the session, or redacts the matching values. + +[package] +name = "apl-pii-scanner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +regex = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pii-scanner/src/config.rs b/crates/apl-pii-scanner/src/config.rs new file mode 100644 index 00000000..e0ecdeb7 --- /dev/null +++ b/crates/apl-pii-scanner/src/config.rs @@ -0,0 +1,85 @@ +// Location: ./crates/apl-pii-scanner/src/config.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use serde::{Deserialize, Serialize}; + +/// Plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PiiScannerConfig { + /// Which patterns to detect. Defaults to `[ssn, credit_card]` + /// which covers the common high-signal cases. + #[serde(default = "default_detect")] + pub detect: Vec, + + /// What to do when a match is found. + #[serde(default)] + pub mode: PiiScanMode, +} + +fn default_detect() -> Vec { + vec![PiiPattern::Ssn, PiiPattern::CreditCard] +} + +/// Built-in PII pattern catalog. Patterns chosen for high signal-to- +/// noise on the kinds of values that flow through agent tool calls. +/// Operators can supply a custom regex via `PiiPattern::Custom`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PiiPattern { + /// US Social Security Number: `NNN-NN-NNNN`. + Ssn, + /// Credit-card-like sequences (13-19 digits, optional separators). + /// Note: does NOT Luhn-check — for v0 the regex match is enough + /// to flag. Luhn validation is a future refinement. + CreditCard, + /// Email address. Surprisingly common false-positive risk — + /// operators turn this off if their tools legitimately deal in + /// email addresses (HR directory, contact lists). + Email, + /// Operator-supplied regex. Useful for company-specific IDs + /// (employee IDs that aren't already public, internal account + /// numbers, etc.). + Custom { name: String, regex: String }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PiiScanMode { + /// Return `pii.detected` violation — gateway translates to 403. + /// The strictest mode; the request never reaches downstream. + #[default] + Deny, + /// Replace each matching value with `[PII]` in the outbound + /// payload. Lets the request through but with secrets neutered. + Redact, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn defaults() { + let cfg: PiiScannerConfig = serde_json::from_value(json!({})).unwrap(); + assert_eq!(cfg.detect.len(), 2); + assert!(matches!(cfg.mode, PiiScanMode::Deny)); + } + + #[test] + fn parse_full_config() { + let raw = json!({ + "detect": [ + { "kind": "ssn" }, + { "kind": "custom", "name": "internal_id", "regex": "^INT-[A-Z0-9]{10}$" } + ], + "mode": "redact", + }); + let cfg: PiiScannerConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.detect.len(), 2); + assert!(matches!(cfg.mode, PiiScanMode::Redact)); + } +} diff --git a/crates/apl-pii-scanner/src/factory.rs b/crates/apl-pii-scanner/src/factory.rs new file mode 100644 index 00000000..66f46995 --- /dev/null +++ b/crates/apl-pii-scanner/src/factory.rs @@ -0,0 +1,70 @@ +// Location: ./crates/apl-pii-scanner/src/factory.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use cpex_core::{ + cmf::CmfHook, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::scanner::PiiScanner; + +/// `kind:` string operators write in CPEX YAML to declare a PII +/// scanner instance. +pub const KIND: &str = "validator/pii-scan"; + +/// Factory for `kind: validator/pii-scan`. Instantiates a +/// `PiiScanner` from the `config:` block and registers a handler +/// for every CMF hook name listed in `cfg.hooks`. Operators +/// typically wire it on `cmf.tool_pre_invoke` / +/// `cmf.prompt_pre_invoke` / `cmf.resource_pre_fetch` so it runs +/// before any of those entity types reach the backend. +pub struct PiiScannerFactory; + +impl PluginFactory for PiiScannerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let scanner = Arc::new(PiiScanner::new(config.clone())?); + + // Register the same handler instance against every CMF hook + // name the operator declared in YAML — same plugin, multiple + // entry points. Empty hooks list is a config error. + if config.hooks.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-pii-scanner): `hooks:` must list at \ + least one CMF hook to scan on (e.g. cmf.tool_pre_invoke)", + config.name + ), + })); + } + + let handlers: Vec<_> = config + .hooks + .iter() + .map(|h| -> (&'static str, _) { + // Leak the string to get a 'static lifetime — the + // handler registry stores it that way for cheap + // comparison. PluginConfigs are read once at startup + // and live for the process lifetime, so the leak + // bound is the number of plugin × hook pairs in + // config (small, bounded). + let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&scanner)), + ); + (leaked, adapter) + }) + .collect(); + + Ok(PluginInstance { + plugin: scanner, + handlers, + }) + } +} diff --git a/crates/apl-pii-scanner/src/lib.rs b/crates/apl-pii-scanner/src/lib.rs new file mode 100644 index 00000000..6f5ee532 --- /dev/null +++ b/crates/apl-pii-scanner/src/lib.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-pii-scanner/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-pii-scanner — CMF `HookHandler` that walks the message's +// ToolCall / PromptRequest argument map and tests each string value +// against configured PII patterns. Modes: +// +// * `deny` — return `pii.detected` violation; gateway 403s +// * `taint` — emit a session taint label (downstream policy can +// gate via `session.labels contains 'PII'`) +// * `redact` — replace matching values with `[PII]` and continue +// +// Operators wire it as a `policy:` step: +// +// policy: +// - "require(perm.email_send)" +// - "plugin(pii-scan)" +// +// The plugin registers on whichever CMF pre-invoke hooks the +// operator declares in YAML (tool / prompt / llm / resource). + +pub mod config; +pub mod factory; +pub mod scanner; + +pub use config::{PiiPattern, PiiScanMode, PiiScannerConfig}; +pub use factory::{PiiScannerFactory, KIND}; +pub use scanner::PiiScanner; diff --git a/crates/apl-pii-scanner/src/scanner.rs b/crates/apl-pii-scanner/src/scanner.rs new file mode 100644 index 00000000..3b18c839 --- /dev/null +++ b/crates/apl-pii-scanner/src/scanner.rs @@ -0,0 +1,322 @@ +// Location: ./crates/apl-pii-scanner/src/scanner.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use async_trait::async_trait; +use regex::Regex; +use serde_json::Value; + +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::config::{PiiPattern, PiiScanMode, PiiScannerConfig}; + +/// CMF plugin that walks the message's ToolCall / PromptRequest / +/// ResourceRef arguments and tests each string value against the +/// configured PII patterns. +#[derive(Debug)] +pub struct PiiScanner { + cfg: PluginConfig, + typed: PiiScannerConfig, + /// Compiled regexes paired with the pattern name (for violation + /// attribution). Compiled once at construction; matched per call. + patterns: Vec<(String, Regex)>, +} + +impl PiiScanner { + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-pii-scanner) requires a `config:` block", + cfg.name + ), + }) + })?; + let typed: PiiScannerConfig = + serde_json::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-pii-scanner) config parse failed: {e}", + cfg.name + ), + }) + })?; + + let patterns = compile_patterns(&typed.detect, &cfg.name)?; + Ok(Self { cfg, typed, patterns }) + } + + /// Scan every string value in the message's structured content + /// (ToolCall.arguments, PromptRequest.arguments) plus any text + /// parts. Returns the name of the first matching pattern, or + /// `None` if no match. The pattern name flows into the violation + /// code so audit logs say `pii.detected: ssn` rather than + /// generic `pii.detected`. + fn first_match(&self, message: &Message) -> Option<&str> { + for part in &message.content { + match part { + ContentPart::ToolCall { content } => { + for v in content.arguments.values() { + if let Some(name) = self.match_value(v) { + return Some(name); + } + } + } + ContentPart::PromptRequest { content } => { + for v in content.arguments.values() { + if let Some(name) = self.match_value(v) { + return Some(name); + } + } + } + ContentPart::Text { text } => { + if let Some(name) = self.match_str(text) { + return Some(name); + } + } + _ => {} // images / video / audio / etc. — out of scope for v0 + } + } + None + } + + fn match_value(&self, v: &Value) -> Option<&str> { + match v { + Value::String(s) => self.match_str(s), + // Numbers / bools can't carry PII patterns. Arrays / + // objects could be walked recursively in a future + // version; for now we only flag flat string fields, + // which covers the common LLM tool-call shape. + _ => None, + } + } + + fn match_str(&self, s: &str) -> Option<&str> { + for (name, re) in &self.patterns { + if re.is_match(s) { + return Some(name); + } + } + None + } + + /// Rewrite the message's content: replace any string value that + /// matches a pattern with `[PII]`. Used in `redact` mode. + fn redact_message(&self, message: &mut Message) { + for part in message.content.iter_mut() { + match part { + ContentPart::ToolCall { content } => { + for v in content.arguments.values_mut() { + self.redact_value(v); + } + } + ContentPart::PromptRequest { content } => { + for v in content.arguments.values_mut() { + self.redact_value(v); + } + } + ContentPart::Text { text } => { + if self.match_str(text).is_some() { + *text = "[PII]".to_string(); + } + } + _ => {} + } + } + } + + fn redact_value(&self, v: &mut Value) { + if let Value::String(s) = v { + if self.match_str(s).is_some() { + *v = Value::String("[PII]".to_string()); + } + } + } +} + +fn compile_patterns( + patterns: &[PiiPattern], + plugin_name: &str, +) -> Result, Box> { + let mut out = Vec::with_capacity(patterns.len()); + for p in patterns { + let (name, re_str) = match p { + PiiPattern::Ssn => ("ssn", r"\b\d{3}-\d{2}-\d{4}\b".to_string()), + PiiPattern::CreditCard => ( + "credit_card", + // 13-19 digit sequences with optional spaces / hyphens + // every 4 digits. Liberal — Luhn validation would + // tighten this but isn't needed for the demo signal. + r"\b(?:\d[ -]?){13,19}\b".to_string(), + ), + PiiPattern::Email => ( + "email", + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b".to_string(), + ), + PiiPattern::Custom { name, regex } => (name.as_str(), regex.clone()), + }; + let re = Regex::new(&re_str).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{plugin_name}' (apl-pii-scanner): pattern '{name}' \ + failed to compile: {e}" + ), + }) + })?; + out.push((name.to_string(), re)); + } + Ok(out) +} + +#[async_trait] +impl Plugin for PiiScanner { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for PiiScanner { + async fn handle( + &self, + payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let hit = self.first_match(&payload.message); + match (hit, self.typed.mode) { + (None, _) => PluginResult::allow(), + (Some(pattern_name), PiiScanMode::Deny) => { + PluginResult::deny(PluginViolation::new( + "pii.detected", + format!( + "PII pattern '{pattern_name}' detected in request \ + args — refusing to forward to downstream" + ), + )) + } + (Some(_), PiiScanMode::Redact) => { + let mut updated = payload.clone(); + self.redact_message(&mut updated.message); + PluginResult::modify_payload(updated) + } + } + } +} + +// Silence unused-import in case a feature is added later that needs +// Arc — kept for parity with how other crates structure their imports. +#[allow(dead_code)] +fn _force_link_arc(_: Arc<()>) {} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{Role, ToolCall}; + use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + use serde_json::json; + use std::collections::HashMap; + + fn cfg(detect: Vec, mode: PiiScanMode) -> PluginConfig { + let cfg_json = serde_json::to_value(PiiScannerConfig { detect, mode }).unwrap(); + PluginConfig { + name: "pii-scan".into(), + kind: "test".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(cfg_json), + ..Default::default() + } + } + + fn message_with_args(args: HashMap) -> MessagePayload { + MessagePayload { + message: Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "1".into(), + name: "send_email".into(), + arguments: args, + namespace: None, + }, + }], + ), + } + } + + #[tokio::test] + async fn ssn_in_args_denied() { + let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Deny)).unwrap(); + let payload = message_with_args(HashMap::from([ + ("body".to_string(), json!("Her SSN is 555-12-3456")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(!r.continue_processing, "should deny"); + let v = r.violation.expect("violation present"); + assert_eq!(v.code, "pii.detected"); + assert!(v.reason.contains("ssn")); + } + + #[tokio::test] + async fn clean_args_allowed() { + let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Deny)).unwrap(); + let payload = message_with_args(HashMap::from([ + ("body".to_string(), json!("Quarterly compensation review summary.")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(r.continue_processing); + assert!(r.modified_payload.is_none()); + } + + #[tokio::test] + async fn redact_mode_rewrites_value() { + let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Redact)).unwrap(); + let payload = message_with_args(HashMap::from([ + ("body".to_string(), json!("555-12-3456")), + ("subject".to_string(), json!("payroll question")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(r.continue_processing, "redact allows; doesn't deny"); + let modified = r.modified_payload.expect("payload was modified"); + let args = match &modified.message.content[0] { + ContentPart::ToolCall { content } => &content.arguments, + _ => panic!("expected ToolCall"), + }; + assert_eq!(args["body"], json!("[PII]")); + // Untouched fields preserved. + assert_eq!(args["subject"], json!("payroll question")); + } + + #[tokio::test] + async fn custom_pattern() { + let p = PiiScanner::new(cfg( + vec![PiiPattern::Custom { + name: "internal_id".into(), + regex: r"^INT-[A-Z0-9]{6}$".into(), + }], + PiiScanMode::Deny, + )) + .unwrap(); + let payload = message_with_args(HashMap::from([ + ("ref".to_string(), json!("INT-ABC123")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(!r.continue_processing); + let v = r.violation.expect("violation present"); + assert!(v.reason.contains("internal_id")); + } +} diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 2885700f..abbd5e62 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -29,3 +29,12 @@ futures = { workspace = true } hashbrown = { workspace = true } arc-swap = { workspace = true } wildmatch = { workspace = true } +chrono = { workspace = true } +# Zeroizing wrapper for raw credential material in RawCredentialsExtension. +# `derive` feature pulls the proc-macro so we can `#[derive(Zeroize)]` on +# token-bearing structs in a future slice; for now only the +# `Zeroizing` wrapper is used directly. +zeroize = { version = "1.8", features = ["zeroize_derive"] } +# Shared concurrency primitive used by `executor::run_concurrent_phase` +# (and apl-core's `Effect::Parallel`). Leaf crate, no cycles back here. +cpex-orchestration = { path = "../cpex-orchestration" } diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs index 12a8ac5e..454ec7b2 100644 --- a/crates/cpex-core/src/cmf/constants.rs +++ b/crates/cpex-core/src/cmf/constants.rs @@ -63,3 +63,34 @@ pub const FIELD_TAGS: &str = "tags"; // OPA envelope pub const FIELD_OPA_INPUT: &str = "input"; + +// --------------------------------------------------------------------------- +// Entity type identifiers — used in MetaExtension.entity_type and as the +// keys for `global.defaults` per-entity-type policy groups. These are the +// MCP entity taxonomy: tools (callable functions), LLMs (model +// invocations), prompts (template fills), resources (URI fetches). +// --------------------------------------------------------------------------- + +pub const ENTITY_TOOL: &str = "tool"; +pub const ENTITY_LLM: &str = "llm"; +pub const ENTITY_PROMPT: &str = "prompt"; +pub const ENTITY_RESOURCE: &str = "resource"; + +// --------------------------------------------------------------------------- +// CMF hook names — the canonical names plugins register under and hosts +// pass to `PluginManager::invoke_named::(...)`. Two per entity +// type — pre-invocation (called from APL's policy / args phase) and +// post-invocation (called from APL's post_policy / result phase). +// +// Used as keys in `hooks::metadata`'s routing table and from plugin +// declarations. +// --------------------------------------------------------------------------- + +pub const HOOK_CMF_TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; +pub const HOOK_CMF_TOOL_POST_INVOKE: &str = "cmf.tool_post_invoke"; +pub const HOOK_CMF_LLM_INPUT: &str = "cmf.llm_input"; +pub const HOOK_CMF_LLM_OUTPUT: &str = "cmf.llm_output"; +pub const HOOK_CMF_PROMPT_PRE_INVOKE: &str = "cmf.prompt_pre_invoke"; +pub const HOOK_CMF_PROMPT_POST_INVOKE: &str = "cmf.prompt_post_invoke"; +pub const HOOK_CMF_RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch"; +pub const HOOK_CMF_RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch"; diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index b2bad350..6a13a2ec 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -66,6 +66,20 @@ impl Message { } } + /// Create a message from an arbitrary list of typed content + /// parts. The schema version is set from `SCHEMA_VERSION` — + /// callers never hardcode it. Use this when the content isn't a + /// single text blob (tool calls, prompt requests, resource refs, + /// multimodal mixes). + pub fn with_content(role: Role, content: Vec) -> Self { + Self { + schema_version: super::constants::SCHEMA_VERSION.to_string(), + role, + content, + channel: None, + } + } + /// Extract all text content from the message. /// /// Concatenates text from all `Text` content parts. diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 89d962a2..6eca89a8 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -159,6 +159,16 @@ pub struct GlobalConfig { /// Keys are `tool`, `resource`, `prompt`, `llm`. #[serde(default)] pub defaults: HashMap, + + /// Global identity dispatch list. Inherited by every route as + /// the first layer of identity resolution. Routes can append + /// to it (additive, the default) or replace it (with + /// `identity.replace_inherited: true` on the route). + /// + /// Same YAML shape as the route-level `identity:` block — see + /// `RouteEntry.identity` for the accepted forms. + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, } // --------------------------------------------------------------------------- @@ -181,6 +191,14 @@ pub struct PolicyGroup { /// Plugin references to activate when this group matches. #[serde(default)] pub plugins: Vec, + + /// Identity dispatch list contributed by this tag bundle. + /// Inherited by routes that carry this tag in `meta.tags`, + /// stacked between the global identity (first) and the route's + /// own identity (last). Same YAML shape as the route-level + /// `identity:` block. + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, } // --------------------------------------------------------------------------- @@ -262,6 +280,161 @@ pub struct RouteEntry { /// Plugin references to activate for this route. #[serde(default)] pub plugins: Vec, + + /// Identity-resolve dispatch list for this route. **Hook-specific**: + /// applies ONLY to the `identity.resolve` hook, independent of the + /// `plugins:` block above (which is hook-agnostic and means + /// different things depending on whether APL is annotating the + /// route — `identity:` always means "these plugins fire on + /// identity.resolve in this order"). + /// + /// Accepts two YAML shapes; both deserialize to the same IR. + /// See `crate::identity::route_config::RouteIdentityConfig`. + /// + /// ```yaml + /// # List form — common case, additive default + /// identity: + /// - corp-jwt + /// - spiffe-attestor + /// + /// # Object form — when the override flag is needed + /// identity: + /// replace_inherited: true + /// steps: + /// - legacy-basic-auth + /// ``` + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, +} + +// --------------------------------------------------------------------------- +// Custom Deserialize for RouteEntry.identity +// --------------------------------------------------------------------------- + +/// Deserialize `identity:` in a `RouteEntry`. Accepts either a YAML +/// list (treated as additive — `replace_inherited: false`) or a +/// YAML map with `replace_inherited: bool?` + `steps: [...]`. Each +/// step is either a bare plugin name (string) or a map with +/// `name:` + optional `on_error:` / `config:`. Produces friendlier +/// error messages than `#[serde(untagged)]` would. +fn deserialize_route_identity<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use crate::identity::RouteIdentityConfig; + use serde::de::Error; + + // Two-stage: deserialize as opaque YAML so we can discriminate + // list vs object shape with operator-friendly errors. + let raw = match Option::::deserialize(deserializer)? { + None => return Ok(None), + Some(serde_yaml::Value::Null) => return Ok(None), + Some(v) => v, + }; + + let (replace_inherited, raw_steps): (bool, Vec) = match raw { + serde_yaml::Value::Sequence(items) => (false, items), + serde_yaml::Value::Mapping(map) => { + let replace_inherited = match map + .get(serde_yaml::Value::String("replace_inherited".to_string())) + { + Some(v) => v.as_bool().ok_or_else(|| { + D::Error::custom("`identity.replace_inherited` must be a boolean") + })?, + None => false, + }; + let steps_val = map + .get(serde_yaml::Value::String("steps".to_string())) + .ok_or_else(|| { + D::Error::custom( + "`identity:` object form requires `steps:` (a list of \ + identity steps); did you mean to write the list form?", + ) + })?; + let items = steps_val + .as_sequence() + .ok_or_else(|| D::Error::custom("`identity.steps` must be a list"))? + .clone(); + (replace_inherited, items) + } + _ => { + return Err(D::Error::custom( + "`identity:` must be a list of steps or an object with \ + `steps:` (and optional `replace_inherited:`)", + )); + } + }; + + let mut steps = Vec::with_capacity(raw_steps.len()); + for (i, raw) in raw_steps.into_iter().enumerate() { + steps.push(parse_identity_step(raw, i).map_err(D::Error::custom)?); + } + + Ok(Some(RouteIdentityConfig { + steps, + replace_inherited, + })) +} + +/// Parse one identity step from raw YAML. Accepts either a bare +/// plugin name (string) or a map with `name:` + optional +/// `on_error:` / `config:` (and any forward-compat extras). +fn parse_identity_step( + raw: serde_yaml::Value, + index: usize, +) -> Result { + use crate::identity::RouteIdentityStep; + + match raw { + serde_yaml::Value::String(name) => { + if name.is_empty() { + return Err(format!( + "identity step [{index}] plugin name cannot be empty" + )); + } + Ok(RouteIdentityStep { + name, + ..Default::default() + }) + } + serde_yaml::Value::Mapping(_) => { + // Lean on serde's derived Deserialize for the map shape — + // `RouteIdentityStep` already handles `name` / `on_error` / + // `config_override` and flattens extras into `extra`. + // Translate the operator-facing key `config` → IR field + // `config_override` (the IR uses a more explicit name to + // distinguish from the plugin's runtime config). + #[derive(serde::Deserialize)] + struct StepYaml { + name: String, + #[serde(default)] + on_error: Option, + #[serde(default)] + config: Option, + #[serde(default, flatten)] + extra: std::collections::HashMap, + } + let parsed: StepYaml = serde_yaml::from_value(raw) + .map_err(|e| format!("identity step [{index}]: {e}"))?; + if parsed.name.is_empty() { + return Err(format!( + "identity step [{index}] `name:` cannot be empty" + )); + } + Ok(RouteIdentityStep { + name: parsed.name, + config_override: parsed.config, + on_error: parsed.on_error, + extra: parsed.extra, + }) + } + _ => Err(format!( + "identity step [{index}] must be a plugin name (string) or a map \ + with `name:` (and optional `on_error:` / `config:`)" + )), + } } // --------------------------------------------------------------------------- @@ -581,6 +754,107 @@ pub fn resolve_plugins_for_entity( deduped } +/// Resolve the identity-resolve dispatch list for a specific +/// entity. Hook-specific counterpart to [`resolve_plugins_for_entity`] +/// — consults `global.identity`, tag-bundle `identity` blocks, and +/// the route's own `identity:` block to determine which plugins fire +/// on the `identity.resolve` hook for this route. +/// +/// # Inheritance / merge order +/// +/// Layers are stacked **global → tag bundles → route**, in that +/// order. Within tags, the order is determined by the request's +/// `meta.tags` (which combines static route tags + runtime request +/// tags). Each layer is appended to the running list unless the +/// **route's** block has `replace_inherited: true`, in which case +/// inherited layers (global + tags) are dropped and only the route's +/// steps remain. Tag-bundle `replace_inherited` is parsed but not +/// honored — only the route layer can opt out of inheritance. +/// +/// Order matters: returned plugins fire in the order they were +/// merged. The first plugin's resolved `IdentityPayload` flows into +/// the second plugin's input via the executor's Sequential-phase +/// semantics, so global identity contributions land first, then +/// tag-bundle, then route-specific overrides / additions. +/// +/// Per-step `config_override` is surfaced as +/// `ResolvedPlugin.config_overrides` so the standard +/// `filter_entries_by_route` override pathway +/// (`create_override_instance`) applies — same mechanism the +/// `plugins:` block uses. +/// +/// Returns an empty `Vec` when no layer contributed any steps +/// (e.g. anonymous routes that explicitly opt out via +/// `replace_inherited: true` + empty `steps: []`). +pub fn resolve_identity_plugins_for_route( + config: &CpexConfig, + entity_type: &str, + entity_name: &str, + request_scope: Option<&str>, +) -> Vec { + // Route-level block is the override authority. Find the matching + // route up-front; absence means there's no route to inherit + // identity FOR (still consult global identity though, since the + // host might be doing per-route hook routing on entity_type + // alone with no specific route). + let route = find_matching_route(config, entity_type, entity_name, request_scope); + let route_identity = route.and_then(|r| r.identity.as_ref()); + + // Check the override flag before doing any inheritance work — + // if the route opts out, inherited layers are dropped. + let replace_inherited = route_identity + .map(|id| id.replace_inherited) + .unwrap_or(false); + + let mut steps: Vec = Vec::new(); + + if !replace_inherited { + // Global layer first — applies to every route. + if let Some(global_identity) = config.global.identity.as_ref() { + steps.extend(global_identity.steps.iter().cloned()); + } + + // Tag-bundle layers next. Walk the route's tags (static + + // any runtime tags would compose here too, but resolve_* + // currently doesn't take runtime tags as a parameter for + // identity — symmetry with the existing `plugins:` resolver + // would extend the signature; deferred until needed). + if let Some(route) = route { + if let Some(meta) = &route.meta { + for tag in &meta.tags { + if let Some(bundle) = config.global.policies.get(tag) { + if let Some(bundle_identity) = bundle.identity.as_ref() { + steps.extend(bundle_identity.steps.iter().cloned()); + } + } + } + } + } + } + + // Route layer last (or only, when replace_inherited). + if let Some(id) = route_identity { + steps.extend(id.steps.iter().cloned()); + } + + steps + .into_iter() + .map(|step| ResolvedPlugin { + name: step.name.clone(), + // Surface config_override under the `config:` key shape + // that `create_override_instance` already understands — + // it reads `overrides.get("config")` to find the merge + // target. Wrapping like this avoids a special-case path. + config_overrides: step.config_override.as_ref().map(|cfg| { + let mut wrapper = serde_json::Map::new(); + wrapper.insert("config".to_string(), cfg.clone()); + serde_json::Value::Object(wrapper) + }), + when: None, + }) + .collect() +} + /// A resolved plugin with optional config overrides and when clause. #[derive(Debug, Clone)] pub struct ResolvedPlugin { @@ -1294,4 +1568,439 @@ routes: let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap(); assert_eq!(route.when.as_deref(), Some("args.sensitive == true")); } + + // ---- route-level `identity:` block ---- + + #[test] + fn parse_route_identity_list_form() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - corp-jwt + - spiffe-attestor +"#; + let cfg = parse_config(yaml).unwrap(); + let route = &cfg.routes[0]; + let id = route.identity.as_ref().expect("identity present"); + assert!(!id.replace_inherited); + assert_eq!(id.steps.len(), 2); + assert_eq!(id.steps[0].name, "corp-jwt"); + assert!(id.steps[0].config_override.is_none()); + assert!(id.steps[0].on_error.is_none()); + assert_eq!(id.steps[1].name, "spiffe-attestor"); + } + + #[test] + fn parse_route_identity_object_form_carries_replace_inherited() { + let yaml = r#" +plugins: + - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: legacy + identity: + replace_inherited: true + steps: + - legacy-basic-auth +"#; + let cfg = parse_config(yaml).unwrap(); + let id = cfg.routes[0].identity.as_ref().unwrap(); + assert!(id.replace_inherited); + assert_eq!(id.steps.len(), 1); + assert_eq!(id.steps[0].name, "legacy-basic-auth"); + } + + #[test] + fn parse_route_identity_map_step_with_on_error_and_config() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - name: corp-jwt + on_error: deny + config: + audience: my-tool +"#; + let cfg = parse_config(yaml).unwrap(); + let id = cfg.routes[0].identity.as_ref().unwrap(); + let s0 = &id.steps[0]; + assert_eq!(s0.name, "corp-jwt"); + assert_eq!(s0.on_error.as_deref(), Some("deny")); + let cfg_override = s0.config_override.as_ref().expect("config_override set"); + assert_eq!( + cfg_override.get("audience").and_then(|v| v.as_str()), + Some("my-tool"), + ); + } + + #[test] + fn parse_route_identity_mixed_bare_and_map_steps() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - name: corp-jwt + on_error: deny + - spiffe-attestor +"#; + let cfg = parse_config(yaml).unwrap(); + let steps = &cfg.routes[0].identity.as_ref().unwrap().steps; + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].on_error.as_deref(), Some("deny")); + assert!(steps[1].on_error.is_none()); + } + + #[test] + fn parse_route_identity_object_form_without_steps_errors() { + let yaml = r#" +routes: + - tool: bad + identity: + replace_inherited: true +"#; + let err = parse_config(yaml).expect_err("object form requires steps"); + let msg = format!("{err}"); + assert!(msg.contains("requires `steps:`"), "got: {msg}"); + } + + #[test] + fn parse_route_identity_replace_inherited_must_be_boolean() { + let yaml = r#" +routes: + - tool: bad + identity: + replace_inherited: "yes" + steps: + - corp-jwt +"#; + let err = parse_config(yaml).expect_err("replace_inherited must be bool"); + let msg = format!("{err}"); + assert!(msg.contains("boolean"), "got: {msg}"); + } + + #[test] + fn parse_route_identity_empty_step_name_errors() { + let yaml = r#" +routes: + - tool: bad + identity: + - "" +"#; + let err = parse_config(yaml).expect_err("empty step name should fail"); + let msg = format!("{err}"); + assert!(msg.contains("empty"), "got: {msg}"); + } + + #[test] + fn parse_route_identity_scalar_shape_errors() { + let yaml = r#" +routes: + - tool: bad + identity: 42 +"#; + let err = parse_config(yaml).expect_err("scalar identity should fail"); + let msg = format!("{err}"); + assert!(msg.contains("list of steps"), "got: {msg}"); + } + + // ---- resolve_identity_plugins_for_route ---- + + #[test] + fn resolve_identity_returns_empty_when_no_route_matches() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - corp-jwt +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None); + assert!(resolved.is_empty()); + } + + #[test] + fn resolve_identity_returns_empty_when_route_has_no_identity_block() { + let yaml = r#" +plugins: + - { name: rate_limiter, kind: builtin, hooks: [tool_pre_invoke] } +routes: + - tool: get_weather + plugins: + - rate_limiter +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + assert!(resolved.is_empty()); + } + + #[test] + fn resolve_identity_preserves_declared_order() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } + - { name: agent-context, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - spiffe-attestor + - corp-jwt + - agent-context +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["spiffe-attestor", "corp-jwt", "agent-context"]); + } + + #[test] + fn resolve_identity_per_step_config_override_surfaces_for_create_override_instance() { + // `create_override_instance` reads `overrides.get("config")` + // — `resolve_identity_plugins_for_route` wraps the step's + // `config_override` under that key so the existing override + // pathway picks it up without a special case. + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - name: corp-jwt + config: + audience: my-tool +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + assert_eq!(resolved.len(), 1); + let overrides = resolved[0] + .config_overrides + .as_ref() + .expect("overrides wrapped"); + let config = overrides.get("config").expect("config key present"); + assert_eq!(config.get("audience").and_then(|v| v.as_str()), Some("my-tool")); + } + + // ---- Slice C: global + tag-bundle inheritance ---- + + #[test] + fn resolve_identity_includes_global_layer_when_route_has_no_block() { + // global.identity defined; route declares no identity. The + // route should inherit the global steps unchanged. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt +routes: + - tool: get_weather +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["corp-jwt"]); + } + + #[test] + fn resolve_identity_appends_route_steps_after_global_by_default() { + // global → route is the standard stacking. Route's `identity:` + // is the list form (implicit replace_inherited=false), so + // its steps APPEND after the global's. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: agent-context, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt +routes: + - tool: get_weather + identity: + - agent-context +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["corp-jwt", "agent-context"]); + } + + #[test] + fn resolve_identity_stacks_global_then_tag_bundle_then_route() { + // Full stack: global + tag bundle + route, all contributing. + // Order is global first, then the matching tag's bundle, + // then the route's own steps. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } + - { name: agent-context, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml +routes: + - tool: get_compensation + meta: + tags: [finance] + identity: + - agent-context +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["corp-jwt", "workday-saml", "agent-context"]); + } + + #[test] + fn resolve_identity_replace_inherited_drops_global_and_tag_layers() { + // Route says `replace_inherited: true` → only route's steps + // survive. Global and tag-bundle contributions get dropped. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } + - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml +routes: + - tool: legacy_endpoint + meta: + tags: [finance] + identity: + replace_inherited: true + steps: + - legacy-basic-auth +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["legacy-basic-auth"]); + } + + #[test] + fn resolve_identity_replace_inherited_with_empty_steps_yields_nothing() { + // `replace_inherited: true` + `steps: []` is the explicit + // opt-out — anonymous routes use this to suppress inherited + // identity entirely. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt +routes: + - tool: anonymous_endpoint + identity: + replace_inherited: true + steps: [] +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None); + assert!(resolved.is_empty()); + } + + #[test] + fn resolve_identity_tag_bundle_only_when_route_carries_the_tag() { + // The tag bundle's identity only contributes when the route + // declares the matching tag — not for unrelated routes. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } +global: + policies: + finance: + identity: + - workday-saml +routes: + - tool: with_tag + meta: + tags: [finance] + - tool: without_tag +"#; + let cfg = parse_config(yaml).unwrap(); + + let tagged = + resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None); + assert_eq!( + tagged.iter().map(|r| r.name.as_str()).collect::>(), + vec!["workday-saml"], + ); + + let untagged = + resolve_identity_plugins_for_route(&cfg, "tool", "without_tag", None); + assert!(untagged.is_empty(), "tag bundle should NOT apply to untagged routes"); + } + + #[test] + fn resolve_identity_scope_filtering_matches_other_route_resolution() { + // Identity routing uses the same `find_matching_route` + // scope-aware matcher as the generic `plugins:` resolution, + // so requests for a different scope shouldn't pick up + // identity from this route. + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + meta: + scope: tenant-a + identity: + - corp-jwt +"#; + let cfg = parse_config(yaml).unwrap(); + let matching = resolve_identity_plugins_for_route( + &cfg, + "tool", + "get_weather", + Some("tenant-a"), + ); + assert_eq!(matching.len(), 1); + + let non_matching = resolve_identity_plugins_for_route( + &cfg, + "tool", + "get_weather", + Some("tenant-b"), + ); + assert!(non_matching.is_empty()); + } } diff --git a/crates/cpex-core/src/delegation/hook.rs b/crates/cpex-core/src/delegation/hook.rs new file mode 100644 index 00000000..9b001514 --- /dev/null +++ b/crates/cpex-core/src/delegation/hook.rs @@ -0,0 +1,86 @@ +// Location: ./crates/cpex-core/src/delegation/hook.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `TokenDelegateHook` — the `HookTypeDef` marker for the +// TokenDelegate hook family. Plugins implement +// `HookHandler`; outbound code dispatches into it +// to mint a downstream-scoped credential for the call it's about to +// make. +// +// Single hook name (for now): `"token.delegate"`. Future variants +// with the same payload shape — e.g. `"token.refresh"` for a +// refresh-token specific flow — could share `TokenDelegateHook` via +// multi-name registration. Variants with different payloads get +// their own hook type rather than reusing this one. + +use crate::hooks::trait_def::PluginResult; + +use super::payload::DelegationPayload; + +/// Primary hook name for TokenDelegate handlers. +pub const HOOK_TOKEN_DELEGATE: &str = "token.delegate"; + +crate::define_hook! { + /// Token-delegation hook. + /// + /// **Payload** ([`DelegationPayload`]) — unified input + accumulator. + /// The outbound caller (typically a forwarding-proxy plugin) + /// populates the input fields (`bearer_token`, `target_name`, + /// `target_audience`, `required_permissions`, …) and invokes the + /// hook; handlers populate the output fields + /// (`delegated_token`, `delegation_update`, `metadata`) on clones + /// of the running payload. Input fields are private and read + /// through accessors — handlers cannot mutate them even on a + /// clone, so the delegation context is canonical across the chain. + /// + /// **Result** ([`PluginResult`][PluginResult]) + /// — the executor's standard envelope. `modified_payload` + /// carries the updated payload. `continue_processing = false` + /// halts the pipeline (handler decided no credential can be + /// minted — e.g. the inbound token's scopes don't cover the + /// target's required permissions). + /// + /// **Threading.** Sequential-phase semantics already thread + /// handler N's `modified_payload` into handler N+1's input, so + /// the chain's natural behavior is "each handler sees the prior + /// handler's contributions in the running payload." Most + /// deployments will register exactly one TokenDelegate handler + /// (RFC 8693 exchanger, UCAN minter, …), but chaining works for + /// hybrid setups — e.g. a passthrough fallback that fires only + /// when the primary exchanger declined. + /// + /// **Handler signature:** + /// + /// ```rust,ignore + /// impl HookHandler for RfcExchanger { + /// async fn handle( + /// &self, + /// payload: &DelegationPayload, + /// _ext: &Extensions, + /// _ctx: &mut PluginContext, + /// ) -> PluginResult { + /// let minted = self + /// .exchange(payload.bearer_token(), payload.target_audience()) + /// .await?; + /// let mut updated = payload.clone(); + /// updated.delegated_token = Some(minted); + /// PluginResult::modify_payload(updated) + /// } + /// } + /// ``` + /// + /// **Registration:** + /// `manager.register_handler_for_names::(plugin, config, &["token.delegate"])`. + /// `register_handler::` alone registers + /// under the marker's `NAME` ("token") which is the hook family, + /// not the specific hook name — `register_handler_for_names` + /// (or the unified-name path) is the right call. + /// + /// [PluginResult]: crate::hooks::trait_def::PluginResult + TokenDelegateHook, "token.delegate" => { + payload: DelegationPayload, + result: PluginResult, + } +} diff --git a/crates/cpex-core/src/delegation/mod.rs b/crates/cpex-core/src/delegation/mod.rs new file mode 100644 index 00000000..af86a83c --- /dev/null +++ b/crates/cpex-core/src/delegation/mod.rs @@ -0,0 +1,21 @@ +// Location: ./crates/cpex-core/src/delegation/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Token-delegation hook family — TokenDelegate. +// +// Mirrors the identity/ module layout: the hook marker + handler +// trait machinery (provided by cpex-core's generic hooks layer) +// plus the hook-specific payload + result types. +// +// Sub-step A scope: data shapes + host helpers — no executor +// wiring (that's free via `mgr.invoke_named::`), +// no TokenCacheControl trait (that lands in a follow-up slice with +// the cache infrastructure). + +pub mod hook; +pub mod payload; + +pub use hook::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}; +pub use payload::{AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType}; diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs new file mode 100644 index 00000000..6328d088 --- /dev/null +++ b/crates/cpex-core/src/delegation/payload.rs @@ -0,0 +1,694 @@ +// Location: ./crates/cpex-core/src/delegation/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `DelegationPayload` — the unified state struct threaded through the +// TokenDelegate hook chain. Same input/output split pattern as +// `IdentityPayload` (slice 2): +// +// * **Input** (private — host-supplied, never mutated by handlers) — +// `bearer_token`, `target_name`, `target_type`, `target_audience`, +// `required_permissions`, `trust_domain`, `auth_enforced_by`, +// `route_attenuation`. Set once at the call site that needs to mint +// a downstream credential. Privacy is enforced at the module +// boundary: external code reads through accessors and has no +// setters or mutable field access. +// +// * **Accumulating output** (`pub` fields) — `delegated_token` and +// `delegation_update`. Handlers clone the payload, populate these, +// return the updated payload via `PluginResult::modify_payload`. +// +// # Where this hook fits +// +// IdentityResolve (slice 2) is *inbound* — validates the caller's +// credentials at request entry, populates `security.subject` / +// `security.client` / `security.caller_workload`. TokenDelegate is +// *outbound* — when a plugin (typically a forwarding proxy) needs to +// make a downstream call to a tool or agent, it asks for an +// appropriately-scoped credential for that target. A handler (RFC +// 8693 token exchanger, UCAN minter, passthrough) produces the +// minted token; the framework stashes it in +// `Extensions.raw_credentials.delegated_tokens` for the proxy plugin +// to attach on the upstream request. +// +// # Caching +// +// Not in this slice. The spec describes a `TokenCacheControl` trait +// at §9.8 that wraps this hook with `get_or_mint(audience, scopes)` +// semantics — outbound callers ask the trait for a token; the trait +// hits the cache first and only dispatches through the hook on cache +// miss. That layer lives one slice later. For now, every +// `mgr.invoke_named::(...)` re-runs the chain. +// +// # Rejection +// +// Same as IdentityResolve: handlers reject via +// `PluginResult::deny(PluginViolation::new(code, reason))`. The +// executor halts the chain; no later handler runs and the request +// fails with the violation surfaced to the host. No `rejected` flag +// on the payload. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::executor::PipelineResult; +use crate::extensions::raw_credentials::DelegationMode; +use crate::extensions::{ + DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken, +}; +use crate::impl_plugin_payload; + +/// Kind of downstream entity the credential is being minted for. +/// `Custom(String)` is the escape hatch for host-defined entity +/// types beyond the well-known shapes. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetType { + /// A tool invocation (MCP tool, function call). + Tool, + /// An agent — another LLM-driven actor. + Agent, + /// A static resource (file, URL, document store entry). + Resource, + /// A service (microservice, internal API). + Service, + /// Operator-defined target kind. + #[serde(untagged)] + Custom(String), +} + +impl Default for TargetType { + fn default() -> Self { + TargetType::Tool + } +} + +/// Who's responsible for enforcing authorization on the downstream +/// call. From the `ObjectSecurityProfile` of the target. Determines +/// whether the gateway brokers credentials (`Caller`), trusts the +/// target to handle auth itself (`Target`), or both layers enforce +/// (`Both`). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthEnforcedBy { + /// Caller (the gateway / our process) enforces — typical for + /// internal services that trust the gateway's authorization + /// decision. + Caller, + /// Target enforces — typical for external services with their + /// own access control. We may still attach credentials but the + /// downstream makes the final allow/deny decision. + Target, + /// Both layers enforce — defense in depth. + Both, +} + +impl Default for AuthEnforcedBy { + fn default() -> Self { + AuthEnforcedBy::Caller + } +} + +/// Scope-attenuation config carried from the route DSL. Lets the +/// route author narrow what the minted credential is allowed to do +/// beyond the broad authorization the inbound credential carried. +/// +/// `resource_template` is a templated URI (e.g. +/// `"hr://employees/{{ args.employee_id }}"`) that the framework +/// renders against request-time arguments before passing into the +/// minted token's scope claim. v0 doesn't include a template +/// renderer — handlers receive the raw template string and render +/// themselves; a framework-side renderer can come later. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AttenuationConfig { + /// Specific capabilities the route author wants granted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, + + /// URI template for the resource being accessed. Unrendered — + /// handlers substitute `{{ args.* }}` placeholders themselves + /// using request context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_template: Option, + + /// Actions allowed on the resource (read / write / delete / + /// custom verbs). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec, + + /// Token lifetime override in seconds. `None` lets the handler + /// pick its default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, +} + +/// State threaded through the TokenDelegate hook chain. +/// +/// See the module-level docs for the input/output split. Input +/// fields are private (set once via the constructor + builders, +/// never mutated). Output fields are `pub` (handlers populate on +/// clones and return the updated payload). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationPayload { + // ----- Input (private — caller-supplied, never mutated by handlers) ----- + /// The caller's current credential — the one a token-exchange + /// handler will swap for a downstream-scoped credential. Cleared + /// on drop via `Zeroizing`. `#[serde(skip)]` — never appears in + /// serialized output. + #[serde(skip)] + bearer_token: Zeroizing, + + /// Name of the tool / agent / resource being called. + target_name: String, + + /// Kind of downstream entity. + #[serde(default)] + target_type: TargetType, + + /// Audience URI for the target, from route config. + #[serde(default, skip_serializing_if = "Option::is_none")] + target_audience: Option, + + /// Required permissions from the target's `ObjectSecurityProfile`. + /// Handlers must produce a credential that grants these (or fail). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + required_permissions: Vec, + + /// Target's trust domain (SPIFFE-style) — useful for handlers + /// that mint workload-identity tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + trust_domain: Option, + + /// Who's responsible for enforcing authorization. + #[serde(default)] + auth_enforced_by: AuthEnforcedBy, + + /// Scope-attenuation config from the route DSL. + #[serde(default, skip_serializing_if = "Option::is_none")] + route_attenuation: Option, + + // ----- Output (pub — handlers populate via direct assignment on clones) ----- + /// The minted outbound credential. `None` until a handler + /// produces one. Carries the raw bytes (cleared on drop), the + /// header the proxy plugin should attach it under, the + /// audience it was minted for, the effective scopes, and the + /// expiry timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegated_token: Option, + + /// Chain update — the new hop to append to the running + /// `DelegationExtension`. Handlers append themselves to the + /// chain so audit / policy can trace who delegated to whom. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation_update: Option, + + /// What kind of principal the minted token represents. + /// Handlers populating `delegated_token` should also set this + /// so `apply_to_extensions` keys the cache correctly: + /// + /// * `OnBehalfOfUser` — token speaks for the original user + /// (RFC 8693 on-behalf-of / actor-token, UCAN delegation). + /// Standard flow; cache key includes the user's subject id. + /// * `AsGateway` — token speaks for the gateway itself. + /// User identity is conveyed through separate context. + /// Cache key falls back to the gateway's identity. + /// + /// `None` defaults to `OnBehalfOfUser` for backward compatibility + /// with handlers that don't yet populate the field. Long-term, + /// handlers should always set this explicitly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation_mode: Option, + + /// Resolution timestamp. Audit-useful. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minted_at: Option>, + + /// Optional metadata produced by the handler (telemetry, + /// diagnostics). Not load-bearing for policy. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} + +impl DelegationPayload { + /// Construct a payload with the required input fields populated. + /// The most common entry point — outbound callers (forwarding + /// proxies, etc.) build this once per delegation point. Optional + /// input slots are set via the `.with_*` builders below; output + /// fields start as `None` / empty and accumulate as handlers run. + pub fn new( + bearer_token: impl Into, + target_name: impl Into, + ) -> Self { + Self { + bearer_token: Zeroizing::new(bearer_token.into()), + target_name: target_name.into(), + target_type: TargetType::Tool, + target_audience: None, + required_permissions: Vec::new(), + trust_domain: None, + auth_enforced_by: AuthEnforcedBy::Caller, + route_attenuation: None, + delegated_token: None, + delegation_update: None, + delegation_mode: None, + minted_at: None, + metadata: HashMap::new(), + } + } + + // -------- Input builders -------- + + pub fn with_target_type(mut self, t: TargetType) -> Self { + self.target_type = t; + self + } + + pub fn with_target_audience(mut self, aud: impl Into) -> Self { + self.target_audience = Some(aud.into()); + self + } + + pub fn with_required_permissions(mut self, perms: Vec) -> Self { + self.required_permissions = perms; + self + } + + pub fn with_trust_domain(mut self, td: impl Into) -> Self { + self.trust_domain = Some(td.into()); + self + } + + pub fn with_auth_enforced_by(mut self, who: AuthEnforcedBy) -> Self { + self.auth_enforced_by = who; + self + } + + pub fn with_route_attenuation(mut self, cfg: AttenuationConfig) -> Self { + self.route_attenuation = Some(cfg); + self + } + + // -------- Input read accessors -------- + + /// The caller's bearer token — borrowed, no way to move or + /// replace the underlying `Zeroizing` through this. + pub fn bearer_token(&self) -> &str { + &self.bearer_token + } + + pub fn target_name(&self) -> &str { + &self.target_name + } + + pub fn target_type(&self) -> &TargetType { + &self.target_type + } + + pub fn target_audience(&self) -> Option<&str> { + self.target_audience.as_deref() + } + + pub fn required_permissions(&self) -> &[String] { + &self.required_permissions + } + + pub fn trust_domain(&self) -> Option<&str> { + self.trust_domain.as_deref() + } + + pub fn auth_enforced_by(&self) -> AuthEnforcedBy { + self.auth_enforced_by + } + + pub fn route_attenuation(&self) -> Option<&AttenuationConfig> { + self.route_attenuation.as_ref() + } + + // -------- Output helpers -------- + + /// Layer another payload's *output* fields onto this one's, + /// following "Some replaces None, last write wins per slot." + /// Input fields are not touched — the running payload's input + /// is canonical for the whole chain. + /// + /// Metadata is merged (not replaced) — `other`'s keys overlay + /// `self`'s, matching the "later handler additively contributes + /// telemetry" expectation. + pub fn merge(&mut self, other: DelegationPayload) { + if other.delegated_token.is_some() { + self.delegated_token = other.delegated_token; + } + if other.delegation_update.is_some() { + self.delegation_update = other.delegation_update; + } + if other.delegation_mode.is_some() { + self.delegation_mode = other.delegation_mode; + } + if other.minted_at.is_some() { + self.minted_at = other.minted_at; + } + for (k, v) in other.metadata { + self.metadata.insert(k, v); + } + } + + // -------- Host-side application helpers -------- + + /// Pull the resolved `DelegationPayload` out of a `PipelineResult` + /// returned by `mgr.invoke_named::(...)`. + /// Returns `None` when the pipeline was denied or when the result's + /// payload wasn't a `DelegationPayload`. Same contract as + /// `IdentityPayload::from_pipeline_result`. + pub fn from_pipeline_result(result: &PipelineResult) -> Option { + result + .modified_payload + .as_ref() + .and_then(|p| p.as_any().downcast_ref::()) + .cloned() + } + + /// Apply this payload's resolved output slots back into an + /// `Extensions` container. Returns a new `Extensions` ready to + /// hand to the outbound proxy plugin that will attach the minted + /// credential and forward. + /// + /// Application rules: + /// + /// - **`raw_credentials.delegated_tokens`** — if the payload + /// carries a `delegated_token`, it's inserted into the map under + /// a `DelegationKey` derived from the input fields (audience, + /// subject not yet plumbed — see "Open work" below). Pre-existing + /// delegated tokens are preserved. + /// - **`delegation`** — `delegation_update` overlays on top of + /// the existing chain (Some replaces None / appends). + /// + /// # Open work + /// + /// The `DelegationKey` we synthesize here uses only fields the + /// payload knows about — `audience`, `scopes` (derived from the + /// effective scopes on the minted token), `mode`. The `subject_id` + /// field of `DelegationKey` requires reading the request's + /// `Extensions.security.subject.id`; we plumb that lookup here + /// rather than asking outbound callers to thread the subject + /// through. If `security.subject.id` is absent the key falls back + /// to the empty string — flagged via tracing but not fatal, + /// because some delegation flows are gateway-as-principal + /// (AsGateway mode) and don't need a subject. + pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions { + if let Some(ref token) = self.delegated_token { + use crate::extensions::raw_credentials::DelegationKey; + + let subject_id = ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()) + .unwrap_or_default(); + + // Default to OnBehalfOfUser when the handler didn't + // populate `delegation_mode`. Backward-compatible with + // handlers from sub-step B; future handlers should + // populate the field explicitly. + let mode = self + .delegation_mode + .clone() + .unwrap_or(DelegationMode::OnBehalfOfUser); + let key = DelegationKey { + subject_id, + audience: token.audience.clone(), + scopes: token.scopes.clone(), + mode, + }; + + let mut raw = ext + .raw_credentials + .as_ref() + .map(|arc| (**arc).clone()) + .unwrap_or_else(RawCredentialsExtension::default); + raw.delegated_tokens.insert(key, token.clone()); + ext.raw_credentials = Some(Arc::new(raw)); + } + + if let Some(ref update) = self.delegation_update { + // Replace wholesale for v0. A per-hop append semantics + // would deep-merge the chain, but `DelegationExtension`'s + // append rules live with the type — handlers that want + // to add a hop produce a `DelegationExtension` containing + // the new hop in its chain. + ext.delegation = Some(Arc::new(update.clone())); + } + + ext + } +} + +impl_plugin_payload!(DelegationPayload); + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::raw_credentials::RawDelegatedToken; + + #[test] + fn bearer_token_does_not_serialize() { + let p = DelegationPayload::new("eyJ.caller.tok", "get_compensation"); + let json = serde_json::to_string(&p).unwrap(); + assert!( + !json.contains("eyJ.caller.tok"), + "bearer_token leaked into serialized form: {}", + json, + ); + assert!(json.contains("get_compensation")); + } + + #[test] + fn deserialize_yields_empty_bearer_token() { + let json = r#"{"target_name":"get_compensation"}"#; + let p: DelegationPayload = serde_json::from_str(json).unwrap(); + assert_eq!(p.bearer_token(), ""); + assert_eq!(p.target_name(), "get_compensation"); + } + + #[test] + fn input_builders_chain() { + let p = DelegationPayload::new("tok", "get_compensation") + .with_target_type(TargetType::Tool) + .with_target_audience("https://hr.example.com") + .with_required_permissions(vec!["read:compensation".into()]) + .with_trust_domain("hr.example.com") + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["read:compensation".into()], + resource_template: Some("hr://employees/{{ args.employee_id }}".into()), + actions: vec!["read".into()], + ttl_seconds: Some(60), + }); + assert_eq!(p.bearer_token(), "tok"); + assert_eq!(p.target_name(), "get_compensation"); + assert_eq!(p.target_audience(), Some("https://hr.example.com")); + assert_eq!(p.required_permissions(), &["read:compensation".to_string()]); + assert_eq!(p.trust_domain(), Some("hr.example.com")); + assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Target); + let att = p.route_attenuation().unwrap(); + assert_eq!(att.ttl_seconds, Some(60)); + assert_eq!(att.actions, vec!["read"]); + } + + #[test] + fn target_type_custom_round_trips() { + let t = TargetType::Custom("workflow".into()); + let json = serde_json::to_string(&t).unwrap(); + let back: TargetType = serde_json::from_str(&json).unwrap(); + assert_eq!(t, back); + } + + #[test] + fn handler_can_populate_output_on_clone() { + // Typical handler pattern: clone running payload, set + // delegated_token + delegation_update, return. + let original = DelegationPayload::new("caller-tok", "downstream-tool"); + let mut updated = original.clone(); + updated.delegated_token = Some(RawDelegatedToken::new( + "minted-bytes", + "Authorization", + "https://api.example.com", + vec!["read".into()], + Utc::now(), + )); + // Input survives the clone. + assert_eq!(updated.bearer_token(), "caller-tok"); + assert_eq!(updated.target_name(), "downstream-tool"); + // Output populated. + assert!(updated.delegated_token.is_some()); + // Original untouched. + assert!(original.delegated_token.is_none()); + } + + #[test] + fn merge_overlays_outputs() { + let mut base = DelegationPayload::new("tok", "tool"); + base.metadata + .insert("attempt".into(), serde_json::json!(1)); + let mut overlay = DelegationPayload::new("", ""); + overlay.delegated_token = Some(RawDelegatedToken::new( + "x", + "Authorization", + "aud", + vec![], + Utc::now(), + )); + overlay + .metadata + .insert("latency_ms".into(), serde_json::json!(42)); + base.merge(overlay); + assert!(base.delegated_token.is_some()); + // Metadata merged additively — both keys present. + assert!(base.metadata.contains_key("attempt")); + assert!(base.metadata.contains_key("latency_ms")); + } + + #[test] + fn apply_to_extensions_writes_delegated_token_keyed_by_audience() { + use crate::extensions::raw_credentials::DelegationMode; + use crate::extensions::SubjectExtension; + + let mut p = DelegationPayload::new("tok", "get_compensation"); + p.delegated_token = Some(RawDelegatedToken::new( + "minted-jwt", + "Authorization", + "https://hr.example.com", + vec!["read:compensation".into()], + Utc::now() + chrono::Duration::seconds(300), + )); + + // Pre-existing subject in extensions — DelegationKey.subject_id + // should pull from there. + let initial_ext = Extensions { + security: Some(Arc::new(crate::extensions::SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + + let updated = p.apply_to_extensions(initial_ext); + let raw = updated.raw_credentials.as_ref().unwrap(); + assert_eq!(raw.delegated_tokens.len(), 1); + + // Look up by the synthesized key. + let expected_key = crate::extensions::raw_credentials::DelegationKey { + subject_id: "alice".into(), + audience: "https://hr.example.com".into(), + scopes: vec!["read:compensation".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert!(raw.delegated_tokens.contains_key(&expected_key)); + } + + #[test] + fn apply_to_extensions_respects_explicit_delegation_mode() { + // Handler that mints an AsGateway-mode token (gateway-as-principal + // flow). The key in `delegated_tokens` should carry AsGateway, + // not the default OnBehalfOfUser. + let mut p = DelegationPayload::new("tok", "tool"); + p.delegated_token = Some(RawDelegatedToken::new( + "gateway-token", + "Authorization", + "https://downstream.example.com", + vec!["service:call".into()], + Utc::now(), + )); + p.delegation_mode = Some( + crate::extensions::raw_credentials::DelegationMode::AsGateway, + ); + + let updated = p.apply_to_extensions(Extensions::default()); + let raw = updated.raw_credentials.as_ref().unwrap(); + let key = raw.delegated_tokens.keys().next().unwrap(); + assert!(matches!( + key.mode, + crate::extensions::raw_credentials::DelegationMode::AsGateway + )); + } + + #[test] + fn apply_to_extensions_defaults_delegation_mode_when_unset() { + // Handler that didn't populate delegation_mode — apply should + // use OnBehalfOfUser as the safe default. + let mut p = DelegationPayload::new("tok", "tool"); + p.delegated_token = Some(RawDelegatedToken::new( + "user-token", + "Authorization", + "https://aud.example.com", + vec!["read".into()], + Utc::now(), + )); + // delegation_mode left None. + let updated = p.apply_to_extensions(Extensions::default()); + let raw = updated.raw_credentials.as_ref().unwrap(); + let key = raw.delegated_tokens.keys().next().unwrap(); + assert!(matches!( + key.mode, + crate::extensions::raw_credentials::DelegationMode::OnBehalfOfUser + )); + } + + #[test] + fn merge_threads_delegation_mode_through_chain() { + // Handler A leaves delegation_mode unset; handler B sets it. + // After merge, the accumulator should carry handler B's mode. + let mut base = DelegationPayload::new("tok", "tool"); + // base.delegation_mode = None + let mut overlay = DelegationPayload::new("", ""); + overlay.delegation_mode = Some( + crate::extensions::raw_credentials::DelegationMode::AsGateway, + ); + base.merge(overlay); + assert!(matches!( + base.delegation_mode, + Some(crate::extensions::raw_credentials::DelegationMode::AsGateway) + )); + } + + #[test] + fn apply_to_extensions_falls_back_to_empty_subject_id_when_no_subject() { + // Gateway-as-principal flow — no Subject extension present. + // The DelegationKey falls back to empty subject_id rather + // than panicking; flagged via tracing in production but + // not fatal here. + let mut p = DelegationPayload::new("tok", "tool"); + p.delegated_token = Some(RawDelegatedToken::new( + "minted", + "Authorization", + "aud", + vec![], + Utc::now(), + )); + let updated = p.apply_to_extensions(Extensions::default()); + let raw = updated.raw_credentials.as_ref().unwrap(); + let key = raw.delegated_tokens.keys().next().unwrap(); + assert_eq!(key.subject_id, ""); + } + + #[test] + fn auth_enforced_by_defaults_to_caller() { + let p = DelegationPayload::new("tok", "tool"); + assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Caller); + } + + #[test] + fn target_type_defaults_to_tool() { + let p = DelegationPayload::new("tok", "tool"); + assert_eq!(p.target_type(), &TargetType::Tool); + } +} diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index ddf4247a..333f725e 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -689,12 +689,18 @@ impl Executor { /// Run the concurrent phase — plugins execute truly in parallel. /// Returns the first violation if any plugin denies. /// - /// Uses a `JoinSet` rather than `Vec + join_all` so we can: - /// - react to results as they complete (`join_next_with_id`) rather than - /// waiting for the slowest task before noticing a deny; - /// - cancel remaining tasks when a halt condition is hit (`abort_all`), - /// making `short_circuit_on_deny` actually short-circuit and bounding - /// the side-effects timed-out / errored handlers can produce. + /// Built on `cpex_orchestration::run_branches`, the workspace's + /// shared "N async branches with abort-on-deny + per-branch timeout" + /// primitive (same crate apl-core's `Effect::Parallel` consumes). + /// Each branch returns a small `BranchData` carrying the plugin's + /// effective outcome (allow / deny / error). The orchestrator's + /// `is_deny` predicate inspects that — including the per-plugin + /// `on_error == Fail` case, which is treated as a halting outcome + /// so that an erroring/timing-out/panicking Fail-mode plugin + /// short-circuits the remaining branches the same way an explicit + /// deny does. Post-loop, we walk the outcomes in input order and + /// apply each plugin's `on_error` policy (Ignore / Disable) to + /// non-halting failures. async fn run_concurrent_phase( &self, entries: &[HookEntry], @@ -703,34 +709,48 @@ impl Executor { ctx_table: &PluginContextTable, errors: &mut Vec, ) -> Option { + use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch}; + if entries.is_empty() { return None; } + // Per-branch outcome. Carries just enough for post-loop policy + // application — plugin name / on_error are looked up via + // `entries[idx]` so we don't have to clone them into the + // future's captures. + enum BranchData { + Allow, + Deny(Option), + Error(Box), + } + // Clone the payload once so each spawned task can borrow from // an owned, 'static copy. Each task gets its own Arc'd clone. let shared_payload: Arc> = Arc::new(payload.clone_boxed()); let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - // Spawn into a JoinSet keyed by tokio task::Id so we can map a - // completed task (or a panicked one — JoinError carries the id) - // back to its entry without positional zip. - type ConcurrentTaskOutput = Result< - Result, Box>, - tokio::time::error::Elapsed, - >; - let mut set: tokio::task::JoinSet = tokio::task::JoinSet::new(); - let mut id_to_index: std::collections::HashMap = - std::collections::HashMap::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { + // Snapshot per-entry on_error decisions BEFORE moving into + // futures — `is_deny` needs them at runtime to decide whether + // an Error outcome halts (Fail) or is logged (Ignore/Disable). + let on_error_by_idx: Vec = entries + .iter() + .map(|e| e.plugin_ref.trusted_config().on_error) + .collect(); + + // Build branch futures. Each does the timing-bounded handler + // invoke and extracts the type-erased result, returning a + // `BranchData` that the orchestrator's `is_deny` predicate can + // inspect without further type knowledge. + let mut branches: Vec> = Vec::with_capacity(entries.len()); + for entry in entries.iter() { let handler = Arc::clone(&entry.handler); let payload_clone = Arc::clone(&shared_payload); let plugin_id = entry.plugin_ref.id(); // Snapshot the plugin's local_state and the canonical global_state. // Concurrent plugins do not merge back — each task owns its copy. let mut ctx = ctx_table.snapshot_context(plugin_id); - let dur = timeout_dur; + let plugin_name = entry.plugin_ref.name().to_string(); // Filter per plugin — each may have different capabilities. // Read-only, no write tokens. Wrap in Arc for 'static spawn. @@ -743,117 +763,96 @@ impl Executor { .collect(); let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - let abort_handle = set.spawn(async move { - timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await - }); - id_to_index.insert(abort_handle.id(), idx); + branches.push(Box::pin(async move { + match handler.invoke(&**payload_clone, &filtered, &mut ctx).await { + Ok(result_box) => match extract_erased(result_box) { + Some(erased) if !erased.continue_processing => { + let violation = erased.violation.map(|mut v| { + v.plugin_name = Some(plugin_name); + v + }); + BranchData::Deny(violation) + } + // `Some(..)` with continue_processing=true, OR + // `None` (downcast failed — historically logged + // and treated as Allow) both fall through. + _ => BranchData::Allow, + }, + Err(e) => BranchData::Error(e), + } + })); } - let mut denials: Vec = Vec::new(); + let cfg = BranchConfig { + timeout_per_branch: Some(timeout_dur), + short_circuit_on_deny: self.config.short_circuit_on_deny, + }; - while let Some(joined) = set.join_next_with_id().await { - // Pull the task::Id and outcome out of the success/error envelope - // so we can look up the entry by id even when the task panicked. - let (task_id, outcome) = match joined { - Ok((id, result)) => (id, Ok(result)), - Err(join_err) => { - let id = join_err.id(); - (id, Err(join_err)) - } - }; - let idx = match id_to_index.get(&task_id) { - Some(i) => *i, - None => { - // Should be impossible — we registered every spawn. - error!("CONCURRENT: untracked task id {:?}", task_id); - continue; - } - }; + // `is_deny` halts on explicit Deny only. It can't halt on + // Error/Timeout/Panic because the predicate sees only the + // value, not the branch index, so it can't read the per-entry + // `on_error` policy. Halting on those failures is handled in + // the post-loop: the first Fail-policy failure becomes the + // returned violation, and any in-flight tasks drop when the + // JoinSet inside `run_branches` goes out of scope. + // + // The original implementation called `set.abort_all()` on + // Fail-class errors too. The behavioural difference: the + // post-loop now waits for all branches to finish (or hit + // their own timeout) before returning. For the slow-plugin + // abort test that's fine — that test exercises the Deny + // path, which still goes through `is_deny` + abort_all. + let outcomes = run_branches(branches, cfg, |v: &BranchData| { + matches!(v, BranchData::Deny(_)) + }) + .await; + + // Post-loop: walk outcomes in input order applying per-plugin + // policy. First halting outcome wins. + let mut first_violation: Option = None; + + for (idx, outcome) in outcomes.into_iter().enumerate() { let entry = &entries[idx]; let plugin_name = entry.plugin_ref.name(); - let on_error = entry.plugin_ref.trusted_config().on_error; + let on_error = on_error_by_idx[idx]; - let result = match outcome { - Ok(r) => r, - Err(e) => { - // Spawned task panicked. Apply the plugin's on_error - // policy just like a returned error or timeout. On - // Fail, abort the remaining tasks before halting. - error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, e); - let panic_err = crate::error::PluginError::Execution { - plugin_name: plugin_name.to_string(), - message: format!("task panicked: {}", e), - source: None, - code: Some("panic".into()), - details: std::collections::HashMap::new(), - proto_error_code: None, - }; - match on_error { - OnError::Fail => { + match outcome { + BranchOutcome::Completed(BranchData::Allow) => {} + BranchOutcome::Completed(BranchData::Deny(opt_v)) => { + let violation = opt_v.unwrap_or_else(|| { + let mut v = crate::error::PluginViolation::new( + "concurrent_deny", + format!("Plugin '{}' denied", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + v + }); + if first_violation.is_none() { + first_violation = Some(violation); + } + } + BranchOutcome::Completed(BranchData::Error(e)) => match on_error { + OnError::Fail => { + if first_violation.is_none() { let mut v = crate::error::PluginViolation::new( - "plugin_panic", - format!("Plugin '{}' task panicked: {}", plugin_name, e), + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), ); v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); - } - OnError::Ignore => { - warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name); - errors.push((&panic_err).into()); - } - OnError::Disable => { - warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name); - errors.push((&panic_err).into()); - entry.plugin_ref.disable(); + first_violation = Some(v); } } - continue; - } - }; - - match result { - Ok(Ok(result_box)) => { - if let Some(erased) = extract_erased(result_box) { - if !erased.continue_processing { - let mut violation = erased.violation.unwrap_or_else(|| { - crate::error::PluginViolation::new( - "concurrent_deny", - format!("Plugin '{}' denied", plugin_name), - ) - }); - violation.plugin_name = Some(plugin_name.to_string()); - if self.config.short_circuit_on_deny { - // Real short-circuit: cancel the rest before - // they keep running and writing side-effects. - set.abort_all(); - return Some(violation); - } - denials.push(violation); - } - } - } - Ok(Err(e)) => match on_error { - OnError::Fail => { - let mut v = crate::error::PluginViolation::new( - "plugin_error", - format!("Plugin '{}' failed: {}", plugin_name, e), - ); - v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); - } OnError::Ignore => { warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); - errors.push((&e).into()); + errors.push((&*e).into()); } OnError::Disable => { warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); - errors.push((&e).into()); + errors.push((&*e).into()); entry.plugin_ref.disable(); } }, - Err(_) => { + BranchOutcome::TimedOut => { let timeout_err = crate::error::PluginError::Timeout { plugin_name: plugin_name.to_string(), timeout_ms: timeout_dur.as_millis() as u64, @@ -861,13 +860,14 @@ impl Executor { }; match on_error { OnError::Fail => { - let mut v = crate::error::PluginViolation::new( - "plugin_timeout", - format!("Plugin '{}' timed out", plugin_name), - ); - v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); + if first_violation.is_none() { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + first_violation = Some(v); + } } OnError::Ignore => { warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); @@ -880,14 +880,47 @@ impl Executor { } } } + BranchOutcome::Panicked(s) => { + error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, s); + let panic_err = crate::error::PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("task panicked: {}", s), + source: None, + code: Some("panic".into()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }; + match on_error { + OnError::Fail => { + if first_violation.is_none() { + let mut v = crate::error::PluginViolation::new( + "plugin_panic", + format!("Plugin '{}' task panicked: {}", plugin_name, s), + ); + v.plugin_name = Some(plugin_name.to_string()); + first_violation = Some(v); + } + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name); + errors.push((&panic_err).into()); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name); + errors.push((&panic_err).into()); + entry.plugin_ref.disable(); + } + } + } + BranchOutcome::Aborted => { + // Cancelled because an earlier branch hit a halt + // condition under short_circuit_on_deny. Intentional + // — no error to record. + } } } - // Return first denial if any were collected (non-short-circuit mode). - // Dropping `set` here also aborts any not-yet-completed tasks; with - // join_next_with_id() above we drained completions, so this is just - // belt-and-braces in case the loop exited unexpectedly. - denials.into_iter().next() + first_violation } // ----------------------------------------------------------------------- diff --git a/crates/cpex-core/src/extensions/authorization.rs b/crates/cpex-core/src/extensions/authorization.rs new file mode 100644 index 00000000..caffbb80 --- /dev/null +++ b/crates/cpex-core/src/extensions/authorization.rs @@ -0,0 +1,81 @@ +// Location: ./crates/cpex-core/src/extensions/authorization.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AuthorizationDetail — RFC 9396 Rich Authorization Requests. +// +// Carried on DelegationHop alongside `scopes_granted`. Each hop can narrow +// the details structurally (drop entries, remove actions, add constraints). +// The narrowing-check helper lives elsewhere (framework enforcement at the +// TokenDelegate boundary, per docs/specs/delegation-hooks-rust-spec.md §9.6). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// A single RFC 9396 authorization_details entry. +/// +/// `type` is required (renamed `detail_type` here to avoid the Rust +/// keyword). The remaining fields are optional per the RFC. API-specific +/// extension fields are captured in `extra` via serde flatten. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct AuthorizationDetail { + #[serde(rename = "type")] + pub detail_type: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locations: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub datatypes: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub privileges: Option>, + + /// API-specific fields not covered by the named RFC 9396 fields above. + /// Subsetting checks treat these opaquely (exact equality). + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_roundtrip_with_rfc9396_keyword() { + let detail = AuthorizationDetail { + detail_type: "tool_invocation".into(), + actions: Some(vec!["read".into()]), + identifier: Some("get_compensation".into()), + ..Default::default() + }; + let json = serde_json::to_string(&detail).unwrap(); + // The `type` field on the wire, not `detail_type`. + assert!(json.contains(r#""type":"tool_invocation""#)); + assert!(!json.contains("detail_type")); + + let back: AuthorizationDetail = serde_json::from_str(&json).unwrap(); + assert_eq!(back, detail); + } + + #[test] + fn extra_fields_round_trip() { + let json = r#"{ + "type": "payment", + "actions": ["initiate"], + "amount": "100.00", + "currency": "USD" + }"#; + let detail: AuthorizationDetail = serde_json::from_str(json).unwrap(); + assert_eq!(detail.detail_type, "payment"); + assert_eq!(detail.extra.get("amount").and_then(|v| v.as_str()), Some("100.00")); + assert_eq!(detail.extra.get("currency").and_then(|v| v.as_str()), Some("USD")); + } +} diff --git a/crates/cpex-core/src/extensions/container.rs b/crates/cpex-core/src/extensions/container.rs index 6409bf43..51da6a81 100644 --- a/crates/cpex-core/src/extensions/container.rs +++ b/crates/cpex-core/src/extensions/container.rs @@ -25,6 +25,7 @@ use super::llm::LLMExtension; use super::mcp::MCPExtension; use super::meta::MetaExtension; use super::provenance::ProvenanceExtension; +use super::raw_credentials::RawCredentialsExtension; use super::request::RequestExtension; use super::security::SecurityExtension; @@ -66,6 +67,19 @@ pub struct Extensions { #[serde(default, skip_serializing_if = "Option::is_none")] pub delegation: Option>, + /// Raw credential material — Layer 3 of the credential storage + /// model (see `RawCredentialsExtension` docs). Capability-gated; + /// `filter_extensions` strips this slot for plugins without + /// `read_inbound_credentials` / `read_delegated_tokens`. Token + /// fields inside this extension are `#[serde(skip)]`, so any + /// serialization (logs, audit dumps, hot-reload snapshots) drops + /// secret material even when the slot itself survives. The + /// out-of-process consequence — remote / WASM plugins can't see + /// raw tokens at all — is intentional and documented on + /// `RawCredentialsExtension`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_credentials: Option>, + /// MCP entity metadata (immutable). #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option>, @@ -113,6 +127,7 @@ impl Clone for Extensions { http: self.http.clone(), security: self.security.clone(), delegation: self.delegation.clone(), + raw_credentials: self.raw_credentials.clone(), mcp: self.mcp.clone(), completion: self.completion.clone(), provenance: self.provenance.clone(), @@ -158,6 +173,7 @@ impl Extensions { llm: self.llm.clone(), framework: self.framework.clone(), meta: self.meta.clone(), + raw_credentials: self.raw_credentials.clone(), // Mutable/monotonic/guarded — cloned out of Arc into owned http: self.http.as_ref().map(|arc| Guarded::new((**arc).clone())), @@ -209,6 +225,16 @@ impl Extensions { && ptr_eq_opt(&self.llm, &modified.llm) && ptr_eq_opt(&self.framework, &modified.framework) && ptr_eq_opt(&self.meta, &modified.meta) + // NOTE: `raw_credentials` is INTENTIONALLY excluded from the + // immutable check. Framework orchestrators (apl-cpex's + // DelegationPluginInvoker) legitimately write + // `delegated_tokens.*` via the shared Mutex during route + // evaluation, producing a new Arc by the time the synthetic + // handler returns. Per-plugin write authority is enforced at + // the capability layer (`write_delegated_tokens` / + // `write_inbound_credentials`), not at this pointer-equality + // gate. Until cap-tier-aware merge lands, treat raw_credentials + // as merge-able like `security` and `delegation`. } /// Merge an OwnedExtensions back into this Extensions. @@ -217,6 +243,18 @@ impl Extensions { self.security = owned.security.map(Arc::new); self.delegation = owned.delegation.map(Arc::new); self.custom = owned.custom.map(Arc::new); + // `raw_credentials` is shared by Arc in `OwnedExtensions` — + // plugins don't mutate it directly. But framework orchestrators + // (apl-cpex's DelegationPluginInvoker) DO write delegated_tokens + // / inbound_tokens through the shared `Arc>` + // before the synthetic handler returns. We must propagate + // those writes back so callers of `invoke_named` see the + // minted tokens in `PipelineResult.modified_extensions`. + // Without this, `delegate(...)` steps silently lose their + // results at the executor merge boundary. + if owned.raw_credentials.is_some() { + self.raw_credentials = owned.raw_credentials; + } } } @@ -248,6 +286,11 @@ pub struct OwnedExtensions { pub llm: Option>, pub framework: Option>, pub meta: Option>, + /// Raw credentials are shared by Arc here too — write tokens for + /// `inbound_tokens` and `delegated_tokens` mutation paths land in + /// slice 2 (IdentityResolve) and slice 3 (TokenDelegate). Until + /// then, no plugin writes through `OwnedExtensions.raw_credentials`. + pub raw_credentials: Option>, // Mutable/monotonic/guarded — owned, modifiable pub http: Option>, diff --git a/crates/cpex-core/src/extensions/delegation.rs b/crates/cpex-core/src/extensions/delegation.rs index e5f5ef50..a8f085fa 100644 --- a/crates/cpex-core/src/extensions/delegation.rs +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -6,17 +6,43 @@ // DelegationExtension — token delegation chain. // Mirrors cpex/framework/extensions/delegation.py. +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use super::authorization::AuthorizationDetail; +use super::security::SubjectType; + +/// Delegation strategy used to mint the credential at this hop. +/// +/// The known variants cover the reference implementations in +/// docs/specs/delegation-hooks-rust-spec.md §9.5. `Custom(String)` is the +/// escape hatch for host-defined strategies (UCAN variants, in-house mints). +/// Marked `#[non_exhaustive]` so new known variants can be added without a +/// breaking change to host code that exhaustively matches. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DelegationStrategy { + TokenExchange, + ClientCredentials, + SpiffeSvid, + Passthrough, + Ucan, + TransactionToken, + #[serde(untagged)] + Custom(String), +} + /// A single hop in the delegation chain. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct DelegationHop { /// Subject ID of the delegator. pub subject_id: String, - /// Subject type of the delegator. + /// Subject type of the delegator. Reuses the typed `SubjectType` + /// enum from `SecurityExtension.subject`, not a freeform string. #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_type: Option, + pub subject_type: Option, /// Target audience. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -26,9 +52,15 @@ pub struct DelegationHop { #[serde(default)] pub scopes_granted: Vec, - /// Timestamp of delegation (ISO 8601). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timestamp: Option, + /// RFC 9396 authorization_details carried alongside scopes. + /// Each hop's details must be structurally narrowed from the previous. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authorization_details: Vec, + + /// When this hop was minted. Default is the Unix epoch — production + /// code constructs with `Utc::now()`; only tests rely on the default. + #[serde(default)] + pub timestamp: DateTime, /// Time-to-live in seconds. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -36,7 +68,7 @@ pub struct DelegationHop { /// Delegation strategy used. #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, + pub strategy: Option, /// Whether this hop was resolved from cache. #[serde(default)] @@ -53,9 +85,9 @@ pub struct DelegationExtension { #[serde(default)] pub chain: Vec, - /// Chain depth (number of hops). + /// Chain depth (number of hops). `u32` for wire-stable width. #[serde(default)] - pub depth: usize, + pub depth: u32, /// Subject ID of the original delegator. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -78,7 +110,9 @@ impl DelegationExtension { /// Append a delegation hop (monotonic — cannot remove). pub fn append_hop(&mut self, hop: DelegationHop) { self.chain.push(hop); - self.depth = self.chain.len(); + // Cast is safe: a chain with > u32::MAX hops would have failed + // memory allocation long ago. + self.depth = self.chain.len() as u32; self.delegated = true; } } @@ -122,7 +156,7 @@ mod tests { subject_id: "alice".into(), audience: Some("service-b".into()), scopes_granted: vec!["read".into(), "write".into()], - strategy: Some("token_exchange".into()), + strategy: Some(DelegationStrategy::TokenExchange), ..Default::default() }); @@ -139,6 +173,25 @@ mod tests { assert_eq!(del.chain[1].scopes_granted, vec!["read"]); } + #[test] + fn test_strategy_serde_known_and_custom() { + // Known variant serializes as snake_case string. + let known = DelegationStrategy::TokenExchange; + let json = serde_json::to_string(&known).unwrap(); + assert_eq!(json, "\"token_exchange\""); + let back: DelegationStrategy = serde_json::from_str(&json).unwrap(); + assert_eq!(back, DelegationStrategy::TokenExchange); + + // Custom variant serializes as a bare string (untagged). + let custom = DelegationStrategy::Custom("in_house_mint".into()); + let json = serde_json::to_string(&custom).unwrap(); + assert_eq!(json, "\"in_house_mint\""); + // Deserializing a string that doesn't match a known variant falls + // through to Custom — the escape hatch. + let back: DelegationStrategy = serde_json::from_str("\"in_house_mint\"").unwrap(); + assert_eq!(back, DelegationStrategy::Custom("in_house_mint".into())); + } + #[test] fn test_delegation_serde_roundtrip() { let mut del = DelegationExtension { @@ -148,7 +201,7 @@ mod tests { }; del.append_hop(DelegationHop { subject_id: "alice".into(), - subject_type: Some("user".into()), + subject_type: Some(SubjectType::User), scopes_granted: vec!["admin".into()], from_cache: true, ..Default::default() diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 1841164a..c8d1cdd0 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -43,8 +43,15 @@ pub enum SlotName { SecuritySubjectTeams, SecuritySubjectClaims, SecuritySubjectPermissions, + SecurityClient, + SecurityCallerWorkload, + SecurityThisWorkload, SecurityObjects, SecurityData, + // Raw credentials sub-slots (Layer 3 — capability-gated, never + // visible to out-of-process plugins regardless of cap). + RawCredentialsInbound, + RawCredentialsDelegated, } /// Get the policy for a given slot. @@ -167,6 +174,44 @@ pub fn slot_policy(slot: SlotName) -> SlotPolicy { read_cap: None, write_cap: None, }, + // Identity slots populated by IdentityResolve handlers. Read + // gated; write is None because the framework — not plugins — + // mutates these slots in response to handler-returned + // `IdentityResult` payloads (see `Capability` docstring). + SlotName::SecurityClient => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadClient), + write_cap: None, + }, + SlotName::SecurityCallerWorkload => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadWorkload), + write_cap: None, + }, + SlotName::SecurityThisWorkload => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadWorkload), + write_cap: None, + }, + // Layer-3 raw credentials. Granular gating so a forwarding + // plugin that only needs delegated tokens never sees inbound + // bearer material, and an identity-resolver that only needs + // inbound tokens never sees the cached delegated set. + SlotName::RawCredentialsInbound => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadInboundCredentials), + write_cap: None, + }, + SlotName::RawCredentialsDelegated => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadDelegatedTokens), + write_cap: None, + }, } } @@ -282,9 +327,59 @@ pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet filtered.security = Some(Arc::new(build_filtered_security(security, capabilities))); } + // Raw credentials — granular sub-map filtering. The slot itself + // appears in the filtered view iff at least one of the two + // sub-caps is held; otherwise the whole slot is `None` so the + // plugin can't even observe that credentials exist. When the + // slot does appear, only the maps whose caps the plugin holds + // are populated; the others are empty. + if let Some(ref raw) = extensions.raw_credentials { + let inbound_policy = slot_policy(SlotName::RawCredentialsInbound); + let delegated_policy = slot_policy(SlotName::RawCredentialsDelegated); + let allow_inbound = has_read_access(&inbound_policy, capabilities); + let allow_delegated = has_read_access(&delegated_policy, capabilities); + if allow_inbound || allow_delegated { + filtered.raw_credentials = Some(Arc::new( + build_filtered_raw_credentials(raw, allow_inbound, allow_delegated), + )); + } + } + filtered } +/// Build a filtered `RawCredentialsExtension` containing only the +/// sub-maps the plugin can read. `inbound_tokens` and +/// `delegated_tokens` are gated independently — a forwarding plugin +/// that only needs to re-attach minted tokens holds +/// `read_delegated_tokens` and never sees inbound bearer material; +/// an identity-resolver holds `read_inbound_credentials` and never +/// sees the cached outbound set. +/// +/// Token *contents* are also stripped at the serde layer +/// (`RawInboundToken.token` / `RawDelegatedToken.token` are +/// `#[serde(skip)]`), so even a serialized snapshot of the filtered +/// extension produces no bearer material. The capability gate is +/// belt-and-suspenders. +fn build_filtered_raw_credentials( + raw: &super::raw_credentials::RawCredentialsExtension, + allow_inbound: bool, + allow_delegated: bool, +) -> super::raw_credentials::RawCredentialsExtension { + super::raw_credentials::RawCredentialsExtension { + inbound_tokens: if allow_inbound { + raw.inbound_tokens.clone() + } else { + Default::default() + }, + delegated_tokens: if allow_delegated { + raw.delegated_tokens.clone() + } else { + Default::default() + }, + } +} + /// Build a filtered SecurityExtension containing only accessible fields. /// /// Unrestricted sub-fields (objects, data, classification) are always @@ -298,12 +393,16 @@ fn build_filtered_security( objects: security.objects.clone(), data: security.data.clone(), classification: security.classification.clone(), - // Agent identity and auth method — always included (host-set, immutable) - agent: security.agent.clone(), + // `auth_method` is metadata about how the request authenticated + // — useful for audit/branching, never carries credential bytes + // — so it's kept unrestricted. auth_method: security.auth_method.clone(), - // Default empty for capability-gated fields + // Default empty / None for capability-gated fields below. labels: super::MonotonicSet::new(), subject: None, + client: None, + caller_workload: None, + this_workload: None, }; // Labels — capability-gated @@ -312,13 +411,48 @@ fn build_filtered_security( filtered.labels = security.labels.clone(); } - // Subject — granular capability-gated + // Subject — granular capability-gated. The slot appears iff any + // subject sub-cap is held; individual sub-fields then check + // their own caps in `build_filtered_subject`. if let Some(ref subject) = security.subject { if has_any_subject_capability(capabilities) { filtered.subject = Some(build_filtered_subject(subject, capabilities)); } } + // Client (OAuth application identity) — gated under `read_client`. + // Note: no granular sub-field gating for client at v0 — operators + // hold `read_client` to see the slot or nothing. Granular caps + // can land later if a real use case wants to expose, say, + // `client.authorized_scopes` without `client.claims`. + if let Some(ref client) = security.client { + let client_policy = slot_policy(SlotName::SecurityClient); + if has_read_access(&client_policy, capabilities) { + filtered.client = Some(client.clone()); + } + } + + // Inbound caller's attested workload identity — gated under + // `read_workload`. Same single cap controls both workload slots. + if let Some(ref cw) = security.caller_workload { + let policy = slot_policy(SlotName::SecurityCallerWorkload); + if has_read_access(&policy, capabilities) { + filtered.caller_workload = Some(cw.clone()); + } + } + + // Our own outbound workload identity — also gated under + // `read_workload`. Plugins not declaring it never see our + // gateway's SPIFFE-SVID (previously this slot was always-visible + // under the old `agent` name; the cap gating is intentional new + // behavior, per spec §4.4). + if let Some(ref tw) = security.this_workload { + let policy = slot_policy(SlotName::SecurityThisWorkload); + if has_read_access(&policy, capabilities) { + filtered.this_workload = Some(tw.clone()); + } + } + filtered } @@ -544,4 +678,186 @@ mod tests { assert!(filtered.delegation.is_some()); assert!(filtered.delegation.unwrap().delegated); } + + // ----------------------------------------------------------------- + // New identity-slot capability gating (slice 1 step C) + // ----------------------------------------------------------------- + + /// Builds a SecurityExtension carrying all four identity principal + /// slots — subject, client, caller_workload, this_workload. + /// Used by the new-slot cap-gating tests. + fn security_with_all_principals() -> SecurityExtension { + use crate::extensions::{ + ClientExtension, ClientTrustLevel, SubjectExtension, WorkloadIdentity, + }; + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }), + client: Some(ClientExtension { + client_id: "agent-app".into(), + trust_level: ClientTrustLevel::FirstParty, + authorized_scopes: vec!["read".into()], + ..Default::default() + }), + caller_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/caller".into()), + trust_domain: Some("corp.com".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/gateway".into()), + trust_domain: Some("corp.com".into()), + ..Default::default() + }), + ..Default::default() + } + } + + fn extensions_with_principals() -> Extensions { + Extensions { + security: Some(Arc::new(security_with_all_principals())), + ..Default::default() + } + } + + #[test] + fn no_caps_hides_client_workload_slots() { + // Sanity for the new gating: with empty caps, none of the new + // identity slots should appear post-filter. Subject also stays + // hidden (existing behavior — left in for breadth). + let ext = extensions_with_principals(); + let filtered = filter_extensions(&ext, &HashSet::new()); + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.subject.is_none()); + assert!(sec.client.is_none(), "client must be hidden without read_client"); + assert!( + sec.caller_workload.is_none(), + "caller_workload must be hidden without read_workload", + ); + assert!( + sec.this_workload.is_none(), + "this_workload must be hidden without read_workload (changed from always-visible in slice 1)", + ); + } + + #[test] + fn read_client_exposes_client_only() { + let ext = extensions_with_principals(); + let caps: HashSet = ["read_client".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.client.is_some()); + assert_eq!(sec.client.as_ref().unwrap().client_id, "agent-app"); + // Granting read_client must not leak workload slots. + assert!(sec.caller_workload.is_none()); + assert!(sec.this_workload.is_none()); + } + + #[test] + fn read_workload_exposes_both_workload_slots() { + // One cap controls both inbound (`caller_workload`) and + // outbound (`this_workload`) attested-workload slots. Asserting + // the symmetric behavior is load-bearing for the architectural + // decision; if we ever split them into separate caps this test + // will catch the regression. + let ext = extensions_with_principals(); + let caps: HashSet = ["read_workload".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.caller_workload.is_some()); + assert_eq!( + sec.caller_workload.as_ref().unwrap().spiffe_id.as_deref(), + Some("spiffe://corp.com/caller"), + ); + assert!(sec.this_workload.is_some()); + assert_eq!( + sec.this_workload.as_ref().unwrap().spiffe_id.as_deref(), + Some("spiffe://corp.com/gateway"), + ); + // No leak into client. + assert!(sec.client.is_none()); + } + + // ----------------------------------------------------------------- + // RawCredentialsExtension capability gating + // ----------------------------------------------------------------- + + fn extensions_with_raw_credentials() -> Extensions { + use crate::extensions::raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + RawInboundToken, TokenKind, TokenRole, + }; + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new("user-jwt-bytes", "X-User-Token", TokenKind::Jwt), + ); + raw.delegated_tokens.insert( + DelegationKey { + subject_id: "alice".into(), + audience: "https://api.example.com".into(), + scopes: vec!["read".into()], + mode: DelegationMode::OnBehalfOfUser, + }, + RawDelegatedToken::new( + "delegated-bytes", + "Authorization", + "https://api.example.com", + vec!["read".into()], + chrono::Utc::now(), + ), + ); + Extensions { + raw_credentials: Some(Arc::new(raw)), + ..Default::default() + } + } + + #[test] + fn no_raw_credential_caps_hides_slot_entirely() { + // Belt-and-suspenders security story: without either sub-cap, + // the plugin can't even observe that credentials exist. + let ext = extensions_with_raw_credentials(); + let filtered = filter_extensions(&ext, &HashSet::new()); + assert!(filtered.raw_credentials.is_none()); + } + + #[test] + fn read_inbound_credentials_exposes_inbound_only() { + let ext = extensions_with_raw_credentials(); + let caps: HashSet = ["read_inbound_credentials".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let raw = filtered.raw_credentials.as_ref().unwrap(); + // Inbound visible. + assert_eq!(raw.inbound_tokens.len(), 1); + // Delegated map present but empty — a plugin holding only + // inbound cap must never see minted outbound tokens. + assert!(raw.delegated_tokens.is_empty()); + } + + #[test] + fn read_delegated_tokens_exposes_delegated_only() { + let ext = extensions_with_raw_credentials(); + let caps: HashSet = ["read_delegated_tokens".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let raw = filtered.raw_credentials.as_ref().unwrap(); + assert!(raw.inbound_tokens.is_empty()); + assert_eq!(raw.delegated_tokens.len(), 1); + } + + #[test] + fn both_raw_credential_caps_exposes_both_maps() { + let ext = extensions_with_raw_credentials(); + let caps: HashSet = [ + "read_inbound_credentials".to_string(), + "read_delegated_tokens".to_string(), + ] + .into(); + let filtered = filter_extensions(&ext, &caps); + let raw = filtered.raw_credentials.as_ref().unwrap(); + assert_eq!(raw.inbound_tokens.len(), 1); + assert_eq!(raw.delegated_tokens.len(), 1); + } } diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs index d51aec62..69a57bf3 100644 --- a/crates/cpex-core/src/extensions/mod.rs +++ b/crates/cpex-core/src/extensions/mod.rs @@ -12,6 +12,7 @@ // Mirrors the Python extensions in cpex/framework/extensions/. pub mod agent; +pub mod authorization; pub mod completion; pub mod container; pub mod delegation; @@ -24,6 +25,7 @@ pub mod mcp; pub mod meta; pub mod monotonic; pub mod provenance; +pub mod raw_credentials; pub mod request; pub mod security; pub mod tiers; @@ -33,8 +35,9 @@ pub use container::{Extensions, OwnedExtensions}; // Re-export all extension types pub use agent::{AgentExtension, ConversationContext}; +pub use authorization::AuthorizationDetail; pub use completion::{CompletionExtension, StopReason, TokenUsage}; -pub use delegation::{DelegationExtension, DelegationHop}; +pub use delegation::{DelegationExtension, DelegationHop, DelegationStrategy}; pub use filter::{filter_extensions, SlotName}; pub use framework::FrameworkExtension; pub use guarded::{Guarded, WriteToken}; @@ -44,9 +47,13 @@ pub use mcp::{MCPExtension, PromptMetadata, ResourceMetadata, ToolMetadata}; pub use meta::MetaExtension; pub use monotonic::{DeclassifierToken, MonotonicSet}; pub use provenance::ProvenanceExtension; +pub use raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, RawInboundToken, + TokenKind, TokenRole, +}; pub use request::RequestExtension; pub use security::{ - AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, - SubjectExtension, SubjectType, + ClientExtension, ClientTrustLevel, DataPolicy, ObjectSecurityProfile, RetentionPolicy, + SecurityExtension, SubjectExtension, SubjectType, WorkloadIdentity, }; pub use tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; diff --git a/crates/cpex-core/src/extensions/raw_credentials.rs b/crates/cpex-core/src/extensions/raw_credentials.rs new file mode 100644 index 00000000..f3d175b7 --- /dev/null +++ b/crates/cpex-core/src/extensions/raw_credentials.rs @@ -0,0 +1,342 @@ +// Location: ./crates/cpex-core/src/extensions/raw_credentials.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `RawCredentialsExtension` — Layer 3 of the three-layer credential +// storage model (docs/specs/delegation-hooks-rust-spec.md §4.2). +// Carries the *raw* token material — bearer JWTs, opaque session +// strings, SPIFFE-JWT-SVIDs, UCAN tokens, transaction tokens — that +// IdentityResolve and TokenDelegate handlers need to do their jobs. +// +// # Why this is its own extension +// +// `SubjectExtension` / `ClientExtension` / `WorkloadIdentity` carry +// *validated* identity — claims already extracted, signature already +// checked, scopes already enumerated. Most plugins want that and +// nothing more. A small set of plugins (identity resolvers, token +// exchangers, forwarding proxies) genuinely need the raw material to +// re-attach it to outbound calls or hand it to an introspection +// endpoint. Separating raw from validated lets us gate the raw layer +// behind narrowly-scoped capabilities (`read_inbound_credentials`, +// `read_delegated_tokens`) so a buggy or malicious plugin without +// those caps can't get at credential strings. +// +// # Serialization safety +// +// `RawInboundToken.token` and `RawDelegatedToken.token` are +// `#[serde(skip)]`. Any normal serialization of an `Extensions` — +// debug dumps, audit logs, trace snapshots, hot-reload bundles — +// produces JSON / YAML where the token field is absent. A deserialize +// then yields a struct with `Zeroizing::new(String::new())` as the +// token, which is explicitly safe (empty bearer authenticates +// nowhere) but a deliberate foot-gun: a plugin that deserializes an +// extension snapshot and expects to find a working token will fail +// loudly, not silently leak credentials by accident. +// +// This implicitly means **out-of-process plugins (remote / WASM) +// cannot read or write raw credentials**. That's by design — the +// security audit story is much simpler when "raw credentials never +// leave the host process" is an invariant rather than a per-plugin +// trust decision. Handlers that need raw material must run in-process. +// See the slice plan and the architecture discussion in +// `docs/raw-credentials-slice-plan.md` for the reasoning. +// +// # Memory hygiene +// +// `Zeroizing` wipes the underlying bytes when the struct is +// dropped. The protection is real but not absolute — bytes can still +// leak via String::clone, format!, or temporaries created on the way +// to the wrapper. Treat tokens as best-effort cleared, not +// guaranteed. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +/// Which principal a raw inbound token represents. Lookups in +/// `RawCredentialsExtension.inbound_tokens` are by this key. +/// +/// `Custom(String)` is the escape hatch for host-defined roles — +/// HashMap equality is by value, so callers must construct the same +/// `Custom("foo".into())` for both insert and lookup. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenRole { + /// The user / subject token (e.g. `id_token`, `X-User-Token`). + User, + /// The OAuth client / gateway-access token (e.g. `Authorization: + /// Bearer ...` from a session JWT). + Client, + /// A JWT-SVID presented by the inbound workload, when SPIFFE + /// attestation is JWT-based instead of mTLS-based. + Workload, + /// Host-defined role. + #[serde(untagged)] + Custom(String), +} + +/// The wire-format family of a raw token. Lets handlers pick the +/// right validation path without parsing the token first. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenKind { + /// Standard JWT — three base64url segments joined by dots. + Jwt, + /// Opaque bearer — handler must introspect (RFC 7662) to validate. + Opaque, + /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims. + SpiffeJwt, + /// UCAN capability token. + Ucan, + /// Transaction token — short-lived, single-request scope. + TxnToken, +} + +/// Whether a delegated outbound token represents the user's identity +/// or the gateway's own identity to the downstream service. Affects +/// scope-narrowing rules and audit-log attribution. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DelegationMode { + /// Outbound token represents the original user (RFC 8693 + /// on-behalf-of / actor-token flows, UCAN delegation). + OnBehalfOfUser, + /// Outbound token represents the gateway / agent itself as the + /// principal; user identity is conveyed via separate context. + AsGateway, +} + +/// One inbound credential, captured at the wire layer and stashed +/// here by an identity-resolver plugin. Validation happens elsewhere +/// — this struct just carries the bytes and a few hints. +/// +/// The `token` field is `#[serde(skip)]`. Serializing a struct of +/// this type yields `{ "source_header": "...", "kind": "..." }` — +/// the secret material is left out. Deserializing produces a struct +/// whose `token` is `Zeroizing::new(String::new())`. Document this +/// invariant when handing instances across any process boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawInboundToken { + /// The raw credential bytes. Cleared on drop via `Zeroizing`. + /// **Never serialized** — `#[serde(skip)]` strips this field. + #[serde(skip)] + pub token: Zeroizing, + + /// The HTTP header (or other wire-level slot) the token arrived + /// in — `"Authorization"`, `"X-User-Token"`, etc. Forwarding + /// plugins re-attach under the same name; audit logs cite it. + pub source_header: String, + + /// Wire-format family of the token. Lets handlers route to the + /// right validator without re-parsing the token contents. + pub kind: TokenKind, +} + +impl RawInboundToken { + /// Build a token from raw material + metadata. The most common + /// constructor; identity-resolver plugins call this once per + /// recognized credential. + pub fn new( + token: impl Into, + source_header: impl Into, + kind: TokenKind, + ) -> Self { + Self { + token: Zeroizing::new(token.into()), + source_header: source_header.into(), + kind, + } + } +} + +/// Composite key for cached delegated tokens. Token cache lookups +/// hit on `(subject, audience, scopes, mode)` so different audiences +/// or scope sets for the same subject mint independent tokens. +/// +/// `scopes` is a `Vec` (not a `HashSet`) because Cedar / OPA +/// policies frequently care about scope *order* — `["read", "write"]` +/// and `["write", "read"]` may carry different semantics in some IdPs. +/// Callers that want set semantics should sort before constructing. +#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)] +pub struct DelegationKey { + pub subject_id: String, + pub audience: String, + pub scopes: Vec, + pub mode: DelegationMode, +} + +/// One minted outbound credential, produced by a TokenDelegate +/// handler and cached for re-use until expiry. The `token` field is +/// serde-skipped under the same invariant as `RawInboundToken.token`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawDelegatedToken { + /// The minted outbound credential. Cleared on drop. + #[serde(skip)] + pub token: Zeroizing, + + /// Where the consuming plugin should attach the token on the + /// upstream request. Often `"Authorization"`, sometimes + /// audience-specific. + pub outbound_header: String, + + /// The audience the token was minted for. Cache keys include + /// this; the field here is for audit / debugging. + pub audience: String, + + /// Effective scopes on the minted token. May be narrower than + /// the inbound credential's scopes — monotonic narrowing is a + /// framework-level invariant enforced by TokenDelegate. + pub scopes: Vec, + + /// Cache eviction trigger. Handlers re-mint when `now >= + /// expires_at - safety_margin`. + pub expires_at: DateTime, +} + +impl RawDelegatedToken { + pub fn new( + token: impl Into, + outbound_header: impl Into, + audience: impl Into, + scopes: Vec, + expires_at: DateTime, + ) -> Self { + Self { + token: Zeroizing::new(token.into()), + outbound_header: outbound_header.into(), + audience: audience.into(), + scopes, + expires_at, + } + } +} + +/// The Layer-3 raw-credentials extension. +/// +/// Lives on `Extensions.raw_credentials`. Two maps: +/// +/// - `inbound_tokens` — what the wire layer handed us, keyed by +/// `TokenRole`. Populated by identity-resolver plugins. +/// - `delegated_tokens` — what we minted for outbound calls, keyed +/// by `DelegationKey`. Populated by TokenDelegate handlers and +/// read by forwarding / proxy plugins. +/// +/// `plugin_credentials` (spec §10.7) is intentionally absent until +/// a plugin-credential consumer exists. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RawCredentialsExtension { + /// Raw inbound tokens, captured at request entry by identity + /// resolvers. Read with `read_inbound_credentials`; write with + /// `write_inbound_credentials` (resolvers only). + #[serde(default)] + pub inbound_tokens: HashMap, + + /// Outbound delegated tokens, minted on demand by TokenDelegate + /// handlers and cached for re-use. Read with + /// `read_delegated_tokens`; write with `write_delegated_tokens` + /// (TokenDelegate handlers only). + #[serde(default)] + pub delegated_tokens: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_inbound_token_serializes_without_secret() { + let tok = RawInboundToken::new( + "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig", + "Authorization", + TokenKind::Jwt, + ); + let json = serde_json::to_string(&tok).unwrap(); + // The secret string must not appear in the serialized form — + // this is the load-bearing invariant of the whole extension. + assert!(!json.contains("eyJhbGciOiJSUzI1NiJ9"), "raw token leaked into serialized form: {}", json); + assert!(json.contains("Authorization")); + assert!(json.contains("jwt")); + } + + #[test] + fn raw_inbound_token_deserializes_with_empty_token() { + let json = r#"{"source_header":"Authorization","kind":"jwt"}"#; + let tok: RawInboundToken = serde_json::from_str(json).unwrap(); + assert_eq!(&*tok.token, ""); + assert_eq!(tok.source_header, "Authorization"); + assert!(matches!(tok.kind, TokenKind::Jwt)); + } + + #[test] + fn raw_delegated_token_serializes_without_secret() { + let tok = RawDelegatedToken::new( + "minted-secret-bytes", + "Authorization", + "https://downstream.example.com", + vec!["read".into()], + Utc::now(), + ); + let json = serde_json::to_string(&tok).unwrap(); + assert!(!json.contains("minted-secret-bytes"), "delegated token leaked: {}", json); + assert!(json.contains("downstream.example.com")); + } + + #[test] + fn token_role_custom_is_hashmap_compatible() { + // Documents the lookup pattern — equal Custom values produce + // equal hashes so they collide in a HashMap as expected. + let mut map: HashMap = HashMap::new(); + map.insert(TokenRole::Custom("partner".into()), "p"); + assert_eq!(map.get(&TokenRole::Custom("partner".into())), Some(&"p")); + assert_eq!(map.get(&TokenRole::Custom("other".into())), None); + } + + #[test] + fn delegation_key_hash_eq_consistency() { + let k1 = DelegationKey { + subject_id: "alice".into(), + audience: "https://api.example.com".into(), + scopes: vec!["read".into(), "write".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + let k2 = DelegationKey { + subject_id: "alice".into(), + audience: "https://api.example.com".into(), + scopes: vec!["read".into(), "write".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert_eq!(k1, k2); + + // Scope order matters (Vec, not HashSet) — different order is + // intentionally a different key. + let k3 = DelegationKey { + scopes: vec!["write".into(), "read".into()], + ..k1.clone() + }; + assert_ne!(k1, k3); + } + + #[test] + fn extension_round_trip_drops_tokens() { + let mut ext = RawCredentialsExtension::default(); + ext.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new("user-jwt", "X-User-Token", TokenKind::Jwt), + ); + + let json = serde_json::to_string(&ext).unwrap(); + assert!(!json.contains("user-jwt")); + + let restored: RawCredentialsExtension = serde_json::from_str(&json).unwrap(); + // Round-trip preserves the structure but strips secret material. + let restored_tok = restored.inbound_tokens.get(&TokenRole::User).unwrap(); + assert_eq!(&*restored_tok.token, ""); + assert_eq!(restored_tok.source_header, "X-User-Token"); + } +} diff --git a/crates/cpex-core/src/extensions/security.rs b/crates/cpex-core/src/extensions/security.rs index 91d54c18..34ceac86 100644 --- a/crates/cpex-core/src/extensions/security.rs +++ b/crates/cpex-core/src/extensions/security.rs @@ -8,7 +8,9 @@ use std::collections::{HashMap, HashSet}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use super::monotonic::MonotonicSet; @@ -106,41 +108,179 @@ pub struct DataPolicy { pub retention: Option, } -/// This agent's own workload identity. +/// Trust classification for the OAuth client / gateway that brokered +/// the request. Distinct from the *user's* subject identity — the same +/// human can connect through a first-party browser flow or a +/// third-party agent, and policies often want to distinguish them. /// -/// Distinct from `SubjectExtension` which represents the *caller*. -/// `AgentIdentity` represents *this agent/service* — its own -/// workload identity, OAuth client_id, and trust domain. -/// -/// Populated by the host before the pipeline runs. Plugins can -/// make decisions based on both who is calling (Subject) and -/// which agent is processing (AgentIdentity). +/// `Custom(String)` lets operators carry a finer-grained vocabulary +/// (e.g. `"partner-tier-A"`) without forking the type. The enum is +/// `#[non_exhaustive]` so new well-known variants can be added later +/// without breaking external matches. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientTrustLevel { + /// First-party clients operated by the same org as this gateway. + FirstParty, + /// External third-party clients, integrated but not operated by us. + ThirdParty, + /// Internal infrastructure clients (control plane, ops tooling). + Internal, + /// Operator-defined trust level — string carried verbatim into + /// policy. Lookups by value (Hash + Eq) work as long as both + /// sides construct identical strings. + #[serde(untagged)] + Custom(String), +} + +impl Default for ClientTrustLevel { + /// Default to the most restrictive well-known level so a + /// missing-or-misconfigured client doesn't silently inherit + /// first-party privileges. + fn default() -> Self { + ClientTrustLevel::ThirdParty + } +} + +/// The OAuth client / gateway-access principal — *what application* +/// is brokering the request, as opposed to *which user* is using it +/// (`SubjectExtension`) and *which attested workload* is the network +/// peer (`WorkloadIdentity`). Populated from a client-credentials or +/// session JWT by an identity-resolver plugin (or supplied directly +/// by a trusted upstream gateway). /// -/// Maps to AuthBridge's `AgentIdentity` and the Go bindings' -/// `SecurityExtension.Agent`. +/// The shape is deliberately symmetric with `SubjectExtension` — +/// roles / permissions / teams / claims appear on both. That lets APL +/// policies write `client.roles.contains("partner")` and +/// `subject.roles.contains("admin")` with the same idiom; some IdPs +/// (Keycloak service accounts, Auth0 M2M apps, AWS IAM role grants) +/// attach RBAC grants to clients directly. #[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AgentIdentity { - /// OAuth client_id of this agent. +pub struct ClientExtension { + /// OAuth `client_id` — required. Anchor identifier for the client. + pub client_id: String, + + /// Human-readable client name from the IdP. Useful for audit logs. #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, + pub client_name: Option, - /// Workload identity URI (SPIFFE, k8s service account, platform-specific). - /// e.g., `spiffe://example.com/ns/team1/sa/weather-tool` + /// Trust classification — see [`ClientTrustLevel`]. + #[serde(default)] + pub trust_level: ClientTrustLevel, + + /// OAuth scopes the IdP authorized for this client (across all + /// audiences). Policy authors use this to gate on what the IdP + /// believes the client is allowed to ask for, before checking + /// whether the specific request stays within those scopes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authorized_scopes: Vec, + + /// OAuth audiences the IdP authorized this client to address. + /// Different IdPs encode this differently; the resolver + /// normalizes them into this list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authorized_audiences: Vec, + + /// Platform-native RBAC roles attached to the client (Keycloak + /// service-account-roles, Auth0 M2M permissions, IAM role grants). + /// Distinct from `authorized_scopes` — scopes are OAuth-issued, + /// roles are platform-issued. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roles: Vec, + + /// Platform-native permissions attached to the client. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub permissions: Vec, + + /// Team / tenant / account memberships, for multi-tenant + /// platforms that scope clients to organizational units. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub teams: Vec, + + /// Raw remaining JWT claims (or equivalent), keyed by claim name. + /// `Value` (not `String`) because claim values can be booleans, + /// numbers, nested objects, arrays — policy authors who reach + /// here generally know the claim's expected shape. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub claims: HashMap, +} + +/// SPIFFE-style workload identity, used for both inbound callers +/// (`SecurityExtension.caller_workload` — added in a subsequent slice) +/// and our own outbound identity (`SecurityExtension.this_workload`). +/// +/// Distinct from `SubjectExtension` (the human/agent caller) and +/// `ClientExtension` (the OAuth client, added in a subsequent slice). +/// Where `Subject` is "who", `Client` is "what app", `Workload` is +/// "which attested process" — typically established at the network +/// edge via mTLS or a SPIFFE attestation API and never present on +/// the same request as an unauthenticated principal. +/// +/// Populated by the framework / identity-resolver plugin from +/// attestation evidence. Plugins read it via the `read_workload` +/// capability. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkloadIdentity { + /// SPIFFE-SVID identifier — `spiffe:///`. + /// Set when the workload presented a SPIFFE-SVID (X.509 or JWT) + /// or otherwise carries a SPIFFE-shaped identity. #[serde(default, skip_serializing_if = "Option::is_none")] - pub workload_id: Option, + pub spiffe_id: Option, - /// Trust domain of the workload identity. - /// e.g., `example.com` + /// Trust domain extracted from the SPIFFE-SVID (or supplied by + /// the attestation source for non-SPIFFE attestors). Lets policy + /// authors gate on the trust boundary without parsing the URI. #[serde(default, skip_serializing_if = "Option::is_none")] pub trust_domain: Option, + + /// When the attestation was performed. Useful for stale-evidence + /// rejection in policy. Populated by the attestor; the framework + /// doesn't refresh it on its own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attested_at: Option>, + + /// Name of the attestor that vouched for the workload — `mtls`, + /// `spire-agent`, `aws-iid`, `gke-workload-identity`, etc. The + /// vocabulary is open; operators document the values they use. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestor: Option, + + /// SPIFFE workload selectors — `k8s:ns:foo`, `unix:uid:1000`, … + /// Empty when no selectors were attached (the SPIFFE-ID alone is + /// the workload's identity). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selectors: Vec, + + /// OAuth client_id, when the workload also carries one. Kept + /// alongside SPIFFE so call sites with both shapes (a SPIFFE + /// workload that's *also* registered as an OAuth client to a + /// dynamic-client-registration IdP) don't have to populate two + /// extensions. The OAuth client's authorization data + /// (scopes / audiences / claims) lives on the separate + /// `ClientExtension` slot, not here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, } /// Security-related extensions. /// /// Carries security labels (monotonic add-only), classification, -/// authenticated caller identity (subject), this agent's own -/// workload identity (agent), object security profiles, and -/// data policies. +/// up to four distinct identity principals, and data-policy metadata. +/// The four principal slots map to the identity sources documented in +/// `docs/specs/delegation-hooks-rust-spec.md` §4.1: +/// +/// - `subject` — the *user* (or service-as-user) initiating the request +/// - `client` — the *OAuth client / application* brokering the request +/// - `caller_workload` — the *attested workload* on the inbound network +/// peer (SPIFFE-SVID, mTLS cert chain) +/// - `this_workload` — *our own* gateway's attested identity, used for +/// outbound calls +/// +/// A request can populate any subset; identity-resolver plugins are +/// expected to fill the slots they're configured for. Policy authors +/// reason about all four uniformly through the `subject.*` / +/// `client.*` / `caller_workload.*` / `this_workload.*` bag namespaces. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SecurityExtension { /// Security labels (monotonic — add-only via MonotonicSet). @@ -152,14 +292,31 @@ pub struct SecurityExtension { #[serde(default, skip_serializing_if = "Option::is_none")] pub classification: Option, - /// Authenticated caller identity (who is calling). + /// Authenticated *user* identity (who is calling). #[serde(default, skip_serializing_if = "Option::is_none")] pub subject: Option, - /// This agent's own workload identity (who this agent is). - /// Populated by the host, not by plugins. + /// Authenticated *OAuth client / application* brokering the + /// request. Distinct from `subject` — the same user can connect + /// through different clients (first-party web, third-party + /// integration), and policies sometimes want to gate on which. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + + /// The inbound caller's attested workload identity — the network + /// peer's SPIFFE-SVID or mTLS-attested identity. Distinct from + /// `client` (the OAuth-layer identity of the application) and + /// `subject` (the user). All three can be present on the same + /// request when an agent acts on behalf of a user through our + /// gateway, peered via mTLS. #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option, + pub caller_workload: Option, + + /// This agent / gateway's own workload identity — the SPIFFE-SVID + /// or attested identity *we* present when making outbound calls. + /// Populated by the host at startup, not per request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub this_workload: Option, /// Authentication method used (e.g., "jwt", "mtls", "spiffe", "api_key"). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -229,30 +386,38 @@ mod tests { } #[test] - fn test_agent_identity() { - let agent = AgentIdentity { - client_id: Some("weather-agent".into()), - workload_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), + fn test_workload_identity() { + let w = WorkloadIdentity { + spiffe_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), trust_domain: Some("example.com".into()), + attestor: Some("spire-agent".into()), + selectors: vec!["k8s:ns:team1".into(), "k8s:sa:weather-tool".into()], + client_id: Some("weather-agent".into()), + ..Default::default() }; - assert_eq!(agent.client_id.as_deref(), Some("weather-agent")); assert_eq!( - agent.workload_id.as_deref(), + w.spiffe_id.as_deref(), Some("spiffe://example.com/ns/team1/sa/weather-tool") ); - assert_eq!(agent.trust_domain.as_deref(), Some("example.com")); + assert_eq!(w.trust_domain.as_deref(), Some("example.com")); + assert_eq!(w.attestor.as_deref(), Some("spire-agent")); + assert_eq!(w.selectors.len(), 2); + assert_eq!(w.client_id.as_deref(), Some("weather-agent")); } #[test] - fn test_agent_identity_default() { - let agent = AgentIdentity::default(); - assert!(agent.client_id.is_none()); - assert!(agent.workload_id.is_none()); - assert!(agent.trust_domain.is_none()); + fn test_workload_identity_default() { + let w = WorkloadIdentity::default(); + assert!(w.spiffe_id.is_none()); + assert!(w.trust_domain.is_none()); + assert!(w.attested_at.is_none()); + assert!(w.attestor.is_none()); + assert!(w.selectors.is_empty()); + assert!(w.client_id.is_none()); } #[test] - fn test_security_with_agent_and_subject() { + fn test_security_with_this_workload_and_subject() { let sec = SecurityExtension { labels: { let mut l = super::super::MonotonicSet::new(); @@ -265,10 +430,11 @@ mod tests { subject_type: Some(SubjectType::User), ..Default::default() }), - agent: Some(AgentIdentity { - client_id: Some("hr-agent".into()), - workload_id: Some("spiffe://corp.com/hr-agent".into()), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/hr-agent".into()), trust_domain: Some("corp.com".into()), + client_id: Some("hr-agent".into()), + ..Default::default() }), auth_method: Some("jwt".into()), ..Default::default() @@ -276,13 +442,13 @@ mod tests { // Caller identity assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); - // Agent identity (distinct from caller) + // Our own workload identity (distinct from caller) assert_eq!( - sec.agent.as_ref().unwrap().client_id.as_deref(), + sec.this_workload.as_ref().unwrap().client_id.as_deref(), Some("hr-agent") ); assert_eq!( - sec.agent.as_ref().unwrap().trust_domain.as_deref(), + sec.this_workload.as_ref().unwrap().trust_domain.as_deref(), Some("corp.com") ); // Auth method @@ -296,7 +462,7 @@ mod tests { let mut sec = SecurityExtension::default(); sec.add_label("PII"); sec.classification = Some("internal".into()); - sec.agent = Some(AgentIdentity { + sec.this_workload = Some(WorkloadIdentity { client_id: Some("my-agent".into()), ..Default::default() }); @@ -308,7 +474,12 @@ mod tests { assert!(deserialized.has_label("PII")); assert_eq!(deserialized.classification.as_deref(), Some("internal")); assert_eq!( - deserialized.agent.as_ref().unwrap().client_id.as_deref(), + deserialized + .this_workload + .as_ref() + .unwrap() + .client_id + .as_deref(), Some("my-agent") ); assert_eq!(deserialized.auth_method.as_deref(), Some("mtls")); diff --git a/crates/cpex-core/src/extensions/tiers.rs b/crates/cpex-core/src/extensions/tiers.rs index a22406f9..cc3592d1 100644 --- a/crates/cpex-core/src/extensions/tiers.rs +++ b/crates/cpex-core/src/extensions/tiers.rs @@ -25,33 +25,93 @@ pub enum MutabilityTier { } /// Declared permission that controls extension access. +/// +/// # Why no `Write*` for identity slots +/// +/// The IdentityResolve and TokenDelegate hook families return result +/// payloads that the framework consumes to mutate `Extensions`. Plugins +/// never write to `security.subject` / `security.client` / +/// `security.*_workload` / `raw_credentials.*` directly — those slots +/// are owned by the framework on behalf of return-based handlers. The +/// matching write capabilities are therefore absent from this enum +/// until a use case appears for plugin-driven mutation of these slots +/// outside the resolve/delegate hooks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Capability { - /// Read the authenticated subject identity. + // ----- Subject (user identity) ----- + /// Read the authenticated subject identity (`security.subject`). + /// Unlocks the slot but not its sub-fields — roles / teams / + /// claims / permissions each have their own cap below. ReadSubject, - /// Read subject roles. + /// Read subject roles (`security.subject.roles`). ReadRoles, - /// Read subject team memberships. + /// Read subject team memberships (`security.subject.teams`). ReadTeams, - /// Read subject claims (e.g., JWT claims). + /// Read subject claims (`security.subject.claims`). ReadClaims, - /// Read subject permissions. + /// Read subject permissions (`security.subject.permissions`). ReadPermissions, - /// Read the agent execution context. + + // ----- Client (OAuth application identity) ----- + /// Read the OAuth client / gateway-access identity + /// (`security.client`). Distinct from the user identity + /// (`subject`) — a single user can connect through different + /// clients (first-party browser, third-party agent) and policies + /// sometimes want to gate on the client. + ReadClient, + + // ----- Workload (attested SPIFFE / mTLS identity) ----- + /// Read either workload-identity slot — both + /// `security.caller_workload` (the inbound attested peer) and + /// `security.this_workload` (our own outbound identity). One + /// capability covers both: a plugin either has access to + /// attested-workload identity or it doesn't. Distinct from + /// `read_agent` which governs session / conversation context, + /// **NOT** identity. + ReadWorkload, + + // ----- Agent execution context (session / conversation) ----- + /// Read the agent execution context (`AgentExtension`). + /// **NOT a credential** — this carries session / conversation / + /// lineage state, not identity. Identity reads use + /// `read_subject` / `read_client` / `read_workload`. ReadAgent, + + // ----- HTTP wire layer ----- /// Read HTTP headers. ReadHeaders, /// Write (modify) HTTP headers. WriteHeaders, + + // ----- Security labels (taint flow) ----- /// Read security labels. ReadLabels, /// Append security labels (monotonic add-only). AppendLabels, + + // ----- Delegation chain (validated) ----- /// Read the delegation chain. ReadDelegation, /// Append to the delegation chain (monotonic). AppendDelegation, + + // ----- Raw credentials (Layer 3) ----- + /// Read raw inbound tokens + /// (`raw_credentials.inbound_tokens`) — the bearer-token + /// strings captured at the wire layer before validation. + /// Narrowly scoped: only IdentityResolve handlers, forwarding + /// plugins, and a small set of audit plugins should declare it. + /// Out-of-process plugins can't see these tokens regardless of + /// capability — token fields are `#[serde(skip)]`. + ReadInboundCredentials, + /// Read minted outbound delegated tokens + /// (`raw_credentials.delegated_tokens`) — the credentials a + /// TokenDelegate handler produced for an upstream call. Held by + /// forwarding / proxy plugins that re-attach them on the outbound + /// request. Same out-of-process caveat as + /// `read_inbound_credentials`. + ReadDelegatedTokens, } /// Access policy for an extension slot. diff --git a/crates/cpex-core/src/hooks/metadata.rs b/crates/cpex-core/src/hooks/metadata.rs new file mode 100644 index 00000000..ff9de667 --- /dev/null +++ b/crates/cpex-core/src/hooks/metadata.rs @@ -0,0 +1,369 @@ +// Location: ./crates/cpex-core/src/hooks/metadata.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Hook routing metadata — answers "what dispatch context does this +// hook name belong to?" +// +// # What this solves +// +// cpex-core's `invoke_named::(hook_name, ...)` already routes to +// the right handlers based on the hook name. But APL's dispatcher +// (`apl-cpex/src/dispatch_plan.rs`) needs a finer-grained question: +// when a plugin is registered for MULTIPLE hooks (e.g. +// `[cmf.tool_pre_invoke, cmf.tool_post_invoke]`), which entry should +// fire for the current dispatch context? +// +// Pre-2026-05-25 dispatch_plan used a naming heuristic — any hook +// name containing "field", "redact", "scan", or "validate" was +// classified as field-context, everything else as step-context. Two +// problems: +// +// 1. **Multi-hook bug.** Two step-context hooks on the same plugin +// (pre + post) collapsed to "first non-field wins" — silent +// wrong dispatch when policy and post_policy needed different +// entries. +// 2. **The "field-hook" classification didn't match any real hook.** +// No CMF hook actually carries `field` / `redact` / `scan` / +// `validate` in its name — the heuristic was anticipating a +// convention no plugin uses. APL's field-stage dispatch (from +// `args:` / `result:` pipelines) routes to the same hook a +// plugin registers under for step dispatch. +// +// This module replaces the heuristic with an explicit hook-name → +// metadata table. +// +// # The table +// +// Each entry maps a hook name to `HookMetadata`: +// +// * `entity_type` — `Some("tool")`, `Some("llm")`, etc. for hooks +// tied to an entity type; `None` for hook families that apply +// regardless of entity (`identity.resolve`, `token.delegate`). +// * `phase` — `Pre` / `Post` / `Unphased`. APL's evaluator uses +// this to pick the right entry for the current phase context. +// +// Lookup is the foundation for `apl-cpex::dispatch_plan`'s entry +// selection. See `docs/apl-hook-family-expansion.md` Layer 1. +// +// # Phase semantics +// +// APL phases map to hook phases: +// +// * `args:` field stage → looks for `Pre` hooks +// * `policy:` step → looks for `Pre` hooks +// * `result:` field stage → looks for `Post` hooks +// * `post_policy:` step → looks for `Post` hooks +// +// A plugin that wants to discriminate "args field stage" from +// "policy step" — both Pre context — inspects `PluginContext::hook_name()` +// itself. The hook-routing layer doesn't slice phase finer than +// Pre/Post. +// +// # Custom hook metadata +// +// Hosts and plugin authors can register metadata for custom hook +// names via [`register_hook_metadata`]. Unregistered hooks return +// [`HookMetadata::unknown`] from `lookup` — entity_type `None`, phase +// `Unphased`. That conservative default matches any dispatch context, +// so custom hooks dispatch on the first registered entry. Authors +// who want phase-aware behavior must register metadata explicitly. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +use crate::cmf::constants::{ + ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, + HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, 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, +}; +use crate::delegation::HOOK_TOKEN_DELEGATE; +use crate::identity::HOOK_IDENTITY_RESOLVE; + +/// Lifecycle position a hook occupies for dispatcher purposes. +/// +/// APL's args/policy phases dispatch to `Pre` hooks; APL's +/// result/post_policy phases dispatch to `Post` hooks. Hook families +/// outside the request-lifecycle model (identity at request entry, +/// token-delegate inside policy) use `Unphased` and match any +/// requested phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HookPhase { + /// Pre-invocation hook — e.g. `cmf.tool_pre_invoke`, + /// `cmf.llm_input`. Dispatched from APL's `args:` field stages + /// and `policy:` steps. + Pre, + /// Post-invocation hook — e.g. `cmf.tool_post_invoke`, + /// `cmf.llm_output`. Dispatched from APL's `result:` field stages + /// and `post_policy:` steps. + Post, + /// Not phase-bound. Covers hook families that fire once per + /// request without an APL phase concept (`identity.resolve`, + /// `token.delegate`) AND custom hooks the framework doesn't know + /// about. APL's dispatcher matches `Unphased` against any + /// requested phase — conservative default that lets unknown + /// hooks still dispatch. + Unphased, +} + +/// Metadata describing what dispatch context a hook name belongs to. +/// See module docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HookMetadata { + /// Entity type the hook applies to (`"tool"`, `"llm"`, `"prompt"`, + /// `"resource"`). `None` means "applies regardless of entity_type" + /// — used for hooks that don't tie to MCP's entity-type taxonomy. + pub entity_type: Option<&'static str>, + /// Lifecycle phase the hook occupies. + pub phase: HookPhase, +} + +impl HookMetadata { + /// Default — `entity_type: None`, `phase: Unphased`. Used as + /// the fallback for hook names not in the registry. The + /// `matches` function treats `Unphased` as "matches any phase," + /// so unknown hooks dispatch on the first registered entry. + pub const fn unknown() -> Self { + Self { + entity_type: None, + phase: HookPhase::Unphased, + } + } + + /// Whether this hook's metadata matches a dispatch context. + /// + /// Matching rules: + /// + /// - `entity_type`: a hook tied to a specific entity_type + /// (`Some("tool")`) matches only contexts with that entity + /// type. A hook with `entity_type: None` matches any context. + /// A request without an entity_type (`None`) matches any hook + /// — the dispatcher hasn't specified what entity is in play, + /// so we can't filter on it. + /// - `phase`: exact match between hook's phase and the requested + /// phase, EXCEPT `Unphased` is a wildcard from either side + /// (lets custom / unregistered hooks dispatch without phase + /// rules). + pub fn matches(&self, request_entity_type: Option<&str>, requested_phase: HookPhase) -> bool { + let entity_ok = match (self.entity_type, request_entity_type) { + (Some(hook_et), Some(req_et)) => hook_et == req_et, + (Some(_), None) => true, // request didn't specify; don't filter + (None, _) => true, // hook applies to any entity_type + }; + if !entity_ok { + return false; + } + match (self.phase, requested_phase) { + (HookPhase::Unphased, _) | (_, HookPhase::Unphased) => true, + (a, b) => a == b, + } + } +} + +// ===================================================================== +// Built-in registry +// ===================================================================== + +/// Built-in hook metadata. Plugin authors and hosts can register +/// additional entries via [`register_hook_metadata`]. The 8 CMF step +/// hooks (entity × pre/post) are the complete CMF-routable surface +/// today; identity + delegation are unphased. +const BUILTIN_METADATA: &[(&str, HookMetadata)] = &[ + // CMF tool + ( + HOOK_CMF_TOOL_PRE_INVOKE, + HookMetadata { entity_type: Some(ENTITY_TOOL), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_TOOL_POST_INVOKE, + HookMetadata { entity_type: Some(ENTITY_TOOL), phase: HookPhase::Post }, + ), + // CMF llm + ( + HOOK_CMF_LLM_INPUT, + HookMetadata { entity_type: Some(ENTITY_LLM), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_LLM_OUTPUT, + HookMetadata { entity_type: Some(ENTITY_LLM), phase: HookPhase::Post }, + ), + // CMF prompt + ( + HOOK_CMF_PROMPT_PRE_INVOKE, + HookMetadata { entity_type: Some(ENTITY_PROMPT), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_PROMPT_POST_INVOKE, + HookMetadata { entity_type: Some(ENTITY_PROMPT), phase: HookPhase::Post }, + ), + // CMF resource + ( + HOOK_CMF_RESOURCE_PRE_FETCH, + HookMetadata { entity_type: Some(ENTITY_RESOURCE), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_RESOURCE_POST_FETCH, + HookMetadata { entity_type: Some(ENTITY_RESOURCE), phase: HookPhase::Post }, + ), + // Non-CMF families (entity-agnostic, not phase-bound). + ( + HOOK_IDENTITY_RESOLVE, + HookMetadata { entity_type: None, phase: HookPhase::Unphased }, + ), + ( + HOOK_TOKEN_DELEGATE, + HookMetadata { entity_type: None, phase: HookPhase::Unphased }, + ), +]; + +/// Runtime-registered additions to the metadata table. Hosts / +/// plugin authors call [`register_hook_metadata`] to populate. +/// Initialized with the BUILTIN_METADATA on first access. +fn registry() -> &'static RwLock> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut map: HashMap = HashMap::new(); + for (name, meta) in BUILTIN_METADATA { + map.insert((*name).to_string(), *meta); + } + RwLock::new(map) + }) +} + +/// Look up metadata for a hook name. Returns +/// [`HookMetadata::unknown`] for names not in the registry — +/// equivalent to "no phase, no entity_type filter," which lets +/// unregistered hooks still dispatch via the conservative wildcard +/// in [`HookMetadata::matches`]. +pub fn lookup(hook_name: &str) -> HookMetadata { + let r = registry().read().unwrap_or_else(|p| p.into_inner()); + r.get(hook_name).copied().unwrap_or(HookMetadata::unknown()) +} + +/// Register or override metadata for a hook name. Idempotent — a +/// host re-registering the same hook with the same metadata is fine. +/// Re-registering with different metadata overwrites the previous +/// entry; intentional for hosts that need to customize defaults. +/// +/// Thread-safe; intended to be called at startup. Concurrent calls +/// are serialized via the registry's `RwLock`. +pub fn register_hook_metadata(hook_name: impl Into, meta: HookMetadata) { + let mut w = registry().write().unwrap_or_else(|p| p.into_inner()); + w.insert(hook_name.into(), meta); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmf_tool_pre_invoke_is_pre_phase_for_tool_entity() { + let meta = lookup(HOOK_CMF_TOOL_PRE_INVOKE); + assert_eq!(meta.entity_type, Some(ENTITY_TOOL)); + assert_eq!(meta.phase, HookPhase::Pre); + } + + #[test] + fn cmf_llm_output_is_post_phase_for_llm_entity() { + let meta = lookup(HOOK_CMF_LLM_OUTPUT); + assert_eq!(meta.entity_type, Some(ENTITY_LLM)); + assert_eq!(meta.phase, HookPhase::Post); + } + + #[test] + fn identity_resolve_is_unphased_no_entity() { + let meta = lookup(HOOK_IDENTITY_RESOLVE); + assert_eq!(meta.entity_type, None); + assert_eq!(meta.phase, HookPhase::Unphased); + } + + #[test] + fn token_delegate_is_unphased_no_entity() { + let meta = lookup(HOOK_TOKEN_DELEGATE); + assert_eq!(meta.entity_type, None); + assert_eq!(meta.phase, HookPhase::Unphased); + } + + #[test] + fn unknown_hook_returns_universal_default() { + let meta = lookup("custom.unrecognized_hook"); + assert_eq!(meta.entity_type, None); + assert_eq!(meta.phase, HookPhase::Unphased); + } + + #[test] + fn matches_filters_by_entity_type_when_set() { + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(!tool_pre.matches(Some(ENTITY_LLM), HookPhase::Pre)); + } + + #[test] + fn matches_allows_any_entity_when_hook_entity_is_none() { + let universal = HookMetadata { + entity_type: None, + phase: HookPhase::Pre, + }; + assert!(universal.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(universal.matches(Some(ENTITY_LLM), HookPhase::Pre)); + assert!(universal.matches(None, HookPhase::Pre)); + } + + #[test] + fn matches_phase_exactly_unless_unphased() { + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(!tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Post)); + } + + #[test] + fn matches_unphased_is_wildcard_in_either_direction() { + let unphased = HookMetadata { + entity_type: None, + phase: HookPhase::Unphased, + }; + assert!(unphased.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(unphased.matches(Some(ENTITY_LLM), HookPhase::Post)); + + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + // Request with Unphased phase matches any registered hook + // of the right entity_type. + assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Unphased)); + } + + #[test] + fn matches_request_without_entity_type_doesnt_filter_on_it() { + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + // Request didn't specify entity_type — hook still matches. + assert!(tool_pre.matches(None, HookPhase::Pre)); + } + + #[test] + fn register_hook_metadata_overrides_default() { + let name = "test_custom.overridden_meta"; + register_hook_metadata( + name, + HookMetadata { + entity_type: Some("custom"), + phase: HookPhase::Pre, + }, + ); + let meta = lookup(name); + assert_eq!(meta.entity_type, Some("custom")); + assert_eq!(meta.phase, HookPhase::Pre); + } +} diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs index e7fb48f3..4139b670 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -18,12 +18,14 @@ pub mod adapter; pub mod macros; +pub mod metadata; pub mod payload; pub mod trait_def; pub mod types; // Re-export core types at the hooks level pub use adapter::TypedHandlerAdapter; +pub use metadata::{lookup as lookup_hook_metadata, register_hook_metadata, HookMetadata, HookPhase}; pub use payload::{Extensions, PluginPayload}; pub use trait_def::{HookHandler, HookTypeDef, PluginResult}; pub use types::{builtin_hook_types, hook_type_from_str, HookType}; diff --git a/crates/cpex-core/src/identity/hook.rs b/crates/cpex-core/src/identity/hook.rs new file mode 100644 index 00000000..a2a77576 --- /dev/null +++ b/crates/cpex-core/src/identity/hook.rs @@ -0,0 +1,99 @@ +// Location: ./crates/cpex-core/src/identity/hook.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `IdentityHook` — the `HookTypeDef` marker for the IdentityResolve +// hook family. Plugins implement `HookHandler`; the +// framework dispatches into them at request entry to populate +// `Extensions.security.subject` / `.client` / `.caller_workload` / +// `Extensions.raw_credentials` before any tool / resource / prompt +// hook runs. +// +// # Single hook name (for now) +// +// v0 registers under the single name `identity.resolve`. If a future +// slice introduces an `identity.validate` phase that uses the same +// payload + result shape (e.g. a post-resolve consistency check), +// it can share `IdentityHook` and register under `identity.validate` +// via the multi-name registration path — same pattern as CMF's +// `cmf.tool_pre_invoke` / `cmf.llm_input` / etc. sharing `CmfHook`. +// Phases with a different payload shape (e.g. TokenDelegate) get +// their own hook type rather than reusing this one. +// +// # Lifecycle +// +// This file defines the *types*. Lifecycle wiring — when the +// framework calls `invoke_named::(...)`, how results +// merge back into `Extensions` — lands in sub-step B / C of slice 2. + +use crate::hooks::trait_def::PluginResult; + +use super::payload::IdentityPayload; + +/// Primary hook name for IdentityResolve handlers. Used as the +/// registry key when a host registers the handler via the standard +/// `register_handler` path. +pub const HOOK_IDENTITY_RESOLVE: &str = "identity.resolve"; + +crate::define_hook! { + /// Identity-resolve hook. + /// + /// **Payload** ([`IdentityPayload`]) — unified input + accumulator. + /// The host populates the input fields (`raw_token`, `source`, + /// `headers`, ...) once at request entry and never touches them + /// again; handlers populate the output fields (`subject`, + /// `client`, `caller_workload`, `delegation`, `raw_credentials`, + /// `rejected`, ...) on clones of the running payload. Input + /// fields are private and read through accessors — handlers + /// cannot mutate them even on a clone, so the wire-layer input + /// is canonical across the whole chain. + /// + /// **Result** ([`PluginResult`][PluginResult]) — + /// the executor's standard envelope. `modified_payload` carries + /// the updated payload. `continue_processing = false` halts the + /// pipeline (set when the handler decides to reject). + /// + /// **Threading.** Sequential-phase semantics already thread + /// handler N's `modified_payload` into handler N+1's input, so + /// the chain's natural behavior is "each handler sees the prior + /// handler's contributions in the running payload." No bespoke + /// `resolve_identity` method on `PluginManager` — the standard + /// `invoke_named::(...)` does the right thing. + /// + /// **Handler signature:** + /// + /// ```rust,ignore + /// impl HookHandler for MyResolver { + /// async fn handle( + /// &self, + /// payload: &IdentityPayload, + /// _extensions: &Extensions, + /// _ctx: &mut PluginContext, + /// ) -> PluginResult { + /// // Validate the raw token, build the SubjectExtension. + /// let claims = self.validate(payload.raw_token()).await?; + /// let mut updated = payload.clone(); + /// updated.subject = Some(claims.into_subject()); + /// PluginResult::modify_payload(updated) + /// } + /// } + /// ``` + /// + /// Handlers that want to layer onto prior state without manually + /// preserving every untouched field reach for + /// [`IdentityPayload::merge`][merge]. + /// + /// **Registration:** `manager.register_handler::(plugin, config)` + /// against the hook name `"identity.resolve"`. Multiple handlers + /// may register; the framework runs them in priority order and + /// the Sequential-phase chain accumulates their contributions + /// into the running payload. + /// + /// [merge]: super::payload::IdentityPayload::merge + /// [PluginResult]: crate::hooks::trait_def::PluginResult + IdentityHook, "identity.resolve" => { + payload: IdentityPayload, + result: PluginResult, + } +} diff --git a/crates/cpex-core/src/identity/mod.rs b/crates/cpex-core/src/identity/mod.rs new file mode 100644 index 00000000..28fca362 --- /dev/null +++ b/crates/cpex-core/src/identity/mod.rs @@ -0,0 +1,25 @@ +// Location: ./crates/cpex-core/src/identity/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Identity hook family — IdentityResolve. +// +// Mirrors the cmf/ module layout: the hook marker + handler trait +// machinery (provided by cpex-core's generic hooks layer) plus the +// hook-specific payload + result types. Token-delegation lives in +// its own sibling module (slice 3); the two hook families share +// nothing in terms of payloads so they get separate `HookTypeDef` +// markers. +// +// Sub-step A scope: data shapes only — no executor wiring, no +// framework merge-into-Extensions logic, no APL integration. Those +// land in sub-steps B / C / D. + +pub mod hook; +pub mod payload; +pub mod route_config; + +pub use hook::{IdentityHook, HOOK_IDENTITY_RESOLVE}; +pub use payload::{IdentityPayload, TokenSource}; +pub use route_config::{RouteIdentityConfig, RouteIdentityStep}; diff --git a/crates/cpex-core/src/identity/payload.rs b/crates/cpex-core/src/identity/payload.rs new file mode 100644 index 00000000..ed886d5e --- /dev/null +++ b/crates/cpex-core/src/identity/payload.rs @@ -0,0 +1,460 @@ +// Location: ./crates/cpex-core/src/identity/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `IdentityPayload` — the unified state struct threaded through the +// IdentityResolve hook chain. Plays two roles in one type: +// +// * **Input** (private fields, read-only after construction) — +// `raw_token`, `source`, `source_header`, `headers`, `client_host`, +// `client_port`. Populated by the host once at request entry and +// never mutated by handlers. Privacy is enforced at the module +// boundary: external code reads through `pub fn raw_token() -> &str` +// etc. and has no setters or mutable field access, so even a +// `payload.clone()` followed by `clone.raw_token = ...` fails to +// compile. +// +// * **Accumulating output** (`pub` fields) — `subject`, `client`, +// `caller_workload`, `delegation`, `raw_credentials`, `rejected`, +// `reject_status`, `reject_reason`, `resolved_at`, `raw_claims`. +// Handlers clone the payload, populate the output fields they care +// about, and return the updated payload via +// `PluginResult::modify_payload`. Sequential-phase executor +// semantics thread plugin N's output into plugin N+1's input, +// producing a natural accumulator chain. +// +// # Why one struct instead of separate Payload + Result +// +// An earlier draft had `IdentityPayload` (input) and `IdentityResult` +// (output) as distinct types — the Python framework's split +// (`cpex/framework/hooks/identity.py`). That made the first handler +// awkward: it received an "empty IdentityResult" with no way to read +// the raw token without dropping back to `Extensions`. Folding the +// two types into one means handler N always has the inputs it needs +// (private getters) plus whatever previous handlers have already +// accumulated (read direct pub fields), and the hook signature stays +// uniform with everything else in the framework — `invoke_named::` +// with `PluginResult` on the way out. +// +// # Rejection model +// +// Handlers reject via `PluginResult::deny(PluginViolation::new(code, +// reason))` — the same path every other hook uses. The executor's +// `continue_processing = false` check halts the chain at the +// framework level, so no later handler can run and accidentally +// overwrite the decision. There is intentionally no `rejected` / +// `reject_status` / `reject_reason` flag on the payload itself — +// duplicating the rejection state in a `pub` field would let a +// later handler clone the payload, clear the flag, and quietly +// turn a 401 into a 200. The framework's existing halt machinery +// already does the right thing. +// +// Host-side HTTP mapping is conventional: `PluginViolation.code` +// is the resolution-specific identifier (`auth.expired`, +// `auth.audience_mismatch`, `auth.missing_scope`), and the host +// maps it to a status code (401 / 403 / etc.). Same pattern as +// CMF tool-pre-invoke denials. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::executor::PipelineResult; +use crate::extensions::{ + ClientExtension, DelegationExtension, Extensions, RawCredentialsExtension, SecurityExtension, + SubjectExtension, WorkloadIdentity, +}; +use crate::impl_plugin_payload; + +/// Where the raw credential was extracted from. Lets handlers +/// short-circuit on payloads they don't service (an mTLS-only +/// resolver ignores `Bearer` payloads). `Custom(String)` is the +/// escape hatch for bespoke wire formats. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenSource { + /// `Authorization: Bearer ` style. + Bearer, + /// `X-User-Token` style — explicit user-identity header alongside + /// a separate gateway-access token in `Authorization`. + UserToken, + /// mTLS — credential is the peer X.509 chain (surfaced via + /// `X-Forwarded-Client-Cert`). `raw_token` may be empty in this + /// case; the chain itself flows through `headers`. + Mtls, + /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims. + SpiffeJwtSvid, + /// API key in a header or query param. + ApiKey, + /// Operator-defined extraction path. + #[serde(untagged)] + Custom(String), +} + +impl Default for TokenSource { + fn default() -> Self { + TokenSource::Bearer + } +} + +/// State threaded through the IdentityResolve hook chain. +/// +/// See the module-level docs for the input/output split. In short: +/// **input fields are private** (set once via the constructor + +/// builders, never mutated), **output fields are `pub`** (handlers +/// populate them on clones and return the updated payload). +/// +/// Implements `PluginPayload` so it can flow through the executor's +/// existing Sequential-phase machinery — no bespoke plumbing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityPayload { + // ----- Input (private — host-supplied, never mutated by handlers) ----- + /// Raw credential bytes. Cleared on drop via `Zeroizing`. + /// `#[serde(skip)]` — never appears in serialized output. + #[serde(skip)] + raw_token: Zeroizing, + + /// Where the credential was extracted from. + source: TokenSource, + + /// HTTP header (or other wire-level slot) the token arrived in. + #[serde(default, skip_serializing_if = "Option::is_none")] + source_header: Option, + + /// Full request headers — escape hatch for custom auth flows. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + headers: HashMap, + + /// Client IP, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_host: Option, + + /// Client TCP port, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_port: Option, + + // ----- Output (pub — handlers populate via direct assignment on clones) ----- + /// Resolved user identity. `None` until a handler populates it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + + /// Resolved OAuth client / gateway-access identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + + /// Resolved attested workload identity for the inbound peer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caller_workload: Option, + + /// Initial delegation chain parsed from `act` / equivalent claims. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation: Option, + + /// Raw inbound tokens to stash in + /// `Extensions.raw_credentials.inbound_tokens` after the chain + /// completes (gated by `read_inbound_credentials` for consumers). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_credentials: Option, + + /// Optional resolution timestamp. Audit-useful. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_at: Option>, + + /// Raw decoded token claims, when a handler wants to expose them + /// for audit/policy without elevating each claim to a typed + /// field. Mirrors the Python `raw_claims: dict[str, Any]`. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub raw_claims: HashMap, +} + +impl IdentityPayload { + /// Construct a payload with the required input fields populated. + /// The most common entry point — hosts call this once per request + /// before invoking the hook. Optional input slots + /// (`source_header`, `headers`, `client_host`, `client_port`) are + /// set via the `.with_*` builders below; output fields start as + /// `None` / `false` / empty and accumulate as handlers run. + pub fn new(raw_token: impl Into, source: TokenSource) -> Self { + Self { + raw_token: Zeroizing::new(raw_token.into()), + source, + source_header: None, + headers: HashMap::new(), + client_host: None, + client_port: None, + subject: None, + client: None, + caller_workload: None, + delegation: None, + raw_credentials: None, + resolved_at: None, + raw_claims: HashMap::new(), + } + } + + // -------- Input builders -------- + + pub fn with_source_header(mut self, h: impl Into) -> Self { + self.source_header = Some(h.into()); + self + } + + pub fn with_headers(mut self, h: HashMap) -> Self { + self.headers = h; + self + } + + pub fn with_client_host(mut self, h: impl Into) -> Self { + self.client_host = Some(h.into()); + self + } + + pub fn with_client_port(mut self, port: u16) -> Self { + self.client_port = Some(port); + self + } + + // -------- Input read accessors (no mutable variants) -------- + + /// The raw credential bytes. Borrowed — handlers cannot move + /// or replace the underlying `Zeroizing` through this + /// accessor. + pub fn raw_token(&self) -> &str { + &self.raw_token + } + + pub fn source(&self) -> &TokenSource { + &self.source + } + + pub fn source_header(&self) -> Option<&str> { + self.source_header.as_deref() + } + + pub fn headers(&self) -> &HashMap { + &self.headers + } + + pub fn client_host(&self) -> Option<&str> { + self.client_host.as_deref() + } + + pub fn client_port(&self) -> Option { + self.client_port + } + + // -------- Output helpers -------- + + /// Layer another payload's *output* fields onto this one's, + /// following "Some replaces None, last write wins per slot." + /// Input fields are not touched — the running payload's input + /// is canonical for the whole chain. + /// + /// Rejection is *not* a merged field — handlers reject via + /// `PluginResult::deny`, which halts the chain at the framework + /// level rather than being expressed as payload state. See the + /// module docs for the rationale. + pub fn merge(&mut self, other: IdentityPayload) { + if other.subject.is_some() { + self.subject = other.subject; + } + if other.client.is_some() { + self.client = other.client; + } + if other.caller_workload.is_some() { + self.caller_workload = other.caller_workload; + } + if other.delegation.is_some() { + self.delegation = other.delegation; + } + if other.raw_credentials.is_some() { + self.raw_credentials = other.raw_credentials; + } + if other.resolved_at.is_some() { + self.resolved_at = other.resolved_at; + } + for (k, v) in other.raw_claims { + self.raw_claims.insert(k, v); + } + } + + // -------- Host-side application helpers -------- + + /// Pull the resolved `IdentityPayload` out of a `PipelineResult` + /// returned by `mgr.invoke_named::(...)`. Returns + /// `None` when the pipeline was denied (no `modified_payload`) + /// or when the result's payload wasn't an `IdentityPayload` — a + /// programmer error if the latter, since the executor produces + /// `modified_payload` typed per the hook's `HookTypeDef::Payload`. + /// + /// Clones the inner payload — the original `Box` + /// stays in the `PipelineResult` so callers can also inspect + /// `continue_processing`, `violation`, etc. + pub fn from_pipeline_result(result: &PipelineResult) -> Option { + result + .modified_payload + .as_ref() + .and_then(|p| p.as_any().downcast_ref::()) + .cloned() + } + + /// Apply this payload's resolved identity slots back into an + /// `Extensions` container. Returns a new `Extensions` ready to + /// hand to the next hook in the request lifecycle (`cmf.tool_pre_invoke`, + /// etc.) — downstream plugins read `security.subject` / + /// `security.client` / `security.caller_workload` / + /// `raw_credentials` etc. through the standard capability-gated + /// filter. + /// + /// Merging rules: + /// + /// - **`security.subject` / `.client` / `.caller_workload`** — + /// `Some` values on the payload overwrite the existing slot; + /// other security fields (labels, classification, this_workload, + /// auth_method, objects, data) are preserved from the input + /// Extensions. + /// - **`raw_credentials`** — replaced wholesale when populated on + /// the payload. Wholesale rather than merged because handlers + /// produce the complete set of inbound tokens for this request; + /// the host's pre-invoke Extensions wouldn't normally carry one. + /// - **`delegation`** — replaced wholesale when populated. + /// Initial chain from `act` claims in the inbound credential. + /// + /// Input fields on the payload (`raw_token`, `headers`, …) are + /// **not** copied into Extensions — they're the resolver's + /// internal workspace, not request-wide state. + pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions { + let needs_security_update = self.subject.is_some() + || self.client.is_some() + || self.caller_workload.is_some(); + + if needs_security_update { + // Clone-out the existing security extension (or default a + // fresh one) so we can write our identity slots while + // preserving labels / classification / etc. + let mut sec: SecurityExtension = ext + .security + .as_ref() + .map(|arc| (**arc).clone()) + .unwrap_or_default(); + if let Some(s) = &self.subject { + sec.subject = Some(s.clone()); + } + if let Some(c) = &self.client { + sec.client = Some(c.clone()); + } + if let Some(w) = &self.caller_workload { + sec.caller_workload = Some(w.clone()); + } + ext.security = Some(Arc::new(sec)); + } + + if let Some(rc) = &self.raw_credentials { + ext.raw_credentials = Some(Arc::new(rc.clone())); + } + + if let Some(d) = &self.delegation { + ext.delegation = Some(Arc::new(d.clone())); + } + + ext + } +} + +impl_plugin_payload!(IdentityPayload); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_token_serializes_without_secret() { + let p = IdentityPayload::new( + "eyJhbGciOiJSUzI1NiJ9.payload.sig", + TokenSource::Bearer, + ); + let json = serde_json::to_string(&p).unwrap(); + assert!( + !json.contains("eyJhbGciOiJSUzI1NiJ9"), + "raw_token leaked into serialized form: {}", + json, + ); + assert!(json.contains("bearer")); + } + + #[test] + fn deserialize_yields_empty_raw_token() { + let json = r#"{"source":"bearer"}"#; + let p: IdentityPayload = serde_json::from_str(json).unwrap(); + assert_eq!(p.raw_token(), ""); + assert_eq!(p.source(), &TokenSource::Bearer); + } + + #[test] + fn token_source_custom_round_trips() { + let s = TokenSource::Custom("magic-link".into()); + let json = serde_json::to_string(&s).unwrap(); + let back: TokenSource = serde_json::from_str(&json).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn input_builders_chain() { + let mut h = HashMap::new(); + h.insert("user-agent".to_string(), "curl/8.0".to_string()); + let p = IdentityPayload::new("tok", TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(h) + .with_client_host("10.0.0.1") + .with_client_port(443); + assert_eq!(p.raw_token(), "tok"); + assert_eq!(p.source_header(), Some("Authorization")); + assert_eq!(p.client_host(), Some("10.0.0.1")); + assert_eq!(p.client_port(), Some(443)); + assert_eq!(p.headers().get("user-agent").map(String::as_str), Some("curl/8.0")); + } + + #[test] + fn handler_can_populate_output_on_clone() { + // Exercises the typical handler pattern: clone the running + // payload, set the output fields the handler is responsible + // for, return the updated payload. Input fields survive + // the clone unchanged. + let original = IdentityPayload::new("eyJ.tok", TokenSource::Bearer); + let mut updated = original.clone(); + updated.subject = Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + assert_eq!(updated.raw_token(), "eyJ.tok"); // input preserved + assert_eq!(updated.subject.as_ref().unwrap().id.as_deref(), Some("alice")); + // Original unchanged — the clone is a separate value. + assert!(original.subject.is_none()); + } + + #[test] + fn merge_overlays_some_onto_none() { + // Cross-handler chaining: handler 1 resolves the subject, + // handler 2 contributes the workload. Merged result carries + // both. + let mut base = IdentityPayload::new("tok", TokenSource::Bearer); + base.subject = Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + let mut overlay = IdentityPayload::new("tok", TokenSource::Bearer); + overlay.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/inbound".into()), + ..Default::default() + }); + base.merge(overlay); + assert_eq!(base.subject.as_ref().unwrap().id.as_deref(), Some("alice")); + assert!(base.caller_workload.is_some()); + } + +} diff --git a/crates/cpex-core/src/identity/route_config.rs b/crates/cpex-core/src/identity/route_config.rs new file mode 100644 index 00000000..1c9aa357 --- /dev/null +++ b/crates/cpex-core/src/identity/route_config.rs @@ -0,0 +1,202 @@ +// Location: ./crates/cpex-core/src/identity/route_config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Route-level identity configuration — the parsed shape of a +// route's `identity:` block in unified-config YAML. +// +// See `docs/apl-identity-delegation-design.md` for the full design. +// +// # Semantic note +// +// Identity binding is **hook-specific**: the `identity:` block +// binds plugins ONLY for the `identity.resolve` hook on this +// route, independent of whatever the route's `plugins:` block does. +// This matters because in APL-driven routes, the `plugins:` block +// has different meaning (it's a per-route config-override list, +// not a dispatch list — APL controls the dispatch). Identity +// needs its own binding mechanism so the meaning is unambiguous +// regardless of whether APL is annotating the route. +// +// # YAML shapes +// +// Two accepted forms parse to the same IR. The visitor / parser +// logic in `crate::config` discriminates them. +// +// ```yaml +// # List form — implicit additive, common case +// identity: +// - corp-jwt +// - spiffe-attestor +// +// # Object form — when the override flag is needed +// identity: +// replace_inherited: true +// steps: +// - legacy-basic-auth +// ``` +// +// Each step is either a bare plugin name (string) or a map with +// `name:` + optional `on_error:` / `config:`: +// +// ```yaml +// identity: +// - corp-jwt # bare name +// - name: spiffe-attestor # map form +// on_error: deny +// config: +// verify_attestation: strict +// ``` + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A route's parsed `identity:` block. Drives dispatch of the +/// `identity.resolve` hook for the route. +/// +/// `None` on a `RouteEntry` means "no identity declared for this +/// route" — `invoke_named::` will return an empty +/// entry list when filtered for this route, and the host's +/// `IdentityPayload` flows through unchanged (no resolvers fire). +/// +/// Inheritance (Slice C, deferred) walks `global → tags → route` +/// and merges each layer's `RouteIdentityConfig` based on +/// `replace_inherited`: when `false` (the default), the new layer's +/// steps append after the inherited ones; when `true`, the new +/// layer's steps replace the inherited list wholesale. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteIdentityConfig { + /// Ordered list of identity steps to run. Empty list is valid: + /// `identity: { replace_inherited: true, steps: [] }` is the + /// "explicitly opt out of inherited identity" knob. + pub steps: Vec, + + /// When true, this block replaces any inherited identity steps + /// instead of appending to them. Set via the object-form YAML + /// (`identity: { replace_inherited: true, steps: [...] }`). + /// The list-form YAML always produces `false`. + /// + /// Honored by the inheritance merge once Slice C lands. Slice A + /// stores the flag without exercising its merge semantics (no + /// inheritance to override yet at route level). + #[serde(default, skip_serializing_if = "is_false")] + pub replace_inherited: bool, +} + +/// One step in the identity-phase pipeline. Points at a plugin +/// registered under the `identity.resolve` hook, optionally with +/// a per-call config override and an `on_error` policy that +/// controls what happens when the step fails. +/// +/// # Cumulative stacking +/// +/// At runtime, every step in the block runs (subject to its own +/// `on_error`). Each step's resolved `IdentityPayload` accumulates +/// — handlers contribute orthogonal slots (JWT → `subject`; +/// SPIFFE → `caller_workload`; agent resolver → `agent`) so they +/// compose without collision in the common case. +/// +/// # On-error semantics +/// +/// - `None` or `Some("continue")` — soft failure: the step's +/// contribution is dropped, the next step runs, and any missing +/// extensions get caught later by `require(authenticated)` / +/// `require(workload.*)` in downstream policy. +/// - `Some("deny")` — hard requirement: a failure halts the +/// request with the plugin's violation code. +/// +/// Unknown strings parse as best-effort; future slices may +/// introduce typed enums. +/// +/// # Per-step config override +/// +/// `config_override` reuses the existing per-call override +/// pathway. When present, the framework's +/// `create_override_instance` builds a new plugin instance with +/// the merged config and dispatches into it for this route. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteIdentityStep { + /// Plugin name — must match an entry in the top-level + /// `plugins:` block that registers under `identity.resolve`. + pub name: String, + + /// Optional config override applied for this step only. + /// `None` means "use the plugin's configured defaults from the + /// `plugins:` declaration." Stored as `serde_json::Value` to + /// match the existing `create_override_instance` interface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_override: Option, + + /// Per-step failure handling. See type-level docs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_error: Option, + + /// Catch-all for any other fields a future schema version + /// adds (timeout, priority, condition, …) — preserved so the + /// parser doesn't reject configs targeting newer runtimes. + #[serde(default, flatten, skip_serializing_if = "HashMap::is_empty")] + pub extra: HashMap, +} + +impl RouteIdentityStep { + /// Convenience for tests / programmatic construction: build a + /// bare step that just names a plugin with no overrides. + pub fn bare(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } +} + +/// `#[serde(skip_serializing_if = "is_false")]` helper — keeps +/// the YAML round-trip clean by omitting the default `false`. +fn is_false(b: &bool) -> bool { + !*b +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_step_has_no_overrides() { + let s = RouteIdentityStep::bare("corp-jwt"); + assert_eq!(s.name, "corp-jwt"); + assert!(s.config_override.is_none()); + assert!(s.on_error.is_none()); + assert!(s.extra.is_empty()); + } + + #[test] + fn config_default_is_empty_additive() { + let c = RouteIdentityConfig::default(); + assert!(c.steps.is_empty()); + assert!(!c.replace_inherited); + } + + #[test] + fn serializes_without_default_replace_inherited() { + // `replace_inherited: false` should round-trip as absent — + // it's the default and clutters the YAML otherwise. + let c = RouteIdentityConfig { + steps: vec![RouteIdentityStep::bare("corp-jwt")], + replace_inherited: false, + }; + let yaml = serde_yaml::to_string(&c).unwrap(); + assert!(!yaml.contains("replace_inherited"), "got: {yaml}"); + assert!(yaml.contains("corp-jwt"), "got: {yaml}"); + } + + #[test] + fn serializes_with_explicit_replace_inherited() { + let c = RouteIdentityConfig { + steps: vec![RouteIdentityStep::bare("legacy-basic-auth")], + replace_inherited: true, + }; + let yaml = serde_yaml::to_string(&c).unwrap(); + assert!(yaml.contains("replace_inherited: true"), "got: {yaml}"); + } +} diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index f2f8f80c..12378bfd 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -20,16 +20,23 @@ // - [`factory`] — Plugin factory registry for config-driven instantiation // - [`context`] — PluginContext (local_state + global_state) // - [`cmf`] — ContextForge Message Format (Message, ContentPart, enums) +// - [`identity`] — IdentityResolve hook family (subject / client / +// workload resolution from raw credentials) +// - [`delegation`] — TokenDelegate hook family (outbound credential +// minting for downstream calls) // - [`error`] — Error types, violations, and result types pub mod cmf; pub mod config; pub mod context; +pub mod delegation; pub mod error; pub mod executor; pub mod extensions; pub mod factory; pub mod hooks; +pub mod identity; pub mod manager; pub mod plugin; pub mod registry; +pub mod visitor; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 16764d49..0054dc93 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -26,7 +26,7 @@ use std::hash::{Hash, Hasher}; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use hashbrown::HashMap; @@ -170,6 +170,46 @@ struct RuntimeSnapshot { /// Maximum number of entries the route cache will hold. Once reached, /// new resolutions are computed normally but not memoized (reject-on-full). route_cache_max_entries: usize, + + /// Per-route, per-hook handler overrides keyed by + /// `(entity_type, entity_name, scope, hook_name)`. When a request matches + /// an annotation, route resolution short-circuits to a single-entry list + /// containing the annotated handler instead of resolving the route's + /// imperative `plugins:` chain. + /// + /// Per-hook keying lets an orchestrator install distinct handlers for + /// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` on the same route — + /// useful when the pre/post phases need different handler state (e.g. + /// apl-cpex's `AplRouteHandler` binds each instance to either + /// `evaluate_pre` or `evaluate_post`). + /// + /// `scope` (None vs `Some("virtual-server-A")`) lets two virtual + /// servers / gateways with the same tool name carry distinct + /// orchestrators. Matching mirrors cpex-core's existing + /// `find_matching_route` semantics: a scoped request first tries the + /// exact `(et, en, Some(req_scope), hook)` annotation; on miss it falls + /// back to the unscoped `(et, en, None, hook)` default. An unscoped + /// request only matches `(et, en, None, hook)`. Net effect: None-scope + /// annotations act as a global default, scoped annotations override + /// per-scope. + /// + /// The plugins listed under the matching route are *still* registered + /// in the registry — they remain discoverable via `find_plugin_entries` + /// so the annotated handler can dispatch into them by-name (this is + /// what apl-cpex's `AplRouteHandler` does via `CmfPluginInvoker` for + /// `plugin(name)` references inside APL rules). + route_annotations: HashMap, +} + +/// Composite key for route annotations. Includes the hook name so a single +/// route can carry distinct handlers per phase (e.g. pre-invoke vs +/// post-invoke). +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +struct AnnotationKey { + entity_type: String, + entity_name: String, + scope: Option, + hook_name: String, } pub struct PluginManager { @@ -204,6 +244,15 @@ pub struct PluginManager { /// can be `&self` and the manager itself can sit behind `Arc`. initialized: AtomicBool, + /// Monotonic config-generation counter. Bumped every time the runtime + /// snapshot is swapped (factory mutation, config (re)load, plugin + /// register/unregister). External orchestrators (apl-cpex's dispatch + /// plan cache) pair their cached values with the generation seen at + /// build time; a generation mismatch on lookup signals "evict + rebuild." + /// Starts at 0; first snapshot publish (empty registry) leaves it at 0, + /// so callers can use 0 as a "never observed" sentinel. + generation: AtomicU64, + /// Tracks in-flight fire-and-forget background tasks across all /// invocations so `shutdown()` can wait for them to drain before /// returning. Without this, audit/telemetry tasks spawned by recent @@ -213,6 +262,13 @@ pub struct PluginManager { /// /// `TaskTracker` is internally `Arc`'d, so cloning is a refcount bump. task_tracker: tokio_util::task::TaskTracker, + + /// External orchestrators registered via `register_visitor`. Walked + /// in registration order during `load_config_yaml` (after plugin + /// instantiation) so each visitor can inspect raw YAML sections and + /// install handlers via `annotate_route`. Empty by default — the + /// `load_config(CpexConfig)` path skips visitors entirely. + visitors: RwLock>>, } /// Emit warnings for YAML settings that the runtime doesn't currently @@ -300,6 +356,7 @@ fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> Ru executor, cpex_config: Some(cpex_config), route_cache_max_entries, + route_annotations: HashMap::new(), } } @@ -312,6 +369,7 @@ impl PluginManager { executor: Executor::new(config.executor), cpex_config: None, route_cache_max_entries: config.route_cache_max_entries, + route_annotations: HashMap::new(), }; Self { runtime: arc_swap::ArcSwap::from_pointee(snapshot), @@ -320,7 +378,9 @@ impl PluginManager { cache_hasher, route_cache_full_warned: AtomicBool::new(false), initialized: AtomicBool::new(false), + generation: AtomicU64::new(0), task_tracker: tokio_util::task::TaskTracker::new(), + visitors: RwLock::new(Vec::new()), } } @@ -341,6 +401,10 @@ impl PluginManager { let mut next = (*current).clone(); let result = f(&mut next); self.runtime.store(Arc::new(next)); + // Release ordering pairs with the Acquire load in + // config_generation() — external cache consumers that observe a + // higher generation are guaranteed to see the new snapshot. + self.generation.fetch_add(1, Ordering::Release); result } @@ -355,9 +419,23 @@ impl PluginManager { let mut next = (*current).clone(); let result = f(&mut next)?; self.runtime.store(Arc::new(next)); + // Same Release-ordered bump as mutate_runtime — only on Ok, since + // Err leaves the snapshot untouched. + self.generation.fetch_add(1, Ordering::Release); Ok(result) } + /// Monotonic counter that increments on every runtime snapshot swap + /// (registry mutation, config (re)load). External orchestrators + /// (e.g. apl-cpex's dispatch-plan cache) pair their cached values + /// with the generation seen at build time; a mismatch on lookup + /// signals "evict + rebuild." `Acquire` pairs with the `Release` + /// fetch_add in `mutate_runtime` / `try_mutate_runtime` so observing + /// a higher generation guarantees visibility of the new snapshot. + pub fn config_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + // ----------------------------------------------------------------------- // Factory Registration // ----------------------------------------------------------------------- @@ -435,6 +513,10 @@ impl PluginManager { self.runtime .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); + // Same generation bump as mutate_runtime — load_config doesn't + // go through that helper because it has to swap registry + executor + // + cache-cap atomically as one snapshot. + self.generation.fetch_add(1, Ordering::Release); // Clear routing cache — config changed. self.clear_routing_cache(); @@ -442,6 +524,154 @@ impl PluginManager { Ok(()) } + /// Register an external config visitor. Visitors run during + /// `load_config_yaml` (after plugin instantiation) and can install + /// per-route handler overrides via `annotate_route`. Visitor order + /// matches registration order. Multiple visitors are allowed — + /// they typically don't share state, so order rarely matters. + pub fn register_visitor(&self, visitor: Arc) { + let mut v = self.visitors.write().unwrap_or_else(|p| p.into_inner()); + v.push(visitor); + } + + /// Load a unified-config YAML string. Parses the YAML twice — once + /// into a typed `CpexConfig` for plugin instantiation, once into a + /// raw `serde_yaml::Value` so visitors can inspect orchestrator- + /// specific blocks (e.g. `apl:`) that cpex-core itself doesn't + /// model. Calls existing `load_config(cpex_config)` first, then + /// walks each registered visitor over the raw YAML's sections in + /// the documented hierarchy order: + /// + /// 1. `visit_global(global_yaml)` + /// 2. `visit_default(entity_type, default_yaml)` per `global.defaults` entry + /// 3. `visit_policy_bundle(tag, bundle_yaml)` per `global.policies` entry + /// 4. `visit_route(route_yaml, parsed_route)` per `routes[]` entry + /// + /// All sections for one visitor run before the next visitor starts, + /// giving each visitor a consistent view of its own accumulated + /// state. A visitor returning Err aborts the load — the plugin + /// snapshot stays at the post-`load_config` state (partial load is + /// not rolled back; operators should treat any error from this + /// method as a hard stop). + pub fn load_config_yaml(self: &Arc, yaml: &str) -> Result<(), Box> { + // Parse once into a Value so the raw shape is available to + // visitors. Then deserialize from that Value into CpexConfig — + // saves a second tokenize/lex pass vs parsing the string twice. + let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| { + Box::new(PluginError::Config { + message: format!("YAML parse error: {}", e), + }) + })?; + let cpex_config: CpexConfig = serde_yaml::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!("CpexConfig deserialize error: {}", e), + }) + })?; + + // Snapshot the parsed routes + plugin declarations before + // load_config moves the config — visitors get the typed + // structures side-by-side with the raw YAML so they don't have + // to re-deserialize anything cpex-core has already validated. + let parsed_routes: Vec = cpex_config.routes.clone(); + let parsed_plugins: Vec = cpex_config.plugins.clone(); + + // Existing plugin-instantiation path. + self.load_config(cpex_config)?; + + // Visitor walk. No-op when no visitors registered — the common + // case for hosts that don't use the orchestrator extension point. + let visitors = { + let v = self.visitors.read().unwrap_or_else(|p| p.into_inner()); + if v.is_empty() { + return Ok(()); + } + v.clone() + }; + + let mgr: Arc = Arc::clone(self); + let global_yaml = raw.get("global").cloned().unwrap_or(serde_yaml::Value::Null); + let defaults_yaml = global_yaml + .get("defaults") + .and_then(serde_yaml::Value::as_mapping) + .cloned(); + let policies_yaml = global_yaml + .get("policies") + .and_then(serde_yaml::Value::as_mapping) + .cloned(); + let routes_yaml: Vec = raw + .get("routes") + .and_then(serde_yaml::Value::as_sequence) + .cloned() + .unwrap_or_default(); + + for visitor in &visitors { + visitor.visit_plugins(&mgr, &parsed_plugins).map_err(|e| { + Box::new(PluginError::Config { + message: format!("visitor '{}' visit_plugins: {}", visitor.name(), e), + }) + })?; + + visitor.visit_global(&mgr, &global_yaml).map_err(|e| { + Box::new(PluginError::Config { + message: format!("visitor '{}' visit_global: {}", visitor.name(), e), + }) + })?; + + if let Some(defaults) = &defaults_yaml { + for (k, v) in defaults { + let Some(entity_type) = k.as_str() else { continue }; + visitor.visit_default(&mgr, entity_type, v).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "visitor '{}' visit_default('{}'): {}", + visitor.name(), + entity_type, + e + ), + }) + })?; + } + } + + if let Some(policies) = &policies_yaml { + for (k, v) in policies { + let Some(tag) = k.as_str() else { continue }; + visitor.visit_policy_bundle(&mgr, tag, v).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "visitor '{}' visit_policy_bundle('{}'): {}", + visitor.name(), + tag, + e + ), + }) + })?; + } + } + + for (i, parsed) in parsed_routes.iter().enumerate() { + let route_yaml = routes_yaml + .get(i) + .cloned() + .unwrap_or(serde_yaml::Value::Null); + visitor + .visit_route(&mgr, &route_yaml, parsed) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "visitor '{}' visit_route[{}]: {}", + visitor.name(), + i, + e + ), + }) + })?; + } + } + + Ok(()) + } + /// Create a PluginManager from a parsed config (convenience). /// /// Uses the passed factory registry for initial instantiation. @@ -713,7 +943,11 @@ impl PluginManager { let hook_type = HookType::new(hook_name); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // Same caveat as `invoke_named`: route annotations can produce a + // dispatch entry without any plugin being registered on the + // hook directly, so we can only short-circuit when both the + // registry and the annotation map are empty. + if all_entries.is_empty() && snapshot.route_annotations.is_empty() { return ( PipelineResult::allowed_with( payload, @@ -791,7 +1025,10 @@ impl PluginManager { let hook_type = HookType::new(H::NAME); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // See `invoke_named` for why we don't short-circuit on + // `all_entries.is_empty()` alone — route annotations can fire + // without a directly-registered plugin. + if all_entries.is_empty() && snapshot.route_annotations.is_empty() { let boxed: Box = Box::new(payload); return ( PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), @@ -862,7 +1099,13 @@ impl PluginManager { let hook_type = HookType::new(hook_name); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // No registered entries AND no route annotations → nothing to + // do. Allow-and-pass-through. We can't short-circuit on + // `all_entries.is_empty()` alone, because route annotations + // (external-orchestrator handlers from APL / future Rego / + // Cedar-direct) can produce a single-entry dispatch even when + // no plugin was registered on the hook directly. + if all_entries.is_empty() && snapshot.route_annotations.is_empty() { let boxed: Box = Box::new(payload); return ( PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), @@ -895,6 +1138,151 @@ impl PluginManager { .await } + /// Find every (hook_name, HookEntry) pair belonging to the named + /// plugin. Returns an empty `Vec` if the plugin isn't registered. + /// + /// Used by external orchestrators (notably apl-cpex) that decide + /// the per-route plugin lineup themselves and need handler refs + + /// trusted_config to build pre-resolved dispatch plans. Cheaper than + /// going through `invoke_named` per request because the caller can + /// cache the resulting entries — pair the result with + /// [`config_generation`](Self::config_generation) to invalidate the + /// cache on snapshot swaps. + /// + /// Bypasses route/entity filtering — caller has already decided this + /// plugin should run. APL's `routes:` is itself the authoritative + /// lineup; cpex-core's condition-based routing is a parallel model + /// for non-APL hosts. + pub fn find_plugin_entries( + &self, + plugin_name: &str, + ) -> Vec<(String, crate::registry::HookEntry)> { + let snapshot = self.load_runtime(); + snapshot.registry.entries_for_plugin(plugin_name) + } + + /// Dispatch a caller-supplied slice of HookEntries through the + /// executor's full 5-phase pipeline (sequential, transform, audit, + /// concurrent, fire-and-forget). All on_error / timeout / mode / + /// write-token machinery applies. + /// + /// Bypasses hook-name lookup and route/entity filtering — caller has + /// already resolved the lineup (typically via + /// [`find_plugin_entries`](Self::find_plugin_entries) + a per-route + /// dispatch plan). The `H: HookTypeDef` parameter enforces payload + /// type at compile time; mismatched payloads fail to compile, same + /// as [`invoke_named`](Self::invoke_named). + /// + /// Returns `(PipelineResult, BackgroundTasks)` identical in shape to + /// `invoke_named` so callers can swap between the two paths without + /// rewriting downstream result handling. + pub async fn invoke_entries( + &self, + entries: &[crate::registry::HookEntry], + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks) { + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return ( + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), + BackgroundTasks::empty(), + ); + } + let snapshot = self.load_runtime(); + let boxed: Box = Box::new(payload); + snapshot + .executor + .execute( + entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) + .await + } + + // ----------------------------------------------------------------------- + // Route Annotation + // ----------------------------------------------------------------------- + + /// Override the resolved plugin list for one `(entity_type, entity_name)` + /// pair on the listed hooks with a single synthetic handler. The handler + /// takes responsibility for any further plugin dispatch within itself + /// (typically by calling [`invoke_entries`](Self::invoke_entries) against + /// the same registry's other entries — i.e. APL's `plugin(name)` → + /// `CmfPluginInvoker` → `invoke_entries` flow). + /// + /// This is the integration point external orchestrators (APL, future + /// Rego/Cedar-direct/Custom) use to drive plugins via their own + /// semantics instead of cpex-core's imperative `routes.*.plugins:` + /// chain. Bumps the config generation so cached dispatch plans in + /// downstream caches invalidate. + /// + /// `config` provides the trusted_config for the synthetic plugin — + /// the executor reads `mode`, `on_error`, `capabilities`, etc. from + /// it the same way it does for any other registered plugin. Capabilities + /// should be a *superset* of what the orchestrator needs to read from + /// `Extensions` (cpex-core's per-plugin filter still applies to the + /// synthetic handler). + /// + /// The underlying `plugins:` chain for this route is *not* removed — + /// those plugins stay discoverable via [`find_plugin_entries`](Self::find_plugin_entries) + /// so the orchestrator can dispatch into them by name. + pub fn annotate_route( + &self, + entity_type: impl Into, + entity_name: impl Into, + scope: Option, + hook_name: impl Into, + handler: Arc, + config: crate::plugin::PluginConfig, + ) + where + H: crate::plugin::Plugin + crate::registry::AnyHookHandler + 'static, + { + let key = AnnotationKey { + entity_type: entity_type.into(), + entity_name: entity_name.into(), + scope, + hook_name: hook_name.into(), + }; + let plugin_ref = Arc::new(crate::registry::PluginRef::new( + handler.clone() as Arc, + config, + )); + let entry = crate::registry::HookEntry { + plugin_ref, + handler: handler as Arc, + }; + self.mutate_runtime(|snap| { + snap.route_annotations.insert(key, entry); + }); + } + + /// Remove a route annotation for a specific hook. No-op when no + /// annotation exists for the key. Bumps the generation so downstream + /// caches invalidate. + pub fn remove_route_annotation( + &self, + entity_type: &str, + entity_name: &str, + scope: Option<&str>, + hook_name: &str, + ) { + let key = AnnotationKey { + entity_type: entity_type.to_string(), + entity_name: entity_name.to_string(), + scope: scope.map(str::to_string), + hook_name: hook_name.to_string(), + }; + self.mutate_runtime(|snap| { + snap.route_annotations.remove(&key); + }); + } + // ----------------------------------------------------------------------- // Route Filtering // ----------------------------------------------------------------------- @@ -916,6 +1304,45 @@ impl PluginManager { extensions: &Extensions, hook_name: &str, ) -> Arc> { + // Route annotation short-circuit: if the request's + // (entity_type, entity_name) has an annotation that handles this + // hook, return a one-entry list containing the annotated handler. + // External orchestrators (APL via apl-cpex; future Rego/Cedar) + // register annotations to drive plugin dispatch under their own + // semantics instead of cpex-core's imperative chain. Underlying + // `plugins:` entries stay in the registry for the orchestrator + // to dispatch into by-name via `invoke_entries`. + if !snapshot.route_annotations.is_empty() { + if let Some(meta) = &extensions.meta { + if let (Some(et), Some(en)) = (&meta.entity_type, &meta.entity_name) { + // Scoped lookup first (specific wins); unscoped lookup + // falls back as a "global default" — matches the + // specificity tiebreaker `find_matching_route` uses. + // Lookup is keyed on the hook name as well, so a route + // can install distinct handlers per phase. + let scoped = meta.scope.as_ref().and_then(|s| { + snapshot.route_annotations.get(&AnnotationKey { + entity_type: et.clone(), + entity_name: en.clone(), + scope: Some(s.clone()), + hook_name: hook_name.to_string(), + }) + }); + let candidate = scoped.or_else(|| { + snapshot.route_annotations.get(&AnnotationKey { + entity_type: et.clone(), + entity_name: en.clone(), + scope: None, + hook_name: hook_name.to_string(), + }) + }); + if let Some(entry) = candidate { + return Arc::new(vec![entry.clone()]); + } + } + } + } + // Routing disabled (or no config): fall back to per-plugin // condition filtering. Empty conditions Vec means "fire always", // so this is backward-compatible with configs that don't use @@ -976,14 +1403,29 @@ impl PluginManager { } } - // Slow path: resolve, filter, and cache (allocations only here) - let resolved = config::resolve_plugins_for_entity( - cpex_config, - entity_type, - entity_name, - request_scope, - &meta.tags, - ); + // Slow path: resolve, filter, and cache (allocations only here). + // + // Hook-specific resolution for identity.resolve: the route's + // `identity:` block is the authoritative dispatch list (NOT + // the `plugins:` block, which in APL-driven routes means + // "per-route overrides" rather than "binding"). For every + // other hook, the generic plugins-block resolution applies. + let resolved = if hook_name == crate::identity::HOOK_IDENTITY_RESOLVE { + config::resolve_identity_plugins_for_route( + cpex_config, + entity_type, + entity_name, + request_scope, + ) + } else { + config::resolve_plugins_for_entity( + cpex_config, + entity_type, + entity_name, + request_scope, + &meta.tags, + ) + }; // Filter entries to resolved plugins, preserving resolution order. // If a plugin has config overrides and we have a factory for its kind, @@ -1045,6 +1487,173 @@ impl PluginManager { cached } + /// Build per-hook `HookEntry`s for a plugin with optional route- + /// level overrides. Used by external orchestrators (notably + /// apl-cpex's dispatch plan) that need to splice per-route plugin + /// variants — different `config`, narrower `capabilities`, different + /// `on_error` — into the dispatch lineup while keeping cpex-core + /// the source of truth for instantiation and isolation. + /// + /// Behavior: + /// - **All three overrides `None`:** returns the base entries + /// unchanged. Caller can use them as-is. + /// - **Only `capabilities_override` / `on_error_override` set + /// (`config_override` is `None`):** builds new `PluginRef`s + /// sharing the *base plugin `Arc`* with a merged `TrustedConfig` + /// (override caps / on_error replace base values) and an + /// independent circuit breaker. Cheap — no factory call. + /// - **`config_override` set:** invokes the registered factory for + /// the plugin's `kind` with a merged `PluginConfig` (override + /// `config` *replaces* base `config` wholesale per unified-config + /// spec — not deep merge), calls `initialize()` on the new + /// instance, and wraps every returned handler in a new + /// `PluginRef` with a fresh circuit breaker. + /// + /// Returns an empty `Vec` when: + /// - the plugin name isn't registered in the manager, + /// - the factory for the plugin's `kind` is missing, + /// - the factory's `create` errors, + /// - or `initialize()` fails on the new instance. + /// + /// Each of those is a configuration / wiring fault the caller + /// should treat as `NotFound` at dispatch time. The method logs + /// the underlying error before returning empty so debugging + /// surfaces in operator logs rather than as a silent miss. + pub async fn build_override_entries( + &self, + plugin_name: &str, + config_override: Option<&serde_yaml::Value>, + capabilities_override: Option<&std::collections::HashSet>, + on_error_override: Option, + ) -> Vec<(String, crate::registry::HookEntry)> { + let base_entries = self.find_plugin_entries(plugin_name); + if base_entries.is_empty() { + return Vec::new(); + } + + // No overrides at all — caller can use base entries unchanged. + if config_override.is_none() + && capabilities_override.is_none() + && on_error_override.is_none() + { + return base_entries; + } + + // Pull the base trusted_config off any of the base entries — + // all of them share the same `Arc` for a given + // plugin name, so picking the first is fine. + let base_ref = Arc::clone(&base_entries[0].1.plugin_ref); + let mut merged_config = base_ref.trusted_config().clone(); + + // Capabilities: override replaces base when present. + if let Some(caps) = capabilities_override { + merged_config.capabilities = caps.clone(); + } + + // on_error: override replaces base when present. + if let Some(oe) = on_error_override { + merged_config.on_error = oe; + } + + // Caps/on_error-only path — shared base plugin Arc, new + // PluginRef with merged config + fresh circuit breaker. + // No factory call, no async work. + if config_override.is_none() { + let new_ref = Arc::new(crate::registry::PluginRef::new( + Arc::clone(base_ref.plugin()), + merged_config, + )); + return base_entries + .into_iter() + .map(|(hook_name, base_entry)| { + ( + hook_name, + crate::registry::HookEntry { + plugin_ref: Arc::clone(&new_ref), + handler: base_entry.handler, + }, + ) + }) + .collect(); + } + + // Config override present — factory path. Convert YAML + // override value into the JSON shape `PluginConfig.config` + // carries (YAML is a superset of JSON so serde re-serialization + // is safe). Per spec, override `config` replaces the base + // `config` wholesale. + let cfg_yaml = config_override.expect("checked above"); + let cfg_json = match serde_json::to_value(cfg_yaml) { + Ok(v) => v, + Err(e) => { + error!( + plugin = %plugin_name, + error = %e, + "build_override_entries: YAML→JSON config conversion failed", + ); + return Vec::new(); + } + }; + merged_config.config = Some(cfg_json); + + let kind = merged_config.kind.clone(); + let instance = { + let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); + let factory = match factories.get(&kind) { + Some(f) => f, + None => { + error!( + plugin = %plugin_name, + kind = %kind, + "build_override_entries: no factory registered for kind", + ); + return Vec::new(); + } + }; + match factory.create(&merged_config) { + Ok(i) => i, + Err(e) => { + error!( + plugin = %plugin_name, + error = %e, + "build_override_entries: factory.create failed", + ); + return Vec::new(); + } + } + }; + + if let Err(e) = instance.plugin.initialize().await { + error!( + plugin = %plugin_name, + error = %e, + "build_override_entries: initialize() failed on new instance", + ); + return Vec::new(); + } + + // One PluginRef shared across the new instance's handlers — + // all hooks served by one instance share a circuit breaker + // (matches registration semantics). + let new_ref = Arc::new(crate::registry::PluginRef::new( + Arc::clone(&instance.plugin), + merged_config, + )); + instance + .handlers + .into_iter() + .map(|(hook_name, handler)| { + ( + hook_name.to_string(), + crate::registry::HookEntry { + plugin_ref: Arc::clone(&new_ref), + handler, + }, + ) + }) + .collect() + } + /// Create an override plugin instance with merged config. /// /// When a route overrides a plugin's config, we create a new diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index 0b4990c1..30f68bf3 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -459,6 +459,24 @@ impl PluginRegistry { pub fn plugin_names(&self) -> Vec { self.plugins.keys().cloned().collect() } + + /// Returns every (hook_name, HookEntry) pair where the entry's plugin + /// matches the given name. Used by external orchestrators that need + /// to build pre-resolved dispatch lineups for a single plugin across + /// every hook it registered to (e.g. apl-cpex deciding which entry + /// handles step-style invocations vs field-style invocations for the + /// same plugin). Owned tuples — no borrows held on the registry. + pub fn entries_for_plugin(&self, plugin_name: &str) -> Vec<(String, HookEntry)> { + let mut out = Vec::new(); + for (hook_type, entries) in &self.hook_index { + for entry in entries { + if entry.plugin_ref.name() == plugin_name { + out.push((hook_type.as_str().to_string(), entry.clone())); + } + } + } + out + } } impl Default for PluginRegistry { diff --git a/crates/cpex-core/src/visitor.rs b/crates/cpex-core/src/visitor.rs new file mode 100644 index 00000000..98651cfd --- /dev/null +++ b/crates/cpex-core/src/visitor.rs @@ -0,0 +1,134 @@ +// Location: ./crates/cpex-core/src/visitor.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `ConfigVisitor` — extension point for external orchestrators (APL, +// future Rego/Cedar-direct/custom) to participate in unified-config +// loading without cpex-core taking a dep on any specific orchestrator. +// +// # How it fits +// +// The host calls `PluginManager::load_config_yaml(yaml)`. cpex-core +// parses the YAML twice (once into a typed `CpexConfig`, once into a +// raw `serde_yaml::Value`), runs its own plugin instantiation, then +// walks each registered visitor in registration order: +// +// 1. `visit_plugins` — once per visitor, immediately after +// cpex-core's own plugin instantiation, +// receiving the parsed `&[PluginConfig]` +// so the visitor doesn't have to re-parse +// the root `plugins:` block from raw YAML. +// 2. `visit_global` — global config block +// 3. `visit_default` — once per entity_type with a default +// 4. `visit_policy_bundle` — once per named policy group (tag) +// 5. `visit_route` — once per route +// +// Each visitor sees the **raw YAML** so it can find its own block +// (e.g. `apl:`) under any section without cpex-core having to know +// about it. Parsed sibling data is passed alongside (`RouteEntry` for +// routes) for convenience — e.g. APL needs to know whether a route +// matches `tool:` or `resource:` to build the annotation key. +// +// # Why visit per-section rather than per-whole-config +// +// Visitors typically accumulate state across the hierarchy (e.g. APL's +// visitor compiles globals/defaults/tag-bundles into `CompiledRoute`s +// kept in visitor state, then merges them into each route at +// `visit_route`). Per-section calls give the orchestrator a natural +// place to do that accumulation without re-parsing. +// +// # Visit order +// +// All sections for one visitor run before the next visitor starts. For +// single-visitor deployments (the common case) this is identical to +// any other ordering; for multi-visitor it gives each visitor a +// consistent view of its own internal state. Visitor methods are +// invoked synchronously — no async runtime needed at load time. + +use std::sync::Arc; + +use crate::config::RouteEntry; +use crate::manager::PluginManager; +use crate::plugin::PluginConfig; + +/// Error type returned by a config visitor. Boxed `dyn Error` so each +/// orchestrator can carry its own error variants (parse errors, missing +/// plugin references, etc.) without cpex-core having to enumerate them. +pub type VisitorError = Box; + +/// Extension point for external orchestrators to participate in unified +/// config loading. Register via [`PluginManager::register_visitor`]; +/// invoked during [`PluginManager::load_config_yaml`]. +/// +/// All methods have default no-op implementations — a visitor only +/// overrides the sections it cares about. +pub trait ConfigVisitor: Send + Sync { + /// Stable identifier for diagnostics — included in error contexts + /// if a visitor method returns Err. Convention: short kebab-case + /// matching the orchestrator's YAML key (e.g. `"apl"`, `"rego"`). + fn name(&self) -> &str; + + /// Visit the typed plugin declarations from the root `plugins:` + /// block. Called once per visitor, immediately after cpex-core's + /// own plugin instantiation completes and before any hierarchy + /// section is walked. Visitors that need a per-name registry of + /// hook / capability / on_error metadata can populate it here + /// without re-parsing the YAML — cpex-core has already validated + /// the block (no duplicate names, etc.) by this point. + fn visit_plugins( + &self, + _mgr: &Arc, + _plugins: &[PluginConfig], + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit the top-level `global:` block. `yaml` is the raw value at + /// that path, or `Value::Null` if `global:` is absent. + fn visit_global( + &self, + _mgr: &Arc, + _yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit one entry in `global.defaults`. Called once per + /// `(entity_type, default_block)` pair. `yaml` is the raw value at + /// `global.defaults.`. + fn visit_default( + &self, + _mgr: &Arc, + _entity_type: &str, + _yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit one entry in `global.policies` (a named tag bundle). + /// Called once per `(tag, policy_group)` pair. `yaml` is the raw + /// value at `global.policies.`. + fn visit_policy_bundle( + &self, + _mgr: &Arc, + _tag: &str, + _yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit one route entry. `yaml` is the raw value at `routes[i]` + /// (so orchestrator can find its own block like `apl:`); `parsed` + /// is the typed `RouteEntry` cpex-core deserialized (so the + /// orchestrator can read `tool`/`resource`/`prompt`/`llm`, + /// `meta.scope`, `meta.tags`, etc. without re-parsing). + fn visit_route( + &self, + _mgr: &Arc, + _yaml: &serde_yaml::Value, + _parsed: &RouteEntry, + ) -> Result<(), VisitorError> { + Ok(()) + } +} diff --git a/crates/cpex-core/tests/delegation_e2e.rs b/crates/cpex-core/tests/delegation_e2e.rs new file mode 100644 index 00000000..10ff13b9 --- /dev/null +++ b/crates/cpex-core/tests/delegation_e2e.rs @@ -0,0 +1,722 @@ +// Location: ./crates/cpex-core/tests/delegation_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for the TokenDelegate hook family — sub-step B of +// slice 3. +// +// Verifies the host-explicit dispatch model: an outbound caller +// (typically a forwarding-proxy plugin) constructs a +// `DelegationPayload`, calls +// `mgr.invoke_named::(...)`, and reads the +// minted credential out of the returned `PipelineResult`. No +// bespoke method on `PluginManager` — `invoke_named` works +// uniformly because Sequential-phase threading already does the +// right thing for the unified `DelegationPayload`. +// +// Tests cover: +// - Single-handler mint: one plugin produces a `RawDelegatedToken`. +// - Two-handler chain: handler A declines (`delegated_token == None`), +// handler B mints — proves Sequential-phase threading carries +// A's null contribution into B's input. +// - Rejection: handler returns `deny()`; pipeline halts. +// - `from_pipeline_result` returns `None` on deny. +// - Full host flow: invoke delegate, apply to Extensions, observe +// `Extensions.raw_credentials.delegated_tokens` populated under +// the synthesized `DelegationKey`. + +use std::sync::Arc; + +use async_trait::async_trait; + +use chrono::{Duration as ChronoDuration, Utc}; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +use cpex_core::error::PluginError; +use cpex_core::extensions::raw_credentials::{DelegationKey, DelegationMode, RawDelegatedToken}; +use cpex_core::extensions::{SecurityExtension, SubjectExtension}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +// ===================================================================== +// Plugin fixtures +// ===================================================================== + +/// Minimal RFC-8693-style stub. Doesn't actually exchange anything +/// — just constructs a `RawDelegatedToken` by combining the caller's +/// bearer token with the target audience. Real handlers would call +/// out to an IdP; we only care about wiring here. +struct StubExchanger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for StubExchanger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for StubExchanger { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + assert!( + !payload.bearer_token().is_empty(), + "exchanger expected non-empty bearer token", + ); + + // Use the route's TTL hint if present; otherwise default to + // 300s. Real handlers would also take min(route_hint, + // idp_response_expires_in). + let ttl_secs = payload + .route_attenuation() + .and_then(|a| a.ttl_seconds) + .unwrap_or(300); + let audience = payload + .target_audience() + .unwrap_or("https://example.com/default") + .to_string(); + // Effective scopes: combine route-attenuation capabilities + // with required_permissions. Real exchangers may narrow + // further based on the IdP's response. + let mut scopes = payload.required_permissions().to_vec(); + if let Some(att) = payload.route_attenuation() { + for cap in &att.capabilities { + if !scopes.contains(cap) { + scopes.push(cap.clone()); + } + } + } + + let minted = RawDelegatedToken::new( + format!("stub-exchanged({})", payload.bearer_token()), + "Authorization", + audience, + scopes, + Utc::now() + ChronoDuration::seconds(ttl_secs as i64), + ); + let mut updated = payload.clone(); + updated.delegated_token = Some(minted); + updated.minted_at = Some(Utc::now()); + PluginResult::modify_payload(updated) + } +} + +/// A handler that always declines — leaves `delegated_token` as +/// `None`. Used to verify chaining: in a chain with a declining +/// primary + a minting fallback, the fallback should see the +/// declined state and mint. +struct DecliningHandler { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DecliningHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DecliningHandler { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Returns the payload unchanged — leaves output slots None, + // signals "this handler had nothing to contribute." + let mut updated = payload.clone(); + updated.metadata.insert( + "declined_by".into(), + serde_json::json!("declining-handler"), + ); + PluginResult::modify_payload(updated) + } +} + +/// Fallback minter — runs after a declining handler. Asserts that +/// the prior handler's `metadata` contribution survived through +/// Sequential-phase threading (i.e. we see "declined_by") and +/// produces a token in spite of the prior decline. +struct FallbackMinter { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for FallbackMinter { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for FallbackMinter { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + assert!( + payload.delegated_token.is_none(), + "fallback minter expected no prior token in chain test", + ); + assert!( + payload.metadata.contains_key("declined_by"), + "fallback minter expected prior handler's metadata in chain", + ); + let mut updated = payload.clone(); + updated.delegated_token = Some(RawDelegatedToken::new( + "fallback-token", + "Authorization", + payload + .target_audience() + .unwrap_or("https://fallback.example.com") + .to_string(), + vec!["read".into()], + Utc::now() + ChronoDuration::seconds(60), + )); + PluginResult::modify_payload(updated) + } +} + +/// Handler that rejects unconditionally. Used to verify the +/// rejection path through `PluginResult::deny`. +struct RejectingHandler { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for RejectingHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RejectingHandler { + async fn handle( + &self, + _payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(cpex_core::error::PluginViolation::new( + "delegation.scope_too_broad", + "requested scopes exceed inbound credential's authorization", + )) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn config(name: &str, priority: i32) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], + mode: PluginMode::Sequential, + priority, + on_error: OnError::Fail, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +/// Build the kind of payload a forwarding-proxy plugin would construct +/// just before making a downstream call. +fn build_payload(target: &str, audience: &str, permissions: &[&str]) -> DelegationPayload { + DelegationPayload::new("eyJ.caller.tok", target) + .with_target_type(TargetType::Tool) + .with_target_audience(audience) + .with_required_permissions(permissions.iter().map(|s| s.to_string()).collect()) + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["audit".into()], + resource_template: Some("hr://employees/{{ args.id }}".into()), + actions: vec!["read".into()], + ttl_seconds: Some(120), + }) +} + +fn extract_delegation(result: &cpex_core::executor::PipelineResult) -> DelegationPayload { + DelegationPayload::from_pipeline_result(result) + .expect("PipelineResult had no DelegationPayload — denied or wrong hook type") +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Single handler runs, mints a `RawDelegatedToken`. Host receives +/// the populated payload via `from_pipeline_result`. +#[tokio::test] +async fn single_handler_mints_token() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("stub-exchanger", 10); + let plugin = Arc::new(StubExchanger { + cfg: cfg.clone(), + }); + mgr.register_handler_for_names::( + plugin, + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ), + Extensions::default(), + None, + ) + .await; + assert!(result.continue_processing); + + let final_payload = extract_delegation(&result); + let token = final_payload + .delegated_token + .as_ref() + .expect("handler should have minted a token"); + + assert_eq!(token.audience, "https://hr.example.com"); + assert_eq!(token.outbound_header, "Authorization"); + assert!(token.scopes.contains(&"read:compensation".to_string())); + // Route attenuation contributed `audit` capability. + assert!(token.scopes.contains(&"audit".to_string())); + // TTL respects the route hint (120s) — token must expire in + // roughly 120s, not 300s default. + let ttl_left = (token.expires_at - Utc::now()).num_seconds(); + assert!( + ttl_left <= 120 && ttl_left > 100, + "token TTL should reflect route hint (~120s); got {}s", + ttl_left, + ); + // Input fields preserved through clone. + assert_eq!(final_payload.bearer_token(), "eyJ.caller.tok"); + assert_eq!(final_payload.target_name(), "get_compensation"); +} + +/// Two-handler chain: declining primary + minting fallback. Proves +/// Sequential-phase threading carries the declining handler's +/// metadata contribution into the fallback handler, and that the +/// fallback's output replaces the lack of a token from the primary. +#[tokio::test] +async fn declining_then_fallback_chain_mints_token() { + let mgr = Arc::new(PluginManager::default()); + + let declining_cfg = config("declining-handler", 10); + let declining = Arc::new(DecliningHandler { + cfg: declining_cfg.clone(), + }); + mgr.register_handler_for_names::( + declining, + declining_cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + + let fallback_cfg = config("fallback-minter", 20); + let fallback = Arc::new(FallbackMinter { + cfg: fallback_cfg.clone(), + }); + mgr.register_handler_for_names::( + fallback, + fallback_cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "downstream-tool", + "https://downstream.example.com", + &["read"], + ), + Extensions::default(), + None, + ) + .await; + assert!(result.continue_processing); + + let final_payload = extract_delegation(&result); + // Fallback minted a token. + let token = final_payload + .delegated_token + .as_ref() + .expect("fallback should have minted"); + assert_eq!(&*token.token, "fallback-token"); + // Declining handler's metadata survived. + assert_eq!( + final_payload.metadata.get("declined_by"), + Some(&serde_json::json!("declining-handler")), + ); +} + +/// Rejecting handler short-circuits via `PluginResult::deny`. Pipeline +/// halts; violation surfaces in `PipelineResult.violation`. +#[tokio::test] +async fn rejecting_handler_halts_pipeline() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("rejecting-handler", 10); + let plugin = Arc::new(RejectingHandler { + cfg: cfg.clone(), + }); + mgr.register_handler_for_names::( + plugin, + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload("tool", "https://aud.example.com", &["read"]), + Extensions::default(), + None, + ) + .await; + assert!(!result.continue_processing); + // from_pipeline_result returns None on deny — host's signal that + // no token was minted. + assert!(DelegationPayload::from_pipeline_result(&result).is_none()); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.scope_too_broad"); +} + +/// Full host-side flow: a request already has a resolved subject in +/// `Extensions.security.subject` (from a prior IdentityResolve pass); +/// the outbound forwarding plugin invokes TokenDelegate; the host +/// applies the result back to Extensions; the minted token now lives +/// in `Extensions.raw_credentials.delegated_tokens` keyed by a +/// `DelegationKey` that incorporates the subject id. +#[tokio::test] +async fn apply_to_extensions_writes_delegated_token_keyed_by_subject() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("stub-exchanger", 10); + let plugin = Arc::new(StubExchanger { + cfg: cfg.clone(), + }); + mgr.register_handler_for_names::( + plugin, + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + + // Initial extensions: identity has already populated subject. + let initial_ext = Extensions { + security: Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ), + initial_ext.clone(), + None, + ) + .await; + assert!(result.continue_processing); + + let delegation = extract_delegation(&result); + let updated_ext = delegation.apply_to_extensions(initial_ext); + + // Minted token now lives in Extensions.raw_credentials.delegated_tokens. + let raw = updated_ext + .raw_credentials + .as_ref() + .expect("raw_credentials slot populated"); + assert_eq!(raw.delegated_tokens.len(), 1); + + // The key is synthesized from (subject.id, audience, scopes, mode). + let expected_key = DelegationKey { + subject_id: "alice@corp.com".into(), + audience: "https://hr.example.com".into(), + // Order matches what StubExchanger produces (required_permissions + // first, then attenuation capabilities). + scopes: vec!["read:compensation".into(), "audit".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert!( + raw.delegated_tokens.contains_key(&expected_key), + "delegated_tokens missing expected key; saw keys: {:?}", + raw.delegated_tokens.keys().collect::>(), + ); + + // Subject from the prior identity pass survived apply. + let sec = updated_ext.security.as_ref().unwrap(); + assert_eq!( + sec.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); +} + +/// Load-bearing integration test: the full host flow from token +/// delegation through downstream CMF dispatch correctly cap-gates +/// the `delegated_tokens` slot. +/// +/// Mirrors the slice 2 `cap_gating_post_apply_through_cmf_dispatch` +/// test but for the *outbound* leg: +/// 1. TokenDelegate handler mints a downstream credential. +/// 2. Host applies the resolved payload back to `Extensions` via +/// `apply_to_extensions` — the minted token lands in +/// `Extensions.raw_credentials.delegated_tokens`. +/// 3. Host invokes `cmf.tool_pre_invoke` (the next outbound step, +/// typically where a forwarding proxy attaches the credential). +/// Two registered CMF plugins: +/// - `DelegatedTokenReader` declares `read_delegated_tokens` +/// — must observe one minted token. +/// - `DelegatedTokenBlind` declares no credential capability +/// — must observe `raw_credentials == None` because +/// `filter_extensions` strips the slot. +/// +/// Validates the symmetric story to identity's `read_inbound_credentials` +/// gating: only forwarding plugins (audit-trail consumers, proxies) +/// that explicitly declare the cap can see the minted credentials. +#[tokio::test] +async fn cap_gating_post_apply_through_cmf_dispatch() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cpex_core::cmf::enums::Role; + use cpex_core::cmf::{CmfHook, Message, MessagePayload}; + + // ----- CMF plugin WITH read_delegated_tokens ----- + struct DelegatedTokenReader { + cfg: PluginConfig, + saw_token_count: Arc, + } + #[async_trait] + impl Plugin for DelegatedTokenReader { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for DelegatedTokenReader { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let n = ext + .raw_credentials + .as_ref() + .map(|r| r.delegated_tokens.len()) + .unwrap_or(0); + self.saw_token_count.store(n, Ordering::SeqCst); + PluginResult::allow() + } + } + + // ----- CMF plugin WITHOUT credential caps ----- + struct DelegatedTokenBlind { + cfg: PluginConfig, + saw_any: Arc, + } + #[async_trait] + impl Plugin for DelegatedTokenBlind { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for DelegatedTokenBlind { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.saw_any + .store(ext.raw_credentials.is_some(), Ordering::SeqCst); + PluginResult::allow() + } + } + + // ----- Wire everything up ----- + let mgr = Arc::new(PluginManager::default()); + + // TokenDelegate handler. + let td_cfg = config("stub-exchanger", 10); + let td_plugin = Arc::new(StubExchanger { + cfg: td_cfg.clone(), + }); + mgr.register_handler_for_names::( + td_plugin, + td_cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + + // CMF reader — declares read_delegated_tokens. Also declares + // read_subject so the handler can verify subject still visible + // through the request lifecycle. + let reader_saw_count = Arc::new(AtomicUsize::new(usize::MAX)); + let reader_cfg = PluginConfig { + name: "delegated-reader".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: ["read_delegated_tokens", "read_subject"] + .iter() + .map(|s| s.to_string()) + .collect(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(DelegatedTokenReader { + cfg: reader_cfg.clone(), + saw_token_count: Arc::clone(&reader_saw_count), + }), + reader_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + // CMF blind — no cred caps. + let blind_saw = Arc::new(AtomicBool::new(false)); + let blind_cfg = PluginConfig { + name: "delegated-blind".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 20, + on_error: OnError::Fail, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(DelegatedTokenBlind { + cfg: blind_cfg.clone(), + saw_any: Arc::clone(&blind_saw), + }), + blind_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // ----- Host flow ----- + // 1. Initial Extensions has a subject (typically from a prior + // IdentityResolve pass). + let initial_ext = Extensions { + security: Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + + // 2. Token delegation. + let (td_result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ), + initial_ext.clone(), + None, + ) + .await; + assert!(td_result.continue_processing); + let delegation = DelegationPayload::from_pipeline_result(&td_result) + .expect("delegation should have minted"); + + // 3. Apply. + let updated_ext = delegation.apply_to_extensions(initial_ext); + + // 4. Dispatch through CMF. + let cmf_payload = MessagePayload { + message: Message::text(Role::User, "fetch compensation"), + }; + let (cmf_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload, + updated_ext, + None, + ) + .await; + assert!( + cmf_result.continue_processing, + "CMF dispatch should not be blocked: violation = {:?}", + cmf_result.violation, + ); + + // ----- Verifications ----- + // Plugin with cap saw the minted token. + assert_eq!( + reader_saw_count.load(Ordering::SeqCst), + 1, + "DelegatedTokenReader with read_delegated_tokens should see 1 token", + ); + // Plugin without cap saw no raw_credentials at all. + assert!( + !blind_saw.load(Ordering::SeqCst), + "DelegatedTokenBlind without credential caps must NOT see raw_credentials", + ); +} + +// PluginError kept imported so a future test wanting to assert on a +// specific error variant can use it without an extra `use` line. +#[allow(dead_code)] +fn _force_plugin_error_link(_e: PluginError) {} diff --git a/crates/cpex-core/tests/identity_e2e.rs b/crates/cpex-core/tests/identity_e2e.rs new file mode 100644 index 00000000..d262a1b2 --- /dev/null +++ b/crates/cpex-core/tests/identity_e2e.rs @@ -0,0 +1,744 @@ +// Location: ./crates/cpex-core/tests/identity_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for the IdentityResolve hook family — sub-step B +// of slice 2. +// +// Verifies the host-explicit dispatch model: the host constructs an +// `IdentityPayload`, calls `mgr.invoke_named::(...)`, +// and reads the populated identity slots back out of the returned +// `PipelineResult.modified_payload`. No bespoke `resolve_identity` +// method on `PluginManager` — `invoke_named` works for `IdentityHook` +// like every other hook, because Sequential-phase threading already +// does the right thing for the unified `IdentityPayload` +// (input + accumulator in one struct). +// +// Tests cover: +// - Single-handler resolve: one plugin populates `subject`. +// - Two-handler chain: plugin A populates `subject`, plugin B +// receives A's output and populates `caller_workload`. Final +// payload carries both — proves Sequential-phase threading. +// - In-band rejection: a handler sets `rejected = true`; the +// pipeline halts; status + reason flow back to the caller. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::{SubjectExtension, WorkloadIdentity}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +// ===================================================================== +// Plugin fixtures +// ===================================================================== + +/// A fake JWT resolver. Doesn't actually validate anything — just +/// asserts a non-empty `raw_token()` and writes a hard-coded subject. +/// Real resolvers would parse + validate the token; for wiring tests +/// we only care that the handler receives the right payload shape +/// and that its output flows back through Sequential-phase threading. +struct SubjectResolver { + cfg: PluginConfig, + subject_id: String, +} + +#[async_trait] +impl Plugin for SubjectResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for SubjectResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + assert!( + !payload.raw_token().is_empty(), + "subject resolver expected a non-empty token", + ); + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some(self.subject_id.clone()), + ..Default::default() + }); + PluginResult::modify_payload(updated) + } +} + +/// Workload resolver. Pulls a SPIFFE-ID out of (in real life) +/// `X-Forwarded-Client-Cert`; here we read it from the +/// `IdentityPayload.headers()` map and hand-roll a `WorkloadIdentity`. +/// Critical assertion for the chaining test: when this runs *after* +/// `SubjectResolver`, it must see `payload.subject` already populated +/// — proves Sequential-phase threading carries plugin 1's output +/// forward into plugin 2's input. +struct WorkloadResolver { + cfg: PluginConfig, + require_prior_subject: bool, +} + +#[async_trait] +impl Plugin for WorkloadResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for WorkloadResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if self.require_prior_subject { + assert!( + payload.subject.is_some(), + "workload resolver expected prior subject in chained run", + ); + } + let spiffe_id = payload + .headers() + .get("x-spiffe-id") + .cloned() + .unwrap_or_else(|| "spiffe://example.com/unknown".to_string()); + let mut updated = payload.clone(); + updated.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some(spiffe_id), + trust_domain: Some("example.com".to_string()), + ..Default::default() + }); + PluginResult::modify_payload(updated) + } +} + +/// Handler that always rejects. Used to verify the in-band rejection +/// pathway: setting `rejected = true` on the returned payload (and +/// using `PluginResult::deny`) must halt the pipeline. +struct RejectingResolver { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for RejectingResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RejectingResolver { + async fn handle( + &self, + _payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(cpex_core::error::PluginViolation::new( + "auth.expired", + "token expired", + )) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn config(name: &str, priority: i32) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_IDENTITY_RESOLVE.to_string()], + mode: PluginMode::Sequential, + priority, + on_error: OnError::Fail, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +/// Build the payload the way a host normally would: raw token from +/// `Authorization`, headers preserved, source set. Identity handlers +/// downstream read these via the public accessors. +fn build_payload(token: &str) -> IdentityPayload { + let mut headers = std::collections::HashMap::new(); + headers.insert( + "authorization".to_string(), + format!("Bearer {}", token), + ); + headers.insert( + "x-spiffe-id".to_string(), + "spiffe://example.com/agent-1".to_string(), + ); + IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers) +} + +/// Shortcut around `IdentityPayload::from_pipeline_result` for tests +/// that know the result must be present and well-typed. +fn extract_identity(result: &cpex_core::executor::PipelineResult) -> IdentityPayload { + IdentityPayload::from_pipeline_result(result) + .expect("PipelineResult had no IdentityPayload — denied or wrong hook type") +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Single handler runs, populates subject. Host receives an +/// `IdentityPayload` with subject populated; input fields survive +/// the chain unchanged. +#[tokio::test] +async fn single_resolver_populates_subject() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("subject-resolver", 10); + let plugin = Arc::new(SubjectResolver { + cfg: cfg.clone(), + subject_id: "alice@corp.com".to_string(), + }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + Extensions::default(), + None, + ) + .await; + + assert!(result.continue_processing, "pipeline should allow"); + let final_payload = extract_identity(&result); + + // Output populated by the handler. + assert_eq!( + final_payload.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); + + // Input fields preserved through Sequential threading + clone. + assert_eq!(final_payload.raw_token(), "eyJ.fake.jwt"); + assert_eq!(final_payload.source_header(), Some("Authorization")); +} + +/// Two handlers in priority order. Handler 1 writes subject; handler +/// 2 — running after — must see subject already populated (via the +/// `require_prior_subject` assertion in its handler). Final payload +/// carries both contributions. +/// +/// This is the load-bearing test for the whole design: it proves +/// that Sequential-phase threading is exactly what the multi-handler +/// composition model needs, without any framework changes beyond +/// what already exists for CMF. +#[tokio::test] +async fn two_resolvers_chain_populates_both_slots() { + let mgr = Arc::new(PluginManager::default()); + + let subject_cfg = config("subject-resolver", 10); + let subject = Arc::new(SubjectResolver { + cfg: subject_cfg.clone(), + subject_id: "alice@corp.com".to_string(), + }); + mgr.register_handler::(subject, subject_cfg) + .unwrap(); + + let workload_cfg = config("workload-resolver", 20); // runs after subject + let workload = Arc::new(WorkloadResolver { + cfg: workload_cfg.clone(), + require_prior_subject: true, + }); + mgr.register_handler::(workload, workload_cfg) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + Extensions::default(), + None, + ) + .await; + + assert!(result.continue_processing, "pipeline should allow"); + let final_payload = extract_identity(&result); + + // Subject from plugin 1 survived plugin 2's pass. + assert_eq!( + final_payload.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); + + // Workload added by plugin 2. + let workload = final_payload + .caller_workload + .as_ref() + .expect("workload resolver should have populated caller_workload"); + assert_eq!( + workload.spiffe_id.as_deref(), + Some("spiffe://example.com/agent-1"), + ); + + // Original input fields still intact. + assert_eq!(final_payload.raw_token(), "eyJ.fake.jwt"); +} + +/// Rejecting handler short-circuits the pipeline. `continue_processing` +/// is `false`; the violation surfaces in `PipelineResult.violation`. +/// Hosts use this to skip downstream tool invocation and return +/// a 401/403 to the client. +#[tokio::test] +async fn rejecting_resolver_halts_pipeline() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("rejecting-resolver", 10); + let plugin = Arc::new(RejectingResolver { cfg: cfg.clone() }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.expired.jwt"), + Extensions::default(), + None, + ) + .await; + + assert!(!result.continue_processing, "rejection should halt"); + let violation = result.violation.expect("rejected → violation present"); + assert_eq!(violation.code, "auth.expired"); + assert_eq!(violation.reason, "token expired"); +} + +/// Full host-side flow: invoke identity, apply the resolved payload +/// back to the `Extensions`, observe that the identity slots are now +/// populated on `Extensions.security.*` / `Extensions.raw_credentials`. +/// Downstream `cmf.tool_pre_invoke` would now see the resolved subject +/// — that's the whole point of having an identity hook. +/// +/// Also exercises the slice-1 invariant that pre-existing security +/// fields (labels, classification) survive the apply step — the +/// host shouldn't lose its earlier annotations just because identity +/// landed. +#[tokio::test] +async fn apply_to_extensions_populates_security_and_preserves_existing_fields() { + use cpex_core::extensions::SecurityExtension; + use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, + }; + + // ----- Handler: produces a subject + a RawCredentialsExtension ----- + struct FullResolver { + cfg: PluginConfig, + } + #[async_trait] + impl Plugin for FullResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for FullResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let token_bytes = payload.raw_token().to_string(); + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + // Stash the validated token under TokenRole::User so a + // forwarding plugin can re-attach it later. + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token_bytes, "Authorization", TokenKind::Jwt), + ); + updated.raw_credentials = Some(raw); + PluginResult::modify_payload(updated) + } + } + + let mgr = Arc::new(PluginManager::default()); + let cfg = config("full-resolver", 10); + let plugin = Arc::new(FullResolver { cfg: cfg.clone() }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + // ----- Host's initial Extensions carries a pre-existing label ----- + // We need to verify that applying the identity result doesn't + // clobber the label — identity should only touch identity slots. + let mut initial_security = SecurityExtension::default(); + initial_security.add_label("PII"); + initial_security.classification = Some("internal".into()); + let initial_ext = Extensions { + security: Some(Arc::new(initial_security)), + ..Default::default() + }; + + // ----- Run identity resolution ----- + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + initial_ext.clone(), + None, + ) + .await; + + assert!(result.continue_processing); + + // ----- Apply back to Extensions ----- + let final_payload = extract_identity(&result); + let updated_ext = final_payload.apply_to_extensions(initial_ext); + + // Identity slots populated on security. + let sec = updated_ext.security.as_ref().expect("security slot present"); + assert_eq!( + sec.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); + + // Pre-existing fields preserved — this is the load-bearing + // assertion for the merge-not-replace semantics. + assert!(sec.has_label("PII"), "pre-existing label survived apply"); + assert_eq!(sec.classification.as_deref(), Some("internal")); + + // RawCredentials surfaced into Extensions. + let raw = updated_ext + .raw_credentials + .as_ref() + .expect("raw_credentials slot present"); + let user_token = raw + .inbound_tokens + .get(&TokenRole::User) + .expect("user token present"); + assert_eq!(user_token.source_header, "Authorization"); + // Token bytes carried over end-to-end. Note: this only works + // because RawCredentialsExtension lives in-process — out-of-process + // serialization would strip the token field. + assert_eq!(&*user_token.token, "eyJ.fake.jwt"); +} + +/// When the IdentityHook chain is denied, `from_pipeline_result` +/// returns `None` because the executor produces no `modified_payload` +/// on the deny path. Hosts use this to distinguish "identity +/// resolved" from "identity rejected" without a separate type. +#[tokio::test] +async fn from_pipeline_result_returns_none_on_deny() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("rejecter", 10); + let plugin = Arc::new(RejectingResolver { cfg: cfg.clone() }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.tok"), + Extensions::default(), + None, + ) + .await; + assert!(!result.continue_processing); + assert!(IdentityPayload::from_pipeline_result(&result).is_none()); +} + +/// Load-bearing integration test: the full host flow from identity +/// resolution through CMF dispatch correctly cap-gates the +/// `raw_credentials` slot. +/// +/// Scenario: +/// 1. IdentityResolve handler populates `subject` + a +/// RawCredentialsExtension with a User token. +/// 2. Host applies the resolved payload back to `Extensions` via +/// `apply_to_extensions`, getting a fully-populated request +/// Extensions container. +/// 3. Host invokes `cmf.tool_pre_invoke` against two registered +/// CMF plugins: +/// - `InboundReader` declares `read_inbound_credentials` — +/// must observe `raw_credentials` with one token. +/// - `InboundBlind` declares no credential capability — +/// must observe `raw_credentials == None` because the +/// executor's `filter_extensions` strips the slot. +/// +/// Proves end-to-end that cap-gating is honored when the identity +/// hook's output flows through the host's apply-then-dispatch path. +/// The unit tests in `extensions/filter.rs` exercise the gate in +/// isolation; this test pins the wiring through the real executor. +#[tokio::test] +async fn cap_gating_post_apply_through_cmf_dispatch() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Mutex; + + use cpex_core::cmf::enums::Role; + use cpex_core::cmf::{CmfHook, Message, MessagePayload}; + use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, + }; + use cpex_core::extensions::SecurityExtension; + + // ----- Identity resolver: populates subject + one inbound token ----- + struct FullResolver { + cfg: PluginConfig, + } + #[async_trait] + impl Plugin for FullResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for FullResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let token = payload.raw_token().to_string(); + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token, "Authorization", TokenKind::Jwt), + ); + updated.raw_credentials = Some(raw); + PluginResult::modify_payload(updated) + } + } + + // ----- CMF plugin WITH read_inbound_credentials ----- + // Writes 1 if it saw a token, 0 if it saw none. + struct InboundReader { + cfg: PluginConfig, + saw_token_count: Arc, + saw_subject_id: Arc>>, + } + #[async_trait] + impl Plugin for InboundReader { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for InboundReader { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Should see the token — plugin declared the cap. + let n = ext + .raw_credentials + .as_ref() + .map(|r| r.inbound_tokens.len()) + .unwrap_or(0); + self.saw_token_count.store(n, Ordering::SeqCst); + // Subject also visible — read_subject gives id+type baseline. + let id = ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()); + *self.saw_subject_id.lock().unwrap() = id; + PluginResult::allow() + } + } + + // ----- CMF plugin WITHOUT credential caps ----- + // Records whether it observed raw_credentials (it shouldn't). + struct InboundBlind { + cfg: PluginConfig, + saw_any_credentials: Arc, + } + #[async_trait] + impl Plugin for InboundBlind { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for InboundBlind { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // raw_credentials must be None — filter_extensions strips + // the slot when neither sub-cap is held. + self.saw_any_credentials + .store(ext.raw_credentials.is_some(), Ordering::SeqCst); + PluginResult::allow() + } + } + + // ----- Wire it all up ----- + let mgr = Arc::new(PluginManager::default()); + + // IdentityHook handler. + let id_cfg = config("full-resolver", 10); + mgr.register_handler::( + Arc::new(FullResolver { + cfg: id_cfg.clone(), + }), + id_cfg, + ) + .unwrap(); + + // CMF plugins. Both register against cmf.tool_pre_invoke; they + // run in priority order during the same invoke. + let reader_saw_count = Arc::new(AtomicUsize::new(usize::MAX)); // sentinel + let reader_saw_subject = Arc::new(Mutex::new(None)); + let reader_cfg = PluginConfig { + name: "inbound-reader".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: ["read_inbound_credentials", "read_subject"] + .iter() + .map(|s| s.to_string()) + .collect(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(InboundReader { + cfg: reader_cfg.clone(), + saw_token_count: Arc::clone(&reader_saw_count), + saw_subject_id: Arc::clone(&reader_saw_subject), + }), + reader_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + let blind_saw_creds = Arc::new(AtomicBool::new(false)); + let blind_cfg = PluginConfig { + name: "inbound-blind".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 20, + on_error: OnError::Fail, + capabilities: Default::default(), // no caps + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(InboundBlind { + cfg: blind_cfg.clone(), + saw_any_credentials: Arc::clone(&blind_saw_creds), + }), + blind_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // ----- Host flow ----- + // 1. Initial Extensions carrying a label — verifies later that + // apply_to_extensions doesn't clobber pre-existing security + // fields when populating identity slots. + let mut initial_security = SecurityExtension::default(); + initial_security.add_label("PII"); + let initial_ext = Extensions { + security: Some(Arc::new(initial_security)), + ..Default::default() + }; + + // 2. Identity resolution. + let (id_result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + initial_ext.clone(), + None, + ) + .await; + assert!(id_result.continue_processing); + let identity = IdentityPayload::from_pipeline_result(&id_result) + .expect("identity should have resolved"); + + // 3. Apply. + let updated_ext = identity.apply_to_extensions(initial_ext); + + // 4. Dispatch through CMF. Both plugins run; each sees the + // capability-filtered view of `updated_ext`. + let cmf_payload = MessagePayload { + message: Message::text(Role::User, "fetch sensitive data"), + }; + let (cmf_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload, + updated_ext, + None, + ) + .await; + assert!( + cmf_result.continue_processing, + "CMF dispatch should not be blocked: violation = {:?}", + cmf_result.violation, + ); + + // ----- Verifications ----- + // Plugin with cap saw the inbound token. + assert_eq!( + reader_saw_count.load(Ordering::SeqCst), + 1, + "InboundReader with read_inbound_credentials should see 1 token", + ); + // Plugin with cap also saw the resolved subject (read_subject baseline). + assert_eq!( + reader_saw_subject.lock().unwrap().as_deref(), + Some("alice@corp.com"), + ); + // Plugin without cap saw nothing — filter_extensions stripped the slot. + assert!( + !blind_saw_creds.load(Ordering::SeqCst), + "InboundBlind without credential caps must NOT see raw_credentials", + ); +} + +// PluginError import only exists to keep the dev-dep on cpex-core +// honest if a future test needs it; unused for now. +#[allow(dead_code)] +fn _force_plugin_error_link(_e: PluginError) {} diff --git a/crates/cpex-core/tests/identity_route_e2e.rs b/crates/cpex-core/tests/identity_route_e2e.rs new file mode 100644 index 00000000..05ae3b19 --- /dev/null +++ b/crates/cpex-core/tests/identity_route_e2e.rs @@ -0,0 +1,867 @@ +// Location: ./crates/cpex-core/tests/identity_route_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for the route-level `identity:` block (Slice A). +// +// Verifies the hook-specific binding semantics: +// * A route's `identity:` block is the authoritative dispatch list +// for the `identity.resolve` hook on that route. +// * The route's `plugins:` block (which means "per-route overrides" +// in APL-driven routes, "per-route binding" otherwise) does NOT +// bind plugins for the `identity.resolve` hook. +// * Dispatch order matches the order steps are declared in +// `identity:`, NOT the plugins' chain-priority values. +// * Per-step config overrides flow through the existing +// `create_override_instance` pathway. +// +// Companion tests for IdentityHook *semantics* (payload threading, +// rejection, apply_to_extensions) live in `identity_e2e.rs`. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use cpex_core::config; +use cpex_core::context::PluginContext; +use cpex_core::extensions::{MetaExtension, SubjectExtension}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +// ===================================================================== +// Test plugin: a recording identity resolver +// ===================================================================== +// +// Each instance writes its own name to a shared `Vec` ledger +// when invoked. That lets tests assert (a) which plugins fired and +// (b) in what order. Also stamps `subject.id` so the post-pipeline +// payload reflects who ran last — useful for verifying that the +// chain produced the expected accumulated state. + +struct RecordingResolver { + cfg: PluginConfig, + name: String, + ledger: Arc>>, + /// Number of times this instance has been invoked. Used to verify + /// that per-step config overrides actually produce a fresh instance + /// rather than reusing the base. + invocation_count: Arc, + /// Optional sink for what `Extensions` slots the plugin saw on + /// invocation. Used by cap-gating tests. `None` when the test + /// doesn't care about visibility. + extensions_observation: Arc>>, +} + +/// What an identity resolver saw in `Extensions` during invocation — +/// drives the cap-gating tests. Only includes slots the tests check +/// (security.subject id, labels). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct IdentityExtensionsObservation { + saw_subject_id: Option, + saw_labels: Vec, +} + +#[async_trait] +impl Plugin for RecordingResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RecordingResolver { + async fn handle( + &self, + payload: &IdentityPayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.ledger.lock().unwrap().push(self.name.clone()); + self.invocation_count.fetch_add(1, Ordering::SeqCst); + + // Capability-gating observation. cpex-core's executor calls + // `filter_extensions(&ext, &caps)` BEFORE handing us `ext`, + // so this snapshot reflects exactly what our declared + // capabilities expose. + *self.extensions_observation.lock().unwrap() = + Some(IdentityExtensionsObservation { + saw_subject_id: ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()), + saw_labels: ext + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(), + }); + + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some(self.name.clone()), + ..Default::default() + }); + PluginResult::modify_payload(updated) + } +} + +// ===================================================================== +// Test factory — used to build plugin instances from a config block +// so route-level `config:` overrides can produce fresh instances via +// `create_override_instance`. +// ===================================================================== + +struct RecordingFactory { + ledger: Arc>>, + /// Count of *factory invocations* (i.e. instance constructions). + /// Distinct from `invocation_count` on individual plugins — + /// asserts that a config override produced a NEW instance. + factory_calls: Arc, + /// Optional shared observation sink — when set, every plugin + /// the factory builds writes its extensions-view snapshot here + /// on invocation. The test holds the same Arc and reads it + /// after dispatch. `None` means observations are off (existing + /// tests don't need them and shouldn't pay the wiring cost). + observation_sink: Option>>>, +} + +impl PluginFactory for RecordingFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + self.factory_calls.fetch_add(1, Ordering::SeqCst); + let plugin = Arc::new(RecordingResolver { + cfg: config.clone(), + name: config.name.clone(), + ledger: Arc::clone(&self.ledger), + invocation_count: Arc::new(AtomicUsize::new(0)), + extensions_observation: self + .observation_sink + .clone() + .unwrap_or_else(|| Arc::new(Mutex::new(None))), + }); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + Ok(PluginInstance { + plugin: plugin as Arc, + handlers: vec![(HOOK_IDENTITY_RESOLVE, adapter)], + }) + } +} + +// ===================================================================== +// Test helpers +// ===================================================================== + +/// Build the request Extensions with MetaExtension set so route +/// filtering kicks in. Without `meta`, the filter falls through to +/// chain dispatch (all entries returned) — that's the wrong code +/// path to be testing. +fn ext_for_tool(tool_name: &str) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".to_string()), + entity_name: Some(tool_name.to_string()), + ..Default::default() + })), + ..Default::default() + } +} + +fn build_payload(token: &str) -> IdentityPayload { + IdentityPayload::new(token, TokenSource::Bearer) +} + +/// Standard set-up: PluginManager with the recording factory +/// registered, plus a shared ledger and factory-call counter the +/// test asserts on. Doesn't wire extensions observation — +/// existing tests don't need it. +fn manager_with_recording_factory() -> ( + Arc, + Arc>>, + Arc, +) { + let ledger = Arc::new(Mutex::new(Vec::new())); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "recording", + Box::new(RecordingFactory { + ledger: Arc::clone(&ledger), + factory_calls: Arc::clone(&factory_calls), + observation_sink: None, + }), + ); + (mgr, ledger, factory_calls) +} + +/// Cap-gating-flavored set-up: also returns a shared `observation_sink` +/// the test holds onto so it can inspect what extensions the plugin +/// actually saw after invocation. Every plugin the factory builds +/// writes its observation to this shared Arc (latest wins). +fn manager_with_observing_factory() -> ( + Arc, + Arc>>, + Arc>>, +) { + let ledger = Arc::new(Mutex::new(Vec::new())); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observation_sink: Arc>> = + Arc::new(Mutex::new(None)); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "recording", + Box::new(RecordingFactory { + ledger: Arc::clone(&ledger), + factory_calls: Arc::clone(&factory_calls), + observation_sink: Some(Arc::clone(&observation_sink)), + }), + ); + (mgr, ledger, observation_sink) +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Baseline: route's `identity:` block dispatches the listed plugins, +/// in declared order, for `identity.resolve`. The ledger should +/// reflect the YAML order verbatim — proves the per-route binding + +/// preserved order story end-to-end. +#[tokio::test] +async fn route_identity_block_dispatches_in_declared_order() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + // Three identity plugins, all registered under `identity.resolve`. + // Route declares them in REVERSE priority order to prove that + // routing follows the `identity:` declaration, not chain priority. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: jwt-a + kind: recording + hooks: [identity.resolve] + priority: 10 + - name: jwt-b + kind: recording + hooks: [identity.resolve] + priority: 20 + - name: jwt-c + kind: recording + hooks: [identity.resolve] + priority: 30 + +routes: + - tool: get_weather + identity: + - jwt-c # priority 30 — would naturally run LAST in chain order + - jwt-a # priority 10 — would naturally run FIRST + - jwt-b # priority 20 +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + + assert!( + result.continue_processing, + "pipeline should allow; violation = {:?}", + result.violation, + ); + + // Order matches the YAML's `identity:` declaration, NOT plugin priority. + let firings = ledger.lock().unwrap().clone(); + assert_eq!(firings, vec!["jwt-c", "jwt-a", "jwt-b"]); +} + +/// `identity:` is hook-specific. Plugins in the route's `plugins:` +/// block (which means "per-route overrides" in APL-driven routes +/// and "per-route binding" otherwise) must NOT fire for the +/// identity.resolve hook. This is the load-bearing test for +/// Option 1 — the design decision that `identity:` is its own +/// dispatch list, independent of `plugins:`. +#[tokio::test] +async fn route_plugins_block_does_not_bind_identity_resolve() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + // The route declares `identity:` with corp-jwt, and `plugins:` + // with rogue-jwt. rogue-jwt also registers under identity.resolve + // — but should NOT fire for the identity.resolve hook on this + // route because it's listed in `plugins:`, not `identity:`. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + - name: rogue-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_weather + identity: + - corp-jwt + plugins: + - rogue-jwt +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + + // Only corp-jwt fired — rogue-jwt was in `plugins:`, not + // `identity:`, so it's NOT bound for this hook on this route. + assert_eq!(ledger.lock().unwrap().clone(), vec!["corp-jwt"]); +} + +/// A route with no `identity:` block produces zero identity +/// dispatches even when the entity_type / entity_name match. The +/// plugins ARE registered under identity.resolve, but no route +/// binds them, so the route-filter returns an empty entry list. +#[tokio::test] +async fn route_without_identity_block_dispatches_no_resolvers() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_weather + # No identity: block. + plugins: + - corp-jwt +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + + // No identity plugins fired — `identity:` was absent, so the + // route binds nothing for the identity.resolve hook even though + // corp-jwt is in `plugins:`. + assert!(ledger.lock().unwrap().is_empty()); +} + +/// A route declared for a different tool doesn't bind identity for +/// this request — proves scope/entity matching still works under +/// the new resolver path. +#[tokio::test] +async fn identity_route_filter_respects_entity_match() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_compensation + identity: + - corp-jwt +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + // Request for a DIFFERENT tool — corp-jwt should not fire. + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("unrelated_tool"), + None, + ) + .await; + assert!(result.continue_processing); + assert!( + ledger.lock().unwrap().is_empty(), + "identity must NOT fire for a non-matching route", + ); +} + +/// Per-step `config_override` produces a fresh plugin instance via +/// the existing `create_override_instance` pathway. The factory +/// call count goes up by one each time the route's identity step +/// is dispatched with an override — proves the wrapper around +/// `resolve_identity_plugins_for_route` correctly threads the +/// override through to `filter_entries_by_route`'s override branch. +#[tokio::test] +async fn per_step_config_override_produces_fresh_instance() { + let (mgr, _ledger, factory_calls) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + config: + audience: default-aud + +routes: + - tool: get_weather + identity: + - name: corp-jwt + config: + audience: route-specific-aud +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + // Sanity: factory was called once for the base plugin during + // load_config. Track from here. + let base_calls = factory_calls.load(Ordering::SeqCst); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + + // One additional factory call for the override instance. + assert_eq!( + factory_calls.load(Ordering::SeqCst), + base_calls + 1, + "config_override should produce a new factory call", + ); +} + +/// Slice C — end-to-end inheritance: global.identity contributes to +/// the dispatch lineup for routes that declare no identity block of +/// their own. Verifies the dispatch path picks up the global layer. +#[tokio::test] +async fn global_identity_inherited_when_route_has_no_block() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + +routes: + - tool: get_weather +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + assert_eq!( + ledger.lock().unwrap().clone(), + vec!["corp-jwt"], + "global identity should fire when the route declares none", + ); +} + +/// Full stack — global + tag bundle + route — in declared order. +/// Proves the merge actually flows the layers through cpex-core's +/// dispatch in the order the resolver guarantees. +#[tokio::test] +async fn global_tag_route_identity_stack_dispatches_in_order() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + - name: workday-saml + kind: recording + hooks: [identity.resolve] + - name: agent-context + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml + +routes: + - tool: get_compensation + meta: + tags: [finance] + identity: + - agent-context +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_compensation"), + None, + ) + .await; + assert!(result.continue_processing); + + // Order: global → tag bundle → route. The ledger captures the + // actual dispatch order (preserves the resolver's stacking). + assert_eq!( + ledger.lock().unwrap().clone(), + vec!["corp-jwt", "workday-saml", "agent-context"], + ); +} + +/// Route opts out via `replace_inherited: true` — inherited layers +/// (global, tag bundles) are dropped. Only the route's steps run. +#[tokio::test] +async fn replace_inherited_drops_inherited_layers_end_to_end() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + - name: workday-saml + kind: recording + hooks: [identity.resolve] + - name: legacy-basic-auth + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml + +routes: + - tool: legacy_endpoint + meta: + tags: [finance] + identity: + replace_inherited: true + steps: + - legacy-basic-auth +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("legacy_endpoint"), + None, + ) + .await; + assert!(result.continue_processing); + + // Only the route's step ran — global and tag-bundle layers + // were dropped because `replace_inherited: true`. + assert_eq!( + ledger.lock().unwrap().clone(), + vec!["legacy-basic-auth"], + ); +} + +/// `replace_inherited: true` + `steps: []` — the explicit +/// "anonymous route, no identity" knob. Zero plugins fire even +/// though global identity is configured. +#[tokio::test] +async fn replace_inherited_with_empty_steps_yields_anonymous_route() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + +routes: + - tool: public_endpoint + identity: + replace_inherited: true + steps: [] +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("public_endpoint"), + None, + ) + .await; + assert!(result.continue_processing); + + assert!( + ledger.lock().unwrap().is_empty(), + "anonymous-route opt-out should suppress global identity", + ); +} + +/// Sanity that an empty Vec from the resolver (route has identity +/// but with `replace_inherited: true` and zero steps — the explicit +/// "opt out" knob) results in zero dispatches. +#[tokio::test] +async fn route_with_empty_identity_steps_dispatches_nothing() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_weather + identity: + replace_inherited: true + steps: [] +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + assert!(ledger.lock().unwrap().is_empty()); +} + +// --------------------------------------------------------------------- +// Capability gating on the identity dispatch path. +// +// Identity plugins go through cpex-core's executor like every other +// hook family — meaning `filter_extensions(&ext, &caps)` runs before +// each handler invoke and narrows what the plugin sees to its +// declared capabilities. These tests pin that behavior for the +// route-level identity dispatch path (Slice A). +// +// Identity is unusual in that resolvers typically WRITE state (subject, +// chain) rather than read it — but they still need read capabilities +// for any extension-derived context they consult during resolution +// (e.g., a `read_meta`-gated resolver that branches on entity tags). +// --------------------------------------------------------------------- + +/// Build extensions seeded with subject + label so cap-gating tests +/// can verify what a resolver sees post-filter. +fn ext_for_tool_with_subject_and_label( + tool_name: &str, + subject_id: &str, + label: &str, +) -> Extensions { + use cpex_core::extensions::{SecurityExtension, SubjectExtension}; + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some(subject_id.to_string()), + ..Default::default() + }); + sec.add_label(label); + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".to_string()), + entity_name: Some(tool_name.to_string()), + ..Default::default() + })), + security: Some(Arc::new(sec)), + ..Default::default() + } +} + +/// Identity resolver declaring `read_subject` sees `subject.id` in +/// Extensions but NOT `security.labels` — the executor strips the +/// labels slot because the plugin doesn't hold `read_labels`. +#[tokio::test] +async fn identity_plugin_with_read_subject_sees_subject_but_not_labels() { + let (mgr, _ledger, sink) = manager_with_observing_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: scoped-jwt + kind: recording + hooks: [identity.resolve] + capabilities: [read_subject] + +routes: + - tool: get_weather + identity: + - scoped-jwt +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + // Extensions populated with BOTH subject (id=alice) AND a label + // (pii). The plugin should see subject only. + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool_with_subject_and_label("get_weather", "alice", "pii"), + None, + ) + .await; + assert!(result.continue_processing); + + let obs = sink + .lock() + .unwrap() + .clone() + .expect("plugin should have recorded its view"); + + assert_eq!( + obs.saw_subject_id.as_deref(), + Some("alice"), + "read_subject cap should expose subject.id", + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, labels must be hidden — saw: {:?}", + obs.saw_labels, + ); +} + +/// Identity resolver with NO capabilities sees a fully-stripped +/// Extensions view. Negative case: confirms the executor's per-entry +/// filter actually hides slots when no cap is declared. +#[tokio::test] +async fn identity_plugin_without_caps_sees_stripped_extensions() { + let (mgr, _ledger, sink) = manager_with_observing_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: capless-jwt + kind: recording + hooks: [identity.resolve] + # capabilities: [] (omitted entirely; same effect) + +routes: + - tool: get_weather + identity: + - capless-jwt +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool_with_subject_and_label("get_weather", "alice", "pii"), + None, + ) + .await; + assert!(result.continue_processing); + + let obs = sink + .lock() + .unwrap() + .clone() + .expect("plugin should have recorded its view"); + + assert!( + obs.saw_subject_id.is_none(), + "without read_subject, subject must be hidden — saw: {:?}", + obs.saw_subject_id, + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, labels must be hidden", + ); +} diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index 73d55683..8adcb8ce 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -20,6 +20,19 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] cpex-core = { path = "../cpex-core" } +# APL governance layer — bundled so Go/Python hosts can enable APL +# policies, route handlers, and the standard plugin/PDP factories via +# the `cpex_apl_install` FFI entry point. Symbols survive in the +# staticlib because that entry point references each factory. +apl-cpex = { path = "../apl-cpex" } +apl-pii-scanner = { path = "../apl-pii-scanner" } +apl-audit-logger = { path = "../apl-audit-logger" } +apl-identity-jwt = { path = "../apl-identity-jwt" } +apl-delegator-oauth = { path = "../apl-delegator-oauth" } +apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct" } +# Heavy (~200 transitive deps via the Cedarling git dep); kept out of the +# default `.a` and behind the `cedarling` feature. +apl-cedarling = { path = "../apl-cedarling", optional = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -27,5 +40,11 @@ rmp-serde = { workspace = true } serde_bytes = { workspace = true } tracing = { workspace = true } +[features] +default = [] +# Opt-in Cedarling-backed identity + PDP. Build with +# `cargo build -p cpex-ffi --features cedarling`. +cedarling = ["dep:apl-cedarling"] + [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md new file mode 100644 index 00000000..0430a34b --- /dev/null +++ b/crates/cpex-ffi/RELEASE.md @@ -0,0 +1,231 @@ +# `libcpex_ffi.a` — Release Artifacts + +CPEX publishes pre-built `libcpex_ffi.a` static libraries as signed +GitHub Release artifacts. Downstream consumers (Go bindings, +language bindings, anyone embedding CPEX) link against these +without needing a Rust toolchain. + +This document covers what is published, how to consume and verify +an artifact, and the FFI ABI policy that makes the contract durable. + +> **APL bundled.** The published `.a` includes the APL (Attribute Policy +> Language) governance layer and its standard plugin/PDP factories +> (`validator/pii-scan`, `audit/logger`, `identity/jwt`, +> `delegator/oauth`, `cedar-direct`). Enable it on a manager via +> `cpex_apl_install` (Go: `PluginManager.EnableAPL()`) after +> `cpex_manager_new_default` and before `cpex_load_config`. The +> Cedarling-backed seams are **not** in the default `.a` — build with +> `cargo build -p cpex-ffi --features cedarling` to include them. + +## What is published + +Every CPEX release tagged `vMAJOR.MINOR.PATCH` (or +`vMAJOR.MINOR.PATCH-`) attaches one tarball per +supported target tuple to the GitHub Release, along with checksums +and signatures. + +### Naming and layout + +For release `vX.Y.Z` and tuple `-[-]`: + +``` +cpex-ffi-vX.Y.Z--[-].tar.gz +cpex-ffi-vX.Y.Z--[-].tar.gz.sha256 +cpex-ffi-vX.Y.Z--[-].tar.gz.sig +cpex-ffi-vX.Y.Z--[-].tar.gz.crt +``` + +Plus one aggregate integrity manifest for the whole release: + +``` +cpex-ffi-vX.Y.Z-SHA256SUMS +cpex-ffi-vX.Y.Z-SHA256SUMS.sig +cpex-ffi-vX.Y.Z-SHA256SUMS.crt +``` + +Each tarball, when extracted, contains: + +| File | Contents | +|-------------------|---------------------------------------------------| +| `libcpex_ffi.a` | Static library — the actual deliverable. | +| `VERSION` | Plain text. Keys: `version`, `git_sha`, `build_date`, `tuple`, `rust_target`. | +| `FFI_ABI` | Single integer line — FFI ABI version. See policy below. | +| `LICENSE` | Copy of CPEX's Apache-2.0 license. | + +Tarballs are flat (no leading directory). `tar xzf -C ` drops the four files directly into ``. + +### Target matrix + +| Tuple | Rust target triple | Runner | +|----------------------|---------------------------------|-----------------| +| `linux-amd64-gnu` | `x86_64-unknown-linux-gnu` | `ubuntu-latest` | +| `linux-arm64-gnu` | `aarch64-unknown-linux-gnu` | `ubuntu-22.04-arm` | +| `linux-amd64-musl` | `x86_64-unknown-linux-musl` | `ubuntu-latest` | +| `linux-arm64-musl` | `aarch64-unknown-linux-musl` | `ubuntu-22.04-arm` | +| `darwin-arm64` | `aarch64-apple-darwin` | `macos-14` | + +`darwin-amd64` and Windows targets are not built in v1. Open an +issue if you need one — adding to the matrix is mechanical. + +### Signing + +Tarballs and the aggregate `SHA256SUMS` are signed with +[cosign](https://github.com/sigstore/cosign) **keyless** via +Sigstore (Fulcio for cert issuance, Rekor for transparency). There +is no long-lived signing key — each release produces short-lived +certs bound to the GitHub Actions OIDC identity of the +`release-ffi.yaml` workflow on the canonical repo. Verification +checks both the cert subject and the OIDC issuer. + +## How to consume + +### One-shot: the helper script + +The repo ships `scripts/download-ffi-artifact.sh` — vendor it +into your build (or fetch via `raw.githubusercontent.com` pinned to +a tag) and call it before `go build` / `cargo build` / etc. + +```sh +export CPEX_FFI_VERSION=v0.9.0 +ARTIFACT_DIR=$(bash scripts/download-ffi-artifact.sh) +export CGO_LDFLAGS="-L${ARTIFACT_DIR} -lcpex_ffi" +go build ./... +``` + +What it does: + +1. Auto-detects your tuple from `uname -s` / `uname -m` (override + with `CPEX_FFI_TARGET`). +2. Downloads the tarball, `.sha256`, `.sig`, `.crt`. +3. Verifies the SHA256 — non-skippable. +4. Verifies the cosign signature against the canonical workflow + identity and OIDC issuer — skippable via + `CPEX_FFI_SKIP_COSIGN=1` only for air-gapped environments. +5. Unpacks to `${CPEX_FFI_DEST}` (default + `./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/`). +6. Prints the absolute destination to stdout. + +Subsequent runs against the same version + dest are no-ops. + +### Manual: cosign + tar + +If you want to do it by hand: + +```sh +VER=v0.9.0 +TUPLE=linux-amd64-gnu +BASE="https://github.com/contextforge-org/cpex/releases/download/${VER}" +NAME="cpex-ffi-${VER}-${TUPLE}.tar.gz" + +curl -fsSLO "${BASE}/${NAME}" +curl -fsSLO "${BASE}/${NAME}.sha256" +curl -fsSLO "${BASE}/${NAME}.sig" +curl -fsSLO "${BASE}/${NAME}.crt" + +sha256sum -c "${NAME}.sha256" + +cosign verify-blob \ + --certificate "${NAME}.crt" \ + --signature "${NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/contextforge-org/cpex/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${NAME}" + +mkdir -p ./libcpex +tar xzf "${NAME}" -C ./libcpex +``` + +After this, `./libcpex/libcpex_ffi.a` is your link target. + +### Using with the in-tree Go binding + +`go/cpex/ffi.go` links via `#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi` +relative to the cpex repo layout. For downstream Go consumers that +pull `go/cpex` via `go get`, set `CGO_LDFLAGS` to point at the +unpacked artifact directory and the cgo `-L` from `LDFLAGS` will be +augmented by the env var: + +```sh +ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.9.0 bash scripts/download-ffi-artifact.sh) +CGO_LDFLAGS="-L${ARTIFACT_DIR}" go build ./... +``` + +## FFI ABI policy + +The `FFI_ABI` integer in each bundle declares the wire-level C +contract version that `libcpex_ffi.a` exposes. Language bindings +must record the ABI version they were generated against and check +it at runtime — the Go binding does this in `go/cpex/abi.go`'s +`init()` and panics on mismatch. Every other binding **must do the +same**; silent acceptance of an ABI mismatch produces undefined +behavior on every subsequent FFI call. + +### What counts as an ABI break + +A bump of `FFI_ABI_VERSION` is **required** for any of: + +- Adding, removing, or renaming an `extern "C"` function. +- Changing argument count, argument type, or return type of an + existing extern function. +- Changing the layout of a struct that crosses the boundary. +- Changing the ownership or lifetime contract of a pointer + returned from / accepted by an extern function. +- Changing the semantics of a return code for a previously-success + case (e.g. a function that used to return `RC_OK` now returns a + new code on the same input). + +A bump is **not** required for: + +- Adding a new `RC_*` code at the end of the existing range. + Existing wire codes are stable; consumers should treat unknown + codes as generic failure. +- Internal Rust refactors that leave the C surface unchanged. +- Documentation / comment changes. + +### Process + +1. The Rust author bumps `FFI_ABI_VERSION` in + `crates/cpex-ffi/src/lib.rs` in the same PR as the breaking + change. +2. All in-tree language bindings (today: `go/cpex/abi.go`'s + `expectedFFIABIVersion`) are bumped to match in the same PR. +3. `CHANGELOG.md` records the bump under **Changed** with the + from→to integers and a one-line description of what moved. +4. The release tag that ships the breaking change is a new + `MINOR` (or `MAJOR`) — never a `PATCH`. + +## Versioning + +The artifact tag matches the CPEX repo tag exactly. There is no +separate "FFI version" — `vX.Y.Z` of CPEX produces `cpex-ffi-vX.Y.Z-*` +artifacts. Prereleases (`vX.Y.Z-rc1`, `vX.Y.Z-beta.1`, +`vX.Y.Z-ffi.test.1`, etc.) publish too and land as GitHub Releases +flagged "prerelease" — they don't surface as "latest". + +The FFI ABI version is independent: a release that doesn't touch +the C surface keeps the same `FFI_ABI`, even across minor / major +CPEX bumps. + +## Reproducibility caveats + +Builds use `cargo build --release --locked`, which pins the +`Cargo.lock` resolution. Beyond that, no guarantees: + +- Timestamps in the built `.a` differ between runs. +- Compiler / OS image patch versions on the runner can shift. +- macOS code-signing metadata varies per build. + +Consumers care about `FFI_ABI` (contract stability) and SHA + cosign +(integrity + authenticity), not bit-identical reproducibility. +Adding `cargo-zigbuild` or a sysroot-pinning toolchain to harden +reproducibility is a v2 ask. + +## When something is wrong + +| Symptom | Likely cause / fix | +|----------------------------------|----------------------------------------------------------------------------| +| `cosign verify-blob` fails | Wrong `--certificate-identity-regexp` (must point at the canonical repo's `release-ffi.yaml`), or the artifact came from a fork rather than the canonical workflow. | +| sha256 mismatch | The download was corrupted or the upstream release was rewritten. Open an issue. | +| Go `init` panics with ABI mismatch | The linked `.a` and the Go binding were generated against different ABI versions. Pin both to the same CPEX tag. | +| Unsupported tuple | Your platform isn't in the matrix. Either add it (PR welcome) or build the `.a` locally from source. | +| `tar` complains about absolute paths | Bundles are flat (no leading dir). Extract with `tar xzf -C `, not into the current dir. | diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs new file mode 100644 index 00000000..b0cd3444 --- /dev/null +++ b/crates/cpex-ffi/src/apl.rs @@ -0,0 +1,101 @@ +// Location: ./crates/cpex-ffi/src/apl.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL (Attribute Policy Language) FFI wiring. +// +// `cpex_apl_install` registers the bundled APL plugin factories and +// installs the APL config visitor on a manager so that a subsequent +// `cpex_load_config` walks `apl:` blocks and installs per-route handlers. +// +// Registration is explicit (no inventory/ctor magic): each factory is +// referenced here so its object code survives in `libcpex_ffi.a`. Adding +// a new bundled factory means adding a `register_factory` call below. +// +// Ordering: call AFTER `cpex_manager_new_default` and BEFORE +// `cpex_load_config`. The config visitor must be registered before the +// config is loaded, and the one-shot `cpex_manager_new(yaml)` path loads +// during construction — so APL is only supported via the default-manager +// flow: +// +// cpex_manager_new_default +// → cpex_apl_install +// → cpex_load_config +// → cpex_initialize + +use std::os::raw::c_int; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::Arc; + +use crate::{CpexManagerInner, RC_INVALID_HANDLE, RC_OK, RC_PANIC}; + +/// Register the bundled APL plugin factories and install the APL config +/// visitor (in-process defaults: memory session store, default baseline +/// capabilities) on `mgr`. +/// +/// Bundled plugin factories (registered by `kind`): +/// - `validator/pii-scan` → apl-pii-scanner +/// - `audit/logger` → apl-audit-logger +/// - `identity/jwt` → apl-identity-jwt +/// - `delegator/oauth` → apl-delegator-oauth +/// +/// Bundled PDP factory (consulted for `global.apl.pdp[]` entries): +/// - `cedar-direct` → apl-pdp-cedar-direct +/// +/// With the `cedarling` cargo feature, the Cedarling-backed identity and +/// PDP seams are additionally wired. +/// +/// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `mgr` is null, or +/// `RC_PANIC` if registration panicked (caught at the FFI boundary). +/// +/// # Safety +/// `mgr` must be a valid handle returned by `cpex_manager_new_default` +/// (or `cpex_manager_new`) and not yet shut down. +#[no_mangle] +pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let result = catch_unwind(AssertUnwindSafe(|| { + // Plugin factories — registered by `kind` string. Must happen + // before load_config so the manager can instantiate plugins whose + // YAML `kind:` matches. + inner.manager.register_factory( + apl_pii_scanner::KIND, + Box::new(apl_pii_scanner::PiiScannerFactory), + ); + inner.manager.register_factory( + apl_audit_logger::KIND, + Box::new(apl_audit_logger::AuditLoggerFactory), + ); + inner.manager.register_factory( + apl_identity_jwt::KIND, + Box::new(apl_identity_jwt::JwtIdentityFactory), + ); + inner.manager.register_factory( + apl_delegator_oauth::KIND, + Box::new(apl_delegator_oauth::OAuthDelegatorFactory), + ); + + // APL config visitor + PDP factories. `pdp_factories` are consulted + // for `global.apl.pdp[]` entries; cedar-direct is the bundled + // default. The visitor keeps a Weak (see + // CpexManagerInner) that upgrades during load_config_yaml. + let mut opts = apl_cpex::AplOptions::in_process(); + opts.pdp_factories = + vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())]; + + apl_cpex::register_apl(&inner.manager, opts); + })); + + match result { + Ok(()) => RC_OK, + Err(_panic) => { + tracing::error!("cpex_apl_install: panic caught at FFI boundary"); + RC_PANIC + } + } +} diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index 760f62d9..7f0e8a47 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -15,7 +15,7 @@ use std::os::raw::{c_char, c_int}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use cpex_core::context::PluginContextTable; @@ -24,6 +24,9 @@ use cpex_core::extensions::Extensions; use cpex_core::hooks::payload::PluginPayload; use cpex_core::manager::PluginManager; +// APL governance wiring — the `cpex_apl_install` extern "C" entry point. +mod apl; + // --------------------------------------------------------------------------- // FFI Result Codes // --------------------------------------------------------------------------- @@ -59,6 +62,50 @@ pub const RC_TIMEOUT: c_int = -6; /// Plugin panicked; caught by `catch_unwind` at the FFI boundary. pub const RC_PANIC: c_int = -7; +// --------------------------------------------------------------------------- +// FFI ABI Version +// --------------------------------------------------------------------------- +// +// The FFI ABI version is an integer that identifies the C-surface +// contract this crate exposes. Bump it on any breaking change to the +// C surface: +// +// - added / removed / renamed extern "C" function +// - argument count, argument type, or return type change on an +// existing function +// - layout change of a struct that crosses the boundary +// - semantic change to an existing function (e.g. new RC_* value +// returned for a previously-success case, change in pointer +// ownership) +// +// Adding a new RC_* code at the end of the existing range is *not* a +// breaking change (the wire codes are stable; consumers handle unknown +// codes as generic failure). +// +// Consumers — every language binding — MUST call `cpex_ffi_abi_version` +// at init and compare against the version their binding was generated +// for. Mismatch is a hard error: the C surface they generated against +// is not the one they're linked against. Document the binding's +// expected ABI version in its source. +// +// Bumps are recorded in CHANGELOG.md under "Changed" with the from→to +// integers and a one-line description of what moved. + +/// FFI ABI version. Bump on breaking C-surface changes; see module +/// docs above for what counts as breaking. +pub const FFI_ABI_VERSION: u32 = 2; + +/// Returns the FFI ABI version this `libcpex_ffi` was built with. +/// Language bindings call this at `init` and panic on mismatch +/// against the version they were generated for. +/// +/// Pure const access — no allocation, no runtime, no panics. Safe to +/// call from anywhere including signal handlers. +#[no_mangle] +pub extern "C" fn cpex_ffi_abi_version() -> u32 { + FFI_ABI_VERSION +} + /// Outer wall-clock timeout for any FFI-driven async call. Per-plugin /// `tokio::time::timeout` only catches cooperative-async timeouts; this /// catches CPU-bound or thread-blocking plugins that never yield. Set @@ -332,7 +379,11 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), Strin /// All managers share the process-singleton runtime returned by /// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why. pub struct CpexManagerInner { - pub manager: PluginManager, + /// Held as `Arc` so the APL config visitor — registered via + /// `cpex_apl_install` — can keep a `Weak` that upgrades + /// during `load_config_yaml`. See `apl::cpex_apl_install` and + /// `apl_cpex::register_apl`. + pub manager: Arc, } /// Opaque handle to a ContextTable (Rust-owned, not serialized). @@ -431,9 +482,12 @@ pub unsafe extern "C" fn cpex_manager_new( // silently no-op. let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); - // Load config — factories must be registered separately via cpex_register_factory + // Load config — factories must be registered separately via cpex_register_factory. + // Note: this one-shot path uses `load_config` (no visitor walk), so APL is + // NOT wired here. APL requires the cpex_manager_new_default → + // cpex_apl_install → cpex_load_config flow. if let Err(e) = manager.load_config(cpex_config) { tracing::error!("cpex_manager_new: load_config failed: {}", e); return ptr::null_mut(); @@ -448,7 +502,7 @@ pub unsafe extern "C" fn cpex_manager_new( #[no_mangle] pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -479,17 +533,22 @@ pub unsafe extern "C" fn cpex_load_config( None => return RC_INVALID_INPUT, }; - let cpex_config = match cpex_core::config::parse_config(yaml) { - Ok(c) => c, - Err(e) => { - tracing::error!("cpex_load_config: config parse failed: {}", e); - return RC_PARSE_ERROR; - } - }; + // Validate first (duplicate plugin names, route shape) — preserves the + // RC_PARSE_ERROR contract. We discard the parsed value and hand the raw + // YAML to `load_config_yaml`, which re-parses into both a typed + // CpexConfig and a raw serde_yaml::Value so registered config visitors + // (e.g. the APL visitor installed by cpex_apl_install) can walk the + // `apl:` blocks and install per-route handlers. Plain `load_config` + // does NOT run that visitor walk. + if let Err(e) = cpex_core::config::parse_config(yaml) { + tracing::error!("cpex_load_config: config parse failed: {}", e); + return RC_PARSE_ERROR; + } - // load_config is sync (no .await), but we still wrap in catch_unwind - // so a panic in serde / config validation doesn't unwind across FFI. - let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_config))); + // load_config_yaml is sync (no .await), but we still wrap in catch_unwind + // so a panic in serde / config validation / a visitor doesn't unwind + // across FFI. + let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config_yaml(yaml))); match load_result { Ok(Ok(())) => RC_OK, Ok(Err(e)) => { @@ -647,7 +706,29 @@ pub unsafe extern "C" fn cpex_plugin_names( /// Returns MessagePack-encoded PipelineResult + opaque handles for /// context table and background tasks. /// -/// Returns 0 on success, -1 on failure. +/// # Ownership contract +/// +/// **The caller's input `context_table` is unconditionally consumed +/// by this function** — even on error paths (RC_INVALID_HANDLE, +/// RC_INVALID_INPUT, RC_PARSE_ERROR, RC_TIMEOUT, RC_PANIC, etc.). +/// The Box is freed inside `cpex_invoke`; the caller's pointer is +/// dead once this function returns. This mirrors the pattern used +/// by `cpex_wait_background` and lets the Go binding nil its handle +/// unconditionally after the call without leaking the underlying Box. +/// +/// On `RC_OK`, a **fresh** `CpexContextTableInner` Box is allocated +/// and its raw pointer is written to `*context_table_out`. On any +/// non-OK return, `*context_table_out` is left as a null pointer +/// (initialized at function entry). The other out parameters +/// (`result_msgpack_out`, `result_len_out`, `bg_handle_out`) follow +/// the same discipline: null/zero on error, populated on success. +/// +/// Pre-P0-1 the function consumed the input only after validation +/// passed but before `run_safely`. On `RC_TIMEOUT` / `RC_PANIC` the +/// input had been consumed but `*context_table_out` was never written, +/// so the Go wrapper kept its stale handle and a subsequent +/// `ContextTable.Close()` ran `cpex_release_context_table` on +/// already-freed memory. /// /// # Safety /// All pointer parameters must be valid or NULL where documented. @@ -672,7 +753,43 @@ pub unsafe extern "C" fn cpex_invoke( context_table_out: *mut *mut CpexContextTableInner, bg_handle_out: *mut *mut CpexBackgroundTasksInner, ) -> c_int { - // Validate manager handle + // Initialize all out params to safe defaults. Any early return + // from here on leaves a consistent state for the caller: every + // out pointer is null/zero, so a downstream attempt to dereference + // produces a clean null-deref crash rather than reading uninit + // stack memory. The success path overwrites these at the end. + *result_msgpack_out = std::ptr::null_mut(); + *result_len_out = 0; + *context_table_out = std::ptr::null_mut(); + *bg_handle_out = std::ptr::null_mut(); + + // Take ownership of the input context_table *immediately*, before + // any validation that could return an error code. From this point + // on, the caller's `context_table` pointer is dead — equivalent + // to free'd memory from the caller's perspective. This mirrors + // how `cpex_wait_background` handles `bg_handle`: ownership + // transfers on entry, the caller nils its reference, and Rust + // is responsible for the Box's lifetime from then on. Pre-fix, + // consumption happened mid-function after some validations, which + // meant validation errors left the input alive (one ownership + // model) and post-validation errors left it consumed without + // writing `*context_table_out` (a *different* ownership model). + // Two contracts in one function is exactly what produced the + // P0-1 UAF. + let input_ctx_table: Option = if context_table.is_null() { + None + } else { + // Box::from_raw consumes the allocation; it'll drop at the + // end of this scope if not moved into Some(...). When moved + // into Some(...), the table value lives until invoke_by_name + // either uses it or it's dropped on a Future-cancellation + // path (RC_TIMEOUT). Either way the Box is gone. + let ct = Box::from_raw(context_table); + Some(ct.table) + }; + + // Validate manager handle. `input_ctx_table` already owns the + // input data — if we return here, it drops cleanly. let inner = match mgr.as_ref() { Some(m) => m, None => return RC_INVALID_HANDLE, @@ -715,22 +832,17 @@ pub unsafe extern "C" fn cpex_invoke( Extensions::default() }; - // Get or create context table - let ctx_table: Option = if context_table.is_null() { - None - } else { - let ct = Box::from_raw(context_table); - Some(ct.table) - }; - // Invoke the hook with wall-clock timeout + panic catch. let (mut result, bg) = match run_safely( inner .manager - .invoke_by_name(name, payload, extensions, ctx_table), + .invoke_by_name(name, payload, extensions, input_ctx_table), "cpex_invoke", ) { SafeRun::Ok(r) => r, + // *context_table_out is already null (set at function entry); + // the input table has been consumed by invoke_by_name's call + // frame and dropped. Caller's handle is dead, no replacement. other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged }; @@ -1049,7 +1161,7 @@ mod tests { // Touch the shared runtime so it's initialized; tests use it // rather than a per-manager runtime. let _ = shared_runtime(); - let manager = cpex_core::manager::PluginManager::default(); + let manager = Arc::new(cpex_core::manager::PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -1310,4 +1422,53 @@ mod tests { assert_eq!(cpex_is_initialized(ptr::null()), 0); } } + + #[test] + fn cpex_apl_install_rejects_null_handle() { + unsafe { + assert_eq!(crate::apl::cpex_apl_install(ptr::null()), RC_INVALID_HANDLE); + } + } + + /// Full APL flow through the FFI surface: default manager → + /// cpex_apl_install (registers bundled factories + APL visitor) → + /// cpex_load_config over an `apl:`-annotated YAML using a bundled + /// plugin kind (`audit/logger`) → cpex_initialize. Proves the visitor + /// walk runs (load uses load_config_yaml) and the bundled factory is + /// reachable, so the plugin actually instantiates. + #[test] + fn cpex_apl_install_then_load_apl_config_initializes() { + const YAML: &str = r#" +plugins: + - name: auditor + kind: audit/logger + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(auditor)" +"#; + unsafe { + let mgr = build_test_manager(); + + assert_eq!(crate::apl::cpex_apl_install(mgr), RC_OK); + + let rc = cpex_load_config(mgr, YAML.as_ptr() as *const c_char, YAML.len() as c_int); + assert_eq!(rc, RC_OK, "load of APL config should succeed"); + + assert_eq!(cpex_initialize(mgr), RC_OK); + + // The bundled `audit/logger` factory instantiated a plugin on + // cmf.tool_pre_invoke — proves cpex_apl_install wired the kind. + assert!(cpex_plugin_count(mgr) >= 1); + let hook = "cmf.tool_pre_invoke"; + assert_eq!( + cpex_has_hooks_for(mgr, hook.as_ptr() as *const c_char, hook.len() as c_int), + 1, + ); + + cpex_shutdown(mgr); + } + } } diff --git a/crates/cpex-orchestration/Cargo.toml b/crates/cpex-orchestration/Cargo.toml new file mode 100644 index 00000000..1f221e2d --- /dev/null +++ b/crates/cpex-orchestration/Cargo.toml @@ -0,0 +1,34 @@ +# Location: ./crates/cpex-orchestration/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Shared async orchestration primitives. +# +# This crate is a leaf utility — no internal workspace dependencies. +# Provides the JoinSet-based concurrent runner that both: +# * `cpex-core::executor::run_concurrent_phase` — fans out concurrent +# plugins for one hook +# * `apl-core::evaluator` — fans out the effects in an APL +# `parallel:` block +# share. +# +# Speaks generic `Future` + an `is_deny` predicate, not any +# domain types. Each caller adapts its concepts to this surface. + +[package] +name = "cpex-orchestration" +description = "Async concurrency primitives shared by the CPEX runtime" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] + +[dependencies] +tokio = { workspace = true, features = ["rt", "time", "macros"] } +futures = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } diff --git a/crates/cpex-orchestration/src/lib.rs b/crates/cpex-orchestration/src/lib.rs new file mode 100644 index 00000000..ff884fb4 --- /dev/null +++ b/crates/cpex-orchestration/src/lib.rs @@ -0,0 +1,449 @@ +// Location: ./crates/cpex-orchestration/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Async concurrency primitives shared by the CPEX runtime. +// +// Two callers today, both running "N async branches concurrently with +// optional short-circuit on first deny": +// +// * `cpex-core::executor::run_concurrent_phase` — fans out concurrent +// plugins for one hook event +// * `apl-core::evaluator::dispatch_parallel` — fans out the effects +// inside an APL `parallel:` block +// +// Both want the same mechanics — `tokio::task::JoinSet` keyed by task +// id, react-to-results-as-they-arrive, optional `abort_all` on first +// deny, per-branch timeout. Without a shared primitive, both would +// reinvent the pattern (slightly differently) and drift. +// +// This crate exposes a single generic function `run_branches`. It +// speaks `Future` + an `is_deny` predicate — no domain +// concepts. Each caller adapts its types (HookEntry, EffectOutcome, +// Decision, …) at the boundary. + +#![deny(rust_2018_idioms)] + +use std::collections::HashMap; +use std::time::Duration; + +use futures::future::BoxFuture; +use tokio::task::{Id, JoinSet}; +use tokio::time::timeout; + +// ===================================================================== +// Public API +// ===================================================================== + +/// Configuration knobs for [`run_branches`]. +#[derive(Debug, Clone, Copy)] +pub struct BranchConfig { + /// Maximum time each individual branch is allowed to run before + /// being recorded as `BranchOutcome::TimedOut`. `None` disables + /// the per-branch timeout (relies on cancellation from + /// `short_circuit_on_deny` and the outer caller). + pub timeout_per_branch: Option, + + /// When `true`, abort the remaining branches as soon as the first + /// branch returns a result satisfying the `is_deny` predicate. + /// Aborted branches are returned as `BranchOutcome::Aborted`. + pub short_circuit_on_deny: bool, +} + +impl Default for BranchConfig { + fn default() -> Self { + Self { + timeout_per_branch: None, + short_circuit_on_deny: true, + } + } +} + +/// What happened to one branch in [`run_branches`]. +/// +/// Branches always return results in the **input order** (index 0 +/// first, even if it physically finished last). Callers that care +/// about wall-clock completion order need to add their own +/// timestamping inside the branch future. +#[derive(Debug)] +pub enum BranchOutcome { + /// Branch ran to completion within its timeout and produced `T`. + Completed(T), + /// Branch exceeded its `timeout_per_branch`. Callers typically + /// treat this as a deny / failure depending on policy. + TimedOut, + /// Branch was cancelled before completion because an earlier + /// branch tripped `short_circuit_on_deny`. Distinguishable from + /// `TimedOut` so audit/logging can tell whether the framework + /// or the caller's own time budget killed the task. + Aborted, + /// Branch's spawned task panicked. Carries the panic payload's + /// `Display` representation for logging — the typed payload is + /// dropped (JoinError doesn't preserve it across boxing). + Panicked(String), +} + +impl BranchOutcome { + /// Get a reference to the completed value if the branch succeeded. + /// `None` for timeouts, aborts, and panics. + pub fn completed(&self) -> Option<&T> { + match self { + BranchOutcome::Completed(v) => Some(v), + _ => None, + } + } + + /// Consume the outcome, returning the completed value if any. + pub fn into_completed(self) -> Option { + match self { + BranchOutcome::Completed(v) => Some(v), + _ => None, + } + } +} + +/// Run `branches` concurrently, returning one [`BranchOutcome`] per +/// branch in **input order**. +/// +/// # Behaviour +/// +/// * Each branch is spawned onto the current tokio runtime via +/// `JoinSet::spawn`. The runtime must be `rt-multi-thread` for the +/// branches to actually run in parallel; single-threaded runtimes +/// will run them concurrently (interleaved) but on one OS thread. +/// * If `config.short_circuit_on_deny` is set, the moment any branch +/// completes with a result satisfying `is_deny`, all remaining +/// branches are aborted via `JoinSet::abort_all`. They surface as +/// `BranchOutcome::Aborted`. +/// * If `config.timeout_per_branch` is set, each branch is wrapped in +/// `tokio::time::timeout`. Timeouts surface as `BranchOutcome::TimedOut`. +/// * Panics inside a branch are caught (tokio's `JoinSet` returns +/// them via `JoinError::is_panic`) and surfaced as +/// `BranchOutcome::Panicked` rather than re-panicking — the +/// intent is that one misbehaving branch shouldn't take down the +/// whole orchestrator. +/// +/// # Cost notes +/// +/// * `tokio::task::spawn` has ~1 µs overhead per spawn — fine for +/// the workload sizes this is designed for (typically 2-20 +/// branches). If you need 1000+ branches, profile first. +/// * Each branch's future is `Send + 'static` (it's spawned onto a +/// task) — captured state must satisfy those bounds. Most callers +/// handle this by cloning state per branch before constructing the +/// future. +pub async fn run_branches( + branches: Vec, + config: BranchConfig, + is_deny: P, +) -> Vec> +where + T: Send + 'static, + F: std::future::Future + Send + 'static, + P: Fn(&T) -> bool + Send + Sync, +{ + let n = branches.len(); + if n == 0 { + return Vec::new(); + } + + // Spawn each branch onto the JoinSet. The spawn handle's `Id` is + // captured into `id_to_idx` so a panicked task — which surfaces as + // a `JoinError` carrying only its `Id`, not the return value — can + // still be mapped back to its input index. + let mut set: JoinSet<(usize, BranchOutcome)> = JoinSet::new(); + let mut id_to_idx: HashMap = HashMap::with_capacity(n); + for (idx, fut) in branches.into_iter().enumerate() { + let to = config.timeout_per_branch; + let handle = set.spawn(async move { + let result = match to { + None => Ok(fut.await), + Some(d) => timeout(d, fut).await, + }; + let outcome = match result { + Ok(v) => BranchOutcome::Completed(v), + Err(_) => BranchOutcome::TimedOut, + }; + (idx, outcome) + }); + id_to_idx.insert(handle.id(), idx); + } + + // Collect outcomes into a position-indexed Vec so the return order + // matches input order regardless of physical completion order. + // `None` slots get filled as branches finish; remaining `None`s + // after all completions get replaced with `Aborted` (only + // possible when short-circuit fired). + let mut slots: Vec>> = (0..n).map(|_| None).collect(); + let mut aborted = false; + + while let Some(joined) = set.join_next_with_id().await { + match joined { + Ok((_id, (idx, outcome))) => { + let halts = matches!(&outcome, BranchOutcome::Completed(v) if is_deny(v)); + slots[idx] = Some(outcome); + if halts && config.short_circuit_on_deny && !aborted { + set.abort_all(); + aborted = true; + // Don't break — we still need to drain whatever + // tasks already completed before we asked for the + // abort, so their outcomes land in their slots + // (vs. being silently lost). The drain loop + // continues until JoinSet is empty. + } + } + Err(e) => { + // A task either panicked or was cancelled by + // `abort_all`. JoinError exposes the task `Id`, which + // we look up in `id_to_idx` to recover the original + // input index. Panicked branches land in their own + // slot; cancelled ones get left as `None` and filled + // with `Aborted` post-loop. + if e.is_panic() { + let payload = format!("{:?}", e); + if let Some(&idx) = id_to_idx.get(&e.id()) { + slots[idx] = Some(BranchOutcome::Panicked(payload)); + } + } + } + } + } + + // Anything still unset was aborted by `short_circuit_on_deny`. + slots + .into_iter() + .map(|s| s.unwrap_or(BranchOutcome::Aborted)) + .collect() +} + +// ===================================================================== +// Implementation note on the generic signature +// ===================================================================== +// +// `P` is the closure type for `is_deny`. We declare it as a generic +// type parameter rather than `impl Fn(...)` so the function works +// uniformly across async runtimes and callers that need to use +// boxed predicates (`Box`) for runtime polymorphism. +// +// The `BoxFuture` import isn't strictly needed for the public API +// but is re-exported below for callers that want to build +// homogeneous branch vectors out of differently-typed futures (the +// common case in apl-core's `Effect::Parallel` dispatch, where each +// effect's future has a unique inferred type). + +/// Convenience alias re-exported from `futures` for callers building +/// type-erased branch vectors. `apl-core`'s `Effect::Parallel` +/// dispatch uses this because the per-effect futures have different +/// inferred types and need erasure to fit in a single `Vec`. +pub type ErasedBranch = BoxFuture<'static, T>; + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn no_deny(_: &T) -> bool { + false + } + + #[tokio::test(flavor = "multi_thread")] + async fn all_complete_in_input_order() { + // Branches finish in REVERSE wall-clock order — sleep more + // for earlier indices. The output Vec must still be in input + // order: branch[0] → first slot, branch[2] → last slot. + let branches: Vec<_> = (0usize..3) + .map(|idx| { + Box::pin(async move { + let delay = Duration::from_millis(30 - 10 * idx as u64); + tokio::time::sleep(delay).await; + idx + }) as BoxFuture<'static, usize> + }) + .collect(); + + let out = run_branches( + branches, + BranchConfig { timeout_per_branch: None, short_circuit_on_deny: false }, + no_deny::, + ) + .await; + + assert_eq!(out.len(), 3); + for (i, outcome) in out.into_iter().enumerate() { + match outcome { + BranchOutcome::Completed(v) => assert_eq!(v, i, "input order preserved"), + other => panic!("expected Completed({}), got {:?}", i, other), + } + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn timeout_marks_branch_as_timed_out() { + let branches: Vec<_> = vec![ + Box::pin(async { + tokio::time::sleep(Duration::from_secs(60)).await; + "should not see this" + }) as BoxFuture<'static, &str>, + Box::pin(async { "quick" }) as BoxFuture<'static, &str>, + ]; + + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: Some(Duration::from_millis(50)), + short_circuit_on_deny: false, + }, + no_deny::<&str>, + ) + .await; + + assert!(matches!(out[0], BranchOutcome::TimedOut)); + assert!(matches!(out[1], BranchOutcome::Completed("quick"))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn short_circuit_on_deny_aborts_remaining() { + // Branch 0 returns Deny quickly; branches 1 and 2 are slow. + // With short_circuit, the slow ones should be Aborted. + let counter = Arc::new(AtomicUsize::new(0)); + let c0 = counter.clone(); + let c1 = counter.clone(); + let c2 = counter.clone(); + + let branches: Vec> = vec![ + Box::pin(async move { + tokio::time::sleep(Duration::from_millis(5)).await; + c0.fetch_add(1, Ordering::SeqCst); + true // deny + }), + Box::pin(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + c1.fetch_add(1, Ordering::SeqCst); + false + }), + Box::pin(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + c2.fetch_add(1, Ordering::SeqCst); + false + }), + ]; + + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: true, + }, + |v: &bool| *v, + ) + .await; + + assert!(matches!(out[0], BranchOutcome::Completed(true))); + assert!(matches!(out[1], BranchOutcome::Aborted)); + assert!(matches!(out[2], BranchOutcome::Aborted)); + // Only the first branch should have incremented; the slow + // ones were aborted before they got past their sleeps. + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "multi_thread")] + async fn short_circuit_disabled_keeps_all_running() { + // Same shape as above but with short_circuit OFF — all three + // should run to completion despite branch 0 denying. + let branches: Vec> = vec![ + Box::pin(async { + tokio::time::sleep(Duration::from_millis(5)).await; + true + }), + Box::pin(async { + tokio::time::sleep(Duration::from_millis(20)).await; + false + }), + Box::pin(async { + tokio::time::sleep(Duration::from_millis(20)).await; + false + }), + ]; + + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: false, + }, + |v: &bool| *v, + ) + .await; + + assert!(matches!(out[0], BranchOutcome::Completed(true))); + assert!(matches!(out[1], BranchOutcome::Completed(false))); + assert!(matches!(out[2], BranchOutcome::Completed(false))); + } + + #[tokio::test] + async fn empty_input_returns_empty_output() { + let out: Vec> = run_branches( + Vec::>::new(), + BranchConfig::default(), + no_deny::<()>, + ) + .await; + assert!(out.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn panic_inside_branch_does_not_take_down_orchestrator() { + let branches: Vec> = vec![ + Box::pin(async { panic!("boom") }), + Box::pin(async { 42 }), + ]; + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: false, + }, + no_deny::, + ) + .await; + // Branch 1 must complete despite branch 0's panic. + assert!(out.iter().any(|o| matches!(o, BranchOutcome::Completed(42)))); + assert!(out.iter().any(|o| matches!(o, BranchOutcome::Panicked(_)))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn panic_lands_in_correct_input_slot() { + // Branch 1 panics; branches 0 and 2 succeed. The panicked + // outcome must land at index 1, not "the first empty slot." + // This guards executor consumers that key per-entry + // `on_error` policy off the branch index. + let branches: Vec> = vec![ + Box::pin(async { 10 }), + Box::pin(async { panic!("middle branch boom") }), + Box::pin(async { 30 }), + ]; + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: false, + }, + no_deny::, + ) + .await; + assert_eq!(out.len(), 3); + assert!(matches!(out[0], BranchOutcome::Completed(10))); + assert!( + matches!(out[1], BranchOutcome::Panicked(_)), + "panic must land at index 1, got {:?}", + out[1] + ); + assert!(matches!(out[2], BranchOutcome::Completed(30))); + } +} diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs index a033576f..dd85aa49 100644 --- a/examples/go-demo/ffi/src/cmf_plugins.rs +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -265,7 +265,7 @@ impl PluginFactory for HeaderInjectorFactory { } /// Register CMF demo plugin factories on a manager. -pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) { +pub fn register_cmf_factories(manager: &cpex_core::manager::PluginManager) { manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory)); manager.register_factory( "builtin/cmf-header-injector", diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index f27125c9..4cbbf53c 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -268,7 +268,7 @@ impl PluginFactory for AuditLoggerFactory { } /// Register all demo plugin factories on a manager. -pub fn register_demo_factories(manager: &mut cpex_core::manager::PluginManager) { +pub fn register_demo_factories(manager: &cpex_core::manager::PluginManager) { manager.register_factory("builtin/identity", Box::new(IdentityCheckerFactory)); manager.register_factory("builtin/pii", Box::new(PiiGuardFactory)); manager.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs index 8f756f3a..8d3eb59f 100644 --- a/examples/go-demo/ffi/src/lib.rs +++ b/examples/go-demo/ffi/src/lib.rs @@ -45,12 +45,14 @@ use std::os::raw::c_int; pub unsafe extern "C" fn cpex_demo_register_factories( mgr: *mut cpex_ffi::CpexManagerInner, ) -> c_int { - let inner = match mgr.as_mut() { + let inner = match mgr.as_ref() { Some(m) => m, None => return -1, }; - demo_plugins::register_demo_factories(&mut inner.manager); - cmf_plugins::register_cmf_factories(&mut inner.manager); + // `register_factory` takes `&self`; `&inner.manager` deref-coerces + // from `Arc` to `&PluginManager`. + demo_plugins::register_demo_factories(&inner.manager); + cmf_plugins::register_cmf_factories(&inner.manager); 0 } diff --git a/go/cpex/abi.go b/go/cpex/abi.go new file mode 100644 index 00000000..81a14cfd --- /dev/null +++ b/go/cpex/abi.go @@ -0,0 +1,50 @@ +// Location: ./go/cpex/abi.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// FFI ABI version check. +// +// On package init, calls cpex_ffi_abi_version() and panics if the +// linked libcpex_ffi reports an ABI version different from what this +// Go binding was generated against. A mismatch means the C surface +// the bindings expect is not the one libcpex_ffi exposes — every +// other cgo call in this package would have undefined behavior, so +// failing loud at init is preferred over silent corruption later. +// +// Bumping expectedFFIABIVersion is required (and only required) when +// the Rust crate bumps FFI_ABI_VERSION. See crates/cpex-ffi/src/lib.rs +// "FFI ABI Version" section for what counts as a breaking change. + +package cpex + +/* +#include + +// Duplicated from ffi.go / manager.go preambles — see the note in +// manager.go about cgo not merging declarations across files. +extern uint32_t cpex_ffi_abi_version(void); +*/ +import "C" + +import "fmt" + +// expectedFFIABIVersion is the FFI_ABI_VERSION integer this binding +// was generated against. Bump in lockstep with the Rust crate's +// FFI_ABI_VERSION whenever the C surface changes in a breaking way. +const expectedFFIABIVersion uint32 = 2 + +func init() { + actual := uint32(C.cpex_ffi_abi_version()) + if actual != expectedFFIABIVersion { + panic(fmt.Sprintf( + "cpex: FFI ABI version mismatch — Go binding expects %d, "+ + "linked libcpex_ffi reports %d. Upgrade github.com/"+ + "contextforge-org/contextforge-plugins-framework/go/cpex "+ + "to a version generated against libcpex_ffi ABI %d, "+ + "or rebuild libcpex_ffi from a CPEX commit whose "+ + "FFI_ABI_VERSION is %d.", + expectedFFIABIVersion, actual, actual, expectedFFIABIVersion, + )) + } +} diff --git a/go/cpex/apl.go b/go/cpex/apl.go new file mode 100644 index 00000000..04cdffa9 --- /dev/null +++ b/go/cpex/apl.go @@ -0,0 +1,61 @@ +// Location: ./go/cpex/apl.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL (Attribute Policy Language) wiring. +// +// EnableAPL registers the bundled APL plugin/PDP factories and installs +// the APL config visitor on the manager via the cpex_apl_install FFI +// entry point. Call it after NewPluginManagerDefault and before +// LoadConfig so that LoadConfig walks the config's `apl:` blocks and +// installs per-route handlers. + +package cpex + +import ( + "fmt" +) + +/* +#include + +// Opaque handle — same typedef as manager.go / ffi.go. Duplicated here +// because cgo does NOT merge declarations across files' preambles; see +// the note in manager.go. Edit all copies together if the signature +// changes. +typedef void* CpexManager; + +extern int cpex_apl_install(CpexManager mgr); +*/ +import "C" + +// EnableAPL registers the bundled APL plugin and PDP factories and +// installs the APL config visitor on the manager (in-process defaults: +// memory session store, default baseline capabilities). +// +// Bundled plugin kinds: validator/pii-scan, audit/logger, identity/jwt, +// delegator/oauth. Bundled PDP kind: cedar-direct. +// +// Ordering: call after NewPluginManagerDefault and before LoadConfig. +// The one-shot NewPluginManager(yaml) constructor loads config during +// creation and therefore does NOT support APL — use the default-manager +// flow instead: +// +// mgr, _ := NewPluginManagerDefault() +// mgr.EnableAPL() +// mgr.LoadConfig(yaml) +// mgr.Initialize() +// +// On failure the returned error wraps a typed sentinel +// (ErrCpexInvalidHandle, ErrCpexPanic). +func (m *PluginManager) EnableAPL() error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("EnableAPL: %w", ErrCpexInvalidHandle) + } + + rc := C.cpex_apl_install(m.handle) + return errorFromRC(int(rc), "EnableAPL") +} diff --git a/go/cpex/apl_test.go b/go/cpex/apl_test.go new file mode 100644 index 00000000..ffe6892b --- /dev/null +++ b/go/cpex/apl_test.go @@ -0,0 +1,73 @@ +// Location: ./go/cpex/apl_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tests for APL wiring (EnableAPL). Run against the real Rust runtime +// via cgo; build the staticlib first: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "testing" +) + +// TestEnableAPLLoadsAplConfig drives the documented APL flow: +// NewPluginManagerDefault → EnableAPL → LoadConfig (APL-annotated) → +// Initialize. The bundled `audit/logger` factory must instantiate, so +// the cmf.tool_pre_invoke hook is registered after load. +func TestEnableAPLLoadsAplConfig(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.EnableAPL(); err != nil { + t.Fatalf("EnableAPL failed: %v", err) + } + + yaml := ` +plugins: + - name: auditor + kind: audit/logger + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(auditor)" +` + if err := mgr.LoadConfig(yaml); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + if mgr.PluginCount() < 1 { + t.Errorf("expected at least 1 plugin, got %d", mgr.PluginCount()) + } + if !mgr.HasHooksFor("cmf.tool_pre_invoke") { + t.Error("expected cmf.tool_pre_invoke hook registered after APL load") + } +} + +// TestEnableAPLAfterShutdown verifies the typed handle error is returned +// when EnableAPL is called on a shut-down manager. +func TestEnableAPLAfterShutdown(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + mgr.Shutdown() + + err = mgr.EnableAPL() + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle, got %v", err) + } +} diff --git a/go/cpex/manager.go b/go/cpex/manager.go index 911bb7ec..6850e37f 100644 --- a/go/cpex/manager.go +++ b/go/cpex/manager.go @@ -341,19 +341,18 @@ func (m *PluginManager) InvokeByName( cHookName := C.CString(hookName) defer C.free(unsafe.Pointer(cHookName)) - // Pass the context-table handle to Rust but DO NOT nil our local - // reference until we know Rust succeeded. Rust consumes the handle - // only at the moment of invoke (after all input validation), so - // pre-invoke failures (bad payload, bad extensions, etc.) leave - // the handle untouched and the caller's ContextTable remains valid. - // - // Caveat: on a post-invoke failure (rare — only result-serialization - // OOM), Rust has consumed the box but doesn't write ctOut, so the - // caller's ContextTable handle becomes dangling. The caller should - // not reuse a ContextTable after an InvokeByName error. + // Pass the context-table handle to Rust. Per the post-P0-1 FFI + // contract, `cpex_invoke` takes ownership of `ctHandle` + // UNCONDITIONALLY on entry — same pattern as `cpex_wait_background` + // with `bg_handle`. We nil our local reference immediately so the + // caller's `ContextTable` can't be accidentally reused after this + // call (its underlying Box is gone regardless of the eventual rc). + // On RC_OK a fresh handle lands in `ctOut`; on any error path + // `ctOut` stays nil and the caller's context-table chain ends here. var ctHandle C.CpexContextTable if contextTable != nil { ctHandle = contextTable.handle + contextTable.handle = nil } var resultPtr *C.uint8_t @@ -386,16 +385,13 @@ func (m *PluginManager) InvokeByName( ) if rc != 0 { + // `ctOut` is null on every non-OK return per the post-P0-1 + // contract. The caller's `contextTable.handle` is already nil + // (we cleared it above before the call), so there's no + // dangling-handle risk on error paths. return nil, nil, nil, errorFromRC(int(rc), "InvokeByName") } - // Rust succeeded — it consumed ctHandle and produced ctOut. - // NOW it's safe to nil the caller's reference (the original Box - // was consumed by Rust; its successor is in ctOut). - if contextTable != nil { - contextTable.handle = nil - } - // Deserialize result from MessagePack resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) diff --git a/go/cpex/manager_test.go b/go/cpex/manager_test.go index f5b31c5a..7dff38aa 100644 --- a/go/cpex/manager_test.go +++ b/go/cpex/manager_test.go @@ -1173,3 +1173,113 @@ func TestLoadConfigInvalidYAML(t *testing.T) { t.Error("expected error for invalid YAML") } } + +// TestInvokeByNameErrorDoesNotUAFContextTable is the regression guard +// for P0-1. Pre-fix, `cpex_invoke` consumed the input ContextTable's +// Box mid-function but didn't write *context_table_out on +// RC_TIMEOUT / RC_PANIC / RC_PARSE_ERROR — the Go wrapper kept its +// stale handle and a subsequent Close() called +// cpex_release_context_table on already-freed memory. +// +// Post-fix, the Go wrapper nils its `contextTable.handle` immediately +// after handing it to Rust (mirroring the bg_handle pattern), so even +// if Rust errors out without producing a replacement, no dangling +// handle survives. +// +// This test: +// 1. Performs a successful invoke to get a real ContextTable. +// 2. Calls InvokeByName again with that ContextTable PLUS an +// invalid payload_type that forces Rust to return RC_PARSE_ERROR +// AFTER the consumption point. +// 3. Confirms the second call errored (sanity). +// 4. Calls Close() on the original ContextTable — must NOT crash. +// Pre-fix this was a UAF (free of already-freed memory). +func TestInvokeByNameErrorDoesNotUAFContextTable(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // 1. Successful first invoke — gives us a real, Rust-allocated + // ContextTable. Calling Close() on this pre-fix would have + // been the second free. + payload := map[string]any{"tool_name": "test"} + _, ctxTable, bg, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg.Close() + if ctxTable == nil || ctxTable.handle == nil { + t.Fatal("expected a non-nil ContextTable from the first invoke") + } + + // 2. Second invoke with an UNKNOWN payload_type (99). The Rust + // side validates payload_type against its registry; an + // unknown value forces a RC_PARSE_ERROR return. Critically, + // that error path is now POST-consumption of the input + // context_table. + const unknownPayloadType uint8 = 99 + _, _, _, err = mgr.InvokeByName("hook2", unknownPayloadType, payload, &Extensions{}, ctxTable) + if err == nil { + t.Fatal("expected error from invoke with unknown payload_type") + } + + // 3. Per the P0-1 contract, ctxTable's handle was nil'd in Go + // *before* the C call returned. So whether or not Rust wrote + // *context_table_out, our local handle is nil. + if ctxTable.handle != nil { + t.Errorf("input ContextTable.handle should be nil after invoke error; got %p", ctxTable.handle) + } + + // 4. The actual UAF check: Close() must be safe. Pre-fix this + // called cpex_release_context_table on already-freed memory. + // Post-fix, Close() short-circuits on a nil handle and is a + // no-op. Either it crashes (fail) or it doesn't (pass). + ctxTable.Close() +} + +// TestInvokeByNameConsumesContextTableOnRcError pins the other half +// of the P0-1 contract — even when the manager rejects the call with +// a validation-class error (here: shutdown after first invoke), the +// caller's ContextTable handle is nil'd unconditionally. +// +// Verifies: no leak of the input Box when Rust never gets to write +// the output; Close() on the input is a safe no-op. +func TestInvokeByNameConsumesContextTableEvenOnShutdownPath(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + mgr.Shutdown() + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + _, ctxTable, bg, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + if err != nil { + mgr.Shutdown() + t.Fatalf("first invoke failed: %v", err) + } + bg.Close() + + // Shut down the manager — Go-side short-circuit will return + // ErrCpexInvalidHandle WITHOUT calling cpex_invoke. The Go + // wrapper hasn't touched ctxTable yet in this case (early + // return at m.handle == nil), so ctxTable.handle remains live. + mgr.Shutdown() + + _, _, _, err = mgr.InvokeByName("hook2", PayloadGeneric, payload, &Extensions{}, ctxTable) + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle after shutdown, got %v", err) + } + + // Even though the Go-side short-circuit didn't transit our + // handle to Rust, Close() must still be safe — it's a legal + // thing for callers to do. + ctxTable.Close() +} diff --git a/scripts/download-ffi-artifact.sh b/scripts/download-ffi-artifact.sh new file mode 100755 index 00000000..a72eb5cc --- /dev/null +++ b/scripts/download-ffi-artifact.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Location: ./scripts/download-ffi-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Consumer-facing script. Downloads a published libcpex_ffi.a +# release tarball from the CPEX GitHub Releases, verifies its +# sha256 + cosign signature, and unpacks it into a directory ready +# for cgo to link from. +# +# Intended for use in downstream Dockerfiles and CI jobs that don't +# want a Rust toolchain. Vendor this file (or fetch it pinned by tag +# from raw.githubusercontent.com) and call it before `go build`. +# +# Inputs (env or flag-equivalent CLI args): +# CPEX_FFI_VERSION Required. Tag of the release, e.g. v0.9.0. +# CPEX_FFI_TARGET Optional. Tuple name (linux-amd64-gnu, +# linux-arm64-gnu, linux-amd64-musl, +# linux-arm64-musl, darwin-arm64). Auto-detected +# from `uname -s` / `uname -m` + libc probe if unset. +# CPEX_FFI_DEST Optional. Destination directory. Defaults to +# ./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/. +# CPEX_FFI_REPO Optional. GitHub owner/repo override. Defaults +# to contextforge-org/cpex. +# CPEX_FFI_BASE_URL Optional. Full URL prefix override (skips the +# github.com/releases/download URL construction). +# Used for local file:// dry-runs. +# CPEX_FFI_SKIP_COSIGN +# Optional. Set to "1" to skip cosign verification. +# sha256 verification is never skipped. Only for +# air-gapped / offline environments where cosign +# cannot reach Sigstore. Document the risk. +# +# Output: +# Prints the absolute destination directory to stdout on success. +# Consumers capture it with $(bash download-ffi-artifact.sh) and +# pass to CGO_LDFLAGS as `-L${dir} -lcpex_ffi`. +# +# Idempotency: +# If ${dest}/VERSION exists and its first "version=..." line matches +# CPEX_FFI_VERSION, the script exits 0 without re-downloading. + +set -euo pipefail + +err() { echo "download-ffi-artifact: error: $*" >&2; exit 1; } +info() { echo "download-ffi-artifact: $*" >&2; } # stderr — stdout is the dest path + +: "${CPEX_FFI_VERSION:?CPEX_FFI_VERSION is required (e.g. v0.9.0)}" +CPEX_FFI_REPO="${CPEX_FFI_REPO:-contextforge-org/cpex}" + +# Detect target tuple if not provided. Inverse of the mapping in +# build-artifact.sh. +detect_tuple() { + local os arch libc="" + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) + # Probe for musl vs gnu. ldd --version writes to stderr; + # musl's ldd prints "musl libc" on stderr too, gnu prints + # "GLIBC". Fallback heuristic: presence of /lib/ld-musl-*. + if (ldd --version 2>&1 || true) | grep -qi musl; then + libc="musl" + elif compgen -G "/lib/ld-musl-*" >/dev/null; then + libc="musl" + else + libc="gnu" + fi + case "$arch" in + x86_64) echo "linux-amd64-${libc}" ;; + aarch64) echo "linux-arm64-${libc}" ;; + *) err "unsupported linux arch: $arch" ;; + esac + ;; + Darwin) + case "$arch" in + arm64) echo "darwin-arm64" ;; + x86_64) echo "darwin-amd64" ;; + *) err "unsupported darwin arch: $arch" ;; + esac + ;; + *) err "unsupported OS: $os" ;; + esac +} + +CPEX_FFI_TARGET="${CPEX_FFI_TARGET:-$(detect_tuple)}" +CPEX_FFI_DEST="${CPEX_FFI_DEST:-./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}}" + +info "version=$CPEX_FFI_VERSION target=$CPEX_FFI_TARGET dest=$CPEX_FFI_DEST" + +# Idempotency: a successful prior run leaves a VERSION file whose +# first line is "version=". If it matches, we're done. +if [[ -f "${CPEX_FFI_DEST}/VERSION" ]]; then + existing="$(head -n1 "${CPEX_FFI_DEST}/VERSION" | sed -E 's/^version=//')" + if [[ "$existing" == "$CPEX_FFI_VERSION" ]]; then + info "already present at $CPEX_FFI_DEST (version=$existing); skipping download" + cd "$CPEX_FFI_DEST" && pwd + exit 0 + fi + info "existing VERSION ($existing) != requested ($CPEX_FFI_VERSION); re-downloading" +fi + +TARBALL_NAME="cpex-ffi-${CPEX_FFI_VERSION}-${CPEX_FFI_TARGET}.tar.gz" +BASE_URL="${CPEX_FFI_BASE_URL:-https://github.com/${CPEX_FFI_REPO}/releases/download/${CPEX_FFI_VERSION}}" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +fetch() { + local name="$1" + local url="${BASE_URL}/${name}" + info " GET $url" + if [[ "$url" == file://* ]]; then + cp "${url#file://}" "${WORK_DIR}/${name}" \ + || err "failed to copy from $url" + else + curl -fsSL --retry 3 --retry-delay 2 -o "${WORK_DIR}/${name}" "$url" \ + || err "failed to download $url" + fi +} + +info "downloading release assets" +fetch "$TARBALL_NAME" +fetch "${TARBALL_NAME}.sha256" + +# sha256 verification — non-negotiable. The .sha256 file contains +# " "; sha256sum -c reads it and checks. macOS's +# shasum -a 256 -c uses the same format. +info "verifying sha256" +if command -v sha256sum >/dev/null; then + (cd "$WORK_DIR" && sha256sum -c "${TARBALL_NAME}.sha256") +else + (cd "$WORK_DIR" && shasum -a 256 -c "${TARBALL_NAME}.sha256") +fi + +# cosign verification — opt-out only. The certificate identity is the +# workflow path; the regex permits any tag ref so re-tagged releases +# still verify. The issuer is pinned to GitHub's OIDC issuer to +# prevent Sigstore certs from other providers from passing. +if [[ "${CPEX_FFI_SKIP_COSIGN:-0}" == "1" ]]; then + info "WARN: skipping cosign verification (CPEX_FFI_SKIP_COSIGN=1)" +else + command -v cosign >/dev/null || err "cosign is required for signature verification (or set CPEX_FFI_SKIP_COSIGN=1 to bypass — not recommended)" + fetch "${TARBALL_NAME}.sig" + fetch "${TARBALL_NAME}.crt" + info "verifying cosign signature" + cosign verify-blob \ + --certificate "${WORK_DIR}/${TARBALL_NAME}.crt" \ + --signature "${WORK_DIR}/${TARBALL_NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/${CPEX_FFI_REPO}/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${WORK_DIR}/${TARBALL_NAME}" \ + >/dev/null \ + || err "cosign verification failed" +fi + +# Unpack into the destination, replacing any prior contents at that +# version (the idempotency check above already handled the +# already-present case). +info "unpacking into $CPEX_FFI_DEST" +mkdir -p "$CPEX_FFI_DEST" +# Clear stale files from a partial earlier run; safe because we only +# touch our own version-stamped dir. +find "$CPEX_FFI_DEST" -mindepth 1 -delete +tar xzf "${WORK_DIR}/${TARBALL_NAME}" -C "$CPEX_FFI_DEST" + +# Print the absolute destination so consumer scripts can capture it. +(cd "$CPEX_FFI_DEST" && pwd) +info "done" diff --git a/scripts/release/build-artifact.sh b/scripts/release/build-artifact.sh new file mode 100755 index 00000000..01b6afdc --- /dev/null +++ b/scripts/release/build-artifact.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/build-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Build one libcpex_ffi.a for a given Rust target triple and stage it +# into a release tarball under dist/. +# +# Invoked once per matrix tuple by .github/workflows/release-ffi.yaml. +# Safe to run locally for the host tuple to validate the bundle shape. +# +# Inputs (env): +# TARGET Required. Rust target triple, e.g. x86_64-unknown-linux-gnu. +# VERSION Required in CI. Git tag, e.g. v0.9.0. Falls back to +# `git describe --tags --dirty` for local invocations. +# DIST_DIR Optional. Output dir for tarball + .sha256. Defaults to ./dist. +# USE_CROSS Optional. If "1", build with `cross` instead of `cargo`. +# Required for cross-compiling musl/arm targets without a +# pre-installed sysroot. +# +# Outputs: +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz.sha256 + +set -euo pipefail + +err() { echo "build-artifact: error: $*" >&2; exit 1; } +info() { echo "build-artifact: $*"; } + +: "${TARGET:?TARGET is required (e.g. x86_64-unknown-linux-gnu)}" +DIST_DIR="${DIST_DIR:-./dist}" +USE_CROSS="${USE_CROSS:-0}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# Version resolution. CI sets VERSION from the tag; locally fall back +# to git-describe so dev iterations get a sensible bundle name. +if [[ -z "${VERSION:-}" ]]; then + VERSION="$(git describe --tags --dirty --always 2>/dev/null || echo "v0.0.0-dev")" + info "VERSION not set; using git-describe fallback: $VERSION" +fi + +# Map Rust target triple → our tuple naming. This is the contract +# downstream consumers' download-ffi-artifact.sh inverts via uname. +case "$TARGET" in + x86_64-unknown-linux-gnu) TUPLE="linux-amd64-gnu" ;; + aarch64-unknown-linux-gnu) TUPLE="linux-arm64-gnu" ;; + x86_64-unknown-linux-musl) TUPLE="linux-amd64-musl" ;; + aarch64-unknown-linux-musl) TUPLE="linux-arm64-musl" ;; + aarch64-apple-darwin) TUPLE="darwin-arm64" ;; + x86_64-apple-darwin) TUPLE="darwin-amd64" ;; + *) err "unsupported TARGET: $TARGET (add a case in build-artifact.sh)" ;; +esac + +# Read FFI_ABI_VERSION from the crate source. Single source of truth — +# bumps in lib.rs flow into the bundle without a separate config edit. +ABI_LINE="$(grep -E '^pub const FFI_ABI_VERSION: u32 = [0-9]+;' \ + crates/cpex-ffi/src/lib.rs || true)" +[[ -n "$ABI_LINE" ]] || err "could not find FFI_ABI_VERSION in crates/cpex-ffi/src/lib.rs" +FFI_ABI="$(echo "$ABI_LINE" | sed -E 's/.*= ([0-9]+);.*/\1/')" +[[ "$FFI_ABI" =~ ^[0-9]+$ ]] || err "extracted FFI_ABI is not an integer: $FFI_ABI" + +info "TARGET=$TARGET TUPLE=$TUPLE VERSION=$VERSION FFI_ABI=$FFI_ABI" + +# Build. `cross` swaps in a containerized toolchain with the right +# sysroot/glibc/musl for the target — used for arm and musl from x86_64 +# linux runners. Local host builds use plain cargo. +if [[ "$USE_CROSS" == "1" ]]; then + command -v cross >/dev/null || err "USE_CROSS=1 but cross is not installed" + info "building with cross" + cross build --release --locked --target "$TARGET" -p cpex-ffi +else + info "building with cargo" + cargo build --release --locked --target "$TARGET" -p cpex-ffi +fi + +ARTIFACT_PATH="target/${TARGET}/release/libcpex_ffi.a" +[[ -f "$ARTIFACT_PATH" ]] || err "expected artifact missing: $ARTIFACT_PATH" + +# Stage into a temp dir, tar from there so the archive has no leading +# directory and tools like the download script can `tar xzf` flat into +# any destination. +STAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGE_DIR"' EXIT + +cp "$ARTIFACT_PATH" "$STAGE_DIR/libcpex_ffi.a" +cp LICENSE "$STAGE_DIR/LICENSE" + +GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +cat > "$STAGE_DIR/VERSION" < "$STAGE_DIR/FFI_ABI" + +mkdir -p "$DIST_DIR" +TARBALL_NAME="cpex-ffi-${VERSION}-${TUPLE}.tar.gz" +TARBALL_PATH="${DIST_DIR}/${TARBALL_NAME}" + +# tar -C ${STAGE_DIR} . produces a flat archive (no leading dir). +# --owner / --group / --mtime would help reproducibility but BSD/GNU +# tar flag divergence makes that finicky; --locked + cargo gives us +# the most important reproducibility guarantee. +tar -czf "$TARBALL_PATH" -C "$STAGE_DIR" . + +# sha256 companion. Recompute on the consumer side as the integrity gate. +# Use coreutils sha256sum if present (linux), shasum -a 256 otherwise (macOS). +if command -v sha256sum >/dev/null; then + (cd "$DIST_DIR" && sha256sum "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +else + (cd "$DIST_DIR" && shasum -a 256 "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +fi + +info "wrote $TARBALL_PATH" +info "wrote ${TARBALL_PATH}.sha256" diff --git a/scripts/release/sign-artifact.sh b/scripts/release/sign-artifact.sh new file mode 100755 index 00000000..f306311a --- /dev/null +++ b/scripts/release/sign-artifact.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/sign-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Sign every tarball + SHA256SUMS file in DIST_DIR with cosign keyless +# (Sigstore Fulcio + Rekor). Produces a .sig and .crt next to each +# signed file so downstream consumers can verify without fetching keys. +# +# Invoked once by the sign-and-release job in release-ffi.yaml after +# all matrix-built tarballs are downloaded into dist/. Requires the +# workflow to have `id-token: write` so cosign can obtain the GitHub +# Actions OIDC token for keyless signing. +# +# Inputs (env): +# DIST_DIR Optional. Directory containing .tar.gz / SHA256SUMS files +# to sign. Defaults to ./dist. +# +# Outputs: +# For every cpex-ffi-*.tar.gz or cpex-ffi-*-SHA256SUMS in DIST_DIR: +# .sig +# .crt + +set -euo pipefail + +err() { echo "sign-artifact: error: $*" >&2; exit 1; } +info() { echo "sign-artifact: $*"; } + +DIST_DIR="${DIST_DIR:-./dist}" +[[ -d "$DIST_DIR" ]] || err "DIST_DIR does not exist: $DIST_DIR" + +command -v cosign >/dev/null || err "cosign is required (install before running)" + +# Sign tarballs and the aggregate SHA256SUMS bundle (if present). The +# per-tarball .sha256 companions are not signed individually — the +# SHA256SUMS file is the signed integrity manifest. The download +# script verifies the tarball's own signature directly, so the +# per-tarball .sha256 is convenience-only. +shopt -s nullglob +TO_SIGN=( "$DIST_DIR"/cpex-ffi-*.tar.gz "$DIST_DIR"/cpex-ffi-*-SHA256SUMS ) +shopt -u nullglob + +[[ ${#TO_SIGN[@]} -gt 0 ]] || err "no files to sign in $DIST_DIR" + +info "signing ${#TO_SIGN[@]} file(s) with cosign keyless" + +for f in "${TO_SIGN[@]}"; do + [[ -f "$f" ]] || continue + info " signing $(basename "$f")" + # --yes skips the interactive "open browser?" prompt — required for + # CI. The OIDC token is sourced automatically from the GHA env + # (ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN). --output-* writes the + # detached signature + cert so verifiers don't need Rekor lookups + # for the basics, though Rekor is still queried for transparency. + cosign sign-blob --yes \ + --output-signature "${f}.sig" \ + --output-certificate "${f}.crt" \ + "$f" +done + +info "done; signed ${#TO_SIGN[@]} file(s)"