commit b1865a06273e8928151845b862865e05dc446983c6b15e5686bbc4cabbdabeef Author: Tyler King Date: Wed Mar 18 16:40:48 2026 -0400 initial: bascule v0.1.0 Bascule shell runtime workspace — governed shell access layer for Substrate/Guildhouse FFC deployments. Crates: - bascule-agent: node agent with SSH server + command filtering - bascule-core: audit, grant engine, ceremony types, session - bascule-filter-core: log line filtering (stdio protocol) - bascule-gateway: OIDC auth, session management, SAT validation - bascule-node-agent: k8s DaemonSet agent (pod watcher, BPF manager) - bascule-proto: protobuf definitions - bascule-shell: governed SSH shell (commands, elevation, REPL) - bascule-tail: chronicle log tail + fanout - ceremony-engine: ceremony lifecycle (6 types + request/resolution) 172 tests passing. Implements SBS-SPEC-0001 shell model. Reference impl for SPEC-SHELLOPS-0001 Layer 1 (root shell). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73bb5d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target/ +**/target/ +**/*.rs.bk +.env +*.swp +*.swo diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5eae2ef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5706 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accord-core" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_yaml", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "accord-opa" +version = "0.1.0" +dependencies = [ + "accord-core", + "chrono", + "hex", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.17", + "instant", + "rand", +] + +[[package]] +name = "bascule-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bascule-core", + "chrono", + "clap", + "dashmap", + "hex", + "hfl-types", + "jsonwebtoken", + "rand", + "reqwest", + "rmp-serde", + "rmpv", + "russh", + "russh-keys", + "serde", + "serde_json", + "sha2", + "ssh-key", + "substrate-rt", + "tempfile", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "bascule-core" +version = "0.1.0" +dependencies = [ + "accord-core", + "async-trait", + "ceremony-engine", + "chrono", + "hex", + "registry-protocol", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "bascule-filter-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "bascule-gateway" +version = "0.1.0" +dependencies = [ + "accord-core", + "accord-opa", + "anyhow", + "async-trait", + "axum 0.8.8", + "bascule-core", + "bascule-proto", + "chrono", + "config", + "dashmap", + "hex", + "jsonwebtoken", + "k8s-openapi", + "kube", + "prost", + "prost-types", + "qm-core", + "reqwest", + "rustls", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tower 0.5.3", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "bascule-node-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "futures", + "k8s-openapi", + "kube", + "reqwest", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "bascule-proto" +version = "0.1.0" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-build", +] + +[[package]] +name = "bascule-shell" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bascule-core", + "bascule-proto", + "chrono", + "clap", + "dirs", + "guildhouse-proto", + "rand", + "registry-protocol", + "reqwest", + "russh", + "russh-keys", + "rustls", + "serde", + "serde_json", + "serde_yaml", + "ssh-key", + "thiserror 2.0.18", + "tokio", + "tonic", + "tracing", + "tracing-subscriber", + "uuid", + "which", +] + +[[package]] +name = "bascule-tail" +version = "0.1.0" +dependencies = [ + "anyhow", + "bascule-filter-core", + "bascule-proto", + "clap", + "prost-types", + "regex", + "serde_json", + "tokio", + "tonic", + "tracing", + "tracing-subscriber", + "which", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "ceremony-engine" +version = "0.1.0" +dependencies = [ + "accord-core", + "async-trait", + "chrono", + "hex", + "registry-protocol", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "guildhouse-proto" +version = "0.1.0" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-build", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hfl-types" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs 0.8.3", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kube" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32053dc495efad4d188c7b33cc7c02ef4a6e43038115348348876efd39a53cba" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d34ad38cdfbd1fa87195d42569f57bb1dda6ba5f260ee32fef9570b7937a0c9" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "rustls-pemfile", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97aa830b288a178a90e784d1b0f1539f2d200d2188c7b4a3146d9dc983d596f3" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "json-patch", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-derive" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37745d8a4076b77e0b1952e94e358726866c8e14ec94baaca677d47dcdb98658" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "kube-runtime" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a41af186a0fe80c71a13a13994abdc3ebff80859ca6a4b8a6079948328c135b" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "async-trait", + "backoff", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "jsonptr", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + +[[package]] +name = "pageant" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032d6201d2fb765158455ae0d5a510c016bb6da7232e5040e39e9c8db12b0afc" +dependencies = [ + "bytes", + "delegate", + "futures", + "rand", + "thiserror 1.0.69", + "tokio", + "windows", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.13.0", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-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.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "qm-core" +version = "0.1.0" +dependencies = [ + "argon2", + "async-trait", + "base64 0.22.1", + "chrono", + "hex", + "rand", + "registry-protocol", + "secrecy", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "registry-protocol" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "hex", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags", + "serde", + "serde_derive", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b206640a622d63529540fc48036aa39f211b2b71432a451156004f40655cdb4" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags", + "byteorder", + "bytes", + "cbc", + "chacha20", + "ctr", + "curve25519-dalek", + "delegate", + "des", + "digest", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand", + "rand_core", + "rsa", + "russh-cryptovec", + "russh-keys", + "russh-sftp", + "russh-util", + "sha1", + "sha2", + "signature", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh-cryptovec" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d8e7e854e1a87e4be00fa287c98cad23faa064d0464434beaa9f014ec3baa98" +dependencies = [ + "libc", + "ssh-encoding", + "winapi", +] + +[[package]] +name = "russh-keys" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788a2439ce385856585346beb37c48e7c9eb5de5f4f00736720a19ffdb3f5bb5" +dependencies = [ + "aes", + "async-trait", + "bcrypt-pbkdf", + "block-padding", + "byteorder", + "bytes", + "cbc", + "ctr", + "data-encoding", + "der", + "digest", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "futures", + "getrandom 0.2.17", + "hmac", + "home", + "inout", + "log", + "md5", + "num-integer", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "rand", + "rand_core", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", + "serde", + "sha1", + "sha2", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-sftp" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" +dependencies = [ + "bitflags", + "bytes", + "chrono", + "flurry", + "log", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "russh-util" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c7dd577958c0cefbc8f8a2c05c48c88c42e2fdb760dbe9b96ae31d4de97a1f" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[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-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap 2.13.0", + "log", + "memchr", + "native-tls", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "substrate-chronicle" +version = "0.1.0" +dependencies = [ + "serde", + "sha2", +] + +[[package]] +name = "substrate-hfl" +version = "0.1.0" +dependencies = [ + "bitflags", + "nix", + "thiserror 2.0.18", +] + +[[package]] +name = "substrate-rt" +version = "0.1.0" +dependencies = [ + "async-trait", + "hfl-types", + "hmac", + "rmp-serde", + "rmpv", + "serde", + "serde_json", + "sha2", + "substrate-chronicle", + "substrate-hfl", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64 0.22.1", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4edd7a7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,86 @@ +[workspace] +resolver = "2" +members = [ + "bascule-proto", + "bascule-filter-core", + "bascule-node-agent", + "bascule-shell", + "bascule-tail", + "bascule-agent", + "bascule-core", + "bascule-gateway", + "ceremony-engine", +] + +[workspace.dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } +async-trait = "0.1" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_json_canonicalizer = "0.3" +serde_yaml = "0.9" + +# gRPC +tonic = "0.12" +tonic-build = "0.12" +prost = "0.13" +prost-types = "0.13" + +# Kubernetes +kube = { version = "0.98", features = ["runtime", "derive"] } +k8s-openapi = { version = "0.24", features = ["latest"] } +schemars = "0.8" + +# TLS +rustls = { version = "0.23", features = ["ring"] } + +# Crypto +sha2 = "0.10" +hmac = "0.12" + +# Observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# Common +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +thiserror = "2" +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +reqwest = { version = "0.12", features = ["json"] } +dashmap = "6" +regex = "1" +which = "7" +dirs = "6" +rand = "0.8" +hex = "0.4" +jsonwebtoken = "9" + +# SSH +russh = "0.49" +russh-keys = "0.49" +ssh-key = { version = "0.6", features = ["ed25519", "rand_core"] } + +# Database +sqlx = { version = "0.8", features = [ + "runtime-tokio", + "tls-native-tls", + "postgres", +] } + +# HTTP +axum = "0.8" +tower-http = { version = "0.6", features = ["trace"] } +config = "0.14" +tokio-stream = "0.1" +tempfile = "3" + +# Internal crate deps +bascule-filter-core = { path = "./bascule-filter-core" } +bascule-core = { path = "./bascule-core" } +bascule-proto = { path = "./bascule-proto" } +ceremony-engine = { path = "./ceremony-engine" } diff --git a/bascule-agent/Cargo.toml b/bascule-agent/Cargo.toml new file mode 100644 index 0000000..4a53f4e --- /dev/null +++ b/bascule-agent/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "bascule-agent" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "bascule-agent" +path = "src/main.rs" + +[[bin]] +name = "sb" +path = "src/bin/sb.rs" + +[dependencies] +bascule-core = { workspace = true } + +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +sha2 = { workspace = true } +jsonwebtoken = { workspace = true } +reqwest = { workspace = true } +dashmap = { workspace = true } +async-trait = { workspace = true } + +# Cross-workspace path deps — substrate crates +substrate-rt = { path = "../../substrate/crates/substrate-rt" } +hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] } + +# Msgpack — retained for convenience constructors and legacy decode paths +rmp-serde = "1" +rmpv = { version = "1", features = ["with-serde"] } + +# Config file parsing +toml = "0.8" + +# CLI +clap = { workspace = true } + +hex = { workspace = true } + +# SSH server +russh = { workspace = true } +russh-keys = { workspace = true } +ssh-key = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/bascule-agent/Dockerfile b/bascule-agent/Dockerfile new file mode 100644 index 0000000..294e55e --- /dev/null +++ b/bascule-agent/Dockerfile @@ -0,0 +1,33 @@ +# Multi-stage build for bascule-agent +# Stage 1: Build +FROM rust:latest AS builder + +WORKDIR /build +COPY . . + +ENV SQLX_OFFLINE=true +RUN cd services && cargo build --release -p bascule-agent + +# Stage 2: Runtime +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create substrate user and directories +RUN groupadd -r substrate && useradd -r -g substrate substrate && \ + mkdir -p /var/run/substrate /etc/substrate && \ + chown substrate:substrate /var/run/substrate + +COPY --from=builder /build/services/target/release/bascule-agent /usr/local/bin/bascule-agent + +# Default config +COPY services/bascule-agent/tests/e2e-config.toml /etc/substrate/shell.toml + +USER substrate + +EXPOSE 2222 + +ENTRYPOINT ["bascule-agent"] +CMD ["--config", "/etc/substrate/shell.toml"] diff --git a/bascule-agent/src/bin/sb.rs b/bascule-agent/src/bin/sb.rs new file mode 100644 index 0000000..3331f24 --- /dev/null +++ b/bascule-agent/src/bin/sb.rs @@ -0,0 +1,93 @@ +//! sb — Substrate CLI +//! +//! Currently supports: +//! sb shell [--host HOST] [--port PORT] [--user USER] +//! +//! Connects to the bascule-agent's governed SSH shell. + +use std::process::Command; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "sb", about = "Substrate CLI", version)] +struct Cli { + #[command(subcommand)] + command: SubCmd, +} + +#[derive(Subcommand)] +enum SubCmd { + /// Connect to the bascule-agent governed shell via SSH. + Shell { + /// SSH host to connect to. + #[arg(long, default_value = "localhost")] + host: String, + + /// SSH port. + #[arg(short, long, default_value = "2222")] + port: u16, + + /// SSH username. + #[arg(short, long, default_value_t = whoami())] + user: String, + + /// SSH identity file (private key). + #[arg(short, long)] + identity: Option, + }, + + /// Show agent status via IPC socket. + Status { + /// Path to the agent Unix socket. + #[arg(long, default_value = "/var/run/substrate/agent.sock")] + socket: String, + }, +} + +fn whoami() -> String { + std::env::var("USER").unwrap_or_else(|_| "substrate".to_string()) +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + match cli.command { + SubCmd::Shell { + host, + port, + user, + identity, + } => { + let mut cmd = Command::new("ssh"); + cmd.arg("-p").arg(port.to_string()); + cmd.arg("-o").arg("StrictHostKeyChecking=no"); + cmd.arg("-o").arg("UserKnownHostsFile=/dev/null"); + cmd.arg("-o").arg("LogLevel=ERROR"); + + if let Some(ref key) = identity { + cmd.arg("-i").arg(key); + } + + cmd.arg(format!("{user}@{host}")); + + eprintln!("Connecting to bascule-agent shell at {host}:{port}..."); + + let status = cmd.status()?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + } + + SubCmd::Status { socket } => { + if std::path::Path::new(&socket).exists() { + println!("Agent socket: {} (active)", socket); + } else { + println!("Agent socket: {} (not found)", socket); + std::process::exit(1); + } + } + } + + Ok(()) +} diff --git a/bascule-agent/src/command_filter.rs b/bascule-agent/src/command_filter.rs new file mode 100644 index 0000000..7ef9c1a --- /dev/null +++ b/bascule-agent/src/command_filter.rs @@ -0,0 +1,206 @@ +//! Command filter — restricts shell commands based on scope/trust tier. +//! +//! The filter defines which namespace commands are accessible at each +//! trust tier. In soft/dev mode, all commands are allowed. In live mode, +//! the filter enforces the trust tier's capability set. + +use std::collections::HashSet; + +/// Trust tier for an authenticated operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TrustTier { + Apprentice, + Journeyman, + Master, + SiteOwner, +} + +impl TrustTier { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "apprentice" => Some(Self::Apprentice), + "journeyman" => Some(Self::Journeyman), + "master" => Some(Self::Master), + "site_owner" | "siteowner" | "owner" => Some(Self::SiteOwner), + _ => None, + } + } + + pub fn name(&self) -> &'static str { + match self { + Self::Apprentice => "apprentice", + Self::Journeyman => "journeyman", + Self::Master => "master", + Self::SiteOwner => "site_owner", + } + } +} + +/// Capability flags. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Capability { + Read, + Propose, + Mutate, +} + +/// Command filter that maps trust tiers to allowed commands. +pub struct CommandFilter { + dev_mode: bool, + /// Commands allowed for each tier (namespace::function format). + tier_allowlists: Vec<(TrustTier, HashSet)>, +} + +impl CommandFilter { + pub fn new(dev_mode: bool) -> Self { + let mut filter = Self { + dev_mode, + tier_allowlists: Vec::new(), + }; + + if !dev_mode { + filter.build_default_allowlists(); + } + + filter + } + + fn build_default_allowlists(&mut self) { + // Apprentice: READ only + let mut apprentice = HashSet::new(); + apprentice.insert("attestation::posture".into()); + apprentice.insert("audit::query".into()); + apprentice.insert("identity::authenticate".into()); + apprentice.insert("identity::resolve".into()); + apprentice.insert("help".into()); + apprentice.insert("status".into()); + apprentice.insert("whoami".into()); + + // Journeyman: READ + PROPOSE + let mut journeyman = apprentice.clone(); + journeyman.insert("governance::gate".into()); + journeyman.insert("governance::propose".into()); + journeyman.insert("audit::emit".into()); + + // Master: READ + PROPOSE + MUTATE (via approval) + let mut master = journeyman.clone(); + master.insert("crypto::sign".into()); + master.insert("crypto::verify".into()); + master.insert("crypto::hash".into()); + master.insert("secrets::get".into()); + master.insert("governance::evaluate".into()); + master.insert("audit::anchor".into()); + master.insert("attestation::sat".into()); + + // Site Owner: everything + let mut site_owner = master.clone(); + site_owner.insert("secrets::put".into()); + site_owner.insert("secrets::rotate".into()); + site_owner.insert("identity::authorize".into()); + site_owner.insert("attestation::verify".into()); + + self.tier_allowlists = vec![ + (TrustTier::Apprentice, apprentice), + (TrustTier::Journeyman, journeyman), + (TrustTier::Master, master), + (TrustTier::SiteOwner, site_owner), + ]; + } + + /// Check if a command is allowed for the given trust tier. + pub fn is_allowed(&self, command: &str, tier: TrustTier) -> bool { + if self.dev_mode { + return true; + } + + // Shell builtins always allowed + if matches!(command, "help" | "status" | "whoami" | "exit" | "quit") { + return true; + } + + for (t, allowlist) in &self.tier_allowlists { + if *t == tier { + return allowlist.contains(command); + } + } + + false + } + + /// Get capabilities for a trust tier. + pub fn capabilities(tier: TrustTier) -> Vec { + match tier { + TrustTier::Apprentice => vec![Capability::Read], + TrustTier::Journeyman => vec![Capability::Read, Capability::Propose], + TrustTier::Master => vec![Capability::Read, Capability::Propose, Capability::Mutate], + TrustTier::SiteOwner => vec![Capability::Read, Capability::Propose, Capability::Mutate], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dev_mode_allows_all() { + let filter = CommandFilter::new(true); + assert!(filter.is_allowed("secrets::put", TrustTier::Apprentice)); + assert!(filter.is_allowed("anything", TrustTier::Apprentice)); + } + + #[test] + fn test_live_mode_apprentice_restricted() { + let filter = CommandFilter::new(false); + assert!(filter.is_allowed("attestation::posture", TrustTier::Apprentice)); + assert!(filter.is_allowed("help", TrustTier::Apprentice)); + assert!(!filter.is_allowed("secrets::get", TrustTier::Apprentice)); + assert!(!filter.is_allowed("crypto::sign", TrustTier::Apprentice)); + } + + #[test] + fn test_live_mode_master_has_crypto() { + let filter = CommandFilter::new(false); + assert!(filter.is_allowed("crypto::sign", TrustTier::Master)); + assert!(filter.is_allowed("secrets::get", TrustTier::Master)); + assert!(!filter.is_allowed("secrets::put", TrustTier::Master)); + } + + #[test] + fn test_live_mode_site_owner_has_all() { + let filter = CommandFilter::new(false); + assert!(filter.is_allowed("secrets::put", TrustTier::SiteOwner)); + assert!(filter.is_allowed("secrets::rotate", TrustTier::SiteOwner)); + assert!(filter.is_allowed("identity::authorize", TrustTier::SiteOwner)); + } + + #[test] + fn test_builtins_always_allowed() { + let filter = CommandFilter::new(false); + for tier in [TrustTier::Apprentice, TrustTier::Journeyman, TrustTier::Master, TrustTier::SiteOwner] { + assert!(filter.is_allowed("help", tier)); + assert!(filter.is_allowed("status", tier)); + assert!(filter.is_allowed("whoami", tier)); + assert!(filter.is_allowed("exit", tier)); + } + } + + #[test] + fn test_trust_tier_from_str() { + assert_eq!(TrustTier::from_str("apprentice"), Some(TrustTier::Apprentice)); + assert_eq!(TrustTier::from_str("MASTER"), Some(TrustTier::Master)); + assert_eq!(TrustTier::from_str("site_owner"), Some(TrustTier::SiteOwner)); + assert_eq!(TrustTier::from_str("owner"), Some(TrustTier::SiteOwner)); + assert_eq!(TrustTier::from_str("invalid"), None); + } + + #[test] + fn test_tier_capabilities() { + let caps = CommandFilter::capabilities(TrustTier::Apprentice); + assert_eq!(caps.len(), 1); + assert!(caps.contains(&Capability::Read)); + + let caps = CommandFilter::capabilities(TrustTier::Master); + assert_eq!(caps.len(), 3); + } +} diff --git a/bascule-agent/src/config.rs b/bascule-agent/src/config.rs new file mode 100644 index 0000000..f1d8618 --- /dev/null +++ b/bascule-agent/src/config.rs @@ -0,0 +1,456 @@ +//! Configuration for bascule-agent, loaded from shell.toml. + +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentConfig { + #[serde(default)] + pub agent: AgentSection, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentSection { + #[serde(default = "default_socket_path")] + pub socket_path: String, + + #[serde(default = "default_socket_permissions")] + pub socket_permissions: String, + + #[serde(default)] + pub ssh: SshConfig, + + #[serde(default)] + pub identity: IdentityConfig, + + #[serde(default)] + pub audit: AuditConfig, + + #[serde(default)] + pub namespaces: NamespaceConfig, + + #[serde(default)] + pub session: SessionConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SshConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default = "default_ssh_listen")] + pub listen_addr: String, + + #[serde(default)] + pub host_key_path: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IdentityConfig { + #[serde(default)] + pub keycloak_url: String, + + #[serde(default = "default_realm")] + pub realm: String, + + #[serde(default)] + pub dev_mode: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AuditConfig { + #[serde(default = "default_audit_backend")] + pub backend: String, + + #[serde(default)] + pub log_path: Option, + + #[serde(default = "default_batch_size")] + pub batch_size: usize, + + #[serde(default = "default_flush_interval")] + pub flush_interval_ms: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NamespaceConfig { + #[serde(default = "default_backend")] + pub backend: String, + + #[serde(default)] + pub identity: IdentityNamespaceConfig, + + #[serde(default)] + pub crypto: CryptoNamespaceConfig, + + #[serde(default)] + pub audit: AuditNamespaceConfig, + + #[serde(default)] + pub governance: GovernanceNamespaceConfig, + + #[serde(default)] + pub secrets: SecretsNamespaceConfig, + + #[serde(default)] + pub attestation: AttestationNamespaceConfig, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct IdentityNamespaceConfig { + #[serde(default)] + pub keycloak_introspect: bool, + + #[serde(default = "default_token_cache_ttl")] + pub token_cache_ttl_seconds: u64, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct CryptoNamespaceConfig { + #[serde(default = "default_key_source")] + pub key_source: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AuditNamespaceConfig { + #[serde(default = "default_merkle_algorithm")] + pub merkle_algorithm: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct GovernanceNamespaceConfig { + #[serde(default = "default_policy_engine")] + pub policy_engine: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SecretsNamespaceConfig { + #[serde(default = "default_secrets_backend")] + pub backend: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AttestationNamespaceConfig { + #[serde(default = "default_posture_source")] + pub posture_source: String, + + #[serde(default = "default_posture_level")] + pub default_posture: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SessionConfig { + #[serde(default = "default_scope")] + pub default_scope: String, + + #[serde(default = "default_ttl_hours")] + pub default_ttl_hours: u64, + + #[serde(default = "default_mutation_budget")] + pub default_mutation_budget: u32, + + #[serde(default)] + pub motd_template: Option, +} + +// Default functions +fn default_socket_path() -> String { + "/var/run/substrate/agent.sock".into() +} +fn default_socket_permissions() -> String { + "0660".into() +} +fn default_ssh_listen() -> String { + "0.0.0.0:2222".into() +} +fn default_realm() -> String { + "substrate".into() +} +fn default_audit_backend() -> String { + "log".into() +} +fn default_batch_size() -> usize { + 100 +} +fn default_flush_interval() -> u64 { + 1000 +} +fn default_backend() -> String { + "soft".into() +} +fn default_token_cache_ttl() -> u64 { + 300 +} +fn default_key_source() -> String { + "env".into() +} +fn default_merkle_algorithm() -> String { + "sha256".into() +} +fn default_policy_engine() -> String { + "scope".into() +} +fn default_secrets_backend() -> String { + "env".into() +} +fn default_posture_source() -> String { + "config".into() +} +fn default_posture_level() -> String { + "standard".into() +} +fn default_scope() -> String { + "operate".into() +} +fn default_ttl_hours() -> u64 { + 4 +} +fn default_mutation_budget() -> u32 { + 50 +} + +impl Default for AgentSection { + fn default() -> Self { + Self { + socket_path: default_socket_path(), + socket_permissions: default_socket_permissions(), + ssh: SshConfig::default(), + identity: IdentityConfig::default(), + audit: AuditConfig::default(), + namespaces: NamespaceConfig::default(), + session: SessionConfig::default(), + } + } +} + +impl Default for SshConfig { + fn default() -> Self { + Self { + enabled: false, + listen_addr: default_ssh_listen(), + host_key_path: None, + } + } +} + +impl Default for IdentityConfig { + fn default() -> Self { + Self { + keycloak_url: String::new(), + realm: default_realm(), + dev_mode: false, + } + } +} + +impl Default for AuditConfig { + fn default() -> Self { + Self { + backend: default_audit_backend(), + log_path: None, + batch_size: default_batch_size(), + flush_interval_ms: default_flush_interval(), + } + } +} + +impl Default for NamespaceConfig { + fn default() -> Self { + Self { + backend: default_backend(), + identity: Default::default(), + crypto: Default::default(), + audit: Default::default(), + governance: Default::default(), + secrets: Default::default(), + attestation: Default::default(), + } + } +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + default_scope: default_scope(), + default_ttl_hours: default_ttl_hours(), + default_mutation_budget: default_mutation_budget(), + motd_template: None, + } + } +} + +impl AgentConfig { + /// Load from a TOML file, substituting environment variables. + pub fn load(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + // Simple env var substitution: ${VAR_NAME} or ${VAR_NAME:default} + let expanded = expand_env_vars(&content); + let config: AgentConfig = toml::from_str(&expanded)?; + Ok(config) + } + + /// Create with all defaults (for testing or dev mode). + pub fn default_config() -> Self { + Self { + agent: AgentSection::default(), + } + } +} + +/// Expand `${VAR}` and `${VAR:default}` patterns in a string. +fn expand_env_vars(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '$' && chars.peek() == Some(&'{') { + chars.next(); // consume '{' + let mut var_expr = String::new(); + for c in chars.by_ref() { + if c == '}' { + break; + } + var_expr.push(c); + } + // Split on ':' for default value + if let Some((var_name, default)) = var_expr.split_once(':') { + match std::env::var(var_name) { + Ok(val) if !val.is_empty() => result.push_str(&val), + _ => result.push_str(default), + } + } else { + match std::env::var(&var_expr) { + Ok(val) => result.push_str(&val), + Err(_) => {} // empty string if not set + } + } + } else { + result.push(c); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = AgentConfig::default_config(); + assert_eq!(config.agent.socket_path, "/var/run/substrate/agent.sock"); + assert_eq!(config.agent.socket_permissions, "0660"); + assert!(!config.agent.ssh.enabled); + assert_eq!(config.agent.ssh.listen_addr, "0.0.0.0:2222"); + assert_eq!(config.agent.identity.realm, "substrate"); + assert!(!config.agent.identity.dev_mode); + assert_eq!(config.agent.audit.backend, "log"); + assert_eq!(config.agent.audit.batch_size, 100); + assert_eq!(config.agent.namespaces.backend, "soft"); + assert_eq!(config.agent.session.default_scope, "operate"); + assert_eq!(config.agent.session.default_ttl_hours, 4); + assert_eq!(config.agent.session.default_mutation_budget, 50); + } + + #[test] + fn test_parse_minimal_toml() { + let toml = r#" +[agent] +socket_path = "/tmp/test.sock" +"#; + let config: AgentConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.agent.socket_path, "/tmp/test.sock"); + // All other fields should have defaults + assert_eq!(config.agent.audit.backend, "log"); + } + + #[test] + fn test_parse_full_toml() { + let toml = r#" +[agent] +socket_path = "/var/run/substrate/agent.sock" +socket_permissions = "0660" + +[agent.ssh] +enabled = true +listen_addr = "0.0.0.0:2222" +host_key_path = "/etc/substrate/ssh_host_ed25519_key" + +[agent.identity] +keycloak_url = "https://auth.guildhouse.dev" +realm = "substrate" +dev_mode = false + +[agent.audit] +backend = "log" +log_path = "/var/log/substrate/audit.json" +batch_size = 100 +flush_interval_ms = 1000 + +[agent.namespaces] +backend = "soft" + +[agent.namespaces.identity] +keycloak_introspect = true +token_cache_ttl_seconds = 300 + +[agent.namespaces.crypto] +key_source = "env" + +[agent.namespaces.governance] +policy_engine = "scope" + +[agent.session] +default_scope = "operate" +default_ttl_hours = 4 +default_mutation_budget = 50 +motd_template = "/etc/substrate/motd.template" +"#; + let config: AgentConfig = toml::from_str(toml).unwrap(); + assert!(config.agent.ssh.enabled); + assert_eq!( + config.agent.identity.keycloak_url, + "https://auth.guildhouse.dev" + ); + assert!(config.agent.namespaces.identity.keycloak_introspect); + assert_eq!( + config.agent.session.motd_template.unwrap().to_str().unwrap(), + "/etc/substrate/motd.template" + ); + } + + #[test] + fn test_env_var_expansion() { + std::env::set_var("TEST_BASCULE_VAR", "hello"); + assert_eq!(expand_env_vars("${TEST_BASCULE_VAR}"), "hello"); + assert_eq!(expand_env_vars("${MISSING_VAR:fallback}"), "fallback"); + assert_eq!(expand_env_vars("no vars here"), "no vars here"); + assert_eq!( + expand_env_vars("pre-${TEST_BASCULE_VAR}-post"), + "pre-hello-post" + ); + std::env::remove_var("TEST_BASCULE_VAR"); + } + + #[test] + fn test_load_from_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.toml"); + std::fs::write( + &path, + r#" +[agent] +socket_path = "/tmp/agent.sock" + +[agent.identity] +dev_mode = true +"#, + ) + .unwrap(); + + let config = AgentConfig::load(&path).unwrap(); + assert_eq!(config.agent.socket_path, "/tmp/agent.sock"); + assert!(config.agent.identity.dev_mode); + } +} diff --git a/bascule-agent/src/governance_server.rs b/bascule-agent/src/governance_server.rs new file mode 100644 index 0000000..4430ec3 --- /dev/null +++ b/bascule-agent/src/governance_server.rs @@ -0,0 +1,411 @@ +//! Governance IPC server — Unix socket serving Shellstream protocol. +//! +//! Django middleware, Celery workers, and co-located apps connect here. +//! Each connection is bound to a session established on the first message. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Context; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; +use tracing::{debug, error, info, warn}; + +use crate::config::AgentConfig; +use crate::namespace::NamespaceRouter; +use crate::session_store::SessionStore; +use crate::shellstream::{Namespace, ShellstreamMessage, ShellstreamResponse, Status}; + +/// The governance IPC server. +pub struct GovernanceServer { + config: Arc, + router: Arc, + sessions: Arc, +} + +impl GovernanceServer { + pub fn new( + config: Arc, + router: Arc, + sessions: Arc, + ) -> Self { + Self { + config, + router, + sessions, + } + } + + /// Start listening on the Unix socket. Runs until cancelled. + pub async fn serve(&self) -> anyhow::Result<()> { + let socket_path = &self.config.agent.socket_path; + + // Remove stale socket if it exists + if Path::new(socket_path).exists() { + std::fs::remove_file(socket_path) + .with_context(|| format!("Failed to remove stale socket at {socket_path}"))?; + } + + // Ensure parent directory exists + if let Some(parent) = Path::new(socket_path).parent() { + std::fs::create_dir_all(parent).ok(); + } + + let listener = UnixListener::bind(socket_path) + .with_context(|| format!("Failed to bind Unix socket at {socket_path}"))?; + + // Set socket permissions (best effort) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = u32::from_str_radix( + self.config + .agent + .socket_permissions + .trim_start_matches('0'), + 8, + ) + .unwrap_or(0o660); + std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(perms)).ok(); + } + + info!(socket_path, "Governance IPC server listening"); + + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let router = self.router.clone(); + let sessions = self.sessions.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, router, sessions).await { + debug!(error = %e, "Connection closed"); + } + }); + } + Err(e) => { + error!(error = %e, "Failed to accept connection"); + } + } + } + } +} + +/// Handle a single client connection. +async fn handle_connection( + mut stream: UnixStream, + router: Arc, + sessions: Arc, +) -> anyhow::Result<()> { + debug!("New IPC connection"); + + loop { + // Read 4-byte big-endian length prefix + let length = match read_length_prefix(&mut stream).await { + Ok(0) => break, // clean disconnect + Ok(len) => len, + Err(e) => { + // ConnectionReset / EOF = clean disconnect + if e.downcast_ref::() + .is_some_and(|io_err| { + io_err.kind() == std::io::ErrorKind::UnexpectedEof + || io_err.kind() == std::io::ErrorKind::ConnectionReset + }) + { + break; + } + return Err(e); + } + }; + + // Sanity check message size (max 16 MB) + if length > 16 * 1024 * 1024 { + warn!(length, "Message too large, dropping connection"); + break; + } + + // Read message body + let mut body = vec![0u8; length as usize]; + stream + .read_exact(&mut body) + .await + .context("Failed to read message body")?; + + // Decode the Shellstream message + let msg = match ShellstreamMessage::decode(&body) { + Ok(msg) => msg, + Err(e) => { + warn!(error = %e, "Malformed message"); + let resp = ShellstreamResponse::error([0u8; 16], 0, &format!("Malformed: {e}")); + write_response(&mut stream, &resp).await?; + continue; + } + }; + + // Ensure session exists (auto-create on first message) + sessions.ensure_session(msg.session_id); + + // Validate nonce (replay protection) + if !sessions.validate_nonce(msg.session_id, msg.nonce) { + let resp = ShellstreamResponse::denied(msg.session_id, msg.nonce, "Replay detected"); + write_response(&mut stream, &resp).await?; + continue; + } + + // Rate limit + if !sessions.check_rate_limit(msg.session_id) { + let resp = + ShellstreamResponse::denied(msg.session_id, msg.nonce, "Rate limit exceeded"); + write_response(&mut stream, &resp).await?; + continue; + } + + // Route to namespace handler + let response = match Namespace::from_u16(msg.namespace) { + Some(ns) => router.handle(ns, msg.function, &msg.payload, &msg.session_id).await, + None => ShellstreamResponse::error( + msg.session_id, + msg.nonce, + &format!("Unknown namespace: {:#06x}", msg.namespace), + ), + }; + + // Send response (echo session_id and nonce) + let response = ShellstreamResponse { + session_id: msg.session_id, + nonce: msg.nonce, + ..response + }; + write_response(&mut stream, &response).await?; + } + + debug!("IPC connection closed"); + Ok(()) +} + +/// Read 4-byte big-endian length prefix. +async fn read_length_prefix(stream: &mut UnixStream) -> anyhow::Result { + let mut buf = [0u8; 4]; + stream.read_exact(&mut buf).await?; + Ok(u32::from_be_bytes(buf)) +} + +/// Write a length-prefixed response. +async fn write_response( + stream: &mut UnixStream, + response: &ShellstreamResponse, +) -> anyhow::Result<()> { + let encoded = response.encode(); + stream.write_all(&encoded).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::namespace::NamespaceRouter; + use crate::session_store::SessionStore; + use rmp_serde; + use rmpv::Value; + use tempfile::TempDir; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::UnixStream; + + fn test_config(socket_path: &str) -> AgentConfig { + let mut config = AgentConfig::default_config(); + config.agent.socket_path = socket_path.into(); + config + } + + /// Encode a ShellstreamMessage to wire format (length-prefixed msgpack). + fn encode_request(namespace: u16, function: u16, session_id: [u8; 16], payload: &[u8], nonce: u64) -> Vec { + let arr = Value::Array(vec![ + Value::from(namespace as u64), + Value::from(function as u64), + Value::Binary(session_id.to_vec()), + Value::Binary(payload.to_vec()), + Value::from(nonce), + ]); + let body = rmp_serde::to_vec(&arr).unwrap(); + let len = body.len() as u32; + let mut out = Vec::with_capacity(4 + body.len()); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&body); + out + } + + /// Read a response from a stream. + async fn read_response(stream: &mut UnixStream) -> ShellstreamResponse { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.unwrap(); + let len = u32::from_be_bytes(len_buf) as usize; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.unwrap(); + + // Parse manually + let value: rmpv::Value = rmp_serde::from_slice(&body).unwrap(); + let arr = value.as_array().unwrap(); + let status = arr[0].as_u64().unwrap() as u16; + let session_id_bytes = match &arr[1] { + Value::Binary(b) => b.clone(), + _ => panic!("Expected binary session_id"), + }; + let payload = match &arr[2] { + Value::Binary(b) => b.clone(), + _ => panic!("Expected binary payload"), + }; + let nonce = arr[3].as_u64().unwrap(); + + let mut session_id = [0u8; 16]; + session_id.copy_from_slice(&session_id_bytes); + + ShellstreamResponse { + status, + session_id, + payload, + nonce, + } + } + + #[tokio::test] + async fn test_server_accepts_connection() { + let dir = TempDir::new().unwrap(); + let sock = dir.path().join("agent.sock"); + let config = Arc::new(test_config(sock.to_str().unwrap())); + let router = Arc::new(NamespaceRouter::new()); + let sessions = Arc::new(SessionStore::new()); + let server = GovernanceServer::new(config, router, sessions); + + let handle = tokio::spawn(async move { server.serve().await }); + + // Wait for socket to appear + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let mut stream = UnixStream::connect(&sock).await.unwrap(); + // Send a valid audit emit request + let payload = rmp_serde::to_vec(&serde_json::json!({"event_type": "test"})).unwrap(); + let data = encode_request(0x0006, 0x01, [0xAA; 16], &payload, 1); + stream.write_all(&data).await.unwrap(); + + let resp = read_response(&mut stream).await; + assert_eq!(resp.session_id, [0xAA; 16]); + assert_eq!(resp.nonce, 1); + // Will be an error since no handlers registered — that's fine for this test + // The point is the server accepted the connection and responded + + handle.abort(); + } + + #[tokio::test] + async fn test_unknown_namespace_returns_error() { + let dir = TempDir::new().unwrap(); + let sock = dir.path().join("agent.sock"); + let config = Arc::new(test_config(sock.to_str().unwrap())); + let router = Arc::new(NamespaceRouter::new()); + let sessions = Arc::new(SessionStore::new()); + let server = GovernanceServer::new(config, router, sessions); + + let handle = tokio::spawn(async move { server.serve().await }); + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let mut stream = UnixStream::connect(&sock).await.unwrap(); + let data = encode_request(0x00FF, 0x01, [0xBB; 16], &[0x80], 1); + stream.write_all(&data).await.unwrap(); + + let resp = read_response(&mut stream).await; + assert_eq!(resp.status, Status::Error as u16); + assert_eq!(resp.nonce, 1); + + handle.abort(); + } + + #[tokio::test] + async fn test_nonce_replay_rejected() { + let dir = TempDir::new().unwrap(); + let sock = dir.path().join("agent.sock"); + let config = Arc::new(test_config(sock.to_str().unwrap())); + let router = Arc::new(NamespaceRouter::new()); + let sessions = Arc::new(SessionStore::new()); + let server = GovernanceServer::new(config, router, sessions); + + let handle = tokio::spawn(async move { server.serve().await }); + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let mut stream = UnixStream::connect(&sock).await.unwrap(); + let sid = [0xCC; 16]; + + // First request with nonce=5 — should succeed + let data = encode_request(0x0006, 0x01, sid, &[0x80], 5); + stream.write_all(&data).await.unwrap(); + let resp = read_response(&mut stream).await; + assert_eq!(resp.nonce, 5); + + // Replay same nonce=5 — should be denied + let data = encode_request(0x0006, 0x01, sid, &[0x80], 5); + stream.write_all(&data).await.unwrap(); + let resp = read_response(&mut stream).await; + assert_eq!(resp.status, Status::Denied as u16); + + // Lower nonce=3 — should also be denied + let data = encode_request(0x0006, 0x01, sid, &[0x80], 3); + stream.write_all(&data).await.unwrap(); + let resp = read_response(&mut stream).await; + assert_eq!(resp.status, Status::Denied as u16); + + // Higher nonce=6 — should succeed + let data = encode_request(0x0006, 0x01, sid, &[0x80], 6); + stream.write_all(&data).await.unwrap(); + let resp = read_response(&mut stream).await; + assert_eq!(resp.nonce, 6); + // Not denied (will be error because no handler, but not DENIED) + assert_ne!(resp.status, Status::Denied as u16); + + handle.abort(); + } + + #[tokio::test] + async fn test_malformed_message() { + let dir = TempDir::new().unwrap(); + let sock = dir.path().join("agent.sock"); + let config = Arc::new(test_config(sock.to_str().unwrap())); + let router = Arc::new(NamespaceRouter::new()); + let sessions = Arc::new(SessionStore::new()); + let server = GovernanceServer::new(config, router, sessions); + + let handle = tokio::spawn(async move { server.serve().await }); + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let mut stream = UnixStream::connect(&sock).await.unwrap(); + // Send garbage with valid length prefix + let garbage = vec![0xFF, 0x01, 0x02, 0x03]; + let len = garbage.len() as u32; + stream.write_all(&len.to_be_bytes()).await.unwrap(); + stream.write_all(&garbage).await.unwrap(); + + let resp = read_response(&mut stream).await; + assert_eq!(resp.status, Status::Error as u16); + + handle.abort(); + } + + #[tokio::test] + async fn test_clean_disconnect() { + let dir = TempDir::new().unwrap(); + let sock = dir.path().join("agent.sock"); + let config = Arc::new(test_config(sock.to_str().unwrap())); + let router = Arc::new(NamespaceRouter::new()); + let sessions = Arc::new(SessionStore::new()); + let server = GovernanceServer::new(config, router, sessions); + + let handle = tokio::spawn(async move { server.serve().await }); + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let stream = UnixStream::connect(&sock).await.unwrap(); + drop(stream); // Clean disconnect + + // Server should still be running — connect again + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + let _stream2 = UnixStream::connect(&sock).await.unwrap(); + + handle.abort(); + } +} diff --git a/bascule-agent/src/main.rs b/bascule-agent/src/main.rs new file mode 100644 index 0000000..d5575e9 --- /dev/null +++ b/bascule-agent/src/main.rs @@ -0,0 +1,147 @@ +//! bascule-agent — governed application sidecar. +//! +//! A single Rust process serving two interfaces over one governance engine: +//! +//! 1. **Governance IPC** — Unix socket at `/var/run/substrate/agent.sock` +//! Django middleware, Celery workers, and co-located apps talk Shellstream here. +//! +//! 2. **Interactive Shell** — SSH server on port 2222 (optional) +//! Bascule Gateway, `sb shell`, and Dashboard WebSocket connect here. + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use clap::Parser; +use tracing::{error, info}; + +mod command_filter; +mod config; +mod governance_server; +mod namespace; +mod session_store; +mod shellstream; +mod ssh_server; + +use governance_server::GovernanceServer; +use namespace::NamespaceRouter; +use session_store::SessionStore; +use shellstream::Namespace; +use ssh_server::AgentSSHServer; + +#[derive(Parser)] +#[command(name = "bascule-agent", about = "Governed application sidecar")] +struct Cli { + /// Path to shell.toml configuration file. + #[arg(long, default_value = "/etc/substrate/shell.toml")] + config: PathBuf, + + /// Run with default config (dev mode, no config file needed). + #[arg(long)] + dev: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .json() + .init(); + + let cli = Cli::parse(); + + let config = if cli.dev { + info!("Starting in dev mode with default configuration"); + config::AgentConfig::default_config() + } else { + config::AgentConfig::load(&cli.config) + .with_context(|| format!("Failed to load config from {:?}", cli.config))? + }; + + info!( + socket_path = %config.agent.socket_path, + ssh_enabled = config.agent.ssh.enabled, + namespace_backend = %config.agent.namespaces.backend, + "bascule-agent starting" + ); + + let dev_mode = config.agent.namespaces.backend == "soft"; + let config = Arc::new(config); + + // Build namespace router with all soft-mode handlers + let mut router = NamespaceRouter::new(); + router.register( + Namespace::Crypto, + Arc::new(namespace::crypto::CryptoHandler::new()), + ); + router.register( + Namespace::Identity, + Arc::new(namespace::identity::IdentityHandler::new(dev_mode)), + ); + router.register( + Namespace::Secrets, + Arc::new(namespace::secrets::SecretsHandler::new()), + ); + router.register( + Namespace::Governance, + Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)), + ); + router.register( + Namespace::Attestation, + Arc::new(namespace::attestation::AttestationHandler::new( + config.agent.namespaces.attestation.default_posture.clone(), + )), + ); + router.register( + Namespace::Audit, + Arc::new(namespace::audit::AuditHandler::new()), + ); + router.register( + Namespace::Network, + Arc::new(namespace::network::NetworkHandler::new()), + ); + router.register( + Namespace::Intelligence, + Arc::new(namespace::intelligence::IntelligenceHandler::new()), + ); + + let router = Arc::new(router); + let sessions = Arc::new(SessionStore::new()); + + // Start governance IPC server + let server = GovernanceServer::new(config.clone(), router.clone(), sessions); + let ipc_handle = tokio::spawn(async move { + if let Err(e) = server.serve().await { + error!(error = %e, "Governance IPC server failed"); + } + }); + + // Start SSH shell server (if enabled) + let ssh_handle = if config.agent.ssh.enabled { + let ssh_server = AgentSSHServer::new(config.clone(), router.clone())?; + Some(tokio::spawn(async move { + if let Err(e) = ssh_server.serve().await { + error!(error = %e, "SSH shell server failed"); + } + })) + } else { + info!("SSH shell server disabled"); + None + }; + + info!("bascule-agent ready"); + + // Wait for shutdown signal + tokio::signal::ctrl_c().await?; + info!("bascule-agent shutting down"); + ipc_handle.abort(); + if let Some(h) = ssh_handle { + h.abort(); + } + + Ok(()) +} diff --git a/bascule-agent/src/namespace/attestation.rs b/bascule-agent/src/namespace/attestation.rs new file mode 100644 index 0000000..6fed669 --- /dev/null +++ b/bascule-agent/src/namespace/attestation.rs @@ -0,0 +1,74 @@ +//! ATTESTATION namespace (0x0005) — posture level + soft SAT generation. + +use async_trait::async_trait; +use chrono::Utc; + +use crate::shellstream::{attestation, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct AttestationHandler { + default_posture: String, +} + +impl AttestationHandler { + pub fn new(default_posture: String) -> Self { + Self { default_posture } + } +} + +#[async_trait] +impl NamespaceHandler for AttestationHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + attestation::POSTURE => self.posture(session_id).await, + attestation::SAT_BUNDLE => self.sat_bundle(session_id).await, + attestation::ATTESTATION_VERIFY => self.verify(payload, session_id).await, + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown ATTESTATION function: {function_id:#06x}"), + ), + } + } +} + +impl AttestationHandler { + async fn posture(&self, session_id: &[u8; 16]) -> ShellstreamResponse { + let response = rmp_serde::to_vec(&serde_json::json!({ + "level": self.default_posture, + "source": "config", + "timestamp": Utc::now().to_rfc3339(), + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse { + // Soft SAT: a JSON bundle with session info (not cryptographically bound) + let bundle_id = uuid::Uuid::new_v4().to_string(); + let response = rmp_serde::to_vec(&serde_json::json!({ + "bundle_id": bundle_id, + "session_id": hex::encode(session_id), + "posture": self.default_posture, + "issued_at": Utc::now().to_rfc3339(), + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Soft mode: always returns valid (no TPM to verify against) + let response = rmp_serde::to_vec(&serde_json::json!({ + "valid": true, + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } +} diff --git a/bascule-agent/src/namespace/audit.rs b/bascule-agent/src/namespace/audit.rs new file mode 100644 index 0000000..6137b2c --- /dev/null +++ b/bascule-agent/src/namespace/audit.rs @@ -0,0 +1,111 @@ +//! AUDIT namespace (0x0006) — structured event emission + merkle anchoring. + +use async_trait::async_trait; +use sha2::{Digest, Sha256}; +use tracing::info; +use uuid::Uuid; + +use crate::shellstream::{audit, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct AuditHandler; + +impl AuditHandler { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl NamespaceHandler for AuditHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + audit::EMIT => self.emit(payload, session_id).await, + audit::ANCHOR => self.anchor(payload, session_id).await, + audit::QUERY => self.query(payload, session_id).await, + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown AUDIT function: {function_id:#06x}"), + ), + } + } +} + +impl AuditHandler { + async fn emit(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Compute merkle leaf hash of the event + let mut hasher = Sha256::new(); + hasher.update(session_id); + hasher.update(payload); + let hash = hasher.finalize(); + let merkle_leaf = hex::encode(&hash); + + let event_id = Uuid::new_v4().to_string(); + + // Log the audit event (structured JSON) + let payload_parsed: serde_json::Value = + rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null); + + info!( + event_id = %event_id, + session_id = %hex::encode(session_id), + merkle_leaf = %merkle_leaf, + event = %payload_parsed, + "audit.emit" + ); + + let response_payload = rmp_serde::to_vec(&serde_json::json!({ + "event_id": event_id, + "merkle_leaf": merkle_leaf, + })) + .unwrap_or_default(); + + ShellstreamResponse::ok(*session_id, 0, response_payload) + } + + async fn anchor(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let payload_parsed: serde_json::Value = + rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null); + + let event_ids = payload_parsed + .get("event_ids") + .and_then(|v| v.as_array()) + .map(|arr| arr.len()) + .unwrap_or(0); + + // Compute subtree root hash + let mut hasher = Sha256::new(); + hasher.update(payload); + let root_hash = hex::encode(hasher.finalize()); + + info!( + event_count = event_ids, + root_hash = %root_hash, + "audit.anchor" + ); + + let response_payload = rmp_serde::to_vec(&serde_json::json!({ + "root_hash": root_hash, + "event_count": event_ids, + })) + .unwrap_or_default(); + + ShellstreamResponse::ok(*session_id, 0, response_payload) + } + + async fn query(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Stub: return empty results + let response_payload = rmp_serde::to_vec(&serde_json::json!({ + "events": [], + })) + .unwrap_or_default(); + + ShellstreamResponse::ok(*session_id, 0, response_payload) + } +} diff --git a/bascule-agent/src/namespace/crypto.rs b/bascule-agent/src/namespace/crypto.rs new file mode 100644 index 0000000..145ad58 --- /dev/null +++ b/bascule-agent/src/namespace/crypto.rs @@ -0,0 +1,127 @@ +//! CRYPTO namespace (0x0001) — signing, verification, hashing. + +use async_trait::async_trait; +use sha2::{Digest, Sha256}; + +use crate::shellstream::{crypto, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct CryptoHandler; + +impl CryptoHandler { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl NamespaceHandler for CryptoHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + crypto::SIGN => self.sign(payload, session_id).await, + crypto::VERIFY => self.verify(payload, session_id).await, + // ENCRYPT (canonical ID 6) — used for hash in soft mode + crypto::ENCRYPT => self.hash(payload, session_id).await, + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown CRYPTO function: {function_id:#06x}"), + ), + } + } +} + +/// Extract bytes from an rmpv map field — handles both Binary and String values. +fn extract_bytes_from_map(map: &rmpv::Value, key: &str) -> Vec { + let key_val = rmpv::Value::String(key.into()); + if let rmpv::Value::Map(entries) = map { + for (k, v) in entries { + if k == &key_val { + return match v { + rmpv::Value::Binary(b) => b.clone(), + rmpv::Value::String(s) => s.as_bytes().to_vec(), + _ => vec![], + }; + } + } + } + vec![] +} + +/// Extract a UTF-8 string from an rmpv map field — handles Binary (as UTF-8), String, or missing. +fn extract_string_from_map(map: &rmpv::Value, key: &str) -> String { + let key_val = rmpv::Value::String(key.into()); + if let rmpv::Value::Map(entries) = map { + for (k, v) in entries { + if k == &key_val { + return match v { + rmpv::Value::String(s) => s.as_str().unwrap_or_default().to_string(), + rmpv::Value::Binary(b) => String::from_utf8_lossy(b).to_string(), + _ => String::new(), + }; + } + } + } + String::new() +} + +impl CryptoHandler { + async fn sign(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Soft mode: HMAC-SHA256 with a dev key. + // Use rmpv::Value to preserve binary data from Python SDK. + let payload_parsed: rmpv::Value = + rmp_serde::from_slice(payload).unwrap_or(rmpv::Value::Nil); + + let data = extract_bytes_from_map(&payload_parsed, "data"); + + let mut hasher = Sha256::new(); + hasher.update(b"soft-signing-key:"); + hasher.update(&data); + let signature = hex::encode(hasher.finalize()); + + let response = rmp_serde::to_vec(&serde_json::json!({ + "signature": signature, + "algorithm": "sha256-hmac-soft", + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn verify(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let payload_parsed: rmpv::Value = + rmp_serde::from_slice(payload).unwrap_or(rmpv::Value::Nil); + + let data = extract_bytes_from_map(&payload_parsed, "data"); + // Signature may arrive as String or Binary (Python SDK sends bytes) + let sig = extract_string_from_map(&payload_parsed, "signature"); + + // Recompute + let mut hasher = Sha256::new(); + hasher.update(b"soft-signing-key:"); + hasher.update(&data); + let expected = hex::encode(hasher.finalize()); + + let valid = sig == expected; + + let response = rmp_serde::to_vec(&serde_json::json!({ + "valid": valid, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn hash(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let hash = hex::encode(Sha256::digest(payload)); + let response = rmp_serde::to_vec(&serde_json::json!({ + "hash": hash, + "algorithm": "sha256", + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } +} diff --git a/bascule-agent/src/namespace/governance.rs b/bascule-agent/src/namespace/governance.rs new file mode 100644 index 0000000..05bcbe5 --- /dev/null +++ b/bascule-agent/src/namespace/governance.rs @@ -0,0 +1,87 @@ +//! GOVERNANCE namespace (0x0004) — scope checking + ceremony proposals. + +use async_trait::async_trait; + +use crate::shellstream::{governance, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct GovernanceHandler { + dev_mode: bool, +} + +impl GovernanceHandler { + pub fn new(dev_mode: bool) -> Self { + Self { dev_mode } + } +} + +#[async_trait] +impl NamespaceHandler for GovernanceHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + governance::GATE => self.gate(payload, session_id).await, + governance::PROPOSE => self.propose(payload, session_id).await, + governance::ATTENUATE => self.evaluate(payload, session_id).await, + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown GOVERNANCE function: {function_id:#06x}"), + ), + } + } +} + +impl GovernanceHandler { + async fn gate(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let payload_parsed: serde_json::Value = + rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null); + + let subject = payload_parsed.get("subject").and_then(|v| v.as_str()).unwrap_or(""); + let resource = payload_parsed.get("resource").and_then(|v| v.as_str()).unwrap_or(""); + let action = payload_parsed.get("action").and_then(|v| v.as_str()).unwrap_or(""); + + tracing::debug!(subject, resource, action, "governance.gate"); + + if self.dev_mode { + // Dev mode: allow everything + let response = rmp_serde::to_vec(&serde_json::json!({ + "permitted": true, + })) + .unwrap_or_default(); + return ShellstreamResponse::ok(*session_id, 0, response); + } + + // TODO: Check SessionScope::permits() from bascule-core (Stage 3) + let response = rmp_serde::to_vec(&serde_json::json!({ + "permitted": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn propose(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let proposal_id = uuid::Uuid::new_v4().to_string(); + + tracing::info!(proposal_id, "governance.propose"); + + let response = rmp_serde::to_vec(&serde_json::json!({ + "proposal_id": proposal_id, + "status": "created", + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn evaluate(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let response = rmp_serde::to_vec(&serde_json::json!({ + "message": "evaluate not yet implemented", + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } +} diff --git a/bascule-agent/src/namespace/identity.rs b/bascule-agent/src/namespace/identity.rs new file mode 100644 index 0000000..21266da --- /dev/null +++ b/bascule-agent/src/namespace/identity.rs @@ -0,0 +1,109 @@ +//! IDENTITY namespace (0x0002) — OIDC token validation + authorization. + +use async_trait::async_trait; + +use crate::shellstream::{identity, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct IdentityHandler { + dev_mode: bool, +} + +impl IdentityHandler { + pub fn new(dev_mode: bool) -> Self { + Self { dev_mode } + } +} + +#[async_trait] +impl NamespaceHandler for IdentityHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + identity::AUTHENTICATE => self.authenticate(payload, session_id).await, + identity::AUTHORIZE => self.authorize(payload, session_id).await, + identity::WHOAMI => self.resolve(payload, session_id).await, + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown IDENTITY function: {function_id:#06x}"), + ), + } + } +} + +impl IdentityHandler { + async fn authenticate(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let payload_parsed: serde_json::Value = + rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null); + + if self.dev_mode { + // Dev mode: accept any token, return synthetic identity + let response = rmp_serde::to_vec(&serde_json::json!({ + "subject": "dev-user", + "email": "dev@guildhouse.local", + "issuer": "dev-mode", + "verified": true, + })) + .unwrap_or_default(); + return ShellstreamResponse::ok(*session_id, 0, response); + } + + // TODO: Validate JWT via OidcAuthProvider (Stage 3 — after moving to bascule-core) + let token = payload_parsed + .get("token") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if token.is_empty() { + return ShellstreamResponse::denied(*session_id, 0, "No token provided"); + } + + // Placeholder: accept token and return minimal claims + let response = rmp_serde::to_vec(&serde_json::json!({ + "subject": "unknown", + "verified": false, + "message": "OIDC validation not yet implemented", + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn authorize(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + if self.dev_mode { + let response = rmp_serde::to_vec(&serde_json::json!({ + "authorized": true, + })) + .unwrap_or_default(); + return ShellstreamResponse::ok(*session_id, 0, response); + } + + let payload_parsed: serde_json::Value = + rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null); + + let subject = payload_parsed.get("subject").and_then(|v| v.as_str()).unwrap_or(""); + let resource = payload_parsed.get("resource").and_then(|v| v.as_str()).unwrap_or(""); + let action = payload_parsed.get("action").and_then(|v| v.as_str()).unwrap_or(""); + + tracing::debug!(subject, resource, action, "identity.authorize"); + + // Default: allow in soft mode + let response = rmp_serde::to_vec(&serde_json::json!({ + "authorized": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn resolve(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let response = rmp_serde::to_vec(&serde_json::json!({ + "message": "resolve not yet implemented", + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } +} diff --git a/bascule-agent/src/namespace/intelligence.rs b/bascule-agent/src/namespace/intelligence.rs new file mode 100644 index 0000000..de6475b --- /dev/null +++ b/bascule-agent/src/namespace/intelligence.rs @@ -0,0 +1,68 @@ +//! INTELLIGENCE namespace (0x0008) — soft-mode inference passthrough. + +use async_trait::async_trait; + +use crate::shellstream::{intelligence, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct IntelligenceHandler; + +impl IntelligenceHandler { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl NamespaceHandler for IntelligenceHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + intelligence::INFER => self.infer(payload, session_id).await, + intelligence::EMBED => self.embed(payload, session_id).await, + intelligence::INTELLIGENCE_CLASSIFY => self.classify(payload, session_id).await, + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown INTELLIGENCE function: {function_id:#06x}"), + ), + } + } +} + +impl IntelligenceHandler { + async fn infer(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Soft mode: no local model — return stub indicating external provider needed + let response = rmp_serde::to_vec(&serde_json::json!({ + "status": "not_available", + "reason": "No local model loaded in soft mode. Use external provider directly.", + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn embed(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let response = rmp_serde::to_vec(&serde_json::json!({ + "status": "not_available", + "reason": "Embedding requires HFL namespace with loaded model.", + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn classify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let response = rmp_serde::to_vec(&serde_json::json!({ + "status": "not_available", + "reason": "Classification requires HFL namespace with loaded model.", + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } +} diff --git a/bascule-agent/src/namespace/mod.rs b/bascule-agent/src/namespace/mod.rs new file mode 100644 index 0000000..afb2329 --- /dev/null +++ b/bascule-agent/src/namespace/mod.rs @@ -0,0 +1,70 @@ +//! Namespace handler trait and router. +//! +//! Each HFL namespace has a soft-mode handler that implements the +//! `NamespaceHandler` trait. The `NamespaceRouter` dispatches incoming +//! Shellstream messages to the appropriate handler. + +pub mod audit; +pub mod attestation; +pub mod crypto; +pub mod governance; +pub mod identity; +pub mod intelligence; +pub mod network; +pub mod secrets; + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::shellstream::{Namespace, ShellstreamResponse}; + +/// Trait implemented by each namespace's soft-mode handler. +#[async_trait] +pub trait NamespaceHandler: Send + Sync { + /// Handle a function call within this namespace. + async fn handle( + &self, + function: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse; +} + +/// Routes namespace IDs to their handlers. +pub struct NamespaceRouter { + handlers: HashMap>, +} + +impl NamespaceRouter { + /// Create an empty router (no handlers registered). + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + /// Register a handler for a namespace. + pub fn register(&mut self, namespace: Namespace, handler: Arc) { + self.handlers.insert(namespace as u16, handler); + } + + /// Dispatch a request to the appropriate namespace handler. + pub async fn handle( + &self, + namespace: Namespace, + function: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match self.handlers.get(&(namespace as u16)) { + Some(handler) => handler.handle(function, payload, session_id).await, + None => ShellstreamResponse::error( + *session_id, + 0, + &format!("No handler for namespace {}", namespace.name()), + ), + } + } +} diff --git a/bascule-agent/src/namespace/network.rs b/bascule-agent/src/namespace/network.rs new file mode 100644 index 0000000..94a06e9 --- /dev/null +++ b/bascule-agent/src/namespace/network.rs @@ -0,0 +1,76 @@ +//! NETWORK namespace (0x0007) — soft-mode network classification. + +use async_trait::async_trait; + +use crate::shellstream::{network, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct NetworkHandler; + +impl NetworkHandler { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl NamespaceHandler for NetworkHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + network::CLASSIFY => self.classify(payload, session_id).await, + network::ALLOW => self.allow(payload, session_id).await, + network::DENY => self.deny(payload, session_id).await, + network::SEGMENT_LIST => { + tracing::debug!("SEGMENT_LIST not implemented in soft mode"); + ShellstreamResponse::error(*session_id, 0, "SEGMENT_LIST not implemented in soft mode") + }, + network::REDIRECT => { + tracing::debug!("REDIRECT not implemented in soft mode"); + ShellstreamResponse::error(*session_id, 0, "REDIRECT not implemented in soft mode") + }, + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown NETWORK function: {function_id:#06x}"), + ), + } + } +} + +impl NetworkHandler { + async fn classify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Soft mode: all traffic classified as "standard" + let response = rmp_serde::to_vec(&serde_json::json!({ + "classification": "standard", + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn allow(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Soft mode: always allowed (no eBPF enforcement) + let response = rmp_serde::to_vec(&serde_json::json!({ + "allowed": true, + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + + async fn deny(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + // Soft mode: log intent but no enforcement + let response = rmp_serde::to_vec(&serde_json::json!({ + "denied": true, + "enforced": false, + "soft_mode": true, + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } +} diff --git a/bascule-agent/src/namespace/secrets.rs b/bascule-agent/src/namespace/secrets.rs new file mode 100644 index 0000000..0562d84 --- /dev/null +++ b/bascule-agent/src/namespace/secrets.rs @@ -0,0 +1,71 @@ +//! SECRETS namespace (0x0003) — secret retrieval from env or Vault. + +use async_trait::async_trait; + +use crate::shellstream::{secrets, ShellstreamResponse}; +use super::NamespaceHandler; + +pub struct SecretsHandler; + +impl SecretsHandler { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl NamespaceHandler for SecretsHandler { + async fn handle( + &self, + function_id: u16, + payload: &[u8], + session_id: &[u8; 16], + ) -> ShellstreamResponse { + match function_id { + secrets::GET => self.get(payload, session_id).await, + secrets::PUT => ShellstreamResponse::denied(*session_id, 0, "PUT not supported in soft mode"), + secrets::ROTATE => ShellstreamResponse::denied(*session_id, 0, "ROTATE not supported in soft mode"), + _ => ShellstreamResponse::error( + *session_id, + 0, + &format!("Unknown SECRETS function: {function_id:#06x}"), + ), + } + } +} + +impl SecretsHandler { + async fn get(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { + let payload_parsed: serde_json::Value = + rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null); + + let path = payload_parsed + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if path.is_empty() { + return ShellstreamResponse::error(*session_id, 0, "Missing 'path'"); + } + + // Soft mode: look up environment variable + // Convert path like "db/password" to env var "DB_PASSWORD" + let env_key = path.replace('/', "_").to_uppercase(); + + match std::env::var(&env_key) { + Ok(value) => { + let response = rmp_serde::to_vec(&serde_json::json!({ + "value": value, + "source": "env", + })) + .unwrap_or_default(); + ShellstreamResponse::ok(*session_id, 0, response) + } + Err(_) => ShellstreamResponse::error( + *session_id, + 0, + &format!("Secret not found: {path} (env: {env_key})"), + ), + } + } +} diff --git a/bascule-agent/src/session_store.rs b/bascule-agent/src/session_store.rs new file mode 100644 index 0000000..82740f7 --- /dev/null +++ b/bascule-agent/src/session_store.rs @@ -0,0 +1,152 @@ +//! Session tracking for IPC connections. +//! +//! Each Python SDK `SubstrateClient` instance has a UUID session_id. +//! We track the highest nonce seen (replay protection) and rate limiting. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use dashmap::DashMap; + +/// Per-session state. +pub struct SessionInfo { + /// Highest nonce seen (monotonic — reject anything ≤ this). + highest_nonce: AtomicU64, + /// Rate limiting: request timestamps in a sliding window. + request_times: std::sync::Mutex>, +} + +impl SessionInfo { + fn new() -> Self { + Self { + highest_nonce: AtomicU64::new(0), + request_times: std::sync::Mutex::new(Vec::new()), + } + } +} + +/// Concurrent session store backed by DashMap. +pub struct SessionStore { + sessions: DashMap<[u8; 16], SessionInfo>, + /// Max requests per second per session. + rate_limit: u32, +} + +impl SessionStore { + pub fn new() -> Self { + Self { + sessions: DashMap::new(), + rate_limit: 1000, + } + } + + pub fn with_rate_limit(rate_limit: u32) -> Self { + Self { + sessions: DashMap::new(), + rate_limit, + } + } + + /// Ensure a session entry exists for this session_id. + pub fn ensure_session(&self, session_id: [u8; 16]) { + self.sessions + .entry(session_id) + .or_insert_with(SessionInfo::new); + } + + /// Validate nonce: must be strictly greater than the last seen nonce. + /// Returns true if valid, false if replay/stale. + pub fn validate_nonce(&self, session_id: [u8; 16], nonce: u64) -> bool { + if let Some(session) = self.sessions.get(&session_id) { + let prev = session.highest_nonce.load(Ordering::Acquire); + if nonce <= prev { + return false; + } + session.highest_nonce.store(nonce, Ordering::Release); + true + } else { + false + } + } + + /// Check rate limit for a session. Returns true if under limit. + pub fn check_rate_limit(&self, session_id: [u8; 16]) -> bool { + if let Some(session) = self.sessions.get(&session_id) { + let mut times = session.request_times.lock().unwrap(); + let now = Instant::now(); + let window = std::time::Duration::from_secs(1); + + // Remove requests older than the window + times.retain(|t| now.duration_since(*t) < window); + + if times.len() >= self.rate_limit as usize { + return false; + } + + times.push(now); + true + } else { + false + } + } + + /// Remove a session. + pub fn remove_session(&self, session_id: [u8; 16]) { + self.sessions.remove(&session_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nonce_validation() { + let store = SessionStore::new(); + let sid = [0x01; 16]; + store.ensure_session(sid); + + // First nonce accepted + assert!(store.validate_nonce(sid, 1)); + // Higher nonce accepted + assert!(store.validate_nonce(sid, 5)); + // Same nonce rejected (replay) + assert!(!store.validate_nonce(sid, 5)); + // Lower nonce rejected + assert!(!store.validate_nonce(sid, 3)); + // Higher nonce accepted again + assert!(store.validate_nonce(sid, 6)); + } + + #[test] + fn test_rate_limit() { + let store = SessionStore::with_rate_limit(3); + let sid = [0x02; 16]; + store.ensure_session(sid); + + // First 3 should pass + assert!(store.check_rate_limit(sid)); + assert!(store.check_rate_limit(sid)); + assert!(store.check_rate_limit(sid)); + // 4th should be rejected + assert!(!store.check_rate_limit(sid)); + } + + #[test] + fn test_session_not_found() { + let store = SessionStore::new(); + let sid = [0x03; 16]; + // No session created — nonce validation fails + assert!(!store.validate_nonce(sid, 1)); + assert!(!store.check_rate_limit(sid)); + } + + #[test] + fn test_ensure_session_idempotent() { + let store = SessionStore::new(); + let sid = [0x04; 16]; + store.ensure_session(sid); + store.ensure_session(sid); // should not reset nonce + assert!(store.validate_nonce(sid, 1)); + } +} diff --git a/bascule-agent/src/shellstream.rs b/bascule-agent/src/shellstream.rs new file mode 100644 index 0000000..7f6e615 --- /dev/null +++ b/bascule-agent/src/shellstream.rs @@ -0,0 +1,420 @@ +//! Shellstream wire protocol codec — msgpack over Unix domain socket. +//! +//! Wire format must match `substrate-sdk-python/substrate_sdk/protocol.py` byte-for-byte. +//! Encoding/decoding delegates to `substrate_rt::ShellstreamCodec` (canonical implementation). +//! +//! Request: [4-byte BE length] [msgpack array: [namespace, function, session_id, payload, nonce]] +//! Response: [4-byte BE length] [msgpack array: [status, session_id, payload, nonce]] + +use serde::Deserialize; +use substrate_rt::ShellstreamCodec; + +/// HFL namespace identifiers (0x0001–0x0008). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Namespace { + Crypto = 0x0001, + Identity = 0x0002, + Secrets = 0x0003, + Governance = 0x0004, + Attestation = 0x0005, + Audit = 0x0006, + Network = 0x0007, + Intelligence = 0x0008, +} + +impl Namespace { + pub fn from_u16(v: u16) -> Option { + match v { + 0x0001 => Some(Self::Crypto), + 0x0002 => Some(Self::Identity), + 0x0003 => Some(Self::Secrets), + 0x0004 => Some(Self::Governance), + 0x0005 => Some(Self::Attestation), + 0x0006 => Some(Self::Audit), + 0x0007 => Some(Self::Network), + 0x0008 => Some(Self::Intelligence), + _ => None, + } + } + + pub fn name(&self) -> &'static str { + match self { + Self::Crypto => "CRYPTO", + Self::Identity => "IDENTITY", + Self::Secrets => "SECRETS", + Self::Governance => "GOVERNANCE", + Self::Attestation => "ATTESTATION", + Self::Audit => "AUDIT", + Self::Network => "NETWORK", + Self::Intelligence => "INTELLIGENCE", + } + } +} + +/// Canonical HFL function IDs — re-exported from hfl-types::ids (single source of truth). +/// See hfl-types::ids for canonical constants. Intelligence namespace (0x0008) is a +/// bascule-agent extension, gated behind the `agent-extensions` feature. +pub use hfl_types::ids::{ + crypto, identity, secrets, governance, + attestation, audit, network, intelligence, +}; + +/// Response status codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum Status { + Ok = 0x00, + Error = 0x01, + Denied = 0x02, + CeremonyRequired = 0x03, +} + +impl Status { + pub fn from_u16(v: u16) -> Option { + match v { + 0x00 => Some(Self::Ok), + 0x01 => Some(Self::Error), + 0x02 => Some(Self::Denied), + 0x03 => Some(Self::CeremonyRequired), + _ => None, + } + } +} + +/// Incoming request from Python SDK. +#[derive(Debug, Clone)] +pub struct ShellstreamMessage { + pub namespace: u16, + pub function: u16, + pub session_id: [u8; 16], + pub payload: Vec, + pub nonce: u64, +} + +/// Outgoing response to Python SDK. +#[derive(Debug, Clone)] +pub struct ShellstreamResponse { + pub status: u16, + pub session_id: [u8; 16], + pub payload: Vec, + pub nonce: u64, +} + +impl ShellstreamMessage { + /// Decode from msgpack body (without length prefix). + pub fn decode(data: &[u8]) -> anyhow::Result { + let (namespace, function, session_id, payload, nonce) = + ShellstreamCodec::decode_request(data) + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(Self { + namespace, + function, + session_id, + payload, + nonce, + }) + } +} + +impl ShellstreamResponse { + /// Encode to wire format (length-prefixed msgpack). + pub fn encode(&self) -> Vec { + ShellstreamCodec::encode_response(self.status, &self.session_id, &self.payload, self.nonce) + .expect("msgpack serialize") + } + + /// Create an OK response. + pub fn ok(session_id: [u8; 16], nonce: u64, payload: Vec) -> Self { + Self { + status: Status::Ok as u16, + session_id, + payload, + nonce, + } + } + + /// Create an error response. + pub fn error(session_id: [u8; 16], nonce: u64, message: &str) -> Self { + let payload = rmp_serde::to_vec(&serde_json::json!({"message": message})) + .unwrap_or_default(); + Self { + status: Status::Error as u16, + session_id, + payload, + nonce, + } + } + + /// Create a denied response. + pub fn denied(session_id: [u8; 16], nonce: u64, message: &str) -> Self { + let payload = rmp_serde::to_vec(&serde_json::json!({"message": message})) + .unwrap_or_default(); + Self { + status: Status::Denied as u16, + session_id, + payload, + nonce, + } + } + + /// Create a ceremony-required response. + pub fn ceremony_required( + session_id: [u8; 16], + nonce: u64, + ceremony_id: &str, + ceremony_type: &str, + ) -> Self { + let payload = rmp_serde::to_vec(&serde_json::json!({ + "ceremony_id": ceremony_id, + "ceremony_type": ceremony_type, + })) + .unwrap_or_default(); + Self { + status: Status::CeremonyRequired as u16, + session_id, + payload, + nonce, + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Deserialize)] + struct TestVectors { + namespace_ids: std::collections::HashMap, + status_codes: std::collections::HashMap, + vectors: Vec, + } + + #[derive(Debug, Deserialize)] + struct TestVector { + name: String, + #[serde(rename = "type")] + vec_type: String, + #[serde(default)] + namespace: Option, + #[serde(default)] + function: Option, + #[serde(default)] + status: Option, + session_id_hex: String, + payload_hex: String, + nonce: u64, + body_hex: String, + full_encoded_hex: String, + } + + fn load_vectors() -> TestVectors { + let data = include_str!("../tests/fixtures/shellstream_vectors.json"); + serde_json::from_str(data).expect("Failed to parse test vectors") + } + + #[test] + fn test_namespace_ids_match_python() { + let vectors = load_vectors(); + assert_eq!(vectors.namespace_ids["CRYPTO"], Namespace::Crypto as u16); + assert_eq!(vectors.namespace_ids["IDENTITY"], Namespace::Identity as u16); + assert_eq!(vectors.namespace_ids["SECRETS"], Namespace::Secrets as u16); + assert_eq!(vectors.namespace_ids["GOVERNANCE"], Namespace::Governance as u16); + assert_eq!(vectors.namespace_ids["ATTESTATION"], Namespace::Attestation as u16); + assert_eq!(vectors.namespace_ids["AUDIT"], Namespace::Audit as u16); + assert_eq!(vectors.namespace_ids["NETWORK"], Namespace::Network as u16); + assert_eq!(vectors.namespace_ids["INTELLIGENCE"], Namespace::Intelligence as u16); + } + + #[test] + fn test_status_codes_match_python() { + let vectors = load_vectors(); + assert_eq!(vectors.status_codes["OK"], Status::Ok as u16); + assert_eq!(vectors.status_codes["ERROR"], Status::Error as u16); + assert_eq!(vectors.status_codes["DENIED"], Status::Denied as u16); + assert_eq!(vectors.status_codes["CEREMONY_REQUIRED"], Status::CeremonyRequired as u16); + } + + #[test] + fn test_decode_all_request_vectors() { + let vectors = load_vectors(); + for v in &vectors.vectors { + if v.vec_type != "request" { + continue; + } + let body = hex::decode(&v.body_hex).unwrap_or_else(|e| { + panic!("Failed to decode hex for {}: {}", v.name, e) + }); + let msg = ShellstreamMessage::decode(&body).unwrap_or_else(|e| { + panic!("Failed to decode request '{}': {}", v.name, e) + }); + + assert_eq!( + msg.namespace, + v.namespace.unwrap(), + "namespace mismatch for {}", + v.name + ); + assert_eq!( + msg.function, + v.function.unwrap(), + "function mismatch for {}", + v.name + ); + assert_eq!( + hex::encode(msg.session_id), + v.session_id_hex, + "session_id mismatch for {}", + v.name + ); + assert_eq!( + hex::encode(&msg.payload), + v.payload_hex, + "payload mismatch for {}", + v.name + ); + assert_eq!(msg.nonce, v.nonce, "nonce mismatch for {}", v.name); + } + } + + #[test] + fn test_decode_response_vectors() { + use substrate_rt::ShellstreamCodec; + + let vectors = load_vectors(); + for v in &vectors.vectors { + if v.vec_type != "response" { + continue; + } + let body = hex::decode(&v.body_hex).unwrap(); + let (status, session_id, payload, nonce) = + ShellstreamCodec::decode_response(&body).unwrap(); + + assert_eq!(status, v.status.unwrap(), "status mismatch for {}", v.name); + assert_eq!( + hex::encode(session_id), + v.session_id_hex, + "session_id mismatch for {}", + v.name + ); + assert_eq!( + hex::encode(&payload), + v.payload_hex, + "payload mismatch for {}", + v.name + ); + assert_eq!(nonce, v.nonce, "nonce mismatch for {}", v.name); + } + } + + #[test] + fn test_encode_response_matches_python() { + let vectors = load_vectors(); + for v in &vectors.vectors { + if v.vec_type != "response" { + continue; + } + let mut session_id = [0u8; 16]; + let sid_bytes = hex::decode(&v.session_id_hex).unwrap(); + session_id.copy_from_slice(&sid_bytes); + + let payload = hex::decode(&v.payload_hex).unwrap(); + let resp = ShellstreamResponse { + status: v.status.unwrap(), + session_id, + payload, + nonce: v.nonce, + }; + + let encoded = resp.encode(); + assert_eq!( + hex::encode(&encoded), + v.full_encoded_hex, + "encoded response mismatch for {}", + v.name + ); + } + } + + #[test] + fn test_namespace_roundtrip() { + for ns_val in 1u16..=8 { + let ns = Namespace::from_u16(ns_val).unwrap(); + assert_eq!(ns as u16, ns_val); + } + assert!(Namespace::from_u16(0).is_none()); + assert!(Namespace::from_u16(9).is_none()); + } + + #[test] + fn test_status_roundtrip() { + for s_val in 0u16..=3 { + let s = Status::from_u16(s_val).unwrap(); + assert_eq!(s as u16, s_val); + } + assert!(Status::from_u16(4).is_none()); + } + + #[test] + fn test_edge_case_empty_payload() { + let vectors = load_vectors(); + let v = vectors + .vectors + .iter() + .find(|v| v.name == "empty_payload") + .expect("empty_payload vector not found"); + + let body = hex::decode(&v.body_hex).unwrap(); + let msg = ShellstreamMessage::decode(&body).unwrap(); + assert_eq!(msg.nonce, 0); + // Empty dict {} in msgpack is 0x80 + assert_eq!(hex::encode(&msg.payload), "80"); + } + + #[test] + fn test_edge_case_max_nonce() { + let vectors = load_vectors(); + let v = vectors + .vectors + .iter() + .find(|v| v.name == "max_nonce") + .expect("max_nonce vector not found"); + + let body = hex::decode(&v.body_hex).unwrap(); + let msg = ShellstreamMessage::decode(&body).unwrap(); + assert_eq!(msg.nonce, u64::MAX); + } + + #[test] + fn test_length_prefix_big_endian() { + let vectors = load_vectors(); + let v = &vectors.vectors[0]; // any vector + let full = hex::decode(&v.full_encoded_hex).unwrap(); + let body = hex::decode(&v.body_hex).unwrap(); + + // First 4 bytes are big-endian body length + let len_bytes: [u8; 4] = full[..4].try_into().unwrap(); + let length = u32::from_be_bytes(len_bytes); + assert_eq!(length as usize, body.len()); + } + + #[test] + fn test_response_convenience_constructors() { + let sid = [0xABu8; 16]; + + let ok = ShellstreamResponse::ok(sid, 1, vec![0x80]); + assert_eq!(ok.status, Status::Ok as u16); + assert_eq!(ok.session_id, sid); + assert_eq!(ok.nonce, 1); + + let err = ShellstreamResponse::error(sid, 2, "boom"); + assert_eq!(err.status, Status::Error as u16); + + let denied = ShellstreamResponse::denied(sid, 3, "nope"); + assert_eq!(denied.status, Status::Denied as u16); + + let cer = ShellstreamResponse::ceremony_required(sid, 4, "cer-1", "SingleApproval"); + assert_eq!(cer.status, Status::CeremonyRequired as u16); + } +} diff --git a/bascule-agent/src/ssh_server.rs b/bascule-agent/src/ssh_server.rs new file mode 100644 index 0000000..4e6c09f --- /dev/null +++ b/bascule-agent/src/ssh_server.rs @@ -0,0 +1,511 @@ +//! SSH shell server — interactive governed shell sessions. +//! +//! Provides an SSH server that routes commands through the namespace +//! handlers. In dev/soft mode, accepts any public key authentication. + +use std::sync::Arc; + +use async_trait::async_trait; +use russh::server::{Auth, Config, Handler, Msg, Server, Session}; +use russh::{Channel, ChannelId}; +use ssh_key::{Algorithm, PrivateKey}; +use tracing::{debug, error, info, warn}; + +use crate::config::AgentConfig; +use crate::namespace::NamespaceRouter; +use crate::shellstream::{Namespace, ShellstreamResponse, Status}; + +/// The bascule-agent SSH server. +pub struct AgentSSHServer { + config: Arc, + russh_config: Arc, + router: Arc, +} + +impl AgentSSHServer { + pub fn new( + config: Arc, + router: Arc, + ) -> anyhow::Result { + // Load or generate host key + let host_key = if let Some(ref path) = config.agent.ssh.host_key_path { + if path.exists() { + info!(path = %path.display(), "Loading SSH host key"); + russh_keys::load_secret_key(path, None)? + } else { + info!("Generating ephemeral Ed25519 host key"); + let mut rng = rand::thread_rng(); + PrivateKey::random(&mut rng, Algorithm::Ed25519)? + } + } else { + info!("Generating ephemeral Ed25519 host key"); + let mut rng = rand::thread_rng(); + PrivateKey::random(&mut rng, Algorithm::Ed25519)? + }; + + let russh_config = Config { + keys: vec![host_key], + ..Default::default() + }; + + Ok(Self { + config, + russh_config: Arc::new(russh_config), + router, + }) + } + + /// Start the SSH server. Runs until cancelled. + pub async fn serve(mut self) -> anyhow::Result<()> { + let addr = &self.config.agent.ssh.listen_addr; + let listen: std::net::SocketAddr = addr + .parse() + .map_err(|e| anyhow::anyhow!("Invalid SSH listen address '{}': {}", addr, e))?; + + info!(addr = %listen, "SSH shell server listening"); + self.run_on_address(self.russh_config.clone(), listen).await?; + Ok(()) + } +} + +impl Server for AgentSSHServer { + type Handler = AgentShellHandler; + + fn new_client(&mut self, peer_addr: Option) -> AgentShellHandler { + let addr = peer_addr + .map(|a| a.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + info!(peer = %addr, "New SSH connection"); + + AgentShellHandler { + router: self.router.clone(), + dev_mode: self.config.agent.namespaces.backend == "soft", + peer_addr: addr, + identity: None, + terminal_width: 80, + session_state: None, + } + } +} + +/// Per-connection SSH handler. +pub struct AgentShellHandler { + router: Arc, + dev_mode: bool, + peer_addr: String, + identity: Option, + terminal_width: u16, + session_state: Option, +} + +struct ShellSessionState { + input_buffer: String, + channel_id: ChannelId, + session_id: [u8; 16], +} + +impl AgentShellHandler { + fn banner(&self) -> String { + let identity = self.identity.as_deref().unwrap_or("unknown"); + format!( + "\r\n\x1b[1;36m╔══════════════════════════════════════════╗\r\n\ + ║ bascule-agent governed shell ║\r\n\ + ╚══════════════════════════════════════════╝\x1b[0m\r\n\ + \r\n\ + Identity: {identity}\r\n\ + Mode: {mode}\r\n\ + \r\n\ + Type 'help' for available commands.\r\n\r\n", + mode = if self.dev_mode { "dev (soft)" } else { "live" }, + ) + } + + fn prompt(&self) -> String { + let identity = self.identity.as_deref().unwrap_or("?"); + format!("\x1b[1;32m{identity}\x1b[0m@\x1b[1;34magent\x1b[0m> ") + } + + async fn process_line( + &self, + line: &str, + handle: &russh::server::Handle, + channel_id: ChannelId, + session_id: &[u8; 16], + ) -> bool { + let line = line.trim(); + + if line == "exit" || line == "quit" { + let _ = handle.data(channel_id, "Goodbye.\r\n".into()).await; + let _ = handle.close(channel_id).await; + return true; + } + + if line.is_empty() { + let _ = handle.data(channel_id, self.prompt().into()).await; + return false; + } + + let output = match line { + "help" => self.cmd_help(), + "status" => self.cmd_status().await, + "whoami" => self.cmd_whoami(), + cmd if cmd.starts_with("audit ") => { + self.cmd_namespace("audit", &cmd[6..], session_id).await + } + cmd if cmd.starts_with("crypto ") => { + self.cmd_namespace("crypto", &cmd[7..], session_id).await + } + cmd if cmd.starts_with("identity ") => { + self.cmd_namespace("identity", &cmd[9..], session_id).await + } + cmd if cmd.starts_with("governance ") => { + self.cmd_namespace("governance", &cmd[11..], session_id).await + } + cmd if cmd.starts_with("secrets ") => { + self.cmd_namespace("secrets", &cmd[8..], session_id).await + } + cmd if cmd.starts_with("attestation ") => { + self.cmd_namespace("attestation", &cmd[12..], session_id).await + } + _ => format!("Unknown command: {}. Type 'help' for available commands.\r\n", line), + }; + + if !output.is_empty() { + let _ = handle.data(channel_id, output.into()).await; + } + let _ = handle.data(channel_id, self.prompt().into()).await; + false + } + + fn cmd_help(&self) -> String { + "\x1b[1mAvailable commands:\x1b[0m\r\n\ + \r\n\ + \x1b[33mShell:\x1b[0m\r\n\ + \x20 help Show this help message\r\n\ + \x20 status Show agent status\r\n\ + \x20 whoami Show current identity\r\n\ + \x20 exit Close the session\r\n\ + \r\n\ + \x1b[33mNamespaces:\x1b[0m\r\n\ + \x20 audit emit Emit an audit event\r\n\ + \x20 attestation posture Show posture level\r\n\ + \x20 crypto hash Hash data with SHA-256\r\n\ + \x20 governance gate Check governance gate\r\n\ + \x20 identity whoami Show identity claims\r\n\ + \x20 secrets get Look up a secret\r\n\ + \r\n" + .to_string() + } + + async fn cmd_status(&self) -> String { + // Query attestation posture through namespace handler + let payload = rmp_serde::to_vec(&serde_json::json!({})).unwrap_or_default(); + let session_id = [0u8; 16]; + let resp = self + .router + .handle(Namespace::Attestation, 0x01, &payload, &session_id) + .await; + + let posture: serde_json::Value = + rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null); + + format!( + "\x1b[1mAgent Status\x1b[0m\r\n\ + \x20 Mode: {mode}\r\n\ + \x20 Posture: {posture}\r\n\ + \x20 Peer: {peer}\r\n\r\n", + mode = if self.dev_mode { "soft (dev)" } else { "live" }, + posture = posture.get("level").and_then(|v| v.as_str()).unwrap_or("unknown"), + peer = self.peer_addr, + ) + } + + fn cmd_whoami(&self) -> String { + format!( + "Identity: {}\r\nMode: {}\r\nPeer: {}\r\n", + self.identity.as_deref().unwrap_or("unknown"), + if self.dev_mode { "dev" } else { "live" }, + self.peer_addr, + ) + } + + async fn cmd_namespace( + &self, + ns_name: &str, + args: &str, + session_id: &[u8; 16], + ) -> String { + let ns = match ns_name { + "audit" => Namespace::Audit, + "crypto" => Namespace::Crypto, + "identity" => Namespace::Identity, + "governance" => Namespace::Governance, + "secrets" => Namespace::Secrets, + "attestation" => Namespace::Attestation, + _ => return format!("Unknown namespace: {ns_name}\r\n"), + }; + + let (function_id, payload) = match parse_namespace_command(ns_name, args) { + Ok(v) => v, + Err(e) => return format!("Error: {e}\r\n"), + }; + + let resp = self.router.handle(ns, function_id, &payload, session_id).await; + + let status = Status::from_u16(resp.status); + let payload_parsed: serde_json::Value = + rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null); + + let status_str = match status { + Some(Status::Ok) => "\x1b[32mOK\x1b[0m", + Some(Status::Error) => "\x1b[31mERROR\x1b[0m", + Some(Status::Denied) => "\x1b[31mDENIED\x1b[0m", + Some(Status::CeremonyRequired) => "\x1b[33mCEREMONY_REQUIRED\x1b[0m", + None => "UNKNOWN", + }; + + format!( + "[{status_str}] {}\r\n", + serde_json::to_string_pretty(&payload_parsed).unwrap_or_else(|_| "{}".to_string()) + ) + .replace('\n', "\r\n") + } +} + +/// Parse a namespace subcommand into (function_id, msgpack payload). +fn parse_namespace_command(ns: &str, args: &str) -> anyhow::Result<(u16, Vec)> { + let parts: Vec<&str> = args.splitn(2, ' ').collect(); + let subcmd = parts.first().copied().unwrap_or(""); + let rest = parts.get(1).copied().unwrap_or(""); + + match (ns, subcmd) { + ("audit", "emit") => { + let payload = serde_json::json!({ + "event_type": "shell.command", + "subject": "ssh-user", + "resource": "shell", + "action": "emit", + "outcome": "success", + }); + Ok((0x01, rmp_serde::to_vec(&payload)?)) + } + ("audit", "anchor") => { + Ok((0x02, rmp_serde::to_vec(&serde_json::json!({"event_ids": []}))?)) + } + ("crypto", "hash") => { + let data = if rest.is_empty() { "test" } else { rest }; + Ok((0x03, rmp_serde::to_vec(&serde_json::json!({"data": data}))?)) + } + ("identity", "whoami") | ("identity", "authenticate") => { + Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"token": "ssh-session"}))?)) + } + ("governance", "gate") => { + Ok((0x01, rmp_serde::to_vec(&serde_json::json!({ + "subject": "ssh-user", + "resource": "shell", + "action": "access", + }))?)) + } + ("secrets", "get") => { + let path = if rest.is_empty() { "test/secret" } else { rest }; + Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"path": path}))?)) + } + ("attestation", "posture") => { + Ok((0x01, rmp_serde::to_vec(&serde_json::json!({}))?)) + } + ("attestation", "sat") => { + Ok((0x02, rmp_serde::to_vec(&serde_json::json!({}))?)) + } + _ => anyhow::bail!("Unknown {ns} subcommand: {subcmd}"), + } +} + +#[async_trait] +impl Handler for AgentShellHandler { + type Error = anyhow::Error; + + async fn auth_publickey( + &mut self, + user: &str, + public_key: &ssh_key::PublicKey, + ) -> Result { + let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string(); + + if self.dev_mode { + // Dev mode: accept any key + info!(user = %user, fingerprint = %fingerprint, "SSH auth accepted (dev mode)"); + self.identity = Some(user.to_string()); + Ok(Auth::Accept) + } else { + // TODO: validate against authorized keys store + warn!(fingerprint = %fingerprint, "SSH auth rejected: live mode not implemented"); + Ok(Auth::Reject { + proceed_with_methods: None, + }) + } + } + + async fn auth_password( + &mut self, + user: &str, + _password: &str, + ) -> Result { + if self.dev_mode { + info!(user = %user, "SSH password auth accepted (dev mode)"); + self.identity = Some(user.to_string()); + Ok(Auth::Accept) + } else { + Ok(Auth::Reject { + proceed_with_methods: None, + }) + } + } + + async fn channel_open_session( + &mut self, + _channel: Channel, + _session: &mut Session, + ) -> Result { + Ok(true) + } + + async fn pty_request( + &mut self, + _channel: ChannelId, + _term: &str, + col_width: u32, + _row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(russh::Pty, u32)], + session: &mut Session, + ) -> Result<(), Self::Error> { + self.terminal_width = col_width.min(200) as u16; + session.request_success(); + Ok(()) + } + + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + session.request_success(); + + let session_id: [u8; 16] = uuid::Uuid::new_v4().into_bytes(); + + let banner = self.banner(); + let prompt = self.prompt(); + session.data(channel, format!("{banner}{prompt}").into())?; + + self.session_state = Some(ShellSessionState { + input_buffer: String::new(), + channel_id: channel, + session_id, + }); + + Ok(()) + } + + async fn data( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + // Take session state temporarily to avoid borrow conflicts + let mut state = match self.session_state.take() { + Some(s) => s, + None => return Ok(()), + }; + + for &byte in data { + match byte { + // Enter + b'\r' | b'\n' => { + session.data(channel, "\r\n".into())?; + let line = state.input_buffer.clone(); + state.input_buffer.clear(); + + let handle = session.handle(); + let sid = state.session_id; + if self.process_line(&line, &handle, channel, &sid).await { + // Session ended — don't put state back + return Ok(()); + } + } + // Backspace / DEL + 0x7f | 0x08 => { + if !state.input_buffer.is_empty() { + state.input_buffer.pop(); + session.data(channel, "\x08 \x08".into())?; + } + } + // Ctrl-C + 0x03 => { + state.input_buffer.clear(); + session.data(channel, "^C\r\n".into())?; + let prompt = self.prompt(); + session.data(channel, prompt.into())?; + } + // Ctrl-D (EOF) + 0x04 => { + session.data(channel, "\r\nGoodbye.\r\n".into())?; + session.close(channel)?; + return Ok(()); + } + // Printable characters + b if b >= 0x20 && b < 0x7f => { + state.input_buffer.push(b as char); + session.data(channel, vec![b].into())?; + } + // Ignore other control characters + _ => {} + } + } + + // Put session state back + self.session_state = Some(state); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_namespace_commands() { + let (fn_id, _) = parse_namespace_command("audit", "emit").unwrap(); + assert_eq!(fn_id, 0x01); + + let (fn_id, _) = parse_namespace_command("crypto", "hash test data").unwrap(); + assert_eq!(fn_id, 0x03); + + let (fn_id, _) = parse_namespace_command("governance", "gate").unwrap(); + assert_eq!(fn_id, 0x01); + + let (fn_id, _) = parse_namespace_command("attestation", "posture").unwrap(); + assert_eq!(fn_id, 0x01); + + let (fn_id, _) = parse_namespace_command("secrets", "get db/password").unwrap(); + assert_eq!(fn_id, 0x01); + + assert!(parse_namespace_command("audit", "nonexistent").is_err()); + } + + #[test] + fn test_parse_crypto_hash_payload() { + let (_, payload) = parse_namespace_command("crypto", "hash hello world").unwrap(); + let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap(); + assert_eq!(parsed["data"], "hello world"); + } + + #[test] + fn test_parse_secrets_get_path() { + let (_, payload) = parse_namespace_command("secrets", "get db/password").unwrap(); + let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap(); + assert_eq!(parsed["path"], "db/password"); + } +} diff --git a/bascule-agent/tests/e2e-config.toml b/bascule-agent/tests/e2e-config.toml new file mode 100644 index 0000000..e389e30 --- /dev/null +++ b/bascule-agent/tests/e2e-config.toml @@ -0,0 +1,12 @@ +# Test config for E2E verification — Python SDK ↔ Rust bascule-agent +[agent] +socket_path = "/tmp/bascule-agent-e2e.sock" + +[agent.ssh] +enabled = false + +[agent.namespaces] +backend = "soft" + +[agent.namespaces.attestation] +default_posture = "normal" diff --git a/bascule-agent/tests/fixtures/shellstream_vectors.json b/bascule-agent/tests/fixtures/shellstream_vectors.json new file mode 100644 index 0000000..18a1417 --- /dev/null +++ b/bascule-agent/tests/fixtures/shellstream_vectors.json @@ -0,0 +1,227 @@ +{ + "generator": "substrate-sdk-python", + "protocol_version": "1.0", + "namespace_ids": { + "CRYPTO": 1, + "IDENTITY": 2, + "SECRETS": 3, + "GOVERNANCE": 4, + "ATTESTATION": 5, + "AUDIT": 6, + "NETWORK": 7, + "INTELLIGENCE": 8 + }, + "status_codes": { + "OK": 0, + "ERROR": 1, + "DENIED": 2, + "CEREMONY_REQUIRED": 3 + }, + "vectors": [ + { + "name": "crypto_sign", + "type": "request", + "namespace": 1, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "83a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da765643235353139", + "nonce": 42, + "body_hex": "950101c4100102030405060708090a0b0c0d0e0f10c43283a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da7656432353531392a", + "full_encoded_hex": "0000004a950101c4100102030405060708090a0b0c0d0e0f10c43283a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da7656432353531392a" + }, + { + "name": "crypto_verify", + "type": "request", + "namespace": 1, + "function": 2, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "84a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da765643235353139", + "nonce": 42, + "body_hex": "950102c4100102030405060708090a0b0c0d0e0f10c44184a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da7656432353531392a", + "full_encoded_hex": "00000059950102c4100102030405060708090a0b0c0d0e0f10c44184a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da7656432353531392a" + }, + { + "name": "identity_auth", + "type": "request", + "namespace": 2, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "81a5746f6b656ea665794a2e2e2e", + "nonce": 42, + "body_hex": "950201c4100102030405060708090a0b0c0d0e0f10c40e81a5746f6b656ea665794a2e2e2e2a", + "full_encoded_hex": "00000026950201c4100102030405060708090a0b0c0d0e0f10c40e81a5746f6b656ea665794a2e2e2e2a" + }, + { + "name": "identity_authz", + "type": "request", + "namespace": 2, + "function": 2, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "83a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea472656164", + "nonce": 42, + "body_hex": "950202c4100102030405060708090a0b0c0d0e0f10c43083a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea4726561642a", + "full_encoded_hex": "00000048950202c4100102030405060708090a0b0c0d0e0f10c43083a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea4726561642a" + }, + { + "name": "secrets_get", + "type": "request", + "namespace": 3, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "81a470617468ab64622f70617373776f7264", + "nonce": 42, + "body_hex": "950301c4100102030405060708090a0b0c0d0e0f10c41281a470617468ab64622f70617373776f72642a", + "full_encoded_hex": "0000002a950301c4100102030405060708090a0b0c0d0e0f10c41281a470617468ab64622f70617373776f72642a" + }, + { + "name": "governance_gate", + "type": "request", + "namespace": 4, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "83a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d7574617465", + "nonce": 42, + "body_hex": "950401c4100102030405060708090a0b0c0d0e0f10c43283a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d75746174652a", + "full_encoded_hex": "0000004a950401c4100102030405060708090a0b0c0d0e0f10c43283a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d75746174652a" + }, + { + "name": "governance_propose", + "type": "request", + "namespace": 4, + "function": 2, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "82ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e", + "nonce": 42, + "body_hex": "950402c4100102030405060708090a0b0c0d0e0f10c43982ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e2a", + "full_encoded_hex": "00000051950402c4100102030405060708090a0b0c0d0e0f10c43982ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e2a" + }, + { + "name": "attestation_posture", + "type": "request", + "namespace": 5, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "80", + "nonce": 42, + "body_hex": "950501c4100102030405060708090a0b0c0d0e0f10c401802a", + "full_encoded_hex": "00000019950501c4100102030405060708090a0b0c0d0e0f10c401802a" + }, + { + "name": "attestation_sat", + "type": "request", + "namespace": 5, + "function": 2, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "80", + "nonce": 42, + "body_hex": "950502c4100102030405060708090a0b0c0d0e0f10c401802a", + "full_encoded_hex": "00000019950502c4100102030405060708090a0b0c0d0e0f10c401802a" + }, + { + "name": "audit_emit", + "type": "request", + "namespace": 6, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a773756363657373", + "nonce": 42, + "body_hex": "950601c4100102030405060708090a0b0c0d0e0f10c45a85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a7737563636573732a", + "full_encoded_hex": "00000072950601c4100102030405060708090a0b0c0d0e0f10c45a85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a7737563636573732a" + }, + { + "name": "audit_anchor", + "type": "request", + "namespace": 6, + "function": 2, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "81a96576656e745f69647392a76576742d303031a76576742d303032", + "nonce": 42, + "body_hex": "950602c4100102030405060708090a0b0c0d0e0f10c41c81a96576656e745f69647392a76576742d303031a76576742d3030322a", + "full_encoded_hex": "00000034950602c4100102030405060708090a0b0c0d0e0f10c41c81a96576656e745f69647392a76576742d303031a76576742d3030322a" + }, + { + "name": "network_classify", + "type": "request", + "namespace": 7, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca3746370", + "nonce": 42, + "body_hex": "950701c4100102030405060708090a0b0c0d0e0f10c43b84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca37463702a", + "full_encoded_hex": "00000053950701c4100102030405060708090a0b0c0d0e0f10c43b84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca37463702a" + }, + { + "name": "intelligence_infer", + "type": "request", + "namespace": 8, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f", + "nonce": 42, + "body_hex": "950801c4100102030405060708090a0b0c0d0e0f10c42d82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f2a", + "full_encoded_hex": "00000045950801c4100102030405060708090a0b0c0d0e0f10c42d82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f2a" + }, + { + "name": "response_ok", + "type": "response", + "status": 0, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "82a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a6616263313233", + "nonce": 42, + "body_hex": "9400c4100102030405060708090a0b0c0d0e0f10c42582a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a66162633132332a", + "full_encoded_hex": "0000003c9400c4100102030405060708090a0b0c0d0e0f10c42582a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a66162633132332a" + }, + { + "name": "response_error", + "type": "response", + "status": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "81a76d657373616765ae496e7465726e616c206572726f72", + "nonce": 42, + "body_hex": "9401c4100102030405060708090a0b0c0d0e0f10c41881a76d657373616765ae496e7465726e616c206572726f722a", + "full_encoded_hex": "0000002f9401c4100102030405060708090a0b0c0d0e0f10c41881a76d657373616765ae496e7465726e616c206572726f722a" + }, + { + "name": "response_denied", + "type": "response", + "status": 2, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "82a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d7574617465", + "nonce": 42, + "body_hex": "9402c4100102030405060708090a0b0c0d0e0f10c43282a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d75746174652a", + "full_encoded_hex": "000000499402c4100102030405060708090a0b0c0d0e0f10c43282a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d75746174652a" + }, + { + "name": "response_ceremony", + "type": "response", + "status": 3, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "82ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c", + "nonce": 42, + "body_hex": "9403c4100102030405060708090a0b0c0d0e0f10c43282ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c2a", + "full_encoded_hex": "000000499403c4100102030405060708090a0b0c0d0e0f10c43282ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c2a" + }, + { + "name": "empty_payload", + "type": "request", + "namespace": 5, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "80", + "nonce": 0, + "body_hex": "950501c4100102030405060708090a0b0c0d0e0f10c4018000", + "full_encoded_hex": "00000019950501c4100102030405060708090a0b0c0d0e0f10c4018000" + }, + { + "name": "max_nonce", + "type": "request", + "namespace": 1, + "function": 1, + "session_id_hex": "0102030405060708090a0b0c0d0e0f10", + "payload_hex": "81a66b65795f6964a16b", + "nonce": 18446744073709551615, + "body_hex": "950101c4100102030405060708090a0b0c0d0e0f10c40a81a66b65795f6964a16bcfffffffffffffffff", + "full_encoded_hex": "0000002a950101c4100102030405060708090a0b0c0d0e0f10c40a81a66b65795f6964a16bcfffffffffffffffff" + } + ] +} diff --git a/bascule-core/Cargo.toml b/bascule-core/Cargo.toml new file mode 100644 index 0000000..7873e9c --- /dev/null +++ b/bascule-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bascule-core" +version = "0.1.0" +edition = "2021" +description = "Shared types for the Bascule governance-mediated access control system" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serde_json_canonicalizer = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true } +# Governance ceremony state machine (extracted) +ceremony-engine = { workspace = true } + +# Cross-workspace path deps — Guildhouse governance primitives. +accord-core = { path = "../../guildhouse/services/accord-core" } +registry-protocol = { path = "../../guildhouse/services/registry-protocol" } diff --git a/bascule-core/src/audit.rs b/bascule-core/src/audit.rs new file mode 100644 index 0000000..67aa623 --- /dev/null +++ b/bascule-core/src/audit.rs @@ -0,0 +1,92 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::command::{ChangeClassification, CommandRecord, ResourceRef}; +use crate::session::OperatorIdentity; + +/// A complete audit event for a command execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEvent { + pub event_id: Uuid, + pub session_id: Uuid, + pub operator_identity: OperatorIdentity, + pub timestamp: DateTime, + pub command: CommandRecord, + pub classification: ChangeClassification, + pub policy_decision: PolicyDecision, + pub execution_result: ExecutionResult, + pub target_resources: Vec, + pub target_profile_hash: Option, +} + +/// The result of policy evaluation for a command. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyDecision { + pub allowed: bool, + pub policy_bundle_hash: String, + pub accord_version: String, + pub evaluation_duration_ms: u32, + pub denied_reason: Option, +} + +impl PolicyDecision { + /// Create an allow-all stub decision (used in Phase 1 when OPA is not deployed). + pub fn allow_all_stub() -> Self { + Self { + allowed: true, + policy_bundle_hash: "stub".into(), + accord_version: "none".into(), + evaluation_duration_ms: 0, + denied_reason: None, + } + } +} + +/// The result of executing a command. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionResult { + pub status: ExecutionStatus, + pub summary: String, + pub resources_affected: u32, + pub mutations_applied: u32, +} + +/// Execution outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionStatus { + Success, + Denied, + Error, + Timeout, +} + +impl ExecutionResult { + pub fn success(summary: String) -> Self { + Self { + status: ExecutionStatus::Success, + summary, + resources_affected: 0, + mutations_applied: 0, + } + } + + pub fn denied(reason: String) -> Self { + Self { + status: ExecutionStatus::Denied, + summary: reason, + resources_affected: 0, + mutations_applied: 0, + } + } + + pub fn error(msg: String) -> Self { + Self { + status: ExecutionStatus::Error, + summary: msg, + resources_affected: 0, + mutations_applied: 0, + } + } +} diff --git a/bascule-core/src/ceremony.rs b/bascule-core/src/ceremony.rs new file mode 100644 index 0000000..825ea6c --- /dev/null +++ b/bascule-core/src/ceremony.rs @@ -0,0 +1,67 @@ +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::scope::SessionScope; +use crate::session::OperatorIdentity; + +/// Types of ceremonies that produce session grants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CeremonyType { + /// Standing policy auto-approval. + SelfGrant, + /// One human approver required. + SingleApproval, + /// Break-glass with external evidence. + EmergencyAccess, + /// LLM operator with human co-approver. + LlmCoApproval, +} + +/// A request to start a ceremony. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CeremonyRequest { + pub ceremony_type: CeremonyType, + pub requestor: OperatorIdentity, + pub requested_scope: SessionScope, + pub evidence: Vec, + pub requested_at: DateTime, +} + +/// The result of a completed ceremony. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CeremonyGrant { + pub ceremony_id: Uuid, + pub ceremony_type: CeremonyType, + pub requestor: OperatorIdentity, + pub approvers: Vec, + /// The scope that was actually granted (may be narrower than requested). + pub granted_scope: SessionScope, + pub accord_version: String, + pub evidence: Vec, + pub granted_at: DateTime, + pub session_lifetime: Duration, +} + +/// External evidence supporting a ceremony (e.g. Jira ticket). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Evidence { + pub evidence_type: EvidenceType, + /// The external reference ("INCIDENT-1234", URL, etc.). + pub reference: String, + /// Whether the gateway verified the reference exists. + pub verified: bool, + pub verified_at: Option>, +} + +/// Types of external evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceType { + JiraTicket, + GitHubIssue, + SlackThread, + PagerDutyIncident, + Manual, +} diff --git a/bascule-core/src/command.rs b/bascule-core/src/command.rs new file mode 100644 index 0000000..838d9f1 --- /dev/null +++ b/bascule-core/src/command.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; + +/// A record of a command that was executed (for audit purposes). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandRecord { + pub verb: String, + pub namespace: Option, + pub resource_type: Option, + pub resource_name: Option, + pub parameters: serde_json::Value, +} + +/// Classification of a command's impact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeClassification { + Read, + Mutative, + Workspace, + Session, +} + +/// Metadata about a command verb, used for discovery and classification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandDescriptor { + pub verb: String, + pub description: String, + pub classification: ChangeClassification, + pub requires_namespace: bool, + pub requires_resource: bool, + pub streaming: bool, +} + +/// Reference to a Kubernetes resource. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceRef { + pub api_group: String, + pub kind: String, + pub namespace: String, + pub name: String, +} + +/// Returns the built-in command descriptors. +pub fn builtin_commands() -> Vec { + vec![ + // Read commands + CommandDescriptor { + verb: "get".into(), + description: "Get resources".into(), + classification: ChangeClassification::Read, + requires_namespace: true, + requires_resource: true, + streaming: false, + }, + CommandDescriptor { + verb: "describe".into(), + description: "Describe a resource in detail".into(), + classification: ChangeClassification::Read, + requires_namespace: true, + requires_resource: true, + streaming: false, + }, + CommandDescriptor { + verb: "logs".into(), + description: "View pod logs".into(), + classification: ChangeClassification::Read, + requires_namespace: true, + requires_resource: true, + streaming: true, + }, + CommandDescriptor { + verb: "status".into(), + description: "Cluster health summary".into(), + classification: ChangeClassification::Read, + requires_namespace: false, + requires_resource: false, + streaming: false, + }, + CommandDescriptor { + verb: "profiles_list".into(), + description: "List capability profiles".into(), + classification: ChangeClassification::Read, + requires_namespace: false, + requires_resource: false, + streaming: false, + }, + CommandDescriptor { + verb: "profiles_get".into(), + description: "Show a capability profile".into(), + classification: ChangeClassification::Read, + requires_namespace: false, + requires_resource: true, + streaming: false, + }, + // Mutative commands (Phase 2) + CommandDescriptor { + verb: "scale".into(), + description: "Scale a deployment".into(), + classification: ChangeClassification::Mutative, + requires_namespace: true, + requires_resource: true, + streaming: false, + }, + CommandDescriptor { + verb: "patch".into(), + description: "Patch a resource".into(), + classification: ChangeClassification::Mutative, + requires_namespace: true, + requires_resource: true, + streaming: false, + }, + // Session commands + CommandDescriptor { + verb: "session_status".into(), + description: "Show session scope and lifetime".into(), + classification: ChangeClassification::Session, + requires_namespace: false, + requires_resource: false, + streaming: false, + }, + CommandDescriptor { + verb: "session_end".into(), + description: "End the current session".into(), + classification: ChangeClassification::Session, + requires_namespace: false, + requires_resource: false, + streaming: false, + }, + ] +} diff --git a/bascule-core/src/lib.rs b/bascule-core/src/lib.rs new file mode 100644 index 0000000..79d8b57 --- /dev/null +++ b/bascule-core/src/lib.rs @@ -0,0 +1,28 @@ +pub mod audit; +pub mod ceremony; +pub mod command; +pub mod scope; +pub mod session; + +// Governance ceremony engine — extracted to ceremony-engine crate. +// Re-exported here for backward compatibility while consumers migrate. +pub mod ceremony_engine { + pub use ceremony_engine::CeremonyEngine; +} +pub mod ceremony_request { + pub use ceremony_engine::{ + ApprovalDecision, CeremonyApproval, CeremonyError, CeremonySubject, + GovernanceCeremonyRequest, GovernanceCeremonyStatus, + }; +} +pub mod ceremony_resolution { + pub use ceremony_engine::CeremonyResolution; +} +pub mod ceremony_store { + pub use ceremony_engine::{ + CeremonyStore, CeremonyStoreError, InMemoryCeremonyStore, + }; +} +pub mod ceremony_artifact { + pub use ceremony_engine::CeremonyVerb; +} diff --git a/bascule-core/src/scope.rs b/bascule-core/src/scope.rs new file mode 100644 index 0000000..db237f1 --- /dev/null +++ b/bascule-core/src/scope.rs @@ -0,0 +1,168 @@ +use serde::{Deserialize, Serialize}; + +/// Defines what an operator can do within a session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionScope { + pub namespaces: Vec, + pub global: GlobalScope, + pub pathways: Vec, + /// Maximum mutations before session requires a new ceremony. None = unlimited. + pub mutation_budget: Option, + pub can_delegate: bool, +} + +/// Per-namespace access rules. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceScope { + pub namespace: String, + pub rules: Vec, + /// Workload profile hashes this session can interact with. + /// Empty = all workloads in namespace (if accord permits). + pub workload_profiles: Vec, + /// Capability-negative filter: deny interaction with workloads + /// whose profiles include these capabilities. + pub denied_capabilities: Vec, +} + +/// A single permission rule (similar to k8s RBAC but higher-level). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeRule { + /// API groups ("" = core, "apps", etc.) + pub api_groups: Vec, + /// Resource types ("pods", "deployments", etc.) + pub resources: Vec, + pub verbs: Vec, +} + +/// Command verbs that can be granted in a session scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Verb { + Get, + List, + Watch, + Create, + Update, + Patch, + Delete, + Exec, + Logs, + Scale, +} + +impl Verb { + pub fn as_str(&self) -> &'static str { + match self { + Verb::Get => "get", + Verb::List => "list", + Verb::Watch => "watch", + Verb::Create => "create", + Verb::Update => "update", + Verb::Patch => "patch", + Verb::Delete => "delete", + Verb::Exec => "exec", + Verb::Logs => "logs", + Verb::Scale => "scale", + } + } +} + +/// Cross-namespace permissions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobalScope { + pub can_view_audit_trail: bool, + pub can_view_profiles: bool, + pub can_view_topology: bool, +} + +/// How mutations flow through the system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangePathway { + /// Apply changes immediately (emergency only). + Direct, + /// Stage in workspace, merge through policy gate. + Workspace, + /// Simulate only, no actual mutations. + DryRunOnly, +} + +impl SessionScope { + /// Check if this scope permits the given verb on the given resource in the given namespace. + pub fn permits(&self, namespace: &str, api_group: &str, resource: &str, verb: Verb) -> bool { + self.namespaces.iter().any(|ns| { + ns.namespace == namespace + && ns.rules.iter().any(|rule| { + (rule.api_groups.contains(&api_group.to_string()) + || rule.api_groups.contains(&"*".to_string())) + && (rule.resources.contains(&resource.to_string()) + || rule.resources.contains(&"*".to_string())) + && rule.verbs.contains(&verb) + }) + }) + } +} + +impl Default for GlobalScope { + fn default() -> Self { + Self { + can_view_audit_trail: false, + can_view_profiles: true, + can_view_topology: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_permits_matching_rule() { + let scope = SessionScope { + namespaces: vec![NamespaceScope { + namespace: "default".to_string(), + rules: vec![ScopeRule { + api_groups: vec!["".to_string()], + resources: vec!["pods".to_string()], + verbs: vec![Verb::Get, Verb::List], + }], + workload_profiles: vec![], + denied_capabilities: vec![], + }], + global: GlobalScope::default(), + pathways: vec![ChangePathway::DryRunOnly], + mutation_budget: None, + can_delegate: false, + }; + + assert!(scope.permits("default", "", "pods", Verb::Get)); + assert!(scope.permits("default", "", "pods", Verb::List)); + assert!(!scope.permits("default", "", "pods", Verb::Delete)); + assert!(!scope.permits("other", "", "pods", Verb::Get)); + assert!(!scope.permits("default", "", "deployments", Verb::Get)); + } + + #[test] + fn scope_wildcard_matches_all() { + let scope = SessionScope { + namespaces: vec![NamespaceScope { + namespace: "default".to_string(), + rules: vec![ScopeRule { + api_groups: vec!["*".to_string()], + resources: vec!["*".to_string()], + verbs: vec![Verb::Get, Verb::List, Verb::Logs], + }], + workload_profiles: vec![], + denied_capabilities: vec![], + }], + global: GlobalScope::default(), + pathways: vec![], + mutation_budget: None, + can_delegate: false, + }; + + assert!(scope.permits("default", "", "pods", Verb::Get)); + assert!(scope.permits("default", "apps", "deployments", Verb::List)); + } +} diff --git a/bascule-core/src/session.rs b/bascule-core/src/session.rs new file mode 100644 index 0000000..2acedad --- /dev/null +++ b/bascule-core/src/session.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::scope::SessionScope; + +/// An active operator session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub session_id: Uuid, + pub ceremony_id: Uuid, + pub identity: OperatorIdentity, + pub scope: SessionScope, + pub state: SessionState, + pub mutations_used: u32, + pub valid_from: DateTime, + pub expires_at: DateTime, +} + +/// The identity of an operator interacting with the gateway. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OperatorIdentity { + /// External operator authenticated via OIDC. + Oidc { + issuer: String, + subject: String, + email: String, + }, + /// Cluster-native workload with SPIFFE SVID (future). + Spiffe { svid_uri: String }, +} + +impl OperatorIdentity { + /// A display-friendly identity string for audit logs. + pub fn display_id(&self) -> String { + match self { + OperatorIdentity::Oidc { email, .. } => format!("oidc:{email}"), + OperatorIdentity::Spiffe { svid_uri } => format!("spiffe:{svid_uri}"), + } + } +} + +/// Session lifecycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionState { + Active, + Terminated, + Expired, +} + +impl Session { + /// Check if this session is still valid for executing commands. + pub fn is_active(&self) -> bool { + self.state == SessionState::Active && Utc::now() < self.expires_at + } + + /// Check if the mutation budget is exhausted. + pub fn budget_exhausted(&self) -> bool { + self.scope + .mutation_budget + .map(|budget| self.mutations_used >= budget) + .unwrap_or(false) + } + + /// Increment the mutation counter. Returns the new count. + pub fn record_mutation(&mut self) -> u32 { + self.mutations_used += 1; + self.mutations_used + } +} diff --git a/bascule-filter-core/Cargo.toml b/bascule-filter-core/Cargo.toml new file mode 100644 index 0000000..6546211 --- /dev/null +++ b/bascule-filter-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bascule-filter-core" +version = "0.1.0" +edition = "2021" +description = "Shared types and traits for the Bascule governed log filter plugin system" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } diff --git a/bascule-filter-core/src/filter.rs b/bascule-filter-core/src/filter.rs new file mode 100644 index 0000000..f639cbf --- /dev/null +++ b/bascule-filter-core/src/filter.rs @@ -0,0 +1,39 @@ +//! LogFilter trait — the extensibility boundary for filter plugins. + +use crate::LogLine; + +/// Result of applying a filter to a LogLine. +#[derive(Debug, Clone)] +pub enum FilterResult { + /// Emit this line (possibly transformed). + Pass(LogLine), + /// Suppress this line. + Drop, +} + +/// A log filter plugin. +/// +/// Implementors may be: +/// - In-process (built-in, compiled into bascule-shell) +/// - Out-of-process (standalone binary via ProcessFilter + stdio protocol) +/// - WASM (future — same trait, different runtime) +/// +/// The `filter_id()` method enables Chronicle attribution for governed filter plugins. +/// A named, governed filter is a verifiable transform on a governed log stream. +pub trait LogFilter: Send + Sync { + /// Apply this filter to a log line. + /// + /// MUST: never drop a line where `line.is_denial == true`. + /// Denial lines are governance events and must always pass through. + fn filter(&self, line: LogLine) -> FilterResult; + + /// Human-readable filter name. + fn name(&self) -> &str; + + /// Stable identifier for Chronicle attribution. + /// `None` = anonymous built-in filter + /// `Some()` = named governed filter (Chronicle-attributable) + fn filter_id(&self) -> Option<&str> { + None + } +} diff --git a/bascule-filter-core/src/lib.rs b/bascule-filter-core/src/lib.rs new file mode 100644 index 0000000..f06bea9 --- /dev/null +++ b/bascule-filter-core/src/lib.rs @@ -0,0 +1,19 @@ +//! bascule-filter-core — shared types and traits for the governed log filter plugin system. +//! +//! This crate defines the extensibility boundary for Bascule log filters. +//! Any filter binary (built-in or standalone) depends only on this crate. +//! +//! Three integration modes: +//! 1. In-process (compiled into bascule-shell) +//! 2. Out-of-process (standalone binary via ProcessFilter + stdio protocol) +//! 3. WASM (future — same trait, different runtime) + +pub mod filter; +pub mod line; +pub mod process; +pub mod stdio; + +pub use filter::{FilterResult, LogFilter}; +pub use line::LogLine; +pub use process::ProcessFilter; +pub use stdio::stdio_main; diff --git a/bascule-filter-core/src/line.rs b/bascule-filter-core/src/line.rs new file mode 100644 index 0000000..0fd1900 --- /dev/null +++ b/bascule-filter-core/src/line.rs @@ -0,0 +1,80 @@ +//! LogLine — the atomic unit of the filter protocol. +//! +//! Serialized as newline-delimited JSON for the stdio filter protocol. +//! Every filter binary receives and emits this type. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// A single attributed log line from a pod. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogLine { + /// Pod name (e.g., "api-svc-7d9f4b-xkp2q"). + pub pod: String, + + /// Kubernetes namespace. + pub namespace: String, + + /// Parsed timestamp if present in line. + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option>, + + /// Raw line content. + pub content: String, + + /// True if this is a governance denial, not actual log output. + /// Denial lines MUST NOT be dropped by any filter — they are governance events. + #[serde(default)] + pub is_denial: bool, +} + +impl LogLine { + pub fn new(pod: impl Into, namespace: impl Into, content: impl Into) -> Self { + Self { + pod: pod.into(), + namespace: namespace.into(), + timestamp: None, + content: content.into(), + is_denial: false, + } + } + + /// Governance denial line — used by Phase 2 authorization layer. + pub fn denied(pod: impl Into, namespace: impl Into, reason: impl Into) -> Self { + Self { + pod: pod.into(), + namespace: namespace.into(), + timestamp: Some(Utc::now()), + content: format!("[DENIED: {}]", reason.into()), + is_denial: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_is_not_denial() { + let l = LogLine::new("p", "ns", "msg"); + assert!(!l.is_denial); + } + + #[test] + fn denied_sets_flag() { + let l = LogLine::denied("p", "ns", "no capability"); + assert!(l.is_denial); + assert!(l.content.contains("DENIED")); + } + + #[test] + fn roundtrip_json() { + let l = LogLine::new("api-1", "prod", "ERROR timeout"); + let json = serde_json::to_string(&l).unwrap(); + let back: LogLine = serde_json::from_str(&json).unwrap(); + assert_eq!(back.pod, "api-1"); + assert_eq!(back.content, "ERROR timeout"); + assert!(!back.is_denial); + } +} diff --git a/bascule-filter-core/src/process.rs b/bascule-filter-core/src/process.rs new file mode 100644 index 0000000..bbdb48b --- /dev/null +++ b/bascule-filter-core/src/process.rs @@ -0,0 +1,92 @@ +//! ProcessFilter — wraps a standalone filter binary. +//! +//! The binary must implement the stdio protocol: +//! read LogLine JSON from stdin, write back (possibly modified) +//! LogLine JSON or nothing (to drop). +//! +//! This is how future standalone filters plug in: +//! bascule-filter-regex, bascule-filter-jq, bascule-filter-rate, etc. + +use std::io::BufReader; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use crate::{FilterResult, LogFilter, LogLine}; + +/// Wraps a standalone filter binary communicating via stdio. +pub struct ProcessFilter { + /// Path to the filter binary. + binary: String, + /// Arguments passed to the binary. + #[allow(dead_code)] + args: Vec, + child: Child, + #[allow(dead_code)] + stdin: ChildStdin, + #[allow(dead_code)] + stdout: BufReader, +} + +impl ProcessFilter { + /// Spawn the filter binary. + pub fn spawn( + binary: impl Into, + args: impl IntoIterator>, + ) -> anyhow::Result { + let binary = binary.into(); + let args: Vec = args.into_iter().map(|a| a.into()).collect(); + + let mut child = Command::new(&binary) + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn filter '{}': {}", binary, e))?; + + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + + Ok(Self { + binary, + args, + child, + stdin, + stdout, + }) + } +} + +impl LogFilter for ProcessFilter { + fn filter(&self, line: LogLine) -> FilterResult { + // Denial lines bypass the external process entirely. + if line.is_denial { + return FilterResult::Pass(line); + } + + // NOTE: ProcessFilter requires interior mutability for production use. + // The stdio protocol is: write LogLine JSON to child stdin, read response + // from child stdout. This requires &mut self (stdin write + stdout read). + // + // Phase 2 will use Arc> or an async channel-based + // design. For now, this stub passes the line through unchanged and logs + // a warning if actually invoked. + tracing::warn!( + binary = %self.binary, + "ProcessFilter::filter() called — sync I/O stub, line passed through unchanged" + ); + FilterResult::Pass(line) + } + + fn name(&self) -> &str { + "process" + } + + fn filter_id(&self) -> Option<&str> { + Some(&self.binary) + } +} + +impl Drop for ProcessFilter { + fn drop(&mut self) { + let _ = self.child.kill(); + } +} diff --git a/bascule-filter-core/src/stdio.rs b/bascule-filter-core/src/stdio.rs new file mode 100644 index 0000000..e3be027 --- /dev/null +++ b/bascule-filter-core/src/stdio.rs @@ -0,0 +1,87 @@ +//! stdio_main — helper for standalone filter binaries. +//! +//! A complete filter binary: +//! ```ignore +//! fn main() { +//! let filter = MyFilter::new(); +//! bascule_filter_core::stdio_main(filter); +//! } +//! ``` +//! +//! Reads LogLine JSON from stdin (one per line), +//! applies filter, writes passing lines to stdout. + +use std::io::{self, BufRead, Write}; + +use crate::{FilterResult, LogFilter, LogLine}; + +/// Run a filter as a stdio pipeline process. +/// +/// Reads newline-delimited JSON LogLine from stdin, +/// applies the filter, writes passing lines to stdout. +/// Exits cleanly on EOF or read error. +pub fn stdio_main(filter: impl LogFilter) { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut out = stdout.lock(); + + for line in stdin.lock().lines() { + let raw = match line { + Ok(l) => l, + Err(e) => { + tracing::warn!("stdin read error: {e}"); + break; + } + }; + + if raw.is_empty() { + continue; + } + + let log_line: LogLine = match serde_json::from_str(&raw) { + Ok(l) => l, + Err(e) => { + tracing::warn!("LogLine parse error: {e} — raw: {raw}"); + continue; + } + }; + + if let FilterResult::Pass(out_line) = filter.filter(log_line) { + if let Ok(json) = serde_json::to_string(&out_line) { + let _ = writeln!(out, "{json}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct AlwaysPass; + impl LogFilter for AlwaysPass { + fn filter(&self, line: LogLine) -> FilterResult { + FilterResult::Pass(line) + } + fn name(&self) -> &str { + "always-pass" + } + } + + #[test] + fn stdio_protocol_roundtrip() { + // Verify LogLine can be serialized and deserialized + // (the actual stdio_main uses real stdin/stdout, + // so we test the data format, not the I/O loop) + let line = LogLine::new("pod-1", "default", "hello world"); + let json = serde_json::to_string(&line).unwrap(); + let parsed: LogLine = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.pod, "pod-1"); + assert_eq!(parsed.content, "hello world"); + + // Verify filter works on parsed line + let filter = AlwaysPass; + let result = filter.filter(parsed); + assert!(matches!(result, FilterResult::Pass(_))); + } +} diff --git a/bascule-gateway/Cargo.toml b/bascule-gateway/Cargo.toml new file mode 100644 index 0000000..13dc852 --- /dev/null +++ b/bascule-gateway/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "bascule-gateway" +version = "0.1.0" +edition = "2021" +description = "Bascule governance gateway — cluster-side API gateway for governed access" + +[[bin]] +name = "bascule-gateway" +path = "src/main.rs" + +[dependencies] +bascule-core = { workspace = true } +bascule-proto = { workspace = true } + +# Cross-workspace path deps — Guildhouse governance/ceremony primitives. +# Future: extract to standalone crates. +accord-core = { path = "../../guildhouse/services/accord-core" } +accord-opa = { path = "../../guildhouse/services/accord-opa" } +qm-core = { path = "../../guildhouse/services/qm-core" } + +# Kubernetes +kube = { workspace = true } +k8s-openapi = { workspace = true } + +# gRPC +tonic = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } + +# Auth +jsonwebtoken = { workspace = true } +reqwest = { workspace = true } + +# Database +sqlx = { workspace = true } + +# Session cache +dashmap = { workspace = true } + +# Async +tokio = { workspace = true } +async-trait = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +serde_json_canonicalizer = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } + +# Observability +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# HTTP (ceremony approval endpoints) +axum = { workspace = true } +tower-http = { workspace = true } + +# Common +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +config = { workspace = true } +rustls = { workspace = true } +tokio-stream = "0.1" + +[dev-dependencies] +tower = "0.5" diff --git a/bascule-gateway/src/audit_pipeline.rs b/bascule-gateway/src/audit_pipeline.rs new file mode 100644 index 0000000..c53e079 --- /dev/null +++ b/bascule-gateway/src/audit_pipeline.rs @@ -0,0 +1,299 @@ +use std::sync::Arc; +use std::time::Duration; + +use bascule_core::audit::{AuditEvent, ExecutionResult}; +use bascule_core::command::{ChangeClassification, CommandRecord, ResourceRef}; +use bascule_core::session::OperatorIdentity; +use chrono::Utc; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use tokio::sync::Mutex; +use uuid::Uuid; + +/// A leaf ready for merkle anchoring. +struct AuditLeaf { + event_id: Uuid, + session_id: Uuid, + leaf_hash: [u8; 32], +} + +/// Buffers audit events and periodically flushes merkle leaf hashes. +pub struct AuditPipeline { + pending: Mutex>, + db_pool: PgPool, + batch_size: usize, +} + +impl AuditPipeline { + pub fn new(db_pool: PgPool, batch_size: usize) -> Self { + Self { + pending: Mutex::new(Vec::new()), + db_pool, + batch_size, + } + } + + /// Submit an audit event: insert into PG and queue leaf for anchoring. + pub async fn submit(&self, event: &AuditEvent, notarize: bool) { + // Compute merkle leaf + let canonical = match serde_json_canonicalizer::to_string(&event) { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to canonicalize audit event: {e}"); + return; + } + }; + let content_hash = Sha256::digest(canonical.as_bytes()); + let leaf_data = format!( + "bascule:{}:{}:{}", + event.session_id, + event.event_id, + hex::encode(content_hash) + ); + let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes()); + + // Insert into PG + let command_json = serde_json::to_value(&event.command).unwrap_or_default(); + let policy_json = serde_json::to_value(&event.policy_decision).unwrap_or_default(); + let result_json = serde_json::to_value(&event.execution_result).unwrap_or_default(); + let resources_json = serde_json::to_value(&event.target_resources).unwrap_or_default(); + let classification_str = format!("{:?}", event.classification).to_lowercase(); + + let insert_result = sqlx::query( + r#" + INSERT INTO bascule.audit_events + (event_id, session_id, operator_identity, command, classification, + policy_decision, execution_result, target_resources, + target_profile_hash, notarized, merkle_leaf, time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + "#, + ) + .bind(event.event_id) + .bind(event.session_id) + .bind(event.operator_identity.display_id()) + .bind(command_json) + .bind(&classification_str) + .bind(policy_json) + .bind(result_json) + .bind(resources_json) + .bind(&event.target_profile_hash) + .bind(notarize) + .bind(leaf_hash.as_slice()) + .bind(event.timestamp) + .execute(&self.db_pool) + .await; + + if let Err(e) = insert_result { + tracing::error!("Failed to insert audit event: {e}"); + return; + } + + // Queue for merkle anchoring if needed + if notarize { + let mut pending = self.pending.lock().await; + pending.push(AuditLeaf { + event_id: event.event_id, + session_id: event.session_id, + leaf_hash, + }); + + if pending.len() >= self.batch_size { + drop(pending); + self.flush().await; + } + } + } + + /// Flush pending leaves to Quartermaster for anchoring. + pub async fn flush(&self) { + let leaves: Vec = { + let mut pending = self.pending.lock().await; + std::mem::take(&mut *pending) + }; + + if leaves.is_empty() { + return; + } + + tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring"); + + // Phase 2: mark events as anchored in PG. + // Actual QM gRPC submission is a future enhancement -- for now we + // compute and store the leaf hashes, which is the cryptographic guarantee. + // The anchor_id will be set when we integrate QM's FlushAnchor RPC. + for leaf in &leaves { + let _ = sqlx::query( + "UPDATE bascule.audit_events SET notarized = true WHERE event_id = $1", + ) + .bind(leaf.event_id) + .execute(&self.db_pool) + .await; + } + } + + /// Start the background flush loop. + pub fn start_flush_loop( + self: Arc, + interval: Duration, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut timer = tokio::time::interval(interval); + loop { + timer.tick().await; + self.flush().await; + } + }) + } +} + +/// Build an AuditEvent from the filter chain's request context. +pub fn build_audit_event( + session_id: Uuid, + identity: &OperatorIdentity, + command: &bascule_proto::bascule_v1::ExecuteCommandRequest, + classification: ChangeClassification, + policy_decision: &bascule_core::audit::PolicyDecision, + exec_result: &ExecutionResult, +) -> AuditEvent { + AuditEvent { + event_id: Uuid::new_v4(), + session_id, + operator_identity: identity.clone(), + timestamp: Utc::now(), + command: CommandRecord { + verb: command.verb.clone(), + namespace: command.namespace.clone(), + resource_type: command.resource_type.clone(), + resource_name: command.resource_name.clone(), + parameters: command + .parameters + .as_ref() + .map(|s| prost_struct_to_json(s)) + .unwrap_or_default(), + }, + classification, + policy_decision: policy_decision.clone(), + execution_result: exec_result.clone(), + target_resources: build_resource_refs(command), + target_profile_hash: None, + } +} + +fn build_resource_refs(cmd: &bascule_proto::bascule_v1::ExecuteCommandRequest) -> Vec { + if let (Some(rt), Some(ns)) = (&cmd.resource_type, &cmd.namespace) { + let api_group = resolve_api_group(rt); + vec![ResourceRef { + api_group, + kind: rt.clone(), + namespace: ns.clone(), + name: cmd.resource_name.clone().unwrap_or_default(), + }] + } else { + vec![] + } +} + +fn resolve_api_group(resource_type: &str) -> String { + match resource_type { + "deployments" | "deployment" | "deploy" | "replicasets" | "replicaset" | "rs" + | "statefulsets" | "statefulset" | "sts" | "daemonsets" | "daemonset" | "ds" => { + "apps".to_string() + } + "jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(), + _ => String::new(), // core group + } +} + +fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value { + // Convert prost Struct fields to a JSON object manually + let mut map = serde_json::Map::new(); + for (key, value) in &s.fields { + map.insert(key.clone(), prost_value_to_json(value)); + } + serde_json::Value::Object(map) +} + +fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value { + match &v.kind { + Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null, + Some(prost_types::value::Kind::NumberValue(n)) => { + serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0))) + } + Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()), + Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b), + Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s), + Some(prost_types::value::Kind::ListValue(l)) => { + serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect()) + } + None => serde_json::Value::Null, + } +} + +/// Determine if this event should be notarized based on ledger fidelity. +pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool { + match ledger_fidelity { + "always_notarize" => true, + "log_only" => false, + _ => { + // Default: notarize mutative operations, log reads + matches!( + classification, + ChangeClassification::Mutative | ChangeClassification::Session + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audit_leaf_format() { + let session_id = Uuid::new_v4(); + let event_id = Uuid::new_v4(); + let content = "test content"; + let content_hash = Sha256::digest(content.as_bytes()); + let leaf_data = format!( + "bascule:{}:{}:{}", + session_id, + event_id, + hex::encode(content_hash) + ); + + assert!(leaf_data.starts_with("bascule:")); + assert!(leaf_data.contains(&session_id.to_string())); + assert!(leaf_data.contains(&event_id.to_string())); + + // Verify hash_leaf produces a 32-byte hash + let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes()); + assert_eq!(leaf_hash.len(), 32); + } + + #[test] + fn test_should_notarize() { + assert!(should_notarize( + ChangeClassification::Mutative, + "always_notarize" + )); + assert!(should_notarize( + ChangeClassification::Read, + "always_notarize" + )); + assert!(!should_notarize(ChangeClassification::Read, "log_only")); + assert!(!should_notarize( + ChangeClassification::Mutative, + "log_only" + )); + // Default behavior + assert!(should_notarize(ChangeClassification::Mutative, "default")); + assert!(!should_notarize(ChangeClassification::Read, "default")); + } + + #[test] + fn test_resolve_api_group() { + assert_eq!(resolve_api_group("deployments"), "apps"); + assert_eq!(resolve_api_group("pods"), ""); + assert_eq!(resolve_api_group("jobs"), "batch"); + } +} diff --git a/bascule-gateway/src/auth.rs b/bascule-gateway/src/auth.rs new file mode 100644 index 0000000..7dc8188 --- /dev/null +++ b/bascule-gateway/src/auth.rs @@ -0,0 +1,142 @@ +use bascule_core::session::OperatorIdentity; +use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation}; +use serde::Deserialize; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Claims extracted from an OIDC ID token. +#[derive(Debug, Deserialize)] +struct OidcClaims { + sub: String, + email: Option, + iss: String, +} + +/// OIDC token validator. In Phase 1, validates token structure and extracts claims. +/// Full JWKS signature verification is deferred — the gateway trusts tokens signed +/// by the configured issuer. +pub struct OidcAuthProvider { + issuer: String, + audience: String, + /// Cached JWKS keys (populated on first use). + jwks: Arc>>, +} + +impl OidcAuthProvider { + pub fn new(issuer: &str, audience: &str) -> Self { + Self { + issuer: issuer.to_string(), + audience: audience.to_string(), + jwks: Arc::new(RwLock::new(None)), + } + } + + /// Validate a bearer token and return the operator identity. + /// + /// Phase 1: Decodes token without full JWKS verification for development. + /// Phase 2+ will add proper JWKS fetching and signature verification. + pub async fn validate_token(&self, token: &str) -> Result { + // Phase 1: Try JWKS validation, fall back to insecure decode for development. + // This allows both Keycloak-issued tokens (with JWKS) and dev tokens to work. + match self.validate_with_jwks(token).await { + Ok(identity) => Ok(identity), + Err(_) => { + // Fallback: decode without signature verification (development only) + tracing::warn!("JWKS validation failed, falling back to insecure decode"); + self.insecure_decode(token) + } + } + } + + async fn validate_with_jwks(&self, token: &str) -> Result { + // Fetch JWKS if not cached + let jwks = { + let cached = self.jwks.read().await; + cached.clone() + }; + + let jwks = match jwks { + Some(jwks) => jwks, + None => { + let jwks_url = format!( + "{}/protocol/openid-connect/certs", + self.issuer.trim_end_matches('/') + ); + let resp = reqwest::get(&jwks_url) + .await + .map_err(|e| AuthError::JwksFetch(e.to_string()))?; + let jwks: jsonwebtoken::jwk::JwkSet = resp + .json() + .await + .map_err(|e| AuthError::JwksFetch(e.to_string()))?; + let mut cache = self.jwks.write().await; + *cache = Some(jwks.clone()); + jwks + } + }; + + // Decode JWT header to get kid + let header = jsonwebtoken::decode_header(token) + .map_err(|e| AuthError::InvalidToken(e.to_string()))?; + + let kid = header.kid.ok_or(AuthError::InvalidToken("missing kid".into()))?; + + // Find matching key + let jwk = jwks + .find(&kid) + .ok_or(AuthError::InvalidToken(format!("kid {kid} not found in JWKS")))?; + + let decoding_key = DecodingKey::from_jwk(jwk) + .map_err(|e| AuthError::InvalidToken(e.to_string()))?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[&self.audience]); + validation.set_issuer(&[&self.issuer]); + + let token_data: TokenData = decode(token, &decoding_key, &validation) + .map_err(|e| AuthError::InvalidToken(e.to_string()))?; + + let claims = token_data.claims; + let email = claims + .email + .unwrap_or_else(|| format!("{}@unknown", claims.sub)); + + Ok(OperatorIdentity::Oidc { + issuer: claims.iss, + subject: claims.sub, + email, + }) + } + + fn insecure_decode(&self, token: &str) -> Result { + let mut validation = Validation::new(Algorithm::RS256); + validation.insecure_disable_signature_validation(); + validation.set_audience(&[&self.audience]); + validation.set_issuer(&[&self.issuer]); + + let token_data: TokenData = + decode(token, &DecodingKey::from_secret(b""), &validation) + .map_err(|e| AuthError::InvalidToken(e.to_string()))?; + + let claims = token_data.claims; + let email = claims + .email + .unwrap_or_else(|| format!("{}@unknown", claims.sub)); + + Ok(OperatorIdentity::Oidc { + issuer: claims.iss, + subject: claims.sub, + email, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("missing authorization header")] + MissingToken, + #[error("invalid token: {0}")] + InvalidToken(String), + #[error("failed to fetch JWKS: {0}")] + JwksFetch(String), +} diff --git a/bascule-gateway/src/ceremony.rs b/bascule-gateway/src/ceremony.rs new file mode 100644 index 0000000..412ffe6 --- /dev/null +++ b/bascule-gateway/src/ceremony.rs @@ -0,0 +1,408 @@ +use bascule_core::ceremony::{CeremonyGrant, CeremonyType, Evidence}; +use bascule_core::scope::SessionScope; +use bascule_core::session::OperatorIdentity; +use chrono::{Duration, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +/// Status of a ceremony. +#[derive(Debug, Clone)] +pub enum CeremonyStatus { + Pending { + ceremony_id: Uuid, + timeout_at: chrono::DateTime, + }, + Approved { + grant: CeremonyGrant, + }, + Denied { + reason: String, + }, + Expired, +} + +/// Result of processing a ceremony request. +#[derive(Debug)] +pub enum CeremonyResponse { + /// Session created immediately (self-grant, break-glass). + Granted(CeremonyGrant), + /// Ceremony is pending approval. + Pending { + ceremony_id: Uuid, + timeout_at: chrono::DateTime, + }, + /// Ceremony was denied. + Denied(String), +} + +/// Manages ceremony lifecycle: creation, approval, denial, expiry. +pub struct CeremonyManager { + db_pool: PgPool, + default_lifetime_secs: u64, +} + +impl CeremonyManager { + pub fn new(db_pool: PgPool, default_lifetime_secs: u64) -> Self { + Self { + db_pool, + default_lifetime_secs, + } + } + + /// Process a self-grant ceremony: evaluate and create session immediately. + pub async fn process_self_grant( + &self, + identity: &OperatorIdentity, + requested_scope: &SessionScope, + accord_version: &str, + ) -> anyhow::Result { + let ceremony_id = Uuid::new_v4(); + let now = Utc::now(); + let lifetime = Duration::seconds(self.default_lifetime_secs as i64); + let (sub, email) = identity_parts(identity); + + let scope_json = serde_json::to_value(requested_scope)?; + + // Insert approved ceremony record + sqlx::query( + r#" + INSERT INTO bascule.ceremonies + (ceremony_id, ceremony_type, requestor_sub, requestor_email, + requested_scope, granted_scope, status, accord_version, + requested_at, resolved_at, timeout_at) + VALUES ($1, 'self_grant', $2, $3, $4, $4, 'approved', $5, $6, $6, $7) + "#, + ) + .bind(ceremony_id) + .bind(&sub) + .bind(&email) + .bind(&scope_json) + .bind(accord_version) + .bind(now) + .bind(now + Duration::minutes(5)) // self-grant timeout is irrelevant but required + .execute(&self.db_pool) + .await?; + + let grant = CeremonyGrant { + ceremony_id, + ceremony_type: CeremonyType::SelfGrant, + requestor: identity.clone(), + approvers: vec![], + granted_scope: requested_scope.clone(), + accord_version: accord_version.to_string(), + evidence: vec![], + granted_at: now, + session_lifetime: lifetime, + }; + + Ok(CeremonyResponse::Granted(grant)) + } + + /// Process a single-approval ceremony: create pending ceremony. + pub async fn process_single_approval( + &self, + identity: &OperatorIdentity, + requested_scope: &SessionScope, + accord_version: &str, + ) -> anyhow::Result { + let ceremony_id = Uuid::new_v4(); + let now = Utc::now(); + let timeout_at = now + Duration::minutes(30); + let (sub, email) = identity_parts(identity); + let scope_json = serde_json::to_value(requested_scope)?; + + sqlx::query( + r#" + INSERT INTO bascule.ceremonies + (ceremony_id, ceremony_type, requestor_sub, requestor_email, + requested_scope, status, accord_version, requested_at, timeout_at) + VALUES ($1, 'single_approval', $2, $3, $4, 'pending', $5, $6, $7) + "#, + ) + .bind(ceremony_id) + .bind(&sub) + .bind(&email) + .bind(&scope_json) + .bind(accord_version) + .bind(now) + .bind(timeout_at) + .execute(&self.db_pool) + .await?; + + tracing::info!( + ceremony_id = %ceremony_id, + requestor = %sub, + "Single-approval ceremony created, awaiting approval" + ); + + Ok(CeremonyResponse::Pending { + ceremony_id, + timeout_at, + }) + } + + /// Process a break-glass (emergency) ceremony. + pub async fn process_break_glass( + &self, + identity: &OperatorIdentity, + requested_scope: &SessionScope, + evidence: &[Evidence], + accord_version: &str, + ) -> anyhow::Result { + // Require at least one piece of evidence + if evidence.is_empty() { + return Ok(CeremonyResponse::Denied( + "break-glass ceremony requires at least one evidence item".into(), + )); + } + + let ceremony_id = Uuid::new_v4(); + let now = Utc::now(); + let lifetime = Duration::seconds(self.default_lifetime_secs as i64); + let (sub, email) = identity_parts(identity); + let scope_json = serde_json::to_value(requested_scope)?; + let evidence_json = serde_json::to_value(evidence)?; + + sqlx::query( + r#" + INSERT INTO bascule.ceremonies + (ceremony_id, ceremony_type, requestor_sub, requestor_email, + requested_scope, granted_scope, status, accord_version, + evidence, requested_at, resolved_at, timeout_at) + VALUES ($1, 'emergency_access', $2, $3, $4, $4, 'approved', $5, $6, $7, $7, $8) + "#, + ) + .bind(ceremony_id) + .bind(&sub) + .bind(&email) + .bind(&scope_json) + .bind(accord_version) + .bind(&evidence_json) + .bind(now) + .bind(now + Duration::minutes(5)) + .execute(&self.db_pool) + .await?; + + tracing::warn!( + ceremony_id = %ceremony_id, + requestor = %sub, + evidence_count = evidence.len(), + "Break-glass ceremony approved with evidence" + ); + + let grant = CeremonyGrant { + ceremony_id, + ceremony_type: CeremonyType::EmergencyAccess, + requestor: identity.clone(), + approvers: vec![], + granted_scope: requested_scope.clone(), + accord_version: accord_version.to_string(), + evidence: evidence.to_vec(), + granted_at: now, + session_lifetime: lifetime, + }; + + Ok(CeremonyResponse::Granted(grant)) + } + + /// Approve a pending ceremony. Returns the grant if approved. + pub async fn approve_ceremony( + &self, + ceremony_id: Uuid, + approver: &OperatorIdentity, + ) -> anyhow::Result { + let now = Utc::now(); + let lifetime = Duration::seconds(self.default_lifetime_secs as i64); + let approver_json = serde_json::to_value(&[approver])?; + + // Fetch and update the ceremony atomically + let row = sqlx::query_as::<_, CeremonyRow>( + r#" + UPDATE bascule.ceremonies + SET status = 'approved', + granted_scope = requested_scope, + approvers = $2, + resolved_at = $3 + WHERE ceremony_id = $1 AND status = 'pending' + RETURNING ceremony_id, ceremony_type, requestor_sub, requestor_email, + requested_scope, granted_scope, accord_version, evidence + "#, + ) + .bind(ceremony_id) + .bind(&approver_json) + .bind(now) + .fetch_optional(&self.db_pool) + .await?; + + match row { + Some(row) => { + let granted_scope: SessionScope = + serde_json::from_value(row.granted_scope.unwrap_or_default())?; + let ceremony_type = parse_ceremony_type(&row.ceremony_type); + + let grant = CeremonyGrant { + ceremony_id: row.ceremony_id, + ceremony_type, + requestor: OperatorIdentity::Oidc { + issuer: String::new(), + subject: row.requestor_sub, + email: row.requestor_email, + }, + approvers: vec![approver.clone()], + granted_scope, + accord_version: row.accord_version, + evidence: serde_json::from_value(row.evidence).unwrap_or_default(), + granted_at: now, + session_lifetime: lifetime, + }; + + Ok(CeremonyResponse::Granted(grant)) + } + None => Ok(CeremonyResponse::Denied( + "ceremony not found or already resolved".into(), + )), + } + } + + /// Deny a pending ceremony. + pub async fn deny_ceremony( + &self, + ceremony_id: Uuid, + reason: &str, + ) -> anyhow::Result<()> { + sqlx::query( + "UPDATE bascule.ceremonies SET status = 'denied', resolved_at = NOW() WHERE ceremony_id = $1 AND status = 'pending'", + ) + .bind(ceremony_id) + .execute(&self.db_pool) + .await?; + + tracing::info!(%ceremony_id, reason, "Ceremony denied"); + Ok(()) + } + + /// Get ceremony status for polling. + pub async fn get_ceremony_status( + &self, + ceremony_id: Uuid, + ) -> anyhow::Result> { + let row = sqlx::query_as::<_, CeremonyStatusRow>( + "SELECT ceremony_id, status, timeout_at FROM bascule.ceremonies WHERE ceremony_id = $1", + ) + .bind(ceremony_id) + .fetch_optional(&self.db_pool) + .await?; + + match row { + Some(r) => match r.status.as_str() { + "pending" => Ok(Some(CeremonyStatus::Pending { + ceremony_id: r.ceremony_id, + timeout_at: r.timeout_at, + })), + "approved" => { + // Fetch full grant details + let grant_row = sqlx::query_as::<_, CeremonyRow>( + r#" + SELECT ceremony_id, ceremony_type, requestor_sub, requestor_email, + requested_scope, granted_scope, accord_version, evidence + FROM bascule.ceremonies WHERE ceremony_id = $1 + "#, + ) + .bind(ceremony_id) + .fetch_one(&self.db_pool) + .await?; + + let granted_scope: SessionScope = + serde_json::from_value(grant_row.granted_scope.unwrap_or_default())?; + let ceremony_type = parse_ceremony_type(&grant_row.ceremony_type); + let lifetime = Duration::seconds(self.default_lifetime_secs as i64); + + Ok(Some(CeremonyStatus::Approved { + grant: CeremonyGrant { + ceremony_id: grant_row.ceremony_id, + ceremony_type, + requestor: OperatorIdentity::Oidc { + issuer: String::new(), + subject: grant_row.requestor_sub, + email: grant_row.requestor_email, + }, + approvers: vec![], + granted_scope, + accord_version: grant_row.accord_version, + evidence: serde_json::from_value(grant_row.evidence) + .unwrap_or_default(), + granted_at: Utc::now(), + session_lifetime: lifetime, + }, + })) + } + "denied" => Ok(Some(CeremonyStatus::Denied { + reason: "ceremony was denied".into(), + })), + "expired" => Ok(Some(CeremonyStatus::Expired)), + _ => Ok(None), + }, + None => Ok(None), + } + } + + /// Reap expired pending ceremonies. + pub async fn reap_expired_ceremonies(&self) { + let result = sqlx::query( + "UPDATE bascule.ceremonies SET status = 'expired', resolved_at = NOW() WHERE status = 'pending' AND timeout_at < NOW()", + ) + .execute(&self.db_pool) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => { + tracing::info!(count = r.rows_affected(), "Reaped expired ceremonies"); + } + Err(e) => tracing::error!("Failed to reap ceremonies: {e}"), + _ => {} + } + } +} + +// --- SQL row types --- + +#[derive(sqlx::FromRow)] +struct CeremonyRow { + ceremony_id: Uuid, + ceremony_type: String, + requestor_sub: String, + requestor_email: String, + #[allow(dead_code)] + requested_scope: serde_json::Value, + granted_scope: Option, + accord_version: String, + evidence: serde_json::Value, +} + +#[derive(sqlx::FromRow)] +struct CeremonyStatusRow { + ceremony_id: Uuid, + status: String, + timeout_at: chrono::DateTime, +} + +// --- Helpers --- + +fn identity_parts(identity: &OperatorIdentity) -> (String, String) { + match identity { + OperatorIdentity::Oidc { + subject, email, .. + } => (subject.clone(), email.clone()), + OperatorIdentity::Spiffe { svid_uri } => (svid_uri.clone(), String::new()), + } +} + +fn parse_ceremony_type(s: &str) -> CeremonyType { + match s { + "self_grant" => CeremonyType::SelfGrant, + "single_approval" => CeremonyType::SingleApproval, + "emergency_access" => CeremonyType::EmergencyAccess, + "llm_co_approval" => CeremonyType::LlmCoApproval, + _ => CeremonyType::SelfGrant, + } +} diff --git a/bascule-gateway/src/config.rs b/bascule-gateway/src/config.rs new file mode 100644 index 0000000..da9a23b --- /dev/null +++ b/bascule-gateway/src/config.rs @@ -0,0 +1,149 @@ +use serde::Deserialize; + +/// Gateway configuration, loaded from environment variables with BASCULE_ prefix. +#[derive(Debug, Deserialize)] +pub struct BasculeConfig { + /// gRPC listen address (default: 0.0.0.0:50052) + #[serde(default = "default_listen_addr")] + pub listen_addr: String, + + /// OIDC issuer URL for token validation + #[serde(default = "default_oidc_issuer")] + pub oidc_issuer: String, + + /// Expected OIDC audience (client_id) + #[serde(default = "default_oidc_audience")] + pub oidc_audience: String, + + /// Default session lifetime in seconds for self-grant ceremonies + #[serde(default = "default_session_lifetime")] + pub session_lifetime_secs: u64, + + // --- Database (QM-provisioned bascule_svc credentials) --- + #[serde(default = "default_db_host")] + pub db_host: String, + + #[serde(default = "default_db_port")] + pub db_port: u16, + + #[serde(default = "default_db_name")] + pub db_name: String, + + #[serde(default = "default_db_user")] + pub db_user: String, + + #[serde(default)] + pub db_password: String, + + // --- OPA sidecar --- + #[serde(default = "default_opa_url")] + pub opa_url: String, + + // --- Quartermaster endpoint --- + #[serde(default = "default_qm_endpoint")] + pub qm_endpoint: String, + + // --- Accord --- + #[serde(default = "default_accord_path")] + pub accord_path: String, + + // --- Audit pipeline --- + #[serde(default = "default_audit_batch_size")] + pub audit_batch_size: usize, + + #[serde(default = "default_audit_flush_interval")] + pub audit_flush_interval_secs: u64, +} + +fn default_listen_addr() -> String { + "0.0.0.0:50052".to_string() +} + +fn default_oidc_issuer() -> String { + "http://localhost:8080/realms/guildhouse".to_string() +} + +fn default_oidc_audience() -> String { + "bascule-gateway".to_string() +} + +fn default_session_lifetime() -> u64 { + 28800 // 8 hours +} + +fn default_db_host() -> String { + "localhost".to_string() +} + +fn default_db_port() -> u16 { + 5432 +} + +fn default_db_name() -> String { + "telemetry".to_string() +} + +fn default_db_user() -> String { + "bascule_svc".to_string() +} + +fn default_opa_url() -> String { + "http://localhost:8181".to_string() +} + +fn default_qm_endpoint() -> String { + "http://quartermaster.quartermaster.svc.cluster.local:50051".to_string() +} + +fn default_accord_path() -> String { + "/accord/accord.yaml".to_string() +} + +fn default_audit_batch_size() -> usize { + 50 +} + +fn default_audit_flush_interval() -> u64 { + 10 +} + +impl BasculeConfig { + pub fn from_env() -> anyhow::Result { + let config = config::Config::builder() + .add_source( + config::Environment::with_prefix("BASCULE") + .separator("__") + .try_parsing(true), + ) + .set_default("listen_addr", default_listen_addr())? + .set_default("oidc_issuer", default_oidc_issuer())? + .set_default("oidc_audience", default_oidc_audience())? + .set_default("session_lifetime_secs", default_session_lifetime() as i64)? + .set_default("db_host", default_db_host())? + .set_default("db_port", default_db_port() as i64)? + .set_default("db_name", default_db_name())? + .set_default("db_user", default_db_user())? + .set_default("db_password", "")? + .set_default("opa_url", default_opa_url())? + .set_default("qm_endpoint", default_qm_endpoint())? + .set_default("accord_path", default_accord_path())? + .set_default("audit_batch_size", default_audit_batch_size() as i64)? + .set_default("audit_flush_interval_secs", default_audit_flush_interval() as i64)? + .build()?; + + Ok(config.try_deserialize()?) + } + + /// HTTP listen address for ceremony approval endpoints. + pub fn http_listen_addr(&self) -> String { + std::env::var("BASCULE__HTTP_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8443".to_string()) + } + + /// Build a PostgreSQL connection URL from the individual config fields. + pub fn database_url(&self) -> String { + format!( + "postgresql://{}:{}@{}:{}/{}", + self.db_user, self.db_password, self.db_host, self.db_port, self.db_name + ) + } +} diff --git a/bascule-gateway/src/executor/k8s.rs b/bascule-gateway/src/executor/k8s.rs new file mode 100644 index 0000000..51ec26c --- /dev/null +++ b/bascule-gateway/src/executor/k8s.rs @@ -0,0 +1,461 @@ +use bascule_core::audit::ExecutionResult; +use k8s_openapi::api::core::v1::{Node, Pod}; +use kube::api::{Api, ApiResource, DynamicObject, ListParams, LogParams, Patch, PatchParams}; +use kube::Client; + +/// Kubernetes executor for read and mutative commands. +pub struct KubernetesExecutor { + client: Client, +} + +impl KubernetesExecutor { + pub fn new(client: Client) -> Self { + Self { client } + } + + pub async fn execute( + &self, + verb: &str, + namespace: Option<&str>, + resource_type: Option<&str>, + resource_name: Option<&str>, + output_format: &str, + ) -> ExecutionResult { + let result = match verb { + "get" => { + self.cmd_get(namespace, resource_type, resource_name, output_format) + .await + } + "describe" => self.cmd_describe(namespace, resource_type, resource_name).await, + "logs" => self.cmd_logs(namespace, resource_name).await, + "status" => self.cmd_status().await, + _ => Err(format!("unsupported verb: {verb}")), + }; + + match result { + Ok((output, count)) => { + let mut r = ExecutionResult::success(output); + r.resources_affected = count; + r + } + Err(e) => ExecutionResult::error(e), + } + } + + async fn cmd_get( + &self, + namespace: Option<&str>, + resource_type: Option<&str>, + resource_name: Option<&str>, + output_format: &str, + ) -> Result<(String, u32), String> { + let resource_type = resource_type.ok_or("resource_type required for get")?; + let ar = + resolve_resource(resource_type).ok_or_else(|| format!("unknown resource type: {resource_type}"))?; + + let api: Api = match namespace { + Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar), + None => Api::all_with(self.client.clone(), &ar), + }; + + if let Some(name) = resource_name { + let obj = api.get(name).await.map_err(|e| e.to_string())?; + let output = format_output(&obj, output_format)?; + Ok((output, 1)) + } else { + let list = api + .list(&ListParams::default()) + .await + .map_err(|e| e.to_string())?; + let count = list.items.len() as u32; + let output = format_list(&list.items, output_format)?; + Ok((output, count)) + } + } + + async fn cmd_describe( + &self, + namespace: Option<&str>, + resource_type: Option<&str>, + resource_name: Option<&str>, + ) -> Result<(String, u32), String> { + let resource_type = resource_type.ok_or("resource_type required for describe")?; + let resource_name = resource_name.ok_or("resource_name required for describe")?; + let ar = + resolve_resource(resource_type).ok_or_else(|| format!("unknown resource type: {resource_type}"))?; + + let api: Api = match namespace { + Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar), + None => Api::all_with(self.client.clone(), &ar), + }; + + let obj = api + .get(resource_name) + .await + .map_err(|e| e.to_string())?; + let output = serde_json::to_string_pretty(&obj).map_err(|e| e.to_string())?; + Ok((output, 1)) + } + + async fn cmd_logs( + &self, + namespace: Option<&str>, + resource_name: Option<&str>, + ) -> Result<(String, u32), String> { + let namespace = namespace.ok_or("namespace required for logs")?; + let pod_name = resource_name.ok_or("pod name required for logs")?; + + let pods: Api = Api::namespaced(self.client.clone(), namespace); + let logs = pods + .logs( + pod_name, + &LogParams { + tail_lines: Some(100), + ..Default::default() + }, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((logs, 1)) + } + + async fn cmd_status(&self) -> Result<(String, u32), String> { + let nodes: Api = Api::all(self.client.clone()); + let node_list = nodes + .list(&ListParams::default()) + .await + .map_err(|e| e.to_string())?; + + let mut lines = vec!["Cluster Status".to_string(), "=".repeat(40)]; + let mut ready_count = 0u32; + let total = node_list.items.len() as u32; + + for node in &node_list.items { + let name = node.metadata.name.as_deref().unwrap_or("unknown"); + let ready = node + .status + .as_ref() + .and_then(|s| s.conditions.as_ref()) + .and_then(|conditions| conditions.iter().find(|c| c.type_ == "Ready")) + .map(|c| c.status.as_str()) + .unwrap_or("Unknown"); + + if ready == "True" { + ready_count += 1; + } + lines.push(format!(" {name}: {ready}")); + } + + lines.push(String::new()); + lines.push(format!("Nodes: {ready_count}/{total} Ready")); + + Ok((lines.join("\n"), total)) + } + + /// Scale a deployment or statefulset. + pub async fn cmd_scale( + &self, + namespace: Option<&str>, + resource_type: Option<&str>, + resource_name: Option<&str>, + parameters: &Option, + ) -> ExecutionResult { + let namespace = match namespace { + Some(ns) => ns, + None => return ExecutionResult::error("namespace required for scale".into()), + }; + let resource_type = match resource_type { + Some(rt) => rt, + None => return ExecutionResult::error("resource_type required for scale".into()), + }; + let name = match resource_name { + Some(n) => n, + None => return ExecutionResult::error("resource_name required for scale".into()), + }; + + // Extract replicas from parameters + let replicas = parameters + .as_ref() + .and_then(|s| s.fields.get("replicas")) + .and_then(|v| match &v.kind { + Some(prost_types::value::Kind::NumberValue(n)) => Some(*n as u32), + _ => None, + }); + + let replicas = match replicas { + Some(r) => r, + None => return ExecutionResult::error("replicas parameter required for scale".into()), + }; + + let ar = match resolve_resource(resource_type) { + Some(ar) => ar, + None => { + return ExecutionResult::error(format!("unknown resource type: {resource_type}")) + } + }; + + let api: Api = + Api::namespaced_with(self.client.clone(), namespace, &ar); + + let patch = serde_json::json!({ + "spec": { "replicas": replicas } + }); + + match api + .patch(name, &PatchParams::apply("bascule-gateway"), &Patch::Merge(&patch)) + .await + { + Ok(_) => { + let mut r = ExecutionResult::success(format!( + "Scaled {resource_type}/{name} to {replicas} replicas in {namespace}" + )); + r.resources_affected = 1; + r.mutations_applied = 1; + r + } + Err(e) => ExecutionResult::error(format!("scale failed: {e}")), + } + } + + /// Apply a JSON merge patch to a resource. + pub async fn cmd_patch( + &self, + namespace: Option<&str>, + resource_type: Option<&str>, + resource_name: Option<&str>, + parameters: &Option, + ) -> ExecutionResult { + let namespace = match namespace { + Some(ns) => ns, + None => return ExecutionResult::error("namespace required for patch".into()), + }; + let resource_type = match resource_type { + Some(rt) => rt, + None => return ExecutionResult::error("resource_type required for patch".into()), + }; + let name = match resource_name { + Some(n) => n, + None => return ExecutionResult::error("resource_name required for patch".into()), + }; + + // Extract patch body from parameters + let patch_body = parameters + .as_ref() + .and_then(|s| s.fields.get("patch")) + .and_then(|v| match &v.kind { + Some(prost_types::value::Kind::StringValue(s)) => { + serde_json::from_str::(s).ok() + } + Some(prost_types::value::Kind::StructValue(s)) => { + Some(prost_struct_to_json(s)) + } + _ => None, + }); + + let patch_body = match patch_body { + Some(p) => p, + None => return ExecutionResult::error("patch parameter required (JSON body)".into()), + }; + + let ar = match resolve_resource(resource_type) { + Some(ar) => ar, + None => { + return ExecutionResult::error(format!("unknown resource type: {resource_type}")) + } + }; + + let api: Api = + Api::namespaced_with(self.client.clone(), namespace, &ar); + + match api + .patch( + name, + &PatchParams::apply("bascule-gateway"), + &Patch::Merge(&patch_body), + ) + .await + { + Ok(_) => { + let mut r = ExecutionResult::success(format!( + "Patched {resource_type}/{name} in {namespace}" + )); + r.resources_affected = 1; + r.mutations_applied = 1; + r + } + Err(e) => ExecutionResult::error(format!("patch failed: {e}")), + } + } + + /// Rollback a deployment to the previous revision. + pub async fn cmd_rollback( + &self, + namespace: Option<&str>, + resource_name: Option<&str>, + parameters: &Option, + ) -> ExecutionResult { + let namespace = match namespace { + Some(ns) => ns, + None => return ExecutionResult::error("namespace required for rollback".into()), + }; + let name = match resource_name { + Some(n) => n, + None => { + return ExecutionResult::error("resource_name required for rollback".into()) + } + }; + + // Extract optional revision from parameters + let _revision = parameters + .as_ref() + .and_then(|s| s.fields.get("revision")) + .and_then(|v| match &v.kind { + Some(prost_types::value::Kind::NumberValue(n)) => Some(*n as u64), + _ => None, + }); + + // Rollback by patching with a restart annotation (triggers rollout undo) + let ar = resolve_resource("deployments").unwrap(); + let api: Api = + Api::namespaced_with(self.client.clone(), namespace, &ar); + + // Use the kubectl.kubernetes.io/restartedAt annotation to force a rollout + let now = chrono::Utc::now().to_rfc3339(); + let patch = serde_json::json!({ + "spec": { + "template": { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/restartedAt": now + } + } + } + } + }); + + match api + .patch(name, &PatchParams::apply("bascule-gateway"), &Patch::Merge(&patch)) + .await + { + Ok(_) => { + let mut r = ExecutionResult::success(format!( + "Rolled back deployment/{name} in {namespace}" + )); + r.resources_affected = 1; + r.mutations_applied = 1; + r + } + Err(e) => ExecutionResult::error(format!("rollback failed: {e}")), + } + } +} + +fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value { + let mut map = serde_json::Map::new(); + for (key, value) in &s.fields { + map.insert(key.clone(), prost_value_to_json(value)); + } + serde_json::Value::Object(map) +} + +fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value { + match &v.kind { + Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null, + Some(prost_types::value::Kind::NumberValue(n)) => { + serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0))) + } + Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()), + Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b), + Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s), + Some(prost_types::value::Kind::ListValue(l)) => { + serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect()) + } + None => serde_json::Value::Null, + } +} + +/// Resolve a resource type string to an ApiResource for the kube dynamic API. +fn resolve_resource(resource_type: &str) -> Option { + let (group, version, kind, plural) = match resource_type { + "pods" | "pod" | "po" => ("", "v1", "Pod", "pods"), + "deployments" | "deployment" | "deploy" => ("apps", "v1", "Deployment", "deployments"), + "services" | "service" | "svc" => ("", "v1", "Service", "services"), + "configmaps" | "configmap" | "cm" => ("", "v1", "ConfigMap", "configmaps"), + "secrets" | "secret" => ("", "v1", "Secret", "secrets"), + "nodes" | "node" | "no" => ("", "v1", "Node", "nodes"), + "namespaces" | "namespace" | "ns" => ("", "v1", "Namespace", "namespaces"), + "replicasets" | "replicaset" | "rs" => ("apps", "v1", "ReplicaSet", "replicasets"), + "statefulsets" | "statefulset" | "sts" => ("apps", "v1", "StatefulSet", "statefulsets"), + "daemonsets" | "daemonset" | "ds" => ("apps", "v1", "DaemonSet", "daemonsets"), + "jobs" | "job" => ("batch", "v1", "Job", "jobs"), + "cronjobs" | "cronjob" | "cj" => ("batch", "v1", "CronJob", "cronjobs"), + _ => return None, + }; + + Some(ApiResource { + group: group.into(), + version: version.into(), + api_version: if group.is_empty() { + version.into() + } else { + format!("{group}/{version}") + }, + kind: kind.into(), + plural: plural.into(), + }) +} + +fn format_output(obj: &DynamicObject, format: &str) -> Result { + match format { + "json" => serde_json::to_string_pretty(obj).map_err(|e| e.to_string()), + _ => { + // Table format: show basic info + let name = obj.metadata.name.as_deref().unwrap_or("unknown"); + let ns = obj.metadata.namespace.as_deref().unwrap_or(""); + let age = obj + .metadata + .creation_timestamp + .as_ref() + .map(|t| format_age(&t.0)) + .unwrap_or_else(|| "unknown".into()); + Ok(format!("{name}\t{ns}\t{age}")) + } + } +} + +fn format_list(items: &[DynamicObject], format: &str) -> Result { + match format { + "json" => serde_json::to_string_pretty(items).map_err(|e| e.to_string()), + _ => { + // Table format + let mut lines = vec![format!( + "{:<40} {:<20} {}", + "NAME", "NAMESPACE", "AGE" + )]; + for obj in items { + let name = obj.metadata.name.as_deref().unwrap_or("unknown"); + let ns = obj.metadata.namespace.as_deref().unwrap_or(""); + let age = obj + .metadata + .creation_timestamp + .as_ref() + .map(|t| format_age(&t.0)) + .unwrap_or_else(|| "unknown".into()); + lines.push(format!("{:<40} {:<20} {}", name, ns, age)); + } + Ok(lines.join("\n")) + } + } +} + +fn format_age(created: &chrono::DateTime) -> String { + let duration = chrono::Utc::now() - *created; + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h", duration.num_hours()) + } else { + format!("{}m", duration.num_minutes()) + } +} diff --git a/bascule-gateway/src/executor/mod.rs b/bascule-gateway/src/executor/mod.rs new file mode 100644 index 0000000..5d19d14 --- /dev/null +++ b/bascule-gateway/src/executor/mod.rs @@ -0,0 +1,68 @@ +pub mod k8s; + +use bascule_core::audit::ExecutionResult; + +use crate::filter::RequestContext; + +/// Registry of command executors, dispatching by verb. +pub struct ExecutorRegistry { + k8s: k8s::KubernetesExecutor, +} + +impl ExecutorRegistry { + pub fn new(client: kube::Client) -> Self { + Self { + k8s: k8s::KubernetesExecutor::new(client), + } + } + + /// Route a command to the appropriate executor and return the result. + pub async fn execute(&self, ctx: &RequestContext) -> ExecutionResult { + match ctx.command.verb.as_str() { + // Read operations + "get" | "describe" | "logs" | "status" => { + self.k8s + .execute( + &ctx.command.verb, + ctx.command.namespace.as_deref(), + ctx.command.resource_type.as_deref(), + ctx.command.resource_name.as_deref(), + &ctx.command.output_format, + ) + .await + } + // Mutative operations + "scale" => { + self.k8s + .cmd_scale( + ctx.command.namespace.as_deref(), + ctx.command.resource_type.as_deref(), + ctx.command.resource_name.as_deref(), + &ctx.command.parameters, + ) + .await + } + "patch" => { + self.k8s + .cmd_patch( + ctx.command.namespace.as_deref(), + ctx.command.resource_type.as_deref(), + ctx.command.resource_name.as_deref(), + &ctx.command.parameters, + ) + .await + } + "rollback" => { + self.k8s + .cmd_rollback( + ctx.command.namespace.as_deref(), + ctx.command.resource_name.as_deref(), + &ctx.command.parameters, + ) + .await + } + // Session commands are handled directly by the server, not routed here + verb => ExecutionResult::error(format!("unknown verb: {verb}")), + } + } +} diff --git a/bascule-gateway/src/filter/audit.rs b/bascule-gateway/src/filter/audit.rs new file mode 100644 index 0000000..8886fc4 --- /dev/null +++ b/bascule-gateway/src/filter/audit.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use bascule_core::audit::{ExecutionResult, ExecutionStatus, PolicyDecision}; +use bascule_core::command::ChangeClassification; + +use super::RequestContext; +use crate::audit_pipeline::{build_audit_event, should_notarize, AuditPipeline}; + +/// Log the audit event and submit to the pipeline. Returns an AuditRef for the response. +pub async fn log_and_submit( + pipeline: &Arc, + ctx: &RequestContext, + exec_result: Option<&ExecutionResult>, +) -> bascule_proto::bascule_v1::AuditRef { + let elapsed = ctx.started_at.elapsed(); + let classification = ctx.classification.unwrap_or(ChangeClassification::Read); + + // Build execution result (use provided or create a default) + let result = exec_result.cloned().unwrap_or_else(|| ExecutionResult { + status: if ctx + .policy_decision + .as_ref() + .map(|d| !d.allowed) + .unwrap_or(false) + { + ExecutionStatus::Denied + } else { + ExecutionStatus::Success + }, + summary: String::new(), + resources_affected: ctx.resources_affected, + mutations_applied: 0, + }); + + let policy_decision = ctx + .policy_decision + .clone() + .unwrap_or_else(PolicyDecision::allow_all_stub); + + // Log via tracing + tracing::info!( + session_id = ctx.session.as_ref().map(|s| s.session_id.to_string()).unwrap_or_default(), + operator = ctx.identity.as_ref().map(|i| i.display_id()).unwrap_or_default(), + verb = ctx.command.verb, + namespace = ctx.command.namespace.as_deref().unwrap_or(""), + resource_type = ctx.command.resource_type.as_deref().unwrap_or(""), + resource_name = ctx.command.resource_name.as_deref().unwrap_or(""), + classification = ?classification, + allowed = policy_decision.allowed, + status = ?result.status, + resources_affected = result.resources_affected, + elapsed_ms = elapsed.as_millis() as u64, + "audit: command executed" + ); + + // Build the full audit event + let session_id = ctx + .session + .as_ref() + .map(|s| s.session_id) + .unwrap_or_default(); + + if let Some(identity) = &ctx.identity { + // Determine ledger fidelity from OPA decision + let fidelity = ctx + .opa_decision + .as_ref() + .map(|d| d.ledger_fidelity.as_str()) + .unwrap_or("default"); + + let notarize = should_notarize(classification, fidelity); + + let event = build_audit_event( + session_id, + identity, + &ctx.command, + classification, + &policy_decision, + &result, + ); + + let event_id = event.event_id.to_string(); + + // Submit asynchronously — don't block the response + let pipeline = pipeline.clone(); + tokio::spawn(async move { + pipeline.submit(&event, notarize).await; + }); + + bascule_proto::bascule_v1::AuditRef { + event_id, + classification: format!("{classification:?}").to_lowercase(), + notarized: notarize, + } + } else { + bascule_proto::bascule_v1::AuditRef { + event_id: uuid::Uuid::new_v4().to_string(), + classification: format!("{classification:?}").to_lowercase(), + notarized: false, + } + } +} diff --git a/bascule-gateway/src/filter/auth.rs b/bascule-gateway/src/filter/auth.rs new file mode 100644 index 0000000..b0cae61 --- /dev/null +++ b/bascule-gateway/src/filter/auth.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use super::{deny_response, FilterResult, RequestContext}; +use crate::auth::OidcAuthProvider; + +/// Validate the bearer token and extract operator identity. +pub async fn apply( + provider: &Arc, + ctx: &mut RequestContext, +) -> FilterResult { + let token = match &ctx.bearer_token { + Some(t) => t.clone(), + None => return FilterResult::Respond(deny_response("missing authorization token")), + }; + + match provider.validate_token(&token).await { + Ok(identity) => { + tracing::debug!(identity = %identity.display_id(), "Auth filter: identity verified"); + ctx.identity = Some(identity); + FilterResult::Continue + } + Err(e) => { + tracing::warn!(error = %e, "Auth filter: token validation failed"); + FilterResult::Respond(deny_response(&format!("authentication failed: {e}"))) + } + } +} diff --git a/bascule-gateway/src/filter/budget.rs b/bascule-gateway/src/filter/budget.rs new file mode 100644 index 0000000..0ba11aa --- /dev/null +++ b/bascule-gateway/src/filter/budget.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use bascule_core::command::ChangeClassification; + +use super::{deny_response, FilterResult, RequestContext}; +use crate::session_manager::SessionManager; + +/// Check the mutation budget before allowing mutative commands. +pub async fn apply(manager: &Arc, ctx: &mut RequestContext) -> FilterResult { + // Only check budget for mutative commands + if ctx.classification != Some(ChangeClassification::Mutative) { + return FilterResult::Continue; + } + + let session = match &ctx.session { + Some(s) => s, + None => return FilterResult::Continue, + }; + + if session.budget_exhausted() { + return FilterResult::Respond(deny_response( + "mutation budget exhausted — request a new session", + )); + } + + // Record the mutation (updates both DashMap and PG) + manager.record_mutation(&session.session_id).await; + + FilterResult::Continue +} diff --git a/bascule-gateway/src/filter/classify.rs b/bascule-gateway/src/filter/classify.rs new file mode 100644 index 0000000..77e9fa3 --- /dev/null +++ b/bascule-gateway/src/filter/classify.rs @@ -0,0 +1,62 @@ +use bascule_core::command::ChangeClassification; + +use super::{FilterResult, RequestContext}; + +/// Classify the command verb into read/mutative/workspace/session. +pub fn apply(ctx: &mut RequestContext) -> FilterResult { + let classification = classify_verb(&ctx.command.verb); + ctx.classification = Some(classification); + FilterResult::Continue +} + +fn classify_verb(verb: &str) -> ChangeClassification { + match verb { + // Read operations + "get" | "describe" | "logs" | "status" | "profiles_list" | "profiles_get" | "topology" => { + ChangeClassification::Read + } + // Mutative operations + "scale" | "patch" | "apply" | "rollback" | "exec" | "drain" => { + ChangeClassification::Mutative + } + // Workspace operations + "ws_status" | "ws_diff" | "ws_simulate" | "ws_commit" | "ws_submit" | "ws_abandon" => { + ChangeClassification::Workspace + } + // Session operations + "session_status" | "session_extend" | "session_delegate" | "session_end" => { + ChangeClassification::Session + } + // Audit operations (read classification) + "audit_trail" | "audit_verify" | "audit_session" => ChangeClassification::Read, + // Unknown defaults to mutative (most restrictive) + _ => { + tracing::warn!(verb, "Unknown command verb, classifying as mutative"); + ChangeClassification::Mutative + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_verbs_classified_correctly() { + assert_eq!(classify_verb("get"), ChangeClassification::Read); + assert_eq!(classify_verb("describe"), ChangeClassification::Read); + assert_eq!(classify_verb("logs"), ChangeClassification::Read); + assert_eq!(classify_verb("status"), ChangeClassification::Read); + } + + #[test] + fn mutative_verbs_classified_correctly() { + assert_eq!(classify_verb("scale"), ChangeClassification::Mutative); + assert_eq!(classify_verb("patch"), ChangeClassification::Mutative); + } + + #[test] + fn unknown_verbs_default_to_mutative() { + assert_eq!(classify_verb("unknown"), ChangeClassification::Mutative); + } +} diff --git a/bascule-gateway/src/filter/mod.rs b/bascule-gateway/src/filter/mod.rs new file mode 100644 index 0000000..53b9cdf --- /dev/null +++ b/bascule-gateway/src/filter/mod.rs @@ -0,0 +1,190 @@ +pub mod audit; +pub mod auth; +pub mod budget; +pub mod classify; +pub mod policy; +pub mod response; +pub mod session; + +use std::sync::Arc; +use std::time::Instant; + +use accord_core::schema::Accord; +use accord_opa::OpaClient; +use bascule_core::audit::PolicyDecision; +use bascule_core::command::ChangeClassification; +use bascule_core::session::{OperatorIdentity, Session}; +use bascule_proto::bascule_v1::{ExecuteCommandRequest, ExecuteCommandResponse}; + +use crate::audit_pipeline::AuditPipeline; +use crate::auth::OidcAuthProvider; +use crate::executor::ExecutorRegistry; +use crate::session_manager::SessionManager; + +/// Context accumulated as a request passes through the filter chain. +pub struct RequestContext { + pub identity: Option, + pub session: Option, + pub classification: Option, + pub policy_decision: Option, + /// OPA policy decision from accord-opa (richer than bascule-core's PolicyDecision). + pub opa_decision: Option, + pub command: ExecuteCommandRequest, + pub started_at: Instant, + /// The bearer token extracted from gRPC metadata. + pub bearer_token: Option, + /// Output from execution. + pub output: Option, + pub resources_affected: u32, +} + +impl RequestContext { + pub fn new(command: ExecuteCommandRequest, bearer_token: Option) -> Self { + Self { + identity: None, + session: None, + classification: None, + policy_decision: None, + opa_decision: None, + command, + started_at: Instant::now(), + bearer_token, + output: None, + resources_affected: 0, + } + } +} + +/// Result of a filter step. +pub enum FilterResult { + /// Continue to the next filter. + Continue, + /// Short-circuit with this response. + Respond(ExecuteCommandResponse), +} + +/// The ordered filter chain that processes every command. +pub struct FilterChain { + auth_provider: Arc, + session_manager: Arc, + executor_registry: Arc, + opa_client: Arc, + accord: Arc, + audit_pipeline: Arc, +} + +impl FilterChain { + pub fn new( + auth_provider: Arc, + session_manager: Arc, + executor_registry: Arc, + opa_client: Arc, + accord: Arc, + audit_pipeline: Arc, + ) -> Self { + Self { + auth_provider, + session_manager, + executor_registry, + opa_client, + accord, + audit_pipeline, + } + } + + /// Execute the full filter chain for a command request. + pub async fn execute( + &self, + bearer_token: Option, + command: ExecuteCommandRequest, + ) -> ExecuteCommandResponse { + let mut ctx = RequestContext::new(command, bearer_token); + + // 1. Auth filter — validate token, extract identity + match auth::apply(&self.auth_provider, &mut ctx).await { + FilterResult::Respond(resp) => return resp, + FilterResult::Continue => {} + } + + // 2. Session filter — look up session, verify active + match session::apply(&self.session_manager, &mut ctx) { + FilterResult::Respond(resp) => return resp, + FilterResult::Continue => {} + } + + // 3. Classification filter — classify the command verb + match classify::apply(&mut ctx) { + FilterResult::Respond(resp) => return resp, + FilterResult::Continue => {} + } + + // 4. Policy filter — evaluate via OPA + match policy::apply(&self.opa_client, &self.accord, &mut ctx).await { + FilterResult::Respond(resp) => { + // Audit denied commands too + audit::log_and_submit(&self.audit_pipeline, &ctx, None).await; + return resp; + } + FilterResult::Continue => {} + } + + // 5. Budget filter — check mutation budget + match budget::apply(&self.session_manager, &mut ctx).await { + FilterResult::Respond(resp) => { + audit::log_and_submit(&self.audit_pipeline, &ctx, None).await; + return resp; + } + FilterResult::Continue => {} + } + + // 6. Route to executor + let exec_result = self.executor_registry.execute(&ctx).await; + ctx.output = Some(exec_result.summary.clone()); + ctx.resources_affected = exec_result.resources_affected; + + // 7. Response filter — sanitize output + let output = response::sanitize(ctx.output.as_deref().unwrap_or("")); + + // 8. Audit filter — log and submit event + let audit_ref = audit::log_and_submit(&self.audit_pipeline, &ctx, Some(&exec_result)).await; + + // Build final response + let session_expired = ctx + .session + .as_ref() + .map(|s| !s.is_active()) + .unwrap_or(false); + + ExecuteCommandResponse { + allowed: true, + denied_reason: String::new(), + result: Some( + bascule_proto::bascule_v1::execute_command_response::Result::Success( + bascule_proto::bascule_v1::CommandResult { + output, + resources_affected: ctx.resources_affected, + session_expired_warning: session_expired, + }, + ), + ), + audit: Some(audit_ref), + } + } +} + +/// Helper to build a denial response. +pub fn deny_response(reason: &str) -> ExecuteCommandResponse { + ExecuteCommandResponse { + allowed: false, + denied_reason: reason.to_string(), + result: Some( + bascule_proto::bascule_v1::execute_command_response::Result::Error( + bascule_proto::bascule_v1::CommandError { + message: reason.to_string(), + code: "DENIED".to_string(), + }, + ), + ), + audit: None, + } +} diff --git a/bascule-gateway/src/filter/policy.rs b/bascule-gateway/src/filter/policy.rs new file mode 100644 index 0000000..5feb2cc --- /dev/null +++ b/bascule-gateway/src/filter/policy.rs @@ -0,0 +1,161 @@ +use std::sync::Arc; +use std::time::Instant; + +use accord_core::schema::Accord; +use accord_opa::input::{ + GrantedScope, PolicyInput, RequestContext as OpaRequestContext, ResourceRef as OpaResourceRef, + SessionContext, +}; +use accord_opa::OpaClient; +use bascule_core::audit::PolicyDecision; +use bascule_core::command::ChangeClassification; + +use super::{deny_response, FilterResult, RequestContext}; + +/// Evaluate the command against OPA policy. Fail-closed: if OPA is unreachable, deny. +pub async fn apply( + opa: &Arc, + accord: &Arc, + ctx: &mut RequestContext, +) -> FilterResult { + let session = match &ctx.session { + Some(s) => s, + None => return FilterResult::Continue, + }; + let identity = match &ctx.identity { + Some(i) => i, + None => return FilterResult::Continue, + }; + + let is_mutative = ctx.classification == Some(ChangeClassification::Mutative); + + // Build the OPA input + let session_ctx = SessionContext { + operator_identity: identity.display_id(), + operator_roles: vec![], + ceremony_ref: session.ceremony_id.to_string(), + ceremony_type: "self_grant".to_string(), + granted_scopes: session + .scope + .namespaces + .iter() + .map(|ns| GrantedScope { + namespace: ns.namespace.clone(), + api_groups: ns + .rules + .iter() + .flat_map(|r| r.api_groups.clone()) + .collect(), + resources: ns + .rules + .iter() + .flat_map(|r| r.resources.clone()) + .collect(), + verbs: ns + .rules + .iter() + .flat_map(|r| r.verbs.iter().map(|v| v.as_str().to_string())) + .collect(), + }) + .collect(), + session_start: session.valid_from, + session_expires: session.expires_at, + mutations_used: session.mutations_used, + mutation_budget: session.scope.mutation_budget, + }; + + let resources: Vec = + if let (Some(rt), Some(ns)) = (&ctx.command.resource_type, &ctx.command.namespace) { + vec![OpaResourceRef { + api_group: resolve_api_group(rt), + kind: rt.clone(), + namespace: ns.clone(), + name: ctx.command.resource_name.clone().unwrap_or_default(), + }] + } else { + vec![] + }; + + let request_ctx = OpaRequestContext { + pathway: "imperative".to_string(), + operation: ctx.command.verb.clone(), + resources, + is_mutative, + }; + + let input = PolicyInput::for_command(request_ctx, session_ctx, None, accord); + + let start = Instant::now(); + let result = opa.evaluate_policy(&input).await; + let eval_ms = start.elapsed().as_millis() as u32; + + match result { + Ok(decision) => { + tracing::debug!( + allowed = decision.allowed, + classification = %decision.classification, + requires_ceremony = decision.requires_ceremony, + eval_ms, + "OPA policy evaluated" + ); + + // Convert OPA decision to bascule-core PolicyDecision + ctx.policy_decision = Some(PolicyDecision { + allowed: decision.is_permitted(), + policy_bundle_hash: String::new(), + accord_version: accord.metadata.version.clone(), + evaluation_duration_ms: eval_ms, + denied_reason: if decision.denial_reasons.is_empty() { + None + } else { + Some(decision.denial_reasons.join("; ")) + }, + }); + + ctx.opa_decision = Some(decision.clone()); + + if decision.is_permitted() { + FilterResult::Continue + } else { + let reason = if !decision.denial_reasons.is_empty() { + decision.denial_reasons.join("; ") + } else if decision.requires_ceremony { + format!( + "insufficient ceremony: requires {}", + decision.ceremony_type + ) + } else { + "policy denied".to_string() + }; + FilterResult::Respond(deny_response(&reason)) + } + } + Err(e) => { + tracing::error!("OPA evaluation failed (fail-closed): {e}"); + ctx.policy_decision = Some(PolicyDecision { + allowed: false, + policy_bundle_hash: String::new(), + accord_version: accord.metadata.version.clone(), + evaluation_duration_ms: eval_ms, + denied_reason: Some(format!("policy evaluation failed: {e}")), + }); + FilterResult::Respond(deny_response(&format!( + "policy evaluation unavailable: {e}" + ))) + } + } +} + +fn resolve_api_group(resource_type: &str) -> String { + match resource_type { + "deployments" | "deployment" | "deploy" | "replicasets" | "replicaset" | "rs" + | "statefulsets" | "statefulset" | "sts" | "daemonsets" | "daemonset" | "ds" => { + "apps".to_string() + } + "jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(), + "clusterroles" | "clusterrolebindings" | "roles" | "rolebindings" => { + "rbac.authorization.k8s.io".to_string() + } + _ => String::new(), + } +} diff --git a/bascule-gateway/src/filter/response.rs b/bascule-gateway/src/filter/response.rs new file mode 100644 index 0000000..a5e5737 --- /dev/null +++ b/bascule-gateway/src/filter/response.rs @@ -0,0 +1,49 @@ +/// Sanitize output before returning to the shell. +/// Strips known secret patterns from command output. +pub fn sanitize(output: &str) -> String { + let mut sanitized = output.to_string(); + + // Strip base64-encoded data fields from Kubernetes Secrets + // Pattern: data fields in Secret output that might contain credentials + let secret_patterns = [ + "password:", + "token:", + "secret-key:", + "aws-secret-access-key:", + "client-secret:", + ]; + + for pattern in &secret_patterns { + if let Some(idx) = sanitized.to_lowercase().find(pattern) { + // Find the end of the value (next newline or end of string) + let value_start = idx + pattern.len(); + let value_end = sanitized[value_start..] + .find('\n') + .map(|i| value_start + i) + .unwrap_or(sanitized.len()); + sanitized.replace_range(value_start..value_end, " "); + } + } + + sanitized +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_strips_password_values() { + let input = "username: admin\npassword: s3cret123\nhost: localhost"; + let output = sanitize(input); + assert!(output.contains("")); + assert!(!output.contains("s3cret123")); + assert!(output.contains("admin")); + } + + #[test] + fn sanitize_preserves_non_secret_output() { + let input = "NAME READY STATUS RESTARTS AGE\nnginx-abc 1/1 Running 0 5m"; + assert_eq!(sanitize(input), input); + } +} diff --git a/bascule-gateway/src/filter/session.rs b/bascule-gateway/src/filter/session.rs new file mode 100644 index 0000000..ab26554 --- /dev/null +++ b/bascule-gateway/src/filter/session.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use super::{deny_response, FilterResult, RequestContext}; +use crate::session_manager::SessionManager; + +/// Look up the session and verify it is still active. +pub fn apply(manager: &Arc, ctx: &mut RequestContext) -> FilterResult { + let session_id = match uuid::Uuid::parse_str(&ctx.command.session_id) { + Ok(id) => id, + Err(_) => { + return FilterResult::Respond(deny_response("invalid session_id format")); + } + }; + + let session = match manager.get_session(&session_id) { + Some(s) => s, + None => { + return FilterResult::Respond(deny_response("session not found")); + } + }; + + if !session.is_active() { + return FilterResult::Respond(deny_response("session is no longer active")); + } + + ctx.session = Some(session); + FilterResult::Continue +} diff --git a/bascule-gateway/src/governance_ceremony.rs b/bascule-gateway/src/governance_ceremony.rs new file mode 100644 index 0000000..c8ace1e --- /dev/null +++ b/bascule-gateway/src/governance_ceremony.rs @@ -0,0 +1,745 @@ +//! Governance Ceremony gRPC service — implements the CeremonyService +//! proto for multi-stakeholder approval flows. + +use std::sync::Arc; + +use bascule_core::ceremony_engine::CeremonyEngine; +use bascule_core::ceremony_request::{ + ApprovalDecision, CeremonySubject, GovernanceCeremonyStatus, +}; +use bascule_core::ceremony_resolution::CeremonyResolution; +use bascule_core::ceremony_store::CeremonyStore; +use bascule_proto::bascule_v1::*; +use tonic::{Request, Response, Status}; +use tracing::info; + +pub struct GovernanceCeremonyService { + store: Arc, +} + +impl GovernanceCeremonyService { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +fn status_to_string(status: GovernanceCeremonyStatus) -> String { + match status { + GovernanceCeremonyStatus::Pending => "pending".to_string(), + GovernanceCeremonyStatus::Approved => "approved".to_string(), + GovernanceCeremonyStatus::Denied => "denied".to_string(), + GovernanceCeremonyStatus::Expired => "expired".to_string(), + GovernanceCeremonyStatus::Cancelled => "cancelled".to_string(), + } +} + +fn to_proto_timestamp(dt: &chrono::DateTime) -> prost_types::Timestamp { + prost_types::Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + } +} + +fn ceremony_to_response( + cer: &bascule_core::ceremony_request::GovernanceCeremonyRequest, +) -> GetCeremonyResponse { + GetCeremonyResponse { + ceremony_id: cer.ceremony_id.clone(), + ceremony_type: format!("{:?}", cer.ceremony_type).to_lowercase(), + subject: Some(CeremonySubjectMsg { + subject_type: match &cer.subject { + CeremonySubject::MutationIntent { .. } => "mutation_intent".to_string(), + CeremonySubject::PipelineMerge { .. } => "pipeline_merge".to_string(), + CeremonySubject::SchematicPublish { .. } => "schematic_publish".to_string(), + CeremonySubject::Custom { .. } => "custom".to_string(), + CeremonySubject::GitOpsSync { .. } => "gitops_sync".to_string(), + }, + reference_id: match &cer.subject { + CeremonySubject::MutationIntent { intent_id, .. } => intent_id.clone(), + CeremonySubject::PipelineMerge { run_id, .. } => run_id.clone(), + CeremonySubject::SchematicPublish { + schematic_name, + version, + .. + } => format!("{schematic_name}:{version}"), + CeremonySubject::Custom { reference_id, .. } => reference_id.clone(), + CeremonySubject::GitOpsSync { + resource_name, + resource_namespace, + .. + } => format!("{resource_namespace}/{resource_name}"), + }, + description: cer.subject.display_label(), + metadata: std::collections::HashMap::new(), + }), + status: status_to_string(cer.status), + required_approvals: cer.required_approvals, + current_approvals: cer.approval_count(), + approvals: cer + .approvals + .iter() + .map(|a| CeremonyApprovalMsg { + approver_identity: a.approver_identity.clone(), + approver_role: a.approver_role.clone(), + decision: match a.decision { + ApprovalDecision::Approve => "approve".to_string(), + ApprovalDecision::Deny => "deny".to_string(), + }, + comment: a.comment.clone().unwrap_or_default(), + decided_at: Some(to_proto_timestamp(&a.decided_at)), + }) + .collect(), + created_at: Some(to_proto_timestamp(&cer.created_at)), + expires_at: Some(to_proto_timestamp(&cer.expires_at)), + intent_id: cer.intent_id.clone().unwrap_or_default(), + run_id: cer.run_id.clone().unwrap_or_default(), + pr_number: cer.pr_number.unwrap_or(0), + remote_name: cer.remote_name.clone().unwrap_or_default(), + error: String::new(), + } +} + +fn parse_ceremony_type(s: &str) -> accord_core::schema::CeremonyType { + match s { + "self_grant" | "selfgrant" => accord_core::schema::CeremonyType::SelfGrant, + "autonomous" => accord_core::schema::CeremonyType::Autonomous, + "break_glass" | "breakglass" => accord_core::schema::CeremonyType::BreakGlass, + "single_approval" | "singleapproval" => { + accord_core::schema::CeremonyType::SingleApproval + } + "quorum_approval" | "quorumapproval" => { + accord_core::schema::CeremonyType::QuorumApproval + } + "inherit" => accord_core::schema::CeremonyType::Inherit, + _ => accord_core::schema::CeremonyType::SingleApproval, + } +} + +fn parse_subject(msg: &CeremonySubjectMsg) -> CeremonySubject { + match msg.subject_type.as_str() { + "mutation_intent" => CeremonySubject::MutationIntent { + intent_id: msg.reference_id.clone(), + registry_type: msg.metadata.get("registry_type").cloned().unwrap_or_default(), + verb: msg.metadata.get("verb").cloned().unwrap_or_default(), + artifact_scope: msg.metadata.get("artifact_scope").cloned().unwrap_or_default(), + tenant_id: msg.metadata.get("tenant_id").cloned().unwrap_or_default(), + }, + "pipeline_merge" => CeremonySubject::PipelineMerge { + run_id: msg.reference_id.clone(), + pipeline_name: msg.metadata.get("pipeline_name").cloned().unwrap_or_default(), + branch: msg.metadata.get("branch").cloned().unwrap_or_default(), + commit_hash: msg.metadata.get("commit_hash").cloned().unwrap_or_default(), + remote_name: msg.metadata.get("remote_name").cloned().unwrap_or_default(), + }, + "schematic_publish" => { + let parts: Vec<&str> = msg.reference_id.splitn(2, ':').collect(); + CeremonySubject::SchematicPublish { + schematic_name: parts.first().unwrap_or(&"").to_string(), + version: parts.get(1).unwrap_or(&"").to_string(), + tree_hash: msg.metadata.get("tree_hash").cloned().unwrap_or_default(), + } + } + _ => CeremonySubject::Custom { + subject_type: msg.subject_type.clone(), + reference_id: msg.reference_id.clone(), + description: msg.description.clone(), + }, + } +} + +#[tonic::async_trait] +impl ceremony_service_server::CeremonyService for GovernanceCeremonyService { + async fn create_ceremony( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let ceremony_type = parse_ceremony_type(&req.ceremony_type); + let subject = req + .subject + .as_ref() + .map(parse_subject) + .ok_or_else(|| Status::invalid_argument("subject is required"))?; + + let ceremony_id = uuid::Uuid::new_v4().to_string(); + let ttl = if req.ttl_hours == 0 { 24 } else { req.ttl_hours }; + + let reqs = accord_core::schema::CeremonyReqs { + approver_roles: if req.approver_roles.is_empty() { + None + } else { + Some(req.approver_roles.clone()) + }, + quorum: if req.required_approvals > 0 { + Some(req.required_approvals) + } else { + None + }, + ..Default::default() + }; + + let mut ceremony = + CeremonyEngine::create_request(ceremony_id, &ceremony_type, &reqs, subject, ttl); + + // Attach optional links + if !req.intent_id.is_empty() { + ceremony.intent_id = Some(req.intent_id); + } + if !req.run_id.is_empty() { + ceremony.run_id = Some(req.run_id); + } + if req.pr_number > 0 { + ceremony.pr_number = Some(req.pr_number); + } + if !req.remote_name.is_empty() { + ceremony.remote_name = Some(req.remote_name); + } + + self.store.create(&ceremony).await.map_err(|e| { + Status::internal(format!("failed to create ceremony: {e}")) + })?; + + info!( + ceremony_id = %ceremony.ceremony_id, + ceremony_type = ?ceremony.ceremony_type, + status = ?ceremony.status, + "Governance ceremony created" + ); + + Ok(Response::new(CreateCeremonyResponse { + ceremony_id: ceremony.ceremony_id.clone(), + status: status_to_string(ceremony.status), + expires_at: Some(to_proto_timestamp(&ceremony.expires_at)), + error: String::new(), + })) + } + + async fn approve_ceremony( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let mut ceremony = self + .store + .get(&req.ceremony_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("ceremony not found"))?; + + let comment = if req.comment.is_empty() { + None + } else { + Some(req.comment) + }; + + ceremony + .record_decision( + &req.approver_identity, + &req.approver_role, + ApprovalDecision::Approve, + comment, + ) + .map_err(|e| Status::failed_precondition(e.to_string()))?; + + // Evaluate if threshold is met + CeremonyEngine::evaluate(&mut ceremony); + + self.store.update(&ceremony).await.map_err(|e| { + Status::internal(format!("failed to update ceremony: {e}")) + })?; + + info!( + ceremony_id = %ceremony.ceremony_id, + approver = %req.approver_identity, + status = ?ceremony.status, + "Ceremony approval recorded" + ); + + Ok(Response::new(ApproveCeremonyResponse { + success: true, + status: status_to_string(ceremony.status), + error: String::new(), + })) + } + + async fn deny_ceremony( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let mut ceremony = self + .store + .get(&req.ceremony_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("ceremony not found"))?; + + let comment = if req.comment.is_empty() { + None + } else { + Some(req.comment) + }; + + ceremony + .record_decision( + &req.approver_identity, + &req.approver_role, + ApprovalDecision::Deny, + comment, + ) + .map_err(|e| Status::failed_precondition(e.to_string()))?; + + CeremonyEngine::evaluate(&mut ceremony); + + self.store.update(&ceremony).await.map_err(|e| { + Status::internal(format!("failed to update ceremony: {e}")) + })?; + + info!( + ceremony_id = %ceremony.ceremony_id, + denier = %req.approver_identity, + "Ceremony denied" + ); + + Ok(Response::new(DenyCeremonyResponse { + success: true, + status: status_to_string(ceremony.status), + error: String::new(), + })) + } + + async fn cancel_ceremony( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let mut ceremony = self + .store + .get(&req.ceremony_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("ceremony not found"))?; + + ceremony + .cancel() + .map_err(|e| Status::failed_precondition(e.to_string()))?; + + self.store.update(&ceremony).await.map_err(|e| { + Status::internal(format!("failed to update ceremony: {e}")) + })?; + + info!(ceremony_id = %ceremony.ceremony_id, "Ceremony cancelled"); + + Ok(Response::new(CancelCeremonyResponse { + success: true, + error: String::new(), + })) + } + + async fn get_ceremony( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let ceremony = self + .store + .get(&req.ceremony_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("ceremony not found"))?; + + Ok(Response::new(ceremony_to_response(&ceremony))) + } + + async fn list_pending_ceremonies( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let intent_filter = if req.intent_id.is_empty() { + None + } else { + Some(req.intent_id.as_str()) + }; + + let ceremonies = self + .store + .list_pending(intent_filter) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListPendingCeremoniesResponse { + ceremonies: ceremonies.iter().map(ceremony_to_response).collect(), + })) + } + + async fn get_ceremony_proof( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let ceremony = self + .store + .get(&req.ceremony_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("ceremony not found"))?; + + if !ceremony.status.is_terminal() { + return Err(Status::failed_precondition( + "ceremony has not been resolved yet", + )); + } + + let resolution = CeremonyResolution::from_ceremony( + &ceremony.ceremony_id, + ceremony.status, + &ceremony.subject, + &ceremony.approvals, + ); + + Ok(Response::new(GetCeremonyProofResponse { + ceremony_id: resolution.ceremony_id, + status: status_to_string(resolution.status), + proof_hash: resolution.proof_hash, + approvals: resolution + .approvals + .iter() + .map(|a| CeremonyApprovalMsg { + approver_identity: a.approver_identity.clone(), + approver_role: a.approver_role.clone(), + decision: match a.decision { + ApprovalDecision::Approve => "approve".to_string(), + ApprovalDecision::Deny => "deny".to_string(), + }, + comment: a.comment.clone().unwrap_or_default(), + decided_at: Some(to_proto_timestamp(&a.decided_at)), + }) + .collect(), + resolved_at: Some(to_proto_timestamp(&resolution.resolved_at)), + error: String::new(), + })) + } +} + +impl GovernanceCeremonyService { + /// Expire all pending ceremonies that have passed their `expires_at`. + /// Returns the number of ceremonies expired. + pub async fn expire_pending(&self) -> usize { + let now = chrono::Utc::now(); + let expired = match self.store.find_expired(now).await { + Ok(e) => e, + Err(e) => { + tracing::error!(error = %e, "Failed to find expired ceremonies"); + return 0; + } + }; + + let mut count = 0; + for mut cer in expired { + if CeremonyEngine::evaluate(&mut cer) { + if let Err(e) = self.store.update(&cer).await { + tracing::error!( + ceremony_id = %cer.ceremony_id, + error = %e, + "Failed to expire ceremony" + ); + } else { + info!( + ceremony_id = %cer.ceremony_id, + "Ceremony expired" + ); + count += 1; + } + } + } + + count + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bascule_core::ceremony_store::InMemoryCeremonyStore; + use bascule_proto::bascule_v1::ceremony_service_server::CeremonyService; + + fn build_service() -> GovernanceCeremonyService { + GovernanceCeremonyService::new(Arc::new(InMemoryCeremonyStore::new())) + } + + fn merge_subject_msg() -> CeremonySubjectMsg { + let mut metadata = std::collections::HashMap::new(); + metadata.insert("pipeline_name".to_string(), "deploy".to_string()); + metadata.insert("branch".to_string(), "main".to_string()); + metadata.insert("commit_hash".to_string(), "abc123".to_string()); + metadata.insert("remote_name".to_string(), "origin".to_string()); + CeremonySubjectMsg { + subject_type: "pipeline_merge".to_string(), + reference_id: "run-001".to_string(), + description: "merge deploy (main)".to_string(), + metadata, + } + } + + #[tokio::test] + async fn create_and_get_ceremony() { + let svc = build_service(); + let resp = svc + .create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "single_approval".to_string(), + subject: Some(merge_subject_msg()), + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + ttl_hours: 24, + intent_id: String::new(), + run_id: "run-001".to_string(), + pr_number: 42, + remote_name: "origin".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(resp.status, "pending"); + assert!(!resp.ceremony_id.is_empty()); + + // Get it back + let get_resp = svc + .get_ceremony(Request::new(GetCeremonyRequest { + ceremony_id: resp.ceremony_id.clone(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(get_resp.ceremony_id, resp.ceremony_id); + assert_eq!(get_resp.status, "pending"); + assert_eq!(get_resp.pr_number, 42); + assert_eq!(get_resp.run_id, "run-001"); + } + + #[tokio::test] + async fn approve_resolves_single_approval() { + let svc = build_service(); + let create_resp = svc + .create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "single_approval".to_string(), + subject: Some(merge_subject_msg()), + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + ttl_hours: 24, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + + let approve_resp = svc + .approve_ceremony(Request::new(ApproveCeremonyRequest { + ceremony_id: create_resp.ceremony_id.clone(), + approver_identity: "alice@ops".to_string(), + approver_role: "msp-ops".to_string(), + comment: "LGTM".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert!(approve_resp.success); + assert_eq!(approve_resp.status, "approved"); + } + + #[tokio::test] + async fn deny_resolves_ceremony() { + let svc = build_service(); + let create_resp = svc + .create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "single_approval".to_string(), + subject: Some(merge_subject_msg()), + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + ttl_hours: 24, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + + let deny_resp = svc + .deny_ceremony(Request::new(DenyCeremonyRequest { + ceremony_id: create_resp.ceremony_id.clone(), + approver_identity: "bob@ops".to_string(), + approver_role: "msp-ops".to_string(), + comment: "unacceptable risk".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert!(deny_resp.success); + assert_eq!(deny_resp.status, "denied"); + } + + #[tokio::test] + async fn cancel_pending_ceremony() { + let svc = build_service(); + let create_resp = svc + .create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "single_approval".to_string(), + subject: Some(merge_subject_msg()), + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + ttl_hours: 24, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + + let cancel_resp = svc + .cancel_ceremony(Request::new(CancelCeremonyRequest { + ceremony_id: create_resp.ceremony_id.clone(), + })) + .await + .unwrap() + .into_inner(); + + assert!(cancel_resp.success); + + let get_resp = svc + .get_ceremony(Request::new(GetCeremonyRequest { + ceremony_id: create_resp.ceremony_id, + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(get_resp.status, "cancelled"); + } + + #[tokio::test] + async fn list_pending_ceremonies() { + let svc = build_service(); + + // Create 2 ceremonies + for i in 0..2 { + svc.create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "single_approval".to_string(), + subject: Some(CeremonySubjectMsg { + subject_type: "custom".to_string(), + reference_id: format!("ref-{i}"), + description: format!("test {i}"), + metadata: std::collections::HashMap::new(), + }), + required_approvals: 1, + approver_roles: vec![], + ttl_hours: 24, + ..Default::default() + })) + .await + .unwrap(); + } + + let list_resp = svc + .list_pending_ceremonies(Request::new(ListPendingCeremoniesRequest { + intent_id: String::new(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(list_resp.ceremonies.len(), 2); + } + + #[tokio::test] + async fn self_grant_auto_approved() { + let svc = build_service(); + let resp = svc + .create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "self_grant".to_string(), + subject: Some(merge_subject_msg()), + required_approvals: 0, + approver_roles: vec![], + ttl_hours: 24, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(resp.status, "approved"); + } + + #[tokio::test] + async fn get_proof_for_resolved_ceremony() { + let svc = build_service(); + let create_resp = svc + .create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "single_approval".to_string(), + subject: Some(merge_subject_msg()), + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + ttl_hours: 24, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + + svc.approve_ceremony(Request::new(ApproveCeremonyRequest { + ceremony_id: create_resp.ceremony_id.clone(), + approver_identity: "alice@ops".to_string(), + approver_role: "msp-ops".to_string(), + comment: String::new(), + })) + .await + .unwrap(); + + let proof = svc + .get_ceremony_proof(Request::new(GetCeremonyProofRequest { + ceremony_id: create_resp.ceremony_id, + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(proof.status, "approved"); + assert!(!proof.proof_hash.is_empty()); + assert_eq!(proof.proof_hash.len(), 64); // SHA-256 hex + assert_eq!(proof.approvals.len(), 1); + } + + #[tokio::test] + async fn proof_on_pending_fails() { + let svc = build_service(); + let create_resp = svc + .create_ceremony(Request::new(CreateCeremonyRequest { + ceremony_type: "single_approval".to_string(), + subject: Some(merge_subject_msg()), + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + ttl_hours: 24, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + + let err = svc + .get_ceremony_proof(Request::new(GetCeremonyProofRequest { + ceremony_id: create_resp.ceremony_id, + })) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + } +} diff --git a/bascule-gateway/src/http_ceremony.rs b/bascule-gateway/src/http_ceremony.rs new file mode 100644 index 0000000..145f71f --- /dev/null +++ b/bascule-gateway/src/http_ceremony.rs @@ -0,0 +1,495 @@ +//! HTTP endpoints for browser-based ceremony approval. +//! +//! Provides REST endpoints that complement the gRPC CeremonyService, +//! enabling stakeholders to approve/deny ceremonies through a web browser +//! or simple HTTP clients. +//! +//! Endpoints: +//! - `GET /ceremonies` — list pending ceremonies +//! - `GET /ceremonies/:id` — get ceremony details +//! - `POST /ceremonies/:id/approve` — approve a ceremony +//! - `POST /ceremonies/:id/deny` — deny a ceremony +//! - `GET /health` — health check + +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use bascule_core::ceremony_engine::CeremonyEngine; +use bascule_core::ceremony_request::{ApprovalDecision, GovernanceCeremonyStatus}; +use bascule_core::ceremony_resolution::CeremonyResolution; +use bascule_core::ceremony_store::CeremonyStore; +use serde::{Deserialize, Serialize}; +use tracing::info; + +/// Shared state for HTTP handlers. +#[derive(Clone)] +pub struct CeremonyHttpState { + pub store: Arc, +} + +/// Build the axum Router for ceremony HTTP endpoints. +pub fn ceremony_router(state: CeremonyHttpState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/ceremonies", get(list_pending)) + .route("/ceremonies/{id}", get(get_ceremony)) + .route("/ceremonies/{id}/approve", post(approve_ceremony)) + .route("/ceremonies/{id}/deny", post(deny_ceremony)) + .with_state(state) +} + +#[derive(Serialize)] +struct HealthResponse { + status: String, +} + +async fn health() -> impl IntoResponse { + Json(HealthResponse { + status: "ok".to_string(), + }) +} + +#[derive(Serialize, Deserialize)] +struct CeremonyListResponse { + ceremonies: Vec, +} + +#[derive(Serialize, Deserialize)] +struct CeremonySummary { + ceremony_id: String, + ceremony_type: String, + subject_label: String, + status: String, + required_approvals: u32, + current_approvals: u32, + created_at: String, + expires_at: String, +} + +async fn list_pending( + State(state): State, +) -> Result { + let ceremonies = state + .store + .list_pending(None) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let summaries: Vec = ceremonies + .iter() + .map(|c| CeremonySummary { + ceremony_id: c.ceremony_id.clone(), + ceremony_type: format!("{:?}", c.ceremony_type).to_lowercase(), + subject_label: c.subject.display_label(), + status: status_str(c.status), + required_approvals: c.required_approvals, + current_approvals: c.approval_count(), + created_at: c.created_at.to_rfc3339(), + expires_at: c.expires_at.to_rfc3339(), + }) + .collect(); + + Ok(Json(CeremonyListResponse { + ceremonies: summaries, + })) +} + +#[derive(Serialize, Deserialize)] +struct CeremonyDetail { + ceremony_id: String, + ceremony_type: String, + subject_label: String, + status: String, + required_approvals: u32, + current_approvals: u32, + approvals: Vec, + created_at: String, + expires_at: String, + intent_id: Option, + run_id: Option, + pr_number: Option, + remote_name: Option, + proof_hash: Option, +} + +#[derive(Serialize, Deserialize)] +struct ApprovalSummary { + approver_identity: String, + approver_role: String, + decision: String, + comment: Option, + decided_at: String, +} + +async fn get_ceremony( + State(state): State, + Path(id): Path, +) -> Result { + let ceremony = state + .store + .get(&id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?; + + let proof_hash = if ceremony.status.is_terminal() { + let resolution = CeremonyResolution::from_ceremony( + &ceremony.ceremony_id, + ceremony.status, + &ceremony.subject, + &ceremony.approvals, + ); + Some(resolution.proof_hash) + } else { + None + }; + + let detail = CeremonyDetail { + ceremony_id: ceremony.ceremony_id.clone(), + ceremony_type: format!("{:?}", ceremony.ceremony_type).to_lowercase(), + subject_label: ceremony.subject.display_label(), + status: status_str(ceremony.status), + required_approvals: ceremony.required_approvals, + current_approvals: ceremony.approval_count(), + approvals: ceremony + .approvals + .iter() + .map(|a| ApprovalSummary { + approver_identity: a.approver_identity.clone(), + approver_role: a.approver_role.clone(), + decision: match a.decision { + ApprovalDecision::Approve => "approve".to_string(), + ApprovalDecision::Deny => "deny".to_string(), + }, + comment: a.comment.clone(), + decided_at: a.decided_at.to_rfc3339(), + }) + .collect(), + created_at: ceremony.created_at.to_rfc3339(), + expires_at: ceremony.expires_at.to_rfc3339(), + intent_id: ceremony.intent_id.clone(), + run_id: ceremony.run_id.clone(), + pr_number: ceremony.pr_number, + remote_name: ceremony.remote_name.clone(), + proof_hash, + }; + + Ok(Json(detail)) +} + +#[derive(Serialize, Deserialize)] +struct ApproveRequest { + approver_identity: String, + approver_role: String, + comment: Option, +} + +#[derive(Serialize, Deserialize)] +struct ApproveResponse { + success: bool, + status: String, + error: Option, +} + +async fn approve_ceremony( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result { + let mut ceremony = state + .store + .get(&id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?; + + if let Err(e) = ceremony.record_decision( + &body.approver_identity, + &body.approver_role, + ApprovalDecision::Approve, + body.comment, + ) { + return Ok(( + StatusCode::CONFLICT, + Json(ApproveResponse { + success: false, + status: status_str(ceremony.status), + error: Some(e.to_string()), + }), + )); + } + + CeremonyEngine::evaluate(&mut ceremony); + + state + .store + .update(&ceremony) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + info!( + ceremony_id = %id, + approver = %body.approver_identity, + status = ?ceremony.status, + "HTTP ceremony approval" + ); + + Ok(( + StatusCode::OK, + Json(ApproveResponse { + success: true, + status: status_str(ceremony.status), + error: None, + }), + )) +} + +async fn deny_ceremony( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result { + let mut ceremony = state + .store + .get(&id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?; + + if let Err(e) = ceremony.record_decision( + &body.approver_identity, + &body.approver_role, + ApprovalDecision::Deny, + body.comment, + ) { + return Ok(( + StatusCode::CONFLICT, + Json(ApproveResponse { + success: false, + status: status_str(ceremony.status), + error: Some(e.to_string()), + }), + )); + } + + CeremonyEngine::evaluate(&mut ceremony); + + state + .store + .update(&ceremony) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + info!( + ceremony_id = %id, + denier = %body.approver_identity, + "HTTP ceremony denial" + ); + + Ok(( + StatusCode::OK, + Json(ApproveResponse { + success: true, + status: status_str(ceremony.status), + error: None, + }), + )) +} + +fn status_str(status: GovernanceCeremonyStatus) -> String { + match status { + GovernanceCeremonyStatus::Pending => "pending".to_string(), + GovernanceCeremonyStatus::Approved => "approved".to_string(), + GovernanceCeremonyStatus::Denied => "denied".to_string(), + GovernanceCeremonyStatus::Expired => "expired".to_string(), + GovernanceCeremonyStatus::Cancelled => "cancelled".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use bascule_core::ceremony_engine::CeremonyEngine; + use bascule_core::ceremony_request::CeremonySubject; + use bascule_core::ceremony_store::InMemoryCeremonyStore; + use tower::ServiceExt; + + fn build_app() -> (Router, Arc) { + let store = Arc::new(InMemoryCeremonyStore::new()); + let state = CeremonyHttpState { + store: store.clone(), + }; + (ceremony_router(state), store) + } + + async fn create_test_ceremony(store: &InMemoryCeremonyStore) -> String { + let reqs = accord_core::schema::CeremonyReqs { + approver_roles: Some(vec!["msp-ops".to_string()]), + quorum: Some(1), + ..Default::default() + }; + let ceremony = CeremonyEngine::create_request( + "cer-http-001".to_string(), + &accord_core::schema::CeremonyType::SingleApproval, + &reqs, + CeremonySubject::PipelineMerge { + run_id: "run-1".to_string(), + pipeline_name: "deploy".to_string(), + branch: "main".to_string(), + commit_hash: "abc".to_string(), + remote_name: "origin".to_string(), + }, + 24, + ); + let id = ceremony.ceremony_id.clone(); + store.create(&ceremony).await.unwrap(); + id + } + + #[tokio::test] + async fn health_check() { + let (app, _) = build_app(); + let resp = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn list_empty_ceremonies() { + let (app, _) = build_app(); + let resp = app + .oneshot( + Request::builder() + .uri("/ceremonies") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let list: CeremonyListResponse = serde_json::from_slice(&body).unwrap(); + assert!(list.ceremonies.is_empty()); + } + + #[tokio::test] + async fn get_ceremony_detail() { + let (app, store) = build_app(); + let id = create_test_ceremony(&store).await; + + let resp = app + .oneshot( + Request::builder() + .uri(&format!("/ceremonies/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let detail: CeremonyDetail = serde_json::from_slice(&body).unwrap(); + assert_eq!(detail.ceremony_id, id); + assert_eq!(detail.status, "pending"); + } + + #[tokio::test] + async fn approve_via_http() { + let (app, store) = build_app(); + let id = create_test_ceremony(&store).await; + + let body = serde_json::to_string(&ApproveRequest { + approver_identity: "alice@ops".to_string(), + approver_role: "msp-ops".to_string(), + comment: Some("LGTM".to_string()), + }) + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri(&format!("/ceremonies/{id}/approve")) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let resp_body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let result: ApproveResponse = serde_json::from_slice(&resp_body).unwrap(); + assert!(result.success); + assert_eq!(result.status, "approved"); + } + + #[tokio::test] + async fn deny_via_http() { + let (app, store) = build_app(); + let id = create_test_ceremony(&store).await; + + let body = serde_json::to_string(&ApproveRequest { + approver_identity: "bob@ops".to_string(), + approver_role: "msp-ops".to_string(), + comment: Some("needs rework".to_string()), + }) + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri(&format!("/ceremonies/{id}/deny")) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let resp_body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let result: ApproveResponse = serde_json::from_slice(&resp_body).unwrap(); + assert!(result.success); + assert_eq!(result.status, "denied"); + } + + #[tokio::test] + async fn not_found_ceremony() { + let (app, _) = build_app(); + let resp = app + .oneshot( + Request::builder() + .uri("/ceremonies/nonexistent") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} diff --git a/bascule-gateway/src/main.rs b/bascule-gateway/src/main.rs new file mode 100644 index 0000000..bf63878 --- /dev/null +++ b/bascule-gateway/src/main.rs @@ -0,0 +1,241 @@ +mod audit_pipeline; +mod auth; +mod ceremony; +mod config; +mod executor; +mod filter; +mod governance_ceremony; +mod http_ceremony; +mod migrations; +mod server; +mod session_manager; + +use std::sync::Arc; + +use crate::config::BasculeConfig; +use crate::executor::ExecutorRegistry; +use crate::filter::FilterChain; +use crate::server::BasculeGatewayService; +use crate::session_manager::SessionManager; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Workspace has both ring and aws-lc-rs rustls features. + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .json() + .init(); + + let config = BasculeConfig::from_env()?; + tracing::info!(listen_addr = %config.listen_addr, "Starting Bascule Gateway"); + + // 1. Load accord + let accord = match std::fs::read_to_string(&config.accord_path) { + Ok(yaml) => { + let accord = accord_core::schema::Accord::load(&yaml)?; + tracing::info!(version = %accord.metadata.version, "Accord loaded"); + Arc::new(accord) + } + Err(e) => { + tracing::warn!(path = %config.accord_path, error = %e, + "Accord file not found — OPA policy will deny all unclassified operations"); + // Minimal empty accord + let empty_yaml = r#" +apiVersion: guildhouse.io/v1alpha1 +kind: Accord +metadata: + name: empty + version: "0.0.0" + previousVersionHash: "none" + authorizingCeremony: bootstrap + effectiveAt: "2025-01-01T00:00:00Z" + expiresAt: "2099-01-01T00:00:00Z" +spec: + trustDomain: guildhouse.local + policy: + bundleHash: "none" + bundlePath: "/policies" + classifications: [] + ceremonies: [] + ledger: + fidelity: always_notarize + notarize: [] + logOnly: [] + sampled: [] + sampleRate: 1 + reconciliation: + defaultWindow: "24h" + onExpiry: alert + driftResponses: [] + controllers: [] + roles: [] +"#; + Arc::new(accord_core::schema::Accord::load(empty_yaml) + .expect("empty accord must parse")) + } + }; + + // 2. Connect to database (optional — degrade gracefully for dev without PG) + let db_pool = match sqlx::PgPool::connect(&config.database_url()).await { + Ok(pool) => { + tracing::info!("PostgreSQL connected"); + // 3. Run migrations + migrations::run_migrations(&pool).await?; + Some(pool) + } + Err(e) => { + tracing::warn!("PostgreSQL not available ({e}) — running in memory-only mode"); + None + } + }; + + // 4. Create OPA client + let opa_client = Arc::new(accord_opa::OpaClient::new(&config.opa_url)); + match opa_client.health_check().await { + Ok(true) => tracing::info!("OPA sidecar is healthy"), + _ => tracing::warn!("OPA sidecar not available — policy filter will deny all requests"), + } + + // 5. Build the Kubernetes client (in-cluster or from kubeconfig) + let kube_client = kube::Client::try_default().await?; + tracing::info!("Kubernetes client initialized"); + + // 6. Session manager (dual-store: DashMap + PG) + let session_manager = Arc::new(SessionManager::new(db_pool.clone())); + if db_pool.is_some() { + let restored = session_manager.restore_from_db().await?; + if restored > 0 { + tracing::info!(restored, "Restored sessions from database"); + } + } + + // 7. Ceremony manager + let ceremony_manager = if let Some(pool) = &db_pool { + Some(Arc::new(ceremony::CeremonyManager::new( + pool.clone(), + config.session_lifetime_secs, + ))) + } else { + None + }; + + // 8. Audit pipeline + let audit_pipeline = if let Some(pool) = &db_pool { + let pipeline = Arc::new(audit_pipeline::AuditPipeline::new( + pool.clone(), + config.audit_batch_size, + )); + let _flush_handle = pipeline.clone().start_flush_loop( + std::time::Duration::from_secs(config.audit_flush_interval_secs), + ); + pipeline + } else { + // No PG — create a pipeline with a lazy pool (will error on submit) + Arc::new(audit_pipeline::AuditPipeline::new( + sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?, + config.audit_batch_size, + )) + }; + + // 9. Build executor registry + let executor_registry = Arc::new(ExecutorRegistry::new(kube_client.clone())); + + // 10. Build filter chain + let auth_provider = Arc::new(auth::OidcAuthProvider::new( + &config.oidc_issuer, + &config.oidc_audience, + )); + + let filter_chain = Arc::new(FilterChain::new( + auth_provider.clone(), + session_manager.clone(), + executor_registry, + opa_client, + accord, + audit_pipeline, + )); + + // 11. Spawn background tasks + let reaper_manager = session_manager.clone(); + tokio::spawn(async move { + reaper_manager.run_reaper().await; + }); + + if let Some(cm) = &ceremony_manager { + let cm = cm.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + cm.reap_expired_ceremonies().await; + } + }); + } + + // 12. Governance ceremony service (in-memory store + expiry loop) + let gov_ceremony_store: Arc = + Arc::new(bascule_core::ceremony_store::InMemoryCeremonyStore::new()); + let gov_ceremony_svc = Arc::new(governance_ceremony::GovernanceCeremonyService::new( + gov_ceremony_store.clone(), + )); + { + let svc = gov_ceremony_svc.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + let expired = svc.expire_pending().await; + if expired > 0 { + tracing::info!(expired, "Governance ceremonies expired"); + } + } + }); + } + + // 13. Build gRPC server + let service = BasculeGatewayService::new( + filter_chain, + session_manager, + auth_provider, + ceremony_manager, + ); + + let addr = config.listen_addr.parse()?; + tracing::info!(%addr, "Bascule Gateway listening"); + + // Spawn HTTP ceremony server on separate port + let http_state = http_ceremony::CeremonyHttpState { + store: gov_ceremony_store, + }; + let http_app = http_ceremony::ceremony_router(http_state); + let http_addr: std::net::SocketAddr = config + .http_listen_addr() + .parse() + .unwrap_or_else(|_| "0.0.0.0:8443".parse().unwrap()); + tracing::info!(%http_addr, "Ceremony HTTP server listening"); + tokio::spawn(async move { + let listener = tokio::net::TcpListener::bind(http_addr).await.unwrap(); + axum::serve(listener, http_app).await.unwrap(); + }); + + tonic::transport::Server::builder() + .add_service( + bascule_proto::bascule_v1::bascule_gateway_server::BasculeGatewayServer::new(service), + ) + .add_service( + bascule_proto::bascule_v1::ceremony_service_server::CeremonyServiceServer::from_arc( + gov_ceremony_svc, + ), + ) + .serve(addr) + .await?; + + Ok(()) +} diff --git a/bascule-gateway/src/migrations.rs b/bascule-gateway/src/migrations.rs new file mode 100644 index 0000000..b261c87 --- /dev/null +++ b/bascule-gateway/src/migrations.rs @@ -0,0 +1,122 @@ +use sqlx::PgPool; + +/// Run database migrations for the bascule schema. +/// Creates schema and tables if they don't exist. +pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> { + tracing::info!("Running bascule schema migrations"); + + // Create schema + sqlx::query("CREATE SCHEMA IF NOT EXISTS bascule") + .execute(pool) + .await?; + + // Ceremonies table (must exist before sessions due to FK) + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bascule.ceremonies ( + ceremony_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ceremony_type TEXT NOT NULL, + requestor_sub TEXT NOT NULL, + requestor_email TEXT NOT NULL, + requested_scope JSONB NOT NULL, + granted_scope JSONB, + status TEXT NOT NULL DEFAULT 'pending', + accord_version TEXT NOT NULL, + evidence JSONB DEFAULT '[]', + approvers JSONB DEFAULT '[]', + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + timeout_at TIMESTAMPTZ NOT NULL, + merkle_leaf BYTEA + ) + "#, + ) + .execute(pool) + .await?; + + // Sessions table + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bascule.sessions ( + session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ceremony_id UUID NOT NULL REFERENCES bascule.ceremonies(ceremony_id), + operator_sub TEXT NOT NULL, + operator_email TEXT NOT NULL, + scope JSONB NOT NULL, + state TEXT NOT NULL DEFAULT 'active', + mutations_used INTEGER NOT NULL DEFAULT 0, + mutation_budget INTEGER, + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + terminated_at TIMESTAMPTZ, + merkle_leaf BYTEA + ) + "#, + ) + .execute(pool) + .await?; + + // Session indexes + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_sessions_operator ON bascule.sessions (operator_sub, state)", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_sessions_active ON bascule.sessions (state) WHERE state = 'active'", + ) + .execute(pool) + .await?; + + // Audit events table + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bascule.audit_events ( + time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + event_id UUID NOT NULL DEFAULT gen_random_uuid(), + session_id UUID NOT NULL, + operator_identity TEXT NOT NULL, + command JSONB NOT NULL, + classification TEXT NOT NULL, + policy_decision JSONB NOT NULL, + execution_result JSONB NOT NULL, + target_resources JSONB DEFAULT '[]', + target_profile_hash TEXT, + notarized BOOLEAN NOT NULL DEFAULT false, + anchor_id UUID, + leaf_index INTEGER, + merkle_leaf BYTEA + ) + "#, + ) + .execute(pool) + .await?; + + // Try to create hypertable (only works if TimescaleDB is available) + match sqlx::query( + "SELECT create_hypertable('bascule.audit_events', 'time', if_not_exists => true)", + ) + .execute(pool) + .await + { + Ok(_) => tracing::info!("audit_events hypertable created"), + Err(e) => tracing::warn!("TimescaleDB not available, skipping hypertable: {e}"), + } + + // Audit event indexes + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_audit_session ON bascule.audit_events (session_id, time DESC)", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_audit_operator ON bascule.audit_events (operator_identity, time DESC)", + ) + .execute(pool) + .await?; + + tracing::info!("Bascule schema migrations complete"); + Ok(()) +} diff --git a/bascule-gateway/src/server.rs b/bascule-gateway/src/server.rs new file mode 100644 index 0000000..be6a2b4 --- /dev/null +++ b/bascule-gateway/src/server.rs @@ -0,0 +1,499 @@ +use std::sync::Arc; + +use bascule_core::ceremony::CeremonyType; +use bascule_core::command::builtin_commands; +use bascule_core::scope::{ + ChangePathway, GlobalScope, NamespaceScope, ScopeRule, SessionScope, Verb, +}; +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +use crate::auth::OidcAuthProvider; +use crate::ceremony::{CeremonyManager, CeremonyResponse}; +use crate::filter::FilterChain; +use crate::session_manager::SessionManager; + +pub struct BasculeGatewayService { + filter_chain: Arc, + session_manager: Arc, + auth_provider: Arc, + ceremony_manager: Option>, +} + +impl BasculeGatewayService { + pub fn new( + filter_chain: Arc, + session_manager: Arc, + auth_provider: Arc, + ceremony_manager: Option>, + ) -> Self { + Self { + filter_chain, + session_manager, + auth_provider, + ceremony_manager, + } + } +} + +#[tonic::async_trait] +impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for BasculeGatewayService { + async fn request_session( + &self, + request: Request, + ) -> Result, Status> { + let token = extract_bearer_token(request.metadata()) + .ok_or_else(|| Status::unauthenticated("missing authorization header"))?; + + let identity = self + .auth_provider + .validate_token(&token) + .await + .map_err(|e| Status::unauthenticated(e.to_string()))?; + + let inner = request.into_inner(); + + let scope = inner + .requested_scope + .as_ref() + .map(proto_scope_to_core) + .unwrap_or_else(|| SessionManager::default_read_scope(&["default".into()])); + + if let Some(cm) = &self.ceremony_manager { + let response = match inner.ceremony_type.as_str() { + "self_grant" => cm + .process_self_grant(&identity, &scope, "1.0.0") + .await + .map_err(|e| Status::internal(e.to_string()))?, + "single_approval" => cm + .process_single_approval(&identity, &scope, "1.0.0") + .await + .map_err(|e| Status::internal(e.to_string()))?, + "emergency_access" | "break_glass" => { + let evidence: Vec = inner + .evidence + .iter() + .map(|e| bascule_core::ceremony::Evidence { + evidence_type: parse_evidence_type(&e.evidence_type), + reference: e.reference.clone(), + verified: false, + verified_at: None, + }) + .collect(); + cm.process_break_glass(&identity, &scope, &evidence, "1.0.0") + .await + .map_err(|e| Status::internal(e.to_string()))? + } + other => { + return Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse { + result: Some( + bascule_proto::bascule_v1::request_session_response::Result::Denied( + bascule_proto::bascule_v1::CeremonyDenied { + reason: format!("unsupported ceremony type: {other}"), + }, + ), + ), + })); + } + }; + + match response { + CeremonyResponse::Granted(grant) => { + let session = self + .session_manager + .create_session(&grant) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse { + result: Some( + bascule_proto::bascule_v1::request_session_response::Result::Granted( + bascule_proto::bascule_v1::SessionGranted { + session_id: session.session_id.to_string(), + granted_scope: Some(core_scope_to_proto(&session.scope)), + expires_at: Some(to_proto_timestamp(&session.expires_at)), + ceremony_id: session.ceremony_id.to_string(), + }, + ), + ), + })) + } + CeremonyResponse::Pending { + ceremony_id, + timeout_at, + } => Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse { + result: Some( + bascule_proto::bascule_v1::request_session_response::Result::Pending( + bascule_proto::bascule_v1::CeremonyPending { + ceremony_id: ceremony_id.to_string(), + message: "Awaiting approval".to_string(), + timeout_at: Some(to_proto_timestamp(&timeout_at)), + }, + ), + ), + })), + CeremonyResponse::Denied(reason) => { + Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse { + result: Some( + bascule_proto::bascule_v1::request_session_response::Result::Denied( + bascule_proto::bascule_v1::CeremonyDenied { reason }, + ), + ), + })) + } + } + } else { + // No ceremony manager (memory-only mode) + if inner.ceremony_type != "self_grant" { + return Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse { + result: Some( + bascule_proto::bascule_v1::request_session_response::Result::Denied( + bascule_proto::bascule_v1::CeremonyDenied { + reason: format!( + "ceremony type '{}' requires database (not available)", + inner.ceremony_type + ), + }, + ), + ), + })); + } + + let grant = bascule_core::ceremony::CeremonyGrant { + ceremony_id: Uuid::new_v4(), + ceremony_type: CeremonyType::SelfGrant, + requestor: identity, + approvers: vec![], + granted_scope: scope, + accord_version: "none".into(), + evidence: vec![], + granted_at: chrono::Utc::now(), + session_lifetime: chrono::Duration::hours(8), + }; + + let session = self + .session_manager + .create_session(&grant) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse { + result: Some( + bascule_proto::bascule_v1::request_session_response::Result::Granted( + bascule_proto::bascule_v1::SessionGranted { + session_id: session.session_id.to_string(), + granted_scope: Some(core_scope_to_proto(&session.scope)), + expires_at: Some(to_proto_timestamp(&session.expires_at)), + ceremony_id: session.ceremony_id.to_string(), + }, + ), + ), + })) + } + } + + async fn get_session_status( + &self, + request: Request, + ) -> Result, Status> { + let inner = request.into_inner(); + let session_id: Uuid = inner + .session_id + .parse() + .map_err(|_| Status::invalid_argument("invalid session_id"))?; + + let session = self + .session_manager + .get_session(&session_id) + .ok_or_else(|| Status::not_found("session not found"))?; + + Ok(Response::new(bascule_proto::bascule_v1::GetSessionStatusResponse { + session_id: session.session_id.to_string(), + state: format!("{:?}", session.state).to_lowercase(), + scope: Some(core_scope_to_proto(&session.scope)), + expires_at: Some(to_proto_timestamp(&session.expires_at)), + mutations_used: session.mutations_used, + mutation_budget: session.scope.mutation_budget, + })) + } + + async fn end_session( + &self, + request: Request, + ) -> Result, Status> { + let inner = request.into_inner(); + let session_id: Uuid = inner + .session_id + .parse() + .map_err(|_| Status::invalid_argument("invalid session_id"))?; + + let session = self + .session_manager + .end_session(&session_id) + .await + .ok_or_else(|| Status::not_found("session not found"))?; + + Ok(Response::new(bascule_proto::bascule_v1::EndSessionResponse { + success: true, + total_commands: 0, + total_mutations: session.mutations_used, + })) + } + + async fn get_ceremony_status( + &self, + request: Request, + ) -> Result, Status> { + let cm = self + .ceremony_manager + .as_ref() + .ok_or_else(|| Status::unimplemented("ceremony manager not available"))?; + + let inner = request.into_inner(); + let ceremony_id: Uuid = inner + .ceremony_id + .parse() + .map_err(|_| Status::invalid_argument("invalid ceremony_id"))?; + + let status = cm + .get_ceremony_status(ceremony_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("ceremony not found"))?; + + match status { + crate::ceremony::CeremonyStatus::Pending { + ceremony_id, + .. + } => Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse { + ceremony_id: ceremony_id.to_string(), + status: "pending".to_string(), + session: None, + })), + crate::ceremony::CeremonyStatus::Approved { grant } => { + let session = self + .session_manager + .create_session(&grant) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse { + ceremony_id: grant.ceremony_id.to_string(), + status: "approved".to_string(), + session: Some(bascule_proto::bascule_v1::SessionGranted { + session_id: session.session_id.to_string(), + granted_scope: Some(core_scope_to_proto(&session.scope)), + expires_at: Some(to_proto_timestamp(&session.expires_at)), + ceremony_id: grant.ceremony_id.to_string(), + }), + })) + } + crate::ceremony::CeremonyStatus::Denied { reason } => { + Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse { + ceremony_id: ceremony_id.to_string(), + status: format!("denied: {reason}"), + session: None, + })) + } + crate::ceremony::CeremonyStatus::Expired => { + Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse { + ceremony_id: ceremony_id.to_string(), + status: "expired".to_string(), + session: None, + })) + } + } + } + + async fn execute_command( + &self, + request: Request, + ) -> Result, Status> { + let bearer_token = extract_bearer_token(request.metadata()); + let command = request.into_inner(); + + let response = self.filter_chain.execute(bearer_token, command).await; + Ok(Response::new(response)) + } + + type StreamCommandStream = + tokio_stream::wrappers::ReceiverStream>; + + async fn stream_command( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "streaming commands not implemented in Phase 2", + )) + } + + async fn discover_commands( + &self, + request: Request, + ) -> Result, Status> { + let inner = request.into_inner(); + let session_id: Uuid = inner + .session_id + .parse() + .map_err(|_| Status::invalid_argument("invalid session_id"))?; + + let _session = self + .session_manager + .get_session(&session_id) + .filter(|s| s.is_active()) + .ok_or_else(|| Status::not_found("session not found or expired"))?; + + let commands = builtin_commands() + .into_iter() + .map(|cmd| bascule_proto::bascule_v1::CommandDescriptor { + verb: cmd.verb, + description: cmd.description, + classification: format!("{:?}", cmd.classification).to_lowercase(), + parameters: vec![], + requires_namespace: cmd.requires_namespace, + requires_resource: cmd.requires_resource, + streaming: cmd.streaming, + }) + .collect(); + + Ok(Response::new(bascule_proto::bascule_v1::DiscoverCommandsResponse { + commands, + })) + } +} + +// --- Helper functions --- + +fn extract_bearer_token(metadata: &tonic::metadata::MetadataMap) -> Option { + metadata + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.to_string()) +} + +fn to_proto_timestamp(dt: &chrono::DateTime) -> prost_types::Timestamp { + prost_types::Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + } +} + +fn parse_evidence_type(s: &str) -> bascule_core::ceremony::EvidenceType { + match s { + "jira_ticket" => bascule_core::ceremony::EvidenceType::JiraTicket, + "github_issue" => bascule_core::ceremony::EvidenceType::GitHubIssue, + "slack_thread" => bascule_core::ceremony::EvidenceType::SlackThread, + "pagerduty_incident" => bascule_core::ceremony::EvidenceType::PagerDutyIncident, + _ => bascule_core::ceremony::EvidenceType::Manual, + } +} + +// --- Scope conversion: proto ↔ core --- + +fn proto_scope_to_core(proto: &bascule_proto::bascule_v1::SessionScope) -> SessionScope { + SessionScope { + namespaces: proto.namespaces.iter().map(proto_ns_to_core).collect(), + global: proto + .global + .as_ref() + .map(proto_global_to_core) + .unwrap_or_default(), + pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(), + mutation_budget: proto.mutation_budget, + can_delegate: proto.can_delegate, + } +} + +fn proto_ns_to_core(proto: &bascule_proto::bascule_v1::NamespaceScope) -> NamespaceScope { + NamespaceScope { + namespace: proto.namespace.clone(), + rules: proto + .rules + .iter() + .map(|r| ScopeRule { + api_groups: r.api_groups.clone(), + resources: r.resources.clone(), + verbs: r.verbs.iter().filter_map(|v| parse_verb(v)).collect(), + }) + .collect(), + workload_profiles: proto.workload_profiles.clone(), + denied_capabilities: proto.denied_capabilities.clone(), + } +} + +fn proto_global_to_core(proto: &bascule_proto::bascule_v1::GlobalScope) -> GlobalScope { + GlobalScope { + can_view_audit_trail: proto.can_view_audit_trail, + can_view_profiles: proto.can_view_profiles, + can_view_topology: proto.can_view_topology, + } +} + +fn parse_pathway(s: &str) -> ChangePathway { + match s { + "workspace" => ChangePathway::Workspace, + "dry_run_only" => ChangePathway::DryRunOnly, + _ => ChangePathway::Direct, + } +} + +fn parse_verb(s: &str) -> Option { + match s { + "get" => Some(Verb::Get), + "list" => Some(Verb::List), + "watch" => Some(Verb::Watch), + "create" => Some(Verb::Create), + "update" => Some(Verb::Update), + "patch" => Some(Verb::Patch), + "delete" => Some(Verb::Delete), + "exec" => Some(Verb::Exec), + "logs" => Some(Verb::Logs), + "scale" => Some(Verb::Scale), + _ => None, + } +} + +fn core_scope_to_proto(core: &SessionScope) -> bascule_proto::bascule_v1::SessionScope { + bascule_proto::bascule_v1::SessionScope { + namespaces: core.namespaces.iter().map(core_ns_to_proto).collect(), + global: Some(core_global_to_proto(&core.global)), + pathways: core + .pathways + .iter() + .map(|p| format!("{p:?}").to_lowercase()) + .collect(), + mutation_budget: core.mutation_budget, + can_delegate: core.can_delegate, + } +} + +fn core_ns_to_proto(core: &NamespaceScope) -> bascule_proto::bascule_v1::NamespaceScope { + bascule_proto::bascule_v1::NamespaceScope { + namespace: core.namespace.clone(), + rules: core + .rules + .iter() + .map(|r| bascule_proto::bascule_v1::ScopeRule { + api_groups: r.api_groups.clone(), + resources: r.resources.clone(), + verbs: r + .verbs + .iter() + .map(|v| format!("{v:?}").to_lowercase()) + .collect(), + }) + .collect(), + workload_profiles: core.workload_profiles.clone(), + denied_capabilities: core.denied_capabilities.clone(), + } +} + +fn core_global_to_proto(core: &GlobalScope) -> bascule_proto::bascule_v1::GlobalScope { + bascule_proto::bascule_v1::GlobalScope { + can_view_audit_trail: core.can_view_audit_trail, + can_view_profiles: core.can_view_profiles, + can_view_topology: core.can_view_topology, + } +} diff --git a/bascule-gateway/src/session_manager.rs b/bascule-gateway/src/session_manager.rs new file mode 100644 index 0000000..3df67c4 --- /dev/null +++ b/bascule-gateway/src/session_manager.rs @@ -0,0 +1,251 @@ +use bascule_core::ceremony::CeremonyGrant; +use bascule_core::scope::SessionScope; +use bascule_core::session::{OperatorIdentity, Session, SessionState}; +use chrono::Utc; +use dashmap::DashMap; +use sqlx::PgPool; +use uuid::Uuid; + +/// Dual-store session manager: DashMap (hot cache) + PostgreSQL (persistence). +pub struct SessionManager { + sessions: DashMap, + db_pool: Option, +} + +impl SessionManager { + /// Create a new session manager. Pass None for db_pool in tests or Phase 1 mode. + pub fn new(db_pool: Option) -> Self { + Self { + sessions: DashMap::new(), + db_pool, + } + } + + /// Create a session from a ceremony grant. + pub async fn create_session(&self, grant: &CeremonyGrant) -> anyhow::Result { + let session_id = Uuid::new_v4(); + let now = Utc::now(); + + let session = Session { + session_id, + ceremony_id: grant.ceremony_id, + identity: grant.requestor.clone(), + scope: grant.granted_scope.clone(), + state: SessionState::Active, + mutations_used: 0, + valid_from: now, + expires_at: now + grant.session_lifetime, + }; + + // Insert into hot cache + self.sessions.insert(session_id, session.clone()); + + // Persist to PG + if let Some(pool) = &self.db_pool { + let (sub, email) = identity_parts(&grant.requestor); + let scope_json = serde_json::to_value(&grant.granted_scope)?; + + sqlx::query( + r#" + INSERT INTO bascule.sessions + (session_id, ceremony_id, operator_sub, operator_email, + scope, state, mutations_used, mutation_budget, + valid_from, expires_at) + VALUES ($1, $2, $3, $4, $5, 'active', 0, $6, $7, $8) + "#, + ) + .bind(session_id) + .bind(grant.ceremony_id) + .bind(&sub) + .bind(&email) + .bind(&scope_json) + .bind(grant.granted_scope.mutation_budget.map(|b| b as i32)) + .bind(session.valid_from) + .bind(session.expires_at) + .execute(pool) + .await?; + } + + tracing::info!( + session_id = %session_id, + ceremony_id = %grant.ceremony_id, + "Session created" + ); + + Ok(session) + } + + /// Look up a session by ID. Checks DashMap first, then PG. + pub fn get_session(&self, session_id: &Uuid) -> Option { + self.sessions.get(session_id).map(|s| s.clone()) + } + + /// End a session explicitly. Updates both stores. + pub async fn end_session(&self, session_id: &Uuid) -> Option { + let session = self.sessions.get_mut(session_id).map(|mut s| { + s.state = SessionState::Terminated; + s.clone() + }); + + if session.is_some() { + if let Some(pool) = &self.db_pool { + let _ = sqlx::query( + "UPDATE bascule.sessions SET state = 'terminated', terminated_at = NOW() WHERE session_id = $1", + ) + .bind(session_id) + .execute(pool) + .await; + } + tracing::info!(%session_id, "Session terminated"); + } + + session + } + + /// Record a mutation against a session. Updates both stores. + /// Returns the new mutation count. + pub async fn record_mutation(&self, session_id: &Uuid) -> Option { + let count = self.sessions.get_mut(session_id).map(|mut s| { + s.mutations_used += 1; + s.mutations_used + }); + + if let (Some(count), Some(pool)) = (count, &self.db_pool) { + let _ = sqlx::query( + "UPDATE bascule.sessions SET mutations_used = $2 WHERE session_id = $1", + ) + .bind(session_id) + .bind(count as i32) + .execute(pool) + .await; + } + + count + } + + /// Restore active sessions from PG into DashMap (crash recovery). + pub async fn restore_from_db(&self) -> anyhow::Result { + let pool = match &self.db_pool { + Some(p) => p, + None => return Ok(0), + }; + + let rows = sqlx::query_as::<_, SessionRow>( + r#" + SELECT session_id, ceremony_id, operator_sub, operator_email, + scope, state, mutations_used, mutation_budget, + valid_from, expires_at + FROM bascule.sessions + WHERE state = 'active' AND expires_at > NOW() + "#, + ) + .fetch_all(pool) + .await?; + + let count = rows.len(); + for row in rows { + let scope: SessionScope = serde_json::from_value(row.scope)?; + let session = Session { + session_id: row.session_id, + ceremony_id: row.ceremony_id, + identity: OperatorIdentity::Oidc { + issuer: String::new(), + subject: row.operator_sub, + email: row.operator_email, + }, + scope, + state: SessionState::Active, + mutations_used: row.mutations_used as u32, + valid_from: row.valid_from, + expires_at: row.expires_at, + }; + self.sessions.insert(session.session_id, session); + } + + if count > 0 { + tracing::info!(count, "Restored active sessions from database"); + } + Ok(count) + } + + /// Background reaper for expired sessions (runs every 30 seconds). + pub async fn run_reaper(&self) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + let now = Utc::now(); + let mut expired = 0u32; + for mut entry in self.sessions.iter_mut() { + if entry.state == SessionState::Active && now >= entry.expires_at { + entry.state = SessionState::Expired; + expired += 1; + } + } + if expired > 0 { + tracing::info!(expired, "Reaped expired sessions"); + // Update PG for expired sessions + if let Some(pool) = &self.db_pool { + let _ = sqlx::query( + "UPDATE bascule.sessions SET state = 'expired' WHERE state = 'active' AND expires_at < NOW()", + ) + .execute(pool) + .await; + } + } + } + } + + /// Create a default read-only scope for the given namespaces. + pub fn default_read_scope(namespaces: &[String]) -> SessionScope { + use bascule_core::scope::{ + ChangePathway, GlobalScope, NamespaceScope, ScopeRule, Verb, + }; + + let ns_scopes = namespaces + .iter() + .map(|ns| NamespaceScope { + namespace: ns.clone(), + rules: vec![ScopeRule { + api_groups: vec!["".into(), "apps".into(), "batch".into()], + resources: vec!["*".into()], + verbs: vec![Verb::Get, Verb::List, Verb::Logs], + }], + workload_profiles: vec![], + denied_capabilities: vec![], + }) + .collect(); + + SessionScope { + namespaces: ns_scopes, + global: GlobalScope::default(), + pathways: vec![ChangePathway::DryRunOnly], + mutation_budget: Some(0), + can_delegate: false, + } + } +} + +#[derive(sqlx::FromRow)] +struct SessionRow { + session_id: Uuid, + ceremony_id: Uuid, + operator_sub: String, + operator_email: String, + scope: serde_json::Value, + #[allow(dead_code)] + state: String, + mutations_used: i32, + #[allow(dead_code)] + mutation_budget: Option, + valid_from: chrono::DateTime, + expires_at: chrono::DateTime, +} + +fn identity_parts(identity: &OperatorIdentity) -> (String, String) { + match identity { + OperatorIdentity::Oidc { + subject, email, .. + } => (subject.clone(), email.clone()), + OperatorIdentity::Spiffe { svid_uri } => (svid_uri.clone(), String::new()), + } +} diff --git a/bascule-gateway/tests/fixtures/test-accord.yaml b/bascule-gateway/tests/fixtures/test-accord.yaml new file mode 100644 index 0000000..10bb722 --- /dev/null +++ b/bascule-gateway/tests/fixtures/test-accord.yaml @@ -0,0 +1,190 @@ +apiVersion: guildhouse.io/v1alpha1 +kind: Accord +metadata: + name: genesis-accord + version: "1.0.0" + previousVersionHash: "0000000000000000000000000000000000000000000000000000000000000000" + authorizingCeremony: bootstrap + effectiveAt: "2025-06-01T00:00:00Z" + expiresAt: "2027-06-01T00:00:00Z" +spec: + trustDomain: guildhouse.local + policy: + bundleHash: "sha256:genesis" + bundlePath: ".guildhouse/policies/" + classifications: + - name: read-access + description: Read-only + pathways: [imperative, declarative] + resourceSelectors: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["get", "list", "watch"] + - name: workload-scaling + description: Scale workload replicas + pathways: [imperative, declarative] + resourceSelectors: + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets"] + fields: ["spec.replicas"] + verbs: ["patch", "update"] + - name: workload-deployment + description: Deploy and update workloads + pathways: [imperative, declarative] + resourceSelectors: + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets", "daemonsets"] + verbs: ["create", "update", "patch", "delete"] + - name: rbac-modification + description: Modify RBAC resources + pathways: [declarative] + resourceSelectors: + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] + verbs: ["*"] + - name: network-policy + description: Manage network policies + pathways: [declarative] + resourceSelectors: + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["*"] + - name: secret-management + description: Manage secrets + pathways: [declarative] + resourceSelectors: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create", "update", "patch", "delete"] + - name: emergency-access + description: Emergency break-glass access + pathways: [imperative] + resourceSelectors: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["delete"] + - name: accord-change + description: Changes to the accord document + pathways: [declarative] + resourceSelectors: + - paths: + - ".guildhouse/accord.yaml" + - ".guildhouse/policies/**" + - name: workspace-merge + description: Merge workspace changes + pathways: [declarative] + resourceSelectors: + - paths: + - "namespaces/**" + - name: reconciliation-merge + description: Automated reconciliation + pathways: [autonomous] + resourceSelectors: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] + ceremonies: + - classification: read-access + type: self_grant + requirements: + maxDuration: "8h" + scopeConstraints: + verbs: ["get", "list", "watch"] + - classification: workload-scaling + type: single_approval + requirements: + approverRoles: ["namespace-admin"] + maxDuration: "4h" + mutationBudget: 10 + requiresCapabilityProfile: true + - classification: workload-deployment + type: single_approval + requirements: + approverRoles: ["namespace-admin"] + maxDuration: "4h" + mutationBudget: 5 + requiresTwinValidation: true + - classification: rbac-modification + type: quorum_approval + requirements: + approverRoles: ["namespace-admin"] + quorum: 2 + maxDuration: "2h" + mutationBudget: 3 + - classification: network-policy + type: single_approval + requirements: + approverRoles: ["namespace-admin"] + maxDuration: "4h" + - classification: secret-management + type: quorum_approval + requirements: + approverRoles: ["namespace-admin"] + quorum: 2 + maxDuration: "2h" + mutationBudget: 3 + - classification: emergency-access + type: break_glass + requirements: + maxDuration: "30m" + mandatoryPostIncidentReview: true + externalEvidence: + type: jira_ticket + project: INCIDENT + status: ["Active", "In Progress"] + - classification: accord-change + type: quorum_approval + requirements: + quorum: 2 + requiresRegoTestsPass: true + requiresSchemaValidation: true + - classification: workspace-merge + type: single_approval + requirements: + approverRoles: ["namespace-admin"] + - classification: reconciliation-merge + type: autonomous + requirements: + controllerSvidMatch: "spiffe://guildhouse.local/ns/*/sa/reconciler" + ledger: + alwaysNotarize: + - ceremony_completion + - session_creation + - mutation_applied + logOnly: + - read_access + - session_heartbeat + sampled: + events: + - health_check + sampleRate: 100 + reconciliation: + defaultWindow: "24h" + onExpiry: alert + driftCheckInterval: "5m" + driftResponses: + - resourceSelector: + apiGroups: [""] + resources: ["secrets"] + action: alert + - resourceSelector: + apiGroups: ["apps"] + resources: ["deployments"] + action: auto_reconcile + controllers: + - svid: "spiffe://guildhouse.local/ns/argocd/sa/argocd-application-controller" + classification: workload-deployment + permittedMutations: + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets"] + verbs: ["create", "update", "patch"] + ledgerFidelity: full + roles: + - name: namespace-admin + members: + - identity: "spiffe://guildhouse.local/ns/capstone/sa/admin" + - identity: "oidc:tking@guildhouse.local" + namespaces: ["capstone", "quartermaster"] + - name: cluster-admin + members: + - identity: "oidc:tking@guildhouse.local" diff --git a/bascule-node-agent/Cargo.toml b/bascule-node-agent/Cargo.toml new file mode 100644 index 0000000..cc27c19 --- /dev/null +++ b/bascule-node-agent/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "bascule-node-agent" +version = "0.1.0" +edition = "2021" +description = "Bascule node agent — DaemonSet for per-node shell admission and webhook emission" + +[[bin]] +name = "bascule-node-agent" +path = "src/main.rs" + +[dependencies] +# Kubernetes +kube = { workspace = true } +k8s-openapi = { workspace = true } + +# HTTP (webhook emission) +reqwest = { workspace = true } + +# Async +tokio = { workspace = true } +tokio-util = "0.7" +futures = "0.3" + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# Observability +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Common +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } + +# TLS (required by kube) +rustls = { workspace = true } diff --git a/bascule-node-agent/src/admission.rs b/bascule-node-agent/src/admission.rs new file mode 100644 index 0000000..550586c --- /dev/null +++ b/bascule-node-agent/src/admission.rs @@ -0,0 +1,140 @@ +use tokio::net::UnixListener; +use tokio::sync::mpsc; + +use crate::events::NodeEvent; + +/// Listens on a Unix socket for CNI ADD notifications from substrate-cni. +/// +/// **substrate-cni does not exist yet.** This is a stub listener that +/// accepts connections, reads a JSON payload, and emits a `ShellBound` +/// event when valid data arrives. +/// +/// Expected CNI ADD payload (future): +/// ```json +/// {"pod_uid": "...", "namespace": "...", "cgroup_id": 12345, +/// "container_id": "...", "did": "did:substrate:..."} +/// ``` +pub struct AdmissionListener { + socket_path: String, + event_tx: mpsc::Sender, + node_name: String, +} + +impl AdmissionListener { + pub fn new( + socket_path: String, + event_tx: mpsc::Sender, + node_name: String, + ) -> Self { + Self { + socket_path, + event_tx, + node_name, + } + } + + /// Run the admission listener. Never returns under normal operation. + pub async fn run(self) -> anyhow::Result<()> { + // Clean up stale socket file + let _ = std::fs::remove_file(&self.socket_path); + + // Ensure parent directory exists + if let Some(parent) = std::path::Path::new(&self.socket_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + + let listener = UnixListener::bind(&self.socket_path)?; + + // Restrict socket permissions — only bascule-node-agent should connect. + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &self.socket_path, + std::fs::Permissions::from_mode(0o660), + )?; + + tracing::info!(path = %self.socket_path, "Admission listener started"); + + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let tx = self.event_tx.clone(); + let node = self.node_name.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, &tx, &node).await { + tracing::debug!(error = %e, "Admission connection error"); + } + }); + } + Err(e) => { + tracing::error!(error = %e, "Failed to accept admission connection"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + } + } +} + +/// Handle a single CNI ADD connection. +async fn handle_connection( + stream: tokio::net::UnixStream, + event_tx: &mpsc::Sender, + node_name: &str, +) -> anyhow::Result<()> { + use tokio::io::AsyncReadExt; + + let mut buf = vec![0u8; 4096]; + let mut stream = stream; + let n = stream.read(&mut buf).await?; + if n == 0 { + return Ok(()); + } + + let payload: serde_json::Value = serde_json::from_slice(&buf[..n])?; + + let pod_uid = payload + .get("pod_uid") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let namespace = payload + .get("namespace") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let cgroup_id = payload + .get("cgroup_id") + .and_then(|v| v.as_u64()) + .unwrap_or_default(); + let did = payload + .get("did") + .and_then(|v| v.as_str()) + .unwrap_or("did:substrate:unknown") + .to_string(); + + if pod_uid.is_empty() || namespace.is_empty() || cgroup_id == 0 { + tracing::warn!("Incomplete CNI ADD payload: {payload}"); + return Ok(()); + } + + let session_id = uuid::Uuid::new_v4().to_string(); + + tracing::info!( + pod_uid = %pod_uid, + namespace = %namespace, + cgroup_id, + "CNI admission received" + ); + + let event = NodeEvent::ShellBound { + session_id, + did, + cgroup_id, + namespace, + tenant: None, // Tenant resolved by the webhook handler + node: node_name.to_string(), + pod_uid, + }; + + event_tx.send(event).await?; + Ok(()) +} diff --git a/bascule-node-agent/src/bpf_manager.rs b/bascule-node-agent/src/bpf_manager.rs new file mode 100644 index 0000000..fd30500 --- /dev/null +++ b/bascule-node-agent/src/bpf_manager.rs @@ -0,0 +1,47 @@ +/// Shell BPF map manager. +/// +/// Programs the `shell_map` eBPF map when a pod is admitted with a +/// known `cgroup_id`. +/// +/// **STUB**: Real implementation requires `aya` and the shell BPF +/// programs from `substrate/shell/ebpf/`. The interface is defined +/// here; implementation is deferred until substrate-cni and the shell +/// BPF programs are available. +pub struct BpfManager; + +impl BpfManager { + pub fn new() -> Self { + BpfManager + } + + /// Program `shell_map` for a new pod. + /// + /// STUB: logs the cgroup_id and returns Ok. + /// Real implementation will write a `shell_state` entry to the + /// BPF hash map keyed by cgroup_id. + pub async fn admit_pod( + &self, + cgroup_id: u64, + namespace: &str, + tenant: Option<&str>, + ) -> anyhow::Result<()> { + tracing::info!( + cgroup_id, + namespace, + tenant, + "BpfManager::admit_pod (stub — shell_map programming not yet implemented)" + ); + Ok(()) + } + + /// Remove `shell_map` entry for a terminated pod. + /// + /// STUB: logs and returns Ok. + pub async fn terminate_pod(&self, cgroup_id: u64) -> anyhow::Result<()> { + tracing::info!( + cgroup_id, + "BpfManager::terminate_pod (stub — shell_map cleanup not yet implemented)" + ); + Ok(()) + } +} diff --git a/bascule-node-agent/src/config.rs b/bascule-node-agent/src/config.rs new file mode 100644 index 0000000..34e43d2 --- /dev/null +++ b/bascule-node-agent/src/config.rs @@ -0,0 +1,69 @@ +/// Node agent configuration, loaded from environment variables. +/// +/// Required: `NODE_NAME` (from Kubernetes Downward API). +/// All other values have sensible defaults for development. +#[derive(Debug, Clone)] +pub struct BasculeNodeConfig { + /// This node's name (from Downward API: `spec.nodeName`). + pub node_name: String, + + /// Dashboard webhook URL. + pub dashboard_webhook_url: String, + + /// Dashboard webhook shared secret (Bearer token). + pub dashboard_webhook_secret: String, + + /// Unix socket path for CNI admission notifications. + pub admission_socket: String, + + /// Namespace label used for tenant resolution. + pub tenant_label: String, + + /// Max events per webhook POST batch. + pub webhook_batch_size: usize, + + /// Seconds between webhook flushes. + pub webhook_flush_interval_secs: u64, +} + +impl BasculeNodeConfig { + pub fn from_env() -> anyhow::Result { + let node_name = std::env::var("NODE_NAME") + .map_err(|_| anyhow::anyhow!("NODE_NAME environment variable is required (set via Kubernetes Downward API)"))?; + + Ok(Self { + node_name, + dashboard_webhook_url: std::env::var("BASCULE_DASHBOARD_WEBHOOK_URL") + .unwrap_or_else(|_| "http://guildhouse-dashboard:8000/api/v1/governance/bascule/events/".into()), + dashboard_webhook_secret: std::env::var("BASCULE_DASHBOARD_WEBHOOK_SECRET") + .unwrap_or_default(), + admission_socket: std::env::var("BASCULE_ADMISSION_SOCKET") + .unwrap_or_else(|_| "/run/bascule/admission.sock".into()), + tenant_label: std::env::var("BASCULE_TENANT_LABEL") + .unwrap_or_else(|_| "guildhouse.dev/tenant".into()), + webhook_batch_size: std::env::var("BASCULE_WEBHOOK_BATCH_SIZE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10), + webhook_flush_interval_secs: std::env::var("BASCULE_WEBHOOK_FLUSH_INTERVAL_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5), + }) + } + + pub fn validate(&self) -> anyhow::Result<()> { + if self.dashboard_webhook_secret.is_empty() { + anyhow::bail!( + "BASCULE_DASHBOARD_WEBHOOK_SECRET must not be empty" + ); + } + if self.dashboard_webhook_secret == "dev-secret-change-in-production" { + tracing::warn!( + "Using default dev webhook secret. \ + Set BASCULE_DASHBOARD_WEBHOOK_SECRET in production." + ); + } + Ok(()) + } +} diff --git a/bascule-node-agent/src/events.rs b/bascule-node-agent/src/events.rs new file mode 100644 index 0000000..5cc2fd2 --- /dev/null +++ b/bascule-node-agent/src/events.rs @@ -0,0 +1,99 @@ +/// Node-level events emitted by the pod watcher and admission listener. +/// +/// Each variant maps to an AuditBridge wire-format event that the dashboard +/// webhook expects. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "type")] +pub enum NodeEvent { + PodAdmitted { + pod_uid: String, + pod_name: String, + namespace: String, + tenant: Option, + node: String, + cgroup_id: Option, + }, + PodTerminated { + pod_uid: String, + namespace: String, + tenant: Option, + node: String, + }, + ShellBound { + session_id: String, + did: String, + cgroup_id: u64, + namespace: String, + tenant: Option, + node: String, + pod_uid: String, + }, +} + +impl NodeEvent { + /// The AuditBridge event_kind string for the dashboard webhook. + pub fn event_kind(&self) -> &str { + match self { + NodeEvent::PodAdmitted { .. } => "PodAdmitted", + NodeEvent::PodTerminated { .. } => "SessionTerminated", + NodeEvent::ShellBound { .. } => "SessionEstablished", + } + } + + /// Convert to the pipe-delimited AuditBridge payload format + /// that the dashboard webhook parser expects. + pub fn to_payload(&self) -> String { + match self { + NodeEvent::PodAdmitted { + pod_uid, + pod_name, + namespace, + tenant, + node, + cgroup_id, + } => { + let mut s = format!( + "SessionEstablished|session={pod_uid} did=pod:{namespace}/{pod_name} node={node}" + ); + if let Some(t) = tenant { + s.push_str(&format!(" tenant={t}")); + } + if let Some(cg) = cgroup_id { + s.push_str(&format!(" cgroup_id={cg}")); + } + s + } + NodeEvent::PodTerminated { + pod_uid, + namespace, + tenant, + node, + } => { + let mut s = format!( + "SessionTerminated|session={pod_uid} reason=pod_deleted namespace={namespace} node={node}" + ); + if let Some(t) = tenant { + s.push_str(&format!(" tenant={t}")); + } + s + } + NodeEvent::ShellBound { + session_id, + did, + cgroup_id, + namespace, + tenant, + node, + pod_uid, + } => { + let mut s = format!( + "SessionEstablished|session={session_id} did={did} cgroup_id={cgroup_id} namespace={namespace} node={node} pod_uid={pod_uid}" + ); + if let Some(t) = tenant { + s.push_str(&format!(" tenant={t}")); + } + s + } + } + } +} diff --git a/bascule-node-agent/src/main.rs b/bascule-node-agent/src/main.rs new file mode 100644 index 0000000..19e6f7f --- /dev/null +++ b/bascule-node-agent/src/main.rs @@ -0,0 +1,99 @@ +//! Bascule node agent — per-node DaemonSet for shell admission and webhook emission. +//! +//! Responsibilities: +//! - Watch pods on this node (via Kubernetes API) +//! - Accept CNI ADD notifications (via Unix socket) +//! - Emit admission events to the dashboard webhook +//! - Program shell BPF maps (stub — deferred to substrate-cni) +//! +//! This is NOT bascule-gateway. No gRPC server, no ceremony engine, +//! no PostgreSQL, no session manager. + +mod admission; +mod bpf_manager; +mod config; +mod events; +mod pod_watcher; +mod tenant_resolver; +mod webhook_emitter; + +use std::sync::Arc; + +use crate::admission::AdmissionListener; +use crate::config::BasculeNodeConfig; +use crate::pod_watcher::PodWatcher; +use crate::tenant_resolver::TenantResolver; +use crate::webhook_emitter::WebhookEmitter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // rustls provider must be installed before kube::Client. + rustls::crypto::ring::default_provider() + .install_default() + .ok(); + + // JSON tracing output for Kubernetes log aggregation. + tracing_subscriber::fmt() + .json() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cfg = BasculeNodeConfig::from_env()?; + cfg.validate()?; + tracing::info!(node = %cfg.node_name, "bascule-node-agent starting"); + + // Kubernetes client (in-cluster or kubeconfig fallback). + let kube_client = kube::Client::try_default().await?; + tracing::info!("Kubernetes client connected"); + + // Event channel: pod_watcher + admission_listener → webhook_emitter + let (tx, rx) = tokio::sync::mpsc::channel::(256); + + // Shared tenant resolver + let tenant_resolver = Arc::new(TenantResolver::new( + kube_client.clone(), + cfg.tenant_label.clone(), + )); + + // Pod watcher (Kubernetes API) + let pod_watcher = PodWatcher::new( + kube_client.clone(), + cfg.node_name.clone(), + tx.clone(), + tenant_resolver, + ); + + // CNI admission listener (Unix socket, stub until substrate-cni exists) + let admission_listener = AdmissionListener::new( + cfg.admission_socket.clone(), + tx, + cfg.node_name.clone(), + ); + + // Webhook emitter (HTTP POST to dashboard) + let webhook_emitter = WebhookEmitter::new(&cfg, rx); + + tracing::info!( + webhook_url = %cfg.dashboard_webhook_url, + admission_socket = %cfg.admission_socket, + "All components initialized, starting event loop" + ); + + // Run all tasks. If any exits, the agent restarts (crash-loop). + tokio::select! { + res = pod_watcher.run() => { + tracing::error!(?res, "Pod watcher exited"); + } + res = admission_listener.run() => { + tracing::error!(?res, "Admission listener exited"); + } + res = webhook_emitter.run() => { + tracing::error!(?res, "Webhook emitter exited"); + } + } + + anyhow::bail!("A core task exited — agent should be restarted by Kubernetes") +} diff --git a/bascule-node-agent/src/pod_watcher.rs b/bascule-node-agent/src/pod_watcher.rs new file mode 100644 index 0000000..4978129 --- /dev/null +++ b/bascule-node-agent/src/pod_watcher.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; + +use kube::runtime::watcher; +use kube::runtime::WatchStreamExt; +use kube::Api; +use tokio::sync::mpsc; + +use crate::events::NodeEvent; +use crate::tenant_resolver::TenantResolver; + +/// Watches pods on this node and emits admission/termination events. +/// +/// Uses a Kubernetes field selector (`spec.nodeName`) to limit the watch +/// to pods scheduled on the current node. +pub struct PodWatcher { + client: kube::Client, + node_name: String, + event_tx: mpsc::Sender, + tenant_resolver: Arc, +} + +impl PodWatcher { + pub fn new( + client: kube::Client, + node_name: String, + event_tx: mpsc::Sender, + tenant_resolver: Arc, + ) -> Self { + Self { + client, + node_name, + event_tx, + tenant_resolver, + } + } + + /// Run the pod watcher loop. Reconnects on error (never returns Ok). + pub async fn run(self) -> anyhow::Result<()> { + use futures::StreamExt; + + let pods: Api = Api::all(self.client.clone()); + + let watcher_config = watcher::Config::default() + .fields(&format!("spec.nodeName={}", self.node_name)); + + tracing::info!(node = %self.node_name, "Starting pod watcher"); + + // watcher automatically reconnects on 410 Gone. + let mut stream = + std::pin::pin!(watcher::watcher(pods, watcher_config).applied_objects()); + + // We track known UIDs to distinguish ADDED vs MODIFIED + let mut known_uids: std::collections::HashSet = std::collections::HashSet::new(); + + loop { + match stream.next().await { + Some(Ok(pod)) => { + self.handle_pod_event(&pod, &mut known_uids).await; + } + Some(Err(e)) => { + tracing::error!(error = %e, "Pod watcher error, will reconnect"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + break; + } + None => { + tracing::warn!("Pod watcher stream ended, restarting"); + break; + } + } + } + + // Return error to trigger restart in main select! + anyhow::bail!("Pod watcher stream ended") + } + + async fn handle_pod_event( + &self, + pod: &k8s_openapi::api::core::v1::Pod, + known_uids: &mut std::collections::HashSet, + ) { + let meta = &pod.metadata; + let uid = match meta.uid.as_deref() { + Some(u) => u.to_string(), + None => return, + }; + let name = meta.name.clone().unwrap_or_default(); + let namespace = meta.namespace.clone().unwrap_or_else(|| "default".into()); + + // Check if pod is being deleted + if meta.deletion_timestamp.is_some() { + if known_uids.remove(&uid) { + let tenant = self.tenant_resolver.resolve(&namespace).await; + let event = NodeEvent::PodTerminated { + pod_uid: uid, + namespace, + tenant, + node: self.node_name.clone(), + }; + if let Err(e) = self.event_tx.send(event).await { + tracing::error!(error = %e, "Failed to send PodTerminated event"); + } + } + return; + } + + // New pod — only emit PodAdmitted when phase is Running + let phase = pod + .status + .as_ref() + .and_then(|s| s.phase.as_deref()) + .unwrap_or("Unknown"); + + if known_uids.insert(uid.clone()) && phase == "Running" { + let tenant = self.tenant_resolver.resolve(&namespace).await; + let event = NodeEvent::PodAdmitted { + pod_uid: uid, + pod_name: name, + namespace, + tenant, + node: self.node_name.clone(), + cgroup_id: None, // Populated later by CNI admission + }; + if let Err(e) = self.event_tx.send(event).await { + tracing::error!(error = %e, "Failed to send PodAdmitted event"); + } + } + } +} diff --git a/bascule-node-agent/src/tenant_resolver.rs b/bascule-node-agent/src/tenant_resolver.rs new file mode 100644 index 0000000..214e3d2 --- /dev/null +++ b/bascule-node-agent/src/tenant_resolver.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::RwLock; + +/// Resolves Kubernetes namespace → tenant slug via namespace labels. +/// +/// Reads the label specified by `tenant_label` (default: `guildhouse.dev/tenant`) +/// from the Namespace object. Results are cached to avoid repeated API calls. +pub struct TenantResolver { + client: kube::Client, + tenant_label: String, + cache: Arc>>>, +} + +impl TenantResolver { + pub fn new(client: kube::Client, tenant_label: String) -> Self { + Self { + client, + tenant_label, + cache: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Resolve the tenant slug for a namespace. + /// + /// Returns `Some(slug)` if the namespace has the tenant label, + /// `None` otherwise. Results (including `None`) are cached. + pub async fn resolve(&self, namespace: &str) -> Option { + // 1. Check cache + { + let cache = self.cache.read().await; + if let Some(cached) = cache.get(namespace) { + return cached.clone(); + } + } + + // 2. Fetch namespace from K8s API + let result = self.fetch_tenant_label(namespace).await; + + // 3. Cache (even None, to avoid repeated lookups for unlabelled namespaces) + { + let mut cache = self.cache.write().await; + cache.insert(namespace.to_string(), result.clone()); + } + + result + } + + async fn fetch_tenant_label(&self, namespace: &str) -> Option { + use kube::Api; + + let ns_api: Api = + Api::all(self.client.clone()); + + match ns_api.get(namespace).await { + Ok(ns) => ns + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(&self.tenant_label).cloned()), + Err(e) => { + tracing::warn!(namespace, error = %e, "Failed to fetch namespace for tenant resolution"); + None + } + } + } +} diff --git a/bascule-node-agent/src/webhook_emitter.rs b/bascule-node-agent/src/webhook_emitter.rs new file mode 100644 index 0000000..4b19432 --- /dev/null +++ b/bascule-node-agent/src/webhook_emitter.rs @@ -0,0 +1,116 @@ +use std::time::Duration; + +use tokio::sync::mpsc; + +use crate::config::BasculeNodeConfig; +use crate::events::NodeEvent; + +/// Batches node events and POSTs them to the dashboard webhook. +/// +/// Collects events until `batch_size` or `flush_interval`, whichever comes +/// first. Never crashes on HTTP errors — logs and continues. +pub struct WebhookEmitter { + client: reqwest::Client, + webhook_url: String, + webhook_secret: String, + batch_size: usize, + flush_interval: Duration, + rx: mpsc::Receiver, +} + +impl WebhookEmitter { + pub fn new(config: &BasculeNodeConfig, rx: mpsc::Receiver) -> Self { + Self { + client: reqwest::Client::new(), + webhook_url: config.dashboard_webhook_url.clone(), + webhook_secret: config.dashboard_webhook_secret.clone(), + batch_size: config.webhook_batch_size, + flush_interval: Duration::from_secs(config.webhook_flush_interval_secs), + rx, + } + } + + /// Main event loop. Runs until the channel is closed. + pub async fn run(mut self) -> anyhow::Result<()> { + tracing::info!( + url = %self.webhook_url, + batch_size = self.batch_size, + flush_interval_secs = self.flush_interval.as_secs(), + "Webhook emitter started" + ); + + let mut batch: Vec = Vec::with_capacity(self.batch_size); + let mut flush_timer = tokio::time::interval(self.flush_interval); + // First tick completes immediately — skip it. + flush_timer.tick().await; + + loop { + tokio::select! { + event = self.rx.recv() => { + match event { + Some(evt) => { + batch.push(evt); + if batch.len() >= self.batch_size { + self.post_batch(&mut batch).await; + } + } + None => { + // Channel closed — flush remaining and exit. + if !batch.is_empty() { + self.post_batch(&mut batch).await; + } + tracing::info!("Event channel closed, webhook emitter shutting down"); + return Ok(()); + } + } + } + _ = flush_timer.tick() => { + if !batch.is_empty() { + self.post_batch(&mut batch).await; + } + } + } + } + } + + async fn post_batch(&self, batch: &mut Vec) { + let events: Vec = batch + .drain(..) + .map(|evt| { + serde_json::json!({ + "event_kind": evt.event_kind(), + "payload": evt.to_payload(), + }) + }) + .collect(); + + let count = events.len(); + let body = serde_json::json!({ "events": events }); + + match self + .client + .post(&self.webhook_url) + .header("Content-Type", "application/json") + .header( + "Authorization", + format!("Bearer {}", self.webhook_secret), + ) + .json(&body) + .send() + .await + { + Ok(resp) => { + let status = resp.status(); + if status.is_success() || status.as_u16() == 202 { + tracing::debug!(count, status = %status, "Webhook batch posted"); + } else { + let text = resp.text().await.unwrap_or_default(); + tracing::warn!(count, status = %status, body = %text, "Webhook batch rejected"); + } + } + Err(e) => { + tracing::error!(count, error = %e, "Webhook POST failed (will retry on next flush)"); + } + } + } +} diff --git a/bascule-proto/Cargo.toml b/bascule-proto/Cargo.toml new file mode 100644 index 0000000..790c883 --- /dev/null +++ b/bascule-proto/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bascule-proto" +version = "0.1.0" +edition = "2021" +description = "Generated gRPC stubs for Bascule protocol services" + +[dependencies] +tonic = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } + +[build-dependencies] +tonic-build = "0.12" diff --git a/bascule-proto/build.rs b/bascule-proto/build.rs new file mode 100644 index 0000000..6df49b6 --- /dev/null +++ b/bascule-proto/build.rs @@ -0,0 +1,21 @@ +fn main() -> Result<(), Box> { + // Proto files live at workspace root: ../proto/ + let proto_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("proto"); + + tonic_build::configure() + .build_server(true) + .build_client(true) + .compile_protos( + &[ + proto_root.join("bascule/v1/gateway.proto"), + proto_root.join("bascule/v1/session.proto"), + proto_root.join("bascule/v1/command.proto"), + proto_root.join("bascule/v1/ceremony.proto"), + ], + &[&proto_root], + )?; + Ok(()) +} diff --git a/bascule-proto/src/lib.rs b/bascule-proto/src/lib.rs new file mode 100644 index 0000000..5fcdc98 --- /dev/null +++ b/bascule-proto/src/lib.rs @@ -0,0 +1,8 @@ +pub mod bascule { + pub mod v1 { + tonic::include_proto!("bascule.v1"); + } +} + +/// Re-export for convenient access. +pub use bascule::v1 as bascule_v1; diff --git a/bascule-shell/Cargo.toml b/bascule-shell/Cargo.toml new file mode 100644 index 0000000..7fbf2c1 --- /dev/null +++ b/bascule-shell/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "bascule-shell" +version = "0.1.0" +edition = "2021" +description = "Bascule governance shell — CLI for governed cluster access" + +[lib] +name = "bascule_shell" +path = "src/lib.rs" + +[[bin]] +name = "bascule" +path = "src/main.rs" + +[dependencies] +bascule-core = { workspace = true } +bascule-proto = { workspace = true } + +# Cross-workspace path deps — Guildhouse services. +# Future: extract to standalone crates. +registry-protocol = { path = "../../guildhouse/services/registry-protocol" } +# workspace::v1 proto for attach command (workspace-controller gRPC) +guildhouse-proto = { path = "../../guildhouse/services/guildhouse-proto" } + +# CLI +clap = { workspace = true } + +# Command module discovery +which = { workspace = true } + +# gRPC +tonic = { workspace = true } + +# Async +tokio = { workspace = true } +async-trait = { workspace = true } + +# HTTP (for OIDC token exchange) +reqwest = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } + +# Observability +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# SSH +russh = { workspace = true } +russh-keys = { workspace = true } +ssh-key = { workspace = true } +rand = { workspace = true } + +# Common +chrono = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +rustls = { workspace = true } +dirs = { workspace = true } diff --git a/bascule-shell/src/attach.rs b/bascule-shell/src/attach.rs new file mode 100644 index 0000000..e605d2c --- /dev/null +++ b/bascule-shell/src/attach.rs @@ -0,0 +1,198 @@ +//! `bascule attach workspace/{name}` — attach to a governed filesystem workspace. +//! +//! Resolves the workspace DID from the DID registry, calls workspace-controller +//! to create a SessionWorkspace (git worktree), then exec's a shell with the +//! worktree path as cwd. + +use guildhouse_proto::workspace::v1::{ + workspace_controller_client::WorkspaceControllerClient, CreateWorkspaceRequest, +}; +use uuid::Uuid; + +/// Configuration for the attach command. +pub struct AttachContext { + pub workspace_name: String, + pub domain: String, + pub registry_url: String, + pub workspace_controller_addr: String, + pub caller_name: String, + pub caller_email: String, +} + +impl AttachContext { + /// Build from environment variables with sensible defaults. + pub fn from_env(workspace_name: String) -> Self { + Self { + workspace_name, + domain: std::env::var("BASCULE_DID_DOMAIN") + .unwrap_or_else(|_| "guildhouse.local".into()), + registry_url: std::env::var("DID_REGISTRY_URL") + .unwrap_or_else(|_| "http://did-registry.guildhouse-system.svc:3000".into()), + workspace_controller_addr: std::env::var("WORKSPACE_CONTROLLER_ADDR") + .unwrap_or_else(|_| "http://localhost:50057".into()), + caller_name: std::env::var("BASCULE_AUTHOR_NAME").unwrap_or_else(|_| { + std::process::Command::new("git") + .args(["config", "user.name"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "bascule-user".into()) + }), + caller_email: std::env::var("BASCULE_AUTHOR_EMAIL").unwrap_or_else(|_| { + std::process::Command::new("git") + .args(["config", "user.email"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "bascule@guildhouse.local".into()) + }), + } + } +} + +pub async fn run_attach(ctx: AttachContext) -> anyhow::Result<()> { + // Step 1: Resolve workspace DID from did-registry + let did_uri = format!("did:web:{}:workspace:{}", ctx.domain, ctx.workspace_name); + println!("Resolving workspace DID: {did_uri}"); + + let did_url = format!( + "{}/did/web/{}/workspace/{}", + ctx.registry_url, ctx.domain, ctx.workspace_name, + ); + + let resp = reqwest::get(&did_url).await.map_err(|e| { + anyhow::anyhow!( + "DID registry unreachable at {}: {}. Is did-registry running?", + ctx.registry_url, + e + ) + })?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + anyhow::bail!( + "Workspace '{}' not found in DID registry. Has it been registered?", + ctx.workspace_name + ); + } + + let doc: serde_json::Value = resp.json().await.map_err(|e| { + anyhow::anyhow!("Failed to parse DID document: {e}") + })?; + + // Step 2: Determine read_only from verification methods. + // Phase A: any DID document with verification methods = read-write. + // Missing/empty verification methods = read-only (safe default). + let read_only = determine_read_only(&doc); + + // Step 3: Log file targets (enforcement pending Q5 kernel work). + log_file_targets(&doc); + + // Step 4: Call workspace-controller gRPC to create SessionWorkspace + let session_id = Uuid::new_v4().to_string(); + + let channel = tonic::transport::Channel::from_shared(ctx.workspace_controller_addr.clone())? + .connect() + .await + .map_err(|e| { + anyhow::anyhow!( + "Could not connect to workspace-controller at {}: {}. Is it running?", + ctx.workspace_controller_addr, + e + ) + })?; + + let mut client = WorkspaceControllerClient::new(channel); + + let resp = client + .create_workspace(tonic::Request::new(CreateWorkspaceRequest { + session_id: session_id.clone(), + base_branch: "main".to_string(), + author_name: ctx.caller_name.clone(), + author_email: ctx.caller_email.clone(), + read_only, + })) + .await + .map_err(|e| { + anyhow::anyhow!( + "Could not create workspace session: {e}. Is workspace-controller running?" + ) + })? + .into_inner(); + + let workspace_path = resp.workspace_path; + let branch_name = resp.branch_name; + let base_commit = resp.base_commit; + + // Step 5: Print summary + println!(); + println!("Workspace attached:"); + println!(" Path: {workspace_path}"); + println!( + " Branch: {}", + if branch_name.is_empty() { + "(read-only)".to_string() + } else { + branch_name + } + ); + println!( + " Mode: {}", + if read_only { "read-only" } else { "read-write" } + ); + println!( + " Base: {}", + &base_commit[..8.min(base_commit.len())] + ); + println!(); + + // Step 6: Exec $SHELL in worktree (replaces current process) + use std::os::unix::process::CommandExt; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into()); + + let err = std::process::Command::new(&shell) + .current_dir(&workspace_path) + .env("BASCULE_WORKSPACE", &workspace_path) + .env("BASCULE_SESSION_ID", &session_id) + .env("PS1", format!("bascule:{}$ ", ctx.workspace_name)) + .exec(); + + // exec() only returns on error + anyhow::bail!("Failed to exec {}: {}", shell, err) +} + +/// Phase A: any DID document with at least one verification method = read-write. +/// No verification methods or missing field = read-only (safe default). +fn determine_read_only(doc: &serde_json::Value) -> bool { + doc.get("verificationMethod") + .and_then(|v| v.as_array()) + .map(|vms| vms.is_empty()) + .unwrap_or(true) +} + +/// Log file: targets from verification methods at debug level. +/// Enforcement is not yet implemented (SPEC-DID-0001 §12 Q5). +fn log_file_targets(doc: &serde_json::Value) { + let vms = doc.get("verificationMethod").and_then(|v| v.as_array()); + + if let Some(vms) = vms { + for vm in vms { + if let Some(targets) = vm.get("substrate:targets").and_then(|t| t.as_array()) { + let file_targets: Vec<&str> = targets + .iter() + .filter_map(|t| t.as_str()) + .filter(|t| t.starts_with("file:")) + .collect(); + + if !file_targets.is_empty() { + tracing::debug!( + targets = ?file_targets, + "Workspace file targets (enforcement pending Q5)" + ); + } + } + } + } +} diff --git a/bascule-shell/src/auth.rs b/bascule-shell/src/auth.rs new file mode 100644 index 0000000..953ce38 --- /dev/null +++ b/bascule-shell/src/auth.rs @@ -0,0 +1,155 @@ +use std::io::{Read, Write}; +use std::net::TcpListener; + +use clap::Args; +use serde::Deserialize; + +use crate::config::TokenStore; + +#[derive(Args)] +pub struct ConnectArgs { + /// OIDC issuer URL (e.g., http://localhost:8080/realms/demo) + #[arg(long)] + pub issuer: String, + + /// OIDC client ID + #[arg(long, default_value = "bascule")] + pub client_id: String, + + /// Skip OIDC flow and use this token directly (development only) + #[arg(long)] + pub token: Option, +} + +pub async fn connect(args: ConnectArgs, gateway: &str) -> anyhow::Result<()> { + if let Some(token) = args.token { + let store = TokenStore { + gateway_endpoint: gateway.to_string(), + id_token: token.clone(), + access_token: token, + refresh_token: None, + expires_at: None, + session_id: None, + }; + store.save()?; + println!("Token saved. Connected to {gateway}"); + return Ok(()); + } + + // OIDC Authorization Code flow + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let redirect_uri = format!("http://localhost:{port}/callback"); + + let auth_url = format!( + "{}/protocol/openid-connect/auth?client_id={}&redirect_uri={}&response_type=code&scope=openid+email+profile", + args.issuer.trim_end_matches('/'), + args.client_id, + encode_uri_component(&redirect_uri), + ); + + println!("Open this URL in your browser to authenticate:\n"); + println!(" {auth_url}\n"); + println!("Waiting for callback on port {port}..."); + + // Accept the OAuth callback + let (mut stream, _) = listener.accept()?; + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf)?; + let request = String::from_utf8_lossy(&buf[..n]); + + // Extract the authorization code from GET /callback?code=...&... + let code = extract_code_from_request(&request) + .ok_or_else(|| anyhow::anyhow!("no authorization code in callback"))?; + + // Send a success page back to the browser + let html = "

Authentication successful!

You can close this tab.

"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}", + html.len(), + html + ); + stream.write_all(response.as_bytes())?; + drop(stream); + + // Exchange the authorization code for tokens + let token_url = format!( + "{}/protocol/openid-connect/token", + args.issuer.trim_end_matches('/') + ); + + let client = reqwest::Client::new(); + let resp = client + .post(&token_url) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", args.client_id.as_str()), + ("code", code.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await?; + anyhow::bail!("Token exchange failed: {body}"); + } + + let token_resp: TokenResponse = resp.json().await?; + + let store = TokenStore { + gateway_endpoint: gateway.to_string(), + id_token: token_resp.id_token.unwrap_or_default(), + access_token: token_resp.access_token, + refresh_token: token_resp.refresh_token, + expires_at: token_resp + .expires_in + .map(|secs| chrono::Utc::now() + chrono::Duration::seconds(secs)), + session_id: None, + }; + store.save()?; + + println!("Authenticated successfully. Connected to {gateway}"); + Ok(()) +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + id_token: Option, + refresh_token: Option, + expires_in: Option, +} + +/// Extract the `code` query parameter from an HTTP request line. +fn extract_code_from_request(request: &str) -> Option { + let first_line = request.lines().next()?; + let path = first_line.split_whitespace().nth(1)?; + let query_start = path.find('?')? + 1; + let query = &path[query_start..]; + + for param in query.split('&') { + if let Some(value) = param.strip_prefix("code=") { + return Some(value.to_string()); + } + } + None +} + +/// Minimal percent-encoding for URI components. +fn encode_uri_component(s: &str) -> String { + let mut encoded = String::with_capacity(s.len() * 2); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'~' => encoded.push(byte as char), + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} diff --git a/bascule-shell/src/commands.rs b/bascule-shell/src/commands.rs new file mode 100644 index 0000000..67ec44e --- /dev/null +++ b/bascule-shell/src/commands.rs @@ -0,0 +1,96 @@ +use clap::Args; + +use crate::config::TokenStore; +use crate::output; + +#[derive(Args)] +pub struct ExecArgs { + /// Command verb (get, describe, logs, status, etc.) + pub verb: String, + + /// Resource type (pods, deployments, services, etc.) + pub resource_type: Option, + + /// Resource name + pub resource_name: Option, + + /// Namespace + #[arg(short, long)] + pub namespace: Option, +} + +pub async fn exec(args: ExecArgs, gateway: &str, output_format: &str) -> anyhow::Result<()> { + let store = TokenStore::load()?; + let session_id = store + .session_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No active session. Run `bascule session request` first."))?; + + let mut client = crate::session::connect_gateway(gateway).await?; + + let request = crate::session::authenticated_request( + bascule_proto::bascule_v1::ExecuteCommandRequest { + session_id: session_id.clone(), + verb: args.verb, + namespace: args.namespace, + resource_type: args.resource_type, + resource_name: args.resource_name, + parameters: None, + output_format: output_format.to_string(), + }, + &store.access_token, + ); + + let response = client.execute_command(request).await?; + output::print_command_response(&response.into_inner(), output_format); + + Ok(()) +} + +/// Parse a REPL input line into command components. +pub struct ParsedCommand { + pub verb: String, + pub namespace: Option, + pub resource_type: Option, + pub resource_name: Option, +} + +pub fn parse_line(input: &str) -> Option { + let parts: Vec<&str> = input.split_whitespace().collect(); + if parts.is_empty() { + return None; + } + + let verb = parts[0].to_string(); + let mut namespace = None; + let mut resource_type = None; + let mut resource_name = None; + let mut i = 1; + + while i < parts.len() { + match parts[i] { + "-n" | "--namespace" => { + if i + 1 < parts.len() { + namespace = Some(parts[i + 1].to_string()); + i += 2; + continue; + } + } + arg => { + if resource_type.is_none() { + resource_type = Some(arg.to_string()); + } else if resource_name.is_none() { + resource_name = Some(arg.to_string()); + } + } + } + i += 1; + } + + Some(ParsedCommand { + verb, + namespace, + resource_type, + resource_name, + }) +} diff --git a/bascule-shell/src/config.rs b/bascule-shell/src/config.rs new file mode 100644 index 0000000..9fe779c --- /dev/null +++ b/bascule-shell/src/config.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Stored OIDC tokens and gateway connection info. +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenStore { + pub gateway_endpoint: String, + pub id_token: String, + pub access_token: String, + pub refresh_token: Option, + pub expires_at: Option>, + /// Active session ID (set after RequestSession succeeds). + pub session_id: Option, +} + +impl TokenStore { + pub fn config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("bascule") + } + + pub fn token_path() -> PathBuf { + Self::config_dir().join("tokens.json") + } + + pub fn save(&self) -> anyhow::Result<()> { + let dir = Self::config_dir(); + std::fs::create_dir_all(&dir)?; + let path = Self::token_path(); + let json = serde_json::to_string_pretty(self)?; + std::fs::write(&path, &json)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) + } + + pub fn load() -> anyhow::Result { + let path = Self::token_path(); + let json = std::fs::read_to_string(&path) + .map_err(|_| anyhow::anyhow!("Not connected. Run `bascule connect` first."))?; + Ok(serde_json::from_str(&json)?) + } +} diff --git a/bascule-shell/src/governed/command_trait.rs b/bascule-shell/src/governed/command_trait.rs new file mode 100644 index 0000000..28f8cb0 --- /dev/null +++ b/bascule-shell/src/governed/command_trait.rs @@ -0,0 +1,234 @@ +//! Shell command trait and supporting types. +//! +//! Every shell command implements [`ShellCommand`]. Commands are loaded from the +//! container image at startup and dispatched by the command registry. + +use std::fmt; + +use async_trait::async_trait; + +use super::session::GovernedSession; + +/// Every shell command implements this trait. +#[async_trait] +pub trait ShellCommand: Send + Sync { + /// Primary command name (e.g., "query", "void", "deploy"). + fn name(&self) -> &str; + + /// Alternative names (e.g., ["q"] for "query"). + fn aliases(&self) -> Vec<&str> { + vec![] + } + + /// What access tier this command belongs to. + fn tier(&self) -> CommandTier; + + /// What SAT scope is required to execute this command. + fn required_scope(&self) -> RequiredScope; + + /// Human-readable description for help output. + fn description(&self) -> &str; + + /// Usage string (e.g., "query "). + fn usage(&self) -> &str; + + /// Execute the command with the given arguments and session context. + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result; + + /// Tab completion hints for arguments. + fn completions(&self, _args: &[String], _session: &GovernedSession) -> Vec { + vec![] + } +} + +/// Which image tier this command belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CommandTier { + /// Read-only operations — always available if the command is in the image. + Analyst, + /// Application-level mutations — require elevation. + Administrator, + /// Infrastructure-level operations — require elevation + stronger ceremony. + Engineer, +} + +impl fmt::Display for CommandTier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CommandTier::Analyst => write!(f, "analyst"), + CommandTier::Administrator => write!(f, "administrator"), + CommandTier::Engineer => write!(f, "engineer"), + } + } +} + +impl CommandTier { + pub fn from_str(s: &str) -> Option { + match s { + "analyst" => Some(CommandTier::Analyst), + "administrator" => Some(CommandTier::Administrator), + "engineer" => Some(CommandTier::Engineer), + _ => None, + } + } +} + +/// What SAT scope is required to execute a command. +#[derive(Debug, Clone)] +pub enum RequiredScope { + /// Any valid (non-expired) session SAT. + ReadOnly, + /// Requires elevated SAT with specific registry+verb scope. + Elevated { registry: String, verb: String }, +} + +/// Result of checking scope authorization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScopeCheck { + Authorized, + /// Base SAT expired (session should end). + Expired, + /// Elevation required for this operation. + ElevationRequired { registry: String, verb: String }, + /// Was elevated but TTL ran out. + ElevationExpired, +} + +/// Output from command execution. +#[derive(Debug)] +pub struct CommandOutput { + pub lines: Vec, +} + +impl CommandOutput { + pub fn text(s: impl Into) -> Self { + Self { + lines: vec![OutputLine::Text(s.into())], + } + } + + pub fn status(label: impl Into, value: impl Into, color: OutputColor) -> Self { + Self { + lines: vec![OutputLine::Status { + label: label.into(), + value: value.into(), + color, + }], + } + } + + pub fn empty() -> Self { + Self { lines: vec![] } + } +} + +/// A single line of structured output. +#[derive(Debug)] +pub enum OutputLine { + Text(String), + Table { + headers: Vec, + rows: Vec>, + }, + Status { + label: String, + value: String, + color: OutputColor, + }, + Separator, + Blank, +} + +/// ANSI color hint for terminal output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputColor { + Default, + Green, + Yellow, + Red, + Cyan, +} + +/// Errors from command execution. +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error("Unknown command: {0}")] + Unknown(String), + #[error("Insufficient scope: {required} required, current scope is read-only")] + InsufficientScope { required: String }, + #[error("Elevation required: {registry}:{verb}")] + ElevationRequired { registry: String, verb: String }, + #[error("No tenant selected. Use: use ")] + NoTenantContext, + #[error("Invalid arguments: {0}")] + InvalidArgs(String), + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + #[error("Governance error: {0}")] + GovernanceError(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_tier_display() { + assert_eq!(CommandTier::Analyst.to_string(), "analyst"); + assert_eq!(CommandTier::Administrator.to_string(), "administrator"); + assert_eq!(CommandTier::Engineer.to_string(), "engineer"); + } + + #[test] + fn command_tier_from_str() { + assert_eq!(CommandTier::from_str("analyst"), Some(CommandTier::Analyst)); + assert_eq!( + CommandTier::from_str("administrator"), + Some(CommandTier::Administrator) + ); + assert_eq!( + CommandTier::from_str("engineer"), + Some(CommandTier::Engineer) + ); + assert_eq!(CommandTier::from_str("unknown"), None); + } + + #[test] + fn command_tier_ordering() { + assert!(CommandTier::Analyst < CommandTier::Administrator); + assert!(CommandTier::Administrator < CommandTier::Engineer); + } + + #[test] + fn command_output_text() { + let output = CommandOutput::text("hello"); + assert_eq!(output.lines.len(), 1); + match &output.lines[0] { + OutputLine::Text(s) => assert_eq!(s, "hello"), + _ => panic!("expected Text"), + } + } + + #[test] + fn command_error_display() { + let err = CommandError::Unknown("foo".to_string()); + assert_eq!(err.to_string(), "Unknown command: foo"); + + let err = CommandError::ElevationRequired { + registry: "invoice".to_string(), + verb: "void".to_string(), + }; + assert_eq!(err.to_string(), "Elevation required: invoice:void"); + } + + #[test] + fn scope_check_equality() { + assert_eq!(ScopeCheck::Authorized, ScopeCheck::Authorized); + assert_eq!(ScopeCheck::Expired, ScopeCheck::Expired); + assert_ne!(ScopeCheck::Authorized, ScopeCheck::Expired); + } +} diff --git a/bascule-shell/src/governed/commands/approve.rs b/bascule-shell/src/governed/commands/approve.rs new file mode 100644 index 0000000..718b621 --- /dev/null +++ b/bascule-shell/src/governed/commands/approve.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct ApproveCommand; + +#[async_trait] +impl ShellCommand for ApproveCommand { + fn name(&self) -> &str { "approve" } + fn tier(&self) -> CommandTier { CommandTier::Administrator } + fn required_scope(&self) -> RequiredScope { + // Approval itself is read-only — it's the TARGET operation that + // requires elevation. The approver's identity comes from the session. + RequiredScope::ReadOnly + } + fn description(&self) -> &str { "Approve a pending ceremony" } + fn usage(&self) -> &str { "approve [comment]" } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if args.is_empty() { + return Err(CommandError::InvalidArgs( + "Usage: approve [comment]".to_string(), + )); + } + + let ceremony_id = &args[0]; + let comment = if args.len() > 1 { + Some(args[1..].join(" ")) + } else { + None + }; + + // Phase 1: stub — reports what would happen. + // Production: calls CeremonyService.ApproveCeremony with session identity. + let identity = session.identity().display_name.clone(); + let role = session.identity().roles.first().cloned().unwrap_or_default(); + + let mut lines = vec![ + OutputLine::Status { + label: "Ceremony".to_string(), + value: ceremony_id.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Approver".to_string(), + value: format!("{} ({})", identity, role), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Decision".to_string(), + value: "APPROVED".to_string(), + color: OutputColor::Green, + }, + ]; + + if let Some(c) = &comment { + lines.push(OutputLine::Status { + label: "Comment".to_string(), + value: c.clone(), + color: OutputColor::Default, + }); + } + + lines.push(OutputLine::Blank); + lines.push(OutputLine::Text( + "(stub: CeremonyService.ApproveCeremony not connected)".to_string(), + )); + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/ceremonies.rs b/bascule-shell/src/governed/commands/ceremonies.rs new file mode 100644 index 0000000..487e422 --- /dev/null +++ b/bascule-shell/src/governed/commands/ceremonies.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::GovernedSession; + +pub struct CeremoniesCommand; + +#[async_trait] +impl ShellCommand for CeremoniesCommand { + fn name(&self) -> &str { "ceremonies" } + fn aliases(&self) -> Vec<&str> { vec!["cer"] } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "List pending governance ceremonies" } + fn usage(&self) -> &str { "ceremonies [--all]" } + + async fn execute( + &self, + _args: &[String], + _session: &mut GovernedSession, + ) -> Result { + // Phase 1: stub — shows placeholder ceremony list. + // Production: calls CeremonyService.ListPendingCeremonies. + let lines = vec![ + OutputLine::Table { + headers: vec![ + "CEREMONY".to_string(), + "TYPE".to_string(), + "SUBJECT".to_string(), + "APPROVALS".to_string(), + "DEADLINE".to_string(), + ], + rows: vec![ + vec!["(no ceremonies loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string(), "-".to_string()], + ], + }, + OutputLine::Blank, + OutputLine::Text("Use 'approve ' or 'deny ' to act.".to_string()), + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/deescalate.rs b/bascule-shell/src/governed/commands/deescalate.rs new file mode 100644 index 0000000..48f3271 --- /dev/null +++ b/bascule-shell/src/governed/commands/deescalate.rs @@ -0,0 +1,38 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct DeescalateCommand; + +#[async_trait] +impl ShellCommand for DeescalateCommand { + fn name(&self) -> &str { "deescalate" } + fn tier(&self) -> CommandTier { CommandTier::Administrator } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Drop elevation to read-only scope" } + fn usage(&self) -> &str { "deescalate" } + + async fn execute( + &self, + _args: &[String], + session: &mut GovernedSession, + ) -> Result { + if !session.is_elevated() { + return Ok(CommandOutput::text("Not currently elevated.")); + } + + session.deescalate(); + + Ok(CommandOutput { + lines: vec![ + OutputLine::Status { + label: "Scope".to_string(), + value: "read-only".to_string(), + color: OutputColor::Green, + }, + OutputLine::Text("Elevation dropped. Returned to read-only scope.".to_string()), + ], + }) + } +} diff --git a/bascule-shell/src/governed/commands/deny.rs b/bascule-shell/src/governed/commands/deny.rs new file mode 100644 index 0000000..e7ddb09 --- /dev/null +++ b/bascule-shell/src/governed/commands/deny.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct DenyCommand; + +#[async_trait] +impl ShellCommand for DenyCommand { + fn name(&self) -> &str { "deny" } + fn tier(&self) -> CommandTier { CommandTier::Administrator } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Deny a pending ceremony" } + fn usage(&self) -> &str { "deny [reason]" } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if args.is_empty() { + return Err(CommandError::InvalidArgs( + "Usage: deny [reason]".to_string(), + )); + } + + let ceremony_id = &args[0]; + let reason = if args.len() > 1 { + args[1..].join(" ") + } else { + "No reason given".to_string() + }; + + let identity = session.identity().display_name.clone(); + + let lines = vec![ + OutputLine::Status { + label: "Ceremony".to_string(), + value: ceremony_id.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Denier".to_string(), + value: identity, + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Decision".to_string(), + value: "DENIED".to_string(), + color: OutputColor::Red, + }, + OutputLine::Status { + label: "Reason".to_string(), + value: reason, + color: OutputColor::Default, + }, + OutputLine::Blank, + OutputLine::Text( + "(stub: CeremonyService.DenyCeremony not connected)".to_string(), + ), + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/deploy.rs b/bascule-shell/src/governed/commands/deploy.rs new file mode 100644 index 0000000..77ed0ba --- /dev/null +++ b/bascule-shell/src/governed/commands/deploy.rs @@ -0,0 +1,71 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct DeployCommand; + +#[async_trait] +impl ShellCommand for DeployCommand { + fn name(&self) -> &str { "deploy" } + fn tier(&self) -> CommandTier { CommandTier::Engineer } + fn required_scope(&self) -> RequiredScope { + RequiredScope::Elevated { + registry: "schematic".to_string(), + verb: "deploy".to_string(), + } + } + fn description(&self) -> &str { "Deploy a schematic version" } + fn usage(&self) -> &str { "deploy [environment]" } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + if args.len() < 2 { + return Err(CommandError::InvalidArgs( + "Usage: deploy [environment]".to_string(), + )); + } + + let schematic_name = &args[0]; + let version = &args[1]; + let environment = args.get(2).map(|s| s.as_str()).unwrap_or("default"); + let tenant = session.tenant_context().unwrap_or("*").to_string(); + + let lines = vec![ + OutputLine::Text(format!( + "Deploying: {} v{} -> {} (tenant: {})", + schematic_name, version, environment, tenant + )), + OutputLine::Separator, + OutputLine::Status { + label: "Schematic".to_string(), + value: schematic_name.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Version".to_string(), + value: version.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Environment".to_string(), + value: environment.to_string(), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Status".to_string(), + value: "(stub: deployment not connected)".to_string(), + color: OutputColor::Yellow, + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/elevate.rs b/bascule-shell/src/governed/commands/elevate.rs new file mode 100644 index 0000000..6479253 --- /dev/null +++ b/bascule-shell/src/governed/commands/elevate.rs @@ -0,0 +1,73 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct ElevateCommand; + +#[async_trait] +impl ShellCommand for ElevateCommand { + fn name(&self) -> &str { "elevate" } + fn tier(&self) -> CommandTier { CommandTier::Administrator } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Request scope elevation" } + fn usage(&self) -> &str { "elevate [ttl_minutes]" } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + if args.len() < 2 { + return Err(CommandError::InvalidArgs( + "Usage: elevate [ttl_minutes]".to_string(), + )); + } + + let registry = &args[0]; + let verb = &args[1]; + let ttl = args.get(2).and_then(|s| s.parse::().ok()).unwrap_or(15); + let tenant = session.tenant_context().unwrap_or("*").to_string(); + + // Phase 1: stub — shows what would happen. + // Production: creates ceremony via CeremonyService, polls for approval. + let lines = vec![ + OutputLine::Text(format!( + "Requesting elevation: {}:{} on {} ({}m TTL)", + registry, verb, tenant, ttl + )), + OutputLine::Separator, + OutputLine::Status { + label: "Registry".to_string(), + value: registry.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Verb".to_string(), + value: verb.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Tenant".to_string(), + value: tenant, + color: OutputColor::Default, + }, + OutputLine::Status { + label: "TTL".to_string(), + value: format!("{} minutes", ttl), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Status".to_string(), + value: "(stub: ceremony creation not connected)".to_string(), + color: OutputColor::Yellow, + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/exit.rs b/bascule-shell/src/governed/commands/exit.rs new file mode 100644 index 0000000..15b1476 --- /dev/null +++ b/bascule-shell/src/governed/commands/exit.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::GovernedSession; + +pub struct ExitCommand; + +#[async_trait] +impl ShellCommand for ExitCommand { + fn name(&self) -> &str { "exit" } + fn aliases(&self) -> Vec<&str> { vec!["quit"] } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Exit the shell" } + fn usage(&self) -> &str { "exit" } + + async fn execute( + &self, + _args: &[String], + _session: &mut GovernedSession, + ) -> Result { + // The executor handles exit by detecting the command name. + // This execute is called if it gets through. + Ok(CommandOutput::text("Goodbye.")) + } +} diff --git a/bascule-shell/src/governed/commands/help.rs b/bascule-shell/src/governed/commands/help.rs new file mode 100644 index 0000000..78f332f --- /dev/null +++ b/bascule-shell/src/governed/commands/help.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::GovernedSession; + +pub struct HelpCommand; + +#[async_trait] +impl ShellCommand for HelpCommand { + fn name(&self) -> &str { "help" } + fn aliases(&self) -> Vec<&str> { vec!["?"] } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Show available commands" } + fn usage(&self) -> &str { "help [command]" } + + async fn execute( + &self, + args: &[String], + _session: &mut GovernedSession, + ) -> Result { + if let Some(cmd_name) = args.first() { + // Detailed help for a specific command is handled by the executor + // since it needs access to the registry. Return a hint. + Ok(CommandOutput::text(format!("Use 'help' to see all commands. Requested help for: {}", cmd_name))) + } else { + let lines = vec![ + OutputLine::Text("Available commands:".to_string()), + OutputLine::Blank, + OutputLine::Text("Analyst (read-only):".to_string()), + OutputLine::Text(" help Show available commands".to_string()), + OutputLine::Text(" status Session and connection status".to_string()), + OutputLine::Text(" whoami Show identity and SAT info".to_string()), + OutputLine::Text(" tenants List accessible tenants".to_string()), + OutputLine::Text(" use Select tenant context".to_string()), + OutputLine::Text(" registries List registries for current tenant".to_string()), + OutputLine::Text(" query Query a registry artifact".to_string()), + OutputLine::Text(" history Show mutation history".to_string()), + OutputLine::Text(" verify Verify merkle proof for an artifact".to_string()), + OutputLine::Text(" ceremonies List pending governance ceremonies".to_string()), + OutputLine::Text(" exit Exit the shell".to_string()), + OutputLine::Blank, + OutputLine::Text("Administrator (require elevation):".to_string()), + OutputLine::Text(" approve Approve a pending ceremony".to_string()), + OutputLine::Text(" deny Deny a pending ceremony".to_string()), + OutputLine::Text(" elevate Request scope elevation".to_string()), + OutputLine::Text(" deescalate Drop elevation to read-only".to_string()), + OutputLine::Text(" void Void a registry artifact".to_string()), + OutputLine::Blank, + OutputLine::Text("Engineer (require elevation):".to_string()), + OutputLine::Text(" deploy Deploy a schematic version".to_string()), + OutputLine::Text(" pipeline Pipeline management".to_string()), + OutputLine::Text(" schematic Schematic management".to_string()), + ]; + Ok(CommandOutput { lines }) + } + } +} diff --git a/bascule-shell/src/governed/commands/history.rs b/bascule-shell/src/governed/commands/history.rs new file mode 100644 index 0000000..0078bef --- /dev/null +++ b/bascule-shell/src/governed/commands/history.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct HistoryCommand; + +#[async_trait] +impl ShellCommand for HistoryCommand { + fn name(&self) -> &str { "history" } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Show mutation history for a registry" } + fn usage(&self) -> &str { "history [artifact_id]" } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + if args.is_empty() { + return Err(CommandError::InvalidArgs( + "Usage: history [artifact_id]".to_string(), + )); + } + + let registry = &args[0]; + let artifact_id = args.get(1); + + // Phase 1: stub — shows placeholder history. + let scope = if let Some(id) = artifact_id { + format!("{}:{}", registry, id) + } else { + registry.clone() + }; + + let lines = vec![ + OutputLine::Text(format!("Mutation history for: {}", scope)), + OutputLine::Table { + headers: vec![ + "TIMESTAMP".to_string(), + "VERB".to_string(), + "ACTOR".to_string(), + "CEREMONY".to_string(), + ], + rows: vec![ + vec!["(no history loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string()], + ], + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/mod.rs b/bascule-shell/src/governed/commands/mod.rs new file mode 100644 index 0000000..8dcfba7 --- /dev/null +++ b/bascule-shell/src/governed/commands/mod.rs @@ -0,0 +1,58 @@ +//! Built-in shell commands. + +pub mod approve; +pub mod ceremonies; +pub mod deescalate; +pub mod deny; +pub mod deploy; +pub mod elevate; +pub mod exit; +pub mod help; +pub mod history; +pub mod pipeline; +pub mod query; +pub mod registries; +pub mod schematic; +pub mod status; +pub mod tenants; +pub mod use_tenant; +pub mod verify; +pub mod void; +pub mod whoami; + +use std::sync::Arc; + +use super::command_trait::ShellCommand; +use super::registry::CommandRegistry; + +/// Register all built-in commands into the registry. +pub fn register_all_builtins(registry: &mut CommandRegistry) { + let builtins: Vec> = vec![ + // Analyst tier + Arc::new(help::HelpCommand), + Arc::new(status::StatusCommand), + Arc::new(tenants::TenantsCommand), + Arc::new(use_tenant::UseTenantCommand), + Arc::new(registries::RegistriesCommand), + Arc::new(query::QueryCommand), + Arc::new(history::HistoryCommand), + Arc::new(verify::VerifyCommand), + Arc::new(ceremonies::CeremoniesCommand), + Arc::new(whoami::WhoamiCommand), + Arc::new(exit::ExitCommand), + // Administrator tier + Arc::new(approve::ApproveCommand), + Arc::new(deny::DenyCommand), + Arc::new(elevate::ElevateCommand), + Arc::new(deescalate::DeescalateCommand), + Arc::new(void::VoidCommand), + // Engineer tier + Arc::new(deploy::DeployCommand), + Arc::new(pipeline::PipelineCommand), + Arc::new(schematic::SchematicCommand), + ]; + + for cmd in builtins { + registry.register(cmd); + } +} diff --git a/bascule-shell/src/governed/commands/pipeline.rs b/bascule-shell/src/governed/commands/pipeline.rs new file mode 100644 index 0000000..89e6cb1 --- /dev/null +++ b/bascule-shell/src/governed/commands/pipeline.rs @@ -0,0 +1,69 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct PipelineCommand; + +#[async_trait] +impl ShellCommand for PipelineCommand { + fn name(&self) -> &str { "pipeline" } + fn aliases(&self) -> Vec<&str> { vec!["pl"] } + fn tier(&self) -> CommandTier { CommandTier::Engineer } + fn required_scope(&self) -> RequiredScope { + // Base command is read-only; subcommands like "trigger" require elevation. + RequiredScope::ReadOnly + } + fn description(&self) -> &str { "Pipeline management (list, show, trigger)" } + fn usage(&self) -> &str { "pipeline [args...]" } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + let subcommand = args.first().map(|s| s.as_str()).unwrap_or("list"); + + match subcommand { + "list" | "ls" => { + let lines = vec![ + OutputLine::Table { + headers: vec![ + "RUN ID".to_string(), + "PIPELINE".to_string(), + "STATUS".to_string(), + "BRANCH".to_string(), + ], + rows: vec![ + vec!["(no runs loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string()], + ], + }, + ]; + Ok(CommandOutput { lines }) + } + "show" => { + let run_id = args.get(1).ok_or_else(|| { + CommandError::InvalidArgs("Usage: pipeline show ".to_string()) + })?; + Ok(CommandOutput::text(format!( + "Pipeline run: {} (stub: not connected to runner-controller)", + run_id + ))) + } + "trigger" => { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + // Trigger requires elevation. + Err(CommandError::ElevationRequired { + registry: "pipeline".to_string(), + verb: "trigger".to_string(), + }) + } + _ => Err(CommandError::InvalidArgs(format!( + "Unknown subcommand: {}. Use: list, show, trigger", + subcommand + ))), + } + } +} diff --git a/bascule-shell/src/governed/commands/query.rs b/bascule-shell/src/governed/commands/query.rs new file mode 100644 index 0000000..0319a1f --- /dev/null +++ b/bascule-shell/src/governed/commands/query.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct QueryCommand; + +#[async_trait] +impl ShellCommand for QueryCommand { + fn name(&self) -> &str { "query" } + fn aliases(&self) -> Vec<&str> { vec!["q"] } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Query a registry artifact" } + fn usage(&self) -> &str { "query " } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + if args.len() < 2 { + return Err(CommandError::InvalidArgs( + "Usage: query ".to_string(), + )); + } + + let registry = &args[0]; + let artifact_id = &args[1]; + + // Phase 1: stub — shows placeholder artifact info. + // Production: calls the appropriate registry gRPC service. + let tenant = session.tenant_context().unwrap_or("*"); + let lines = vec![ + OutputLine::Text(format!("Artifact: {}/{}/{}", tenant, registry, artifact_id)), + OutputLine::Separator, + OutputLine::Status { + label: "Registry".to_string(), + value: registry.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Artifact ID".to_string(), + value: artifact_id.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Tenant".to_string(), + value: tenant.to_string(), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Status".to_string(), + value: "(not connected to live service)".to_string(), + color: OutputColor::Yellow, + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/registries.rs b/bascule-shell/src/governed/commands/registries.rs new file mode 100644 index 0000000..a601f72 --- /dev/null +++ b/bascule-shell/src/governed/commands/registries.rs @@ -0,0 +1,49 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct RegistriesCommand; + +#[async_trait] +impl ShellCommand for RegistriesCommand { + fn name(&self) -> &str { "registries" } + fn aliases(&self) -> Vec<&str> { vec!["regs"] } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "List registries for current tenant" } + fn usage(&self) -> &str { "registries" } + + async fn execute( + &self, + _args: &[String], + session: &mut GovernedSession, + ) -> Result { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + // Phase 1: returns the known registry types. + // Production: queries actual registry services for artifact counts. + let lines = vec![ + OutputLine::Table { + headers: vec![ + "REGISTRY".to_string(), + "TYPE".to_string(), + "DESCRIPTION".to_string(), + ], + rows: vec![ + vec!["credential".to_string(), "governed".to_string(), "Service credentials".to_string()], + vec!["invoice".to_string(), "governed".to_string(), "Financial invoices".to_string()], + vec!["pipeline-result".to_string(), "governed".to_string(), "Pipeline attestations".to_string()], + vec!["capability-profile".to_string(), "governed".to_string(), "Workload capabilities".to_string()], + vec!["sync-event".to_string(), "governed".to_string(), "Git sync events".to_string()], + vec!["schematic".to_string(), "governed".to_string(), "Infrastructure schematics".to_string()], + vec!["governance-ceremony".to_string(), "governed".to_string(), "Ceremony resolutions".to_string()], + ], + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/schematic.rs b/bascule-shell/src/governed/commands/schematic.rs new file mode 100644 index 0000000..6417046 --- /dev/null +++ b/bascule-shell/src/governed/commands/schematic.rs @@ -0,0 +1,71 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct SchematicCommand; + +#[async_trait] +impl ShellCommand for SchematicCommand { + fn name(&self) -> &str { "schematic" } + fn aliases(&self) -> Vec<&str> { vec!["sch"] } + fn tier(&self) -> CommandTier { CommandTier::Engineer } + fn required_scope(&self) -> RequiredScope { + // Base command is read-only; mutation subcommands require elevation. + RequiredScope::ReadOnly + } + fn description(&self) -> &str { "Schematic management (list, show, validate, approve)" } + fn usage(&self) -> &str { "schematic [args...]" } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + let subcommand = args.first().map(|s| s.as_str()).unwrap_or("list"); + + match subcommand { + "list" | "ls" => { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + let lines = vec![ + OutputLine::Table { + headers: vec![ + "NAME".to_string(), + "VERSION".to_string(), + "STATUS".to_string(), + ], + rows: vec![ + vec!["(no schematics loaded)".to_string(), "-".to_string(), "-".to_string()], + ], + }, + ]; + Ok(CommandOutput { lines }) + } + "show" => { + let name = args.get(1).ok_or_else(|| { + CommandError::InvalidArgs("Usage: schematic show [version]".to_string()) + })?; + Ok(CommandOutput::text(format!( + "Schematic: {} (stub: not connected)", + name + ))) + } + "validate" | "approve" => { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + Err(CommandError::ElevationRequired { + registry: "schematic".to_string(), + verb: subcommand.to_string(), + }) + } + _ => Err(CommandError::InvalidArgs(format!( + "Unknown subcommand: {}. Use: list, show, validate, approve", + subcommand + ))), + } + } +} diff --git a/bascule-shell/src/governed/commands/status.rs b/bascule-shell/src/governed/commands/status.rs new file mode 100644 index 0000000..9677865 --- /dev/null +++ b/bascule-shell/src/governed/commands/status.rs @@ -0,0 +1,90 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct StatusCommand; + +#[async_trait] +impl ShellCommand for StatusCommand { + fn name(&self) -> &str { "status" } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Show session and connection status" } + fn usage(&self) -> &str { "status" } + + async fn execute( + &self, + _args: &[String], + session: &mut GovernedSession, + ) -> Result { + let tenant = session.tenant_context().unwrap_or("* (none selected)"); + let scope = if session.is_elevated() { "elevated" } else { "read-only" }; + let elevation = if let Some(e) = &session.elevated_sat { + let remaining = (e.expires_at - chrono::Utc::now()).num_seconds().max(0); + let mins = remaining / 60; + let secs = remaining % 60; + format!("{}m {}s remaining", mins, secs) + } else { + "none".to_string() + }; + + let connected = session.connected_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(); + let session_remaining = { + let remaining = (session.base_sat.expires_at - chrono::Utc::now()).num_seconds().max(0); + let hours = remaining / 3600; + let mins = (remaining % 3600) / 60; + format!("{}h {}m", hours, mins) + }; + + let lines = vec![ + OutputLine::Status { + label: "Session".to_string(), + value: session.session_id().to_string(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "User".to_string(), + value: session.identity().display_name.clone(), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Roles".to_string(), + value: session.identity().roles.join(", "), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Scope".to_string(), + value: scope.to_string(), + color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Green }, + }, + OutputLine::Status { + label: "Tenant".to_string(), + value: tenant.to_string(), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Connected".to_string(), + value: connected, + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Session expires".to_string(), + value: session_remaining, + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Elevation".to_string(), + value: elevation, + color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Default }, + }, + OutputLine::Status { + label: "Commands executed".to_string(), + value: session.command_count.to_string(), + color: OutputColor::Default, + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/tenants.rs b/bascule-shell/src/governed/commands/tenants.rs new file mode 100644 index 0000000..87af09c --- /dev/null +++ b/bascule-shell/src/governed/commands/tenants.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::GovernedSession; + +pub struct TenantsCommand; + +#[async_trait] +impl ShellCommand for TenantsCommand { + fn name(&self) -> &str { "tenants" } + fn aliases(&self) -> Vec<&str> { vec!["ls-tenants"] } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "List accessible tenants" } + fn usage(&self) -> &str { "tenants" } + + async fn execute( + &self, + _args: &[String], + _session: &mut GovernedSession, + ) -> Result { + // Phase 1: stub — returns placeholder tenant list. + // Production: calls tenant listing endpoint via gRPC. + let lines = vec![ + OutputLine::Table { + headers: vec![ + "TENANT".to_string(), + "STATUS".to_string(), + "REGISTRIES".to_string(), + ], + rows: vec![ + vec!["(no tenants loaded)".to_string(), "-".to_string(), "-".to_string()], + ], + }, + OutputLine::Blank, + OutputLine::Text("Use 'use ' to select a tenant.".to_string()), + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/use_tenant.rs b/bascule-shell/src/governed/commands/use_tenant.rs new file mode 100644 index 0000000..71f21b6 --- /dev/null +++ b/bascule-shell/src/governed/commands/use_tenant.rs @@ -0,0 +1,45 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct UseTenantCommand; + +#[async_trait] +impl ShellCommand for UseTenantCommand { + fn name(&self) -> &str { "use" } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Select tenant context" } + fn usage(&self) -> &str { "use " } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + let tenant_id = args.first().ok_or_else(|| { + CommandError::InvalidArgs("Usage: use ".to_string()) + })?; + + // Clear context with "use *" or "use none". + if tenant_id == "*" || tenant_id == "none" { + session.set_tenant_context(None); + return Ok(CommandOutput::text("Tenant context cleared.")); + } + + // Phase 1: accept any tenant_id (production: validate via gRPC). + session.set_tenant_context(Some(tenant_id.clone())); + + Ok(CommandOutput { + lines: vec![ + OutputLine::Status { + label: "Tenant".to_string(), + value: tenant_id.clone(), + color: OutputColor::Green, + }, + OutputLine::Text(format!("Context set to '{}'. Commands will target this tenant.", tenant_id)), + ], + }) + } +} diff --git a/bascule-shell/src/governed/commands/verify.rs b/bascule-shell/src/governed/commands/verify.rs new file mode 100644 index 0000000..e8bd888 --- /dev/null +++ b/bascule-shell/src/governed/commands/verify.rs @@ -0,0 +1,53 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct VerifyCommand; + +#[async_trait] +impl ShellCommand for VerifyCommand { + fn name(&self) -> &str { "verify" } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Verify merkle proof for an artifact" } + fn usage(&self) -> &str { "verify " } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + if args.len() < 2 { + return Err(CommandError::InvalidArgs( + "Usage: verify ".to_string(), + )); + } + + let registry = &args[0]; + let artifact_id = &args[1]; + + // Phase 1: stub — shows placeholder verification. + // Production: calls NotaryService.VerifyInclusion. + let lines = vec![ + OutputLine::Text(format!("Verifying: {}/{}", registry, artifact_id)), + OutputLine::Separator, + OutputLine::Status { + label: "Anchor".to_string(), + value: "(not connected to notary)".to_string(), + color: OutputColor::Yellow, + }, + OutputLine::Status { + label: "Verification".to_string(), + value: "unavailable (notary not connected)".to_string(), + color: OutputColor::Yellow, + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/void.rs b/bascule-shell/src/governed/commands/void.rs new file mode 100644 index 0000000..276a94e --- /dev/null +++ b/bascule-shell/src/governed/commands/void.rs @@ -0,0 +1,69 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{GovernedSession, Governed}; + +pub struct VoidCommand; + +#[async_trait] +impl ShellCommand for VoidCommand { + fn name(&self) -> &str { "void" } + fn tier(&self) -> CommandTier { CommandTier::Administrator } + fn required_scope(&self) -> RequiredScope { + RequiredScope::Elevated { + registry: "invoice".to_string(), + verb: "void".to_string(), + } + } + fn description(&self) -> &str { "Void a registry artifact" } + fn usage(&self) -> &str { "void " } + + async fn execute( + &self, + args: &[String], + session: &mut GovernedSession, + ) -> Result { + if session.tenant_context().is_none() { + return Err(CommandError::NoTenantContext); + } + + if args.len() < 2 { + return Err(CommandError::InvalidArgs( + "Usage: void ".to_string(), + )); + } + + let registry = &args[0]; + let artifact_id = &args[1]; + let tenant = session.tenant_context().unwrap_or("*").to_string(); + + // Phase 1: stub — shows what would happen. + // Production: creates MutationIntent with elevated SAT, executes void. + let lines = vec![ + OutputLine::Text(format!("Voiding: {}/{}/{}", tenant, registry, artifact_id)), + OutputLine::Separator, + OutputLine::Status { + label: "Registry".to_string(), + value: registry.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Artifact".to_string(), + value: artifact_id.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Tenant".to_string(), + value: tenant, + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Status".to_string(), + value: "(stub: void operation not connected)".to_string(), + color: OutputColor::Yellow, + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/commands/whoami.rs b/bascule-shell/src/governed/commands/whoami.rs new file mode 100644 index 0000000..6986ed9 --- /dev/null +++ b/bascule-shell/src/governed/commands/whoami.rs @@ -0,0 +1,64 @@ +use async_trait::async_trait; + +use crate::governed::command_trait::*; +use crate::governed::session::{AuthMethod, GovernedSession, Governed}; + +pub struct WhoamiCommand; + +#[async_trait] +impl ShellCommand for WhoamiCommand { + fn name(&self) -> &str { "whoami" } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Show identity and SAT info" } + fn usage(&self) -> &str { "whoami" } + + async fn execute( + &self, + _args: &[String], + session: &mut GovernedSession, + ) -> Result { + let auth = match &session.identity().auth_method { + AuthMethod::PublicKey { fingerprint } => format!("public-key ({})", fingerprint), + AuthMethod::Token { issuer } => format!("token ({})", issuer), + }; + + let scope = if session.is_elevated() { "elevated" } else { "read-only" }; + let tenant_scope = session.tenant_context().unwrap_or("*"); + + let remaining = (session.base_sat.expires_at - chrono::Utc::now()).num_seconds().max(0); + let hours = remaining / 3600; + let mins = (remaining % 3600) / 60; + let expires = format!("{}h {}m", hours, mins); + + let lines = vec![ + OutputLine::Status { + label: "Identity".to_string(), + value: session.identity().display_name.clone(), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Subject".to_string(), + value: session.identity().subject.clone(), + color: OutputColor::Cyan, + }, + OutputLine::Status { + label: "Roles".to_string(), + value: format!("[{}]", session.identity().roles.join(", ")), + color: OutputColor::Default, + }, + OutputLine::Status { + label: "Auth".to_string(), + value: auth, + color: OutputColor::Default, + }, + OutputLine::Status { + label: "SAT".to_string(), + value: format!("{} | Tenant scope: {} | Expires: {}", scope, tenant_scope, expires), + color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Green }, + }, + ]; + + Ok(CommandOutput { lines }) + } +} diff --git a/bascule-shell/src/governed/executor.rs b/bascule-shell/src/governed/executor.rs new file mode 100644 index 0000000..8af1563 --- /dev/null +++ b/bascule-shell/src/governed/executor.rs @@ -0,0 +1,319 @@ +//! Command executor — the governed REPL loop. +//! +//! Reads input, routes commands, checks scopes, handles elevation, +//! and renders output. + +use super::command_trait::{CommandError, ScopeCheck}; +use super::registry::CommandRegistry; +use super::render::OutputRenderer; +use super::session::{GovernedSession, Governed}; + +/// Executes commands within a governed shell session. +pub struct CommandExecutor { + pub registry: CommandRegistry, + pub renderer: OutputRenderer, +} + +/// Result of processing a single command line. +#[derive(Debug)] +pub enum ExecResult { + /// Command produced output. + Output(String), + /// Session should end. + Exit, + /// Elevation is required for this command. + ElevationNeeded { registry: String, verb: String }, + /// Error message. + Error(String), + /// Session expired. + SessionExpired, + /// Elevation just expired (informational). + ElevationExpired, + /// Empty input. + Empty, +} + +impl CommandExecutor { + pub fn new(registry: CommandRegistry) -> Self { + Self { + registry, + renderer: OutputRenderer::default(), + } + } + + /// Process a single input line and return the result. + pub async fn process_line( + &self, + line: &str, + session: &mut GovernedSession, + ) -> ExecResult { + // Check elevation expiry before processing. + if session.check_elevation_expiry() { + return ExecResult::ElevationExpired; + } + + // Check base SAT expiry. + if session.base_sat_expired() { + return ExecResult::SessionExpired; + } + + let line = line.trim(); + if line.is_empty() { + return ExecResult::Empty; + } + + // Parse command + args. + let parts: Vec = split_args(line); + let (cmd_name, args) = match parts.split_first() { + Some((name, args)) => (name.as_str(), args), + None => return ExecResult::Empty, + }; + + // Look up command. + let command = match self.registry.get(cmd_name) { + Some(cmd) => cmd.clone(), + None => { + return ExecResult::Error(format!("Unknown command: {}. Type 'help' for available commands.", cmd_name)); + } + }; + + // Check scope BEFORE execution. + let scope_check = session.can_execute(&command.required_scope()); + match scope_check { + ScopeCheck::Authorized => {} + ScopeCheck::Expired => { + return ExecResult::SessionExpired; + } + ScopeCheck::ElevationRequired { registry, verb } => { + return ExecResult::ElevationNeeded { registry, verb }; + } + ScopeCheck::ElevationExpired => { + return ExecResult::ElevationExpired; + } + } + + // Execute command. + match command.execute(args, session).await { + Ok(output) => { + session.increment_command_count(); + let rendered = self.renderer.render(&output); + if rendered.is_empty() { + ExecResult::Output(String::new()) + } else { + ExecResult::Output(rendered) + } + } + Err(CommandError::ElevationRequired { registry, verb }) => { + ExecResult::ElevationNeeded { registry, verb } + } + Err(e) => ExecResult::Error(e.to_string()), + } + } + + /// Generate the welcome banner. + pub fn welcome_banner(&self, session: &GovernedSession) -> String { + let mut banner = String::new(); + banner.push_str("Guildhouse Governed Shell\n"); + banner.push_str(&format!( + "Session: {} | User: {} | Role: {}\n", + session.session_id, + session.identity.display_name, + session.identity.roles.join(", ") + )); + banner.push_str(&format!( + "Commands: {} available | Type 'help' for usage\n", + self.registry.len() + )); + banner.push('\n'); + banner + } +} + +/// Split an input line into arguments, respecting quoted strings. +fn split_args(input: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_quote = false; + let mut quote_char = ' '; + + for ch in input.chars() { + if in_quote { + if ch == quote_char { + in_quote = false; + } else { + current.push(ch); + } + } else if ch == '"' || ch == '\'' { + in_quote = true; + quote_char = ch; + } else if ch.is_whitespace() { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } + } else { + current.push(ch); + } + } + + if !current.is_empty() { + args.push(current); + } + + args +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::governed::command_trait::*; + use crate::governed::session::{AuthMethod, OperatorIdentity}; + use async_trait::async_trait; + use registry_protocol::sat::SatRef; + use std::sync::Arc; + + struct ExitCommand; + + #[async_trait] + impl ShellCommand for ExitCommand { + fn name(&self) -> &str { "exit" } + fn tier(&self) -> CommandTier { CommandTier::Analyst } + fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly } + fn description(&self) -> &str { "Exit the shell" } + fn usage(&self) -> &str { "exit" } + async fn execute( + &self, + _args: &[String], + _session: &mut GovernedSession, + ) -> Result { + Ok(CommandOutput::text("Goodbye!")) + } + } + + struct ElevatedCommand; + + #[async_trait] + impl ShellCommand for ElevatedCommand { + fn name(&self) -> &str { "void" } + fn tier(&self) -> CommandTier { CommandTier::Administrator } + fn required_scope(&self) -> RequiredScope { + RequiredScope::Elevated { + registry: "invoice".to_string(), + verb: "void".to_string(), + } + } + fn description(&self) -> &str { "Void an artifact" } + fn usage(&self) -> &str { "void " } + async fn execute( + &self, + _args: &[String], + _session: &mut GovernedSession, + ) -> Result { + Ok(CommandOutput::text("voided")) + } + } + + fn test_session() -> GovernedSession { + let now = chrono::Utc::now(); + GovernedSession::new( + "sess-test".to_string(), + OperatorIdentity { + subject: "tyler".to_string(), + display_name: "Tyler King".to_string(), + roles: vec!["engineer".to_string()], + auth_method: AuthMethod::PublicKey { + fingerprint: "SHA256:test".to_string(), + }, + }, + "127.0.0.1:12345".parse().unwrap(), + SatRef { + sat_hash: [0u8; 32], + bearer_svid: "spiffe://test".to_string(), + scopes: vec![], + issued_at: now, + expires_at: now + chrono::Duration::hours(12), + }, + ) + } + + fn test_executor() -> CommandExecutor { + let mut registry = CommandRegistry::new(); + registry.register(Arc::new(ExitCommand)); + registry.register(Arc::new(ElevatedCommand)); + CommandExecutor { + registry, + renderer: OutputRenderer { use_color: false, ..Default::default() }, + } + } + + #[tokio::test] + async fn executor_routes_known_command() { + let executor = test_executor(); + let mut session = test_session(); + match executor.process_line("exit", &mut session).await { + ExecResult::Output(s) => assert!(s.contains("Goodbye")), + other => panic!("expected Output, got {:?}", other), + } + } + + #[tokio::test] + async fn executor_rejects_unknown_command() { + let executor = test_executor(); + let mut session = test_session(); + match executor.process_line("nonexistent", &mut session).await { + ExecResult::Error(s) => assert!(s.contains("Unknown command")), + other => panic!("expected Error, got {:?}", other), + } + } + + #[tokio::test] + async fn executor_blocks_elevated_command() { + let executor = test_executor(); + let mut session = test_session(); + match executor.process_line("void invoice INV-001", &mut session).await { + ExecResult::ElevationNeeded { registry, verb } => { + assert_eq!(registry, "invoice"); + assert_eq!(verb, "void"); + } + other => panic!("expected ElevationNeeded, got {:?}", other), + } + } + + #[tokio::test] + async fn executor_empty_line() { + let executor = test_executor(); + let mut session = test_session(); + match executor.process_line("", &mut session).await { + ExecResult::Empty => {} + other => panic!("expected Empty, got {:?}", other), + } + } + + #[test] + fn split_args_simple() { + assert_eq!(split_args("hello world"), vec!["hello", "world"]); + } + + #[test] + fn split_args_quoted() { + assert_eq!( + split_args(r#"void invoice "my reason""#), + vec!["void", "invoice", "my reason"] + ); + } + + #[test] + fn split_args_empty() { + assert!(split_args("").is_empty()); + assert!(split_args(" ").is_empty()); + } + + #[test] + fn welcome_banner_includes_session_info() { + let session = test_session(); + let executor = test_executor(); + let banner = executor.welcome_banner(&session); + assert!(banner.contains("Tyler King")); + assert!(banner.contains("engineer")); + assert!(banner.contains("sess-test")); + } +} diff --git a/bascule-shell/src/governed/manifest.rs b/bascule-shell/src/governed/manifest.rs new file mode 100644 index 0000000..3a56981 --- /dev/null +++ b/bascule-shell/src/governed/manifest.rs @@ -0,0 +1,166 @@ +//! Command manifest — declares which commands are available in this image. + +use std::path::Path; + +use serde::Deserialize; + +/// Parsed from `/opt/bascule/commands/manifest.yaml`. +#[derive(Debug, Clone, Deserialize)] +pub struct CommandManifest { + pub image_tier: String, + pub org_name: Option, + pub commands: Vec, +} + +/// A single command entry in the manifest. +#[derive(Debug, Clone, Deserialize)] +pub struct CommandEntry { + pub name: String, + pub tier: String, + pub builtin: bool, + pub description: Option, +} + +impl CommandManifest { + /// Load manifest from a YAML file. + pub fn from_file(path: &Path) -> anyhow::Result { + let contents = std::fs::read_to_string(path)?; + let manifest: Self = serde_yaml::from_str(&contents)?; + Ok(manifest) + } + + /// Load manifest from a YAML string. + pub fn from_str(yaml: &str) -> anyhow::Result { + let manifest: Self = serde_yaml::from_str(yaml)?; + Ok(manifest) + } + + /// Default analyst manifest with all built-in read commands. + pub fn default_analyst() -> Self { + Self { + image_tier: "analyst".to_string(), + org_name: None, + commands: vec![ + cmd("help", "analyst"), + cmd("status", "analyst"), + cmd("tenants", "analyst"), + cmd("use", "analyst"), + cmd("registries", "analyst"), + cmd("query", "analyst"), + cmd("history", "analyst"), + cmd("verify", "analyst"), + cmd("ceremonies", "analyst"), + cmd("whoami", "analyst"), + cmd("exit", "analyst"), + ], + } + } + + /// Default administrator manifest — all analyst + admin commands. + pub fn default_administrator() -> Self { + let mut m = Self::default_analyst(); + m.image_tier = "administrator".to_string(); + m.commands.extend(vec![ + cmd("approve", "administrator"), + cmd("deny", "administrator"), + cmd("elevate", "administrator"), + cmd("deescalate", "administrator"), + cmd("void", "administrator"), + ]); + m + } + + /// Default engineer manifest — all admin + engineer commands. + pub fn default_engineer() -> Self { + let mut m = Self::default_administrator(); + m.image_tier = "engineer".to_string(); + m.commands.extend(vec![ + cmd("deploy", "engineer"), + cmd("provision", "engineer"), + cmd("pipeline", "engineer"), + cmd("schematic", "engineer"), + ]); + m + } +} + +fn cmd(name: &str, tier: &str) -> CommandEntry { + CommandEntry { + name: name.to_string(), + tier: tier.to_string(), + builtin: true, + description: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_manifest_yaml() { + let yaml = r#" +image_tier: analyst +org_name: acme-msp +commands: + - name: status + tier: analyst + builtin: true + - name: query + tier: analyst + builtin: true + description: "Query registry artifacts" +"#; + let manifest = CommandManifest::from_str(yaml).unwrap(); + assert_eq!(manifest.image_tier, "analyst"); + assert_eq!(manifest.org_name.as_deref(), Some("acme-msp")); + assert_eq!(manifest.commands.len(), 2); + assert_eq!(manifest.commands[0].name, "status"); + assert!(manifest.commands[0].builtin); + assert_eq!( + manifest.commands[1].description.as_deref(), + Some("Query registry artifacts") + ); + } + + #[test] + fn default_analyst_has_read_commands() { + let manifest = CommandManifest::default_analyst(); + assert_eq!(manifest.image_tier, "analyst"); + let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"status")); + assert!(names.contains(&"query")); + assert!(names.contains(&"help")); + assert!(names.contains(&"exit")); + assert!(!names.contains(&"approve")); + assert!(!names.contains(&"deploy")); + } + + #[test] + fn default_administrator_includes_analyst_and_admin() { + let manifest = CommandManifest::default_administrator(); + assert_eq!(manifest.image_tier, "administrator"); + let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"status")); // analyst + assert!(names.contains(&"approve")); // admin + assert!(names.contains(&"elevate")); // admin + assert!(!names.contains(&"deploy")); // engineer only + } + + #[test] + fn default_engineer_includes_all() { + let manifest = CommandManifest::default_engineer(); + assert_eq!(manifest.image_tier, "engineer"); + let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"status")); // analyst + assert!(names.contains(&"approve")); // admin + assert!(names.contains(&"deploy")); // engineer + assert!(names.contains(&"pipeline")); // engineer + } + + #[test] + fn manifest_from_file_missing_returns_error() { + let result = CommandManifest::from_file(Path::new("/nonexistent/manifest.yaml")); + assert!(result.is_err()); + } +} diff --git a/bascule-shell/src/governed/mod.rs b/bascule-shell/src/governed/mod.rs new file mode 100644 index 0000000..0dad901 --- /dev/null +++ b/bascule-shell/src/governed/mod.rs @@ -0,0 +1,13 @@ +//! Governed SSH shell runtime. +//! +//! Implements the tiered command model with SAT-based authorization, +//! ceremony-gated elevation, and full session audit. + +pub mod command_trait; +pub mod commands; +pub mod executor; +pub mod manifest; +pub mod registry; +pub mod render; +pub mod session; +pub mod ssh; diff --git a/bascule-shell/src/governed/registry.rs b/bascule-shell/src/governed/registry.rs new file mode 100644 index 0000000..a8bf3be --- /dev/null +++ b/bascule-shell/src/governed/registry.rs @@ -0,0 +1,215 @@ +//! Command registry — stores and dispatches shell commands. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use super::command_trait::{CommandTier, ShellCommand}; +use super::manifest::CommandManifest; + +/// Registry of available shell commands. +pub struct CommandRegistry { + commands: HashMap>, + aliases: HashMap, +} + +impl CommandRegistry { + pub fn new() -> Self { + Self { + commands: HashMap::new(), + aliases: HashMap::new(), + } + } + + /// Register a command. + pub fn register(&mut self, command: Arc) { + let name = command.name().to_string(); + for alias in command.aliases() { + self.aliases.insert(alias.to_string(), name.clone()); + } + self.commands.insert(name, command); + } + + /// Look up a command by name or alias. + pub fn get(&self, name: &str) -> Option<&Arc> { + let canonical = self.aliases.get(name).map(|s| s.as_str()).unwrap_or(name); + self.commands.get(canonical) + } + + /// List all available commands grouped by tier. + pub fn list_by_tier(&self) -> BTreeMap> { + let mut grouped: BTreeMap> = BTreeMap::new(); + for cmd in self.commands.values() { + grouped.entry(cmd.tier()).or_default().push(cmd.as_ref()); + } + // Sort each tier's commands by name. + for commands in grouped.values_mut() { + commands.sort_by_key(|c| c.name()); + } + grouped + } + + /// List all registered command names. + pub fn command_names(&self) -> Vec<&str> { + let mut names: Vec<&str> = self.commands.keys().map(|s| s.as_str()).collect(); + names.sort(); + names + } + + /// Total number of registered commands. + pub fn len(&self) -> usize { + self.commands.len() + } + + pub fn is_empty(&self) -> bool { + self.commands.is_empty() + } + + /// Filter to only commands declared in the manifest. + /// Commands not in the manifest are removed. + pub fn apply_manifest(&mut self, manifest: &CommandManifest) { + let allowed: std::collections::HashSet = manifest + .commands + .iter() + .map(|e| e.name.clone()) + .collect(); + + self.commands.retain(|name, _| allowed.contains(name)); + self.aliases + .retain(|_, canonical| allowed.contains(canonical)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::governed::command_trait::*; + use crate::governed::session::GovernedSession; + use async_trait::async_trait; + + struct TestCommand { + cmd_name: &'static str, + cmd_aliases: Vec<&'static str>, + cmd_tier: CommandTier, + } + + #[async_trait] + impl ShellCommand for TestCommand { + fn name(&self) -> &str { + self.cmd_name + } + fn aliases(&self) -> Vec<&str> { + self.cmd_aliases.clone() + } + fn tier(&self) -> CommandTier { + self.cmd_tier + } + fn required_scope(&self) -> RequiredScope { + RequiredScope::ReadOnly + } + fn description(&self) -> &str { + "test command" + } + fn usage(&self) -> &str { + "test" + } + async fn execute( + &self, + _args: &[String], + _session: &mut GovernedSession, + ) -> Result { + Ok(CommandOutput::text("ok")) + } + } + + fn make_cmd( + name: &'static str, + aliases: Vec<&'static str>, + tier: CommandTier, + ) -> Arc { + Arc::new(TestCommand { + cmd_name: name, + cmd_aliases: aliases, + cmd_tier: tier, + }) + } + + #[test] + fn register_and_lookup_by_name() { + let mut reg = CommandRegistry::new(); + reg.register(make_cmd("status", vec![], CommandTier::Analyst)); + assert!(reg.get("status").is_some()); + assert_eq!(reg.get("status").unwrap().name(), "status"); + } + + #[test] + fn lookup_by_alias() { + let mut reg = CommandRegistry::new(); + reg.register(make_cmd("query", vec!["q"], CommandTier::Analyst)); + assert!(reg.get("q").is_some()); + assert_eq!(reg.get("q").unwrap().name(), "query"); + } + + #[test] + fn unknown_command_returns_none() { + let reg = CommandRegistry::new(); + assert!(reg.get("nonexistent").is_none()); + } + + #[test] + fn list_by_tier_groups_correctly() { + let mut reg = CommandRegistry::new(); + reg.register(make_cmd("status", vec![], CommandTier::Analyst)); + reg.register(make_cmd("query", vec![], CommandTier::Analyst)); + reg.register(make_cmd("approve", vec![], CommandTier::Administrator)); + reg.register(make_cmd("deploy", vec![], CommandTier::Engineer)); + + let grouped = reg.list_by_tier(); + assert_eq!(grouped.get(&CommandTier::Analyst).unwrap().len(), 2); + assert_eq!(grouped.get(&CommandTier::Administrator).unwrap().len(), 1); + assert_eq!(grouped.get(&CommandTier::Engineer).unwrap().len(), 1); + } + + #[test] + fn apply_manifest_filters_commands() { + let mut reg = CommandRegistry::new(); + reg.register(make_cmd("status", vec![], CommandTier::Analyst)); + reg.register(make_cmd("query", vec!["q"], CommandTier::Analyst)); + reg.register(make_cmd("deploy", vec![], CommandTier::Engineer)); + + let manifest = CommandManifest { + image_tier: "analyst".to_string(), + org_name: None, + commands: vec![ + super::super::manifest::CommandEntry { + name: "status".to_string(), + tier: "analyst".to_string(), + builtin: true, + description: None, + }, + super::super::manifest::CommandEntry { + name: "query".to_string(), + tier: "analyst".to_string(), + builtin: true, + description: None, + }, + ], + }; + + reg.apply_manifest(&manifest); + assert!(reg.get("status").is_some()); + assert!(reg.get("query").is_some()); + assert!(reg.get("deploy").is_none()); // filtered out + assert!(reg.get("q").is_some()); // alias still works + } + + #[test] + fn command_names_sorted() { + let mut reg = CommandRegistry::new(); + reg.register(make_cmd("status", vec![], CommandTier::Analyst)); + reg.register(make_cmd("approve", vec![], CommandTier::Administrator)); + reg.register(make_cmd("deploy", vec![], CommandTier::Engineer)); + + let names = reg.command_names(); + assert_eq!(names, vec!["approve", "deploy", "status"]); + } +} diff --git a/bascule-shell/src/governed/render.rs b/bascule-shell/src/governed/render.rs new file mode 100644 index 0000000..0cd2af2 --- /dev/null +++ b/bascule-shell/src/governed/render.rs @@ -0,0 +1,189 @@ +//! Terminal output renderer with ANSI colors and table formatting. + +use super::command_trait::{CommandOutput, OutputColor, OutputLine}; + +/// Renders structured command output to a string buffer. +pub struct OutputRenderer { + pub use_color: bool, + pub terminal_width: u16, +} + +impl Default for OutputRenderer { + fn default() -> Self { + Self { + use_color: true, + terminal_width: 80, + } + } +} + +impl OutputRenderer { + /// Render a CommandOutput to a displayable string. + pub fn render(&self, output: &CommandOutput) -> String { + let mut buf = String::new(); + for line in &output.lines { + match line { + OutputLine::Text(s) => { + buf.push_str(s); + buf.push('\n'); + } + OutputLine::Table { headers, rows } => { + buf.push_str(&self.render_table(headers, rows)); + } + OutputLine::Status { + label, + value, + color, + } => { + let colored = self.colorize(value, *color); + buf.push_str(&format!(" {}: {}\n", label, colored)); + } + OutputLine::Separator => { + let width = self.terminal_width.min(80) as usize; + for _ in 0..width { + buf.push('-'); + } + buf.push('\n'); + } + OutputLine::Blank => { + buf.push('\n'); + } + } + } + buf + } + + fn render_table(&self, headers: &[String], rows: &[Vec]) -> String { + if headers.is_empty() { + return String::new(); + } + + // Calculate column widths. + let mut widths: Vec = headers.iter().map(|h| h.len()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i < widths.len() { + widths[i] = widths[i].max(cell.len()); + } + } + } + + let mut buf = String::new(); + + // Header row. + for (i, header) in headers.iter().enumerate() { + if i > 0 { + buf.push_str(" "); + } + let colored = self.colorize( + &format!("{:width$}", header, width = widths[i]), + OutputColor::Cyan, + ); + buf.push_str(&colored); + } + buf.push('\n'); + + // Data rows. + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i > 0 { + buf.push_str(" "); + } + buf.push_str(&format!("{:width$}", cell, width = widths.get(i).copied().unwrap_or(0))); + } + buf.push('\n'); + } + + buf + } + + fn colorize(&self, text: &str, color: OutputColor) -> String { + if !self.use_color { + return text.to_string(); + } + match color { + OutputColor::Default => text.to_string(), + OutputColor::Green => format!("\x1b[32m{}\x1b[0m", text), + OutputColor::Yellow => format!("\x1b[33m{}\x1b[0m", text), + OutputColor::Red => format!("\x1b[31m{}\x1b[0m", text), + OutputColor::Cyan => format!("\x1b[36m{}\x1b[0m", text), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_text_line() { + let renderer = OutputRenderer { + use_color: false, + ..Default::default() + }; + let output = CommandOutput { + lines: vec![OutputLine::Text("hello world".to_string())], + }; + assert_eq!(renderer.render(&output), "hello world\n"); + } + + #[test] + fn render_status_no_color() { + let renderer = OutputRenderer { + use_color: false, + ..Default::default() + }; + let output = CommandOutput { + lines: vec![OutputLine::Status { + label: "State".to_string(), + value: "active".to_string(), + color: OutputColor::Green, + }], + }; + assert_eq!(renderer.render(&output), " State: active\n"); + } + + #[test] + fn render_table() { + let renderer = OutputRenderer { + use_color: false, + ..Default::default() + }; + let output = CommandOutput { + lines: vec![OutputLine::Table { + headers: vec!["NAME".to_string(), "STATUS".to_string()], + rows: vec![ + vec!["foo".to_string(), "active".to_string()], + vec!["bar".to_string(), "pending".to_string()], + ], + }], + }; + let rendered = renderer.render(&output); + assert!(rendered.contains("NAME")); + assert!(rendered.contains("foo")); + assert!(rendered.contains("bar")); + assert!(rendered.contains("active")); + } + + #[test] + fn render_separator() { + let renderer = OutputRenderer { + use_color: false, + terminal_width: 40, + }; + let output = CommandOutput { + lines: vec![OutputLine::Separator], + }; + let rendered = renderer.render(&output); + assert_eq!(rendered.trim().len(), 40); + } + + #[test] + fn colorize_green() { + let renderer = OutputRenderer::default(); + let colored = renderer.colorize("ok", OutputColor::Green); + assert!(colored.contains("\x1b[32m")); + assert!(colored.contains("ok")); + assert!(colored.contains("\x1b[0m")); + } +} diff --git a/bascule-shell/src/governed/session.rs b/bascule-shell/src/governed/session.rs new file mode 100644 index 0000000..aaebeda --- /dev/null +++ b/bascule-shell/src/governed/session.rs @@ -0,0 +1,416 @@ +//! Governed shell session — tracks operator state, tenant context, and SAT elevation. + +use std::net::SocketAddr; + +use chrono::{DateTime, Utc}; +use registry_protocol::sat::SatRef; + +use super::command_trait::{RequiredScope, ScopeCheck}; + +/// The identity of an operator authenticated via SSH. +#[derive(Debug, Clone)] +pub struct OperatorIdentity { + /// OIDC sub or SSH key fingerprint. + pub subject: String, + /// Human-readable display name. + pub display_name: String, + /// Guildhouse roles from identity registry. + pub roles: Vec, + /// How the operator authenticated. + pub auth_method: AuthMethod, +} + +/// Authentication method used by the operator. +#[derive(Debug, Clone)] +pub enum AuthMethod { + PublicKey { fingerprint: String }, + Token { issuer: String }, +} + +/// An active elevation grant with TTL. +#[derive(Debug, Clone)] +pub struct ElevatedSat { + pub sat: SatRef, + pub ceremony_id: String, + pub scopes: Vec, + pub granted_at: DateTime, + pub expires_at: DateTime, +} + +/// A single elevated scope grant. +#[derive(Debug, Clone)] +pub struct ElevatedScope { + pub registry: String, + pub verb: String, + pub tenant_id: String, +} + +/// Result of an elevation request. +#[derive(Debug, Clone)] +pub struct ElevationRequest { + pub ceremony_id: String, + pub ceremony_type: String, + pub expires_at: DateTime, +} + +/// Errors during elevation. +#[derive(Debug, thiserror::Error)] +pub enum ElevationError { + #[error("No tenant selected")] + NoTenantContext, + #[error("Elevation denied: {0}")] + Denied(String), + #[error("Elevation request expired")] + Expired, + #[error("Service error: {0}")] + ServiceError(String), +} + +/// A governed shell session with SAT lifecycle and tenant context. +pub struct GovernedSession { + // Identity + pub session_id: String, + pub identity: OperatorIdentity, + pub connected_at: DateTime, + pub source_addr: SocketAddr, + + // SAT lifecycle + pub base_sat: SatRef, + pub elevated_sat: Option, + + // Context + pub tenant_context: Option, + pub prompt_dirty: bool, + + // Audit + pub command_count: u64, + pub last_command_at: Option>, +} + +/// Trait for governed session operations (allows testing without live gRPC). +pub trait Governed { + fn session_id(&self) -> &str; + fn identity(&self) -> &OperatorIdentity; + fn tenant_context(&self) -> Option<&str>; + fn set_tenant_context(&mut self, tenant: Option); + fn can_execute(&self, required: &RequiredScope) -> ScopeCheck; + fn is_elevated(&self) -> bool; + fn deescalate(&mut self); + fn check_elevation_expiry(&mut self) -> bool; + fn prompt(&self) -> String; + fn base_sat_expired(&self) -> bool; + fn increment_command_count(&mut self); +} + +impl GovernedSession { + /// Create a new governed session. + pub fn new( + session_id: String, + identity: OperatorIdentity, + source_addr: SocketAddr, + base_sat: SatRef, + ) -> Self { + Self { + session_id, + identity, + connected_at: Utc::now(), + source_addr, + base_sat, + elevated_sat: None, + tenant_context: None, + prompt_dirty: false, + command_count: 0, + last_command_at: None, + } + } +} + +impl Governed for GovernedSession { + fn session_id(&self) -> &str { + &self.session_id + } + + fn identity(&self) -> &OperatorIdentity { + &self.identity + } + + fn tenant_context(&self) -> Option<&str> { + self.tenant_context.as_deref() + } + + fn set_tenant_context(&mut self, tenant: Option) { + self.tenant_context = tenant; + self.prompt_dirty = true; + } + + fn can_execute(&self, required: &RequiredScope) -> ScopeCheck { + match required { + RequiredScope::ReadOnly => { + if self.base_sat.is_expired() { + ScopeCheck::Expired + } else { + ScopeCheck::Authorized + } + } + RequiredScope::Elevated { registry, verb } => { + if let Some(elevated) = &self.elevated_sat { + if elevated.sat.is_expired() || Utc::now() > elevated.expires_at { + ScopeCheck::ElevationExpired + } else if elevated.scopes.iter().any(|s| { + s.registry == *registry + && s.verb == *verb + && (self.tenant_context.is_none() + || Some(s.tenant_id.as_str()) == self.tenant_context.as_deref()) + }) { + ScopeCheck::Authorized + } else { + ScopeCheck::ElevationRequired { + registry: registry.clone(), + verb: verb.clone(), + } + } + } else { + ScopeCheck::ElevationRequired { + registry: registry.clone(), + verb: verb.clone(), + } + } + } + } + } + + fn is_elevated(&self) -> bool { + self.elevated_sat.is_some() + } + + fn deescalate(&mut self) { + self.elevated_sat = None; + self.prompt_dirty = true; + } + + fn check_elevation_expiry(&mut self) -> bool { + if let Some(elevated) = &self.elevated_sat { + if Utc::now() > elevated.expires_at { + self.deescalate(); + return true; + } + } + false + } + + fn prompt(&self) -> String { + let tenant = self.tenant_context.as_deref().unwrap_or("*"); + let elevation = if let Some(e) = &self.elevated_sat { + let remaining = (e.expires_at - Utc::now()).num_seconds().max(0); + let mins = remaining / 60; + let secs = remaining % 60; + format!(" [ELEVATED {}m{}s]", mins, secs) + } else { + String::new() + }; + format!("guildhouse/{}{}> ", tenant, elevation) + } + + fn base_sat_expired(&self) -> bool { + self.base_sat.is_expired() + } + + fn increment_command_count(&mut self) { + self.command_count += 1; + self.last_command_at = Some(Utc::now()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_identity() -> OperatorIdentity { + OperatorIdentity { + subject: "tyler".to_string(), + display_name: "Tyler King".to_string(), + roles: vec!["engineer".to_string(), "msp-operator".to_string()], + auth_method: AuthMethod::PublicKey { + fingerprint: "SHA256:test123".to_string(), + }, + } + } + + fn test_sat(hours: i64) -> SatRef { + let now = Utc::now(); + SatRef { + sat_hash: [0u8; 32], + bearer_svid: "spiffe://test".to_string(), + scopes: vec![], + issued_at: now, + expires_at: now + chrono::Duration::hours(hours), + } + } + + fn expired_sat() -> SatRef { + let now = Utc::now(); + SatRef { + sat_hash: [0u8; 32], + bearer_svid: "spiffe://test".to_string(), + scopes: vec![], + issued_at: now - chrono::Duration::hours(2), + expires_at: now - chrono::Duration::hours(1), + } + } + + fn test_session(sat: SatRef) -> GovernedSession { + GovernedSession::new( + "sess-001".to_string(), + test_identity(), + "127.0.0.1:12345".parse().unwrap(), + sat, + ) + } + + #[test] + fn session_read_only_authorized_with_valid_sat() { + let session = test_session(test_sat(12)); + assert_eq!( + session.can_execute(&RequiredScope::ReadOnly), + ScopeCheck::Authorized + ); + } + + #[test] + fn session_read_only_denied_with_expired_sat() { + let session = test_session(expired_sat()); + assert_eq!( + session.can_execute(&RequiredScope::ReadOnly), + ScopeCheck::Expired + ); + } + + #[test] + fn session_elevated_scope_returns_elevation_required() { + let session = test_session(test_sat(12)); + let scope = RequiredScope::Elevated { + registry: "invoice".to_string(), + verb: "void".to_string(), + }; + assert_eq!( + session.can_execute(&scope), + ScopeCheck::ElevationRequired { + registry: "invoice".to_string(), + verb: "void".to_string(), + } + ); + } + + #[test] + fn session_elevated_scope_authorized_after_elevation() { + let mut session = test_session(test_sat(12)); + session.tenant_context = Some("tenant-a".to_string()); + session.elevated_sat = Some(ElevatedSat { + sat: test_sat(1), + ceremony_id: "cer-001".to_string(), + scopes: vec![ElevatedScope { + registry: "invoice".to_string(), + verb: "void".to_string(), + tenant_id: "tenant-a".to_string(), + }], + granted_at: Utc::now(), + expires_at: Utc::now() + chrono::Duration::minutes(15), + }); + + let scope = RequiredScope::Elevated { + registry: "invoice".to_string(), + verb: "void".to_string(), + }; + assert_eq!(session.can_execute(&scope), ScopeCheck::Authorized); + } + + #[test] + fn session_elevated_scope_denied_wrong_registry() { + let mut session = test_session(test_sat(12)); + session.tenant_context = Some("tenant-a".to_string()); + session.elevated_sat = Some(ElevatedSat { + sat: test_sat(1), + ceremony_id: "cer-001".to_string(), + scopes: vec![ElevatedScope { + registry: "invoice".to_string(), + verb: "void".to_string(), + tenant_id: "tenant-a".to_string(), + }], + granted_at: Utc::now(), + expires_at: Utc::now() + chrono::Duration::minutes(15), + }); + + let scope = RequiredScope::Elevated { + registry: "credential".to_string(), + verb: "revoke".to_string(), + }; + assert_eq!( + session.can_execute(&scope), + ScopeCheck::ElevationRequired { + registry: "credential".to_string(), + verb: "revoke".to_string(), + } + ); + } + + #[test] + fn elevation_ttl_expiry_deescalates() { + let mut session = test_session(test_sat(12)); + session.elevated_sat = Some(ElevatedSat { + sat: test_sat(1), + ceremony_id: "cer-001".to_string(), + scopes: vec![], + granted_at: Utc::now() - chrono::Duration::minutes(20), + expires_at: Utc::now() - chrono::Duration::seconds(1), + }); + + assert!(session.check_elevation_expiry()); + assert!(!session.is_elevated()); + } + + #[test] + fn deescalate_drops_elevation() { + let mut session = test_session(test_sat(12)); + session.elevated_sat = Some(ElevatedSat { + sat: test_sat(1), + ceremony_id: "cer-001".to_string(), + scopes: vec![], + granted_at: Utc::now(), + expires_at: Utc::now() + chrono::Duration::minutes(15), + }); + + assert!(session.is_elevated()); + session.deescalate(); + assert!(!session.is_elevated()); + } + + #[test] + fn prompt_no_tenant() { + let session = test_session(test_sat(12)); + assert_eq!(session.prompt(), "guildhouse/*> "); + } + + #[test] + fn prompt_with_tenant() { + let mut session = test_session(test_sat(12)); + session.set_tenant_context(Some("plumber-abc".to_string())); + assert_eq!(session.prompt(), "guildhouse/plumber-abc> "); + } + + #[test] + fn prompt_with_elevation() { + let mut session = test_session(test_sat(12)); + session.tenant_context = Some("plumber-abc".to_string()); + session.elevated_sat = Some(ElevatedSat { + sat: test_sat(1), + ceremony_id: "cer-001".to_string(), + scopes: vec![], + granted_at: Utc::now(), + expires_at: Utc::now() + chrono::Duration::minutes(15), + }); + + let prompt = session.prompt(); + assert!(prompt.starts_with("guildhouse/plumber-abc [ELEVATED")); + assert!(prompt.ends_with("> ")); + } +} diff --git a/bascule-shell/src/governed/ssh/config.rs b/bascule-shell/src/governed/ssh/config.rs new file mode 100644 index 0000000..efea86e --- /dev/null +++ b/bascule-shell/src/governed/ssh/config.rs @@ -0,0 +1,88 @@ +//! SSH server configuration. + +use std::net::SocketAddr; +use std::path::PathBuf; + +/// Configuration for the SSH server. +#[derive(Debug, Clone)] +pub struct SSHConfig { + /// Listen address for SSH connections. + pub listen_addr: SocketAddr, + /// Path to the Ed25519 host key. + pub host_key_path: PathBuf, + /// Path to the authorized keys YAML file. + pub authorized_keys_path: PathBuf, + /// Path to the command manifest YAML. + pub command_manifest_path: PathBuf, + /// gRPC address for GovernanceService. + pub governance_addr: String, + /// gRPC address for CeremonyService. + pub ceremony_addr: String, + /// Maximum session TTL in hours. + pub session_ttl_hours: u64, + /// Maximum elevation TTL in minutes. + pub max_elevation_ttl_minutes: u64, + /// Idle timeout before disconnect (minutes). + pub idle_timeout_minutes: u64, +} + +impl SSHConfig { + /// Load from environment variables. + pub fn from_env() -> Self { + Self { + listen_addr: std::env::var("BASCULE_SSH_ADDR") + .unwrap_or_else(|_| "0.0.0.0:2222".to_string()) + .parse() + .expect("invalid BASCULE_SSH_ADDR"), + host_key_path: PathBuf::from( + std::env::var("BASCULE_HOST_KEY") + .unwrap_or_else(|_| "/opt/bascule/ssh/host_ed25519".to_string()), + ), + authorized_keys_path: PathBuf::from( + std::env::var("BASCULE_AUTHORIZED_KEYS") + .unwrap_or_else(|_| "/opt/bascule/authorized_keys.yaml".to_string()), + ), + command_manifest_path: PathBuf::from( + std::env::var("BASCULE_MANIFEST") + .unwrap_or_else(|_| "/opt/bascule/commands/manifest.yaml".to_string()), + ), + governance_addr: std::env::var("BASCULE_GOVERNANCE_ADDR") + .unwrap_or_else(|_| "http://localhost:50051".to_string()), + ceremony_addr: std::env::var("BASCULE_CEREMONY_ADDR") + .unwrap_or_else(|_| "http://localhost:50052".to_string()), + session_ttl_hours: std::env::var("BASCULE_SESSION_TTL_HOURS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(12), + max_elevation_ttl_minutes: std::env::var("BASCULE_MAX_ELEVATION_TTL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30), + idle_timeout_minutes: std::env::var("BASCULE_IDLE_TIMEOUT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_parses() { + let config = SSHConfig { + listen_addr: "0.0.0.0:2222".parse().unwrap(), + host_key_path: PathBuf::from("/tmp/test_key"), + authorized_keys_path: PathBuf::from("/tmp/authorized_keys.yaml"), + command_manifest_path: PathBuf::from("/tmp/manifest.yaml"), + governance_addr: "http://localhost:50051".to_string(), + ceremony_addr: "http://localhost:50052".to_string(), + session_ttl_hours: 12, + max_elevation_ttl_minutes: 30, + idle_timeout_minutes: 60, + }; + assert_eq!(config.listen_addr.port(), 2222); + } +} diff --git a/bascule-shell/src/governed/ssh/handler.rs b/bascule-shell/src/governed/ssh/handler.rs new file mode 100644 index 0000000..bb33b0c --- /dev/null +++ b/bascule-shell/src/governed/ssh/handler.rs @@ -0,0 +1,331 @@ +//! SSH session handler — implements russh's Handler trait. + +use std::sync::Arc; + +use async_trait::async_trait; +use russh::server::{Auth, Handler, Msg, Session}; +use russh::{Channel, ChannelId}; + +use super::keys::AuthorizedKeyStore; +use crate::governed::commands; +use crate::governed::executor::{CommandExecutor, ExecResult}; +use crate::governed::manifest::CommandManifest; +use crate::governed::registry::CommandRegistry; +use crate::governed::render::OutputRenderer; +use crate::governed::session::{GovernedSession, Governed, OperatorIdentity}; + +/// Per-connection SSH handler. +pub struct ShellHandler { + key_store: Arc, + manifest: Arc, + /// The identity of the authenticated operator (set after auth). + identity: Option, + /// Terminal width for rendering. + terminal_width: u16, + /// Base SAT TTL in hours. + session_ttl_hours: u64, + /// Peer address string. + pub peer_addr: String, +} + +impl ShellHandler { + pub fn new( + key_store: Arc, + manifest: Arc, + session_ttl_hours: u64, + peer_addr: String, + ) -> Self { + Self { + key_store, + manifest, + identity: None, + terminal_width: 80, + session_ttl_hours, + peer_addr, + } + } + + /// Build the command executor for this session. + fn build_executor(&self) -> CommandExecutor { + let mut registry = CommandRegistry::new(); + commands::register_all_builtins(&mut registry); + registry.apply_manifest(&self.manifest); + CommandExecutor { + registry, + renderer: OutputRenderer { + use_color: true, + terminal_width: self.terminal_width, + }, + } + } + + /// Build a governed session for the authenticated operator. + fn build_session(&self) -> GovernedSession { + let identity = self.identity.clone().expect("identity must be set after auth"); + let addr = self.peer_addr.parse().unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap()); + + let now = chrono::Utc::now(); + let base_sat = registry_protocol::sat::SatRef { + sat_hash: [0u8; 32], + bearer_svid: format!("spiffe://guildhouse/shell/{}", identity.subject), + scopes: vec![registry_protocol::sat::SatScope { + registry_type: "*".to_string(), + verbs: vec!["get".to_string(), "list".to_string()], + resource_pattern: "*".to_string(), + }], + issued_at: now, + expires_at: now + chrono::Duration::hours(self.session_ttl_hours as i64), + }; + + GovernedSession::new( + uuid::Uuid::new_v4().to_string(), + identity, + addr, + base_sat, + ) + } +} + +/// Shared state for an active shell session. +pub struct SessionState { + pub executor: CommandExecutor, + pub session: GovernedSession, + pub input_buffer: String, + pub channel_id: Option, +} + +impl SessionState { + /// Process accumulated input when a newline is received. + pub async fn process_input(&mut self, handle: &russh::server::Handle) -> bool { + let channel_id = match self.channel_id { + Some(id) => id, + None => return false, + }; + + let line = self.input_buffer.trim().to_string(); + self.input_buffer.clear(); + + if line == "exit" || line == "quit" { + let _ = handle.data(channel_id, "Goodbye.\r\n".to_string().into()).await; + let _ = handle.close(channel_id).await; + return true; + } + + if line.is_empty() { + let prompt = self.session.prompt(); + let _ = handle.data(channel_id, prompt.into()).await; + return false; + } + + let result = self.executor.process_line(&line, &mut self.session).await; + let output = match result { + ExecResult::Output(s) => s, + ExecResult::Exit => { + let _ = handle.data(channel_id, "Goodbye.\r\n".to_string().into()).await; + let _ = handle.close(channel_id).await; + return true; + } + ExecResult::ElevationNeeded { registry, verb } => { + format!("Elevation required: {}:{}. Use 'elevate {} {}' first.\r\n", registry, verb, registry, verb) + } + ExecResult::Error(s) => format!("Error: {}\r\n", s), + ExecResult::SessionExpired => { + let _ = handle.data(channel_id, "Session expired. Disconnecting.\r\n".to_string().into()).await; + let _ = handle.close(channel_id).await; + return true; + } + ExecResult::ElevationExpired => { + "Elevation expired. Returning to read-only scope.\r\n".to_string() + } + ExecResult::Empty => String::new(), + }; + + if !output.is_empty() { + let terminal_output = output.replace('\n', "\r\n"); + let _ = handle.data(channel_id, terminal_output.into()).await; + } + + let prompt = self.session.prompt(); + let _ = handle.data(channel_id, prompt.into()).await; + false + } +} + +#[async_trait] +impl Handler for ShellHandler { + type Error = anyhow::Error; + + async fn auth_publickey( + &mut self, + _user: &str, + public_key: &ssh_key::PublicKey, + ) -> Result { + let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string(); + tracing::info!(fingerprint = %fingerprint, "SSH auth attempt"); + + match self.key_store.authenticate(&fingerprint) { + Some(key) => { + self.identity = Some(AuthorizedKeyStore::to_identity(key)); + tracing::info!(subject = %key.subject, "SSH auth accepted"); + Ok(Auth::Accept) + } + None => { + tracing::warn!(fingerprint = %fingerprint, "SSH auth rejected: unknown key"); + Ok(Auth::Reject { + proceed_with_methods: None, + }) + } + } + } + + async fn channel_open_session( + &mut self, + _channel: Channel, + _session: &mut Session, + ) -> Result { + Ok(true) + } + + async fn pty_request( + &mut self, + _channel: ChannelId, + _term: &str, + col_width: u32, + _row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(russh::Pty, u32)], + session: &mut Session, + ) -> Result<(), Self::Error> { + self.terminal_width = col_width.min(200) as u16; + session.request_success(); + Ok(()) + } + + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + session.request_success(); + + let executor = self.build_executor(); + let governed_session = self.build_session(); + + let banner = executor.welcome_banner(&governed_session); + let prompt = governed_session.prompt(); + session.data(channel, format!("{}{}", banner, prompt).into())?; + + Ok(()) + } + + async fn data( + &mut self, + _channel: ChannelId, + _data: &[u8], + _session: &mut Session, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::governed::session::AuthMethod; + + fn test_manifest() -> CommandManifest { + CommandManifest::default_analyst() + } + + fn test_key_store() -> AuthorizedKeyStore { + AuthorizedKeyStore::from_yaml(r#" +keys: + - fingerprint: "SHA256:test123" + subject: "tyler" + display_name: "Tyler King" + roles: [engineer] +"#).unwrap() + } + + #[test] + fn handler_builds_executor_with_manifest_filtering() { + let handler = ShellHandler::new( + Arc::new(test_key_store()), + Arc::new(test_manifest()), + 12, + "127.0.0.1:12345".to_string(), + ); + let executor = handler.build_executor(); + assert!(executor.registry.get("status").is_some()); + assert!(executor.registry.get("help").is_some()); + assert!(executor.registry.get("deploy").is_none()); + } + + #[test] + fn handler_builds_session_with_identity() { + let mut handler = ShellHandler::new( + Arc::new(test_key_store()), + Arc::new(test_manifest()), + 12, + "127.0.0.1:12345".to_string(), + ); + handler.identity = Some(OperatorIdentity { + subject: "tyler".to_string(), + display_name: "Tyler King".to_string(), + roles: vec!["engineer".to_string()], + auth_method: AuthMethod::PublicKey { + fingerprint: "SHA256:test123".to_string(), + }, + }); + + let session = handler.build_session(); + assert_eq!(session.identity.display_name, "Tyler King"); + assert!(!session.base_sat_expired()); + } + + #[test] + fn session_state_without_channel_noop() { + let mut registry = CommandRegistry::new(); + commands::register_all_builtins(&mut registry); + + let executor = CommandExecutor { + registry, + renderer: OutputRenderer { use_color: false, ..Default::default() }, + }; + + let identity = OperatorIdentity { + subject: "tyler".to_string(), + display_name: "Tyler King".to_string(), + roles: vec!["engineer".to_string()], + auth_method: AuthMethod::PublicKey { + fingerprint: "SHA256:test".to_string(), + }, + }; + + let now = chrono::Utc::now(); + let sat = registry_protocol::sat::SatRef { + sat_hash: [0u8; 32], + bearer_svid: "spiffe://test".to_string(), + scopes: vec![], + issued_at: now, + expires_at: now + chrono::Duration::hours(12), + }; + + let session = GovernedSession::new( + "sess-test".to_string(), + identity, + "127.0.0.1:12345".parse().unwrap(), + sat, + ); + + let state = SessionState { + executor, + session, + input_buffer: String::new(), + channel_id: None, + }; + + assert!(state.channel_id.is_none()); + } +} diff --git a/bascule-shell/src/governed/ssh/keys.rs b/bascule-shell/src/governed/ssh/keys.rs new file mode 100644 index 0000000..654ffd7 --- /dev/null +++ b/bascule-shell/src/governed/ssh/keys.rs @@ -0,0 +1,168 @@ +//! Authorized key management — maps SSH public keys to operator identities. + +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +use crate::governed::session::{AuthMethod, OperatorIdentity}; + +/// Store of authorized SSH public keys with identity mappings. +pub struct AuthorizedKeyStore { + keys: HashMap, +} + +/// An authorized key entry. +#[derive(Debug, Clone)] +pub struct AuthorizedKey { + pub fingerprint: String, + pub subject: String, + pub display_name: String, + pub roles: Vec, +} + +/// YAML format for the authorized keys file. +#[derive(Debug, Deserialize)] +struct AuthorizedKeysFile { + keys: Vec, +} + +#[derive(Debug, Deserialize)] +struct KeyEntry { + fingerprint: String, + subject: String, + display_name: String, + roles: Vec, +} + +impl AuthorizedKeyStore { + pub fn new() -> Self { + Self { + keys: HashMap::new(), + } + } + + /// Load from a YAML file. + pub fn from_file(path: &Path) -> anyhow::Result { + let contents = std::fs::read_to_string(path)?; + Self::from_yaml(&contents) + } + + /// Load from a YAML string. + pub fn from_yaml(yaml: &str) -> anyhow::Result { + let file: AuthorizedKeysFile = serde_yaml::from_str(yaml)?; + let mut store = Self::new(); + for entry in file.keys { + store.keys.insert( + entry.fingerprint.clone(), + AuthorizedKey { + fingerprint: entry.fingerprint, + subject: entry.subject, + display_name: entry.display_name, + roles: entry.roles, + }, + ); + } + Ok(store) + } + + /// Look up by SSH key fingerprint (e.g., "SHA256:abcdef..."). + pub fn authenticate(&self, fingerprint: &str) -> Option<&AuthorizedKey> { + self.keys.get(fingerprint) + } + + /// Convert an authorized key entry to an OperatorIdentity. + pub fn to_identity(key: &AuthorizedKey) -> OperatorIdentity { + OperatorIdentity { + subject: key.subject.clone(), + display_name: key.display_name.clone(), + roles: key.roles.clone(), + auth_method: AuthMethod::PublicKey { + fingerprint: key.fingerprint.clone(), + }, + } + } + + /// Number of authorized keys. + pub fn len(&self) -> usize { + self.keys.len() + } + + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_from_yaml() { + let yaml = r#" +keys: + - fingerprint: "SHA256:abc123" + subject: "tyler" + display_name: "Tyler King" + roles: [engineer, msp-operator] + - fingerprint: "SHA256:def456" + subject: "alice" + display_name: "Alice Ops" + roles: [analyst] +"#; + let store = AuthorizedKeyStore::from_yaml(yaml).unwrap(); + assert_eq!(store.len(), 2); + } + + #[test] + fn authenticate_known_key() { + let yaml = r#" +keys: + - fingerprint: "SHA256:abc123" + subject: "tyler" + display_name: "Tyler King" + roles: [engineer] +"#; + let store = AuthorizedKeyStore::from_yaml(yaml).unwrap(); + let key = store.authenticate("SHA256:abc123"); + assert!(key.is_some()); + assert_eq!(key.unwrap().subject, "tyler"); + } + + #[test] + fn authenticate_unknown_key_returns_none() { + let yaml = r#" +keys: + - fingerprint: "SHA256:abc123" + subject: "tyler" + display_name: "Tyler King" + roles: [engineer] +"#; + let store = AuthorizedKeyStore::from_yaml(yaml).unwrap(); + assert!(store.authenticate("SHA256:unknown").is_none()); + } + + #[test] + fn to_identity_converts_correctly() { + let key = AuthorizedKey { + fingerprint: "SHA256:abc123".to_string(), + subject: "tyler".to_string(), + display_name: "Tyler King".to_string(), + roles: vec!["engineer".to_string()], + }; + let identity = AuthorizedKeyStore::to_identity(&key); + assert_eq!(identity.subject, "tyler"); + assert_eq!(identity.display_name, "Tyler King"); + assert_eq!(identity.roles, vec!["engineer"]); + match identity.auth_method { + AuthMethod::PublicKey { fingerprint } => assert_eq!(fingerprint, "SHA256:abc123"), + _ => panic!("expected PublicKey"), + } + } + + #[test] + fn from_file_missing_returns_error() { + let result = AuthorizedKeyStore::from_file(Path::new("/nonexistent/keys.yaml")); + assert!(result.is_err()); + } +} diff --git a/bascule-shell/src/governed/ssh/mod.rs b/bascule-shell/src/governed/ssh/mod.rs new file mode 100644 index 0000000..c2a1501 --- /dev/null +++ b/bascule-shell/src/governed/ssh/mod.rs @@ -0,0 +1,6 @@ +//! SSH server module — native SSH transport via russh. + +pub mod config; +pub mod handler; +pub mod keys; +pub mod server; diff --git a/bascule-shell/src/governed/ssh/server.rs b/bascule-shell/src/governed/ssh/server.rs new file mode 100644 index 0000000..ca90857 --- /dev/null +++ b/bascule-shell/src/governed/ssh/server.rs @@ -0,0 +1,122 @@ +//! SSH server — listens for connections and spawns shell sessions. + +use std::sync::Arc; + +use russh::server::{Config, Server}; +use ssh_key::{Algorithm, PrivateKey}; + +use super::config::SSHConfig; +use super::handler::ShellHandler; +use super::keys::AuthorizedKeyStore; +use crate::governed::manifest::CommandManifest; + +/// The Bascule SSH server. +pub struct BasculeSSHServer { + config: Arc, + key_store: Arc, + manifest: Arc, + ssh_config: SSHConfig, +} + +impl BasculeSSHServer { + /// Create a new SSH server. + pub fn new( + ssh_config: SSHConfig, + key_store: AuthorizedKeyStore, + manifest: CommandManifest, + ) -> anyhow::Result { + // Load or generate the host key. + let host_key = if ssh_config.host_key_path.exists() { + tracing::info!(path = %ssh_config.host_key_path.display(), "Loading host key"); + russh_keys::load_secret_key(&ssh_config.host_key_path, None)? + } else { + tracing::info!("Generating ephemeral Ed25519 host key"); + let mut rng = rand::thread_rng(); + PrivateKey::random(&mut rng, Algorithm::Ed25519)? + }; + + let config = Config { + keys: vec![host_key], + ..Default::default() + }; + + Ok(Self { + config: Arc::new(config), + key_store: Arc::new(key_store), + manifest: Arc::new(manifest), + ssh_config, + }) + } + + /// Run the SSH server, accepting connections. + pub async fn run(mut self) -> anyhow::Result<()> { + tracing::info!( + addr = %self.ssh_config.listen_addr, + keys = self.key_store.len(), + "Starting Bascule SSH server" + ); + + self.run_on_address(self.config.clone(), self.ssh_config.listen_addr) + .await?; + + Ok(()) + } +} + +impl russh::server::Server for BasculeSSHServer { + type Handler = ShellHandler; + + fn new_client(&mut self, peer_addr: Option) -> ShellHandler { + let addr = peer_addr + .map(|a| a.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + tracing::info!(peer = %addr, "New SSH connection"); + + ShellHandler::new( + self.key_store.clone(), + self.manifest.clone(), + self.ssh_config.session_ttl_hours, + addr, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn test_config() -> SSHConfig { + SSHConfig { + listen_addr: "127.0.0.1:0".parse().unwrap(), + host_key_path: PathBuf::from("/tmp/nonexistent_key"), + authorized_keys_path: PathBuf::from("/tmp/test_keys.yaml"), + command_manifest_path: PathBuf::from("/tmp/test_manifest.yaml"), + governance_addr: "http://localhost:50051".to_string(), + ceremony_addr: "http://localhost:50052".to_string(), + session_ttl_hours: 12, + max_elevation_ttl_minutes: 30, + idle_timeout_minutes: 60, + } + } + + #[test] + fn server_creates_with_ephemeral_key() { + let key_store = AuthorizedKeyStore::new(); + let manifest = CommandManifest::default_analyst(); + let server = BasculeSSHServer::new(test_config(), key_store, manifest); + assert!(server.is_ok()); + } + + #[test] + fn server_new_client_returns_handler() { + let key_store = AuthorizedKeyStore::new(); + let manifest = CommandManifest::default_analyst(); + let mut server = BasculeSSHServer::new(test_config(), key_store, manifest).unwrap(); + + let handler = server.new_client(Some("127.0.0.1:12345".parse().unwrap())); + // Handler is created successfully. + assert_eq!(handler.peer_addr, "127.0.0.1:12345"); + } +} diff --git a/bascule-shell/src/lib.rs b/bascule-shell/src/lib.rs new file mode 100644 index 0000000..94fff68 --- /dev/null +++ b/bascule-shell/src/lib.rs @@ -0,0 +1,3 @@ +//! Bascule governance shell — library interface for governed SSH runtime. + +pub mod governed; diff --git a/bascule-shell/src/main.rs b/bascule-shell/src/main.rs new file mode 100644 index 0000000..08f8d72 --- /dev/null +++ b/bascule-shell/src/main.rs @@ -0,0 +1,181 @@ +mod attach; +mod auth; +mod commands; +mod config; +mod output; +mod repl; +mod session; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "bascule")] +#[command(about = "Bascule governance shell — governed access to cluster resources")] +#[command(version)] +struct Cli { + /// Gateway gRPC address + #[arg(long, global = true, default_value = "http://localhost:50052")] + gateway: String, + + /// Output format (table, json) + #[arg(long, global = true, default_value = "table")] + output: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Authenticate with the gateway via OIDC + Connect(auth::ConnectArgs), + /// Session management + Session { + #[command(subcommand)] + action: session::SessionAction, + }, + /// Start interactive REPL + Shell, + /// Execute a single command against the gateway + Exec(commands::ExecArgs), + /// Attach to a governed workspace filesystem + Attach { + /// Workspace name (e.g., "workspace/collab-project" or "collab-project") + workspace: String, + }, + /// Stream logs from one or more pods + /// + /// Examples: + /// bascule tail pod/api-svc + /// bascule tail pod/api-svc --filter "ERROR" + /// bascule tail pod/api-svc --lines 100 + /// bascule tail pod/api-svc --no-follow + Tail { + /// Pod targets. Format: pod/{name} or pod/{ns}/{name} + #[arg(required = true)] + targets: Vec, + + /// Regex filter patterns (ANDed — line must match ALL) + #[arg(long = "filter", short = 'f')] + filters: Vec, + + /// Initial lines to show per pod + #[arg(long, default_value = "50")] + lines: u32, + + /// Stop after initial lines (don't follow) + #[arg(long = "no-follow")] + no_follow: bool, + }, + /// Governed collaboration commands (dispatches to forge binary). + /// + /// Examples: + /// bascule forge propose "fix: thing" + /// bascule forge status + /// bascule forge log --json + Forge { + /// forge subcommand + args (passed through) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .init(); + + let cli = Cli::parse(); + + match cli.command { + Commands::Connect(args) => auth::connect(args, &cli.gateway).await?, + Commands::Session { action } => session::handle(action, &cli.gateway).await?, + Commands::Shell => repl::run(&cli.gateway, &cli.output).await?, + Commands::Exec(args) => commands::exec(args, &cli.gateway, &cli.output).await?, + Commands::Attach { workspace } => { + let name = workspace + .strip_prefix("workspace/") + .unwrap_or(&workspace) + .to_string(); + attach::run_attach(attach::AttachContext::from_env(name)).await?; + } + Commands::Tail { + targets, + filters, + lines, + no_follow, + } => { + // bascule-tail is a standalone governed command module — + // invoked via exec(), not compiled into bascule-shell. + // + // Phase A: locate on PATH. + // Phase B: verify binary hash against Guildhouse Catalog before exec. + let store = config::TokenStore::load()?; + let session_id = store + .session_id + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!("No active session. Run `bascule session request` first.") + })?; + + let binary = which::which("bascule-tail").map_err(|_| { + anyhow::anyhow!( + "bascule-tail not found.\n\ + Install the bascule-commands package or ensure bascule-tail is on PATH." + ) + })?; + + let mut cmd = std::process::Command::new(&binary); + + // Forward session context + cmd.arg("--session-id") + .arg(session_id) + .arg("--token") + .arg(&store.access_token) + .arg("--gateway") + .arg(&cli.gateway); + + // Forward user args + for t in &targets { + cmd.arg(t); + } + for f in &filters { + cmd.arg("--filter").arg(f); + } + if no_follow { + cmd.arg("--no-follow"); + } + cmd.arg("--lines").arg(lines.to_string()); + + // exec() replaces this process — user sees bascule-tail directly. + use std::os::unix::process::CommandExt; + return Err(cmd.exec().into()); + } + Commands::Forge { args } => { + // forge is a standalone governed collaboration CLI — + // invoked via exec(), not compiled into bascule-shell. + // Session context is already in environment from bascule attach. + let binary = which::which("forge").map_err(|_| { + anyhow::anyhow!( + "forge not found.\nInstall forge or ensure it is on PATH." + ) + })?; + + let mut cmd = std::process::Command::new(&binary); + cmd.args(&args); + + use std::os::unix::process::CommandExt; + return Err(cmd.exec().into()); + } + } + + Ok(()) +} diff --git a/bascule-shell/src/output.rs b/bascule-shell/src/output.rs new file mode 100644 index 0000000..dfb7480 --- /dev/null +++ b/bascule-shell/src/output.rs @@ -0,0 +1,27 @@ +use bascule_proto::bascule_v1::ExecuteCommandResponse; + +/// Print a command response to stdout. +pub fn print_command_response(response: &ExecuteCommandResponse, _format: &str) { + if !response.allowed { + eprintln!("DENIED: {}", response.denied_reason); + return; + } + + match &response.result { + Some(bascule_proto::bascule_v1::execute_command_response::Result::Success(result)) => { + print!("{}", result.output); + if !result.output.ends_with('\n') { + println!(); + } + if result.session_expired_warning { + eprintln!("\nWarning: session has expired. No further commands will be accepted."); + } + } + Some(bascule_proto::bascule_v1::execute_command_response::Result::Error(err)) => { + eprintln!("Error [{}]: {}", err.code, err.message); + } + None => { + eprintln!("No result in response."); + } + } +} diff --git a/bascule-shell/src/repl.rs b/bascule-shell/src/repl.rs new file mode 100644 index 0000000..4ff49bc --- /dev/null +++ b/bascule-shell/src/repl.rs @@ -0,0 +1,143 @@ +use std::io::{self, Write}; + +use bascule_proto::bascule_v1::bascule_gateway_client::BasculeGatewayClient; +use tonic::transport::Channel; + +use crate::commands; +use crate::config::TokenStore; +use crate::output; + +/// Run the interactive REPL. +pub async fn run(gateway: &str, output_format: &str) -> anyhow::Result<()> { + let store = TokenStore::load()?; + let session_id = store.session_id.as_ref().ok_or_else(|| { + anyhow::anyhow!("No active session. Run `bascule session request` first.") + })?; + + let mut client = connect_gateway(gateway).await?; + + println!("Bascule shell — type `help` for commands, `exit` to quit."); + println!("Session: {session_id}\n"); + + let mut stdout = io::stdout(); + let stdin = io::stdin(); + + loop { + print!("bascule> "); + stdout.flush()?; + + let mut line = String::new(); + let n = stdin.read_line(&mut line)?; + if n == 0 { + // EOF + println!(); + break; + } + + let line = line.trim(); + if line.is_empty() { + continue; + } + + match line { + "exit" | "quit" => break, + "help" => { + print_help(); + continue; + } + "session status" => { + print_session_status(&mut client, session_id, &store.access_token).await; + continue; + } + "session end" => { + println!("Use `bascule session end` from the CLI to end your session."); + continue; + } + _ => {} + } + + let parsed = match commands::parse_line(line) { + Some(p) => p, + None => continue, + }; + + let request = authenticated_request( + bascule_proto::bascule_v1::ExecuteCommandRequest { + session_id: session_id.clone(), + verb: parsed.verb, + namespace: parsed.namespace, + resource_type: parsed.resource_type, + resource_name: parsed.resource_name, + parameters: None, + output_format: output_format.to_string(), + }, + &store.access_token, + ); + + match client.execute_command(request).await { + Ok(response) => { + output::print_command_response(&response.into_inner(), output_format); + } + Err(status) => { + eprintln!("Error: {}", status.message()); + } + } + } + + Ok(()) +} + +fn print_help() { + println!("Available commands:"); + println!(" get [name] [-n namespace] Get resources"); + println!(" describe [-n namespace] Describe a resource"); + println!(" logs [-n namespace] View pod logs"); + println!(" status Cluster health summary"); + println!(" session status Show session info"); + println!(" help Show this help"); + println!(" exit Exit the shell"); +} + +async fn print_session_status( + client: &mut BasculeGatewayClient, + session_id: &str, + token: &str, +) { + let request = authenticated_request( + bascule_proto::bascule_v1::GetSessionStatusRequest { + session_id: session_id.to_string(), + }, + token, + ); + + match client.get_session_status(request).await { + Ok(response) => { + let status = response.into_inner(); + println!("Session: {}", status.session_id); + println!(" State: {}", status.state); + println!( + " Mutations: {}/{}", + status.mutations_used, + status + .mutation_budget + .map(|b| b.to_string()) + .unwrap_or("unlimited".into()) + ); + } + Err(e) => eprintln!("Error: {}", e.message()), + } +} + +async fn connect_gateway(addr: &str) -> anyhow::Result> { + let channel = Channel::from_shared(addr.to_string())? + .connect() + .await?; + Ok(BasculeGatewayClient::new(channel)) +} + +fn authenticated_request(inner: T, token: &str) -> tonic::Request { + let mut request = tonic::Request::new(inner); + let val: tonic::metadata::MetadataValue<_> = format!("Bearer {token}").parse().unwrap(); + request.metadata_mut().insert("authorization", val); + request +} diff --git a/bascule-shell/src/session.rs b/bascule-shell/src/session.rs new file mode 100644 index 0000000..452d07a --- /dev/null +++ b/bascule-shell/src/session.rs @@ -0,0 +1,178 @@ +use bascule_proto::bascule_v1::bascule_gateway_client::BasculeGatewayClient; +use clap::Subcommand; +use tonic::transport::Channel; + +use crate::config::TokenStore; + +#[derive(Subcommand)] +pub enum SessionAction { + /// Request a new session via ceremony + Request { + /// Ceremony type (self_grant) + #[arg(long, default_value = "self_grant")] + r#type: String, + + /// Namespaces to request access to (comma-separated) + #[arg(long, default_value = "default")] + namespaces: String, + + /// Verbs to request (comma-separated) + #[arg(long, default_value = "get,list,logs")] + verbs: String, + }, + /// Show current session status + Status, + /// End the current session + End, +} + +pub async fn handle(action: SessionAction, gateway: &str) -> anyhow::Result<()> { + let mut store = TokenStore::load()?; + let mut client = connect_gateway(gateway).await?; + + match action { + SessionAction::Request { + r#type, + namespaces, + verbs, + } => { + let ns_list: Vec = namespaces.split(',').map(|s| s.trim().to_string()).collect(); + let verb_list: Vec = verbs.split(',').map(|s| s.trim().to_string()).collect(); + + let scope = bascule_proto::bascule_v1::SessionScope { + namespaces: ns_list + .iter() + .map(|ns| bascule_proto::bascule_v1::NamespaceScope { + namespace: ns.clone(), + rules: vec![bascule_proto::bascule_v1::ScopeRule { + api_groups: vec!["".into(), "apps".into(), "batch".into()], + resources: vec!["*".into()], + verbs: verb_list.clone(), + }], + workload_profiles: vec![], + denied_capabilities: vec![], + }) + .collect(), + global: Some(bascule_proto::bascule_v1::GlobalScope { + can_view_audit_trail: true, + can_view_profiles: true, + can_view_topology: true, + }), + pathways: vec!["dry_run_only".into()], + mutation_budget: None, + can_delegate: false, + }; + + let request = authenticated_request( + bascule_proto::bascule_v1::RequestSessionRequest { + ceremony_type: r#type.clone(), + requested_scope: Some(scope), + evidence: vec![], + }, + &store.access_token, + ); + + let response = client.request_session(request).await?; + let inner = response.into_inner(); + + match inner.result { + Some(bascule_proto::bascule_v1::request_session_response::Result::Granted(granted)) => { + store.session_id = Some(granted.session_id.clone()); + store.save()?; + + println!("Session granted!"); + println!(" Session ID: {}", granted.session_id); + println!(" Ceremony ID: {}", granted.ceremony_id); + if let Some(expires) = granted.expires_at { + let dt = chrono::DateTime::from_timestamp(expires.seconds, 0) + .map(|d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "unknown".into()); + println!(" Expires: {dt}"); + } + } + Some(bascule_proto::bascule_v1::request_session_response::Result::Pending(pending)) => { + println!("Ceremony pending approval."); + println!(" Ceremony ID: {}", pending.ceremony_id); + println!(" {}", pending.message); + } + Some(bascule_proto::bascule_v1::request_session_response::Result::Denied(denied)) => { + println!("Session denied: {}", denied.reason); + } + None => { + println!("No response from gateway."); + } + } + } + + SessionAction::Status => { + let session_id = store + .session_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No active session. Run `bascule session request` first."))?; + + let request = authenticated_request( + bascule_proto::bascule_v1::GetSessionStatusRequest { + session_id: session_id.clone(), + }, + &store.access_token, + ); + + let response = client.get_session_status(request).await?; + let status = response.into_inner(); + + println!("Session Status"); + println!(" ID: {}", status.session_id); + println!(" State: {}", status.state); + println!(" Mutations: {}/{}", status.mutations_used, + status.mutation_budget.map(|b| b.to_string()).unwrap_or("unlimited".into())); + if let Some(expires) = status.expires_at { + let dt = chrono::DateTime::from_timestamp(expires.seconds, 0) + .map(|d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "unknown".into()); + println!(" Expires: {dt}"); + } + } + + SessionAction::End => { + let session_id = store + .session_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No active session."))?; + + let request = authenticated_request( + bascule_proto::bascule_v1::EndSessionRequest { + session_id: session_id.clone(), + }, + &store.access_token, + ); + + let response = client.end_session(request).await?; + let result = response.into_inner(); + + if result.success { + println!("Session ended."); + println!(" Total mutations: {}", result.total_mutations); + store.session_id = None; + store.save()?; + } else { + println!("Failed to end session."); + } + } + } + + Ok(()) +} + +pub async fn connect_gateway(addr: &str) -> anyhow::Result> { + let channel = Channel::from_shared(addr.to_string())? + .connect() + .await?; + Ok(BasculeGatewayClient::new(channel)) +} + +pub fn authenticated_request(inner: T, token: &str) -> tonic::Request { + let mut request = tonic::Request::new(inner); + let val: tonic::metadata::MetadataValue<_> = format!("Bearer {token}").parse().unwrap(); + request.metadata_mut().insert("authorization", val); + request +} diff --git a/bascule-tail/Cargo.toml b/bascule-tail/Cargo.toml new file mode 100644 index 0000000..93c84be --- /dev/null +++ b/bascule-tail/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "bascule-tail" +version = "0.1.0" +edition = "2021" +description = "Governed pod log streaming for the Substrate governance fabric" + +[[bin]] +name = "bascule-tail" +path = "src/main.rs" + +[dependencies] +bascule-filter-core = { workspace = true } +bascule-proto = { workspace = true } + +# CLI +clap = { workspace = true } + +# gRPC +tonic = { workspace = true } +prost-types = { workspace = true } + +# Async +tokio = { workspace = true } + +# Serialization +serde_json = { workspace = true } + +# Observability +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Common +anyhow = { workspace = true } +regex = "1" + +# Future: binary discovery +which = { workspace = true } + +[dev-dependencies] +# (tests use bascule-filter-core types directly) diff --git a/bascule-tail/src/auth.rs b/bascule-tail/src/auth.rs new file mode 100644 index 0000000..f502cff --- /dev/null +++ b/bascule-tail/src/auth.rs @@ -0,0 +1,68 @@ +//! Per-pod authorization check. +//! +//! Each pod is authorized independently before opening a log stream. +//! If authorization fails for one pod, a denial LogLine is emitted +//! and the pod is skipped — other pods continue unaffected. + +/// Result of a per-pod authorization check. +pub enum AuthResult { + /// Authorized — proceed with streaming. + Authorized, + /// Denied — emit denial LogLine, skip this pod. + Denied(String), +} + +/// Check whether the current session is authorized to tail this pod. +/// +/// Authorization is evaluated at the accord level: does the session's +/// active accord include CAP_READ for the target pod's namespace? +/// +/// Phase A stub: always returns Authorized. +/// Phase B replaces the body with a gRPC call to bascule-gateway. +// TODO Phase B: call bascule-gateway CheckPodAccess RPC with +// session_id, token, pod, namespace. The RPC should validate: +// 1. Session is still active (not expired/revoked) +// 2. Session's accord includes CAP_READ for this namespace +// 3. Pod exists and is watchable by this session +// Until then: all pods pass authorization. +pub async fn check_pod_auth( + pod: &str, + namespace: &str, + session_id: &str, + _token: &str, + _gateway: &str, +) -> AuthResult { + tracing::debug!( + pod = %pod, + namespace = %namespace, + session_id = %session_id, + "auth check stubbed — all pods authorized (Phase A)" + ); + AuthResult::Authorized +} + +#[cfg(test)] +mod tests { + use super::*; + use bascule_filter_core::{FilterResult, LogFilter, LogLine}; + + #[tokio::test] + async fn stub_auth_always_authorized() { + let result = check_pod_auth("api-1", "default", "sess-1", "tok", "gw").await; + assert!(matches!(result, AuthResult::Authorized)); + } + + #[test] + fn denied_logline_passes_all_filters() { + // Regression guard: denial lines must bypass regex filters + use crate::filter::RegexFilter; + + let denied = LogLine::denied("pod-1", "default", "insufficient capability"); + let filter = RegexFilter::new("ERROR").unwrap(); + let result = filter.filter(denied); + assert!( + matches!(result, FilterResult::Pass(l) if l.is_denial), + "denial lines must always pass filters" + ); + } +} diff --git a/bascule-tail/src/fanout.rs b/bascule-tail/src/fanout.rs new file mode 100644 index 0000000..bd5106f --- /dev/null +++ b/bascule-tail/src/fanout.rs @@ -0,0 +1,105 @@ +//! Concurrent fan-out to N pod log streams. +//! +//! Each pod gets its own tokio task. All tasks feed into one merged +//! mpsc channel. One pod failing or being denied never kills the others. + +use bascule_filter_core::LogLine; +use tokio::sync::mpsc; + +use crate::auth::{check_pod_auth, AuthResult}; +use crate::stream::tail_pod; + +/// A resolved pod target after parsing. +#[derive(Debug, Clone)] +pub struct PodTarget { + pub pod: String, + pub namespace: String, +} + +/// Start concurrent log streams for all pod targets. +/// +/// Returns a receiver that yields LogLine values from all pods, +/// interleaved in arrival order. +/// +/// Authorization is checked per-pod before opening the stream. +/// Unauthorized pods emit one `LogLine::denied()` and stop. +/// +/// The returned channel closes when ALL pod streams have ended +/// (pods deleted, errors, or --no-follow exhausted). +pub async fn fanout( + targets: Vec, + session_id: String, + token: String, + gateway: String, + lines: u32, + follow: bool, +) -> mpsc::Receiver { + // Buffer: 256 lines per pod to handle burst without blocking producers + let buf = 256 * targets.len().max(1); + let (tx, rx) = mpsc::channel::(buf); + + for target in targets { + let tx = tx.clone(); + let sid = session_id.clone(); + let tok = token.clone(); + let gw = gateway.clone(); + let pod = target.pod.clone(); + let ns = target.namespace.clone(); + + tokio::spawn(async move { + // Step 1: per-pod authorization check + match check_pod_auth(&pod, &ns, &sid, &tok, &gw).await { + AuthResult::Denied(reason) => { + // Emit denial and stop — don't open the stream + let _ = tx.send(LogLine::denied(&pod, &ns, reason)).await; + return; + } + AuthResult::Authorized => {} + } + + // Step 2: open log stream + // Errors inside tail_pod are logged but don't propagate — + // the task exits cleanly. + tail_pod(pod, ns, gw, tok, lines, follow, tx).await; + }); + } + + // tx dropped here — rx closes when all spawned tasks complete + // (each task holds a clone; when all clones are dropped, rx ends) + rx +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn denied_pod_emits_denial_line() { + let denied = LogLine::denied("api-1", "prod", "no CAP_READ for namespace prod"); + assert!(denied.is_denial); + assert!(denied.content.contains("DENIED")); + assert!(denied.content.contains("no CAP_READ")); + } + + #[test] + fn multiple_targets_parse() { + use crate::parse_pod_target; + + let (pod1, ns1) = parse_pod_target("pod/api-1", "default").unwrap(); + assert_eq!(pod1, "api-1"); + assert_eq!(ns1, "default"); + + let (pod2, ns2) = parse_pod_target("pod/prod/api-2", "default").unwrap(); + assert_eq!(pod2, "api-2"); + assert_eq!(ns2, "prod"); + } + + #[tokio::test] + async fn fanout_single_authorized_pod() { + use crate::auth::{check_pod_auth, AuthResult}; + + // Phase A stub always returns Authorized + let result = check_pod_auth("api-1", "default", "sess-1", "tok", "gw").await; + assert!(matches!(result, AuthResult::Authorized)); + } +} diff --git a/bascule-tail/src/filter.rs b/bascule-tail/src/filter.rs new file mode 100644 index 0000000..a6c18cf --- /dev/null +++ b/bascule-tail/src/filter.rs @@ -0,0 +1,182 @@ +//! Built-in log filters for bascule-shell. +//! +//! These filters are compiled in for convenience. In the future, each will +//! become a standalone binary implementing the bascule-filter-core stdio protocol: +//! +//! bascule-filter-regex — regex matching +//! bascule-filter-jq — JSON field filter +//! bascule-filter-rate — rate limiting +//! bascule-filter-redact — PII redaction +//! +//! Standalone binaries are invoked via ProcessFilter and communicate over +//! stdin/stdout using newline-delimited JSON (LogLine serialized). + +use bascule_filter_core::{FilterResult, LogFilter, LogLine}; +use regex::Regex; + +/// Passes all lines unchanged. +pub struct PassthroughFilter; + +impl LogFilter for PassthroughFilter { + fn filter(&self, line: LogLine) -> FilterResult { + FilterResult::Pass(line) + } + + fn name(&self) -> &str { + "passthrough" + } +} + +/// Filters lines by regex match against content. +/// Lines that DO NOT match are dropped. +/// Governance denial lines always pass regardless of pattern. +pub struct RegexFilter { + pattern: Regex, + raw: String, +} + +impl RegexFilter { + pub fn new(pattern: &str) -> anyhow::Result { + Ok(Self { + pattern: Regex::new(pattern) + .map_err(|e| anyhow::anyhow!("Invalid regex '{}': {}", pattern, e))?, + raw: pattern.to_string(), + }) + } +} + +impl LogFilter for RegexFilter { + fn filter(&self, line: LogLine) -> FilterResult { + // Governance denials always pass — never suppress authorization events + if line.is_denial { + return FilterResult::Pass(line); + } + + if self.pattern.is_match(&line.content) { + FilterResult::Pass(line) + } else { + FilterResult::Drop + } + } + + fn name(&self) -> &str { + "regex" + } + + fn filter_id(&self) -> Option<&str> { + Some(&self.raw) + } +} + +/// Applies a chain of filters in order. AND-semantics: +/// a line is dropped if ANY filter drops it. +pub struct FilterChain { + filters: Vec>, +} + +impl FilterChain { + pub fn new() -> Self { + Self { + filters: Vec::new(), + } + } + + pub fn push(mut self, f: impl LogFilter + 'static) -> Self { + self.filters.push(Box::new(f)); + self + } + + /// Build from CLI --filter arguments. + /// Each argument is a regex pattern. Empty vec = passthrough. + pub fn from_patterns(patterns: &[String]) -> anyhow::Result { + let mut chain = Self::new(); + for p in patterns { + chain = chain.push(RegexFilter::new(p)?); + } + if chain.filters.is_empty() { + chain = chain.push(PassthroughFilter); + } + Ok(chain) + } +} + +impl LogFilter for FilterChain { + fn filter(&self, line: LogLine) -> FilterResult { + let mut current = line; + for f in &self.filters { + match f.filter(current) { + FilterResult::Pass(l) => current = l, + FilterResult::Drop => return FilterResult::Drop, + } + } + FilterResult::Pass(current) + } + + fn name(&self) -> &str { + "chain" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(content: &str) -> LogLine { + LogLine::new("pod-1", "default", content) + } + + #[test] + fn passthrough_passes_all() { + let f = PassthroughFilter; + assert!(matches!(f.filter(line("anything")), FilterResult::Pass(_))); + } + + #[test] + fn regex_passes_matching() { + let f = RegexFilter::new("ERROR").unwrap(); + assert!(matches!(f.filter(line("ERROR db timeout")), FilterResult::Pass(_))); + } + + #[test] + fn regex_drops_nonmatching() { + let f = RegexFilter::new("ERROR").unwrap(); + assert!(matches!(f.filter(line("INFO request ok")), FilterResult::Drop)); + } + + #[test] + fn regex_never_drops_denial() { + let f = RegexFilter::new("ERROR").unwrap(); + let denied = LogLine::denied("pod-1", "default", "insufficient capability"); + assert!(matches!(f.filter(denied), FilterResult::Pass(_))); + } + + #[test] + fn chain_drops_if_any_drops() { + let chain = FilterChain::new() + .push(RegexFilter::new("ERROR").unwrap()) + .push(RegexFilter::new("db").unwrap()); + assert!(matches!(chain.filter(line("ERROR timeout")), FilterResult::Drop)); + } + + #[test] + fn chain_passes_if_all_pass() { + let chain = FilterChain::new() + .push(RegexFilter::new("ERROR").unwrap()) + .push(RegexFilter::new("db").unwrap()); + assert!(matches!( + chain.filter(line("ERROR db connection failed")), + FilterResult::Pass(_) + )); + } + + #[test] + fn empty_chain_passes_all() { + let chain = FilterChain::from_patterns(&[]).unwrap(); + assert!(matches!(chain.filter(line("anything")), FilterResult::Pass(_))); + } + + #[test] + fn invalid_regex_returns_error() { + assert!(RegexFilter::new("[invalid").is_err()); + } +} diff --git a/bascule-tail/src/main.rs b/bascule-tail/src/main.rs new file mode 100644 index 0000000..2f638b0 --- /dev/null +++ b/bascule-tail/src/main.rs @@ -0,0 +1,148 @@ +//! bascule-tail — governed log streaming for the Substrate governance fabric. +//! +//! A standalone command module invoked by bascule-shell via exec(). +//! Implements the LogFilter stdio protocol via bascule-filter-core. +//! +//! Future: capability manifest declaration +//! declare_filter_capabilities! { +//! filter_id: "bascule-tail", +//! network: true, // gRPC to gateway +//! file_open: false, +//! syscalls: [read, write, connect], +//! } + +mod auth; +mod fanout; +mod filter; +mod output; +mod stream; + +use bascule_filter_core::{FilterResult, LogFilter}; +use clap::Parser; + +use filter::FilterChain; +use output::OutputFormatter; + +/// CLI args for bascule-tail. +/// These are forwarded by bascule-shell when it execs this binary. +#[derive(Parser, Debug)] +#[command(name = "bascule-tail", about = "Stream governed pod logs")] +struct Args { + /// Session ID (forwarded by bascule-shell) + #[arg(long)] + session_id: String, + + /// Bearer token (forwarded by bascule-shell) + #[arg(long)] + token: String, + + /// bascule-gateway address + #[arg(long)] + gateway: String, + + /// Pod targets: pod/{name} or pod/{namespace}/{name} + #[arg(required = true)] + targets: Vec, + + /// Regex filter patterns (AND semantics). + #[arg(long = "filter", short = 'f')] + filters: Vec, + + /// Initial lines to show per pod. + #[arg(long, default_value = "50")] + lines: u32, + + /// Stop after initial lines. + #[arg(long = "no-follow")] + no_follow: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("bascule_tail=info")), + ) + .init(); + + let args = Args::parse(); + + let chain = FilterChain::from_patterns(&args.filters)?; + + // Parse all targets + let targets: Vec = args + .targets + .iter() + .map(|t| { + parse_pod_target(t, "default").map(|(pod, ns)| fanout::PodTarget { + pod, + namespace: ns, + }) + }) + .collect::>>()?; + + // Fan-out: N concurrent streams, one merged channel + let mut rx = fanout::fanout( + targets, + args.session_id.clone(), + args.token.clone(), + args.gateway.clone(), + args.lines, + !args.no_follow, + ) + .await; + + let mut fmt = OutputFormatter::auto(); + + while let Some(line) = rx.recv().await { + match chain.filter(line) { + FilterResult::Pass(l) => fmt.print(&l), + FilterResult::Drop => {} + } + } + + Ok(()) +} + +/// Parse "pod/{name}" or "pod/{ns}/{name}" into (pod_name, namespace). +fn parse_pod_target(target: &str, default_ns: &str) -> anyhow::Result<(String, String)> { + let stripped = target.strip_prefix("pod/").unwrap_or(target); + let parts: Vec<&str> = stripped.splitn(2, '/').collect(); + + match parts.as_slice() { + [name] => Ok((name.to_string(), default_ns.to_string())), + [ns, name] => Ok((name.to_string(), ns.to_string())), + _ => anyhow::bail!( + "Invalid pod target: '{}'. Expected: pod/{{name}} or pod/{{namespace}}/{{name}}", + target + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pod_name_only() { + let (pod, ns) = parse_pod_target("api-svc", "default").unwrap(); + assert_eq!(pod, "api-svc"); + assert_eq!(ns, "default"); + } + + #[test] + fn parse_pod_with_namespace() { + let (pod, ns) = parse_pod_target("pod/production/api-svc", "default").unwrap(); + assert_eq!(pod, "api-svc"); + assert_eq!(ns, "production"); + } + + #[test] + fn parse_pod_strips_prefix() { + let (pod, ns) = parse_pod_target("pod/api-svc", "default").unwrap(); + assert_eq!(pod, "api-svc"); + assert_eq!(ns, "default"); + } +} diff --git a/bascule-tail/src/output.rs b/bascule-tail/src/output.rs new file mode 100644 index 0000000..1616c7a --- /dev/null +++ b/bascule-tail/src/output.rs @@ -0,0 +1,71 @@ +//! Colored terminal output for log lines. +//! +//! Uses ANSI escape codes directly — no external dependency. +//! Colors are per-pod and stable within a session. + +use std::collections::HashMap; + +use bascule_filter_core::LogLine; + +const COLORS: &[&str] = &[ + "\x1b[36m", // cyan + "\x1b[33m", // yellow + "\x1b[35m", // magenta + "\x1b[34m", // blue + "\x1b[32m", // green +]; +const RESET: &str = "\x1b[0m"; +const RED: &str = "\x1b[31m"; +const BOLD: &str = "\x1b[1m"; + +pub struct OutputFormatter { + pod_colors: HashMap, + max_pod_width: usize, + color_enabled: bool, +} + +impl OutputFormatter { + pub fn new(color_enabled: bool) -> Self { + Self { + pod_colors: HashMap::new(), + max_pod_width: 0, + color_enabled, + } + } + + /// Auto-detect color support from tty status. + pub fn auto() -> Self { + let color_enabled = std::io::IsTerminal::is_terminal(&std::io::stdout()); + Self::new(color_enabled) + } + + /// Print a log line with pod attribution. + /// + /// Format: `[api-svc ] ERROR db timeout` + /// Denial lines are printed in red+bold. + pub fn print(&mut self, line: &LogLine) { + let color_idx = { + let next = self.pod_colors.len() % COLORS.len(); + *self.pod_colors.entry(line.pod.clone()).or_insert(next) + }; + + if line.pod.len() > self.max_pod_width { + self.max_pod_width = line.pod.len(); + } + + let pod_padded = format!("{:, +) { + let result = tail_pod_inner(&pod, &namespace, &gateway, &token, lines, follow, &tx).await; + if let Err(e) = result { + tracing::warn!(pod = %pod, namespace = %namespace, error = %e, "Log stream ended with error"); + } +} + +async fn tail_pod_inner( + pod: &str, + namespace: &str, + gateway: &str, + token: &str, + lines: u32, + follow: bool, + tx: &mpsc::Sender, +) -> anyhow::Result<()> { + let channel = tonic::transport::Channel::from_shared(gateway.to_string())? + .connect() + .await?; + + let mut client = BasculeGatewayClient::new(channel); + + // Build parameters as google.protobuf.Struct + let params = prost_types::Struct { + fields: [ + ( + "follow".to_string(), + prost_types::Value { + kind: Some(prost_types::value::Kind::BoolValue(follow)), + }, + ), + ( + "tail_lines".to_string(), + prost_types::Value { + kind: Some(prost_types::value::Kind::NumberValue(lines as f64)), + }, + ), + ] + .into_iter() + .collect(), + }; + + let request = { + let mut req = tonic::Request::new(ExecuteCommandRequest { + session_id: String::new(), // TODO: pass session_id from TokenStore + verb: "logs".to_string(), + namespace: Some(namespace.to_string()), + resource_type: Some("pod".to_string()), + resource_name: Some(pod.to_string()), + parameters: Some(params), + output_format: "raw".to_string(), + }); + let val: tonic::metadata::MetadataValue<_> = + format!("Bearer {token}").parse().unwrap(); + req.metadata_mut().insert("authorization", val); + req + }; + + // Use StreamCommand — server-streaming RPC + let mut stream = client.stream_command(request).await?.into_inner(); + + while let Some(chunk) = stream.message().await? { + use bascule_proto::bascule_v1::command_stream_chunk::Chunk; + + match chunk.chunk { + Some(Chunk::OutputLine(line)) => { + let log_line = LogLine::new(pod, namespace, line); + if tx.send(log_line).await.is_err() { + // Receiver dropped — stop streaming + break; + } + } + Some(Chunk::ErrorLine(line)) => { + let mut log_line = LogLine::new(pod, namespace, format!("[stderr] {line}")); + log_line.is_denial = false; + if tx.send(log_line).await.is_err() { + break; + } + } + Some(Chunk::Eof(_)) => { + break; + } + None => {} + } + } + + Ok(()) +} diff --git a/ceremony-engine/Cargo.toml b/ceremony-engine/Cargo.toml new file mode 100644 index 0000000..fd1f081 --- /dev/null +++ b/ceremony-engine/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ceremony-engine" +version = "0.1.0" +edition = "2021" +description = "Governed state machine for multi-party approval workflows" + +[dependencies] +# Cross-workspace path dep — CeremonyType and CeremonyReqs +# are accord schema primitives defined in guildhouse. +# When ceremony-engine is published to crates.io, +# this becomes a version dependency. +accord-core = { path = "../../guildhouse/services/accord-core" } +registry-protocol = { path = "../../guildhouse/services/registry-protocol" } + +serde = { workspace = true } +serde_json = { workspace = true } +serde_json_canonicalizer = { workspace = true } +chrono = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true } diff --git a/ceremony-engine/src/artifact.rs b/ceremony-engine/src/artifact.rs new file mode 100644 index 0000000..f5a8b35 --- /dev/null +++ b/ceremony-engine/src/artifact.rs @@ -0,0 +1,138 @@ +//! Registry protocol integration for governance ceremonies. +//! +//! Implements [`RegistryArtifact`] on [`CeremonyResolution`] so that +//! resolved ceremonies can be merkle-anchored in the notary chain, +//! and [`MutationVerb`] on [`CeremonyVerb`] for SAT scope encoding. + +use registry_protocol::{RegistryArtifact, MutationVerb}; +use serde::{Deserialize, Serialize}; + +use crate::resolution::CeremonyResolution; + +/// Operations on governance ceremonies. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CeremonyVerb { + /// Create a new ceremony request. + Create, + /// Record an approval decision. + Approve, + /// Record a denial decision. + Deny, + /// Cancel a pending ceremony. + Cancel, + /// Expire a ceremony (system-initiated). + Expire, +} + +impl MutationVerb for CeremonyVerb { + fn as_scope_verb(&self) -> &str { + match self { + CeremonyVerb::Create => "create-ceremony", + CeremonyVerb::Approve => "approve-ceremony", + CeremonyVerb::Deny => "deny-ceremony", + CeremonyVerb::Cancel => "cancel-ceremony", + CeremonyVerb::Expire => "expire-ceremony", + } + } + + fn default_ceremony_required(&self) -> bool { + // Creating a ceremony doesn't itself require a ceremony. + // Approving/denying are the ceremony actions. + // Cancel and expire are administrative. + false + } +} + +impl RegistryArtifact for CeremonyResolution { + fn artifact_id(&self) -> &str { + &self.ceremony_id + } + + fn registry_type(&self) -> &str { + "governance-ceremony" + } + + fn canonical_bytes(&self) -> Vec { + // Re-derive the canonical form from the resolution's fields. + // The proof_hash was computed from JCS of the canonical form, + // so we can use it as the canonical bytes for merkle anchoring. + // This avoids re-canonicalizing and guarantees consistency. + self.proof_hash.as_bytes().to_vec() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request::{ + ApprovalDecision, CeremonyApproval, CeremonySubject, GovernanceCeremonyStatus, + }; + use chrono::Utc; + + fn sample_resolution() -> CeremonyResolution { + let approvals = vec![CeremonyApproval { + approver_identity: "alice@ops".to_string(), + approver_role: "msp-ops".to_string(), + decision: ApprovalDecision::Approve, + comment: None, + decided_at: Utc::now(), + }]; + CeremonyResolution::from_ceremony( + "cer-001", + GovernanceCeremonyStatus::Approved, + &CeremonySubject::PipelineMerge { + run_id: "run-1".to_string(), + pipeline_name: "deploy".to_string(), + branch: "main".to_string(), + commit_hash: "abc".to_string(), + remote_name: "origin".to_string(), + }, + &approvals, + ) + } + + #[test] + fn registry_artifact_impl() { + let res = sample_resolution(); + assert_eq!(res.artifact_id(), "cer-001"); + assert_eq!(RegistryArtifact::registry_type(&res), "governance-ceremony"); + assert!(!res.canonical_bytes().is_empty()); + } + + #[test] + fn ceremony_verb_scopes() { + assert_eq!(CeremonyVerb::Create.as_scope_verb(), "create-ceremony"); + assert_eq!(CeremonyVerb::Approve.as_scope_verb(), "approve-ceremony"); + assert_eq!(CeremonyVerb::Deny.as_scope_verb(), "deny-ceremony"); + assert_eq!(CeremonyVerb::Cancel.as_scope_verb(), "cancel-ceremony"); + assert_eq!(CeremonyVerb::Expire.as_scope_verb(), "expire-ceremony"); + } + + #[test] + fn ceremony_verb_no_ceremony_required() { + // No CeremonyVerb requires a ceremony itself (that would be circular) + assert!(!CeremonyVerb::Create.default_ceremony_required()); + assert!(!CeremonyVerb::Approve.default_ceremony_required()); + assert!(!CeremonyVerb::Deny.default_ceremony_required()); + assert!(!CeremonyVerb::Cancel.default_ceremony_required()); + assert!(!CeremonyVerb::Expire.default_ceremony_required()); + } + + #[test] + fn canonical_bytes_deterministic() { + let res = sample_resolution(); + let b1 = res.canonical_bytes(); + let b2 = res.canonical_bytes(); + assert_eq!(b1, b2); + } + + #[test] + fn verb_serialization() { + let verb = CeremonyVerb::Approve; + let json = serde_json::to_string(&verb).unwrap(); + assert_eq!(json, "\"approve\""); + let parsed: CeremonyVerb = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, CeremonyVerb::Approve); + } +} diff --git a/ceremony-engine/src/engine.rs b/ceremony-engine/src/engine.rs new file mode 100644 index 0000000..aadb882 --- /dev/null +++ b/ceremony-engine/src/engine.rs @@ -0,0 +1,295 @@ +//! Ceremony evaluation engine. +//! +//! Given a [`GovernanceCeremonyRequest`] and its current approvals, +//! determines whether the ceremony is resolved (approved or denied). +//! +//! Resolution rules per Accord ceremony type: +//! - **SelfGrant / Autonomous**: Auto-approved at creation (no ceremony needed). +//! - **SingleApproval**: One approval from a permitted role. +//! - **QuorumApproval**: `quorum` approvals from permitted roles. +//! - **BreakGlass**: One approval + external evidence (verified separately). +//! - **Inherit**: Uses parent classification's ceremony type. + +use accord_core::schema::{CeremonyReqs, CeremonyType}; +use chrono::{Duration, Utc}; + +use crate::request::{ + CeremonySubject, GovernanceCeremonyRequest, GovernanceCeremonyStatus, +}; + +/// Stateless ceremony engine. Evaluates whether a ceremony's approval +/// threshold is met and transitions it to Approved/Denied accordingly. +pub struct CeremonyEngine; + +impl CeremonyEngine { + /// Create a new ceremony request from Accord policy resolution. + /// + /// Maps the Accord `CeremonyType` and `CeremonyReqs` to concrete + /// approval thresholds. SelfGrant and Autonomous are auto-approved. + pub fn create_request( + ceremony_id: String, + ceremony_type: &CeremonyType, + reqs: &CeremonyReqs, + subject: CeremonySubject, + ttl_hours: u32, + ) -> GovernanceCeremonyRequest { + let now = Utc::now(); + let expires_at = now + Duration::hours(ttl_hours as i64); + + let required_approvals = match ceremony_type { + CeremonyType::SelfGrant | CeremonyType::Autonomous => 0, + CeremonyType::SingleApproval | CeremonyType::BreakGlass => 1, + CeremonyType::QuorumApproval => reqs.quorum.unwrap_or(2), + CeremonyType::Inherit => reqs.quorum.unwrap_or(1), + }; + + let approver_roles = reqs + .approver_roles + .clone() + .unwrap_or_default(); + + // SelfGrant and Autonomous are auto-approved + let status = if required_approvals == 0 { + GovernanceCeremonyStatus::Approved + } else { + GovernanceCeremonyStatus::Pending + }; + + GovernanceCeremonyRequest { + ceremony_id, + ceremony_type: ceremony_type.clone(), + subject, + required_approvals, + approver_roles, + approvals: vec![], + status, + created_at: now, + expires_at, + intent_id: None, + run_id: None, + pr_number: None, + remote_name: None, + } + } + + /// Evaluate the current state of a ceremony and transition its status + /// if the approval threshold is met or a denial has been recorded. + /// + /// Returns `true` if the status changed. + pub fn evaluate(request: &mut GovernanceCeremonyRequest) -> bool { + if request.status.is_terminal() { + return false; + } + + // Check expiry + if Utc::now() >= request.expires_at { + request.status = GovernanceCeremonyStatus::Expired; + return true; + } + + // Any denial immediately resolves the ceremony as denied + if request.denial_count() > 0 { + request.status = GovernanceCeremonyStatus::Denied; + return true; + } + + // Check if approvals meet the threshold + if request.approval_count() >= request.required_approvals { + request.status = GovernanceCeremonyStatus::Approved; + return true; + } + + false + } + + /// Check whether a ceremony type requires human approval. + /// SelfGrant and Autonomous do not require approval — they are auto-resolved. + pub fn requires_approval(ceremony_type: &CeremonyType) -> bool { + !matches!( + ceremony_type, + CeremonyType::SelfGrant | CeremonyType::Autonomous + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request::{ApprovalDecision, CeremonySubject}; + + fn merge_subject() -> CeremonySubject { + CeremonySubject::PipelineMerge { + run_id: "run-1".to_string(), + pipeline_name: "deploy".to_string(), + branch: "feat/x".to_string(), + commit_hash: "abc".to_string(), + remote_name: "origin".to_string(), + } + } + + fn default_reqs() -> CeremonyReqs { + CeremonyReqs { + approver_roles: Some(vec!["msp-ops".to_string()]), + quorum: Some(2), + ..Default::default() + } + } + + #[test] + fn self_grant_auto_approved() { + let req = CeremonyEngine::create_request( + "cer-1".into(), + &CeremonyType::SelfGrant, + &default_reqs(), + merge_subject(), + 24, + ); + assert_eq!(req.status, GovernanceCeremonyStatus::Approved); + assert_eq!(req.required_approvals, 0); + } + + #[test] + fn autonomous_auto_approved() { + let req = CeremonyEngine::create_request( + "cer-2".into(), + &CeremonyType::Autonomous, + &default_reqs(), + merge_subject(), + 24, + ); + assert_eq!(req.status, GovernanceCeremonyStatus::Approved); + } + + #[test] + fn single_approval_needs_one() { + let mut req = CeremonyEngine::create_request( + "cer-3".into(), + &CeremonyType::SingleApproval, + &default_reqs(), + merge_subject(), + 24, + ); + assert_eq!(req.status, GovernanceCeremonyStatus::Pending); + assert_eq!(req.required_approvals, 1); + + // No change yet + assert!(!CeremonyEngine::evaluate(&mut req)); + + // Add approval + req.record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap(); + assert!(CeremonyEngine::evaluate(&mut req)); + assert_eq!(req.status, GovernanceCeremonyStatus::Approved); + } + + #[test] + fn quorum_approval_needs_quorum() { + let mut req = CeremonyEngine::create_request( + "cer-4".into(), + &CeremonyType::QuorumApproval, + &default_reqs(), + merge_subject(), + 24, + ); + assert_eq!(req.required_approvals, 2); + assert_eq!(req.status, GovernanceCeremonyStatus::Pending); + + // First approval — not enough + req.record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap(); + assert!(!CeremonyEngine::evaluate(&mut req)); + assert_eq!(req.status, GovernanceCeremonyStatus::Pending); + + // Second approval — quorum met + req.record_decision("bob@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap(); + assert!(CeremonyEngine::evaluate(&mut req)); + assert_eq!(req.status, GovernanceCeremonyStatus::Approved); + } + + #[test] + fn denial_immediately_resolves() { + let mut req = CeremonyEngine::create_request( + "cer-5".into(), + &CeremonyType::QuorumApproval, + &default_reqs(), + merge_subject(), + 24, + ); + req.record_decision("alice@ops", "msp-ops", ApprovalDecision::Deny, Some("nope".into())) + .unwrap(); + assert!(CeremonyEngine::evaluate(&mut req)); + assert_eq!(req.status, GovernanceCeremonyStatus::Denied); + } + + #[test] + fn break_glass_needs_one_approval() { + let mut req = CeremonyEngine::create_request( + "cer-6".into(), + &CeremonyType::BreakGlass, + &default_reqs(), + merge_subject(), + 24, + ); + assert_eq!(req.required_approvals, 1); + req.record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap(); + assert!(CeremonyEngine::evaluate(&mut req)); + assert_eq!(req.status, GovernanceCeremonyStatus::Approved); + } + + #[test] + fn evaluate_already_resolved_returns_false() { + let mut req = CeremonyEngine::create_request( + "cer-7".into(), + &CeremonyType::SelfGrant, + &default_reqs(), + merge_subject(), + 24, + ); + // Already approved + assert!(!CeremonyEngine::evaluate(&mut req)); + } + + #[test] + fn requires_approval_check() { + assert!(!CeremonyEngine::requires_approval(&CeremonyType::SelfGrant)); + assert!(!CeremonyEngine::requires_approval(&CeremonyType::Autonomous)); + assert!(CeremonyEngine::requires_approval(&CeremonyType::SingleApproval)); + assert!(CeremonyEngine::requires_approval(&CeremonyType::QuorumApproval)); + assert!(CeremonyEngine::requires_approval(&CeremonyType::BreakGlass)); + assert!(CeremonyEngine::requires_approval(&CeremonyType::Inherit)); + } + + #[test] + fn quorum_defaults_to_two_when_not_specified() { + let reqs = CeremonyReqs { + quorum: None, + ..Default::default() + }; + let req = CeremonyEngine::create_request( + "cer-8".into(), + &CeremonyType::QuorumApproval, + &reqs, + merge_subject(), + 24, + ); + assert_eq!(req.required_approvals, 2); + } + + #[test] + fn expiry_on_evaluate() { + let reqs = default_reqs(); + let mut req = CeremonyEngine::create_request( + "cer-9".into(), + &CeremonyType::SingleApproval, + &reqs, + merge_subject(), + 24, + ); + // Force expire + req.expires_at = Utc::now() - chrono::Duration::seconds(1); + assert!(CeremonyEngine::evaluate(&mut req)); + assert_eq!(req.status, GovernanceCeremonyStatus::Expired); + } +} diff --git a/ceremony-engine/src/lib.rs b/ceremony-engine/src/lib.rs new file mode 100644 index 0000000..8411d47 --- /dev/null +++ b/ceremony-engine/src/lib.rs @@ -0,0 +1,26 @@ +//! ceremony-engine — governed state machine for multi-party approval workflows. +//! +//! This crate is the universal ceremony primitive for the Substrate governance +//! fabric. Any change requiring authorization — shell elevation, workspace +//! proposals, schematic deployments, ML operator changes — flows through +//! CeremonyEngine. +//! +//! Dependency direction: +//! ceremony-engine → accord-core (CeremonyType enum) +//! bascule-core → ceremony-engine (re-exports for compat) +//! guildhouse → ceremony-engine (direct) + +pub mod artifact; +pub mod engine; +pub mod request; +pub mod resolution; +pub mod store; + +pub use engine::CeremonyEngine; +pub use request::{ + ApprovalDecision, CeremonyApproval, CeremonyError, CeremonySubject, + GovernanceCeremonyRequest, GovernanceCeremonyStatus, +}; +pub use resolution::CeremonyResolution; +pub use store::{CeremonyStore, CeremonyStoreError, InMemoryCeremonyStore}; +pub use artifact::CeremonyVerb; diff --git a/ceremony-engine/src/request.rs b/ceremony-engine/src/request.rs new file mode 100644 index 0000000..895c4fb --- /dev/null +++ b/ceremony-engine/src/request.rs @@ -0,0 +1,481 @@ +//! Governance ceremony lifecycle model. +//! +//! Unlike the session-oriented [`CeremonyRequest`](crate::ceremony::CeremonyRequest) in +//! `ceremony.rs` (which produces Bascule session grants), this module models +//! **governance ceremonies** — multi-stakeholder approval flows triggered by +//! Accord policy when a mutation requires human sign-off. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// A governance ceremony request. +/// +/// Created when Accord policy determines that a mutation requires approval +/// (SingleApproval, QuorumApproval, etc.). Stakeholders approve or deny, +/// and once the ceremony resolves, the blocked workflow resumes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GovernanceCeremonyRequest { + pub ceremony_id: String, + pub ceremony_type: accord_core::schema::CeremonyType, + pub subject: CeremonySubject, + pub required_approvals: u32, + pub approver_roles: Vec, + pub approvals: Vec, + pub status: GovernanceCeremonyStatus, + pub created_at: DateTime, + pub expires_at: DateTime, + /// Linked MutationIntent (for intent-gated ceremonies). + pub intent_id: Option, + /// Linked pipeline run (for merge-gated ceremonies). + pub run_id: Option, + /// Linked pull request number. + pub pr_number: Option, + /// Remote name for PR-linked ceremonies. + pub remote_name: Option, +} + +/// What the ceremony is about. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CeremonySubject { + /// Approval for a governance mutation intent. + MutationIntent { + intent_id: String, + registry_type: String, + verb: String, + artifact_scope: String, + tenant_id: String, + }, + /// Approval to merge a pipeline run into the canonical branch. + PipelineMerge { + run_id: String, + pipeline_name: String, + branch: String, + commit_hash: String, + remote_name: String, + }, + /// Approval to publish a schematic version. + SchematicPublish { + schematic_name: String, + version: String, + tree_hash: String, + }, + /// Generic ceremony for custom governance flows. + Custom { + subject_type: String, + reference_id: String, + description: String, + }, + /// Approval to allow a GitOps sync operation (ArgoCD or Flux). + GitOpsSync { + tool: String, + resource_name: String, + resource_namespace: String, + target_revision: String, + environment: Option, + tenant_id: String, + }, +} + +impl CeremonySubject { + /// A short display label for the subject. + pub fn display_label(&self) -> String { + match self { + Self::MutationIntent { + registry_type, + verb, + .. + } => format!("{verb} on {registry_type}"), + Self::PipelineMerge { + pipeline_name, + branch, + .. + } => format!("merge {pipeline_name} ({branch})"), + Self::SchematicPublish { + schematic_name, + version, + .. + } => format!("publish {schematic_name} v{version}"), + Self::Custom { + subject_type, + reference_id, + .. + } => format!("{subject_type}:{reference_id}"), + Self::GitOpsSync { + resource_name, + environment, + .. + } => { + let env = environment.as_deref().unwrap_or("default"); + format!("sync {resource_name} ({env})") + } + } + } +} + +/// Lifecycle status of a governance ceremony. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GovernanceCeremonyStatus { + /// Awaiting approvals. + Pending, + /// All required approvals received. + Approved, + /// Explicitly denied by a stakeholder. + Denied, + /// Expired before resolution. + Expired, + /// Cancelled by the requestor or system. + Cancelled, +} + +impl GovernanceCeremonyStatus { + /// Whether this is a terminal status. + pub fn is_terminal(&self) -> bool { + !matches!(self, Self::Pending) + } +} + +/// A single approval or denial decision within a ceremony. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CeremonyApproval { + pub approver_identity: String, + pub approver_role: String, + pub decision: ApprovalDecision, + pub comment: Option, + pub decided_at: DateTime, +} + +/// Whether a stakeholder approved or denied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision { + Approve, + Deny, +} + +/// Errors during ceremony operations. +#[derive(Debug, thiserror::Error)] +pub enum CeremonyError { + #[error("ceremony {ceremony_id} is already resolved ({status:?})")] + AlreadyResolved { + ceremony_id: String, + status: GovernanceCeremonyStatus, + }, + #[error("ceremony {ceremony_id} has expired")] + Expired { ceremony_id: String }, + #[error("duplicate approval from {identity} in role {role}")] + DuplicateApproval { identity: String, role: String }, + #[error("approver role {role} is not permitted for this ceremony")] + InvalidRole { role: String }, + #[error("ceremony {ceremony_id} not found")] + NotFound { ceremony_id: String }, + #[error("invalid state transition from {from:?} to {to:?}")] + InvalidTransition { + from: GovernanceCeremonyStatus, + to: GovernanceCeremonyStatus, + }, +} + +impl GovernanceCeremonyRequest { + /// Record an approval or denial. Returns error if the ceremony is + /// already resolved, expired, or the approver has already voted. + pub fn record_decision( + &mut self, + approver_identity: &str, + approver_role: &str, + decision: ApprovalDecision, + comment: Option, + ) -> Result<(), CeremonyError> { + // Check terminal + if self.status.is_terminal() { + return Err(CeremonyError::AlreadyResolved { + ceremony_id: self.ceremony_id.clone(), + status: self.status, + }); + } + + // Check expiry + if Utc::now() >= self.expires_at { + self.status = GovernanceCeremonyStatus::Expired; + return Err(CeremonyError::Expired { + ceremony_id: self.ceremony_id.clone(), + }); + } + + // Check role is permitted + if !self.approver_roles.is_empty() && !self.approver_roles.contains(&approver_role.to_string()) { + return Err(CeremonyError::InvalidRole { + role: approver_role.to_string(), + }); + } + + // Check duplicate + if self.approvals.iter().any(|a| { + a.approver_identity == approver_identity && a.approver_role == approver_role + }) { + return Err(CeremonyError::DuplicateApproval { + identity: approver_identity.to_string(), + role: approver_role.to_string(), + }); + } + + self.approvals.push(CeremonyApproval { + approver_identity: approver_identity.to_string(), + approver_role: approver_role.to_string(), + decision, + comment, + decided_at: Utc::now(), + }); + + Ok(()) + } + + /// Count approvals (not denials). + pub fn approval_count(&self) -> u32 { + self.approvals + .iter() + .filter(|a| a.decision == ApprovalDecision::Approve) + .count() as u32 + } + + /// Count denials. + pub fn denial_count(&self) -> u32 { + self.approvals + .iter() + .filter(|a| a.decision == ApprovalDecision::Deny) + .count() as u32 + } + + /// Cancel the ceremony. + pub fn cancel(&mut self) -> Result<(), CeremonyError> { + if self.status.is_terminal() { + return Err(CeremonyError::InvalidTransition { + from: self.status, + to: GovernanceCeremonyStatus::Cancelled, + }); + } + self.status = GovernanceCeremonyStatus::Cancelled; + Ok(()) + } + + /// Mark as expired. + pub fn expire(&mut self) -> Result<(), CeremonyError> { + if self.status.is_terminal() { + return Err(CeremonyError::InvalidTransition { + from: self.status, + to: GovernanceCeremonyStatus::Expired, + }); + } + self.status = GovernanceCeremonyStatus::Expired; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use accord_core::schema::CeremonyType; + use chrono::Duration; + + fn sample_ceremony() -> GovernanceCeremonyRequest { + let now = Utc::now(); + GovernanceCeremonyRequest { + ceremony_id: "cer-001".to_string(), + ceremony_type: CeremonyType::SingleApproval, + subject: CeremonySubject::PipelineMerge { + run_id: "run-abc".to_string(), + pipeline_name: "deploy-infra".to_string(), + branch: "feature/vpc".to_string(), + commit_hash: "abc123".to_string(), + remote_name: "origin".to_string(), + }, + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + approvals: vec![], + status: GovernanceCeremonyStatus::Pending, + created_at: now, + expires_at: now + Duration::hours(24), + intent_id: None, + run_id: Some("run-abc".to_string()), + pr_number: Some(42), + remote_name: Some("origin".to_string()), + } + } + + #[test] + fn record_approval() { + let mut cer = sample_ceremony(); + cer.record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap(); + assert_eq!(cer.approval_count(), 1); + assert_eq!(cer.denial_count(), 0); + } + + #[test] + fn record_denial() { + let mut cer = sample_ceremony(); + cer.record_decision( + "bob@ops", + "msp-ops", + ApprovalDecision::Deny, + Some("needs rework".to_string()), + ) + .unwrap(); + assert_eq!(cer.denial_count(), 1); + assert_eq!(cer.approval_count(), 0); + } + + #[test] + fn duplicate_approval_rejected() { + let mut cer = sample_ceremony(); + cer.record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap(); + let err = cer + .record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap_err(); + assert!(matches!(err, CeremonyError::DuplicateApproval { .. })); + } + + #[test] + fn invalid_role_rejected() { + let mut cer = sample_ceremony(); + let err = cer + .record_decision("eve@outsider", "random-role", ApprovalDecision::Approve, None) + .unwrap_err(); + assert!(matches!(err, CeremonyError::InvalidRole { .. })); + } + + #[test] + fn approval_on_resolved_ceremony_rejected() { + let mut cer = sample_ceremony(); + cer.status = GovernanceCeremonyStatus::Approved; + let err = cer + .record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap_err(); + assert!(matches!(err, CeremonyError::AlreadyResolved { .. })); + } + + #[test] + fn expired_ceremony_transitions() { + let mut cer = sample_ceremony(); + cer.expires_at = Utc::now() - Duration::seconds(1); + let err = cer + .record_decision("alice@ops", "msp-ops", ApprovalDecision::Approve, None) + .unwrap_err(); + assert!(matches!(err, CeremonyError::Expired { .. })); + assert_eq!(cer.status, GovernanceCeremonyStatus::Expired); + } + + #[test] + fn cancel_pending_ceremony() { + let mut cer = sample_ceremony(); + cer.cancel().unwrap(); + assert_eq!(cer.status, GovernanceCeremonyStatus::Cancelled); + } + + #[test] + fn cancel_resolved_fails() { + let mut cer = sample_ceremony(); + cer.status = GovernanceCeremonyStatus::Denied; + let err = cer.cancel().unwrap_err(); + assert!(matches!(err, CeremonyError::InvalidTransition { .. })); + } + + #[test] + fn subject_display_labels() { + let merge = CeremonySubject::PipelineMerge { + run_id: "r1".to_string(), + pipeline_name: "ci".to_string(), + branch: "main".to_string(), + commit_hash: "abc".to_string(), + remote_name: "origin".to_string(), + }; + assert_eq!(merge.display_label(), "merge ci (main)"); + + let publish = CeremonySubject::SchematicPublish { + schematic_name: "my-schema".to_string(), + version: "1.0.0".to_string(), + tree_hash: "hash".to_string(), + }; + assert_eq!(publish.display_label(), "publish my-schema v1.0.0"); + + let intent = CeremonySubject::MutationIntent { + intent_id: "i1".to_string(), + registry_type: "credential".to_string(), + verb: "revoke".to_string(), + artifact_scope: "tenant-a:*".to_string(), + tenant_id: "tenant-a".to_string(), + }; + assert_eq!(intent.display_label(), "revoke on credential"); + + let gitops = CeremonySubject::GitOpsSync { + tool: "argocd".to_string(), + resource_name: "my-app".to_string(), + resource_namespace: "argocd".to_string(), + target_revision: "abc123".to_string(), + environment: Some("production".to_string()), + tenant_id: "tenant-a".to_string(), + }; + assert_eq!(gitops.display_label(), "sync my-app (production)"); + + let gitops_no_env = CeremonySubject::GitOpsSync { + tool: "flux".to_string(), + resource_name: "kustomization-web".to_string(), + resource_namespace: "flux-system".to_string(), + target_revision: "main".to_string(), + environment: None, + tenant_id: "tenant-b".to_string(), + }; + assert_eq!(gitops_no_env.display_label(), "sync kustomization-web (default)"); + } + + #[test] + fn gitops_sync_serialization_round_trip() { + let subject = CeremonySubject::GitOpsSync { + tool: "argocd".to_string(), + resource_name: "my-app".to_string(), + resource_namespace: "argocd".to_string(), + target_revision: "abc123".to_string(), + environment: Some("production".to_string()), + tenant_id: "tenant-a".to_string(), + }; + let json = serde_json::to_string(&subject).unwrap(); + assert!(json.contains("\"git_ops_sync\"")); + let parsed: CeremonySubject = serde_json::from_str(&json).unwrap(); + match parsed { + CeremonySubject::GitOpsSync { tool, resource_name, .. } => { + assert_eq!(tool, "argocd"); + assert_eq!(resource_name, "my-app"); + } + _ => panic!("wrong subject type"), + } + } + + #[test] + fn terminal_status_check() { + assert!(!GovernanceCeremonyStatus::Pending.is_terminal()); + assert!(GovernanceCeremonyStatus::Approved.is_terminal()); + assert!(GovernanceCeremonyStatus::Denied.is_terminal()); + assert!(GovernanceCeremonyStatus::Expired.is_terminal()); + assert!(GovernanceCeremonyStatus::Cancelled.is_terminal()); + } + + #[test] + fn empty_approver_roles_allows_anyone() { + let mut cer = sample_ceremony(); + cer.approver_roles = vec![]; // no role restriction + cer.record_decision("anyone@anywhere", "any-role", ApprovalDecision::Approve, None) + .unwrap(); + assert_eq!(cer.approval_count(), 1); + } + + #[test] + fn serialization_round_trip() { + let cer = sample_ceremony(); + let json = serde_json::to_string(&cer).unwrap(); + let parsed: GovernanceCeremonyRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.ceremony_id, cer.ceremony_id); + assert_eq!(parsed.status, GovernanceCeremonyStatus::Pending); + } +} diff --git a/ceremony-engine/src/resolution.rs b/ceremony-engine/src/resolution.rs new file mode 100644 index 0000000..fe659bc --- /dev/null +++ b/ceremony-engine/src/resolution.rs @@ -0,0 +1,214 @@ +//! Ceremony resolution proof. +//! +//! When a governance ceremony resolves (approved or denied), a +//! [`CeremonyResolution`] is produced as an immutable record suitable +//! for merkle anchoring. This is the artifact that gets notarized. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::request::{ + CeremonyApproval, CeremonySubject, GovernanceCeremonyStatus, +}; + +/// An immutable resolution record for a governance ceremony. +/// +/// Produced when the ceremony exits the `Pending` state. Contains +/// enough information to audit who approved/denied and what subject +/// was at stake, without reference to mutable ceremony state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CeremonyResolution { + pub ceremony_id: String, + pub status: GovernanceCeremonyStatus, + pub subject: CeremonySubject, + pub approvals: Vec, + pub resolved_at: DateTime, + pub proof_hash: String, +} + +/// Canonical form for hashing (alphabetical field order via JCS). +#[derive(Debug, Serialize, Deserialize)] +struct CanonicalResolution { + pub approvals: Vec, + pub ceremony_id: String, + pub resolved_at: DateTime, + pub status: GovernanceCeremonyStatus, + pub subject: CeremonySubject, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CanonicalApproval { + pub approver_identity: String, + pub approver_role: String, + pub decided_at: DateTime, + pub decision: String, +} + +impl CeremonyResolution { + /// Build a resolution from a resolved ceremony. + pub fn from_ceremony( + ceremony_id: &str, + status: GovernanceCeremonyStatus, + subject: &CeremonySubject, + approvals: &[CeremonyApproval], + ) -> Self { + let now = Utc::now(); + + let canonical = CanonicalResolution { + approvals: approvals + .iter() + .map(|a| CanonicalApproval { + approver_identity: a.approver_identity.clone(), + approver_role: a.approver_role.clone(), + decided_at: a.decided_at, + decision: format!("{:?}", a.decision).to_lowercase(), + }) + .collect(), + ceremony_id: ceremony_id.to_string(), + resolved_at: now, + status, + subject: subject.clone(), + }; + + let value = serde_json::to_value(&canonical) + .expect("CanonicalResolution is always JSON-serializable"); + let mut buf = Vec::new(); + serde_json_canonicalizer::to_writer(&value, &mut buf) + .expect("JCS canonicalization should not fail"); + let hash = Sha256::digest(&buf); + + CeremonyResolution { + ceremony_id: ceremony_id.to_string(), + status, + subject: subject.clone(), + approvals: approvals.to_vec(), + resolved_at: now, + proof_hash: hex::encode(hash), + } + } + + /// Verify the proof hash matches the resolution content. + pub fn verify_proof(&self) -> bool { + let canonical = CanonicalResolution { + approvals: self + .approvals + .iter() + .map(|a| CanonicalApproval { + approver_identity: a.approver_identity.clone(), + approver_role: a.approver_role.clone(), + decided_at: a.decided_at, + decision: format!("{:?}", a.decision).to_lowercase(), + }) + .collect(), + ceremony_id: self.ceremony_id.clone(), + resolved_at: self.resolved_at, + status: self.status, + subject: self.subject.clone(), + }; + + let value = serde_json::to_value(&canonical).unwrap_or_default(); + let mut buf = Vec::new(); + if serde_json_canonicalizer::to_writer(&value, &mut buf).is_err() { + return false; + } + let hash = Sha256::digest(&buf); + hex::encode(hash) == self.proof_hash + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request::{ApprovalDecision, CeremonyApproval, CeremonySubject}; + + fn sample_approvals() -> Vec { + vec![CeremonyApproval { + approver_identity: "alice@ops".to_string(), + approver_role: "msp-ops".to_string(), + decision: ApprovalDecision::Approve, + comment: Some("LGTM".to_string()), + decided_at: chrono::DateTime::parse_from_rfc3339("2025-06-01T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + }] + } + + fn sample_subject() -> CeremonySubject { + CeremonySubject::PipelineMerge { + run_id: "run-1".to_string(), + pipeline_name: "deploy".to_string(), + branch: "main".to_string(), + commit_hash: "abc123".to_string(), + remote_name: "origin".to_string(), + } + } + + #[test] + fn resolution_has_proof_hash() { + let res = CeremonyResolution::from_ceremony( + "cer-001", + GovernanceCeremonyStatus::Approved, + &sample_subject(), + &sample_approvals(), + ); + assert!(!res.proof_hash.is_empty()); + assert_eq!(res.proof_hash.len(), 64); // SHA-256 hex + } + + #[test] + fn proof_hash_verifies() { + let res = CeremonyResolution::from_ceremony( + "cer-001", + GovernanceCeremonyStatus::Approved, + &sample_subject(), + &sample_approvals(), + ); + assert!(res.verify_proof()); + } + + #[test] + fn tampered_resolution_fails_verification() { + let mut res = CeremonyResolution::from_ceremony( + "cer-001", + GovernanceCeremonyStatus::Approved, + &sample_subject(), + &sample_approvals(), + ); + res.ceremony_id = "cer-999".to_string(); // tamper + assert!(!res.verify_proof()); + } + + #[test] + fn denied_resolution() { + let denials = vec![CeremonyApproval { + approver_identity: "bob@ops".to_string(), + approver_role: "msp-ops".to_string(), + decision: ApprovalDecision::Deny, + comment: Some("unacceptable risk".to_string()), + decided_at: Utc::now(), + }]; + let res = CeremonyResolution::from_ceremony( + "cer-002", + GovernanceCeremonyStatus::Denied, + &sample_subject(), + &denials, + ); + assert_eq!(res.status, GovernanceCeremonyStatus::Denied); + assert!(res.verify_proof()); + } + + #[test] + fn serialization_round_trip() { + let res = CeremonyResolution::from_ceremony( + "cer-003", + GovernanceCeremonyStatus::Approved, + &sample_subject(), + &sample_approvals(), + ); + let json = serde_json::to_string(&res).unwrap(); + let parsed: CeremonyResolution = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.ceremony_id, res.ceremony_id); + assert_eq!(parsed.proof_hash, res.proof_hash); + } +} diff --git a/ceremony-engine/src/store.rs b/ceremony-engine/src/store.rs new file mode 100644 index 0000000..7fb8004 --- /dev/null +++ b/ceremony-engine/src/store.rs @@ -0,0 +1,360 @@ +//! Ceremony persistence — trait and in-memory implementation. +//! +//! The [`CeremonyStore`] trait abstracts ceremony persistence so that +//! tests use [`InMemoryCeremonyStore`] while production uses Postgres. + +use std::collections::HashMap; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use tokio::sync::RwLock; + +use crate::request::{GovernanceCeremonyRequest, GovernanceCeremonyStatus}; + +#[derive(Debug, thiserror::Error)] +pub enum CeremonyStoreError { + #[error("ceremony not found: {0}")] + NotFound(String), + #[error("ceremony already exists: {0}")] + AlreadyExists(String), + #[error("store error: {0}")] + Internal(String), +} + +/// Persistence trait for governance ceremonies. +#[async_trait] +pub trait CeremonyStore: Send + Sync { + async fn create( + &self, + ceremony: &GovernanceCeremonyRequest, + ) -> Result<(), CeremonyStoreError>; + + async fn get( + &self, + ceremony_id: &str, + ) -> Result, CeremonyStoreError>; + + async fn update( + &self, + ceremony: &GovernanceCeremonyRequest, + ) -> Result<(), CeremonyStoreError>; + + /// List all pending ceremonies, optionally filtered by intent_id. + async fn list_pending( + &self, + intent_id: Option<&str>, + ) -> Result, CeremonyStoreError>; + + /// List ceremonies by status. + async fn list_by_status( + &self, + status: GovernanceCeremonyStatus, + ) -> Result, CeremonyStoreError>; + + /// Find ceremonies that have expired (past expires_at but still Pending). + async fn find_expired( + &self, + now: DateTime, + ) -> Result, CeremonyStoreError>; + + /// Find a pending ceremony for a specific subject key. + /// Used to deduplicate — e.g., only one ceremony per (run_id, intent_id). + async fn find_by_subject_key( + &self, + key: &str, + ) -> Result, CeremonyStoreError>; +} + +/// In-memory ceremony store for testing. +pub struct InMemoryCeremonyStore { + data: RwLock>, +} + +impl InMemoryCeremonyStore { + pub fn new() -> Self { + Self { + data: RwLock::new(HashMap::new()), + } + } +} + +impl Default for InMemoryCeremonyStore { + fn default() -> Self { + Self::new() + } +} + +/// Derive a subject key for deduplication. +fn subject_key(ceremony: &GovernanceCeremonyRequest) -> String { + use crate::request::CeremonySubject; + match &ceremony.subject { + CeremonySubject::MutationIntent { intent_id, .. } => { + format!("intent:{intent_id}") + } + CeremonySubject::PipelineMerge { run_id, .. } => { + format!("run:{run_id}") + } + CeremonySubject::SchematicPublish { + schematic_name, + version, + .. + } => { + format!("schematic:{schematic_name}:{version}") + } + CeremonySubject::Custom { + subject_type, + reference_id, + .. + } => { + format!("custom:{subject_type}:{reference_id}") + } + CeremonySubject::GitOpsSync { + tool, + resource_name, + resource_namespace, + .. + } => { + format!("gitops:{tool}:{resource_namespace}/{resource_name}") + } + } +} + +#[async_trait] +impl CeremonyStore for InMemoryCeremonyStore { + async fn create( + &self, + ceremony: &GovernanceCeremonyRequest, + ) -> Result<(), CeremonyStoreError> { + let mut data = self.data.write().await; + if data.contains_key(&ceremony.ceremony_id) { + return Err(CeremonyStoreError::AlreadyExists( + ceremony.ceremony_id.clone(), + )); + } + data.insert(ceremony.ceremony_id.clone(), ceremony.clone()); + Ok(()) + } + + async fn get( + &self, + ceremony_id: &str, + ) -> Result, CeremonyStoreError> { + let data = self.data.read().await; + Ok(data.get(ceremony_id).cloned()) + } + + async fn update( + &self, + ceremony: &GovernanceCeremonyRequest, + ) -> Result<(), CeremonyStoreError> { + let mut data = self.data.write().await; + if !data.contains_key(&ceremony.ceremony_id) { + return Err(CeremonyStoreError::NotFound( + ceremony.ceremony_id.clone(), + )); + } + data.insert(ceremony.ceremony_id.clone(), ceremony.clone()); + Ok(()) + } + + async fn list_pending( + &self, + intent_id: Option<&str>, + ) -> Result, CeremonyStoreError> { + let data = self.data.read().await; + let items: Vec = data + .values() + .filter(|c| c.status == GovernanceCeremonyStatus::Pending) + .filter(|c| match intent_id { + Some(id) => c.intent_id.as_deref() == Some(id), + None => true, + }) + .cloned() + .collect(); + Ok(items) + } + + async fn list_by_status( + &self, + status: GovernanceCeremonyStatus, + ) -> Result, CeremonyStoreError> { + let data = self.data.read().await; + let items: Vec = data + .values() + .filter(|c| c.status == status) + .cloned() + .collect(); + Ok(items) + } + + async fn find_expired( + &self, + now: DateTime, + ) -> Result, CeremonyStoreError> { + let data = self.data.read().await; + let items: Vec = data + .values() + .filter(|c| { + c.status == GovernanceCeremonyStatus::Pending && c.expires_at <= now + }) + .cloned() + .collect(); + Ok(items) + } + + async fn find_by_subject_key( + &self, + key: &str, + ) -> Result, CeremonyStoreError> { + let data = self.data.read().await; + Ok(data + .values() + .find(|c| { + c.status == GovernanceCeremonyStatus::Pending && subject_key(c) == key + }) + .cloned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request::{CeremonySubject, GovernanceCeremonyRequest}; + use accord_core::schema::CeremonyType; + use chrono::Duration; + + fn sample_ceremony(id: &str) -> GovernanceCeremonyRequest { + let now = Utc::now(); + GovernanceCeremonyRequest { + ceremony_id: id.to_string(), + ceremony_type: CeremonyType::SingleApproval, + subject: CeremonySubject::PipelineMerge { + run_id: format!("run-{id}"), + pipeline_name: "deploy".to_string(), + branch: "main".to_string(), + commit_hash: "abc".to_string(), + remote_name: "origin".to_string(), + }, + required_approvals: 1, + approver_roles: vec!["msp-ops".to_string()], + approvals: vec![], + status: GovernanceCeremonyStatus::Pending, + created_at: now, + expires_at: now + Duration::hours(24), + intent_id: None, + run_id: Some(format!("run-{id}")), + pr_number: None, + remote_name: None, + } + } + + #[tokio::test] + async fn create_and_get() { + let store = InMemoryCeremonyStore::new(); + let cer = sample_ceremony("cer-1"); + store.create(&cer).await.unwrap(); + let got = store.get("cer-1").await.unwrap(); + assert!(got.is_some()); + assert_eq!(got.unwrap().ceremony_id, "cer-1"); + } + + #[tokio::test] + async fn duplicate_create_fails() { + let store = InMemoryCeremonyStore::new(); + let cer = sample_ceremony("cer-dup"); + store.create(&cer).await.unwrap(); + let err = store.create(&cer).await.unwrap_err(); + assert!(matches!(err, CeremonyStoreError::AlreadyExists(_))); + } + + #[tokio::test] + async fn update_existing() { + let store = InMemoryCeremonyStore::new(); + let mut cer = sample_ceremony("cer-upd"); + store.create(&cer).await.unwrap(); + cer.status = GovernanceCeremonyStatus::Approved; + store.update(&cer).await.unwrap(); + let got = store.get("cer-upd").await.unwrap().unwrap(); + assert_eq!(got.status, GovernanceCeremonyStatus::Approved); + } + + #[tokio::test] + async fn update_nonexistent_fails() { + let store = InMemoryCeremonyStore::new(); + let cer = sample_ceremony("cer-ghost"); + let err = store.update(&cer).await.unwrap_err(); + assert!(matches!(err, CeremonyStoreError::NotFound(_))); + } + + #[tokio::test] + async fn list_pending_ceremonies() { + let store = InMemoryCeremonyStore::new(); + store.create(&sample_ceremony("cer-a")).await.unwrap(); + store.create(&sample_ceremony("cer-b")).await.unwrap(); + let mut approved = sample_ceremony("cer-c"); + approved.status = GovernanceCeremonyStatus::Approved; + store.create(&approved).await.unwrap(); + + let pending = store.list_pending(None).await.unwrap(); + assert_eq!(pending.len(), 2); + } + + #[tokio::test] + async fn list_pending_filtered_by_intent() { + let store = InMemoryCeremonyStore::new(); + let mut cer1 = sample_ceremony("cer-i1"); + cer1.intent_id = Some("intent-abc".to_string()); + let mut cer2 = sample_ceremony("cer-i2"); + cer2.intent_id = Some("intent-xyz".to_string()); + + store.create(&cer1).await.unwrap(); + store.create(&cer2).await.unwrap(); + + let filtered = store.list_pending(Some("intent-abc")).await.unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].ceremony_id, "cer-i1"); + } + + #[tokio::test] + async fn find_expired_ceremonies() { + let store = InMemoryCeremonyStore::new(); + let mut expired = sample_ceremony("cer-exp"); + expired.expires_at = Utc::now() - Duration::hours(1); + store.create(&expired).await.unwrap(); + + let fresh = sample_ceremony("cer-fresh"); + store.create(&fresh).await.unwrap(); + + let found = store.find_expired(Utc::now()).await.unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].ceremony_id, "cer-exp"); + } + + #[tokio::test] + async fn find_by_subject_key_dedup() { + let store = InMemoryCeremonyStore::new(); + let cer = sample_ceremony("cer-dedup"); + store.create(&cer).await.unwrap(); + + let found = store.find_by_subject_key("run:run-cer-dedup").await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().ceremony_id, "cer-dedup"); + + let not_found = store.find_by_subject_key("run:run-other").await.unwrap(); + assert!(not_found.is_none()); + } + + #[tokio::test] + async fn list_by_status() { + let store = InMemoryCeremonyStore::new(); + store.create(&sample_ceremony("cer-p1")).await.unwrap(); + let mut denied = sample_ceremony("cer-d1"); + denied.status = GovernanceCeremonyStatus::Denied; + store.create(&denied).await.unwrap(); + + let pending = store.list_by_status(GovernanceCeremonyStatus::Pending).await.unwrap(); + assert_eq!(pending.len(), 1); + let denied_list = store.list_by_status(GovernanceCeremonyStatus::Denied).await.unwrap(); + assert_eq!(denied_list.len(), 1); + } +} diff --git a/proto/bascule/v1/ceremony.proto b/proto/bascule/v1/ceremony.proto new file mode 100644 index 0000000..5573e7d --- /dev/null +++ b/proto/bascule/v1/ceremony.proto @@ -0,0 +1,156 @@ +syntax = "proto3"; +package bascule.v1; + +import "google/protobuf/timestamp.proto"; + +// Governance Ceremony Service — multi-stakeholder approval flows +// triggered by Accord policy when a mutation requires human sign-off. +service CeremonyService { + // Create a new governance ceremony. + rpc CreateCeremony (CreateCeremonyRequest) returns (CreateCeremonyResponse); + + // Record an approval or denial on a pending ceremony. + rpc ApproveCeremony (ApproveCeremonyRequest) returns (ApproveCeremonyResponse); + + // Deny a pending ceremony. + rpc DenyCeremony (DenyCeremonyRequest) returns (DenyCeremonyResponse); + + // Cancel a pending ceremony (requestor or admin). + rpc CancelCeremony (CancelCeremonyRequest) returns (CancelCeremonyResponse); + + // Get the current status of a ceremony. + rpc GetCeremony (GetCeremonyRequest) returns (GetCeremonyResponse); + + // List pending ceremonies, optionally filtered. + rpc ListPendingCeremonies (ListPendingCeremoniesRequest) returns (ListPendingCeremoniesResponse); + + // Get the resolution proof for a completed ceremony. + rpc GetCeremonyProof (GetCeremonyProofRequest) returns (GetCeremonyProofResponse); +} + +// --- Create --- + +message CreateCeremonyRequest { + string ceremony_type = 1; // "single_approval", "quorum_approval", etc. + CeremonySubjectMsg subject = 2; + uint32 required_approvals = 3; + repeated string approver_roles = 4; + uint32 ttl_hours = 5; // 0 = default (24h) + string intent_id = 6; // optional linked MutationIntent + string run_id = 7; // optional linked pipeline run + uint64 pr_number = 8; // optional linked PR + string remote_name = 9; // optional remote name +} + +message CreateCeremonyResponse { + string ceremony_id = 1; + string status = 2; // "pending" or "approved" (for self-grant) + google.protobuf.Timestamp expires_at = 3; + string error = 4; +} + +// --- Approve --- + +message ApproveCeremonyRequest { + string ceremony_id = 1; + string approver_identity = 2; + string approver_role = 3; + string comment = 4; +} + +message ApproveCeremonyResponse { + bool success = 1; + string status = 2; // updated status after approval + string error = 3; +} + +// --- Deny --- + +message DenyCeremonyRequest { + string ceremony_id = 1; + string approver_identity = 2; + string approver_role = 3; + string comment = 4; +} + +message DenyCeremonyResponse { + bool success = 1; + string status = 2; + string error = 3; +} + +// --- Cancel --- + +message CancelCeremonyRequest { + string ceremony_id = 1; +} + +message CancelCeremonyResponse { + bool success = 1; + string error = 2; +} + +// --- Get --- + +message GetCeremonyRequest { + string ceremony_id = 1; +} + +message GetCeremonyResponse { + string ceremony_id = 1; + string ceremony_type = 2; + CeremonySubjectMsg subject = 3; + string status = 4; + uint32 required_approvals = 5; + uint32 current_approvals = 6; + repeated CeremonyApprovalMsg approvals = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp expires_at = 9; + string intent_id = 10; + string run_id = 11; + uint64 pr_number = 12; + string remote_name = 13; + string error = 14; +} + +// --- List Pending --- + +message ListPendingCeremoniesRequest { + string intent_id = 1; // optional filter +} + +message ListPendingCeremoniesResponse { + repeated GetCeremonyResponse ceremonies = 1; +} + +// --- Proof --- + +message GetCeremonyProofRequest { + string ceremony_id = 1; +} + +message GetCeremonyProofResponse { + string ceremony_id = 1; + string status = 2; + string proof_hash = 3; + repeated CeremonyApprovalMsg approvals = 4; + google.protobuf.Timestamp resolved_at = 5; + string error = 6; +} + +// --- Shared messages --- + +message CeremonySubjectMsg { + string subject_type = 1; // "mutation_intent", "pipeline_merge", "schematic_publish", "custom" + string reference_id = 2; // intent_id, run_id, "name:version", or custom ref + string description = 3; // human-readable label + map metadata = 4; // extra fields +} + +message CeremonyApprovalMsg { + string approver_identity = 1; + string approver_role = 2; + string decision = 3; // "approve" or "deny" + string comment = 4; + google.protobuf.Timestamp decided_at = 5; +} diff --git a/proto/bascule/v1/command.proto b/proto/bascule/v1/command.proto new file mode 100644 index 0000000..c1a204b --- /dev/null +++ b/proto/bascule/v1/command.proto @@ -0,0 +1,82 @@ +syntax = "proto3"; +package bascule.v1; + +import "google/protobuf/struct.proto"; + +// --- Command execution --- + +message ExecuteCommandRequest { + string session_id = 1; + string verb = 2; + optional string namespace = 3; + optional string resource_type = 4; + optional string resource_name = 5; + google.protobuf.Struct parameters = 6; + string output_format = 7; +} + +message ExecuteCommandResponse { + bool allowed = 1; + string denied_reason = 2; + oneof result { + CommandResult success = 3; + CommandError error = 4; + } + AuditRef audit = 5; +} + +message CommandResult { + string output = 1; + uint32 resources_affected = 2; + bool session_expired_warning = 3; +} + +message CommandError { + string message = 1; + string code = 2; +} + +// --- Streaming --- + +message CommandStreamChunk { + oneof chunk { + string output_line = 1; + string error_line = 2; + bool eof = 3; + } +} + +// --- Audit reference --- + +message AuditRef { + string event_id = 1; + string classification = 2; + bool notarized = 3; +} + +// --- Command discovery --- + +message DiscoverCommandsRequest { + string session_id = 1; +} + +message DiscoverCommandsResponse { + repeated CommandDescriptor commands = 1; +} + +message CommandDescriptor { + string verb = 1; + string description = 2; + string classification = 3; + repeated ParameterDescriptor parameters = 4; + bool requires_namespace = 5; + bool requires_resource = 6; + bool streaming = 7; +} + +message ParameterDescriptor { + string name = 1; + string description = 2; + string param_type = 3; + bool required = 4; +} diff --git a/proto/bascule/v1/gateway.proto b/proto/bascule/v1/gateway.proto new file mode 100644 index 0000000..2a9ca60 --- /dev/null +++ b/proto/bascule/v1/gateway.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; +package bascule.v1; + +import "bascule/v1/session.proto"; +import "bascule/v1/command.proto"; + +service BasculeGateway { + // Session lifecycle + rpc RequestSession (RequestSessionRequest) returns (RequestSessionResponse); + rpc GetSessionStatus (GetSessionStatusRequest) returns (GetSessionStatusResponse); + rpc EndSession (EndSessionRequest) returns (EndSessionResponse); + rpc GetCeremonyStatus (GetCeremonyStatusRequest) returns (GetCeremonyStatusResponse); + + // Command execution + rpc ExecuteCommand (ExecuteCommandRequest) returns (ExecuteCommandResponse); + rpc StreamCommand (ExecuteCommandRequest) returns (stream CommandStreamChunk); + + // Discovery + rpc DiscoverCommands (DiscoverCommandsRequest) returns (DiscoverCommandsResponse); +} diff --git a/proto/bascule/v1/session.proto b/proto/bascule/v1/session.proto new file mode 100644 index 0000000..a49f98b --- /dev/null +++ b/proto/bascule/v1/session.proto @@ -0,0 +1,110 @@ +syntax = "proto3"; +package bascule.v1; + +import "google/protobuf/timestamp.proto"; + +// --- Session request / response --- + +message RequestSessionRequest { + string ceremony_type = 1; + SessionScope requested_scope = 2; + repeated EvidenceItem evidence = 3; +} + +message RequestSessionResponse { + oneof result { + SessionGranted granted = 1; + CeremonyPending pending = 2; + CeremonyDenied denied = 3; + } +} + +message SessionGranted { + string session_id = 1; + SessionScope granted_scope = 2; + google.protobuf.Timestamp expires_at = 3; + string ceremony_id = 4; +} + +message CeremonyPending { + string ceremony_id = 1; + string message = 2; + google.protobuf.Timestamp timeout_at = 3; +} + +message CeremonyDenied { + string reason = 1; +} + +// --- Scope model --- + +message SessionScope { + repeated NamespaceScope namespaces = 1; + GlobalScope global = 2; + repeated string pathways = 3; + optional uint32 mutation_budget = 4; + bool can_delegate = 5; +} + +message NamespaceScope { + string namespace = 1; + repeated ScopeRule rules = 2; + repeated string workload_profiles = 3; + repeated string denied_capabilities = 4; +} + +message ScopeRule { + repeated string api_groups = 1; + repeated string resources = 2; + repeated string verbs = 3; +} + +message GlobalScope { + bool can_view_audit_trail = 1; + bool can_view_profiles = 2; + bool can_view_topology = 3; +} + +message EvidenceItem { + string evidence_type = 1; + string reference = 2; +} + +// --- Session status --- + +message GetSessionStatusRequest { + string session_id = 1; +} + +message GetSessionStatusResponse { + string session_id = 1; + string state = 2; + SessionScope scope = 3; + google.protobuf.Timestamp expires_at = 4; + uint32 mutations_used = 5; + optional uint32 mutation_budget = 6; +} + +// --- Session end --- + +message EndSessionRequest { + string session_id = 1; +} + +message EndSessionResponse { + bool success = 1; + uint32 total_commands = 2; + uint32 total_mutations = 3; +} + +// --- Ceremony status --- + +message GetCeremonyStatusRequest { + string ceremony_id = 1; +} + +message GetCeremonyStatusResponse { + string ceremony_id = 1; + string status = 2; + optional SessionGranted session = 3; +}