crates/gunnar-grpc/Cargo.toml
[package]
name = "gunnar-grpc"
description = "The tonic service implementations and the event bus behind gunnar's gRPC control plane."
readme = "README.md"
# 2.0.0 (2026-10-26, lane GUN-INSTALL / G-BOTH, the R5C publish): master differs from crates.io, so
# it gets its own version; a major: cargo-semver-checks names breaking changes against crates.io 1.5.0.
version = "4.0.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
# On the `cargo install gunnar` chain (owner order 2026-10-22).
publish = true
authors.workspace = true
repository.workspace = true
[features]
# **Company SSO** (OIDC + SAML login: `sso::oidc`, `sso::saml`), edda's
# `nornir-identity-sso` protocols (A34 M-4). Off, the two protocols answer "not
# compiled into this build" and their crates (jsonwebtoken, the SAML verifier,
# ...) are not linked; the plain `gunnar` default is built without it
# (2026-10-22). gunnar-server forwards it as its own `sso` (A34-15).
sso = ["nornir-identity-sso/oidc", "nornir-identity-sso/saml"]
# `Entitlement`, the metered appliance's paid-state service (`service/entitlement.rs`).
# Off, neither the service nor its proto types exist in this crate — see
# `gunnar-server/Cargo.toml` for the doctrine. Forwarded to `gunnar-proto/metered`,
# which is what compiles `entitlement.proto` at all.
#
# It also carries `Billing` (`service/billing.rs`), the tenant's own renewal
# path: the RPC that mints the appliance's word that one human may act for one
# tenant. That is why `monetize-embed` is named below — gunnar MINTS a ticket in
# that crate's canonical form rather than writing a second one — and why
# `ed25519-dalek` is: `MemoryControlPlane` holds a real key so the contract test
# verifies a real signature. Both are already in the graph under `ssh-key`, so
# neither adds a package to the appliance; with the feature off neither is
# linked at all.
metered = ["gunnar-proto/metered", "dep:monetize-embed", "dep:ed25519-dalek"]
[dependencies]
# The clock-sync report `Admin.Health` and `Bootstrap.Status` serve. Default
# features only: the report type and its store, no socket, no crypto, no syscall.
estate-clock = { workspace = true }
gunnar-proto = { workspace = true }
# For `BUILD_REF` alone: `Admin.Health` reports WHICH CODE is running, not
# just the crate version pinned in a manifest. No cycle -- gunnar-common
# never depends on gunnar-grpc -- and default-features is off, so this is
# the constant and not the CLI half.
gunnar-common = { workspace = true }
tonic = { workspace = true }
# The request-id span is a tower LAYER and not a tonic interceptor, so
# the `Layer` trait has to be nameable here. `tonic` already depends on
# `tower-layer` directly (its `Server::layer` returns `tower_layer::Stack`), so
# this adds NO package to any graph — MEASURED with `cargo xtask guard`, the
# appliance profile unchanged at 281 crates / 267 third-party. `tower-service`
# is not named: `tonic::codegen::Service` re-exports it.
tower-layer = { workspace = true }
tokio = { workspace = true }
# `sync` is what brings in `wrappers::BroadcastStream`. The event bus reads its
# lag signal (`BroadcastStreamRecvError::Lagged`) straight off it, which is the
# whole reason a lagging subscriber can be told it lost events instead of being
# buffered without bound.
tokio-stream = { workspace = true, features = ["sync"] }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
# SSH-key-derived gRPC identity. The `sshsig` verifier, and the
# signer the client half uses. Already in the `S` dependency budget ("the
# SSH server + ssh-sig verification"): `gunnar-policy` verifies commit
# signatures with the same crate, so this adds no crate to the appliance image.
# Since 2026-10-22 nornir-auth-sshsig is on russh's ssh-key 0.7 too (one copy);
# a credential still carries a fingerprint and an OpenSSH key LINE, not a type.
ssh-key = { workspace = true }
# The per-call SSHSIG credential, verifier and replay guard — edda's, shared
# with monetize's control API (workspace RULE 2). gunnar's wire format is
# `auth::GunnarScheme`. Path + version: the path until edda publishes 0.1.0.
nornir-auth-sshsig = { version = "0.1.0" }
# WebAuthn: passkey keys, assertions, attestations and the per-client challenge
# store (A34 M-3, 2026-10-27: Gunnar's own webauthn.rs moved into this shared edda
# crate). `ceremony` only: no ciborium, no pure backend's session token; p256,
# sha2, base64, serde_json and getrandom, all already in this closure.
nornir-auth-webauthn = { version = "0.2.1", default-features = false, features = ["ceremony"] }
# The step-up rule, one copy for Gunnar's two approvals and Holger (A34 M-5).
nornir-identity-session = { version = "0.1.0", default-features = false, features = ["step-up", "invites"] }
# **The identity core** (IDENTITY-HOST, 2026-10-27): sign-up, passkey login,
# invites, sessions, step-up and logout, one copy for every product; Gunnar
# implements `IdentityHost` (`identity.rs`) and serves its `IdentityService`.
nornir-identity = { version = "0.2.3", features = ["grpc"] }
serde = { workspace = true }
# **Company SSO** (2026-10-05; A34 M-4, 2026-10-27: edda's shared crate). Its
# `sso` half — the egress guard, one listener's memory, the limits — is always
# here (no network client, no XML, no JWT: sha2, base64, getrandom, serde_json
# and nornir-auth-webauthn, all already in this closure); the protocols (`oidc`
# over nornir-auth-oidc's `rp`, `saml` over nornir-auth-saml's pure-Rust `sp`)
# only with this crate's `sso`. Neither turns on a network client: gunnar
# reaches an IdP through its own `ureq` (`sso::Egress`). A crates.io version
# (the sibling patch until edda publishes 0.1.0).
nornir-identity-sso = { version = "0.1.1", default-features = false, features = ["sso"] }
# **SCIM 2.0 and the CSV import, edda's** (A34 M-6): the protocol, the import
# planned whole and applied in one write (A34-17), the name rules (A34-43) and
# the bearer's expiry/scope/flood guard (A34-27). `sso/scim.rs` is only the
# seam onto `SsoPlane`. Pure: serde_json and unicode-normalization.
nornir-identity-directory = { version = "0.1.1", default-features = false, features = ["csv", "scim"] }
# **Company SSO** (2026-10-05). The relying party (`rp`: discovery, PKCE,
# the ID token's alg allow-list and claims) and the SAML SP verifier (`sp`:
# exc-c14n + XML-DSig + the SAML rules, pure Rust — samael's xmlsec1/OpenSSL
# is what this appliance cannot link). Both are edda leaves, by PATH with the
# version kept, as `nornir-auth-sshsig` above; neither turns on a network
# client: gunnar reaches an IdP through its own `ureq` (`sso::Egress`).
nornir-auth-oidc = { version = "0.2.0", default-features = false, optional = true }
nornir-auth-saml = { version = "0.1.2", default-features = false, features = ["sp"], optional = true }
base64 = "0.22"
# Push attestation: one JSON line per push, appended to gunnar's own log. This
# is the ENTIRE cost of attestation: no warehouse, no arrow, no parquet, no
# Build Thing. Subscribers that want a warehouse row write it themselves.
serde_json = { workspace = true }
# **The process-wide TLS provider, pinned to ring** — see `ensure_crypto_provider`.
#
# `tonic`'s `tls-ring` already pulls rustls in, so this names a crate the graph
# has rather than adding one. It is a DIRECT dependency because the choice
# between ring and aws-lc-rs has to be made in code: `russh` brings aws-lc-rs
# into the same process, rustls refuses to guess between two linked providers,
# and the refusal is a panic on first handshake rather than a build error.
# `gunnar-http` pins the same provider for the same reason (its comment
# predicted this exact collision).
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# The client signer's nonce (`client_signer.rs`); already in the graph through ssh-key.
rand = { workspace = true }
# ── `metered` only ──────────────────────────────────────────────────────────
#
# **The ticket format, and nothing else.** `service/billing.rs` builds a
# `monetize_embed::ticket::ActorTicket`, signs `ticket_message` of it and
# base64s the signature with that crate's own encoder. gunnar writes no second
# canonical form and no second alphabet: a verifier gunnar does not run has to
# agree with it byte for byte, and two encoders is how that stops being true
# quietly. `version` and not `path` — the sibling-checkout cost is paid off and
# a fresh clone must still resolve (see the workspace manifest).
#
# **PREREQUISITE, 2026-09-17.** `monetize-embed` 0.1.0 as published on crates.io
# has neither the `ticket` module nor `signing::base64_encode`, so a METERED
# build resolves this to a crate that cannot compile `service/billing.rs`. The
# community build is unaffected — with the feature off this line is not in the
# graph at all. The fix is a release of `monetize-embed` (`dwarves
# crate-release`) followed by `cargo update -p monetize-embed` here; the
# requirement is `^0.1.0`, so no version in this file has to move. Until then a
# metered build needs a per-invocation override and touches no committed file:
#
# cargo --config 'patch.crates-io.monetize-embed.path="<…>/monetize/crates/monetize-embed"' …
#
# NOT a `[patch.crates-io]` in the workspace manifest. That is the entry which
# would put the sibling-checkout requirement back for everybody, and the
# manifest says at length why it was paid off.
# **Web credentials verified BY GUNNAR** (Stage L, 2026-10-04): a passkey's
# ES256 assertion (P-256 ECDSA over authenticatorData || SHA-256(clientData)),
# checked here so a console relays the browser's answer and decides nothing.
# Pure RustCrypto, no OpenSSL. p256's own `ecdsa` re-export only — its ecdsa
# already carries `der` (the DER signature a browser hands back) — and NO
# direct `ecdsa` line: the requirement follows whatever p256 resolves.
#
# The range reaches back to 0.14.0-rc.10 on purpose: a graph that links russh
# 0.61 (the ops kit's) pins `p256 =0.14.0-rc.10` / `ecdsa =0.17.0-rc.18`, and
# a plain "0.14" would exclude that pre-release and fail to resolve beside it.
# gunnar's own lock resolves 0.14.0; the four calls used here
# (VerifyingKey::from_sec1_bytes, Signature::from_der, Verifier::verify,
# to_sec1_point in tests) are the same in both.
p256 = { version = ">=0.14.0-rc.10, <0.15", default-features = false, features = ["ecdsa"] }
sha2 = "0.11"
base64ct = { version = "1", features = ["alloc"] }
# An account's own IdP CA, judged when it is set (`sso::idp_ca`): the parser
# gunnar-core already uses; no package.
x509-parser = { workspace = true }
monetize-embed = { version = "0.3.0", optional = true }
# `MemoryControlPlane`'s actor-ticket key. The double stands in for the STORE
# and never for the signature, so it holds a real ed25519 key and
# `tests/billing.rs` checks what it produced with `monetize_embed::verify_ticket`
# — the function the other repository runs. Already in the graph under ssh-key.
ed25519-dalek = { version = "3", optional = true }
[dev-dependencies]
# The identity conformance suite, TestHost kit and soft passkey.
nornir-identity = { version = "0.2.3", features = ["grpc", "test-support"] }
tempfile = "3"
# The software FIDO2 authenticator in `tests/security_key*.rs` (an sk-ssh key
# signs raw ed25519 over its own preimage). Already in the graph.
ed25519-dalek = "3"
# A CA minted in a test (`sso::idp_ca`); the workspace's, no package.
rcgen = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
# Minting a real ed25519 key per auth test, so nothing in `tests/auth.rs` is
# signed or verified by a stand-in.
rand = { workspace = true }
# Capturing the audit trail: the tests assert on the RECORD a mutation emits,
# not on the fact that a function was called.
tracing-subscriber = { workspace = true }
# `tests/clock_trigger.rs` installs a skew handler and reads the suppressed count.
estate-clock = { workspace = true }
# The metered appliance's own test target. `tests/billing.rs` names
# `monetize_embed` and `ed25519_dalek`, neither of which is in the graph without
# the feature — so `required-features` makes a community `cargo test` SKIP it by
# name ("target requires features metered") instead of failing to resolve an
# import, which reads like a broken tree rather than a deliberate diet. Same
# shape `gunnar-server` uses for `tests/entitlement.rs`.
[[test]]
name = "billing"
required-features = ["metered"]
# `tests/entitlement_scope.rs` drives `Entitlement`, which exists only in the
# metered build — without this a plain `cargo test --workspace` fails to COMPILE
# (E0432 on `EntitlementService`), which is how it sat on master 693e96c.
[[test]]
name = "entitlement_scope"
required-features = ["metered"]
# NO rayon (the multicore rule is gatling, never rayon), no HTTP server
# framework, no openssl. This crate is in the
# `S` (server) set: it must still work when there is no filesystem, no shell and
# no user database, with this binary as PID 1.