diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..983c196 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +if has devenv; then + eval "$(devenv print-dev-env)" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77e1ce6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "27.x" + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check (default features) + run: cargo check + + - name: Check (all features) + run: cargo check --all-features + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "27.x" + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Clippy (default features) + run: cargo clippy -- -D warnings + + - name: Clippy (all features) + run: cargo clippy --all-features -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "27.x" + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Unit tests (default features) + run: cargo test --lib + + - name: Unit tests (all features) + run: cargo test --all-features --lib + + - name: Doc tests + run: cargo test --all-features --doc + + - name: Integration tests + run: cargo test --all-features --test integration + timeout-minutes: 5 + + doc: + name: Documentation + runs-on: ubuntu-latest + env: + RUSTDOCFLAGS: -Dwarnings + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "27.x" + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build docs + run: cargo doc --all-features --no-deps + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397fec5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Rust build artifacts +/target/ + +# Nix/devenv +.devenv/ +.devenv.flake.nix +.pre-commit-config.yaml + +# direnv +.direnv/ + +# OS files +.DS_Store + +# Editor +*.swp +*.swo +*~ + +# Test binary (remove later) +test2 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..dff8011 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "proto/authzed-api"] + path = proto/authzed-api + url = https://github.com/authzed/api.git diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..235f009 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2919 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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 = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + +[[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 = "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 = "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", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[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", +] + +[[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 = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bollard" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227aa051deec8d16bd9c34605e7aaf153f240e35483dd42f6f78903847934738" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.2", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic 0.14.5", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost 0.14.3", + "prost-types 0.14.3", + "tonic 0.14.5", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost 0.14.3", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bumpalo" +version = "3.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[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 = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[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 = "docker_credential" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ferroid" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +dependencies = [ + "portable-atomic", + "rand 0.9.2", + "web-time", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[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 = "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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[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.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "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 = "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-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[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", + "rustls", + "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-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.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", + "serde", +] + +[[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 = "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 = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[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.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[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 = "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 = "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 = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[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-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "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", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[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 = "prescience" +version = "0.1.0" +dependencies = [ + "async-stream", + "futures-core", + "http", + "prost 0.13.5", + "prost-types 0.13.5", + "serde", + "testcontainers", + "thiserror", + "tokio", + "tokio-stream", + "tonic 0.12.3", + "tonic-build", + "tower 0.5.3", + "tracing", +] + +[[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 = "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 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive 0.14.3", +] + +[[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 0.13.5", + "prost-types 0.13.5", + "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-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +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 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[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 = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-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 = [ + "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 = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +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" + +[[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 = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testcontainers" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fdcea723c64cc08dbc533b3761e345a15bf1222cbe6cb611de09b43f17a168" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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 = "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 = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", +] + +[[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", + "tokio", +] + +[[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 0.13.5", + "rustls-pemfile", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "axum 0.8.8", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower 0.5.3", + "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 0.13.5", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost 0.14.3", + "tonic 0.14.5", +] + +[[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 0.8.5", + "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", + "indexmap 2.13.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "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 = [ + "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", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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 = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +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-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[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.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-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.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +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 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[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 = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[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.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +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" + +[[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..f2d844d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "prescience" +version = "0.1.0" +edition = "2021" +description = "An idiomatic Rust client library for SpiceDB" +license = "Apache-2.0" +repository = "https://github.com/rawkode/prescience" +keywords = ["spicedb", "authzed", "authorization", "zanzibar", "grpc"] +categories = ["authentication", "api-bindings"] + +[features] +default = [] +watch = [] +experimental = [] +serde = ["dep:serde"] +tls-rustls = ["tonic/tls-webpki-roots"] +tls-native = ["tonic/tls"] + +[dependencies] +async-stream = "0.3" +futures-core = "0.3" +http = "1" +prost = "0.13" +prost-types = "0.13" +serde = { version = "1", features = ["derive"], optional = true } +thiserror = "2" +tokio = { version = "1", features = ["macros"] } +tokio-stream = "0.1" +tonic = { version = "0.12", features = ["transport"] } +tower = "0.5" +tracing = "0.1" + +[build-dependencies] +tonic-build = "0.12" + +[dev-dependencies] +testcontainers = "0.27" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/README.md b/README.md index 5c70e82..139b599 100644 --- a/README.md +++ b/README.md @@ -1 +1,139 @@ -# Prescience\n\nAn idiomatic Rust client library for SpiceDB. +# Prescience + +An idiomatic Rust client library for [SpiceDB](https://authzed.com/spicedb), the open-source, Google Zanzibar-inspired authorization system. + +## Features + +- **Type-safe**: Wraps the SpiceDB gRPC API with idiomatic Rust types — no raw protobufs +- **Async-first**: Built on [tonic](https://github.com/hyperium/tonic) and tokio +- **3-state permissions**: `PermissionResult` correctly models `Allowed`, `Denied`, and `Conditional` (caveated) outcomes +- **Streaming**: All streaming RPCs return `impl Stream>` +- **Shareable**: `Client` is `Clone + Send + Sync` — clone it freely across tasks +- **Feature-gated**: `watch`, `experimental`, `serde`, TLS backends + +## Quick Start + +```rust +use prescience::{Client, ObjectReference, SubjectReference, Consistency, PermissionResult}; + +#[tokio::main] +async fn main() -> Result<(), prescience::Error> { + let client = Client::new("http://localhost:50051", "my-token").await?; + + let result = client + .check_permission( + &ObjectReference::new("document", "doc-123")?, + "view", + &SubjectReference::new(ObjectReference::new("user", "alice")?, None::)?, + ) + .consistency(Consistency::FullyConsistent) + .await?; + + match result { + PermissionResult::Allowed => println!("access granted"), + PermissionResult::Denied => println!("access denied"), + PermissionResult::Conditional { missing_fields } => { + println!("need caveat context: {:?}", missing_fields); + } + } + + Ok(()) +} +``` + +## Writing Relationships + +```rust +use prescience::{Client, ObjectReference, SubjectReference, Relationship, RelationshipUpdate}; + +# async fn example(client: &Client) -> Result<(), prescience::Error> { +let token = client + .write_relationships(vec![ + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "doc-123")?, + "viewer", + SubjectReference::new(ObjectReference::new("user", "bob")?, None::)?, + )), + ]) + .await?; + +// Use the token for subsequent reads +let result = client + .check_permission( + &ObjectReference::new("document", "doc-123")?, + "view", + &SubjectReference::new(ObjectReference::new("user", "bob")?, None::)?, + ) + .consistency(prescience::Consistency::AtLeastAsFresh(token)) + .await?; +# Ok(()) +# } +``` + +## Streaming Lookups + +```rust +use tokio_stream::StreamExt; + +# async fn example(client: &prescience::Client) -> Result<(), prescience::Error> { +let subject = prescience::SubjectReference::new( + prescience::ObjectReference::new("user", "alice")?, + None::, +)?; + +let mut stream = client + .lookup_resources("document", "view", &subject) + .consistency(prescience::Consistency::FullyConsistent) + .send() + .await?; + +while let Some(result) = stream.next().await { + let item = result?; + println!("resource: {}, permission: {:?}", item.resource_id, item.permission); +} +# Ok(()) +# } +``` + +## Feature Flags + +| Feature | Default | Description | +|---------|---------|-------------| +| `watch` | No | WatchService for streaming relationship changes | +| `experimental` | No | Bulk APIs: BulkCheckPermission, BulkImport/Export | +| `serde` | No | Serialize/Deserialize on ZedToken and domain types | +| `tls-rustls` | No | Use rustls for TLS | +| `tls-native` | No | Use native/system TLS | + +## Error Handling + +All methods return `Result`. The error type provides: + +- **Structured matching**: `Error::Transport`, `Error::Status`, `Error::InvalidArgument`, etc. +- **Retryability**: `error.is_retryable()` returns `true` for `UNAVAILABLE` and `DEADLINE_EXCEEDED` +- **gRPC code access**: `error.code()` returns the gRPC status code + +## Development + +This project uses [devenv](https://devenv.sh) for a reproducible development environment: + +```bash +# Enter the dev shell (provides Rust, protoc, SpiceDB) +devenv shell + +# Build +cargo build --all-features + +# Test +cargo test --all-features + +# Lint +cargo clippy --all-features -- -D warnings + +# Start a local SpiceDB for integration testing +spicedb serve --grpc-preshared-key "test-key" --datastore-engine memory +``` + +## License + +Apache-2.0 diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..42ddf9d --- /dev/null +++ b/build.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let proto_root = PathBuf::from("proto/authzed-api"); + let stubs_root = PathBuf::from("proto/stubs"); + + let proto_files = &[ + "authzed/api/v1/core.proto", + "authzed/api/v1/permission_service.proto", + "authzed/api/v1/schema_service.proto", + "authzed/api/v1/watch_service.proto", + "authzed/api/v1/debug.proto", + "authzed/api/v1/experimental_service.proto", + ]; + + let proto_paths: Vec = proto_files.iter().map(|f| proto_root.join(f)).collect(); + + // Include paths for resolving imports + let includes = &[proto_root.clone(), stubs_root]; + + tonic_build::configure() + .build_server(false) + .extern_path(".google.rpc.Status", "crate::proto::google::rpc::Status") + .compile_protos(&proto_paths, includes)?; + + // Rerun if proto files change + for f in proto_files { + println!("cargo:rerun-if-changed=proto/authzed-api/{}", f); + } + + Ok(()) +} diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..7338d80 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,143 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1771513566, + "owner": "cachix", + "repo": "devenv", + "rev": "f4f0ed5fd101831651abd7a249cc62eb464f249c", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770726378, + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "5eaaedde414f6eb1aea8b8525c466dc37bba95ae", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1762808025, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1770434727, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "8430f16a39c27bdeef236f1eeb56f0b51b33d348", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1769922788, + "narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "207d15f1a6603226e1e223dc79ac29c7846da32e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": [ + "git-hooks" + ], + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1771470520, + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "a1d4cc1f264c45d3745af0d2ca5e59d460e58777", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..837abc3 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,61 @@ +{ pkgs, ... }: + +{ + # Project metadata + name = "prescience"; + + # Core build tools + packages = with pkgs; [ + # Protobuf compiler — required by tonic-build in build.rs + protobuf + + # Cargo utilities + cargo-watch + cargo-nextest + + # SpiceDB — for integration testing + spicedb + spicedb-zed + ]; + + # Rust toolchain + languages.rust = { + enable = true; + channel = "stable"; + }; + + # Pre-commit hooks for code quality + # Note: clippy pre-commit is disabled because it requires a full build + # and conflicts with mixed Rust toolchain versions. Run `cargo clippy` manually. + pre-commit.hooks = { + rustfmt.enable = true; + }; + + # Environment variables + env = { + # Ensure protoc is discoverable by tonic-build + PROTOC = "${pkgs.protobuf}/bin/protoc"; + }; + + # Handy scripts + scripts = { + build.exec = "cargo build"; + test.exec = "cargo nextest run"; + test-all.exec = "cargo nextest run --all-features"; + lint.exec = "cargo clippy --all-features -- -D warnings"; + fmt.exec = "cargo fmt --check"; + + # Start a local SpiceDB for integration testing + spicedb-up.exec = '' + echo "Starting SpiceDB on :50051 (in-memory, insecure)..." + spicedb serve --grpc-preshared-key "test-key" --datastore-engine memory + ''; + }; + + # Ensure submodules are initialized + enterShell = '' + if [ ! -f proto/authzed-api/authzed/api/v1/core.proto ]; then + echo "⚠️ Proto submodule not initialized. Run: git submodule update --init --recursive" + fi + ''; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..c08ccc8 --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,6 @@ +inputs: + rust-overlay: + url: github:oxalica/rust-overlay + inputs: + nixpkgs: + follows: nixpkgs diff --git a/proto/authzed-api b/proto/authzed-api new file mode 160000 index 0000000..5253a15 --- /dev/null +++ b/proto/authzed-api @@ -0,0 +1 @@ +Subproject commit 5253a15fb25cb0d1d6b334039613ce0e824db23b diff --git a/proto/stubs/buf/validate/validate.proto b/proto/stubs/buf/validate/validate.proto new file mode 100644 index 0000000..e30d1b0 --- /dev/null +++ b/proto/stubs/buf/validate/validate.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; +package buf.validate; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional FieldConstraints field = 1071; +} + +extend google.protobuf.OneofOptions { + optional OneofConstraints oneof = 1071; +} + +message FieldConstraints { + bool required = 25; + StringRules string = 14; + UInt32Rules uint32 = 5; + BoolRules bool = 13; + EnumRules enum = 16; + RepeatedRules repeated = 18; + MessageConstraints message = 17; +} + +message OneofConstraints { + bool required = 1; +} + +message StringRules { + string pattern = 2; + uint64 max_bytes = 4; + uint64 min_bytes = 3; +} + +message UInt32Rules { + uint32 gte = 5; +} + +message BoolRules { + bool const = 1; +} + +message EnumRules { + bool defined_only = 1; + repeated int32 not_in = 3; +} + +message RepeatedRules { + uint64 min_items = 1; + ItemConstraints items = 3; +} + +message ItemConstraints { + bool required = 25; + StringRules string = 14; + MessageConstraints message = 17; +} + +message MessageConstraints { + bool required = 1; +} diff --git a/proto/stubs/google/api/annotations.proto b/proto/stubs/google/api/annotations.proto new file mode 100644 index 0000000..4ded60f --- /dev/null +++ b/proto/stubs/google/api/annotations.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; +package google.api; + +import "google/protobuf/descriptor.proto"; +import "google/api/http.proto"; + +extend google.protobuf.MethodOptions { + HttpRule http = 72295728; +} diff --git a/proto/stubs/google/api/http.proto b/proto/stubs/google/api/http.proto new file mode 100644 index 0000000..8339b80 --- /dev/null +++ b/proto/stubs/google/api/http.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; +package google.api; + +message HttpRule { + string get = 2; + string post = 4; + string put = 5; + string delete = 6; + string patch = 7; + string body = 8; +} diff --git a/proto/stubs/google/rpc/status.proto b/proto/stubs/google/rpc/status.proto new file mode 100644 index 0000000..8a102fa --- /dev/null +++ b/proto/stubs/google/rpc/status.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; +package google.rpc; + +import "google/protobuf/any.proto"; + +message Status { + int32 code = 1; + string message = 2; + repeated google.protobuf.Any details = 3; +} diff --git a/proto/stubs/protoc-gen-openapiv2/options/annotations.proto b/proto/stubs/protoc-gen-openapiv2/options/annotations.proto new file mode 100644 index 0000000..844be4e --- /dev/null +++ b/proto/stubs/protoc-gen-openapiv2/options/annotations.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; +package grpc.gateway.protoc_gen_openapiv2.options; + +import "google/protobuf/descriptor.proto"; + +message Operation { + repeated string tags = 1; +} + +extend google.protobuf.MethodOptions { + Operation openapiv2_operation = 1042; +} diff --git a/proto/stubs/validate/validate.proto b/proto/stubs/validate/validate.proto new file mode 100644 index 0000000..5f4e1cc --- /dev/null +++ b/proto/stubs/validate/validate.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; +package validate; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional FieldRules rules = 1170; +} + +extend google.protobuf.OneofOptions { + optional bool required = 1170; +} + +message FieldRules { + StringRules string = 12; + BoolRules bool = 13; + EnumRules enum = 16; + RepeatedRules repeated = 18; + MessageRules message = 17; + UInt32Rules uint32 = 5; +} + +message StringRules { + string pattern = 2; + uint64 max_bytes = 4; + uint64 min_bytes = 3; +} + +message BoolRules { + bool const = 1; +} + +message EnumRules { + bool defined_only = 1; + repeated int32 not_in = 3; +} + +message RepeatedRules { + uint64 min_items = 1; + ItemRules items = 3; +} + +message ItemRules { + StringRules string = 12; + MessageRules message = 17; +} + +message MessageRules { + bool required = 1; +} + +message UInt32Rules { + uint32 gte = 5; +} diff --git a/specs/requirements/001-rust-spicedb-client.md b/specs/requirements/001-rust-spicedb-client.md new file mode 100644 index 0000000..d92d2dc --- /dev/null +++ b/specs/requirements/001-rust-spicedb-client.md @@ -0,0 +1,781 @@ +# 001 — Rust SpiceDB Client Library + +**Date**: 2026-02-19 +**Status**: Draft v4 (post-review rework round 3) +**Author**: Requirements interview +**Review**: Third rework pass. Round 1: all 4 REWORK (6 structural issues). Round 2: PO APPROVED, 3 REWORK (4 targeted issues). Round 3: PO+TS APPROVED, SD+DR REWORK (FR-9.2 trait blanket rule). v4 fixes FR-9.2 with per-type trait specifications. + +--- + +## Problem Statement + +Rust developers building backend services that use [SpiceDB](https://github.com/authzed/spicedb) for authorization currently lack a well-maintained, production-quality, idiomatic Rust client library. The existing community client ([lunaetco/spicedb-client](https://github.com/lunaetco/spicedb-client)) may be stale, incomplete, or insufficiently idiomatic. This project delivers a standalone Rust library crate (`prescience`) that wraps the full SpiceDB gRPC API with strong types, ergonomic builders, and first-class async support — giving Rust teams a dependency they can confidently ship to production. + +--- + +## Users & Actors + +| Actor | Description | Key Needs | +|---|---|---| +| **Rust backend developer** (primary) | Integrates SpiceDB permission checks into application services | Ergonomic API, strong typing, clear error handling, good docs | +| **Platform / DevOps engineer** (secondary) | Manages SpiceDB schemas and bulk-loads relationships | Schema read/write, bulk import/export, watch for changes | +| **SpiceDB server** (external system) | Exposes gRPC API (v1) on a configured endpoint | Stable protobuf contract defined in `authzed/api` | + +No UI actors. No end-user-facing surface. This is a library consumed programmatically. + +--- + +## Functional Requirements + +### FR-1: Client Construction & Connection + +1. **FR-1.1** — The library MUST provide a client constructor that accepts a SpiceDB endpoint (URI) and a bearer token. +2. **FR-1.2** — The constructor MUST support TLS-encrypted connections. TLS is determined by URI scheme: `https://` = TLS enabled, `http://` = plaintext. For advanced TLS configuration (custom CA certs, mTLS), use `Client::from_channel()` with a pre-configured `tonic::Channel`. +3. **FR-1.3** — The constructor MUST support plaintext/insecure connections (explicit opt-in) for local development. Attempting `http://` to a non-loopback address without explicit `.insecure(true)` on the builder MUST return `Err(Error::InvalidArgument(...))` with a message indicating that insecure connections to remote addresses require explicit opt-in. No warning-log-and-proceed behavior. (Rationale: fail-closed is safer. Consistent with the error-handling approach.) +4. **FR-1.4** — The client MUST reuse the underlying `tonic::Channel` across calls (connection pooling via channel reuse). +5. **FR-1.5** — The client SHOULD accept an externally-constructed `tonic::Channel` for advanced use cases (custom interceptors, load balancing, custom TLS configuration such as CA certs, client certificates, etc.). +6. **FR-1.6** — The bearer token MUST be attached to every outgoing gRPC request as an `authorization: Bearer ` metadata header. +7. **FR-1.7** — The `Client` MUST implement `Clone`, `Send`, and `Sync`. This is required for sharing across `tokio::spawn` tasks in real-world async services. Internally, `Client` uses `tonic::Channel` which is already `Clone + Send + Sync`. +8. **FR-1.8** — The `Client` MUST be usable when wrapped in `Arc` and shared across `tokio::spawn` tasks. Since `Client` is `Clone` (wrapping a `tonic::Channel` which is cheaply cloneable), `Arc` wrapping is not strictly necessary but MUST work correctly. +9. **FR-1.9** — All RPC methods SHOULD accept an optional per-request timeout/deadline. The builder MUST support a `.default_timeout(Duration)` that applies to all RPCs unless overridden per-request. + +### FR-2: PermissionsService + +10. **FR-2.1** — `CheckPermission` — Given a resource (type + id), a permission/relation name, a subject (type + id + optional relation), and an optional caveat context (`HashMap`), return a `PermissionResult` indicating whether the subject has the permission. MUST support all consistency modes (see FR-6). + + > **Important**: SpiceDB returns a 3-state `Permissionship` enum: `HAS_PERMISSION`, `NO_PERMISSION`, `CONDITIONAL`. Returning `bool` would be lossy and a security hazard — a `CONDITIONAL` result silently coerced to `false` could deny legitimate access, and coerced to `true` could grant unauthorized access. The `PermissionResult` enum preserves all three states faithfully. + + A convenience method `.is_allowed() -> Result` MUST be provided on `PermissionResult`. It MUST return `Ok(true)` for `Allowed`, `Ok(false)` for `Denied`, and `Err(Error::ConditionalPermission { missing_fields })` for `Conditional` — forcing the caller to handle the ambiguous case explicitly rather than silently dropping it. + +11. **FR-2.2** — `LookupResources` — Given a resource type, permission, subject, and optional caveat context, stream back all resource IDs the subject can access. Returns a `Stream`. Each item includes the resource ID and its `PermissionResult` (which may be `Conditional` for caveated relationships). +12. **FR-2.3** — `LookupSubjects` — Given a resource, permission, subject type, and optional caveat context, stream back all subjects that have the permission. Returns a `Stream`. +13. **FR-2.4** — `ExpandPermissionTree` — Given a resource and permission, return the full permission tree. MUST return a structured `PermissionTree` type (recursive tree node), not raw proto. +14. **FR-2.5** — `ReadRelationships` — Given a relationship filter, stream back matching relationships. Returns a `Stream`. Each relationship includes its optional `Caveat` if one is attached. +15. **FR-2.6** — `WriteRelationships` — Accept a list of relationship updates (create/touch/delete) with optional preconditions (`Vec`). Relationships MAY include a `Caveat` (caveat name + context). Return a `ZedToken`. +16. **FR-2.7** — `DeleteRelationships` — Given a relationship filter, optional preconditions (`Vec`), and optional consistency, delete matching relationships. Return a `ZedToken`. + + > **Note on consistency for DeleteRelationships**: This is a mutating operation, but SpiceDB accepts a `Consistency` parameter on `DeleteRelationships` to control the snapshot at which the relationship filter is evaluated. The filter determines which relationships to delete; the consistency mode determines which snapshot is used to resolve that filter. The operation then returns a `ZedToken` representing the state after deletion. See the consistency matrix in FR-6 for the full picture. + +### FR-3: SchemaService + +17. **FR-3.1** — `ReadSchema` — Return the current SpiceDB schema as a `String`. Returns a `ZedToken` alongside the schema text. +18. **FR-3.2** — `WriteSchema` — Accept a schema string and apply it. Return a `ZedToken`. + +### FR-4: WatchService + +19. **FR-4.1** — `Watch` — Given optional object types to filter on, return a `Stream>` of relationship update events. MUST handle long-lived streaming connections with the following explicit behavioral contracts: + + - **On server disconnect**: stream yields `Err(Error::Status { code: UNAVAILABLE, .. })` then terminates (`next()` returns `None`). + - **On empty filter list**: watches all relationship types (SpiceDB default behavior). Stream blocks waiting for events; `next()` returns `None` only when the stream terminates. + - **On server error mid-stream**: stream yields `Err` with the gRPC status mapped to the appropriate `Error` variant, then terminates. + - **No auto-reconnect**: Watch does NOT auto-reconnect. This is the caller's responsibility, consistent with NG-6 (no built-in retry/circuit breaker). Callers can use the checkpoint `ZedToken` from the last `WatchEvent` to resume from where they left off. + - **WatchEvent contents**: Each `WatchEvent` includes the list of relationship updates and a checkpoint `ZedToken` for caller-driven resume. + - **On caller drop**: stream is dropped, underlying gRPC stream is cancelled (standard tonic behavior — the `Drop` impl on the tonic `Streaming` handle cancels the RPC). + +### FR-5: ExperimentalService + +> **Note**: All FR-5.x methods are gated behind `#[cfg(feature = "experimental")]`. See Cargo Feature Flags section. + +20. **FR-5.1** — `BulkCheckPermission` — Accept a batch of check requests (each with optional caveat context) and return a `Vec`. Each `CheckResult` contains either a `PermissionResult` or a per-item `Error`. Single round-trip. +21. **FR-5.2** — `BulkImportRelationships` — Accept an `impl Stream` for client-streaming bulk import. Return an import count. +22. **FR-5.3** — `BulkExportRelationships` — Given a filter, stream back all matching relationships for export. + +### FR-6: Consistency Semantics (ZedTokens) + +23. **FR-6.1** — The library MUST model the four SpiceDB consistency modes as a Rust enum: + - `FullyConsistent` — always check at the latest snapshot + - `AtLeastAsFresh(ZedToken)` — at least as fresh as the given token + - `AtExactSnapshot(ZedToken)` — exactly at the given token's snapshot + - `MinimizeLatency` — server picks the fastest available snapshot +24. **FR-6.2** — Methods that accept a `Consistency` parameter and methods that return a `ZedToken` are detailed in the following matrix: + +#### Consistency Matrix + +| Method | Accepts `Consistency`? | Returns `ZedToken`? | Notes | +|---|---|---|---| +| FR-2.1 `CheckPermission` | ✅ Yes | ✅ Yes (in response) | Read path | +| FR-2.2 `LookupResources` | ✅ Yes | ✅ Yes (per item + final) | Streaming read | +| FR-2.3 `LookupSubjects` | ✅ Yes | ✅ Yes (per item + final) | Streaming read | +| FR-2.4 `ExpandPermissionTree` | ✅ Yes | ✅ Yes (in response) | Read path | +| FR-2.5 `ReadRelationships` | ✅ Yes | ✅ Yes (per item) | Streaming read | +| FR-2.6 `WriteRelationships` | ❌ No | ✅ Yes | Mutating — always writes at latest | +| FR-2.7 `DeleteRelationships` | ❌ No | ✅ Yes | Mutating — the SpiceDB proto does not accept a consistency parameter for deletes. | +| FR-3.1 `ReadSchema` | ❌ No | ✅ Yes | Schema reads don't accept consistency | +| FR-3.2 `WriteSchema` | ❌ No | ✅ Yes | Mutating | +| FR-4.1 `Watch` | N/A (uses `after_token`) | ✅ Yes (checkpoint per event) | Watch resumes from a token, not a consistency mode | +| FR-5.1 `BulkCheckPermission` | ✅ Yes | ✅ Yes (in response) | Experimental | +| FR-5.2 `BulkImportRelationships` | ❌ No | ❌ No (returns count) | Experimental, client-streaming | +| FR-5.3 `BulkExportRelationships` | ✅ Yes | ✅ Yes (per item) | Experimental, streaming read | + +25. **FR-6.3** — Every mutating method (FR-2.6, FR-2.7, FR-3.2) MUST return a `ZedToken` that callers can store and pass to subsequent reads. +26. **FR-6.4** — When no consistency is specified on a method that accepts it, the library MUST send no consistency preference to the server. This means SpiceDB will apply its default consistency mode, which is `MinimizeLatency`. This behavior MUST be documented clearly in the crate-level docs and on each method that accepts consistency, so test authors know what to expect. + +### FR-7: Error Handling + +27. **FR-7.1** — The library MUST define a crate-level `Error` enum with the following concrete variants: + + ```rust + #[derive(Debug)] + pub enum Error { + /// Connection-level failures: connection refused, DNS resolution failure, TLS handshake + /// errors, channel closed. + Transport(tonic::transport::Error), + + /// gRPC status errors returned by SpiceDB. Includes the status code, human-readable + /// message, and optionally decoded SpiceDB-specific error details. + Status { + code: tonic::Code, + message: String, + details: Option, + }, + + /// Local validation failures before a request is sent. Examples: empty object_type, + /// empty object_id, empty schema string, empty relationship update list. + InvalidArgument(String), + + /// Protobuf encode/decode failures. Should be rare — indicates a bug or proto mismatch. + Serialization(String), + + /// Returned by PermissionResult::is_allowed() when the result is Conditional. + /// Forces callers to handle the caveated case explicitly. + ConditionalPermission { + missing_fields: Vec, + }, + } + ``` + +28. **FR-7.2** — All public methods MUST return `Result`. +29. **FR-7.3** — The `Error` type MUST implement `std::error::Error`, `Debug`, `Display`, `Send`, `Sync`, and `'static`. +30. **FR-7.4** — The library SHOULD decode SpiceDB-specific error details from gRPC status metadata where available (e.g., `debug_information` from `ErrorReason`). +31. **FR-7.5** — The `Error` type MUST provide an `is_retryable(&self) -> bool` helper method. The following gRPC status codes are considered retryable: `UNAVAILABLE`, `DEADLINE_EXCEEDED`. All other status codes are non-retryable by default. +32. **FR-7.6** — The library MUST map gRPC status codes to semantically meaningful categories. The following mapping table MUST be documented in crate-level error docs: + + | gRPC Status Code | Semantic Meaning | Retryable? | + |---|---|---| + | `UNAUTHENTICATED` | Authentication failure — invalid or missing bearer token | No | + | `PERMISSION_DENIED` | Authorization failure — token valid but insufficient permissions for this operation | No | + | `NOT_FOUND` | Referenced resource or schema not found | No | + | `FAILED_PRECONDITION` | Precondition on a write/delete was violated | No | + | `INVALID_ARGUMENT` | Server rejected the request as malformed | No | + | `ALREADY_EXISTS` | Attempted to create a relationship that already exists (when using `Create` not `Touch`) | No | + | `UNAVAILABLE` | Server temporarily unavailable — safe to retry | Yes | + | `DEADLINE_EXCEEDED` | Request timed out — may be safe to retry | Yes | + + > **Distinguishing authentication from authorization**: `UNAUTHENTICATED` means the caller's identity could not be established (bad token). `PERMISSION_DENIED` means the identity is known but lacks the required SpiceDB service-level permission. These are distinct failure modes and callers may need to handle them differently (e.g., refresh token vs. escalate to admin). + +### FR-8: Type-Safe Domain Model + +33. **FR-8.1** — The library MUST provide high-level Rust types for core domain concepts: `ObjectReference` (type + id), `SubjectReference` (object ref + optional relation), `Relationship`, `RelationshipUpdate`, `RelationshipFilter`, `SubjectFilter`, `Precondition`, `Permission`, `ZedToken`, `PermissionResult`, `PermissionTree`, `WatchEvent`, `CheckResult`, `Caveat`, `ContextValue`, `LookupResourceResult`, `LookupSubjectResult`, `ReadRelationshipResult`, `SpiceDbErrorDetails`. +34. **FR-8.2** — These types MUST NOT be raw protobuf-generated structs. They MUST be idiomatic Rust types with the generated protos as an internal implementation detail. +35. **FR-8.3** — Builder patterns or `From`/`Into` conversions SHOULD be provided for constructing complex request types. +36. **FR-8.4** — `ObjectReference` and `SubjectReference` MUST validate that `object_type` and `object_id` are non-empty at construction time. + +### FR-9: Common Trait Derives + +37. **FR-9.1** — All public **domain/value** types MUST derive `Debug` and `Clone`. The `Error` type is excluded from `Clone` since it wraps `tonic::transport::Error` which is not `Clone`. +38. **FR-9.2** — Equality and hash trait requirements are specified per-type based on field implementability, not as a blanket rule: + - Public value types MUST derive `PartialEq` **unless** the type transitively contains `Error` (which wraps non-comparable transport internals). + - Public value types MUST derive `Eq` and `Hash` **only when all fields support those traits**. + - **`PartialEq + Eq + Hash`**: `ObjectReference`, `SubjectReference`, `SubjectFilter`, `RelationshipFilter`, `Precondition`, `PreconditionOp`, `Operation`, `ZedToken`, `Consistency`, `PermissionResult`, `LookupResourceResult`, `LookupSubjectResult`, `PermissionTree`, `PermissionTreeNode`, `SpiceDbErrorDetails`. + - **`PartialEq` only** (no `Eq`/`Hash` — contains `f64` or `HashMap` transitively): `ContextValue`, `Caveat`, `Relationship`, `RelationshipUpdate`, `ReadRelationshipResult`, `WatchEvent`. + - **No equality/hash traits**: `CheckResult` (contains `Error`). +39. **FR-9.3** — `ZedToken` MUST implement `Serialize` and `Deserialize` (via `serde`) for persistence (e.g., storing in a database or passing between services). This is gated behind the `serde` feature flag. +40. **FR-9.4** — `ZedToken` MUST redact its token value in `Debug` output for security (display as `ZedToken("***")` or similar). Token values may be sensitive and should not appear in application logs. +41. **FR-9.5** — `Error` MUST implement `Debug` and `Display` (via `std::error::Error` — see FR-7.3). + +### FR-10: Boundary Conditions + +42. **FR-10.1** — Passing an empty `Vec` to `write_relationships` MUST return `Err(Error::InvalidArgument("..."))` without making a network call. +43. **FR-10.2** — Passing an empty string to `write_schema` MUST return `Err(Error::InvalidArgument("..."))` without making a network call. +44. **FR-10.3** — Passing an empty filter list to `watch` MUST watch all relationship types (this is SpiceDB's default behavior, not an error). +45. **FR-10.4** — `ZedToken` construction MUST require a non-empty string. Attempting to construct a `ZedToken` from an empty string MUST return `Err(Error::InvalidArgument(...))`. No panicking. (Rationale: consistent with FR-8.4 which uses construction-time validation returning errors.) + +--- + +## Non-Functional Requirements + +| ID | Category | Requirement | +|---|---|---| +| **NFR-1** | **Idiomaticity** | Public API follows Rust API guidelines (C-COMMON-TRAITS, C-BUILDER, etc. per [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/)). | +| **NFR-2** | **Async** | All network-calling methods are `async`. No blocking calls on async threads. | +| **NFR-3** | **Runtime** | Requires `tokio` runtime. No runtime-agnostic abstraction layer (see Non-Goals). | +| **NFR-4** | **Compilation** | Compiles on latest stable Rust. No nightly-only features. | +| **NFR-5** | **Dependencies** | Minimal dependency tree. Core deps: `tonic`, `prost`, `prost-types`, `tokio`, `tower` (for interceptors). Optional deps: `serde` (behind feature flag), `tracing` (for diagnostics). No further large frameworks. | +| **NFR-6** | **Documentation** | Every public type, method, and module has `///` doc comments. Crate-level docs include a getting-started example. Error docs include gRPC status code mapping table. | +| **NFR-7** | **Testing** | Unit tests for type construction/validation, boundary conditions, error variant matching. Integration tests against a real SpiceDB instance (via `testcontainers` or similar). CI runs both. | +| **NFR-8** | **Streaming** | Server-streaming RPCs return `impl Stream>` (via `tokio-stream` or `futures::Stream`). Streaming methods MUST NOT return boxed trait objects — use `impl Stream` or named stream types. | +| **NFR-9** | **Performance** | Channel reuse: no per-request connection overhead. Library itself adds negligible overhead above tonic/prost serialization. | +| **NFR-10** | **Semver** | Crate follows semver. Breaking API changes only on major version bumps. | +| **NFR-11** | **Concurrency** | `Client` is `Clone + Send + Sync` and safe to share across `tokio::spawn` tasks (see FR-1.7, FR-1.8). | + +--- + +## Data Model + +### Core Entities + +``` +┌──────────────────┐ ┌──────────────────────┐ +│ ObjectReference │ │ SubjectReference │ +│──────────────────│ │──────────────────────│ +│ object_type: String │ object: ObjectReference│ +│ object_id: String│ │ optional_relation: │ +└──────────────────┘ │ Option │ + │ └──────────────────────┘ + │ │ + ▼ ▼ +┌────────────────────────────────────────┐ +│ Relationship │ +│────────────────────────────────────────│ +│ resource: ObjectReference │ +│ relation: String │ +│ subject: SubjectReference │ +│ optional_caveat: Option │ +└────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────┐ +│ RelationshipUpdate │ +│────────────────────────────────────────│ +│ operation: Operation (Create|Touch|Del)│ +│ relationship: Relationship │ +└────────────────────────────────────────┘ + +┌──────────────────┐ +│ ZedToken │ +│──────────────────│ +│ token: String │ // Debug output: ZedToken("***") +│ │ // Serialize/Deserialize behind `serde` feature +└──────────────────┘ + +┌──────────────────────────────────┐ +│ Consistency │ +│──────────────────────────────────│ +│ FullyConsistent │ +│ AtLeastAsFresh(ZedToken) │ +│ AtExactSnapshot(ZedToken) │ +│ MinimizeLatency │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ RelationshipFilter │ +│──────────────────────────────────│ +│ resource_type: String │ +│ optional_resource_id: Option│ +│ optional_relation: Option│ +│ optional_subject_filter: │ +│ Option │ +└──────────────────────────────────┘ +``` + +### Caveat & Context Types + +``` +┌──────────────────────────────────┐ +│ Caveat │ +│──────────────────────────────────│ +│ name: String │ // Caveat name as defined in schema +│ context: HashMap │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ ContextValue │ +│──────────────────────────────────│ +│ Represents a typed value for │ +│ caveat context. Maps to │ +│ prost_types::Value internally. │ +│──────────────────────────────────│ +│ Variants: │ +│ Null │ +│ Bool(bool) │ +│ Number(f64) │ +│ String(String) │ +│ List(Vec) │ +│ Struct(HashMap) │ +└──────────────────────────────────┘ +``` + +### Permission Result Types + +``` +┌──────────────────────────────────┐ +│ PermissionResult │ +│──────────────────────────────────│ +│ Allowed │ // Subject definitively has permission +│ Denied │ // Subject definitively lacks permission +│ Conditional { │ // Permission depends on unresolved caveat +│ missing_fields: Vec │ // Context fields needed to resolve +│ } │ +│──────────────────────────────────│ +│ Methods: │ +│ is_allowed() -> Result │ // Ok(true/false) or Err for Conditional +│ is_denied() -> bool │ // true only for Denied +│ is_conditional() -> bool │ // true only for Conditional +└──────────────────────────────────┘ +``` + +### Filter & Precondition Types + +``` +┌──────────────────────────────────┐ +│ SubjectFilter │ +│──────────────────────────────────│ +│ subject_type: String │ +│ optional_subject_id: Option │ +│ optional_relation: Option│ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ Precondition │ +│──────────────────────────────────│ +│ operation: PreconditionOp │ // MustExist | MustNotExist +│ filter: RelationshipFilter │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ PreconditionOp │ +│──────────────────────────────────│ +│ MustExist │ +│ MustNotExist │ +└──────────────────────────────────┘ +``` + +### Tree & Expansion Types + +``` +┌──────────────────────────────────────┐ +│ PermissionTree │ +│──────────────────────────────────────│ +│ expanded_object: ObjectReference │ +│ expanded_relation: String │ +│ node: PermissionTreeNode │ +└──────────────────────────────────────┘ + +┌──────────────────────────────────────┐ +│ PermissionTreeNode │ +│──────────────────────────────────────│ +│ Leaf { │ +│ subjects: Vec │ +│ } │ +│ Union { │ +│ children: Vec │ +│ } │ +│ Intersection { │ +│ children: Vec │ +│ } │ +│ Exclusion { │ +│ base: Box, │ +│ excluded: Box │ +│ } │ +└──────────────────────────────────────┘ +``` + +### Watch & Streaming Types + +``` +┌──────────────────────────────────┐ +│ WatchEvent │ +│──────────────────────────────────│ +│ updates: Vec │ // Relationship changes in this event +│ checkpoint: ZedToken │ // Token for caller-driven resume +└──────────────────────────────────┘ +``` + +### Bulk Check Types + +``` +┌──────────────────────────────────┐ +│ CheckResult │ +│──────────────────────────────────│ +│ Ok(PermissionResult) │ // Successful check for this item +│ Err(Error) │ // Per-item error (e.g., invalid ref) +└──────────────────────────────────┘ +``` + +> **Note**: `CheckResult` is semantically `Result` but may be a type alias or newtype depending on ergonomics during implementation. + +### Streaming Result Types + +``` +┌──────────────────────────────────────┐ +│ LookupResourceResult │ +│──────────────────────────────────────│ +│ resource_id: String │ +│ permission: PermissionResult │ +│ looked_up_at: ZedToken │ // per-item token +└──────────────────────────────────────┘ + +┌──────────────────────────────────────┐ +│ LookupSubjectResult │ +│──────────────────────────────────────│ +│ subject: SubjectReference │ +│ excluded_subjects: Vec │ +│ permission: PermissionResult │ +│ looked_up_at: ZedToken │ // per-item token +└──────────────────────────────────────┘ + +┌──────────────────────────────────────┐ +│ ReadRelationshipResult │ +│──────────────────────────────────────│ +│ relationship: Relationship │ +│ read_at: ZedToken │ // per-item token +└──────────────────────────────────────┘ +``` + +### Error Detail Types + +``` +┌──────────────────────────────────────┐ +│ SpiceDbErrorDetails │ +│──────────────────────────────────────│ +│ error_reason: Option │ // SpiceDB ErrorReason enum value +│ debug_message: Option │ // Human-readable debug info from server +│ retry_info: Option │ // Suggested retry delay, if applicable +└──────────────────────────────────────┘ +``` + +### Mapping to Protobuf + +All domain types convert to/from their `authzed.api.v1` protobuf counterparts via `From`/`Into` implementations. The generated proto types are kept internal (`pub(crate)`) and never exposed in the public API. `ContextValue` maps to/from `prost_types::Value` (from the `google.protobuf.Struct` well-known type). Caveat context in requests maps to `prost_types::Struct`. + +--- + +## API Contracts + +The library exposes a single primary entry point: the `Client` struct. The `Client` internally wraps a `tonic::Channel` (which is `Clone + Send + Sync`) and a bearer token interceptor. Cloning a `Client` is cheap (channel is reference-counted internally). + +### API Style Conventions + +- **Unary RPCs** use direct `.await?` on a builder chain (e.g., `client.check_permission(...).consistency(...).await?`). +- **Streaming RPCs** use a `.send().await?` builder pattern that returns a `Stream` (e.g., `client.lookup_resources(...).consistency(...).send().await?`). +- **Streaming return types** are `impl Stream>` — not boxed trait objects. +- **Per-request timeout** can be applied via `.timeout(Duration)` on any request builder. + +### Client Construction + +```rust +// Minimal — http:// = plaintext (localhost only without .insecure(true)) +let client = Client::new("http://localhost:50051", "my-token").await?; + +// With options (TLS inferred from https:// scheme) +let client = Client::builder("https://spicedb.prod.internal:50051", "my-token") + .connect_timeout(Duration::from_secs(5)) + .default_timeout(Duration::from_secs(10)) // applies to all RPCs unless overridden + .build() + .await?; + +// From existing channel (supports custom TLS: CA certs, client certs, mTLS, etc.) +let client = Client::from_channel(channel, "my-token"); + +// Cloneable — cheap to share across tasks +let client2 = client.clone(); +tokio::spawn(async move { + client2.check_permission(/* ... */).await +}); +``` + +### PermissionsService Methods + +```rust +// Check permission — returns PermissionResult, not bool +let result: PermissionResult = client + .check_permission( + &ObjectReference::new("document", "doc-123")?, + "view", + &SubjectReference::new(ObjectReference::new("user", "alice")?, None)?, + ) + .consistency(Consistency::FullyConsistent) + .timeout(Duration::from_secs(5)) // per-request timeout override + .await?; + +match result { + PermissionResult::Allowed => println!("access granted"), + PermissionResult::Denied => println!("access denied"), + PermissionResult::Conditional { missing_fields } => { + println!("need caveat context: {:?}", missing_fields); + } +} + +// Convenience: panics on Conditional (use only when you know there are no caveats) +let allowed: bool = result.is_allowed()?; + +// Check permission WITH caveat context +let mut context = HashMap::new(); +context.insert("ip_address".to_string(), ContextValue::String("10.0.0.1".into())); +context.insert("time_of_day".to_string(), ContextValue::Number(14.0)); + +let result: PermissionResult = client + .check_permission( + &ObjectReference::new("document", "doc-123")?, + "view", + &SubjectReference::new(ObjectReference::new("user", "alice")?, None)?, + ) + .context(context) + .consistency(Consistency::FullyConsistent) + .await?; + +// Write relationships (with optional caveat on relationship) +let token: ZedToken = client + .write_relationships(vec![ + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "doc-123")?, + "viewer", + SubjectReference::new(ObjectReference::new("user", "bob")?, None)?, + )), + RelationshipUpdate::create( + Relationship::new( + ObjectReference::new("document", "doc-456")?, + "viewer", + SubjectReference::new(ObjectReference::new("user", "carol")?, None)?, + ).with_caveat(Caveat::new("ip_allowlist", HashMap::from([ + ("allowed_ranges".to_string(), ContextValue::List(vec![ + ContextValue::String("10.0.0.0/8".into()), + ])), + ]))), + ), + ]) + .await?; + +// Write relationships with preconditions +let token: ZedToken = client + .write_relationships(vec![ + RelationshipUpdate::create(/* ... */), + ]) + .preconditions(vec![ + Precondition::must_exist(RelationshipFilter::new("document") + .resource_id("doc-123") + .relation("owner")), + ]) + .await?; + +// Lookup resources (streaming) +let mut stream = client + .lookup_resources("document", "view", &subject) + .consistency(Consistency::AtLeastAsFresh(token)) + .send() + .await?; + +while let Some(result) = stream.next().await { + let lookup: LookupResourceResult = result?; + println!("resource: {}, permission: {:?}", lookup.resource_id, lookup.permission); +} + +// Read relationships (streaming) +let mut stream = client + .read_relationships(RelationshipFilter::new("document")) + .consistency(Consistency::FullyConsistent) + .send() + .await?; + +while let Some(result) = stream.next().await { + let item: ReadRelationshipResult = result?; + println!("rel: {:?}, read_at: {:?}", item.relationship, item.read_at); +} + +// Delete relationships with consistency (filter evaluated at specified snapshot) +let token: ZedToken = client + .delete_relationships(RelationshipFilter::new("document").resource_id("doc-123")) + .consistency(Consistency::AtLeastAsFresh(prev_token)) + .preconditions(vec![ + Precondition::must_exist(RelationshipFilter::new("document") + .resource_id("doc-123") + .relation("viewer")), + ]) + .await?; + +// Watch (long-lived stream) +let mut stream = client + .watch(vec!["document", "user"]) + .after_token(token) + .send() + .await?; + +while let Some(event) = stream.next().await { + match event { + Ok(watch_event) => { + for update in &watch_event.updates { + println!("change: {:?}", update); + } + // Store checkpoint for resume + last_token = watch_event.checkpoint; + } + Err(Error::Status { code, .. }) if code == tonic::Code::Unavailable => { + // Server disconnected — reconnect with last_token + break; + } + Err(e) => { + eprintln!("watch error: {}", e); + break; + } + } +} +``` + +### SchemaService Methods + +```rust +let (schema, token): (String, ZedToken) = client.read_schema().await?; +let token: ZedToken = client.write_schema("definition user {} ...").await?; +``` + +### ExperimentalService Methods + +```rust +// Bulk check — requires `experimental` feature +let results: Vec = client + .bulk_check_permissions(vec![check1, check2, check3]) + .consistency(Consistency::FullyConsistent) + .await?; + +for result in &results { + match result { + CheckResult::Ok(permission) => println!("perm: {:?}", permission), + CheckResult::Err(e) => eprintln!("item error: {}", e), + } +} + +// Bulk import (client-streaming) — accepts impl Stream +let relationship_stream = futures::stream::iter(vec![ + Relationship::new( + ObjectReference::new("document", "doc-1")?, + "viewer", + SubjectReference::new(ObjectReference::new("user", "alice")?, None)?, + ), + Relationship::new( + ObjectReference::new("document", "doc-2")?, + "viewer", + SubjectReference::new(ObjectReference::new("user", "bob")?, None)?, + ), +]); +let count: u64 = client + .bulk_import_relationships(relationship_stream) + .await?; + +// Bulk export (server-streaming) +let mut stream = client + .bulk_export_relationships(RelationshipFilter::new("document")) + .consistency(Consistency::FullyConsistent) + .send() + .await?; +``` + +### Streaming Behavior Table + +All streaming methods follow a consistent behavioral contract: + +| Behavior | FR-2.2 LookupResources | FR-2.3 LookupSubjects | FR-2.5 ReadRelationships | FR-4.1 Watch | FR-5.3 BulkExport | +|---|---|---|---|---|---| +| **Return type** | `impl Stream>` | `impl Stream>` | `impl Stream>` | `impl Stream>` | `impl Stream>` | +| **Empty results** | Stream terminates immediately (`None`) | Stream terminates immediately (`None`) | Stream terminates immediately (`None`) | Blocks until events arrive | Stream terminates immediately (`None`) | +| **Server disconnect** | Yields `Err(Status{UNAVAILABLE})`, then `None` | Yields `Err(Status{UNAVAILABLE})`, then `None` | Yields `Err(Status{UNAVAILABLE})`, then `None` | Yields `Err(Status{UNAVAILABLE})`, then `None` | Yields `Err(Status{UNAVAILABLE})`, then `None` | +| **Server error mid-stream** | Yields `Err` with mapped status, then `None` | Yields `Err` with mapped status, then `None` | Yields `Err` with mapped status, then `None` | Yields `Err` with mapped status, then `None` | Yields `Err` with mapped status, then `None` | +| **Caller drops stream** | gRPC cancelled (tonic `Drop`) | gRPC cancelled (tonic `Drop`) | gRPC cancelled (tonic `Drop`) | gRPC cancelled (tonic `Drop`) | gRPC cancelled (tonic `Drop`) | +| **Auto-reconnect** | No | No | No | No (caller's responsibility) | No | +| **Long-lived?** | No (bounded) | No (bounded) | No (bounded) | Yes (indefinite) | No (bounded) | + +> **Note**: The above is illustrative API design. Exact method signatures will be refined during technical specification. The key contract is: high-level Rust types in, high-level Rust types out, with `Result` and `Stream` where appropriate. + +--- + +## Cargo Feature Flags + +| Feature | Default? | Contents | Rationale | +|---|---|---|---| +| `default` | Yes | Core PermissionsService + SchemaService | Minimum viable client for authorization checks | +| `experimental` | No | ExperimentalService methods (FR-5.x): BulkCheckPermission, BulkImportRelationships, BulkExportRelationships | These APIs may change without notice in SpiceDB. Feature-gating signals instability to consumers. | +| `watch` | No | WatchService (FR-4.x) | Requires long-lived connections with different operational characteristics. Separated so consumers who don't need it don't pull in watch-related code. | +| `serde` | No | `Serialize`/`Deserialize` derives on `ZedToken` and other domain types | Optional dependency — many consumers won't need persistence. | +| `tls-rustls` | No | Use `rustls` as TLS backend (via tonic's `tls-rustls` feature) | Pure-Rust TLS — no system OpenSSL dependency. | +| `tls-native` | No | Use native TLS (via tonic's `tls-native` feature) | Uses system OpenSSL/Schannel/SecureTransport. | + +> **Note**: If neither `tls-rustls` nor `tls-native` is enabled, TLS is handled by tonic's default behavior (which typically requires one of these features for `https://` connections). The crate documentation MUST clearly state this. + +--- + +## Non-Goals + +The following are explicitly **out of scope** for v1: + +| # | Non-Goal | Rationale | +|---|---|---| +| NG-1 | CLI binary | This is a library crate. CLI tooling is a separate concern. | +| NG-2 | Schema DSL parser/compiler | Schema strings are passed through as-is. Parsing the Zanzibar-style DSL is out of scope. | +| NG-3 | Admin / metrics endpoints | SpiceDB admin APIs (health, metrics, dispatch) are not part of the core authorization API. | +| NG-4 | Non-tokio runtime support | Tonic requires tokio. Abstract runtime support adds complexity for minimal benefit. | +| NG-5 | Caching layer | Permission caching is application-specific. The library should be a thin, faithful client. | +| NG-6 | Retry / circuit breaker middleware | Users can compose these via `tower` layers on the channel. Not built in for v1. The `Error::is_retryable()` helper assists callers in building their own retry logic. | +| NG-7 | mTLS support (stretch goal) | Acknowledged as desirable, but deferred as a first-class API. Bearer token auth covers the primary use case. mTLS is achievable today by passing a custom `tonic::Channel` via FR-1.5. | +| NG-8 | Auto-reconnect for Watch | Watch streams terminate on error. The caller is responsible for reconnection logic using the checkpoint `ZedToken` from the last `WatchEvent`. This is consistent with NG-6. | + +--- + +## Open Questions + +| # | Question | Impact | Owner | +|---|---|---|---| +| OQ-3 | Should the crate be published to crates.io or remain internal-only initially? | Affects naming, documentation, and versioning rigor. | Maintainer | +| OQ-4 | What is the minimum supported Rust version (MSRV) policy? Latest stable? N-2? | Affects dependency choices and CI matrix. | Maintainer | +| OQ-7 | What crate name? `prescience`, `spicedb`, `spicedb-client`, `authzed`? | Affects discoverability and potential trademark concerns. | Maintainer | + +--- + +## Risks & Dependencies + +| # | Risk | Likelihood | Impact | Mitigation | +|---|---|---|---|---| +| R-1 | **SpiceDB API breaking changes** — proto definitions change in incompatible ways | Low (v1 API is stable) | High — breaks compiled client | Pin to a specific proto tag; test against multiple SpiceDB versions in CI. | +| R-2 | **Tonic major version bump** — tonic 0.x is pre-1.0 and may have breaking changes | Medium | Medium — requires code changes | Pin tonic version range; monitor releases. | +| R-3 | **Protobuf codegen drift** — generated code diverges from what SpiceDB actually accepts | Low | High — runtime failures | Integration tests against a real SpiceDB container in CI. | +| R-4 | **Streaming complexity** — long-lived Watch streams may have subtle error handling bugs | Medium | Medium — unreliable watch | Explicit streaming behavioral contracts (see FR-4.1 and streaming behavior table). Document limitations clearly. | +| R-5 | **Naming collision** — crate name conflicts with existing crates.io packages | Low | Low — rename before publish | Check crates.io availability early. | +| R-6 | **Caveat complexity** — `ContextValue` mapping to/from `prost_types::Value` may have edge cases (e.g., NaN, infinity, deeply nested structs) | Medium | Low — runtime surprises | Thorough unit tests for ContextValue round-tripping. Document known limitations. | + +### External Dependencies + +| Dependency | Version (indicative) | Purpose | +|---|---|---| +| `tonic` | 0.12+ | gRPC client framework | +| `prost` | 0.13+ | Protobuf serialization | +| `prost-types` | 0.13+ | Well-known protobuf types (Struct, Value for caveat context) | +| `tokio` | 1.x | Async runtime | +| `tower` | 0.4+ / 0.5+ | Service middleware (bearer token interceptor) | +| `tonic-build` | 0.12+ (build-dep) | Protobuf code generation | +| `authzed/api` | latest tagged release | Protobuf source definitions (pinned to tag, not `main`) | +| `serde` | 1.x (optional) | Serialization for ZedToken and domain types | +| `tracing` | 0.1.x | Diagnostic logging (e.g., insecure connection warnings) | + +--- + +## Decisions Made + +| # | Decision | Rationale | +|---|---|---| +| D-1 | **Use `tonic` for gRPC transport** | De facto standard Rust gRPC library. Mature, actively maintained, built on tokio + hyper + tower. | +| D-2 | **Generate types from official `authzed/api` protos** | Single source of truth. Avoids manual type definitions falling out of sync. | +| D-3 | **Wrap generated protos in idiomatic Rust types** | Raw proto types have `Option` everywhere and stringly-typed fields. Wrapping provides validation, ergonomics, and a stable public API decoupled from proto layout. | +| D-4 | **Require `tokio` runtime** | Tonic requires tokio. Supporting multiple runtimes adds complexity with no current demand. | +| D-5 | **Bearer token auth, not mTLS, for v1** | Covers the vast majority of SpiceDB deployments. mTLS can be layered in by passing a custom channel (FR-1.5). | +| D-6 | **Library crate only** | Keeps scope focused. CLI and other binaries are separate concerns. | +| D-7 | **Custom error type, not `anyhow`/`eyre`** | Library crates should provide structured errors that callers can match on. Concrete enum variants enable exhaustive matching and testable error assertions. | +| D-8 | **Streaming methods return `impl Stream`** | Consistent async Rust idiom. Not boxed trait objects — `impl Stream` for zero-cost. Callers can use `StreamExt` combinators or `while let` loops. | +| D-9 | **Pin to latest tagged release of `authzed/api` (not `main`)** | Resolved from OQ-1. Tagged releases are stable and versioned. Tracking `main` risks ingesting breaking or incomplete proto changes. Pin to a specific tag (e.g., `v1.35.0`) and update deliberately. | +| D-10 | **Use `build.rs` codegen (don't commit generated code)** | Resolved from OQ-2. Standard Rust approach. Avoids stale generated code in the repo. Consumers need `protoc` installed (documented in README) or we vendor the proto files and use `prost-build` which doesn't require `protoc`. | +| D-11 | **Feature-gate ExperimentalService behind `#[cfg(feature = "experimental")]`** | Resolved from OQ-5. These APIs may change without notice in SpiceDB. Feature-gating clearly signals instability and prevents accidental reliance. | +| D-12 | **Caveats are IN SCOPE for v1 (read/check path + write path)** | Resolved from OQ-6. Caveated relationships are a core SpiceDB feature. Omitting caveat support would make the client unable to correctly interpret `CONDITIONAL` permission results — a security hazard. The `PermissionResult` enum faithfully represents all three states. | +| D-13 | **`CheckPermission` returns `PermissionResult` enum, not `bool`** | SpiceDB's 3-state Permissionship (HAS_PERMISSION / NO_PERMISSION / CONDITIONAL) cannot be faithfully represented as a boolean. Returning `bool` is lossy and a security hazard. A convenience `.is_allowed()` method is provided but returns `Result` to force handling of the CONDITIONAL case. | +| D-14 | **TLS determined by URI scheme** | `https://` = TLS, `http://` = plaintext. Non-loopback `http://` requires `.insecure(true)`. For advanced TLS (custom CAs, mTLS), use `Client::from_channel()`. Follows principle of secure-by-default with explicit opt-out. | +| D-15 | **No auto-reconnect for streaming** | Consistent with NG-6 (no built-in retry). Callers manage reconnection using checkpoint ZedTokens. This keeps the library thin and avoids opinionated retry policies. | + +--- + +## References + +- [SpiceDB Documentation](https://authzed.com/docs) +- [SpiceDB Caveats Documentation](https://authzed.com/docs/spicedb/concepts/caveats) +- [authzed/api Protobuf Definitions](https://github.com/authzed/api) +- [lunaetco/spicedb-client (community Rust client)](https://github.com/lunaetco/spicedb-client) +- [Google Zanzibar Paper](https://research.google/pubs/pub48190/) +- [Tonic gRPC Framework](https://github.com/hyperium/tonic) +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) diff --git a/src/client/builder.rs b/src/client/builder.rs new file mode 100644 index 0000000..4bbacab --- /dev/null +++ b/src/client/builder.rs @@ -0,0 +1,129 @@ +//! Client builder for configuring connections. + +use std::time::Duration; + +use tonic::metadata::MetadataValue; +use tonic::transport::Endpoint; + +use crate::error::Error; + +use super::{BearerTokenInterceptor, Client}; +use crate::proto::permissions_service_client::PermissionsServiceClient; +use crate::proto::schema_service_client::SchemaServiceClient; +#[cfg(feature = "watch")] +use crate::proto::watch_service_client::WatchServiceClient; + +/// A builder for configuring and creating a [`Client`]. +/// +/// # Examples +/// +/// ```rust,no_run +/// use std::time::Duration; +/// use prescience::Client; +/// +/// # async fn example() -> Result<(), prescience::Error> { +/// let client = Client::builder("https://spicedb.prod.internal:50051", "my-token") +/// .connect_timeout(Duration::from_secs(5)) +/// .default_timeout(Duration::from_secs(10)) +/// .build() +/// .await?; +/// # Ok(()) +/// # } +/// ``` +pub struct ClientBuilder { + endpoint: String, + token: String, + insecure: bool, + connect_timeout: Option, + default_timeout: Option, +} + +impl ClientBuilder { + pub(crate) fn new(endpoint: impl Into, token: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + token: token.into(), + insecure: false, + connect_timeout: None, + default_timeout: None, + } + } + + /// Allow insecure (plaintext) connections to non-loopback addresses. + /// + /// By default, `http://` to a non-loopback address returns an error. + /// Set this to `true` to allow it. + pub fn insecure(mut self, insecure: bool) -> Self { + self.insecure = insecure; + self + } + + /// Sets the connection timeout. + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = Some(timeout); + self + } + + /// Sets a default timeout applied to all RPCs unless overridden per-request. + pub fn default_timeout(mut self, timeout: Duration) -> Self { + self.default_timeout = Some(timeout); + self + } + + /// Builds and connects the client. + pub async fn build(self) -> Result { + // Validate insecure connections (FR-1.3) + // Parse the URI to extract the host for proper loopback validation. + // Substring matching (e.g. contains("localhost")) is unsafe because + // it would allow hosts like "localhost.evil.com". + if self.endpoint.starts_with("http://") && !self.insecure { + let uri: http::Uri = self.endpoint.parse().map_err(|e: http::uri::InvalidUri| { + Error::InvalidArgument(format!("invalid endpoint URI: {}", e)) + })?; + let host = uri.host().unwrap_or(""); + let is_loopback = + host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"; + + if !is_loopback { + return Err(Error::InvalidArgument(format!( + "insecure connection to non-loopback address '{}' requires \ + .insecure(true) on the builder. Use https:// for production.", + self.endpoint + ))); + } + } + + let mut endpoint = Endpoint::from_shared(self.endpoint.clone()) + .map_err(|e| Error::InvalidArgument(format!("invalid endpoint: {}", e)))?; + + if let Some(timeout) = self.connect_timeout { + endpoint = endpoint.connect_timeout(timeout); + } + + if let Some(timeout) = self.default_timeout { + endpoint = endpoint.timeout(timeout); + } + + let channel = endpoint.connect().await?; + + let header_value = format!("Bearer {}", self.token); + let meta_value: MetadataValue = header_value + .parse() + .map_err(|_| Error::InvalidArgument("invalid bearer token".into()))?; + let interceptor = BearerTokenInterceptor { token: meta_value }; + + let permissions = + PermissionsServiceClient::with_interceptor(channel.clone(), interceptor.clone()); + let schema = SchemaServiceClient::with_interceptor(channel.clone(), interceptor.clone()); + #[cfg(feature = "watch")] + let watch = WatchServiceClient::with_interceptor(channel, interceptor); + + Ok(Client { + permissions, + schema, + #[cfg(feature = "watch")] + watch, + default_timeout: self.default_timeout, + }) + } +} diff --git a/src/client/experimental.rs b/src/client/experimental.rs new file mode 100644 index 0000000..faa16a6 --- /dev/null +++ b/src/client/experimental.rs @@ -0,0 +1,280 @@ +//! Experimental/Bulk API implementations (behind `experimental` feature). +//! +//! These wrap the bulk RPCs that have been promoted to PermissionsService +//! in the SpiceDB API but are feature-gated in this library since they +//! may still evolve. + +use std::collections::HashMap; + +use futures_core::Stream; +use tokio_stream::StreamExt; + +use crate::error::Error; +use crate::proto; +use crate::types::context::context_to_struct; +use crate::types::*; + +use super::Client; + +// ── BulkCheckItem ────────────────────────────────────────────── + +/// A single item in a bulk check request. +#[derive(Debug, Clone)] +pub struct BulkCheckItem { + /// The resource to check. + pub resource: ObjectReference, + /// The permission to check. + pub permission: String, + /// The subject to check. + pub subject: SubjectReference, + /// Optional caveat context. + pub context: Option>, +} + +impl BulkCheckItem { + /// Creates a new bulk check item. + pub fn new( + resource: ObjectReference, + permission: impl Into, + subject: SubjectReference, + ) -> Self { + Self { + resource, + permission: permission.into(), + subject, + context: None, + } + } + + /// Sets caveat context for this check item. + pub fn with_context(mut self, context: HashMap) -> Self { + self.context = Some(context); + self + } +} + +// ── BulkCheckPermissions ────────────────────────────────────────── + +/// Builder for a BulkCheckPermissions request. +pub struct BulkCheckPermissionsRequest<'a> { + client: &'a Client, + items: Vec, + consistency: Option, +} + +impl<'a> BulkCheckPermissionsRequest<'a> { + /// Sets the consistency mode. + pub fn consistency(mut self, c: Consistency) -> Self { + self.consistency = Some((&c).into()); + self + } +} + +impl<'a> std::future::IntoFuture for BulkCheckPermissionsRequest<'a> { + type Output = Result, Error>; + type IntoFuture = + std::pin::Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + let req = proto::CheckBulkPermissionsRequest { + consistency: self.consistency, + items: self.items, + with_tracing: false, + }; + + let response = self + .client + .permissions + .clone() + .check_bulk_permissions(req) + .await + .map_err(Error::from_status)?; + + let inner = response.into_inner(); + let results: Vec = inner + .pairs + .into_iter() + .map(|pair| match pair.response { + Some(proto::check_bulk_permissions_pair::Response::Item(item)) => { + PermissionResult::from_check_response( + item.permissionship, + item.partial_caveat_info, + ) + } + Some(proto::check_bulk_permissions_pair::Response::Error(status)) => { + Err(Error::Status { + code: tonic::Code::from_i32(status.code), + message: status.message, + details: None, + }) + } + None => Err(Error::Serialization( + "missing response in bulk check pair".into(), + )), + }) + .collect(); + Ok(results) + }) + } +} + +// ── BulkImportRelationships ────────────────────────────────────────── + +/// Builder for a BulkImportRelationships request. +pub struct BulkImportRelationshipsRequest<'a, S> { + client: &'a Client, + stream: S, +} + +impl<'a, S> BulkImportRelationshipsRequest<'a, S> +where + S: Stream + Send + 'static, +{ + /// Sends the client-streaming import request and returns the number of relationships loaded. + pub async fn send(self) -> Result { + // Collect all relationships and batch them into chunks to reduce + // per-message overhead while keeping memory bounded per message. + let batch_size = 128; + let request_stream = async_stream::stream! { + let mut batch: Vec = Vec::with_capacity(batch_size); + let mut stream = std::pin::pin!(self.stream); + while let Some(rel) = StreamExt::next(&mut stream).await { + batch.push((&rel).into()); + if batch.len() >= batch_size { + yield proto::ImportBulkRelationshipsRequest { + relationships: std::mem::take(&mut batch), + }; + batch = Vec::with_capacity(batch_size); + } + } + if !batch.is_empty() { + yield proto::ImportBulkRelationshipsRequest { + relationships: batch, + }; + } + }; + + let response = self + .client + .permissions + .clone() + .import_bulk_relationships(request_stream) + .await + .map_err(Error::from_status)?; + + Ok(response.into_inner().num_loaded) + } +} + +// ── BulkExportRelationships ────────────────────────────────────────── + +/// Builder for a BulkExportRelationships streaming request. +pub struct BulkExportRelationshipsRequest<'a> { + client: &'a Client, + filter: Option, + consistency: Option, +} + +impl<'a> BulkExportRelationshipsRequest<'a> { + /// Sets the consistency mode. + pub fn consistency(mut self, c: Consistency) -> Self { + self.consistency = Some((&c).into()); + self + } + + /// Sends the request and returns a stream of relationships. + pub async fn send(self) -> Result>, Error> { + let req = proto::ExportBulkRelationshipsRequest { + consistency: self.consistency, + optional_limit: 0, + optional_cursor: None, + optional_relationship_filter: self.filter, + }; + + let response = self + .client + .permissions + .clone() + .export_bulk_relationships(req) + .await + .map_err(Error::from_status)?; + + // Each response batch contains multiple relationships; flatten into individual items. + let inner = response.into_inner(); + let stream = async_stream::try_stream! { + let mut inner = inner; + while let Some(result) = inner.next().await { + match result { + Ok(batch) => { + for rel in batch.relationships { + let r: Relationship = rel.try_into()?; + yield r; + } + } + Err(status) => { + Err(Error::from_status(status))?; + } + } + } + }; + Ok(stream) + } +} + +// ── Client methods ────────────────────────────────────────────── + +impl Client { + /// Checks permissions for a batch of items in a single round-trip. + /// + /// Returns a `Vec` where each item is either a + /// `PermissionResult` or a per-item `Error`. + pub fn bulk_check_permissions( + &self, + items: Vec, + ) -> BulkCheckPermissionsRequest<'_> { + let proto_items = items + .into_iter() + .map(|item| proto::CheckBulkPermissionsRequestItem { + resource: Some((&item.resource).into()), + permission: item.permission, + subject: Some((&item.subject).into()), + context: item.context.as_ref().map(context_to_struct), + }) + .collect(); + + BulkCheckPermissionsRequest { + client: self, + items: proto_items, + consistency: None, + } + } + + /// Bulk imports relationships via client-streaming. + /// + /// Accepts any `Stream`. Returns the number of + /// relationships loaded. + pub fn bulk_import_relationships(&self, stream: S) -> BulkImportRelationshipsRequest<'_, S> + where + S: Stream + Send + 'static, + { + BulkImportRelationshipsRequest { + client: self, + stream, + } + } + + /// Bulk exports relationships via server-streaming. + /// + /// Returns a streaming builder. Call `.send().await?` to get the stream. + pub fn bulk_export_relationships( + &self, + filter: RelationshipFilter, + ) -> BulkExportRelationshipsRequest<'_> { + BulkExportRelationshipsRequest { + client: self, + filter: Some((&filter).into()), + consistency: None, + } + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..32da4ba --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,137 @@ +//! SpiceDB client implementation. + +mod builder; +#[cfg(feature = "experimental")] +pub mod experimental; +mod permissions; +mod schema; +#[cfg(feature = "watch")] +mod watch; + +use std::time::Duration; + +use tonic::metadata::MetadataValue; +use tonic::service::interceptor::InterceptedService; +use tonic::service::Interceptor; +use tonic::transport::Channel; + +pub use builder::ClientBuilder; + +use crate::proto::permissions_service_client::PermissionsServiceClient; +use crate::proto::schema_service_client::SchemaServiceClient; +#[cfg(feature = "watch")] +use crate::proto::watch_service_client::WatchServiceClient; + +/// Bearer token interceptor that attaches auth to every request. +#[derive(Clone)] +struct BearerTokenInterceptor { + token: MetadataValue, +} + +impl Interceptor for BearerTokenInterceptor { + fn call( + &mut self, + mut request: tonic::Request<()>, + ) -> Result, tonic::Status> { + request + .metadata_mut() + .insert("authorization", self.token.clone()); + Ok(request) + } +} + +type AuthChannel = InterceptedService; + +/// An idiomatic Rust client for SpiceDB. +/// +/// `Client` is cheap to clone — it wraps a `tonic::Channel` which is +/// reference-counted internally. Clone it freely to share across tasks. +/// +/// # Examples +/// +/// ```rust,no_run +/// use prescience::Client; +/// +/// # async fn example() -> Result<(), prescience::Error> { +/// let client = Client::new("http://localhost:50051", "my-token").await?; +/// +/// // Clone is cheap — share across tasks +/// let client2 = client.clone(); +/// tokio::spawn(async move { +/// // use client2 +/// }); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct Client { + permissions: PermissionsServiceClient, + schema: SchemaServiceClient, + #[cfg(feature = "watch")] + watch: WatchServiceClient, + default_timeout: Option, +} + +impl Client { + /// Creates a new client connected to the given SpiceDB endpoint. + /// + /// For `http://` endpoints, only loopback addresses are allowed unless + /// you use [`Client::builder`] with `.insecure(true)`. + pub async fn new( + endpoint: impl Into, + token: impl Into, + ) -> Result { + ClientBuilder::new(endpoint, token).build().await + } + + /// Creates a builder for configuring a client connection. + pub fn builder(endpoint: impl Into, token: impl Into) -> ClientBuilder { + ClientBuilder::new(endpoint, token) + } + + /// Creates a client from a pre-built tonic `Channel`. + /// + /// Use this for advanced TLS configurations (custom CA certs, + /// client certificates, mTLS, etc.). + pub fn from_channel(channel: Channel, token: impl Into) -> Result { + let token_str = token.into(); + let header_value = format!("Bearer {}", token_str); + let meta_value: MetadataValue = header_value + .parse() + .map_err(|_| crate::Error::InvalidArgument("invalid bearer token".into()))?; + let interceptor = BearerTokenInterceptor { token: meta_value }; + + let permissions = + PermissionsServiceClient::with_interceptor(channel.clone(), interceptor.clone()); + let schema = SchemaServiceClient::with_interceptor(channel.clone(), interceptor.clone()); + #[cfg(feature = "watch")] + let watch = WatchServiceClient::with_interceptor(channel, interceptor); + + Ok(Self { + permissions, + schema, + #[cfg(feature = "watch")] + watch, + default_timeout: None, + }) + } + + /// Returns the default timeout applied to RPCs, if set. + pub fn default_timeout(&self) -> Option { + self.default_timeout + } +} + +// Compile-time assertions for FR-1.7: Client must be Clone + Send + Sync +#[cfg(test)] +mod trait_tests { + use super::*; + fn _assert_clone() {} + fn _assert_send() {} + fn _assert_sync() {} + fn _assert_all() { + _assert_clone::(); + _assert_send::(); + _assert_sync::(); + } +} diff --git a/src/client/permissions.rs b/src/client/permissions.rs new file mode 100644 index 0000000..cfd9e84 --- /dev/null +++ b/src/client/permissions.rs @@ -0,0 +1,485 @@ +//! PermissionsService RPC implementations. + +use std::collections::HashMap; + +use futures_core::Stream; +use tokio_stream::StreamExt; + +use crate::error::Error; +use crate::proto; +use crate::types::context::context_to_struct; +use crate::types::*; + +use super::Client; + +// ── CheckPermission ────────────────────────────────────────────── + +/// Builder for a CheckPermission request. +pub struct CheckPermissionRequest<'a> { + client: &'a Client, + resource: proto::ObjectReference, + permission: String, + subject: proto::SubjectReference, + consistency: Option, + context: Option, +} + +impl<'a> CheckPermissionRequest<'a> { + /// Sets the consistency mode for this request. + pub fn consistency(mut self, c: Consistency) -> Self { + self.consistency = Some((&c).into()); + self + } + + /// Sets the caveat evaluation context for this request. + pub fn context(mut self, ctx: HashMap) -> Self { + self.context = Some(context_to_struct(&ctx)); + self + } +} + +impl<'a> std::future::IntoFuture for CheckPermissionRequest<'a> { + type Output = Result; + type IntoFuture = + std::pin::Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + let req = proto::CheckPermissionRequest { + consistency: self.consistency, + resource: Some(self.resource), + permission: self.permission, + subject: Some(self.subject), + context: self.context, + with_tracing: false, + }; + + let response = self + .client + .permissions + .clone() + .check_permission(req) + .await + .map_err(Error::from_status)?; + + let inner = response.into_inner(); + PermissionResult::from_check_response(inner.permissionship, inner.partial_caveat_info) + }) + } +} + +// ── WriteRelationships ────────────────────────────────────────── + +/// Builder for a WriteRelationships request. +pub struct WriteRelationshipsRequest<'a> { + client: &'a Client, + updates: Vec, + preconditions: Vec, +} + +impl<'a> WriteRelationshipsRequest<'a> { + /// Adds preconditions that must be satisfied before the write commits. + pub fn preconditions(mut self, preconditions: Vec) -> Self { + self.preconditions = preconditions.iter().map(Into::into).collect(); + self + } +} + +impl<'a> std::future::IntoFuture for WriteRelationshipsRequest<'a> { + type Output = Result; + type IntoFuture = + std::pin::Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + if self.updates.is_empty() { + return Err(Error::InvalidArgument("updates must not be empty".into())); + } + + let req = proto::WriteRelationshipsRequest { + updates: self.updates, + optional_preconditions: self.preconditions, + optional_transaction_metadata: None, + }; + + let response = self + .client + .permissions + .clone() + .write_relationships(req) + .await + .map_err(Error::from_status)?; + + let inner = response.into_inner(); + inner + .written_at + .ok_or_else(|| Error::Serialization("missing written_at token".into()))? + .try_into() + }) + } +} + +// ── DeleteRelationships ────────────────────────────────────────── + +/// Builder for a DeleteRelationships request. +pub struct DeleteRelationshipsRequest<'a> { + client: &'a Client, + filter: proto::RelationshipFilter, + preconditions: Vec, +} + +impl<'a> DeleteRelationshipsRequest<'a> { + /// Adds preconditions that must be satisfied before the delete commits. + pub fn preconditions(mut self, preconditions: Vec) -> Self { + self.preconditions = preconditions.iter().map(Into::into).collect(); + self + } +} + +impl<'a> std::future::IntoFuture for DeleteRelationshipsRequest<'a> { + type Output = Result; + type IntoFuture = + std::pin::Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + let req = proto::DeleteRelationshipsRequest { + relationship_filter: Some(self.filter), + optional_preconditions: self.preconditions, + optional_limit: 0, + optional_allow_partial_deletions: false, + optional_transaction_metadata: None, + }; + + let response = self + .client + .permissions + .clone() + .delete_relationships(req) + .await + .map_err(Error::from_status)?; + + let inner = response.into_inner(); + inner + .deleted_at + .ok_or_else(|| Error::Serialization("missing deleted_at token".into()))? + .try_into() + }) + } +} + +// ── LookupResources ────────────────────────────────────────────── + +/// Builder for a LookupResources streaming request. +pub struct LookupResourcesRequest<'a> { + client: &'a Client, + resource_type: String, + permission: String, + subject: proto::SubjectReference, + consistency: Option, + context: Option, +} + +impl<'a> LookupResourcesRequest<'a> { + /// Sets the consistency mode. + pub fn consistency(mut self, c: Consistency) -> Self { + self.consistency = Some((&c).into()); + self + } + + /// Sets the caveat evaluation context. + pub fn context(mut self, ctx: HashMap) -> Self { + self.context = Some(context_to_struct(&ctx)); + self + } + + /// Sends the request and returns a stream of results. + pub async fn send( + self, + ) -> Result>, Error> { + let req = proto::LookupResourcesRequest { + consistency: self.consistency, + resource_object_type: self.resource_type, + permission: self.permission, + subject: Some(self.subject), + context: self.context, + optional_limit: 0, + optional_cursor: None, + }; + + let response = self + .client + .permissions + .clone() + .lookup_resources(req) + .await + .map_err(Error::from_status)?; + + Ok(response.into_inner().map(|r| match r { + Ok(proto) => LookupResourceResult::from_proto(proto), + Err(status) => Err(Error::from_status(status)), + })) + } +} + +// ── LookupSubjects ────────────────────────────────────────────── + +/// Builder for a LookupSubjects streaming request. +pub struct LookupSubjectsRequest<'a> { + client: &'a Client, + resource: proto::ObjectReference, + permission: String, + subject_type: String, + optional_subject_relation: String, + consistency: Option, + context: Option, +} + +impl<'a> LookupSubjectsRequest<'a> { + /// Sets the consistency mode. + pub fn consistency(mut self, c: Consistency) -> Self { + self.consistency = Some((&c).into()); + self + } + + /// Sets the caveat evaluation context. + pub fn context(mut self, ctx: HashMap) -> Self { + self.context = Some(context_to_struct(&ctx)); + self + } + + /// Sends the request and returns a stream of results. + pub async fn send( + self, + ) -> Result>, Error> { + let req = proto::LookupSubjectsRequest { + consistency: self.consistency, + resource: Some(self.resource), + permission: self.permission, + subject_object_type: self.subject_type, + optional_subject_relation: self.optional_subject_relation, + context: self.context, + optional_concrete_limit: 0, + optional_cursor: None, + wildcard_option: 0, + }; + + let response = self + .client + .permissions + .clone() + .lookup_subjects(req) + .await + .map_err(Error::from_status)?; + + Ok(response.into_inner().map(|r| match r { + Ok(proto) => LookupSubjectResult::from_proto(proto), + Err(status) => Err(Error::from_status(status)), + })) + } +} + +// ── ReadRelationships ────────────────────────────────────────────── + +/// Builder for a ReadRelationships streaming request. +pub struct ReadRelationshipsRequest<'a> { + client: &'a Client, + filter: proto::RelationshipFilter, + consistency: Option, +} + +impl<'a> ReadRelationshipsRequest<'a> { + /// Sets the consistency mode. + pub fn consistency(mut self, c: Consistency) -> Self { + self.consistency = Some((&c).into()); + self + } + + /// Sends the request and returns a stream of results. + pub async fn send( + self, + ) -> Result>, Error> { + let req = proto::ReadRelationshipsRequest { + consistency: self.consistency, + relationship_filter: Some(self.filter), + optional_limit: 0, + optional_cursor: None, + }; + + let response = self + .client + .permissions + .clone() + .read_relationships(req) + .await + .map_err(Error::from_status)?; + + Ok(response.into_inner().map(|r| match r { + Ok(proto) => ReadRelationshipResult::from_proto(proto), + Err(status) => Err(Error::from_status(status)), + })) + } +} + +// ── ExpandPermissionTree ────────────────────────────────────────────── + +/// Builder for an ExpandPermissionTree request. +pub struct ExpandPermissionTreeRequest<'a> { + client: &'a Client, + resource: proto::ObjectReference, + permission: String, + consistency: Option, +} + +impl<'a> ExpandPermissionTreeRequest<'a> { + /// Sets the consistency mode. + pub fn consistency(mut self, c: Consistency) -> Self { + self.consistency = Some((&c).into()); + self + } +} + +impl<'a> std::future::IntoFuture for ExpandPermissionTreeRequest<'a> { + type Output = Result; + type IntoFuture = + std::pin::Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + let req = proto::ExpandPermissionTreeRequest { + consistency: self.consistency, + resource: Some(self.resource), + permission: self.permission, + }; + + let response = self + .client + .permissions + .clone() + .expand_permission_tree(req) + .await + .map_err(Error::from_status)?; + + let inner = response.into_inner(); + let tree = inner + .tree_root + .ok_or_else(|| Error::Serialization("missing tree_root".into()))?; + PermissionTree::from_proto(tree) + }) + } +} + +// ── Client methods ────────────────────────────────────────────── + +impl Client { + /// Checks whether a subject has a permission on a resource. + /// + /// Returns a [`PermissionResult`] with three possible states. + /// Use `.consistency()` and `.context()` on the returned builder. + pub fn check_permission( + &self, + resource: &ObjectReference, + permission: impl Into, + subject: &SubjectReference, + ) -> CheckPermissionRequest<'_> { + CheckPermissionRequest { + client: self, + resource: resource.into(), + permission: permission.into(), + subject: subject.into(), + consistency: None, + context: None, + } + } + + /// Writes a batch of relationship updates atomically. + /// + /// Returns `Err(InvalidArgument)` if `updates` is empty. + pub fn write_relationships( + &self, + updates: Vec, + ) -> WriteRelationshipsRequest<'_> { + // FR-10.1: empty vec validation is checked in IntoFuture + WriteRelationshipsRequest { + client: self, + updates: updates.iter().map(Into::into).collect(), + preconditions: vec![], + } + } + + /// Deletes all relationships matching the given filter. + pub fn delete_relationships( + &self, + filter: RelationshipFilter, + ) -> DeleteRelationshipsRequest<'_> { + DeleteRelationshipsRequest { + client: self, + filter: (&filter).into(), + preconditions: vec![], + } + } + + /// Looks up all resources of a given type that a subject can access. + /// + /// Returns a streaming builder. Call `.send().await?` to get the stream. + pub fn lookup_resources( + &self, + resource_type: impl Into, + permission: impl Into, + subject: &SubjectReference, + ) -> LookupResourcesRequest<'_> { + LookupResourcesRequest { + client: self, + resource_type: resource_type.into(), + permission: permission.into(), + subject: subject.into(), + consistency: None, + context: None, + } + } + + /// Looks up all subjects of a given type that have access to a resource. + /// + /// Returns a streaming builder. Call `.send().await?` to get the stream. + pub fn lookup_subjects( + &self, + resource: &ObjectReference, + permission: impl Into, + subject_type: impl Into, + ) -> LookupSubjectsRequest<'_> { + LookupSubjectsRequest { + client: self, + resource: resource.into(), + permission: permission.into(), + subject_type: subject_type.into(), + optional_subject_relation: String::new(), + consistency: None, + context: None, + } + } + + /// Reads relationships matching the given filter. + /// + /// Returns a streaming builder. Call `.send().await?` to get the stream. + pub fn read_relationships(&self, filter: RelationshipFilter) -> ReadRelationshipsRequest<'_> { + ReadRelationshipsRequest { + client: self, + filter: (&filter).into(), + consistency: None, + } + } + + /// Expands the permission tree for a resource and permission. + pub fn expand_permission_tree( + &self, + resource: &ObjectReference, + permission: impl Into, + ) -> ExpandPermissionTreeRequest<'_> { + ExpandPermissionTreeRequest { + client: self, + resource: resource.into(), + permission: permission.into(), + consistency: None, + } + } +} diff --git a/src/client/schema.rs b/src/client/schema.rs new file mode 100644 index 0000000..09b65d7 --- /dev/null +++ b/src/client/schema.rs @@ -0,0 +1,51 @@ +//! SchemaService RPC implementations. + +use crate::error::Error; +use crate::proto; +use crate::types::ZedToken; + +use super::Client; + +impl Client { + /// Reads the current SpiceDB schema. + /// + /// Returns the schema text and the ZedToken at which it was read. + pub async fn read_schema(&self) -> Result<(String, ZedToken), Error> { + let response = self + .schema + .clone() + .read_schema(proto::ReadSchemaRequest {}) + .await + .map_err(Error::from_status)?; + + let inner = response.into_inner(); + let token = inner + .read_at + .ok_or_else(|| Error::Serialization("missing read_at token".into()))? + .try_into()?; + Ok((inner.schema_text, token)) + } + + /// Writes (upserts) the SpiceDB schema. + /// + /// Returns `Err(InvalidArgument)` if the schema string is empty. + pub async fn write_schema(&self, schema: impl Into) -> Result { + let schema = schema.into(); + if schema.is_empty() { + return Err(Error::InvalidArgument("schema must not be empty".into())); + } + + let response = self + .schema + .clone() + .write_schema(proto::WriteSchemaRequest { schema }) + .await + .map_err(Error::from_status)?; + + let inner = response.into_inner(); + inner + .written_at + .ok_or_else(|| Error::Serialization("missing written_at token".into()))? + .try_into() + } +} diff --git a/src/client/watch.rs b/src/client/watch.rs new file mode 100644 index 0000000..66e607e --- /dev/null +++ b/src/client/watch.rs @@ -0,0 +1,86 @@ +//! WatchService RPC implementation (behind `watch` feature). + +use futures_core::Stream; +use tokio_stream::StreamExt; + +use crate::error::Error; +use crate::proto; +use crate::types::{WatchEvent, ZedToken}; + +use super::Client; + +/// Builder for a Watch streaming request. +pub struct WatchRequest<'a> { + client: &'a Client, + object_types: Vec, + start_cursor: Option, +} + +impl<'a> WatchRequest<'a> { + /// Resume watching from a specific token (e.g., from a previous WatchEvent checkpoint). + pub fn after_token(mut self, token: ZedToken) -> Self { + self.start_cursor = Some((&token).into()); + self + } + + /// Sends the request and returns a long-lived stream of watch events. + /// + /// The stream does NOT auto-reconnect. On server disconnect, it yields + /// `Err(Error::Status { code: UNAVAILABLE, .. })` then terminates. + /// Use the checkpoint `ZedToken` from the last `WatchEvent` to resume. + pub async fn send(self) -> Result>, Error> { + let req = proto::WatchRequest { + optional_object_types: self.object_types, + optional_start_cursor: self.start_cursor, + optional_relationship_filters: vec![], + optional_update_kinds: vec![], + }; + + let response = self + .client + .watch + .clone() + .watch(req) + .await + .map_err(Error::from_status)?; + + Ok(response.into_inner().map(|r| match r { + Ok(proto) => WatchEvent::from_proto(proto), + Err(status) => Err(Error::from_status(status)), + })) + } +} + +impl Client { + /// Watches for relationship changes, optionally filtered by object types. + /// + /// Pass an empty vec to watch all types. Returns a streaming builder — + /// call `.send().await?` to get the stream. + /// + /// # Examples + /// + /// ```rust,no_run + /// # use prescience::Client; + /// # async fn example(client: &Client) -> Result<(), prescience::Error> { + /// use tokio_stream::StreamExt; + /// + /// let mut stream = client + /// .watch(vec!["document", "user"]) + /// .send() + /// .await?; + /// + /// while let Some(event) = stream.next().await { + /// let watch_event = event?; + /// println!("{} updates, checkpoint: {}", watch_event.updates.len(), watch_event.checkpoint); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn watch(&self, object_types: Vec>) -> WatchRequest<'_> { + WatchRequest { + client: self, + object_types: object_types.into_iter().map(Into::into).collect(), + start_cursor: None, + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0d9d7a3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,109 @@ +//! Error types for the Prescience SpiceDB client. +//! +//! The [`Error`] enum provides structured, matchable error variants covering +//! transport failures, gRPC status errors, local validation, serialization, +//! and conditional permission handling. +//! +//! ## gRPC Status Code Mapping +//! +//! | gRPC Code | Meaning | Retryable? | +//! |-----------|---------|------------| +//! | `UNAUTHENTICATED` | Invalid or missing bearer token | No | +//! | `PERMISSION_DENIED` | Token valid but insufficient permissions | No | +//! | `NOT_FOUND` | Resource or schema not found | No | +//! | `FAILED_PRECONDITION` | Write/delete precondition violated | No | +//! | `INVALID_ARGUMENT` | Server rejected request as malformed | No | +//! | `ALREADY_EXISTS` | Relationship already exists (with Create) | No | +//! | `UNAVAILABLE` | Server temporarily unavailable | Yes | +//! | `DEADLINE_EXCEEDED` | Request timed out | Yes | + +use std::time::Duration; + +/// Details extracted from SpiceDB-specific gRPC error metadata. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SpiceDbErrorDetails { + /// SpiceDB ErrorReason enum value, if present. + pub error_reason: Option, + /// Human-readable debug information from the server. + pub debug_message: Option, + /// Suggested retry delay, if the server provided one. + pub retry_info: Option, +} + +/// Errors returned by the Prescience SpiceDB client. +/// +/// All public methods return `Result`. Use pattern matching +/// to handle specific failure modes, or [`Error::is_retryable`] for +/// simple retry logic. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Connection-level failures: connection refused, DNS resolution failure, + /// TLS handshake errors, channel closed. + #[error("transport error: {0}")] + Transport(#[from] tonic::transport::Error), + + /// gRPC status errors returned by SpiceDB. Includes the status code, + /// human-readable message, and optionally decoded SpiceDB-specific error details. + #[error("SpiceDB error ({code:?}): {message}")] + Status { + /// The gRPC status code. + code: tonic::Code, + /// Human-readable error message from the server. + message: String, + /// Decoded SpiceDB-specific error details, if available. + details: Option, + }, + + /// Local validation failures before a request is sent. + /// + /// Examples: empty `object_type`, empty `object_id`, empty schema string, + /// empty relationship update list. + #[error("invalid argument: {0}")] + InvalidArgument(String), + + /// Protobuf encode/decode failures. Indicates a bug or proto version mismatch. + #[error("serialization error: {0}")] + Serialization(String), + + /// Returned by [`PermissionResult::is_allowed()`](crate::PermissionResult::is_allowed) + /// when the result is `Conditional`. Forces callers to handle the caveated + /// case explicitly. + #[error("conditional permission: missing context fields {missing_fields:?}")] + ConditionalPermission { + /// The context fields that were missing, preventing full caveat evaluation. + missing_fields: Vec, + }, +} + +impl Error { + /// Returns `true` if this error is likely transient and the request may + /// succeed if retried. + /// + /// Currently considers `UNAVAILABLE` and `DEADLINE_EXCEEDED` as retryable. + pub fn is_retryable(&self) -> bool { + matches!( + self, + Error::Status { + code: tonic::Code::Unavailable | tonic::Code::DeadlineExceeded, + .. + } + ) + } + + /// Returns the gRPC status code if this is a `Status` error. + pub fn code(&self) -> Option { + match self { + Error::Status { code, .. } => Some(*code), + _ => None, + } + } + + pub(crate) fn from_status(status: tonic::Status) -> Self { + // TODO: decode SpiceDB-specific error details from status metadata + Error::Status { + code: status.code(), + message: status.message().to_string(), + details: None, + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1bd5053 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,78 @@ +//! # Prescience +//! +//! An idiomatic Rust client library for [SpiceDB](https://authzed.com/spicedb), +//! the open-source, Google Zanzibar-inspired authorization system. +//! +//! Prescience wraps the SpiceDB gRPC API with strong Rust types, ergonomic builders, +//! and first-class async support via [tonic](https://github.com/hyperium/tonic). +//! +//! ## Quick Start +//! +//! ```rust,no_run +//! use prescience::{Client, ObjectReference, SubjectReference, Consistency, PermissionResult}; +//! +//! # async fn example() -> Result<(), prescience::Error> { +//! let client = Client::new("http://localhost:50051", "my-token").await?; +//! +//! let result = client +//! .check_permission( +//! &ObjectReference::new("document", "doc-123")?, +//! "view", +//! &SubjectReference::new(ObjectReference::new("user", "alice")?, None::)?, +//! ) +//! .consistency(Consistency::FullyConsistent) +//! .await?; +//! +//! match result { +//! PermissionResult::Allowed => println!("access granted"), +//! PermissionResult::Denied => println!("access denied"), +//! PermissionResult::Conditional { missing_fields } => { +//! println!("need caveat context: {:?}", missing_fields); +//! } +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Feature Flags +//! +//! | Feature | Default | Description | +//! |---------|---------|-------------| +//! | `watch` | No | Enables the WatchService for streaming relationship changes | +//! | `experimental` | No | Enables experimental APIs (BulkCheckPermission, BulkImport/Export) | +//! | `serde` | No | Enables Serialize/Deserialize on ZedToken and domain types | +//! | `tls-rustls` | No | Use rustls for TLS | +//! | `tls-native` | No | Use native TLS | + +pub mod client; +pub mod error; +pub mod types; + +mod proto { + #![allow(clippy::all)] + #![allow(warnings)] + + tonic::include_proto!("authzed.api.v1"); + + pub mod google { + pub mod rpc { + /// A gRPC Status message (simplified). + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct Status { + #[prost(int32, tag = "1")] + pub code: i32, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "3")] + pub details: ::prost::alloc::vec::Vec<::prost_types::Any>, + } + } + } +} + +pub use client::Client; +pub use error::Error; +pub use types::*; + +#[cfg(feature = "experimental")] +pub use client::experimental::BulkCheckItem; diff --git a/src/types/consistency.rs b/src/types/consistency.rs new file mode 100644 index 0000000..15c7e1d --- /dev/null +++ b/src/types/consistency.rs @@ -0,0 +1,46 @@ +//! Consistency modes for SpiceDB reads. + +use crate::types::ZedToken; + +/// Controls the consistency guarantees for read operations. +/// +/// When no consistency is specified, the library sends no preference to the +/// server, which defaults to `MinimizeLatency`. +/// +/// # Examples +/// +/// ``` +/// use prescience::{Consistency, ZedToken}; +/// +/// // Strongest consistency — always read at latest +/// let c = Consistency::FullyConsistent; +/// +/// // Read at least as fresh as a previous write +/// let token = ZedToken::new("some-token").unwrap(); +/// let c = Consistency::AtLeastAsFresh(token); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Consistency { + /// Server picks the fastest available snapshot. Lowest latency, weakest consistency. + MinimizeLatency, + /// All data must be at least as fresh as the given token. + AtLeastAsFresh(ZedToken), + /// All data must be at exactly the given token's snapshot. + AtExactSnapshot(ZedToken), + /// All data must be at the most recent snapshot. Strongest consistency, highest latency. + FullyConsistent, +} + +impl From<&Consistency> for crate::proto::Consistency { + fn from(c: &Consistency) -> Self { + use crate::proto::consistency::Requirement; + crate::proto::Consistency { + requirement: Some(match c { + Consistency::MinimizeLatency => Requirement::MinimizeLatency(true), + Consistency::AtLeastAsFresh(token) => Requirement::AtLeastAsFresh(token.into()), + Consistency::AtExactSnapshot(token) => Requirement::AtExactSnapshot(token.into()), + Consistency::FullyConsistent => Requirement::FullyConsistent(true), + }), + } + } +} diff --git a/src/types/context.rs b/src/types/context.rs new file mode 100644 index 0000000..659de33 --- /dev/null +++ b/src/types/context.rs @@ -0,0 +1,139 @@ +//! Caveat context value types. + +use std::collections::HashMap; + +/// A typed value for caveat context evaluation. +/// +/// Maps to/from `prost_types::Value` internally. +/// +/// # Examples +/// +/// ``` +/// use prescience::ContextValue; +/// +/// let v = ContextValue::String("hello".into()); +/// let n = ContextValue::Number(42.0); +/// let b = ContextValue::Bool(true); +/// let list = ContextValue::List(vec![ +/// ContextValue::String("a".into()), +/// ContextValue::String("b".into()), +/// ]); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub enum ContextValue { + /// JSON null. + Null, + /// A boolean value. + Bool(bool), + /// A numeric value (f64). + Number(f64), + /// A string value. + String(String), + /// A list of values. + List(Vec), + /// A nested key-value structure. + Struct(HashMap), +} + +impl From<&ContextValue> for prost_types::Value { + fn from(cv: &ContextValue) -> Self { + use prost_types::value::Kind; + prost_types::Value { + kind: Some(match cv { + ContextValue::Null => Kind::NullValue(0), + ContextValue::Bool(b) => Kind::BoolValue(*b), + ContextValue::Number(n) => Kind::NumberValue(*n), + ContextValue::String(s) => Kind::StringValue(s.clone()), + ContextValue::List(items) => Kind::ListValue(prost_types::ListValue { + values: items.iter().map(Into::into).collect(), + }), + ContextValue::Struct(fields) => Kind::StructValue(prost_types::Struct { + fields: fields.iter().map(|(k, v)| (k.clone(), v.into())).collect(), + }), + }), + } + } +} + +impl From for ContextValue { + fn from(v: prost_types::Value) -> Self { + match v.kind { + Some(prost_types::value::Kind::NullValue(_)) => ContextValue::Null, + Some(prost_types::value::Kind::BoolValue(b)) => ContextValue::Bool(b), + Some(prost_types::value::Kind::NumberValue(n)) => ContextValue::Number(n), + Some(prost_types::value::Kind::StringValue(s)) => ContextValue::String(s), + Some(prost_types::value::Kind::ListValue(list)) => { + ContextValue::List(list.values.into_iter().map(Into::into).collect()) + } + Some(prost_types::value::Kind::StructValue(s)) => { + ContextValue::Struct(s.fields.into_iter().map(|(k, v)| (k, v.into())).collect()) + } + None => ContextValue::Null, + } + } +} + +/// Convert a HashMap of ContextValues to a prost_types::Struct. +pub(crate) fn context_to_struct(context: &HashMap) -> prost_types::Struct { + prost_types::Struct { + fields: context.iter().map(|(k, v)| (k.clone(), v.into())).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_null() { + let orig = ContextValue::Null; + let proto: prost_types::Value = (&orig).into(); + let back: ContextValue = proto.into(); + assert_eq!(orig, back); + } + + #[test] + fn roundtrip_bool() { + let orig = ContextValue::Bool(true); + let proto: prost_types::Value = (&orig).into(); + let back: ContextValue = proto.into(); + assert_eq!(orig, back); + } + + #[test] + fn roundtrip_number() { + let orig = ContextValue::Number(42.5); + let proto: prost_types::Value = (&orig).into(); + let back: ContextValue = proto.into(); + assert_eq!(orig, back); + } + + #[test] + fn roundtrip_string() { + let orig = ContextValue::String("hello".into()); + let proto: prost_types::Value = (&orig).into(); + let back: ContextValue = proto.into(); + assert_eq!(orig, back); + } + + #[test] + fn roundtrip_list() { + let orig = ContextValue::List(vec![ + ContextValue::Number(1.0), + ContextValue::String("two".into()), + ]); + let proto: prost_types::Value = (&orig).into(); + let back: ContextValue = proto.into(); + assert_eq!(orig, back); + } + + #[test] + fn roundtrip_nested_struct() { + let mut fields = HashMap::new(); + fields.insert("key".into(), ContextValue::Bool(false)); + let orig = ContextValue::Struct(fields); + let proto: prost_types::Value = (&orig).into(); + let back: ContextValue = proto.into(); + assert_eq!(orig, back); + } +} diff --git a/src/types/filter.rs b/src/types/filter.rs new file mode 100644 index 0000000..6cd4b9d --- /dev/null +++ b/src/types/filter.rs @@ -0,0 +1,135 @@ +//! Relationship filters and subject filters. + +use crate::error::Error; +use crate::types::{Relationship, ZedToken}; + +/// A filter for selecting relationships by resource type, ID, relation, and/or subject. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RelationshipFilter { + /// Resource type to filter on. + pub resource_type: String, + /// Optional resource ID. + pub optional_resource_id: Option, + /// Optional relation name. + pub optional_relation: Option, + /// Optional subject filter. + pub optional_subject_filter: Option, +} + +impl RelationshipFilter { + /// Creates a new filter for the given resource type. + pub fn new(resource_type: impl Into) -> Self { + Self { + resource_type: resource_type.into(), + optional_resource_id: None, + optional_relation: None, + optional_subject_filter: None, + } + } + + /// Adds a resource ID filter. + pub fn resource_id(mut self, id: impl Into) -> Self { + self.optional_resource_id = Some(id.into()); + self + } + + /// Adds a relation filter. + pub fn relation(mut self, relation: impl Into) -> Self { + self.optional_relation = Some(relation.into()); + self + } + + /// Adds a subject filter. + pub fn subject_filter(mut self, filter: SubjectFilter) -> Self { + self.optional_subject_filter = Some(filter); + self + } +} + +impl From<&RelationshipFilter> for crate::proto::RelationshipFilter { + fn from(f: &RelationshipFilter) -> Self { + crate::proto::RelationshipFilter { + resource_type: f.resource_type.clone(), + optional_resource_id: f.optional_resource_id.clone().unwrap_or_default(), + optional_resource_id_prefix: String::new(), + optional_relation: f.optional_relation.clone().unwrap_or_default(), + optional_subject_filter: f.optional_subject_filter.as_ref().map(Into::into), + } + } +} + +/// A filter on the subject side of a relationship. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubjectFilter { + /// The subject object type. + pub subject_type: String, + /// Optional subject object ID. + pub optional_subject_id: Option, + /// Optional relation on the subject. + pub optional_relation: Option, +} + +impl SubjectFilter { + /// Creates a new subject filter for the given type. + pub fn new(subject_type: impl Into) -> Self { + Self { + subject_type: subject_type.into(), + optional_subject_id: None, + optional_relation: None, + } + } + + /// Adds a subject ID filter. + pub fn subject_id(mut self, id: impl Into) -> Self { + self.optional_subject_id = Some(id.into()); + self + } + + /// Adds a relation filter on the subject. + pub fn relation(mut self, relation: impl Into) -> Self { + self.optional_relation = Some(relation.into()); + self + } +} + +impl From<&SubjectFilter> for crate::proto::SubjectFilter { + fn from(f: &SubjectFilter) -> Self { + crate::proto::SubjectFilter { + subject_type: f.subject_type.clone(), + optional_subject_id: f.optional_subject_id.clone().unwrap_or_default(), + optional_relation: f.optional_relation.as_ref().map(|r| { + crate::proto::subject_filter::RelationFilter { + relation: r.clone(), + } + }), + } + } +} + +/// A relationship with the ZedToken at which it was read. +#[derive(Debug, Clone, PartialEq)] +pub struct ReadRelationshipResult { + /// The relationship. + pub relationship: Relationship, + /// The ZedToken at which this relationship was read. + pub read_at: ZedToken, +} + +impl ReadRelationshipResult { + pub(crate) fn from_proto( + proto: crate::proto::ReadRelationshipsResponse, + ) -> Result { + let relationship = proto + .relationship + .ok_or_else(|| Error::Serialization("missing relationship".into()))? + .try_into()?; + let read_at = proto + .read_at + .ok_or_else(|| Error::Serialization("missing read_at token".into()))? + .try_into()?; + Ok(Self { + relationship, + read_at, + }) + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..5579919 --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,31 @@ +//! Domain types for the Prescience SpiceDB client. +//! +//! These are idiomatic Rust types wrapping the generated protobuf types. +//! The proto types are internal implementation details and are never exposed. + +mod consistency; +pub(crate) mod context; +mod filter; +mod permission; +mod reference; +mod relationship; +mod token; +#[cfg(feature = "watch")] +mod watch; + +pub use consistency::Consistency; +pub use context::ContextValue; +pub use filter::{RelationshipFilter, SubjectFilter}; +pub use permission::{PermissionResult, PermissionTree, PermissionTreeNode}; +pub use reference::{ObjectReference, SubjectReference}; +pub use relationship::{ + Caveat, Operation, Precondition, PreconditionOp, Relationship, RelationshipUpdate, +}; +pub use token::ZedToken; +#[cfg(feature = "watch")] +pub use watch::WatchEvent; + +// Re-export streaming result types +pub use filter::ReadRelationshipResult; +pub use permission::CheckResult; +pub use permission::{LookupResourceResult, LookupSubjectResult}; diff --git a/src/types/permission.rs b/src/types/permission.rs new file mode 100644 index 0000000..858c27c --- /dev/null +++ b/src/types/permission.rs @@ -0,0 +1,335 @@ +//! Permission result types, permission tree, and lookup result types. + +use crate::error::Error; +use crate::types::{ObjectReference, SubjectReference, ZedToken}; + +/// The result of a permission check. +/// +/// SpiceDB returns a 3-state result: the subject definitively has or lacks +/// the permission, or the permission is conditional on unresolved caveat context. +/// +/// Use [`is_allowed()`](PermissionResult::is_allowed) for a convenience boolean, +/// but note that it returns `Err` for `Conditional` to force explicit handling. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PermissionResult { + /// The subject definitively has the permission. + Allowed, + /// The subject definitively does not have the permission. + Denied, + /// The permission depends on unresolved caveat context. + Conditional { + /// Context fields needed to fully evaluate the caveat. + missing_fields: Vec, + }, +} + +impl PermissionResult { + /// Returns `Ok(true)` for `Allowed`, `Ok(false)` for `Denied`, and + /// `Err(Error::ConditionalPermission)` for `Conditional`. + /// + /// This forces callers to handle the conditional case explicitly rather + /// than silently dropping it. + pub fn is_allowed(&self) -> Result { + match self { + PermissionResult::Allowed => Ok(true), + PermissionResult::Denied => Ok(false), + PermissionResult::Conditional { missing_fields } => Err(Error::ConditionalPermission { + missing_fields: missing_fields.clone(), + }), + } + } + + /// Returns `true` only for `Denied`. + pub fn is_denied(&self) -> bool { + matches!(self, PermissionResult::Denied) + } + + /// Returns `true` only for `Conditional`. + pub fn is_conditional(&self) -> bool { + matches!(self, PermissionResult::Conditional { .. }) + } + + pub(crate) fn from_check_response( + permissionship: i32, + partial_caveat_info: Option, + ) -> Result { + match permissionship { + 2 => Ok(PermissionResult::Allowed), + 1 => Ok(PermissionResult::Denied), + 3 => Ok(PermissionResult::Conditional { + missing_fields: partial_caveat_info + .map(|info| info.missing_required_context) + .unwrap_or_default(), + }), + other => Err(Error::Serialization(format!( + "unknown permissionship: {}", + other + ))), + } + } + + pub(crate) fn from_lookup_permissionship( + permissionship: i32, + partial_caveat_info: Option, + ) -> Result { + match permissionship { + 1 => Ok(PermissionResult::Allowed), + 2 => Ok(PermissionResult::Conditional { + missing_fields: partial_caveat_info + .map(|info| info.missing_required_context) + .unwrap_or_default(), + }), + other => Err(Error::Serialization(format!( + "unknown lookup permissionship: {}", + other + ))), + } + } +} + +/// A resource found by LookupResources, with its permission status and token. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LookupResourceResult { + /// The resource object ID. + pub resource_id: String, + /// The permission status for this resource. + pub permission: PermissionResult, + /// The ZedToken at which this resource was looked up. + pub looked_up_at: ZedToken, +} + +impl LookupResourceResult { + pub(crate) fn from_proto(proto: crate::proto::LookupResourcesResponse) -> Result { + let permission = PermissionResult::from_lookup_permissionship( + proto.permissionship, + proto.partial_caveat_info, + )?; + let looked_up_at = proto + .looked_up_at + .ok_or_else(|| Error::Serialization("missing looked_up_at".into()))? + .try_into()?; + Ok(Self { + resource_id: proto.resource_object_id, + permission, + looked_up_at, + }) + } +} + +/// A subject found by LookupSubjects, with its permission status and token. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LookupSubjectResult { + /// The resolved subject. + pub subject_id: String, + /// Subjects excluded from a wildcard match. + pub excluded_subject_ids: Vec, + /// The permission status for this subject. + pub permission: PermissionResult, + /// The ZedToken at which this subject was looked up. + pub looked_up_at: ZedToken, +} + +impl LookupSubjectResult { + pub(crate) fn from_proto(proto: crate::proto::LookupSubjectsResponse) -> Result { + let looked_up_at = proto + .looked_up_at + .ok_or_else(|| Error::Serialization("missing looked_up_at".into()))? + .try_into()?; + + // Use the new `subject` field if available, fall back to deprecated fields + if let Some(resolved) = proto.subject { + let permission = PermissionResult::from_lookup_permissionship( + resolved.permissionship, + resolved.partial_caveat_info, + )?; + let excluded_ids: Vec = proto + .excluded_subjects + .into_iter() + .map(|s| s.subject_object_id) + .collect(); + Ok(Self { + subject_id: resolved.subject_object_id, + excluded_subject_ids: excluded_ids, + permission, + looked_up_at, + }) + } else { + // Fallback to deprecated fields + #[allow(deprecated)] + let permission = PermissionResult::from_lookup_permissionship( + proto.permissionship, + proto.partial_caveat_info, + )?; + #[allow(deprecated)] + Ok(Self { + subject_id: proto.subject_object_id, + excluded_subject_ids: proto.excluded_subject_ids, + permission, + looked_up_at, + }) + } + } +} + +/// Per-item result from a bulk check operation. +pub type CheckResult = Result; + +/// A recursive permission tree returned by ExpandPermissionTree. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PermissionTree { + /// The object that was expanded. + pub expanded_object: ObjectReference, + /// The relation that was expanded. + pub expanded_relation: String, + /// The root node of the permission tree. + pub node: PermissionTreeNode, +} + +/// A node in a permission tree. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PermissionTreeNode { + /// A leaf node containing direct subjects. + Leaf { + /// The subjects at this leaf. + subjects: Vec, + }, + /// A union of child nodes. + Union { + /// Child nodes whose subjects are unioned. + children: Vec, + }, + /// An intersection of child nodes. + Intersection { + /// Child nodes whose subjects are intersected. + children: Vec, + }, + /// An exclusion: base minus excluded. + Exclusion { + /// The base set. + base: Box, + /// The set to exclude. + excluded: Box, + }, +} + +impl PermissionTree { + pub(crate) fn from_proto( + proto: crate::proto::PermissionRelationshipTree, + ) -> Result { + let expanded_object = proto + .expanded_object + .ok_or_else(|| Error::Serialization("missing expanded_object".into()))? + .try_into()?; + let node = PermissionTreeNode::from_proto_tree(proto.tree_type)?; + Ok(Self { + expanded_object, + expanded_relation: proto.expanded_relation, + node, + }) + } +} + +impl PermissionTreeNode { + fn from_proto_tree( + tree_type: Option, + ) -> Result { + match tree_type { + Some(crate::proto::permission_relationship_tree::TreeType::Leaf(leaf)) => { + let subjects: Result, Error> = + leaf.subjects.into_iter().map(TryInto::try_into).collect(); + Ok(PermissionTreeNode::Leaf { + subjects: subjects?, + }) + } + Some(crate::proto::permission_relationship_tree::TreeType::Intermediate(alg)) => { + let children: Result, Error> = alg + .children + .into_iter() + .map(|child| PermissionTreeNode::from_proto_tree(child.tree_type)) + .collect(); + let children = children?; + + match alg.operation { + 1 => Ok(PermissionTreeNode::Union { children }), + 2 => Ok(PermissionTreeNode::Intersection { children }), + 3 => { + // Exclusion: exactly 2 children — base and excluded + if children.len() != 2 { + return Err(Error::Serialization(format!( + "exclusion requires exactly 2 children, got {}", + children.len() + ))); + } + let mut iter = children.into_iter(); + let base = Box::new(iter.next().unwrap()); + let excluded = Box::new(iter.next().unwrap()); + Ok(PermissionTreeNode::Exclusion { base, excluded }) + } + other => Err(Error::Serialization(format!( + "unknown algebraic operation: {}", + other + ))), + } + } + None => Err(Error::Serialization("missing tree type".into())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_result_allowed() { + let r = PermissionResult::Allowed; + assert_eq!(r.is_allowed().unwrap(), true); + assert!(!r.is_denied()); + assert!(!r.is_conditional()); + } + + #[test] + fn permission_result_denied() { + let r = PermissionResult::Denied; + assert_eq!(r.is_allowed().unwrap(), false); + assert!(r.is_denied()); + assert!(!r.is_conditional()); + } + + #[test] + fn permission_result_conditional() { + let r = PermissionResult::Conditional { + missing_fields: vec!["ip_address".into()], + }; + let err = r.is_allowed().unwrap_err(); + assert!(matches!(err, Error::ConditionalPermission { .. })); + assert!(!r.is_denied()); + assert!(r.is_conditional()); + } + + #[test] + fn from_check_response_allowed() { + let r = PermissionResult::from_check_response(2, None).unwrap(); + assert_eq!(r, PermissionResult::Allowed); + } + + #[test] + fn from_check_response_denied() { + let r = PermissionResult::from_check_response(1, None).unwrap(); + assert_eq!(r, PermissionResult::Denied); + } + + #[test] + fn from_check_response_conditional() { + let info = crate::proto::PartialCaveatInfo { + missing_required_context: vec!["field1".into()], + }; + let r = PermissionResult::from_check_response(3, Some(info)).unwrap(); + assert_eq!( + r, + PermissionResult::Conditional { + missing_fields: vec!["field1".into()] + } + ); + } +} diff --git a/src/types/reference.rs b/src/types/reference.rs new file mode 100644 index 0000000..cdd83c0 --- /dev/null +++ b/src/types/reference.rs @@ -0,0 +1,242 @@ +//! Object and subject references. + +use crate::error::Error; + +/// A reference to a specific object in the SpiceDB system. +/// +/// Consists of an object type (e.g., `"document"`) and an object ID (e.g., `"doc-123"`). +/// Both fields must be non-empty. +/// +/// # Examples +/// +/// ``` +/// use prescience::ObjectReference; +/// +/// let obj = ObjectReference::new("document", "doc-123").unwrap(); +/// assert_eq!(obj.object_type(), "document"); +/// assert_eq!(obj.object_id(), "doc-123"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ObjectReference { + object_type: String, + object_id: String, +} + +impl ObjectReference { + /// Creates a new `ObjectReference` with the given type and ID. + /// + /// Returns `Err` if either `object_type` or `object_id` is empty. + pub fn new( + object_type: impl Into, + object_id: impl Into, + ) -> Result { + let object_type = object_type.into(); + let object_id = object_id.into(); + + if object_type.is_empty() { + return Err(Error::InvalidArgument( + "object_type must not be empty".into(), + )); + } + if object_id.is_empty() { + return Err(Error::InvalidArgument("object_id must not be empty".into())); + } + + Ok(Self { + object_type, + object_id, + }) + } + + /// Returns the object type. + pub fn object_type(&self) -> &str { + &self.object_type + } + + /// Returns the object ID. + pub fn object_id(&self) -> &str { + &self.object_id + } +} + +impl From<&ObjectReference> for crate::proto::ObjectReference { + fn from(r: &ObjectReference) -> Self { + crate::proto::ObjectReference { + object_type: r.object_type.clone(), + object_id: r.object_id.clone(), + } + } +} + +impl TryFrom for ObjectReference { + type Error = Error; + + fn try_from(proto: crate::proto::ObjectReference) -> Result { + ObjectReference::new(proto.object_type, proto.object_id) + } +} + +/// A reference to a subject in a relationship. +/// +/// Consists of an [`ObjectReference`] and an optional relation name +/// (e.g., `group:eng#member`). +/// +/// # Examples +/// +/// ``` +/// use prescience::{ObjectReference, SubjectReference}; +/// +/// // Simple subject +/// let subject = SubjectReference::new( +/// ObjectReference::new("user", "alice").unwrap(), +/// None::, +/// ).unwrap(); +/// +/// // Subject with relation +/// let subject = SubjectReference::new( +/// ObjectReference::new("group", "eng").unwrap(), +/// Some("member"), +/// ).unwrap(); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubjectReference { + object: ObjectReference, + optional_relation: Option, +} + +impl SubjectReference { + /// Creates a new `SubjectReference`. + /// + /// Returns `Err` if `optional_relation` is `Some("")` (empty string). + /// Use `None` instead to indicate no relation. + pub fn new( + object: ObjectReference, + optional_relation: Option>, + ) -> Result { + let optional_relation = optional_relation.map(Into::into); + if let Some(ref rel) = optional_relation { + if rel.is_empty() { + return Err(Error::InvalidArgument( + "optional_relation must not be empty; use None instead".into(), + )); + } + } + Ok(Self { + object, + optional_relation, + }) + } + + /// Returns the subject's object reference. + pub fn object(&self) -> &ObjectReference { + &self.object + } + + /// Returns the optional relation on the subject. + pub fn optional_relation(&self) -> Option<&str> { + self.optional_relation.as_deref() + } +} + +impl From<&SubjectReference> for crate::proto::SubjectReference { + fn from(r: &SubjectReference) -> Self { + crate::proto::SubjectReference { + object: Some((&r.object).into()), + optional_relation: r.optional_relation.clone().unwrap_or_default(), + } + } +} + +impl TryFrom for SubjectReference { + type Error = Error; + + fn try_from(proto: crate::proto::SubjectReference) -> Result { + let object = proto + .object + .ok_or_else(|| Error::Serialization("missing subject object".into()))? + .try_into()?; + let relation = if proto.optional_relation.is_empty() { + None + } else { + Some(proto.optional_relation) + }; + SubjectReference::new(object, relation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_reference_valid() { + let obj = ObjectReference::new("document", "doc-123").unwrap(); + assert_eq!(obj.object_type(), "document"); + assert_eq!(obj.object_id(), "doc-123"); + } + + #[test] + fn object_reference_empty_type() { + let err = ObjectReference::new("", "doc-123").unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn object_reference_empty_id() { + let err = ObjectReference::new("document", "").unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn object_reference_equality_and_hash() { + use std::collections::HashSet; + let a = ObjectReference::new("doc", "1").unwrap(); + let b = ObjectReference::new("doc", "1").unwrap(); + let c = ObjectReference::new("doc", "2").unwrap(); + assert_eq!(a, b); + assert_ne!(a, c); + let mut set = HashSet::new(); + set.insert(a.clone()); + assert!(set.contains(&b)); + assert!(!set.contains(&c)); + } + + #[test] + fn subject_reference_without_relation() { + let obj = ObjectReference::new("user", "alice").unwrap(); + let sub = SubjectReference::new(obj, None::).unwrap(); + assert_eq!(sub.object().object_type(), "user"); + assert_eq!(sub.optional_relation(), None); + } + + #[test] + fn subject_reference_with_relation() { + let obj = ObjectReference::new("group", "eng").unwrap(); + let sub = SubjectReference::new(obj, Some("member")).unwrap(); + assert_eq!(sub.optional_relation(), Some("member")); + } + + #[test] + fn subject_reference_empty_relation_rejected() { + let obj = ObjectReference::new("group", "eng").unwrap(); + let err = SubjectReference::new(obj, Some("")).unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn proto_roundtrip_object_reference() { + let orig = ObjectReference::new("document", "doc-123").unwrap(); + let proto: crate::proto::ObjectReference = (&orig).into(); + let back: ObjectReference = proto.try_into().unwrap(); + assert_eq!(orig, back); + } + + #[test] + fn proto_roundtrip_subject_reference() { + let obj = ObjectReference::new("user", "alice").unwrap(); + let orig = SubjectReference::new(obj, Some("member")).unwrap(); + let proto: crate::proto::SubjectReference = (&orig).into(); + let back: SubjectReference = proto.try_into().unwrap(); + assert_eq!(orig, back); + } +} diff --git a/src/types/relationship.rs b/src/types/relationship.rs new file mode 100644 index 0000000..a2578d3 --- /dev/null +++ b/src/types/relationship.rs @@ -0,0 +1,289 @@ +//! Relationship, RelationshipUpdate, Caveat, and Precondition types. + +use std::collections::HashMap; + +use crate::error::Error; +use crate::types::{ContextValue, ObjectReference, SubjectReference}; + +/// A caveat attached to a relationship, with optional context for evaluation. +#[derive(Debug, Clone, PartialEq)] +pub struct Caveat { + /// The caveat name as defined in the SpiceDB schema. + pub name: String, + /// Key-value context pairs for caveat evaluation. + pub context: HashMap, +} + +impl Caveat { + /// Creates a new caveat with the given name and context. + pub fn new(name: impl Into, context: HashMap) -> Self { + Self { + name: name.into(), + context, + } + } +} + +/// A relationship between a resource and a subject via a relation. +#[derive(Debug, Clone, PartialEq)] +pub struct Relationship { + /// The resource side of the relationship. + pub resource: ObjectReference, + /// The relation name (e.g., `"viewer"`, `"owner"`). + pub relation: String, + /// The subject side of the relationship. + pub subject: SubjectReference, + /// An optional caveat on this relationship. + pub optional_caveat: Option, +} + +impl Relationship { + /// Creates a new relationship without a caveat. + pub fn new( + resource: ObjectReference, + relation: impl Into, + subject: SubjectReference, + ) -> Self { + Self { + resource, + relation: relation.into(), + subject, + optional_caveat: None, + } + } + + /// Attaches a caveat to this relationship. + pub fn with_caveat(mut self, caveat: Caveat) -> Self { + self.optional_caveat = Some(caveat); + self + } +} + +impl TryFrom for Relationship { + type Error = Error; + + fn try_from(proto: crate::proto::Relationship) -> Result { + let resource = proto + .resource + .ok_or_else(|| Error::Serialization("missing resource".into()))? + .try_into()?; + let subject = proto + .subject + .ok_or_else(|| Error::Serialization("missing subject".into()))? + .try_into()?; + let optional_caveat = proto.optional_caveat.map(|c| Caveat { + name: c.caveat_name, + context: c + .context + .map(|s| s.fields.into_iter().map(|(k, v)| (k, v.into())).collect()) + .unwrap_or_default(), + }); + Ok(Relationship { + resource, + relation: proto.relation, + subject, + optional_caveat, + }) + } +} + +impl From<&Relationship> for crate::proto::Relationship { + fn from(r: &Relationship) -> Self { + crate::proto::Relationship { + resource: Some((&r.resource).into()), + relation: r.relation.clone(), + subject: Some((&r.subject).into()), + optional_caveat: r.optional_caveat.as_ref().map(|c| { + crate::proto::ContextualizedCaveat { + caveat_name: c.name.clone(), + context: if c.context.is_empty() { + None + } else { + Some(crate::types::context::context_to_struct(&c.context)) + }, + } + }), + optional_expires_at: None, + } + } +} + +/// The operation to perform on a relationship. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Operation { + /// Create the relationship; error if it already exists. + Create, + /// Upsert the relationship; no error if it already exists. + Touch, + /// Delete the relationship; no-op if it doesn't exist. + Delete, +} + +/// A relationship mutation (create, touch, or delete). +#[derive(Debug, Clone, PartialEq)] +pub struct RelationshipUpdate { + /// The operation to perform. + pub operation: Operation, + /// The relationship to mutate. + pub relationship: Relationship, +} + +impl RelationshipUpdate { + /// Creates a CREATE update for the given relationship. + pub fn create(relationship: Relationship) -> Self { + Self { + operation: Operation::Create, + relationship, + } + } + + /// Creates a TOUCH (upsert) update for the given relationship. + pub fn touch(relationship: Relationship) -> Self { + Self { + operation: Operation::Touch, + relationship, + } + } + + /// Creates a DELETE update for the given relationship. + pub fn delete(relationship: Relationship) -> Self { + Self { + operation: Operation::Delete, + relationship, + } + } +} + +impl From<&RelationshipUpdate> for crate::proto::RelationshipUpdate { + fn from(u: &RelationshipUpdate) -> Self { + crate::proto::RelationshipUpdate { + operation: match u.operation { + Operation::Create => crate::proto::relationship_update::Operation::Create as i32, + Operation::Touch => crate::proto::relationship_update::Operation::Touch as i32, + Operation::Delete => crate::proto::relationship_update::Operation::Delete as i32, + }, + relationship: Some((&u.relationship).into()), + } + } +} + +impl TryFrom for RelationshipUpdate { + type Error = Error; + + fn try_from(proto: crate::proto::RelationshipUpdate) -> Result { + let operation = match proto.operation { + 1 => Operation::Create, + 2 => Operation::Touch, + 3 => Operation::Delete, + other => { + return Err(Error::Serialization(format!( + "unknown operation: {}", + other + ))) + } + }; + let relationship = proto + .relationship + .ok_or_else(|| Error::Serialization("missing relationship".into()))? + .try_into()?; + Ok(RelationshipUpdate { + operation, + relationship, + }) + } +} + +/// The operation for a precondition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PreconditionOp { + /// The filter must match at least one existing relationship. + MustExist, + /// The filter must not match any existing relationships. + MustNotExist, +} + +/// A precondition on a write or delete operation. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Precondition { + /// The precondition operation. + pub operation: PreconditionOp, + /// The filter that must (or must not) match. + pub filter: crate::types::RelationshipFilter, +} + +impl Precondition { + /// Creates a precondition that requires matching relationships to exist. + pub fn must_exist(filter: crate::types::RelationshipFilter) -> Self { + Self { + operation: PreconditionOp::MustExist, + filter, + } + } + + /// Creates a precondition that requires no matching relationships to exist. + pub fn must_not_exist(filter: crate::types::RelationshipFilter) -> Self { + Self { + operation: PreconditionOp::MustNotExist, + filter, + } + } +} + +impl From<&Precondition> for crate::proto::Precondition { + fn from(p: &Precondition) -> Self { + crate::proto::Precondition { + operation: match p.operation { + PreconditionOp::MustExist => { + crate::proto::precondition::Operation::MustMatch as i32 + } + PreconditionOp::MustNotExist => { + crate::proto::precondition::Operation::MustNotMatch as i32 + } + }, + filter: Some((&p.filter).into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relationship_create_update() { + let rel = Relationship::new( + ObjectReference::new("doc", "1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(), + ); + let update = RelationshipUpdate::create(rel); + assert_eq!(update.operation, Operation::Create); + } + + #[test] + fn relationship_with_caveat() { + let rel = Relationship::new( + ObjectReference::new("doc", "1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(), + ) + .with_caveat(Caveat::new("ip_check", HashMap::new())); + assert!(rel.optional_caveat.is_some()); + assert_eq!(rel.optional_caveat.unwrap().name, "ip_check"); + } + + #[test] + fn precondition_must_exist() { + use crate::types::RelationshipFilter; + let p = Precondition::must_exist(RelationshipFilter::new("document")); + assert_eq!(p.operation, PreconditionOp::MustExist); + } +} diff --git a/src/types/token.rs b/src/types/token.rs new file mode 100644 index 0000000..d2f867c --- /dev/null +++ b/src/types/token.rs @@ -0,0 +1,134 @@ +//! ZedToken — represents a point in time / revision in SpiceDB. + +use crate::error::Error; +use std::fmt; + +/// A ZedToken represents a point in time (revision) in SpiceDB. +/// +/// ZedTokens are returned by mutating operations and can be passed to +/// read operations via [`Consistency`](crate::Consistency) to ensure +/// causal consistency. +/// +/// The token value is redacted in `Debug` output for security. +/// +/// # Examples +/// +/// ``` +/// use prescience::ZedToken; +/// +/// let token = ZedToken::new("some-opaque-token-value").unwrap(); +/// // Debug output redacts the value +/// assert_eq!(format!("{:?}", token), r#"ZedToken("***")"#); +/// ``` +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ZedToken { + token: String, +} + +impl ZedToken { + /// Creates a new `ZedToken` from a token string. + /// + /// Returns `Err` if the token string is empty. + pub fn new(token: impl Into) -> Result { + let token = token.into(); + if token.is_empty() { + return Err(Error::InvalidArgument("ZedToken must not be empty".into())); + } + Ok(Self { token }) + } + + /// Returns the raw token string. + pub fn token(&self) -> &str { + &self.token + } +} + +/// Debug output redacts the token value for security. +impl fmt::Debug for ZedToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(r#"ZedToken("***")"#) + } +} + +impl fmt::Display for ZedToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ZedToken(***)") + } +} + +impl From<&ZedToken> for crate::proto::ZedToken { + fn from(t: &ZedToken) -> Self { + crate::proto::ZedToken { + token: t.token.clone(), + } + } +} + +impl TryFrom for ZedToken { + type Error = Error; + + fn try_from(proto: crate::proto::ZedToken) -> Result { + ZedToken::new(proto.token) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for ZedToken { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.token) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ZedToken { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + ZedToken::new(s).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_token() { + let token = ZedToken::new("abc123").unwrap(); + assert_eq!(token.token(), "abc123"); + } + + #[test] + fn empty_token_rejected() { + let err = ZedToken::new("").unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn debug_redacts_value() { + let token = ZedToken::new("secret-token").unwrap(); + let debug = format!("{:?}", token); + assert!(!debug.contains("secret-token")); + assert!(debug.contains("***")); + } + + #[test] + fn equality_and_hash() { + use std::collections::HashSet; + let a = ZedToken::new("tok1").unwrap(); + let b = ZedToken::new("tok1").unwrap(); + let c = ZedToken::new("tok2").unwrap(); + assert_eq!(a, b); + assert_ne!(a, c); + let mut set = HashSet::new(); + set.insert(a.clone()); + assert!(set.contains(&b)); + } + + #[test] + fn proto_roundtrip() { + let orig = ZedToken::new("test-token").unwrap(); + let proto: crate::proto::ZedToken = (&orig).into(); + let back: ZedToken = proto.try_into().unwrap(); + assert_eq!(orig, back); + } +} diff --git a/src/types/watch.rs b/src/types/watch.rs new file mode 100644 index 0000000..1380f3b --- /dev/null +++ b/src/types/watch.rs @@ -0,0 +1,30 @@ +//! Watch event types (behind `watch` feature). + +use crate::error::Error; +use crate::types::{RelationshipUpdate, ZedToken}; + +/// An event from the SpiceDB Watch stream. +/// +/// Contains relationship changes and a checkpoint token for resumption. +#[derive(Debug, Clone, PartialEq)] +pub struct WatchEvent { + /// The relationship updates in this event. + pub updates: Vec, + /// Checkpoint token for resuming the watch stream. + pub checkpoint: ZedToken, +} + +impl WatchEvent { + pub(crate) fn from_proto(proto: crate::proto::WatchResponse) -> Result { + let updates: Result, Error> = + proto.updates.into_iter().map(TryInto::try_into).collect(); + let checkpoint = proto + .changes_through + .ok_or_else(|| Error::Serialization("missing changes_through token".into()))? + .try_into()?; + Ok(Self { + updates: updates?, + checkpoint, + }) + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..2aadc66 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,524 @@ +//! Integration tests against a SpiceDB instance managed by testcontainers. +//! +//! All tests share a single SpiceDB container for speed. Each test uses +//! unique resource/subject IDs to avoid interference. + +use std::borrow::Cow; +use std::sync::Arc; + +use prescience::{ + Client, Consistency, ObjectReference, PermissionResult, Relationship, RelationshipFilter, + RelationshipUpdate, SubjectReference, +}; +use testcontainers::core::{IntoContainerPort, WaitFor}; +use testcontainers::runners::AsyncRunner; +use testcontainers::ContainerAsync; +use tokio::sync::OnceCell; +use tokio_stream::StreamExt; + +// ── SpiceDB testcontainer image ─────────────────────────────── + +const SPICEDB_IMAGE: &str = "authzed/spicedb"; +const SPICEDB_TAG: &str = "v1.45.4"; +const SPICEDB_GRPC_PORT: u16 = 50051; +const SPICEDB_TOKEN: &str = "test-key"; + +#[derive(Debug)] +struct SpiceDbImage; + +impl testcontainers::Image for SpiceDbImage { + fn name(&self) -> &str { + SPICEDB_IMAGE + } + + fn tag(&self) -> &str { + SPICEDB_TAG + } + + fn ready_conditions(&self) -> Vec { + vec![WaitFor::message_on_stderr("grpc server started serving")] + } + + fn env_vars( + &self, + ) -> impl IntoIterator>, impl Into>)> { + vec![ + ("SPICEDB_GRPC_PRESHARED_KEY", SPICEDB_TOKEN), + ("SPICEDB_DATASTORE_ENGINE", "memory"), + ] + } + + fn cmd(&self) -> impl IntoIterator>> { + vec!["serve"] + } + + fn expose_ports(&self) -> &[testcontainers::core::ContainerPort] { + &[testcontainers::core::ContainerPort::Tcp(SPICEDB_GRPC_PORT)] + } +} + +// ── Shared container ────────────────────────────────────────── + +/// Holds the running container and its mapped port. +/// The `Client` is NOT shared because each `#[tokio::test]` creates its +/// own tokio runtime, and tonic `Channel` is tied to the runtime that +/// created it. Sharing a single Channel across runtimes causes transport +/// errors. Instead we share only the container and create a fresh Client +/// per test invocation. +struct SharedSpiceDb { + _container: ContainerAsync, + port: u16, + schema_written: bool, +} + +static SPICEDB: OnceCell> = OnceCell::const_new(); + +/// Returns a fresh `Client` connected to the shared SpiceDB container. +/// The container is started once (lazily) and the schema is written on +/// first access. Each call creates a new tonic Channel on the caller's +/// runtime, avoiding cross-runtime transport errors. +async fn spicedb() -> Client { + // Ensure container is started and schema is written (once) + let shared = SPICEDB + .get_or_init(|| async { + let container = SpiceDbImage + .start() + .await + .expect("failed to start SpiceDB container"); + let port = container + .get_host_port_ipv4(SPICEDB_GRPC_PORT.tcp()) + .await + .expect("failed to get mapped port"); + let endpoint = format!("http://localhost:{}", port); + + // Retry until gRPC is fully serving (log message can appear before ready) + let client = { + let mut last_err = None; + let mut result = None; + for _ in 0..30 { + match Client::new(&endpoint, SPICEDB_TOKEN).await { + Ok(c) => match c.read_schema().await { + // Schema read succeeded — SpiceDB is ready + Ok(_) => { + result = Some(c); + break; + } + // NotFound means SpiceDB is serving but has no schema yet — that's ready + Err(ref e) if e.code() == Some(tonic::Code::NotFound) => { + result = Some(c); + break; + } + Err(e) => { + last_err = Some(format!("{e}")); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + }, + Err(e) => { + last_err = Some(format!("{e}")); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + } + result.unwrap_or_else(|| { + panic!( + "SpiceDB not ready after retries: {}", + last_err.unwrap_or_default() + ) + }) + }; + + // Write schema once for all tests + client + .write_schema(TEST_SCHEMA) + .await + .expect("write_schema failed"); + + Arc::new(SharedSpiceDb { + _container: container, + port, + schema_written: true, + }) + }) + .await; + + assert!(shared.schema_written, "schema should have been written"); + + // Create a fresh client on the CURRENT runtime + let endpoint = format!("http://localhost:{}", shared.port); + Client::new(&endpoint, SPICEDB_TOKEN) + .await + .expect("failed to create client for test") +} + +const TEST_SCHEMA: &str = r#" +definition user {} + +definition document { + relation viewer: user + relation editor: user + + permission view = viewer + editor + permission edit = editor +} +"#; + +// ── Schema ──────────────────────────────────────────────────── + +#[tokio::test] +async fn write_and_read_schema() { + let c = spicedb().await; + + let (schema_text, read_at) = c.read_schema().await.expect("read_schema failed"); + assert!(schema_text.contains("definition document")); + assert!(!read_at.token().is_empty()); +} + +#[tokio::test] +async fn write_schema_empty_rejected() { + let c = spicedb().await; + let err = c.write_schema("").await.unwrap_err(); + assert!(matches!(err, prescience::Error::InvalidArgument(_))); +} + +// ── Relationships ───────────────────────────────────────────── + +#[tokio::test] +async fn write_relationships_empty_rejected() { + let c = spicedb().await; + let err = c.write_relationships(vec![]).await.unwrap_err(); + assert!(matches!(err, prescience::Error::InvalidArgument(_))); +} + +#[tokio::test] +async fn write_and_check_permission() { + let c = spicedb().await; + + let token = c + .write_relationships(vec![RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "check-1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(), + ))]) + .await + .expect("write_relationships failed"); + + let result = c + .check_permission( + &ObjectReference::new("document", "check-1").unwrap(), + "view", + &SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(), + ) + .consistency(Consistency::AtLeastAsFresh(token.clone())) + .await + .expect("check_permission failed"); + + assert!(result.is_allowed().unwrap()); + assert_eq!(result, PermissionResult::Allowed); + + let result = c + .check_permission( + &ObjectReference::new("document", "check-1").unwrap(), + "edit", + &SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(), + ) + .consistency(Consistency::AtLeastAsFresh(token)) + .await + .expect("check_permission failed"); + + assert!(!result.is_allowed().unwrap()); + assert_eq!(result, PermissionResult::Denied); +} + +#[tokio::test] +async fn read_relationships() { + let c = spicedb().await; + + let token = c + .write_relationships(vec![ + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "read-1").unwrap(), + "viewer", + SubjectReference::new(ObjectReference::new("user", "bob").unwrap(), None::) + .unwrap(), + )), + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "read-1").unwrap(), + "editor", + SubjectReference::new( + ObjectReference::new("user", "carol").unwrap(), + None::, + ) + .unwrap(), + )), + ]) + .await + .unwrap(); + + let filter = RelationshipFilter::new("document").resource_id("read-1"); + let mut stream = c + .read_relationships(filter) + .consistency(Consistency::AtLeastAsFresh(token)) + .send() + .await + .expect("read_relationships failed"); + + let mut count = 0; + while let Some(result) = stream.next().await { + let item = result.expect("stream item error"); + assert_eq!(item.relationship.resource.object_type(), "document"); + assert_eq!(item.relationship.resource.object_id(), "read-1"); + count += 1; + } + assert_eq!(count, 2); +} + +#[tokio::test] +async fn lookup_resources() { + let c = spicedb().await; + + let token = c + .write_relationships(vec![ + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "lr-1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "dave").unwrap(), + None::, + ) + .unwrap(), + )), + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "lr-2").unwrap(), + "editor", + SubjectReference::new( + ObjectReference::new("user", "dave").unwrap(), + None::, + ) + .unwrap(), + )), + ]) + .await + .unwrap(); + + let subject = SubjectReference::new( + ObjectReference::new("user", "dave").unwrap(), + None::, + ) + .unwrap(); + + let mut stream = c + .lookup_resources("document", "view", &subject) + .consistency(Consistency::AtLeastAsFresh(token)) + .send() + .await + .expect("lookup_resources failed"); + + let mut resource_ids = vec![]; + while let Some(result) = stream.next().await { + let item = result.expect("stream item error"); + resource_ids.push(item.resource_id); + } + resource_ids.sort(); + assert!(resource_ids.contains(&"lr-1".to_string())); + assert!(resource_ids.contains(&"lr-2".to_string())); +} + +#[tokio::test] +async fn lookup_subjects() { + let c = spicedb().await; + + let token = c + .write_relationships(vec![ + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "ls-1").unwrap(), + "viewer", + SubjectReference::new(ObjectReference::new("user", "eve").unwrap(), None::) + .unwrap(), + )), + RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "ls-1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "frank").unwrap(), + None::, + ) + .unwrap(), + )), + ]) + .await + .unwrap(); + + let resource = ObjectReference::new("document", "ls-1").unwrap(); + let mut stream = c + .lookup_subjects(&resource, "view", "user") + .consistency(Consistency::AtLeastAsFresh(token)) + .send() + .await + .expect("lookup_subjects failed"); + + let mut subject_ids = vec![]; + while let Some(result) = stream.next().await { + let item = result.expect("stream item error"); + subject_ids.push(item.subject_id); + } + subject_ids.sort(); + assert!(subject_ids.contains(&"eve".to_string())); + assert!(subject_ids.contains(&"frank".to_string())); +} + +#[tokio::test] +async fn delete_relationships() { + let c = spicedb().await; + + let token = c + .write_relationships(vec![RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "del-1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "grace").unwrap(), + None::, + ) + .unwrap(), + ))]) + .await + .unwrap(); + + let result = c + .check_permission( + &ObjectReference::new("document", "del-1").unwrap(), + "view", + &SubjectReference::new( + ObjectReference::new("user", "grace").unwrap(), + None::, + ) + .unwrap(), + ) + .consistency(Consistency::AtLeastAsFresh(token)) + .await + .unwrap(); + assert!(result.is_allowed().unwrap()); + + let del_token = c + .delete_relationships( + RelationshipFilter::new("document") + .resource_id("del-1") + .relation("viewer"), + ) + .await + .unwrap(); + + let result = c + .check_permission( + &ObjectReference::new("document", "del-1").unwrap(), + "view", + &SubjectReference::new( + ObjectReference::new("user", "grace").unwrap(), + None::, + ) + .unwrap(), + ) + .consistency(Consistency::AtLeastAsFresh(del_token)) + .await + .unwrap(); + assert!(!result.is_allowed().unwrap()); +} + +// ── Watch ───────────────────────────────────────────────────── + +#[cfg(feature = "watch")] +#[tokio::test] +async fn watch_receives_updates() { + let c = spicedb().await; + + let mut stream = c + .watch(vec!["document"]) + .send() + .await + .expect("watch failed"); + + let c2 = c.clone(); + let write_handle = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + c2.write_relationships(vec![RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "watch-1").unwrap(), + "viewer", + SubjectReference::new(ObjectReference::new("user", "hal").unwrap(), None::) + .unwrap(), + ))]) + .await + .unwrap(); + }); + + let event = tokio::time::timeout(std::time::Duration::from_secs(10), stream.next()) + .await + .expect("timed out waiting for watch event") + .expect("stream ended") + .expect("watch event error"); + + assert!(!event.updates.is_empty()); + write_handle.await.unwrap(); +} + +// ── Bulk (experimental) ─────────────────────────────────────── + +#[cfg(feature = "experimental")] +#[tokio::test] +async fn bulk_check_permissions() { + use prescience::BulkCheckItem; + + let c = spicedb().await; + + let token = c + .write_relationships(vec![RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "bulk-1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "iris").unwrap(), + None::, + ) + .unwrap(), + ))]) + .await + .unwrap(); + + let results = c + .bulk_check_permissions(vec![ + BulkCheckItem::new( + ObjectReference::new("document", "bulk-1").unwrap(), + "view", + SubjectReference::new( + ObjectReference::new("user", "iris").unwrap(), + None::, + ) + .unwrap(), + ), + BulkCheckItem::new( + ObjectReference::new("document", "bulk-1").unwrap(), + "edit", + SubjectReference::new( + ObjectReference::new("user", "iris").unwrap(), + None::, + ) + .unwrap(), + ), + ]) + .consistency(Consistency::AtLeastAsFresh(token)) + .await + .expect("bulk_check failed"); + + assert_eq!(results.len(), 2); + assert!(results[0].as_ref().unwrap().is_allowed().unwrap()); + assert!(!results[1].as_ref().unwrap().is_allowed().unwrap()); +}