Skip to content

Commit feb0eb5

Browse files
authored
feat: convert to workspace with pluggable storage backends (#2)
Split `zerolease` into a multi-crate workspace to support pluggable storage backends and avoid libsqlite3-sys link conflicts with downstream consumers like zeroclaw. New crates: - `zerolease-provider`: `CredentialProvider` trait, `ZeroleaseProvider` (vault-backed), and `StaticProvider` (for testing/migration) - `zerolease-store-rusqlite`: `RusqliteStore` + `RusqliteAuditLog` for apps that already depend on rusqlite (e.g., zeroclaw) - `zerolease-store-postgres`: `PostgresStore` + `PostgresAuditLog` for shared infrastructure deployments (excluded from workspace due to sqlx v0.8 `libsqlite3-sys` conflict; builds standalone) Core crate changes: - No default storage backend (consumers pick a store crate) - Shared serialization helpers moved to methods on `CipherAlgorithm`, `SecretKind`, and `AuditEvent::indexed_fields()` - Removed standalone bin (needs its own crate to choose a backend) - Updated `basic_vault` example to use `zerolease-store-rusqlite` Updated the CI workflow for workspace structures. `sqlx` is starting to annoy me a little bit.
1 parent d5b6325 commit feb0eb5

27 files changed

Lines changed: 1846 additions & 177 deletions

File tree

.github/workflows/test.yaml

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
branches: ["latest"]
55
paths:
66
- src/**
7+
- crates/**
78
- Cargo.toml
89
- Cargo.lock
910
- .github/workflows/test.yaml
@@ -46,8 +47,12 @@ jobs:
4647
~/.cargo/bin
4748
target
4849
49-
- name: consult Clippy
50-
run: cargo clippy --all-targets --features postgres,vsock
50+
- name: Lint workspace (clippy)
51+
run: |
52+
cargo clippy --workspace --all-targets
53+
cargo clippy -p zerolease --all-targets --features vsock,kms
54+
cargo clippy -p zerolease-provider --all-targets --features vault
55+
cargo clippy --manifest-path crates/zerolease-store-postgres/Cargo.toml --all-targets
5156
5257
- name: Install cargo-nextest
5358
shell: bash
@@ -58,13 +63,16 @@ jobs:
5863
mv cargo-nextest /home/runner/.cargo/bin
5964
fi
6065
61-
- name: Run tests (including vsock)
62-
run: cargo nextest run --features vsock
66+
- name: Run workspace tests
67+
run: cargo nextest run --workspace
68+
69+
- name: Run vsock tests
70+
run: cargo nextest run -p zerolease --features vsock
6371

6472
- name: Run PostgreSQL integration tests
6573
env:
6674
DATABASE_URL: postgres://zerolease:zerolease@localhost/zerolease_test
67-
run: cargo nextest run --features postgres store::postgres::tests -E 'test(store::postgres)' --run-ignored ignored-only --test-threads=1
75+
run: cargo nextest run --manifest-path crates/zerolease-store-postgres/Cargo.toml --run-ignored ignored-only --test-threads=1
6876

6977
- name: Run KMS integration tests
7078
env:
@@ -73,10 +81,12 @@ jobs:
7381
AWS_REGION: ${{ secrets.AWS_REGION }}
7482
ZEROLEASE_KMS_TEST_KEY_ID: alias/zerolease-test
7583
ZEROLEASE_KMS_TEST_REGION: us-west-2
76-
run: cargo nextest run --features kms keysource::kms -E 'test(keysource::kms)' --run-ignored ignored-only --test-threads=1
84+
run: cargo nextest run -p zerolease --features kms -E 'test(keysource::kms)' --run-ignored ignored-only --test-threads=1
7785

7886
- name: Run doctests
79-
run: cargo test --doc
87+
run: |
88+
cargo test --workspace --doc
89+
cargo test --manifest-path crates/zerolease-store-postgres/Cargo.toml --doc
8090
8191
coverage:
8292
name: coverage
@@ -108,17 +118,20 @@ jobs:
108118
- name: Install cargo-llvm-cov
109119
uses: taiki-e/install-action@cargo-llvm-cov
110120

111-
- name: Generate coverage (default + vsock tests)
112-
run: cargo llvm-cov --features vsock,postgres --lcov --output-path lcov-default.info
121+
- name: Generate coverage (workspace tests)
122+
run: cargo llvm-cov --workspace --lcov --output-path lcov-workspace.info
123+
124+
- name: Generate coverage (vsock tests)
125+
run: cargo llvm-cov -p zerolease --features vsock --lcov --output-path lcov-vsock.info --no-clean
113126

114127
- name: Generate coverage (PostgreSQL tests)
115128
env:
116129
DATABASE_URL: postgres://zerolease:zerolease@localhost/zerolease_test
117-
run: cargo llvm-cov --features postgres --lcov --output-path lcov-postgres.info -- store::postgres --ignored --test-threads=1
130+
run: cargo llvm-cov --manifest-path crates/zerolease-store-postgres/Cargo.toml --lcov --output-path lcov-postgres.info -- --ignored --test-threads=1
118131

119132
- name: Upload coverage to Codecov
120133
uses: codecov/codecov-action@v5
121134
with:
122-
files: lcov-default.info,lcov-postgres.info
135+
files: lcov-workspace.info,lcov-vsock.info,lcov-postgres.info
123136
fail_ci_if_error: false
124137
token: ${{ secrets.CODECOV_TOKEN }}

Cargo.toml

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,87 @@
1+
[workspace]
2+
members = [".", "crates/zerolease-provider", "crates/zerolease-store-rusqlite"]
3+
# zerolease-store-postgres excluded from default workspace due to sqlx v0.8
4+
# libsqlite3-sys conflict with rusqlite. Build/test it separately:
5+
# cargo check -p zerolease-store-postgres --manifest-path crates/zerolease-store-postgres/Cargo.toml
6+
exclude = ["crates/zerolease-store-postgres"]
7+
resolver = "3"
8+
9+
[workspace.package]
10+
edition = "2024"
11+
authors = ["C J Silverio <ceejceej@gmail.com>"]
12+
license = "Apache-2.0"
13+
repository = "https://github.com/ceejbot/zerolease"
14+
15+
[workspace.lints.rust]
16+
unsafe_code = { level = "deny", priority = 0 }
17+
future_incompatible = { level = "deny", priority = 1 }
18+
rust_2018_idioms = { level = "warn", priority = 2 }
19+
trivial_casts = { level = "warn", priority = 3 }
20+
trivial_numeric_casts = { level = "warn", priority = 4 }
21+
unused_lifetimes = { level = "warn", priority = 5 }
22+
unused_qualifications = { level = "warn", priority = 6 }
23+
24+
[workspace.lints.clippy]
25+
unwrap_used = "deny"
26+
27+
[workspace.dependencies]
28+
async-trait = "0.1"
29+
chrono = { version = "0.4", features = ["serde"] }
30+
secrecy = { version = "0.10", features = ["serde"] }
31+
serde = { version = "1", features = ["derive"] }
32+
serde_json = "1"
33+
thiserror = "2"
34+
tokio = { version = "1", features = ["full"] }
35+
uuid = { version = "1", features = ["v7", "serde"] }
36+
zerolease = { path = ".", default-features = false }
37+
138
[package]
239
name = "zerolease"
3-
authors = ["C J Silverio <ceejceej@gmail.com>"]
40+
authors.workspace = true
441
version = "0.1.0"
5-
edition = "2024"
42+
edition.workspace = true
643
description = "A lightweight, agent-aware credential vault with lease-based access control"
7-
license = "Apache-2.0"
8-
repository = "https://github.com/ceejbot/zerolease"
44+
license.workspace = true
45+
repository.workspace = true
946
keywords = ["credentials", "vault", "security", "agents", "secrets"]
1047
readme = "README.md"
1148
categories = ["authentication", "cryptography"]
1249

1350
[dependencies]
1451
aes-gcm = "0.10"
15-
async-trait = "0.1"
52+
async-trait.workspace = true
1653
aws-config = { version = "1", optional = true }
1754
aws-sdk-kms = { version = "1", optional = true }
1855
base64 = "0.22"
1956
chacha20poly1305 = "0.10"
20-
chrono = { version = "0.4", features = ["serde"] }
21-
secrecy = { version = "0.10", features = ["serde"] }
22-
serde = { version = "1", features = ["derive"] }
23-
serde_json = "1"
24-
thiserror = "2"
25-
tokio = { version = "1", features = ["full"] }
57+
chrono.workspace = true
58+
secrecy.workspace = true
59+
serde.workspace = true
60+
serde_json.workspace = true
61+
thiserror.workspace = true
62+
tokio.workspace = true
2663
tracing = "0.1"
2764
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
28-
uuid = { version = "1", features = ["v7", "serde"] }
65+
uuid.workspace = true
2966
zeroize = { version = "1.8", features = ["derive"] }
3067

31-
[dependencies.sqlx]
32-
version = "0.8"
33-
features = ["runtime-tokio", "sqlite", "postgres", "chrono", "uuid"]
34-
optional = true
35-
3668
[features]
37-
default = ["sqlite"]
38-
sqlite = ["sqlx"]
39-
postgres = ["sqlx"]
69+
default = []
70+
# Gates integration tests that depend on the in-tree sqlx-based stores
71+
# (not yet extracted to zerolease-store-sqlx). Unused for now.
72+
sqlite = []
4073
vsock = ["tokio-vsock"]
4174
kms = ["aws-sdk-kms", "aws-config"]
4275

4376
[target.'cfg(unix)'.dependencies]
44-
# OS keychain (Linux secret-service, macOS Keychain)
4577
keyring = "3.6"
4678

4779
[target.'cfg(target_os = "linux")'.dependencies]
48-
# vsock for QEMU and Firecracker host-guest communication
4980
tokio-vsock = { version = "0.7", optional = true }
5081

5182
[dev-dependencies]
5283
tempfile = "3"
84+
zerolease-store-rusqlite = { path = "crates/zerolease-store-rusqlite" }
5385

54-
[lints.rust]
55-
unsafe_code = { level = "deny", priority = 0 }
56-
future_incompatible = { level = "deny", priority = 1 }
57-
rust_2018_idioms = { level = "warn", priority = 2 }
58-
trivial_casts = { level = "warn", priority = 3 }
59-
trivial_numeric_casts = { level = "warn", priority = 4 }
60-
unused_lifetimes = { level = "warn", priority = 5 }
61-
unused_qualifications = { level = "warn", priority = 6 }
62-
63-
[lints.clippy]
64-
unwrap_used = "deny"
86+
[lints]
87+
workspace = true
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[package]
2+
name = "zerolease-provider"
3+
version = "0.1.0"
4+
description = "CredentialProvider trait for lease-based credential access in AI agent tools"
5+
edition.workspace = true
6+
authors.workspace = true
7+
license.workspace = true
8+
repository.workspace = true
9+
10+
[dependencies]
11+
async-trait.workspace = true
12+
secrecy.workspace = true
13+
thiserror.workspace = true
14+
tokio = { workspace = true, features = ["sync", "rt"] }
15+
uuid.workspace = true
16+
zerolease = { workspace = true, default-features = false, optional = true }
17+
18+
[features]
19+
vault = ["dep:zerolease"]
20+
21+
[lints]
22+
workspace = true
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! Credential guard: a zeroize-on-drop, revoke-on-drop handle to a secret.
2+
//!
3+
//! `CredentialGuard` wraps a `SecretString` obtained through a zerolease
4+
//! lease. When the guard is dropped, the secret is zeroized from memory
5+
//! and the lease revocation is requested via a background channel.
6+
//!
7+
//! The `expose()` closure pattern prevents callers from storing the
8+
//! credential in a variable that outlives the guard.
9+
10+
use secrecy::{ExposeSecret, SecretString};
11+
use tokio::sync::mpsc;
12+
use uuid::Uuid;
13+
14+
/// A handle to an active credential. The secret value is accessible
15+
/// only through [`expose()`](Self::expose) and is zeroized when this
16+
/// guard drops. The underlying lease is revoked on drop.
17+
///
18+
/// NOT Clone, NOT Serialize. Debug redacts the secret value.
19+
pub struct CredentialGuard {
20+
secret: SecretString,
21+
lease_id: Uuid,
22+
target_domain: String,
23+
revoke_tx: Option<mpsc::Sender<Uuid>>,
24+
}
25+
26+
impl CredentialGuard {
27+
/// Create a guard backed by a vault lease with a revocation channel.
28+
#[allow(dead_code)] // we have library users
29+
pub(crate) fn new(
30+
secret: SecretString,
31+
lease_id: Uuid,
32+
target_domain: String,
33+
revoke_tx: mpsc::Sender<Uuid>,
34+
) -> Self {
35+
Self {
36+
secret,
37+
lease_id,
38+
target_domain,
39+
revoke_tx: Some(revoke_tx),
40+
}
41+
}
42+
43+
/// Create a guard with no revocation channel (for static/test providers).
44+
pub(crate) fn new_static(secret: SecretString, target_domain: String) -> Self {
45+
Self {
46+
secret,
47+
lease_id: Uuid::now_v7(),
48+
target_domain,
49+
revoke_tx: None,
50+
}
51+
}
52+
53+
/// Access the secret value. The closure receives a `&str` that
54+
/// must not be stored beyond the closure's scope.
55+
pub fn expose<F, R>(&self, f: F) -> R
56+
where
57+
F: FnOnce(&str) -> R,
58+
{
59+
f(self.secret.expose_secret())
60+
}
61+
62+
/// The domain this credential is scoped to.
63+
pub fn target_domain(&self) -> &str {
64+
&self.target_domain
65+
}
66+
67+
/// The lease ID backing this credential.
68+
pub fn lease_id(&self) -> Uuid {
69+
self.lease_id
70+
}
71+
}
72+
73+
impl std::fmt::Debug for CredentialGuard {
74+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75+
f.debug_struct("CredentialGuard")
76+
.field("lease_id", &self.lease_id)
77+
.field("target_domain", &self.target_domain)
78+
.field("secret", &"[REDACTED]")
79+
.finish()
80+
}
81+
}
82+
83+
impl Drop for CredentialGuard {
84+
fn drop(&mut self) {
85+
if let Some(tx) = self.revoke_tx.take() {
86+
// Best-effort: if the channel is full or closed, we can't
87+
// block in Drop. The lease will expire on its own via TTL.
88+
let _ = tx.try_send(self.lease_id);
89+
}
90+
// SecretString handles zeroization of the secret value.
91+
}
92+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//! Error types for the credential provider.
2+
//!
3+
//! These errors intentionally do not expose vault internals.
4+
//! A tool sees "credential unavailable" — never lease IDs,
5+
//! policy details, or encryption errors.
6+
7+
#[derive(Debug, thiserror::Error)]
8+
pub enum ProviderError {
9+
/// The requested credential could not be acquired.
10+
/// This covers policy denial, missing secrets, and vault errors —
11+
/// intentionally vague to avoid leaking vault internals.
12+
#[error("credential unavailable: {0}")]
13+
Unavailable(String),
14+
15+
/// The provider could not connect to the vault.
16+
#[error("vault connection failed: {0}")]
17+
ConnectionFailed(String),
18+
19+
/// The credential value was not valid UTF-8.
20+
#[error("credential is not valid UTF-8")]
21+
InvalidUtf8,
22+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! zerolease-provider: a `CredentialProvider` trait for AI agent tools.
2+
//!
3+
//! Instead of storing credentials as static `String` fields for the
4+
//! process lifetime, tools call `provider.acquire()` at execution time
5+
//! and receive a `CredentialGuard` that is time-bounded, domain-scoped,
6+
//! and zeroized on drop.
7+
//!
8+
//! The `ZeroleaseProvider` implementation connects to a zerolease vault
9+
//! server over UDS. Other backends (static config, HashiCorp Vault, etc.)
10+
//! can implement the same trait.
11+
12+
pub mod credential;
13+
pub mod error;
14+
pub mod provider;
15+
pub mod static_provider;
16+
#[cfg(feature = "vault")]
17+
pub mod zerolease_provider;
18+
19+
pub use credential::CredentialGuard;
20+
pub use error::ProviderError;
21+
pub use provider::{CredentialProvider, CredentialRequest};
22+
pub use static_provider::StaticProvider;
23+
#[cfg(feature = "vault")]
24+
pub use zerolease_provider::ZeroleaseProvider;

0 commit comments

Comments
 (0)