From 7224419c6753ab47d9f7c28ad01af5f14ab52617 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 3 Jul 2026 15:57:49 +0200 Subject: [PATCH] feat: Start ADR 0025 immplementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequences the WASM plugin work into independently-mergeable phases (runtime foundation, full_auth mode, mapping mode, route mode, admin APIs), pinning implementation choices the ADR left open: a dedicated dynamic-plugin-runtime crate, a Rust reference test plugin, and mode-by-mode phasing with admin APIs deferred to the final phase. Complete ADR 0025 Phase 0: reference plugin and resource-limit enforcement - crates/config: new [dynamic_plugins]/[dynamic_plugin.*] sections (openstack_keystone_config::dynamic_plugins) covering every field from ADR 0025 §5, plus config-load-time validation of the fail-loud invariants from §4/§5 (reserved auth-method names, mode-vs-capability restrictions, hard-denylisted headers, route_targets constraints, well-formed sha256). Wired into Config::load_all alongside the existing validator-derive pass. - crates/dynamic-plugin-runtime: new crate hosting the Extism/wasmtime dependency in isolation from crates/core. WasmPluginRegistry loads each configured plugin, verifies its SHA-256 against the pinned config value, and compiles it with WASI disabled. A checksum mismatch or missing file disables only that plugin (logged, not fatal) - every other plugin and builtin auth method still load, per ADR 0025 §5. - add a minimal Rust reference dynamic auth plugin (Extism PDK, wasm32-unknown-unknown, standalone workspace so its guest dependency graph never touches the shipped binary) under crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin. It exposes authenticate/mapping/route stand-in entry points plus two resource-limit fixtures (spin, allocate_memory) for PR 0.4's tests. The integration test suite builds it on the fly rather than via a separate xtask/CI step, so it can never run against a stale artifact. - wire fuel_limit, timeout_ms, and memory_limit_mb into the Extism manifest/PluginBuilder at compile time, and add LoadedPlugin::invoke() which instantiates a fresh extism::Plugin (and so a fresh wasmtime::Store) per call, matching the ADR's "isolation between requests" requirement. A new InvokeError type distinguishes fuel exhaustion, timeout, and other traps (e.g. an over-budget guest allocation). Five new integration tests exercise the real compiled reference plugin: successful authenticate round-trip, each of the three resource bounds independently tripping with the right error variant, and proof that an exhausted invocation doesn't affect the next call against the same loaded plugin. Assisted-By: Claude Sonnet 5 Signed-off-by: Artem Goncharov --- .github/workflows/ci.yml | 2 +- Cargo.lock | 1363 ++++++++++++++++- Cargo.toml | 3 + crates/config/src/dynamic_plugins.rs | 728 +++++++++ crates/config/src/lib.rs | 18 +- crates/dynamic-plugin-runtime/Cargo.toml | 28 + crates/dynamic-plugin-runtime/src/lib.rs | 399 +++++ .../fixtures/reference-plugin/Cargo.lock | 379 +++++ .../fixtures/reference-plugin/Cargo.toml | 25 + .../tests/fixtures/reference-plugin/README.md | 38 + .../fixtures/reference-plugin/src/lib.rs | 106 ++ .../tests/reference_plugin.rs | 204 +++ doc/plans/0025-implementation-plan.md | 325 ++++ 13 files changed, 3588 insertions(+), 30 deletions(-) create mode 100644 crates/config/src/dynamic_plugins.rs create mode 100644 crates/dynamic-plugin-runtime/Cargo.toml create mode 100644 crates/dynamic-plugin-runtime/src/lib.rs create mode 100644 crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.lock create mode 100644 crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.toml create mode 100644 crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/README.md create mode 100644 crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs create mode 100644 crates/dynamic-plugin-runtime/tests/reference_plugin.rs create mode 100644 doc/plans/0025-implementation-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e1e045ec..c35747245 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # stable with: toolchain: stable - targets: ${{ matrix.target }} + targets: "${{ matrix.target }},wasm32-unknown-unknown" - name: Install cargo-nextest uses: taiki-e/install-action@de6bbd1333b8f331563d54a051e542c7dfef81c3 # v2.68.34 diff --git a/Cargo.lock b/Cargo.lock index 221c0a664..400a58ef8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,16 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", +] + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli 0.33.0", ] [[package]] @@ -119,6 +128,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -505,11 +520,11 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line", + "addr2line 0.25.1", "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link", ] @@ -606,6 +621,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "bitvec" version = "1.1.1" @@ -714,6 +738,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "byte-unit" @@ -749,6 +776,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -776,6 +809,72 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9" +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.52.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.6", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + [[package]] name = "cast" version = "0.3.0" @@ -791,6 +890,24 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "heck 0.5.0", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.118", + "tempfile", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "cc" version = "1.2.65" @@ -1079,8 +1196,8 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml", - "winnow", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.3", "yaml-rust2", ] @@ -1193,6 +1310,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpubits" version = "0.1.1" @@ -1217,6 +1343,148 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli 0.33.0", + "hashbrown 0.16.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck 0.5.0", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" + +[[package]] +name = "cranelift-control" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" + +[[package]] +name = "cranelift-native" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" + [[package]] name = "crc" version = "3.4.0" @@ -1340,7 +1608,7 @@ dependencies = [ "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.4", "winapi", ] @@ -1526,6 +1794,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -1675,6 +1952,16 @@ dependencies = [ "ctutils", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1692,10 +1979,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "display-more" version = "0.2.6" @@ -1925,6 +2223,73 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extism" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b66cd9ac5c64b49c9bac69db3d1b10d8f9386e7caab73489c2f197ba43d5e05" +dependencies = [ + "anyhow", + "async-trait", + "cbindgen", + "extism-convert", + "extism-manifest", + "glob", + "libc", + "serde", + "serde_json", + "sha2 0.10.9", + "toml 0.9.12+spec-1.1.0", + "tracing", + "tracing-subscriber", + "ureq", + "url", + "uuid", + "wasi-common", + "wasmtime", + "wiggle", +] + +[[package]] +name = "extism-convert" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytemuck", + "extism-convert-macros", + "prost", + "rmp-serde", + "serde", + "serde_json", +] + +[[package]] +name = "extism-convert-macros" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2932799f6d9f9646f97b65287f6bb2addc75a0ee61e40fb24559a7540dd928" +dependencies = [ + "manyhow", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "extism-manifest" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1941,6 +2306,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "fernet" version = "0.2.2" @@ -1990,6 +2366,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2095,13 +2477,24 @@ dependencies = [ "futures-core", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "fs4" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" dependencies = [ - "rustix", + "rustix 1.1.4", "tokio", "windows-sys 0.61.2", ] @@ -2232,6 +2625,20 @@ dependencies = [ "slab", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags 2.13.0", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2308,6 +2715,18 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" @@ -2413,6 +2832,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -2806,6 +3227,12 @@ dependencies = [ "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" @@ -2833,6 +3260,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "im-rc" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "indenter" version = "0.3.4" @@ -2919,6 +3360,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.12.0" @@ -2983,6 +3440,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "jni" version = "0.22.4" @@ -3168,6 +3645,18 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -3203,6 +3692,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3295,6 +3790,38 @@ dependencies = [ "winapi", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "maplit" version = "1.0.2" @@ -3316,6 +3843,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "md-5" version = "0.10.6" @@ -3332,6 +3865,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3623,6 +4165,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "crc32fast", + "hashbrown 0.16.1", + "indexmap", + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -3844,7 +4398,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "toml", + "toml 1.1.2+spec-1.1.0", "tonic", "tower", "tower-http 0.7.0", @@ -4176,6 +4730,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "openstack-keystone-dynamic-plugin-runtime" +version = "0.1.0" +dependencies = [ + "config", + "extism", + "openstack-keystone-config", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "openstack-keystone-federation-driver-sql" version = "0.1.0" @@ -5114,13 +5684,23 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap", ] @@ -5388,6 +5968,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -5430,7 +6021,7 @@ dependencies = [ "itertools 0.14.0", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", "prost", "prost-types", @@ -5503,6 +6094,29 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "pulley-interpreter" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "quanta" version = "0.12.6" @@ -5687,6 +6301,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -5748,6 +6371,17 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5779,6 +6413,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.4" @@ -6104,6 +6752,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6113,10 +6774,20 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.41" @@ -6126,6 +6797,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -6487,6 +7159,10 @@ 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" @@ -6652,6 +7328,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial_test" version = "3.5.0" @@ -6814,6 +7503,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "slab" version = "0.4.12" @@ -7252,6 +7951,22 @@ dependencies = [ "libc", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags 2.13.0", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.52.0", + "winx", +] + [[package]] name = "tabwriter" version = "1.4.1" @@ -7278,6 +7993,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "temp-env" version = "0.3.6" @@ -7296,10 +8017,19 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" @@ -7633,6 +8363,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -7641,9 +8386,18 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -7662,9 +8416,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -7673,9 +8427,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.14.6" @@ -8025,6 +8785,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "unsafe-libyaml-norway" version = "0.2.15" @@ -8044,21 +8810,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "url" -version = "2.5.8" +name = "ureq" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "form_urlencoded", - "idna", + "base64 0.22.1", + "flate2", + "log", "percent-encoding", - "serde", - "serde_derive", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", ] [[package]] -name = "url-macro" -version = "0.2.3" +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "url-macro" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46f8fd02d998459be6962aa74831961202461c1545cf056725e2c73b9f860c91" dependencies = [ @@ -8071,6 +8866,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -8247,6 +9048,32 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi-common" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46137f5bcc41a0f002ed14688e463665388a6f3a6662a12a8c315d4b8849791c" +dependencies = [ + "async-trait", + "bitflags 2.13.0", + "cap-fs-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "io-extras", + "io-lifetimes", + "log", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle", + "windows-sys 0.61.2", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -8318,6 +9145,47 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-compose" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd23d12cc95c451c1306db5bc63075fbebb612bb70c53b4237b1ce5bc178343" +dependencies = [ + "anyhow", + "heck 0.5.0", + "im-rc", + "indexmap", + "log", + "petgraph 0.6.5", + "serde", + "serde_derive", + "serde_yaml", + "smallvec", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wat", +] + +[[package]] +name = "wasm-encoder" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +dependencies = [ + "leb128fmt", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -8331,6 +9199,329 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.16.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags 2.13.0", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasmtime" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" +dependencies = [ + "addr2line 0.26.1", + "async-trait", + "bitflags 2.13.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli 0.33.0", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object 0.38.1", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli 0.33.0", + "hashbrown 0.16.1", + "indexmap", + "log", + "object 0.38.1", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed398988226d7aa0505ac6bb576e09532ad722d702ec4e66365d78ed695c95f" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml 0.9.12+spec-1.1.0", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f" + +[[package]] +name = "wasmtime-internal-core" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli 0.33.0", + "itertools 0.14.0", + "log", + "object 0.38.1", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" +dependencies = [ + "cc", + "object 0.38.1", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object 0.38.1", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f599b79545e3bba0b7913406055ebede5bb0dabee9ba2015ef25a9f4c9f47807" +dependencies = [ + "cranelift-codegen", + "gimli 0.33.0", + "log", + "object 0.38.1", + "target-lexicon", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "heck 0.5.0", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "252.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.252.0", +] + +[[package]] +name = "wat" +version = "1.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" +dependencies = [ + "wast 252.0.0", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -8461,6 +9652,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -8471,6 +9671,47 @@ dependencies = [ "wasite", ] +[[package]] +name = "wiggle" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8cfd3db2f05619c6f36f257d84327c11546e28d61e3a1c1220aaad553bc4b0" +dependencies = [ + "bitflags 2.13.0", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", + "witx", +] + +[[package]] +name = "wiggle-generate" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd7a197903e5b4ff5e13aef9c891960d71e92073600ecf4c86c7e795ac1c803" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6410b86fcec207070d9372b215d3470bad67215e6bbac46981a16999c4abbc28" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8502,6 +9743,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dbb0cf07b0dfe7b7a1ca8efb8f94ba98bd0fb144c411ea1665c78f0449e958" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli 0.33.0", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -8794,6 +10054,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.3" @@ -8803,12 +10069,53 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.0", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-parser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.245.1", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + [[package]] name = "writeable" version = "0.6.3" @@ -8866,7 +10173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 88dc16929..e8776d194 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/core", "crates/core-types", "crates/credential-driver-sql", + "crates/dynamic-plugin-runtime", "crates/federation-driver-sql", "crates/identity-driver-sql", "crates/idmapping-driver-sql", @@ -77,6 +78,7 @@ dashmap = "6.2" data-encoding = "2" derive_builder = { version = "0.20" } dyn-clone = { version = "1.0" } +extism = { version = "1.30" } eyre = { version = "0.6" } fernet = { version = "0.2", default-features = false } futures = { version = "0.3" } @@ -107,6 +109,7 @@ openstack-keystone-core = { version = "0.1.0", path = "crates/core" } openstack-keystone-core-types = { version = "0.1.0", path = "crates/core-types" } openstack-keystone-credential-driver-sql = { version = "0.1", path = "crates/credential-driver-sql/" } openstack-keystone-distributed-storage = { version = "0.1.0", path = "crates/storage"} +openstack-keystone-dynamic-plugin-runtime = { version = "0.1.0", path = "crates/dynamic-plugin-runtime" } openstack-keystone-storage-api = { version = "0.1.0", path = "crates/storage-api" } openstack-keystone-storage-crypto = { version = "0.1.0", path = "crates/storage-crypto" } openstack-keystone-federation-driver-sql = { version = "0.1", path = "crates/federation-driver-sql" } diff --git a/crates/config/src/dynamic_plugins.rs b/crates/config/src/dynamic_plugins.rs new file mode 100644 index 000000000..744807180 --- /dev/null +++ b/crates/config/src/dynamic_plugins.rs @@ -0,0 +1,728 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Keystone configuration +//! +//! Parsing and validation of the `[dynamic_plugins]` / `[dynamic_plugin.*]` +//! configuration sections introduced by ADR 0025 ("Dynamic Auth Plugins via +//! WebAssembly"). This module only covers config *shape* and the +//! config-load-time invariants the ADR calls out as fail-loud (§4, §5) - it +//! does not load or execute any plugin bytecode. +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::Deserialize; +use thiserror::Error; + +use crate::common::csv; + +/// Auth method names that are compiled into `keystone-rs` and can therefore +/// never be shadowed by a dynamic plugin name, per ADR 0025 §4 "Reserved +/// Auth-Method Names". +pub const RESERVED_AUTH_METHOD_NAMES: &[&str] = &[ + "password", + "token", + "openid", + "application_credential", + "trust", + "webauthn", + "mapped", + "k8s", + "admin", +]; + +/// Auth methods a `route`-mode plugin's `route_targets` may never name, +/// regardless of what the plugin's own config otherwise allows - ADR 0025 +/// §4 "Reserved Auth-Method Names": neither is a method a router's blast +/// radius should ever be able to reach. +pub const ROUTE_TARGET_FORBIDDEN_NAMES: &[&str] = &["admin", "trust"]; + +/// HTTP headers that can never appear in a plugin's `exposed_headers` list, +/// regardless of operator configuration - ADR 0025 §4 "Guest Contract - +/// `full_auth` Mode". +pub const HARD_DENYLISTED_HEADERS: &[&str] = &[ + "authorization", + "cookie", + "x-auth-token", + "x-service-token", + "x-subject-token", + "proxy-authorization", +]; + +/// The three host-function capabilities a plugin can be granted, per ADR +/// 0025 §6 (A-D; §6.E auditing is mandatory infrastructure, not a granted +/// capability, and therefore has no entry here). +pub const KNOWN_CAPABILITIES: &[&str] = + &["http_fetch", "provision_user", "find_user", "assign_role"]; + +/// A dynamic auth plugin's operating mode - ADR 0025 §4 "Three Operating +/// Modes". +#[derive(Debug, Default, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PluginMode { + /// The plugin is the terminal identity authority for its method name. + #[default] + FullAuth, + /// The plugin only produces claims for the Unified Mapping Engine + /// (ADR 0020) to evaluate. + Mapping, + /// The plugin runs pre-dispatch and may only relabel `identity.methods` + /// and hand a payload to one allowlisted target method. + Route, +} + +/// `[dynamic_plugins]` section: the list of configured plugin names, each of +/// which must have a corresponding `[dynamic_plugin.]` section. +#[derive(Debug, Default, Deserialize, Clone)] +pub struct DynamicPluginsSection { + /// Names of the dynamic plugins to load at startup. Each entry must + /// have a matching `[dynamic_plugin.]` section. + #[serde(default, deserialize_with = "csv")] + pub plugins: Vec, +} + +/// A single `[dynamic_plugin.]` section - ADR 0025 §5. +#[derive(Debug, Deserialize, Clone)] +pub struct DynamicPluginConfig { + /// Local filesystem path to the compiled `.wasm` module. + pub path: PathBuf, + + /// Lowercase hex-encoded SHA-256 of the file at `path`, pinned at + /// config time. A mismatch at load time disables only this plugin + /// (ADR 0025 §5) - it is not validated against the filesystem here, + /// only checked for well-formedness. + pub sha256: String, + + /// Operating mode - defaults to `full_auth` (ADR 0025 §4). + #[serde(default)] + pub mode: PluginMode, + + /// Host functions (ADR 0025 §6 A-D) this plugin may call. An unlisted + /// function is not registered into the plugin's `extism::Plugin` + /// instance at all. + #[serde(default, deserialize_with = "csv")] + pub capabilities: Vec, + + /// Allowlisted subset of inbound HTTP headers forwarded to the guest. + /// Never allowed to contain a `HARD_DENYLISTED_HEADERS` entry. + #[serde(default, deserialize_with = "csv")] + pub exposed_headers: Vec, + + /// Hostnames the `http_fetch` capability may connect to. + #[serde(default, deserialize_with = "csv")] + pub allowed_hosts: Vec, + + /// Whether `http_fetch` follows redirects (each hop re-validated + /// against `allowed_hosts` and the SSRF IP-range check). Defaults to + /// `false` - ADR 0025 §6.A. + #[serde(default)] + pub http_fetch_follow_redirects: bool, + + /// Header name the host attaches to every outbound `http_fetch` + /// request, if set. + #[serde(default)] + pub http_fetch_auth_header: Option, + + /// Host-side environment variable the outbound secret value is read + /// from. Never placed in guest memory (ADR 0025 §6.A). + #[serde(default)] + pub http_fetch_auth_secret_env: Option, + + /// Domain `provision_user` may create users in. Mutually additive with + /// `allowed_provision_domains` (ADR 0025 §6.B) - at least one of the two + /// must be set for a plugin holding `provision_user`/`find_user`. + #[serde(default)] + pub provision_domain_id: Option, + + /// Small explicit list of domains `provision_user` may create users in, + /// for plugins that genuinely need more than one (ADR 0025 §6.B). + #[serde(default, deserialize_with = "csv")] + pub allowed_provision_domains: Vec, + + /// Role names `assign_role` may grant (ADR 0025 §6.D). + #[serde(default, deserialize_with = "csv")] + pub assign_role_allowed: Vec, + + /// `route` mode only: `identity.methods` entries that trigger this + /// plugin's invocation (ADR 0025 §4 "Guest Contract - `route` Mode"). + #[serde(default, deserialize_with = "csv")] + pub inspect_methods: Vec, + + /// `route` mode only: auth methods this plugin is permitted to reroute + /// a request to. + #[serde(default, deserialize_with = "csv")] + pub route_targets: Vec, + + /// Wall-clock deadline for a single invocation, including any + /// `http_fetch` calls (ADR 0025 §7). + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, + + /// Fuel (instruction count) limit for a single invocation. + #[serde(default = "default_fuel_limit")] + pub fuel_limit: u64, + + /// Linear memory cap, in MiB, for a single invocation. + #[serde(default = "default_memory_limit_mb")] + pub memory_limit_mb: u32, + + /// Per-`(plugin_name, remote_addr)` token bucket, checked before the + /// plugin-wide bucket (ADR 0025 §7). + #[serde(default = "default_rate_limit_per_source_per_minute")] + pub invocation_rate_limit_per_source_per_minute: u32, + + /// Per-plugin token bucket, shared across all sources. + #[serde(default = "default_rate_limit_per_minute")] + pub invocation_rate_limit_per_minute: u32, + + /// Concurrency cap on in-flight invocations for this plugin. + #[serde(default = "default_max_concurrent_invocations")] + pub max_concurrent_invocations: u32, +} + +fn default_timeout_ms() -> u64 { + 1_000 +} + +fn default_fuel_limit() -> u64 { + 10_000_000 +} + +fn default_memory_limit_mb() -> u32 { + 16 +} + +fn default_rate_limit_per_source_per_minute() -> u32 { + 20 +} + +fn default_rate_limit_per_minute() -> u32 { + 300 +} + +fn default_max_concurrent_invocations() -> u32 { + 16 +} + +/// A `[dynamic_plugins]`/`[dynamic_plugin.*]` configuration that failed one +/// of ADR 0025's fail-loud, config-load-time invariants. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum DynamicPluginConfigError { + #[error( + "dynamic plugin `{0}` is listed in [dynamic_plugins] plugins but has no \ + [dynamic_plugin.{0}] section" + )] + MissingSection(String), + + #[error( + "dynamic plugin name `{0}` collides with a builtin auth method name and \ + cannot be used" + )] + ReservedName(String), + + #[error("dynamic plugin `{plugin}` declares unknown capability `{capability}`")] + UnknownCapability { plugin: String, capability: String }, + + #[error( + "dynamic plugin `{plugin}` is mode `{mode:?}` and cannot be granted capability \ + `{capability}` - provision_user/find_user/assign_role are config-load errors \ + outside full_auth mode" + )] + CapabilityNotAllowedInMode { + plugin: String, + mode: PluginMode, + capability: String, + }, + + #[error( + "dynamic plugin `{0}` is mode full_auth but has neither provision_user nor \ + find_user capability, so it can never produce a valid identity handle and \ + can only ever Deny" + )] + FullAuthWithoutIdentityCapability(String), + + #[error( + "dynamic plugin `{plugin}` grants provision_user/find_user but has neither \ + provision_domain_id nor allowed_provision_domains set" + )] + ProvisioningWithoutDomain { plugin: String }, + + #[error( + "dynamic plugin `{plugin}` exposes hard-denylisted header `{header}` via \ + exposed_headers - this header can never be forwarded to a plugin" + )] + HardDenylistedHeaderExposed { plugin: String, header: String }, + + #[error("dynamic plugin `{0}` is mode route but has an empty inspect_methods list")] + RouteWithoutInspectMethods(String), + + #[error("dynamic plugin `{0}` is mode route but has an empty route_targets list")] + RouteWithoutTargets(String), + + #[error( + "dynamic plugin `{plugin}` route_targets names `{target}`, which is never a \ + valid route target" + )] + RouteTargetForbidden { plugin: String, target: String }, + + #[error("dynamic plugin `{plugin}` route_targets names itself (`{plugin}`)")] + RouteTargetSelfReference { plugin: String }, + + #[error("dynamic plugin `{0}` sha256 must be 64 lowercase hex characters")] + MalformedSha256(String), + + #[error( + "dynamic plugin `{0}` sets inspect_methods and/or route_targets but is not mode \ + route - these fields only take effect in route mode and setting them elsewhere \ + is almost certainly a misconfiguration" + )] + RouteFieldsOutsideRouteMode(String), +} + +impl DynamicPluginsSection { + /// Validate the cross-field, config-load-time invariants ADR 0025 §4/§5 + /// describe as fail-loud errors rather than silent no-ops. This does + /// **not** touch the filesystem (no checksum verification against the + /// `.wasm` file - that is a load-time, not config-parse-time, concern + /// per ADR 0025 §5) and does not require a live plugin registry. + pub fn validate_semantics( + &self, + plugins: &HashMap, + ) -> Result<(), DynamicPluginConfigError> { + for name in &self.plugins { + let plugin = plugins + .get(name) + .ok_or_else(|| DynamicPluginConfigError::MissingSection(name.clone()))?; + + if RESERVED_AUTH_METHOD_NAMES.contains(&name.as_str()) { + return Err(DynamicPluginConfigError::ReservedName(name.clone())); + } + + if !plugin.sha256.len().eq(&64) + || !plugin.sha256.bytes().all(|b| b.is_ascii_hexdigit()) + || plugin.sha256.bytes().any(|b| b.is_ascii_uppercase()) + { + return Err(DynamicPluginConfigError::MalformedSha256(name.clone())); + } + + for capability in &plugin.capabilities { + if !KNOWN_CAPABILITIES.contains(&capability.as_str()) { + return Err(DynamicPluginConfigError::UnknownCapability { + plugin: name.clone(), + capability: capability.clone(), + }); + } + } + + if plugin.mode != PluginMode::Route + && (!plugin.inspect_methods.is_empty() || !plugin.route_targets.is_empty()) + { + return Err(DynamicPluginConfigError::RouteFieldsOutsideRouteMode( + name.clone(), + )); + } + + match plugin.mode { + PluginMode::FullAuth => { + let has_identity_capability = plugin + .capabilities + .iter() + .any(|c| c == "provision_user" || c == "find_user"); + if !has_identity_capability { + return Err(DynamicPluginConfigError::FullAuthWithoutIdentityCapability( + name.clone(), + )); + } + let grants_provisioning = plugin + .capabilities + .iter() + .any(|c| c == "provision_user" || c == "find_user"); + if grants_provisioning + && plugin.provision_domain_id.is_none() + && plugin.allowed_provision_domains.is_empty() + { + return Err(DynamicPluginConfigError::ProvisioningWithoutDomain { + plugin: name.clone(), + }); + } + } + PluginMode::Mapping | PluginMode::Route => { + for capability in &plugin.capabilities { + if matches!( + capability.as_str(), + "provision_user" | "find_user" | "assign_role" + ) { + return Err(DynamicPluginConfigError::CapabilityNotAllowedInMode { + plugin: name.clone(), + mode: plugin.mode, + capability: capability.clone(), + }); + } + } + if plugin.mode == PluginMode::Route { + if plugin.inspect_methods.is_empty() { + return Err(DynamicPluginConfigError::RouteWithoutInspectMethods( + name.clone(), + )); + } + if plugin.route_targets.is_empty() { + return Err(DynamicPluginConfigError::RouteWithoutTargets( + name.clone(), + )); + } + for target in &plugin.route_targets { + if ROUTE_TARGET_FORBIDDEN_NAMES.contains(&target.as_str()) { + return Err(DynamicPluginConfigError::RouteTargetForbidden { + plugin: name.clone(), + target: target.clone(), + }); + } + if target == name { + return Err(DynamicPluginConfigError::RouteTargetSelfReference { + plugin: name.clone(), + }); + } + } + } + } + } + + for header in &plugin.exposed_headers { + if HARD_DENYLISTED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) { + return Err(DynamicPluginConfigError::HardDenylistedHeaderExposed { + plugin: name.clone(), + header: header.clone(), + }); + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use config::{Config, File, FileFormat}; + + use super::*; + + fn parse(ini: &str) -> (DynamicPluginsSection, HashMap) { + #[derive(Debug, Deserialize)] + struct Wrapper { + #[serde(default)] + dynamic_plugins: DynamicPluginsSection, + #[serde(default)] + dynamic_plugin: HashMap, + } + + let c = Config::builder() + .add_source(File::from_str(ini, FileFormat::Ini)) + .build() + .unwrap(); + let wrapper: Wrapper = c.try_deserialize().unwrap(); + (wrapper.dynamic_plugins, wrapper.dynamic_plugin) + } + + fn valid_full_auth_ini() -> &'static str { + r#" +[dynamic_plugins] +plugins = acme_risk_sso + +[dynamic_plugin.acme_risk_sso] +path = /etc/keystone/plugins/acme_risk_sso.wasm +sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 +mode = full_auth +capabilities = http_fetch,provision_user,find_user +exposed_headers = X-Acme-Session-Id +allowed_hosts = risk.acme.example.com +provision_domain_id = domain_acme_sso +assign_role_allowed = reader,member +"# + } + + #[test] + fn test_valid_full_auth_config_parses_and_validates() { + let (section, plugins) = parse(valid_full_auth_ini()); + assert_eq!(section.plugins, vec!["acme_risk_sso".to_string()]); + let plugin = plugins.get("acme_risk_sso").unwrap(); + assert_eq!(plugin.mode, PluginMode::FullAuth); + assert_eq!(plugin.timeout_ms, default_timeout_ms()); + section.validate_semantics(&plugins).unwrap(); + } + + #[test] + fn test_route_mode_config_parses_and_validates() { + let ini = r#" +[dynamic_plugins] +plugins = tf_appcred_router + +[dynamic_plugin.tf_appcred_router] +path = /etc/keystone/plugins/tf_appcred_router.wasm +sha256 = 3b5d5c3712955042212316173ccf37be9de53d6c84a5c7c8e6e0e5e7f5f8a1bc +mode = route +inspect_methods = application_credential +route_targets = tf_appcred_handler +"#; + let (section, plugins) = parse(ini); + section.validate_semantics(&plugins).unwrap(); + } + + #[test] + fn test_missing_section_is_rejected() { + let ini = r#" +[dynamic_plugins] +plugins = acme_risk_sso +"#; + let (section, plugins) = parse(ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::MissingSection( + "acme_risk_sso".into() + )) + ); + } + + #[test] + fn test_reserved_name_is_rejected() { + let mut ini = valid_full_auth_ini().replace("acme_risk_sso", "password"); + // Sanity: replacement must have hit both the [dynamic_plugins] list + // and the section header/name. + assert!(ini.contains("plugins = password")); + ini = ini.replace("provision_domain_id = domain_acme_sso", ""); + let (section, plugins) = parse(&ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::ReservedName("password".into())) + ); + } + + #[test] + fn test_malformed_sha256_is_rejected() { + let ini = valid_full_auth_ini().replace( + "sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "sha256 = not-a-hash", + ); + let (section, plugins) = parse(&ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::MalformedSha256( + "acme_risk_sso".into() + )) + ); + } + + #[test] + fn test_unknown_capability_is_rejected() { + let ini = valid_full_auth_ini().replace( + "capabilities = http_fetch,provision_user,find_user", + "capabilities = http_fetch,provision_user,find_user,mint_token", + ); + let (section, plugins) = parse(&ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::UnknownCapability { + plugin: "acme_risk_sso".into(), + capability: "mint_token".into(), + }) + ); + } + + #[test] + fn test_mapping_mode_with_provision_user_is_rejected() { + let ini = valid_full_auth_ini().replace("mode = full_auth", "mode = mapping"); + let (section, plugins) = parse(&ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::CapabilityNotAllowedInMode { + plugin: "acme_risk_sso".into(), + mode: PluginMode::Mapping, + capability: "provision_user".into(), + }) + ); + } + + #[test] + fn test_full_auth_without_identity_capability_is_rejected() { + let ini = valid_full_auth_ini().replace( + "capabilities = http_fetch,provision_user,find_user", + "capabilities = http_fetch", + ); + let (section, plugins) = parse(&ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::FullAuthWithoutIdentityCapability( + "acme_risk_sso".into() + )) + ); + } + + #[test] + fn test_provisioning_without_domain_is_rejected() { + let ini = valid_full_auth_ini().replace("provision_domain_id = domain_acme_sso", ""); + let (section, plugins) = parse(&ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::ProvisioningWithoutDomain { + plugin: "acme_risk_sso".into(), + }) + ); + } + + #[test] + fn test_hard_denylisted_header_is_rejected() { + let ini = valid_full_auth_ini().replace( + "exposed_headers = X-Acme-Session-Id", + "exposed_headers = Authorization", + ); + let (section, plugins) = parse(&ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::HardDenylistedHeaderExposed { + plugin: "acme_risk_sso".into(), + header: "Authorization".into(), + }) + ); + } + + #[test] + fn test_route_without_inspect_methods_is_rejected() { + let ini = r#" +[dynamic_plugins] +plugins = tf_appcred_router + +[dynamic_plugin.tf_appcred_router] +path = /etc/keystone/plugins/tf_appcred_router.wasm +sha256 = 3b5d5c3712955042212316173ccf37be9de53d6c84a5c7c8e6e0e5e7f5f8a1bc +mode = route +route_targets = tf_appcred_handler +"#; + let (section, plugins) = parse(ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::RouteWithoutInspectMethods( + "tf_appcred_router".into() + )) + ); + } + + #[test] + fn test_route_without_targets_is_rejected() { + let ini = r#" +[dynamic_plugins] +plugins = tf_appcred_router + +[dynamic_plugin.tf_appcred_router] +path = /etc/keystone/plugins/tf_appcred_router.wasm +sha256 = 3b5d5c3712955042212316173ccf37be9de53d6c84a5c7c8e6e0e5e7f5f8a1bc +mode = route +inspect_methods = application_credential +"#; + let (section, plugins) = parse(ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::RouteWithoutTargets( + "tf_appcred_router".into() + )) + ); + } + + #[test] + fn test_route_target_forbidden_name_is_rejected() { + let ini = r#" +[dynamic_plugins] +plugins = tf_appcred_router + +[dynamic_plugin.tf_appcred_router] +path = /etc/keystone/plugins/tf_appcred_router.wasm +sha256 = 3b5d5c3712955042212316173ccf37be9de53d6c84a5c7c8e6e0e5e7f5f8a1bc +mode = route +inspect_methods = application_credential +route_targets = admin +"#; + let (section, plugins) = parse(ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::RouteTargetForbidden { + plugin: "tf_appcred_router".into(), + target: "admin".into(), + }) + ); + } + + #[test] + fn test_route_target_self_reference_is_rejected() { + let ini = r#" +[dynamic_plugins] +plugins = tf_appcred_router + +[dynamic_plugin.tf_appcred_router] +path = /etc/keystone/plugins/tf_appcred_router.wasm +sha256 = 3b5d5c3712955042212316173ccf37be9de53d6c84a5c7c8e6e0e5e7f5f8a1bc +mode = route +inspect_methods = application_credential +route_targets = tf_appcred_router +"#; + let (section, plugins) = parse(ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::RouteTargetSelfReference { + plugin: "tf_appcred_router".into(), + }) + ); + } + + #[test] + fn test_route_targets_outside_route_mode_is_rejected() { + let ini = r#" +[dynamic_plugins] +plugins = acme_risk_sso + +[dynamic_plugin.acme_risk_sso] +path = /etc/keystone/plugins/acme_risk_sso.wasm +sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 +mode = full_auth +capabilities = provision_user +provision_domain_id = domain_acme_sso +route_targets = password +"#; + let (section, plugins) = parse(ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::RouteFieldsOutsideRouteMode( + "acme_risk_sso".into() + )) + ); + } + + #[test] + fn test_inspect_methods_outside_route_mode_is_rejected() { + let ini = r#" +[dynamic_plugins] +plugins = acme_risk_sso + +[dynamic_plugin.acme_risk_sso] +path = /etc/keystone/plugins/acme_risk_sso.wasm +sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 +mode = mapping +inspect_methods = password +"#; + let (section, plugins) = parse(ini); + assert_eq!( + section.validate_semantics(&plugins), + Err(DynamicPluginConfigError::RouteFieldsOutsideRouteMode( + "acme_risk_sso".into() + )) + ); + } +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9b125aa0d..d7b3c49af 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -43,7 +43,7 @@ //! let cfg = cfg_mgr.config.read().await; //! } //! ``` -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::env; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -67,6 +67,7 @@ mod credential; mod database; mod default; mod distributed_storage; +mod dynamic_plugins; mod ec2; mod federation; mod fernet_token; @@ -97,6 +98,7 @@ pub use credential::*; pub use database::*; pub use default::*; pub use distributed_storage::*; +pub use dynamic_plugins::*; pub use ec2::*; pub use federation::*; pub use fernet_token::*; @@ -164,6 +166,14 @@ pub struct Config { #[validate(nested)] pub distributed_storage: Option, + /// Dynamic (WebAssembly) auth plugins configuration - ADR 0025. + #[serde(default)] + pub dynamic_plugins: DynamicPluginsSection, + + /// Per-plugin `[dynamic_plugin.]` sections - ADR 0025. + #[serde(default)] + pub dynamic_plugin: HashMap, + /// `POST /v3/ec2tokens` configuration. #[serde(default)] pub ec2: Ec2Provider, @@ -296,6 +306,12 @@ impl Config { .wrap_err("compiling password_regex")?; // Validate the config after loading all the referred files. cfg.validate().wrap_err("Configuration validation failed")?; + // Cross-field validation for [dynamic_plugins]/[dynamic_plugin.*] + // (ADR 0025 §4/§5 fail-loud invariants) - not expressible via + // `#[validate(...)]` derive since it needs both sections at once. + cfg.dynamic_plugins + .validate_semantics(&cfg.dynamic_plugin) + .wrap_err("validating [dynamic_plugins] configuration")?; Ok(cfg) } diff --git a/crates/dynamic-plugin-runtime/Cargo.toml b/crates/dynamic-plugin-runtime/Cargo.toml new file mode 100644 index 000000000..c41c8c40a --- /dev/null +++ b/crates/dynamic-plugin-runtime/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "openstack-keystone-dynamic-plugin-runtime" +description = "OpenStack Keystone dynamic (WebAssembly) auth plugin runtime - ADR 0025" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +exclude.workspace = true + +[dependencies] +extism.workspace = true +openstack-keystone-config.workspace = true +sha2.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +config = { workspace = true, features = ["ini"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/crates/dynamic-plugin-runtime/src/lib.rs b/crates/dynamic-plugin-runtime/src/lib.rs new file mode 100644 index 000000000..6eb541700 --- /dev/null +++ b/crates/dynamic-plugin-runtime/src/lib.rs @@ -0,0 +1,399 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Dynamic auth plugin runtime (ADR 0025) +//! +//! Loads WebAssembly auth plugins from local disk at process startup, +//! verifies each against its pinned SHA-256 checksum, and compiles it via +//! [`extism`]/`wasmtime` with each plugin's configured resource bounds +//! (`fuel_limit`, `timeout_ms`, `memory_limit_mb`, ADR §7) baked into the +//! compiled module. This crate implements ADR 0025 Phase 0 only +//! (`doc/src/adr/0025-implementation-plan.md`): plugin loading, checksum +//! verification, resource-bounded invocation. It does **not** yet register +//! any host functions (§6) or dispatch real `authenticate`/`mapping`/ +//! `route` requests from an auth method - that's Phases 1-3. +//! +//! No WASI imports are ever registered on a loaded plugin, matching ADR +//! 0025 §6.F ("Sandbox Baseline: No WASI"). Every call to +//! [`LoadedPlugin::invoke`] instantiates a fresh [`extism::Plugin`] (and so +//! a fresh `wasmtime::Store`) from the cached compiled module, matching ADR +//! §7 "Isolation between requests" - no state or resource budget survives +//! from one invocation to the next. +use std::collections::HashMap; +use std::time::Duration; + +use extism::{CompiledPlugin, Manifest, Plugin, PluginBuilder, Wasm}; +use openstack_keystone_config::{DynamicPluginConfig, DynamicPluginsSection}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +/// WebAssembly linear memory page size, per the Wasm spec (64 KiB). +const WASM_PAGE_BYTES: u64 = 64 * 1024; + +/// Why a single configured plugin failed to load. Per ADR 0025 §5, this is +/// never fatal to the process - it only means *this* plugin is not +/// registered as an auth method; every other plugin and every builtin auth +/// method still start normally. +#[derive(Debug, Error)] +pub enum PluginLoadError { + #[error("reading plugin file {path}: {source}")] + ReadFile { + path: String, + #[source] + source: std::io::Error, + }, + #[error( + "checksum mismatch for plugin: configured sha256 {expected} does not match \ + computed sha256 {actual} of {path}" + )] + ChecksumMismatch { + path: String, + expected: String, + actual: String, + }, + #[error("compiling wasm module {path}: {source}")] + Compile { + path: String, + #[source] + source: extism::Error, + }, +} + +/// Why a single invocation of a loaded plugin failed. Per ADR 0025 §7, +/// exceeding any bound fails only that invocation - the plugin stays +/// loaded and registered for the next call. +#[derive(Debug, Error)] +pub enum InvokeError { + /// The plugin's `fuel_limit` (instruction budget) was exhausted before + /// the call returned. + #[error("plugin exceeded its fuel_limit and was aborted")] + FuelExhausted, + /// The plugin's `timeout_ms` wall-clock budget elapsed before the call + /// returned. + #[error("plugin exceeded its timeout_ms and was aborted")] + Timeout, + /// Any other guest-side failure: a trap (e.g. an allocation past + /// `memory_limit_mb`, an explicit panic/abort in the guest), a + /// malformed call, or an instantiation failure. + #[error("plugin invocation failed: {0}")] + Trap(String), +} + +impl InvokeError { + /// Extism/wasmtime don't expose a typed distinction between "ran out of + /// fuel" and "hit the epoch deadline" versus an ordinary trap - both + /// surface as an [`extism::Error`] whose message is produced by + /// wasmtime. Classify on the (stable, wasmtime-owned) message text + /// rather than leaving every resource-limit violation as an + /// undifferentiated [`InvokeError::Trap`]. + fn classify(err: extism::Error) -> Self { + let msg = err.to_string(); + if msg.contains("fuel") { + InvokeError::FuelExhausted + } else if msg.contains("timeout") + || msg.contains("epoch deadline") + || msg.contains("interrupt") + { + InvokeError::Timeout + } else { + InvokeError::Trap(msg) + } + } +} + +/// A plugin that loaded and checksum-verified successfully. Holds a +/// *compiled* module plus its resource-limit configuration; no +/// `wasmtime::Store` is created (and so no guest memory allocated, no fuel +/// budget started) until [`LoadedPlugin::invoke`] is called. No host +/// functions are registered on it yet (Phase 0 scope) - capability wiring +/// is added in Phase 1 (ADR 0025 §6). +pub struct LoadedPlugin { + pub name: String, + pub sha256: String, + compiled: CompiledPlugin, +} + +impl LoadedPlugin { + /// Instantiate a fresh [`extism::Plugin`] from the cached compiled + /// module and call `function` with `input`, returning the raw output + /// bytes. A new `wasmtime::Store` (and so a fresh fuel budget, timeout + /// clock, and linear memory) is created for this call only - nothing + /// carries over between invocations (ADR §7 "Isolation between + /// requests"). + pub fn invoke(&self, function: &str, input: &[u8]) -> Result, InvokeError> { + let mut plugin = Plugin::new_from_compiled(&self.compiled) + .map_err(|e| InvokeError::Trap(e.to_string()))?; + plugin + .call::<&[u8], Vec>(function, input) + .map_err(InvokeError::classify) + } +} + +/// Registry of successfully loaded dynamic auth plugins, keyed by plugin +/// name. Plugins that failed to load (missing file, checksum mismatch, +/// compile error) are not present here - see [`WasmPluginRegistry::load`] +/// for the per-plugin errors raised along the way. +#[derive(Default)] +pub struct WasmPluginRegistry { + plugins: HashMap, +} + +impl WasmPluginRegistry { + /// Load and checksum-verify every plugin named in `section`, looking up + /// each one's `[dynamic_plugin.]` entry in `configs`. Returns the + /// registry of plugins that loaded successfully, plus one + /// [`PluginLoadError`] per plugin that did not - the caller (process + /// startup) is expected to log each error at `CRITICAL` and continue, + /// per ADR 0025 §5's "cross-node divergence is an accepted trade-off" + /// posture: a load failure disables only that plugin, never the node. + pub fn load( + section: &DynamicPluginsSection, + configs: &HashMap, + ) -> (Self, Vec<(String, PluginLoadError)>) { + let mut registry = Self::default(); + let mut errors = Vec::new(); + + for name in §ion.plugins { + // Config-shape validation (missing section, reserved names, ...) + // is `DynamicPluginsSection::validate_semantics`'s job and is + // assumed to have already run and succeeded before `load` is + // called - a name with no config entry here is a programming + // error in the caller, not a runtime condition to recover from. + let Some(config) = configs.get(name) else { + continue; + }; + + match Self::load_one(name, config) { + Ok(loaded) => { + registry.plugins.insert(name.clone(), loaded); + } + Err(err) => { + tracing::error!( + target: "keystone_dynamic_plugin_load_failure", + plugin_name = %name, + error = %err, + "dynamic auth plugin failed to load; this plugin is disabled, \ + all other auth methods start normally" + ); + errors.push((name.clone(), err)); + } + } + } + + (registry, errors) + } + + fn load_one(name: &str, config: &DynamicPluginConfig) -> Result { + let bytes = std::fs::read(&config.path).map_err(|source| PluginLoadError::ReadFile { + path: config.path.display().to_string(), + source, + })?; + + let actual = hex::encode(Sha256::digest(&bytes)); + if !actual.eq_ignore_ascii_case(&config.sha256) { + return Err(PluginLoadError::ChecksumMismatch { + path: config.path.display().to_string(), + expected: config.sha256.clone(), + actual, + }); + } + + // Round up so a `memory_limit_mb` that isn't an exact multiple of + // the 64 KiB page size still gets at least the requested budget, + // and clamp to `[1, u32::MAX]` pages: never zero (a 0-page cap + // would make every plugin call fail instantiation instead of + // failing the way ADR §7 intends: a specific over-budget call + // traps) and never silently wrapped by the `u32` cast for a large + // `memory_limit_mb` (e.g. `268435456` MB is exactly 2^32 pages, + // which would otherwise truncate to 0). + let max_pages = (u64::from(config.memory_limit_mb) * 1024 * 1024) + .div_ceil(WASM_PAGE_BYTES) + .clamp(1, u64::from(u32::MAX)) as u32; + let manifest = Manifest::new([Wasm::data(bytes)]) + .with_memory_max(max_pages) + .with_timeout(Duration::from_millis(config.timeout_ms)); + + // `wasi(false)` is the `PluginBuilder` default - stated explicitly + // here so this stays true even if that default ever changes + // upstream (ADR 0025 §6.F: no WASI imports, ever). + let compiled = PluginBuilder::new(manifest) + .with_wasi(false) + .with_fuel_limit(config.fuel_limit) + .compile() + .map_err(|source| PluginLoadError::Compile { + path: config.path.display().to_string(), + source, + })?; + + Ok(LoadedPlugin { + name: name.to_string(), + sha256: actual, + compiled, + }) + } + + /// Number of plugins currently loaded and available. + pub fn len(&self) -> usize { + self.plugins.len() + } + + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() + } + + /// Look up a loaded plugin by name (its `[auth] methods` entry). + pub fn get(&self, name: &str) -> Option<&LoadedPlugin> { + self.plugins.get(name) + } + + /// True if `name` was loaded and checksum-verified successfully. + pub fn contains(&self, name: &str) -> bool { + self.plugins.contains_key(name) + } +} + +// Minimal local hex-encoding helper so this crate doesn't need to pull in +// a dedicated `hex` dependency for a single call site. +mod hex { + pub fn encode(bytes: impl AsRef<[u8]>) -> String { + use std::fmt::Write; + bytes.as_ref().iter().fold(String::new(), |mut acc, b| { + let _ = write!(acc, "{:02x}", b); + acc + }) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + use std::path::Path; + + use openstack_keystone_config::PluginMode; + use sha2::{Digest, Sha256}; + use tempfile::NamedTempFile; + + use super::*; + + // The smallest valid WASM module: `(module)`. + const EMPTY_WASM: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + + fn write_wasm(bytes: &[u8]) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(bytes).unwrap(); + f.flush().unwrap(); + f + } + + fn config_for(path: &Path, sha256: String) -> DynamicPluginConfig { + // Constructed via a throwaway INI parse since `DynamicPluginConfig` + // has no public constructor by design (its fields are meant to + // come from `keystone.conf`, not be hand-built by application + // code). + use config::{Config, File, FileFormat}; + + #[derive(Debug, serde::Deserialize)] + struct Wrapper { + dynamic_plugin: HashMap, + } + + let ini = format!( + "[dynamic_plugin.p]\npath = {}\nsha256 = {}\nmode = full_auth\ncapabilities = provision_user\nprovision_domain_id = d\n", + path.display(), + sha256 + ); + let c = Config::builder() + .add_source(File::from_str(&ini, FileFormat::Ini)) + .build() + .unwrap(); + let wrapper: Wrapper = c.try_deserialize().unwrap(); + wrapper.dynamic_plugin.into_iter().next().unwrap().1 + } + + fn section(name: &str) -> DynamicPluginsSection { + DynamicPluginsSection { + plugins: vec![name.to_string()], + } + } + + #[test] + fn test_load_valid_plugin_succeeds() { + let f = write_wasm(EMPTY_WASM); + let sha256 = hex::encode(Sha256::digest(EMPTY_WASM)); + let config = config_for(f.path(), sha256.clone()); + assert_eq!(config.mode, PluginMode::FullAuth); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion("p"), &configs); + + assert!(errors.is_empty(), "unexpected load errors: {errors:?}"); + assert_eq!(registry.len(), 1); + assert!(registry.contains("p")); + assert_eq!(registry.get("p").unwrap().sha256, sha256); + } + + #[test] + fn test_checksum_mismatch_disables_only_that_plugin() { + let f = write_wasm(EMPTY_WASM); + // Deliberately wrong, but well-formed (64 hex chars), checksum. + let wrong_sha256 = "0".repeat(64); + let config = config_for(f.path(), wrong_sha256); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion("p"), &configs); + + assert!(registry.is_empty()); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].1, + PluginLoadError::ChecksumMismatch { .. } + )); + } + + #[test] + fn test_missing_file_disables_only_that_plugin() { + let config = config_for(Path::new("/nonexistent/path/plugin.wasm"), "0".repeat(64)); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion("p"), &configs); + + assert!(registry.is_empty()); + assert_eq!(errors.len(), 1); + assert!(matches!(errors[0].1, PluginLoadError::ReadFile { .. })); + } + + #[test] + fn test_multiple_plugins_one_bad_one_good() { + let good_file = write_wasm(EMPTY_WASM); + let good_sha256 = hex::encode(Sha256::digest(EMPTY_WASM)); + let good_config = config_for(good_file.path(), good_sha256); + let bad_config = config_for(Path::new("/nonexistent/plugin.wasm"), "0".repeat(64)); + + let mut configs = HashMap::new(); + configs.insert("good".to_string(), good_config); + configs.insert("bad".to_string(), bad_config); + let section = DynamicPluginsSection { + plugins: vec!["good".to_string(), "bad".to_string()], + }; + let (registry, errors) = WasmPluginRegistry::load(§ion, &configs); + + assert_eq!(registry.len(), 1); + assert!(registry.contains("good")); + assert!(!registry.contains("bad")); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].0, "bad"); + } +} diff --git a/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.lock b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.lock new file mode 100644 index 000000000..51b421676 --- /dev/null +++ b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.lock @@ -0,0 +1,379 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "extism-convert" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb" +dependencies = [ + "anyhow", + "base64", + "bytemuck", + "extism-convert-macros", + "prost", + "rmp-serde", + "serde", + "serde_json", +] + +[[package]] +name = "extism-convert-macros" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2932799f6d9f9646f97b65287f6bb2addc75a0ee61e40fb24559a7540dd928" +dependencies = [ + "manyhow", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "extism-manifest" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383" +dependencies = [ + "base64", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" +dependencies = [ + "anyhow", + "base64", + "extism-convert", + "extism-manifest", + "extism-pdk-derive", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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 = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[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.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reference-plugin" +version = "0.1.0" +dependencies = [ + "extism-pdk", + "serde", +] + +[[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 = "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_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", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.toml b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.toml new file mode 100644 index 000000000..48a45901c --- /dev/null +++ b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/Cargo.toml @@ -0,0 +1,25 @@ +# Standalone crate, deliberately NOT a member of the top-level workspace +# (see the `[workspace]` table below): it compiles to `wasm32-unknown- +# unknown` with a completely different dependency graph (the Extism guest +# PDK) than the rest of the repo, and must never affect the shipped +# `keystone` binary's dependency resolution. See +# `doc/src/adr/0025-implementation-plan.md` PR 0.3. +[workspace] + +[package] +name = "reference-plugin" +description = "ADR 0025 reference dynamic auth plugin, used only by openstack-keystone-dynamic-plugin-runtime's test suite" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +extism-pdk = "1.4" +serde = { version = "1.0", features = ["derive"] } + +[profile.release] +opt-level = "s" +lto = true diff --git a/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/README.md b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/README.md new file mode 100644 index 000000000..5d93b56a3 --- /dev/null +++ b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/README.md @@ -0,0 +1,38 @@ +# ADR 0025 reference dynamic auth plugin + +A minimal Rust plugin used only by `openstack-keystone-dynamic-plugin-runtime`'s +test suite (`../../reference_plugin.rs`) to prove the runtime crate can load, +checksum-verify, invoke, and resource-bound a real `wasm32-unknown-unknown` +module compiled with the [Extism Rust PDK](https://github.com/extism/rust-pdk). +It is **not** a production plugin - see +`doc/src/adr/0025-implementation-plan.md` PR 0.3. + +This crate is deliberately excluded from the top-level Cargo workspace (its +own `Cargo.toml` declares an empty `[workspace]` table) so its guest-side +dependency graph never affects the shipped `keystone` binary. + +## Building it yourself + +```sh +rustup target add wasm32-unknown-unknown +cargo build --release --target wasm32-unknown-unknown +``` + +produces `target/wasm32-unknown-unknown/release/reference_plugin.wasm`. The +test suite does exactly this itself before every run, so the fixture can +never drift out of sync with its source. + +## Writing your own plugin + +A third-party author following this same pattern needs only: + +- `extism-pdk` as a dependency (see `Cargo.toml`). +- `crate-type = ["cdylib"]` in `[lib]`. +- One `#[extism_pdk::plugin_fn]`-annotated function per guest entry point + your plugin's `mode` requires (`authenticate` for `full_auth`, `mapping` + for `mapping`, `route` for `route` - see ADR 0025 §4), each taking and + returning `extism_pdk::Json` for whatever `T` the entry point's + contract specifies. +- Compile for `wasm32-unknown-unknown` and record the SHA-256 of the + resulting `.wasm` file in the plugin's `[dynamic_plugin.]` + `sha256` config value. diff --git a/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs new file mode 100644 index 000000000..5695db9b3 --- /dev/null +++ b/crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs @@ -0,0 +1,106 @@ +//! ADR 0025 reference dynamic auth plugin. +//! +//! Minimal fixture used only by `openstack-keystone-dynamic-plugin-runtime`'s +//! test suite (see PR 0.3 in `doc/src/adr/0025-implementation-plan.md`). It +//! is not a production plugin and its wire shapes are intentionally simple +//! placeholders - Phase 1-3 of the implementation plan define the real +//! `AuthPluginRequest`/`AuthPluginResponse` etc. contracts these entry +//! points will grow into. +//! +//! A third-party plugin author following the same pattern needs only: +//! `extism-pdk` as a dependency, `crate-type = ["cdylib"]`, and to compile +//! for the `wasm32-unknown-unknown` target (`rustup target add +//! wasm32-unknown-unknown`, then `cargo build --release --target +//! wasm32-unknown-unknown`). +use extism_pdk::{plugin_fn, FnResult, Json}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct AuthRequest { + pub external_id: String, + #[serde(default)] + pub deny: bool, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub decision: &'static str, + pub external_id: String, +} + +/// Stand-in for the eventual `authenticate` guest entry point (`full_auth` +/// mode). Allows unless the caller asks to be denied, echoing back the +/// external id it was given - just enough behavior for Phase 0 tests to +/// prove a real Rust-compiled `.wasm` module loads and round-trips JSON +/// through Extism. +#[plugin_fn] +pub fn authenticate(req: Json) -> FnResult> { + let req = req.0; + let decision = if req.deny { "deny" } else { "allow" }; + Ok(Json(AuthResponse { + decision, + external_id: req.external_id, + })) +} + +#[derive(Debug, Deserialize)] +pub struct MappingRequest { + #[serde(default)] + pub deny: bool, +} + +#[derive(Debug, Serialize)] +pub struct MappingResponse { + pub decision: &'static str, +} + +/// Stand-in for the eventual `mapping` guest entry point (`mapping` mode). +#[plugin_fn] +pub fn mapping(req: Json) -> FnResult> { + let decision = if req.0.deny { "deny" } else { "claims" }; + Ok(Json(MappingResponse { decision })) +} + +#[derive(Debug, Deserialize)] +pub struct RouteRequest { + pub target_method: String, +} + +#[derive(Debug, Serialize)] +pub struct RouteResponse { + pub decision: &'static str, + pub target_method: String, +} + +/// Stand-in for the eventual `route` guest entry point (`route` mode). +#[plugin_fn] +pub fn route(req: Json) -> FnResult> { + Ok(Json(RouteResponse { + decision: "route", + target_method: req.0.target_method, + })) +} + +/// Resource-limit fixture (PR 0.4): burns CPU without ever returning, +/// tripping either the host's fuel limit or its wall-clock timeout +/// depending on which bound the caller configured tighter for a given +/// test - the loop body itself is identical either way. +#[plugin_fn] +pub fn spin() -> FnResult<()> { + let mut x: u64 = 0; + loop { + x = x.wrapping_add(1).wrapping_mul(2654435761); + std::hint::black_box(x); + } +} + +/// Resource-limit fixture (PR 0.4): grows a buffer without bound until the +/// guest allocator fails, tripping the host's `memory_limit_mb`. +#[plugin_fn] +pub fn allocate_memory() -> FnResult<()> { + let mut chunks: Vec> = Vec::new(); + loop { + chunks.push(vec![0xAAu8; 1024 * 1024]); + std::hint::black_box(&chunks); + } +} diff --git a/crates/dynamic-plugin-runtime/tests/reference_plugin.rs b/crates/dynamic-plugin-runtime/tests/reference_plugin.rs new file mode 100644 index 000000000..7c8eb5855 --- /dev/null +++ b/crates/dynamic-plugin-runtime/tests/reference_plugin.rs @@ -0,0 +1,204 @@ +//! Integration tests against the real reference plugin (ADR 0025 PR 0.3 / +//! PR 0.4), compiled to `wasm32-unknown-unknown` on the fly - see +//! `tests/fixtures/reference-plugin`. This is the "equivalent" of a +//! `cargo xtask build-test-plugin` step described in +//! `doc/src/adr/0025-implementation-plan.md`: rather than a separate CI +//! step that can drift out of sync with the fixture source, every run of +//! this test suite rebuilds the fixture itself (cargo no-ops if nothing +//! changed) and hashes the artifact it just produced, so a test can never +//! run against a stale `.wasm`/`sha256` pair. +#![cfg(test)] +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use config::{Config, File, FileFormat}; +use openstack_keystone_config::{DynamicPluginConfig, DynamicPluginsSection}; +use openstack_keystone_dynamic_plugin_runtime::{InvokeError, WasmPluginRegistry}; +use sha2::{Digest, Sha256}; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/reference-plugin") +} + +/// Builds the reference plugin for `wasm32-unknown-unknown` and returns the +/// path to the resulting `.wasm` artifact plus its SHA-256. +fn build_reference_plugin() -> (PathBuf, String) { + let dir = fixture_dir(); + let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string())) + .args(["build", "--release", "--target", "wasm32-unknown-unknown"]) + .current_dir(&dir) + .status() + .expect("failed to spawn cargo to build the reference plugin fixture"); + assert!( + status.success(), + "building tests/fixtures/reference-plugin for wasm32-unknown-unknown failed" + ); + + let wasm_path = dir.join("target/wasm32-unknown-unknown/release/reference_plugin.wasm"); + assert!( + wasm_path.is_file(), + "expected build artifact at {}", + wasm_path.display() + ); + let bytes = std::fs::read(&wasm_path).unwrap(); + let sha256 = { + use std::fmt::Write; + Sha256::digest(&bytes) + .iter() + .fold(String::new(), |mut acc, b| { + let _ = write!(acc, "{b:02x}"); + acc + }) + }; + (wasm_path, sha256) +} + +fn config_for(path: &Path, sha256: &str, extra: &str) -> DynamicPluginConfig { + #[derive(Debug, serde::Deserialize)] + struct Wrapper { + dynamic_plugin: HashMap, + } + + let ini = format!( + "[dynamic_plugin.p]\npath = {}\nsha256 = {}\nmode = full_auth\ncapabilities = provision_user\nprovision_domain_id = d\n{extra}\n", + path.display(), + sha256, + ); + let c = Config::builder() + .add_source(File::from_str(&ini, FileFormat::Ini)) + .build() + .unwrap(); + let wrapper: Wrapper = c.try_deserialize().unwrap(); + wrapper.dynamic_plugin.into_iter().next().unwrap().1 +} + +fn section() -> DynamicPluginsSection { + DynamicPluginsSection { + plugins: vec!["p".to_string()], + } +} + +#[test] +fn test_load_and_invoke_authenticate() { + let (path, sha256) = build_reference_plugin(); + let config = config_for(&path, &sha256, ""); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion(), &configs); + assert!(errors.is_empty(), "unexpected load errors: {errors:?}"); + + let plugin = registry.get("p").expect("plugin should have loaded"); + let out = plugin + .invoke("authenticate", br#"{"external_id":"alice","deny":false}"#) + .expect("authenticate call should succeed"); + let out: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(out["decision"], "allow"); + assert_eq!(out["external_id"], "alice"); + + let out = plugin + .invoke("authenticate", br#"{"external_id":"bob","deny":true}"#) + .expect("authenticate call should succeed"); + let out: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(out["decision"], "deny"); +} + +#[test] +fn test_fuel_exhaustion_fails_closed() { + let (path, sha256) = build_reference_plugin(); + // Generous timeout, tiny fuel budget: the `spin` export must exhaust + // fuel long before any wall-clock bound could plausibly fire. + let config = config_for(&path, &sha256, "timeout_ms = 60000\nfuel_limit = 100000\n"); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion(), &configs); + assert!(errors.is_empty(), "unexpected load errors: {errors:?}"); + + let plugin = registry.get("p").unwrap(); + let err = plugin + .invoke("spin", b"") + .expect_err("spin should not return"); + assert!( + matches!(err, InvokeError::FuelExhausted), + "expected FuelExhausted, got {err:?}" + ); +} + +#[test] +fn test_timeout_fails_closed() { + let (path, sha256) = build_reference_plugin(); + // Generous fuel budget, tiny timeout: the `spin` export must hit the + // wall-clock bound long before it could exhaust that much fuel. + let config = config_for( + &path, + &sha256, + "timeout_ms = 50\nfuel_limit = 500000000000\n", + ); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion(), &configs); + assert!(errors.is_empty(), "unexpected load errors: {errors:?}"); + + let plugin = registry.get("p").unwrap(); + let err = plugin + .invoke("spin", b"") + .expect_err("spin should not return"); + assert!( + matches!(err, InvokeError::Timeout), + "expected Timeout, got {err:?}" + ); +} + +#[test] +fn test_memory_limit_fails_closed() { + let (path, sha256) = build_reference_plugin(); + // Generous fuel/timeout, tiny memory cap: `allocate_memory` must trip + // the guest allocator well before either other bound. + let config = config_for( + &path, + &sha256, + "timeout_ms = 60000\nfuel_limit = 500000000000\nmemory_limit_mb = 1\n", + ); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion(), &configs); + assert!(errors.is_empty(), "unexpected load errors: {errors:?}"); + + let plugin = registry.get("p").unwrap(); + let err = plugin + .invoke("allocate_memory", b"") + .expect_err("allocate_memory should not return"); + assert!( + matches!(err, InvokeError::Trap(_)), + "expected Trap (guest allocator failure), got {err:?}" + ); +} + +#[test] +fn test_invocations_are_isolated_between_calls() { + let (path, sha256) = build_reference_plugin(); + let config = config_for(&path, &sha256, "timeout_ms = 60000\nfuel_limit = 100000\n"); + + let mut configs = HashMap::new(); + configs.insert("p".to_string(), config); + let (registry, errors) = WasmPluginRegistry::load(§ion(), &configs); + assert!(errors.is_empty(), "unexpected load errors: {errors:?}"); + let plugin = registry.get("p").unwrap(); + + // A call that exhausts its fuel budget must not poison subsequent + // calls against the same loaded (compiled) plugin - each invocation + // gets a fresh Store and so a fresh fuel budget (ADR §7). + assert!(matches!( + plugin.invoke("spin", b""), + Err(InvokeError::FuelExhausted) + )); + let out = plugin + .invoke("authenticate", br#"{"external_id":"carol","deny":false}"#) + .expect("a fresh invocation after a fuel-exhausted one should still succeed"); + let out: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(out["decision"], "allow"); +} diff --git a/doc/plans/0025-implementation-plan.md b/doc/plans/0025-implementation-plan.md new file mode 100644 index 000000000..e530db7af --- /dev/null +++ b/doc/plans/0025-implementation-plan.md @@ -0,0 +1,325 @@ +# ADR 0025 Implementation Plan: Dynamic Auth Plugins via WebAssembly + +This document breaks ADR 0025 (`0025-dynamic-auth-plugins.md`) into an +incremental, independently-mergeable sequence of PRs. It is a working plan, not +a design document — all design decisions live in the ADR itself; this file only +sequences the work and pins the implementation choices the ADR deliberately left +to an implementer (crate layout, test strategy, rollout order). + +## Ground rules for every phase + +- Each PR must build, pass `cargo test`/`cargo clippy` for the whole workspace, + and leave `main` in a releasable state — no phase depends on a later phase's + code existing, only on its own preceding PRs. +- No phase changes the public behavior of an existing, already-shipped auth + method. Everything is additive behind `[dynamic_plugins]` / + `[dynamic_plugin.*]` config sections that default to empty (no plugins + configured → zero behavioral change, zero new dependencies pulled into a + running node's request path). +- Every PR that adds a host-callable capability must land its CADF audit + wrapping (§6.E) in the _same_ PR — audit is infrastructure, not a follow-up, + per the ADR. No PR grants a capability before its audit event exists. + +## Decisions pinned for this plan + +(Selected in the interview that produced this document — recorded here so future +readers don't have to re-derive them.) + +| Decision | Choice | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Phasing strategy | Incremental, mode-by-mode: runtime + `full_auth` → `mapping` → `route` → admin APIs | +| Crate layout | New dedicated crate `crates/dynamic-plugin-runtime` (Extism/wasmtime isolated from `core`) | +| Test plugin | A minimal Rust reference plugin, built via the Extism Rust PDK, checked into the repo and compiled to `.wasm` in CI, used by integration tests | +| Admin APIs (`identity_links`, `revoke_all`) | Deferred to Phase 4, after `full_auth` self-provisioning is proven | +| This document's scope | Plan only — no scaffolding code included in this change | + +--- + +## Phase 0 — Runtime Foundation (no auth-method wiring yet) + +Goal: get Extism/wasmtime into the workspace, loading and validating plugin +config, with resource limits enforced, but not yet reachable from any auth +request. This isolates the highest-uncertainty new dependency (a WASM runtime +that has never existed in this codebase) from the auth-method logic that depends +on it. + +**New crate:** `crates/dynamic-plugin-runtime` +(`openstack-keystone-dynamic-plugin-runtime`) + +- Depends on `extism` (host SDK) and transitively `wasmtime`; depends on + `openstack-keystone-config` for its config types and + `openstack-keystone-core-types` for shared types (`ResolvedIdentityHandle`, + wire structs). Does **not** depend on `openstack-keystone-core` — keeps the + dependency direction one-way (core will depend on this crate, not vice versa), + matching the `*-driver-*` crate pattern already used for backends. + +### PR 0.1 — Crate skeleton + `[dynamic_plugins]` config parsing + +- `crates/dynamic-plugin-runtime/Cargo.toml`, empty `lib.rs`. +- `crates/config/src/dynamic_plugins.rs`: `DynamicPluginsConfig` (plugin name + list) + `DynamicPluginConfig` (per-plugin: `path`, `sha256`, `mode`, + `capabilities`, `exposed_headers`, `allowed_hosts`, + `http_fetch_auth_header`/`_secret_env`, `provision_domain_id` / + `allowed_provision_domains`, `assign_role_allowed`, `inspect_methods`, + `route_targets`, `timeout_ms`, `fuel_limit`, `memory_limit_mb`, + `invocation_rate_limit_per_source_per_minute`, + `invocation_rate_limit_per_minute`, `max_concurrent_invocations`), following + the `K8sAuthProvider` pattern (`serde::Deserialize` + `Default` + - `validator::Validate`). +- Validation errors implemented as **config-load-time** failures per the ADR's + fail-loud posture: reserved-name collisions (§4 "Reserved Auth-Method Names"), + `mode`-vs-`capabilities` mismatches (§4 capability-restriction rules for + `mapping`/`route`), a hard-denylisted header in `exposed_headers`, + `route_targets` containing `admin`/`trust`, a plugin granted neither + `provision_user` nor `find_user` in `full_auth` mode. +- Unit tests: one per validation rule (reject case) + one happy-path parse. +- **Acceptance:** `cargo test -p openstack-keystone-config` covers every + fail-loud rule in ADR §4/§5 with a dedicated test; no other crate touched. + +### PR 0.2 — Plugin loading, checksum verification, `WasmPluginRegistry` + +- `WasmPluginRegistry`: loads each configured plugin at startup, computes + SHA-256 of the file on disk, compares to the pinned `sha256`. On mismatch: log + `CRITICAL`, increment `keystone_dynamic_plugin_load_failure{plugin_name}`, + **do not** register that plugin, continue loading the rest (§5). On match: + compile once via `wasmtime`/`extism::Plugin` and cache the compiled module. +- No host functions registered yet in this PR — registry only proves load + + checksum + compile succeeds/fails correctly. +- Integration test: three fixture files (valid small `.wasm` — see PR 0.3 + reference plugin once it exists, or a placeholder empty-module `.wasm` for + this PR — missing file, tampered checksum) → assert registry state (loaded / + not loaded) and that the metric fires only in the mismatch case. +- **Acceptance:** a config with an intentionally wrong `sha256` starts the + process successfully with every _other_ method available, per ADR §5 + "Cross-node divergence is the trade-off... accepted explicitly." + +### PR 0.3 — Reference test plugin (Rust, Extism PDK) + +- New crate under e.g. + `crates/dynamic-plugin-runtime/tests/fixtures/reference-plugin` (or a + top-level `test-fixtures/` dir — keep it out of the release workspace member + list so it doesn't affect the shipped binary's dependency graph), implementing + `authenticate`, `mapping`, and `route` entry points behind compile-time + feature flags or three separate crates, whichever the Extism Rust PDK's + `#[plugin_fn]` macro makes cleaner as a single small crate exporting multiple + functions. +- Add a `build.rs`/CI step (Makefile or `xtask`) that compiles it to + `wasm32-unknown-unknown` (Extism's target) and computes its SHA-256 for test + fixtures, so tests never hand-maintain a stale hash. +- Document in the crate's README how a third-party plugin author would repeat + this (mirrors the ADR's stated goal of multi-language PDK support, though only + the Rust PDK is exercised by our own tests). +- **Acceptance:** `cargo xtask build-test-plugin` (or equivalent) produces a + `.wasm` artifact consumed by Phase 1+ integration tests; CI caches/ rebuilds + it as part of the normal test job. + +### PR 0.4 — Resource limits (fuel / wall-clock / memory) + isolation + +- Wire `fuel_limit`, `timeout_ms`, `memory_limit_mb` into the `Store` + construction per invocation (fresh `Store` per call, per ADR §7 "Isolation + between requests"). +- Tests: a fixture plugin function that spins (fuel exhaustion), one that sleeps + past `timeout_ms`, one that allocates past `memory_limit_mb` — each asserted + to fail closed with the right error variant, not panic/hang the test process. +- **Acceptance:** all three bounds independently provable to trigger via a + dedicated fixture export in the reference plugin (PR 0.3). + +--- + +## Phase 1 — `full_auth` Mode End-to-End + +Goal: a plugin can be configured as a real `[auth] methods` entry and +authenticate a request it provisions itself. This is the ADR's primary mechanism +and the only mode needed to satisfy requirements 1–3 from ADR §1. + +### PR 1.1 — Host functions A–D (capability-gated) + mandatory audit (§6.E) + +- Implement `http_fetch` (§6.A, including connect-time IP re-validation against + the resolved `IpAddr`, no-redirect-by-default, host-injected secrets), + `provision_user`/`find_user` (§6.B/C, namespace-scoped, atomic upsert on + `(plugin_name, external_id)`), `assign_role` (§6.D, three-axis scope + restriction). +- Each is only registered into a given plugin's `extism::Plugin` instance if + listed in that plugin's `capabilities` — implemented as conditional + registration, not a runtime permission check (ADR §6 opening paragraph). +- CADF audit wrapping (§6.E) as an inline, fail-closed `AuditHook` extension: + new `EventPayload` variant recording `plugin_name`, host function, outcome. +- New storage: `(plugin_name, external_id) -> user_id` mapping table + + per-`Store` handle map for `ResolvedIdentityHandle`. Confirm backend (likely + SQL, alongside other identity-adjacent tables — needs a migration in whichever + driver crate backs `IdentityBackend`). +- **Acceptance:** each host function has a unit test proving the + namespace-scoping/domain-restriction/role-scope invariant it's supposed to + enforce actually holds (e.g. `find_user` cannot resolve a handle for a + `user_id` provisioned by a different `plugin_name`), plus one test per + function proving the audit event fires on both success and failure. + +### PR 1.2 — `AuthenticationContext::WasmPlugin` + auth-method dispatch + +- Extend `AuthenticationContext` (`crates/core-types/src/auth.rs`) with the + `WasmPlugin { plugin_name, plugin_sha256, claims, token }` variant. +- Wire method-name resolution in `crates/core/src/api/auth.rs`: unmatched method + name → `WasmPluginRegistry` lookup → build `AuthPluginRequest` (payload, + `exposed_headers`-filtered headers with the hard denylist enforced at + config-load _and_ re-checked here defensively, trusted `remote_addr` only) → + invoke `authenticate` → map `Allow`/`Deny` to `ValidatedSecurityContext` via + the existing `new_for_scope()` pipeline. +- Enforce identity-binding validation (§4 "Identity Binding" steps 3–4): + reject + audit a `resolved_identity` handle that doesn't match this + invocation's issued handles. +- Response payload bounds (§7 "Response Payload Bounds"): size cap, claims + count/key/value caps, `plugin_claims..*` namespacing, rejection + of the reserved envelope key / `__keystone`-prefixed keys. +- **Acceptance:** an end-to-end integration test using the reference plugin (PR + 0.3) that: provisions a new user on first login, returns the same user on + second login (idempotency), denies on a bad handle, and confirms claims land + under `plugin_claims..*` only. + +### PR 1.3 — Rate limiting & concurrency (§7) + +- Per-source token bucket → per-plugin token bucket → concurrency semaphore, + using `governor` (already a workspace dependency), mirroring ADR 0020 §7.2's + two-tier pattern with the added source-scoped front tier. +- `remote_addr = None` fallback behavior (bounds 2/3 only) implemented and + tested explicitly, matching the ADR's documented residual gap. +- **Acceptance:** load-test-style unit tests proving each of the three bounds + independently rejects with `429`/audit `RateLimited` once exceeded, and that + one plugin's exhausted budget doesn't affect another plugin's. + +### PR 1.4 — Plugin Version Binding + token verification + +- Token minted via `WasmPlugin` embeds `plugin_sha256`; verification path + compares against the currently-loaded hash for that `plugin_name` and rejects + with `PluginVersionMismatch` on drift. +- **Acceptance:** integration test — mint a token, "patch" the plugin (swap the + loaded module + hash in a test harness), confirm the old token now fails + verification while a fresh login against the new plugin succeeds. + +**Phase 1 exit criteria:** a `mode = full_auth` plugin is usable end-to-end in a +real `[auth] methods` deployment: load, checksum-verify, rate-limit, +resource-bound, provision/find/assign, audit, mint a version-bound token, verify +it. This is independently shippable and useful without Phases 2–4. + +--- + +## Phase 2 — `mapping` Mode + +Goal: let a plugin feed the existing, already-reviewed Mapping Engine (ADR 0020) +instead of terminating authentication itself — the direct path to authenticating +SCIM-provisioned and other pre-existing users without new identity-binding +machinery. + +### PR 2.1 — `IdentitySource::WasmPlugin` + `mapping` entry point + +- `crates/core-types/src/mapping/resolution.rs`: add + `WasmPlugin { plugin_name }`. +- New guest entry point `mapping(request) -> MappingResponse` (`Claims`/`Deny`, + no `Allow`), gated so `mode = mapping` plugins never get A–D registered + (config-load-time error if they're granted, per PR 0.1's validation). +- Route `mapping`-mode plugin output into the existing evaluator under + `provider_id = "wasm:"`, unmodified. +- **Acceptance:** integration test — author a `MappingRuleSet` under + `wasm:` with an `IdentityMode::Local` rule, drive a login through + the reference plugin's `mapping` export, confirm it resolves to the + pre-existing local user and that a plugin with no ruleset authored gets + `MappingNotFound` (fail-closed by construction, §4 step 4). + +### PR 2.2 — `MappingContext.wasm_plugin_sha256` + verification + +- Add the optional field (ADR §4 "Plugin-version binding for `mapping` mode"), + populate on issuance, check alongside `ruleset_version` at verification. +- **Acceptance:** same drift test pattern as PR 1.4, applied to the mapping + path. + +**Phase 2 exit criteria:** `mapping` mode fully covers the SCIM/pre-existing- +user login case without touching `full_auth`'s identity-binding code at all. + +--- + +## Phase 3 — `route` Mode + +Goal: pre-dispatch request routing for clients that can't send a custom method +name (Terraform `application_credential` case). + +### PR 3.1 — `route` entry point + host-side dispatch rewrite + +- `route(request: RouteRequest) -> RouteResponse` guest contract. +- Host-side: `inspect_methods` trigger scoping (invoke only when + `identity.methods` intersects it), `route_targets` allowlist enforcement + (malformed-response rejection on off-allowlist target, not correction), + scope-immutability (no `scope` field on `RouteResponse` at the type level), + single-shot flag on the rewritten request (internal, non-guest- settable). +- Independent rate-limit/concurrency budget from the target method (reuse PR + 1.3's bucket implementation, instantiated per-router). +- Audit: originally-requested method list + decision + resulting `target_method` + recorded distinctly from the eventually-dispatched method's own audit trail + (§4 "Audit"). +- **Acceptance:** integration test — reference plugin's `route` export + configured with `inspect_methods = application_credential`, prove: (a) + `password` requests never invoke it, (b) a `Route` response correctly + redispatches to an allowlisted target and that target still independently + verifies the payload, (c) a `Route` naming a non-allowlisted target is + rejected as malformed, (d) a request already routed once cannot be routed + again, (e) `Deny`/timeout/trap fails closed without falling through to the + original method. + +**Phase 3 exit criteria:** all three modes from ADR §4 are implemented and +independently tested. + +--- + +## Phase 4 — Admin APIs & Bulk Revocation + +Goal: admin-authorized external identity linking (the `full_auth` path to +pre-existing users the ADR frames as required for the SCIM full-authority case) +and incident-response tooling. + +### PR 4.1 — `POST/DELETE /v4/dynamic_plugins/{plugin_name}/identity_links` + +- RBAC-tiered per ADR §4 (system-admin if target holds system-scope; + domain-admin scoped to target's own domain otherwise), enforces the plugin's + `provision_domain_id`/`allowed_provision_domains` against the target user's + domain, `409` on re-link without prior `DELETE`. +- `{scim_provider_id, scim_external_id}` convenience form resolving via the + existing ADR 0024 §3.B index. +- `find_user` (PR 1.1) updated to re-validate live `domain_id` on every + resolution for admin-linked entries (§4 "Domain restriction is re-checked at + resolve time"). +- `DELETE` triggers existing token-revocation pipeline for the unlinked user. +- **Acceptance:** integration tests for the domain-move-revokes-reach case, the + `409` conflict case, and the SCIM-convenience resolution path. + +### PR 4.2 — `POST /v4/dynamic_plugins/{plugin_name}/revoke_all` + +- System-admin only; disables provisioned users, revokes plugin-granted role + assignments individually, deletes `identity_links` entries, triggers token + revocation for every affected user; returns per-category counts; idempotent + no-op on a plugin with no remaining state. +- **Acceptance:** integration test proving a provisioned user with an unrelated + (non-plugin) role assignment keeps that assignment after `revoke_all`, and + that re-running the endpoint twice is safe. + +**Phase 4 exit criteria:** full ADR 0025 scope implemented, including +incident-response tooling. + +--- + +## Cross-cutting, tracked but not blocking any phase + +- **Documentation:** operator-facing docs for `[dynamic_plugins]` config, a + "writing your first plugin" guide referencing the reference plugin (PR 0.3) + and the Extism PDK, added once Phase 1 ships (real, usable guidance) rather + than speculatively earlier. +- **Metrics/alerting wiring:** `keystone_dynamic_plugin_load_failure` (PR 0.2) + and rate-limit counters (PR 1.3) should get dashboard/alert examples in ops + docs once Phase 1 ships — not a blocking code change, tracked as a follow-up. +- **Fuzzing:** `AuthPluginResponse`/`RouteResponse` deserialization (attacker- + shaped guest output, §7 "Response Payload Bounds") is a good `cargo-fuzz` + target once Phase 1/3 land; not required to ship either phase. + +## Explicitly out of scope for this plan (per ADR §8) + +Per-domain plugin scoping, hot reload/upload API, signing beyond SHA-256, secret +rotation without restart, and the "reinstate only one non-vulnerable version's +state" gap in `revoke_all` are all ADR-documented future work, not part of this +implementation plan.