use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use secrecy::zeroize::Zeroize;
use secrecy::{ExposeSecret, SecretString};
use super::{BackendFailure, BackendKind, Capabilities, Capability, FailureCategory};
use crate::desired_state::secrets::{
ForbiddenTransition, LifecycleTransition, SecretLifecycle, SecretOwner, SecretRef,
};
pub mod envelope;
pub mod postgres;
pub const ENVELOPE_CAPABILITIES: Capabilities =
Capabilities::new(&[Capability::EnvelopeEncryption]);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SecretBackend {
#[default]
EncryptedPostgres,
External,
}
impl SecretBackend {
pub const fn kind(self) -> BackendKind {
match self {
Self::EncryptedPostgres => BackendKind::Postgres,
Self::External => BackendKind::ExternalSecretManager,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct KekRef(pub String);
impl std::fmt::Display for KekRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone)]
pub struct SecretMaterial(SecretString);
impl SecretMaterial {
pub fn new(plaintext: String) -> Self {
Self(SecretString::from(plaintext))
}
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
pub fn is_empty(&self) -> bool {
self.0.expose_secret().is_empty()
}
}
impl From<SecretString> for SecretMaterial {
fn from(secret: SecretString) -> Self {
Self(secret)
}
}
impl std::fmt::Debug for SecretMaterial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretMaterial(<redacted>)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecretDescriptor {
pub reference: SecretRef,
pub owner: SecretOwner,
pub lifecycle: SecretLifecycle,
}
impl SecretDescriptor {
pub const fn permits_resolution(&self) -> bool {
self.lifecycle.permits_resolution()
}
}
impl std::fmt::Display for SecretDescriptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({}, {})", self.reference, self.owner, self.lifecycle)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SecretError {
#[error("secret store `{backend}` unavailable: {message}")]
Unavailable {
backend: &'static str,
message: String,
},
#[error("secret {0} is not stored")]
NotFound(SecretRef),
#[error("secret {reference} could not be unwrapped under KEK `{kek}`")]
Unwrap { reference: SecretRef, kek: KekRef },
#[error("secret {reference} does not belong to {owner}")]
Ownership {
reference: SecretRef,
owner: SecretOwner,
},
#[error("secret {reference} is {state} and cannot be resolved")]
Lifecycle {
reference: SecretRef,
state: SecretLifecycle,
},
#[error("secret {reference} cannot be moved: {source}")]
Transition {
reference: SecretRef,
#[source]
source: ForbiddenTransition,
},
#[error("invalid secret request: {0}")]
Invalid(String),
#[error("secret store `{backend}` refused the operation: {message}")]
Denied {
backend: &'static str,
message: String,
},
}
impl BackendFailure for SecretError {
fn category(&self) -> FailureCategory {
match self {
Self::Unavailable { .. } => FailureCategory::Unavailable,
Self::NotFound(_) | Self::Ownership { .. } => FailureCategory::NotFound,
Self::Unwrap { .. } => FailureCategory::Corrupt,
Self::Invalid(_) => FailureCategory::Invalid,
Self::Lifecycle { .. } | Self::Transition { .. } | Self::Denied { .. } => {
FailureCategory::Denied
}
}
}
}
#[async_trait]
pub trait SecretResolver: Send + Sync {
fn name(&self) -> &'static str;
fn capabilities(&self) -> Capabilities;
async fn resolve(
&self,
owner: SecretOwner,
reference: &SecretRef,
) -> Result<SecretMaterial, SecretError>;
async fn exists(&self, owner: SecretOwner, reference: &SecretRef) -> Result<bool, SecretError>;
}
#[async_trait]
pub trait SecretStore: SecretResolver {
async fn stage(
&self,
owner: SecretOwner,
material: SecretMaterial,
) -> Result<SecretDescriptor, SecretError>;
async fn rotate(
&self,
owner: SecretOwner,
reference: &SecretRef,
material: SecretMaterial,
) -> Result<SecretDescriptor, SecretError>;
async fn transition(
&self,
owner: SecretOwner,
reference: &SecretRef,
next: SecretLifecycle,
) -> Result<LifecycleTransition, SecretError>;
async fn describe(
&self,
owner: SecretOwner,
reference: &SecretRef,
) -> Result<SecretDescriptor, SecretError>;
}
pub async fn build(
secret_store: &crate::config::SecretStore,
control_plane: &crate::config::ControlPlane,
env: &HashMap<String, String>,
) -> Result<Arc<dyn SecretStore>, SecretError> {
match secret_store.backend {
crate::config::SecretStoreBackend::Postgres => {
let dsn = dsn(secret_store, control_plane, env)?;
let kek = deployment_kek(secret_store, env)?;
let settings = postgres::SecretStoreSettings::from_config(secret_store, control_plane);
Ok(Arc::new(
postgres::PostgresSecrets::connect(&dsn, settings, kek).await?,
))
}
}
}
fn dsn(
secret_store: &crate::config::SecretStore,
control_plane: &crate::config::ControlPlane,
env: &HashMap<String, String>,
) -> Result<String, SecretError> {
fn named(name: &Option<String>) -> Option<&str> {
name.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
}
let name = named(&secret_store.dsn_env)
.or_else(|| named(&control_plane.dsn_env))
.ok_or_else(|| {
denied("no `dsn_env` names the secret store's connection string".to_owned())
})?;
env.get(name)
.map(|dsn| dsn.trim().to_owned())
.filter(|dsn| !dsn.is_empty())
.ok_or_else(|| denied(format!("`{name}` is unset or empty")))
}
fn deployment_kek(
secret_store: &crate::config::SecretStore,
env: &HashMap<String, String>,
) -> Result<envelope::DeploymentKek, SecretError> {
let (source, name) = secret_store.kek_reference().ok_or_else(|| {
denied("`[secret_store]` names no key-encryption key to unwrap material with".to_owned())
})?;
let reference = KekRef(format!("{source}:{name}"));
let mut encoded = match source {
"kek_env" => env
.get(name)
.cloned()
.ok_or_else(|| denied(format!("`{name}` is unset")))?,
_ => std::fs::read_to_string(name)
.map_err(|error| denied(format!("`{name}` could not be read: {error}")))?,
};
let kek = envelope::DeploymentKek::parse(reference, &encoded)
.map_err(|error| denied(format!("the deployment KEK is unusable: {error}")));
encoded.zeroize();
kek
}
fn denied(message: String) -> SecretError {
SecretError::Denied {
backend: "encrypted-postgres",
message,
}
}
#[cfg(test)]
mod tests {
use super::super::{Capability, fakes::InMemorySecrets};
use super::*;
use crate::desired_state::fixtures::{project_id, secret_id, tenant_id};
fn owner() -> SecretOwner {
SecretOwner::tenant(tenant_id(1))
}
async fn active(store: &InMemorySecrets, owner: SecretOwner, plaintext: &str) -> SecretRef {
let staged = store
.stage(owner, SecretMaterial::new(plaintext.to_owned()))
.await
.expect("store");
store
.transition(owner, &staged.reference, SecretLifecycle::Active)
.await
.expect("staged material can be put in service");
staged.reference
}
#[test]
fn material_is_not_debuggable() {
let material = SecretMaterial::new("sk-live-do-not-log".to_owned());
let rendered = format!("{material:?}");
assert_eq!(rendered, "SecretMaterial(<redacted>)");
assert!(!rendered.contains("sk-live"));
#[derive(Debug)]
struct Holder {
#[allow(dead_code)]
material: SecretMaterial,
}
assert!(!format!("{:?}", Holder { material }).contains("sk-live"));
}
#[test]
fn references_are_opaque_and_versioned() {
let secret = secret_id(1);
let reference = SecretRef::first(secret);
let rendered = reference.to_string();
assert_eq!(rendered, format!("{secret}@v1"));
assert!(format!("{reference:?}").contains(&secret.uuid().to_string()));
assert!(!rendered.contains("sk-"));
let descriptor = SecretDescriptor {
reference,
owner: owner(),
lifecycle: SecretLifecycle::Active,
};
assert_eq!(
descriptor.to_string(),
format!("{secret}@v1 ({}, active)", owner())
);
assert!(!format!("{descriptor:?}").contains("sk-"));
}
#[test]
fn error_messages_never_carry_material() {
let reference = SecretRef::first(secret_id(2));
let errors = [
SecretError::NotFound(reference),
SecretError::Unwrap {
reference,
kek: KekRef("AXOND_KEK".to_owned()),
},
SecretError::Ownership {
reference,
owner: owner(),
},
SecretError::Lifecycle {
reference,
state: SecretLifecycle::Revoked,
},
SecretError::Transition {
reference,
source: ForbiddenTransition {
from: SecretLifecycle::Revoked,
to: SecretLifecycle::Active,
},
},
SecretError::Invalid("empty material".to_owned()),
];
for error in errors {
let rendered = error.to_string();
assert!(!rendered.contains("sk-live-do-not-log"));
assert!(!rendered.contains("AXOND_KEK_SECRET_VALUE"));
}
}
#[tokio::test]
async fn stored_material_round_trips_through_a_reference() {
let store = InMemorySecrets::new();
let staged = store
.stage(owner(), SecretMaterial::new("sk-live-1".to_owned()))
.await
.expect("store");
assert_eq!(staged.lifecycle, SecretLifecycle::Staged);
assert!(store.exists(owner(), &staged.reference).await.unwrap());
assert_eq!(
store
.resolve(owner(), &staged.reference)
.await
.unwrap()
.expose(),
"sk-live-1"
);
assert_eq!(
store.describe(owner(), &staged.reference).await.unwrap(),
staged
);
}
#[tokio::test]
async fn rotation_keeps_earlier_versions_resolvable() {
let store = InMemorySecrets::new();
let first = active(&store, owner(), "sk-live-1").await;
let second = store
.rotate(owner(), &first, SecretMaterial::new("sk-live-2".to_owned()))
.await
.unwrap();
assert_eq!(second.reference.secret, first.secret);
assert_eq!(second.reference.version, first.version.next());
assert_eq!(second.lifecycle, SecretLifecycle::Staged);
assert_eq!(
store.describe(owner(), &first).await.unwrap().lifecycle,
SecretLifecycle::Active
);
assert_eq!(
store.resolve(owner(), &first).await.unwrap().expose(),
"sk-live-1"
);
assert_eq!(
store
.resolve(owner(), &second.reference)
.await
.unwrap()
.expose(),
"sk-live-2"
);
let error = store
.rotate(owner(), &first, SecretMaterial::new("sk-live-3".to_owned()))
.await
.expect_err("a version is immutable once it is stored");
assert_eq!(error.category(), FailureCategory::Invalid);
assert_eq!(
store
.resolve(owner(), &second.reference)
.await
.unwrap()
.expose(),
"sk-live-2"
);
}
#[tokio::test]
async fn empty_material_is_rejected_rather_than_stored() {
let store = InMemorySecrets::new();
let error = store
.stage(owner(), SecretMaterial::new(String::new()))
.await
.expect_err("empty material is unusable");
assert_eq!(error.category(), FailureCategory::Invalid);
}
#[tokio::test]
async fn an_unwrappable_secret_is_corrupt_not_unavailable() {
let store = InMemorySecrets::new();
let reference = active(&store, owner(), "sk-live-1").await;
store.break_kek();
let error = store
.resolve(owner(), &reference)
.await
.expect_err("KEK is wrong");
assert_eq!(error.category(), FailureCategory::Corrupt);
assert!(!error.retryable());
assert!(store.exists(owner(), &reference).await.unwrap());
assert!(
store
.describe(owner(), &reference)
.await
.unwrap()
.permits_resolution()
);
}
#[tokio::test]
async fn a_missing_reference_is_distinguishable_from_an_outage() {
let store = InMemorySecrets::new();
let unknown = SecretRef::first(secret_id(3));
assert_eq!(
store
.resolve(owner(), &unknown)
.await
.unwrap_err()
.category(),
FailureCategory::NotFound
);
store.set_unavailable(true);
let outage = store.resolve(owner(), &unknown).await.expect_err("outage");
assert_eq!(outage.category(), FailureCategory::Unavailable);
assert!(outage.retryable());
}
#[tokio::test]
async fn material_never_resolves_for_another_owner() {
let store = InMemorySecrets::new();
let intruder = SecretOwner::tenant(tenant_id(9));
let sibling = SecretOwner::project(tenant_id(1), project_id(2));
let reference = active(&store, owner(), "sk-live-1").await;
for other in [intruder, sibling] {
let error = store
.resolve(other, &reference)
.await
.expect_err("material belongs to its owner alone");
assert_eq!(
error,
SecretError::Ownership {
reference,
owner: other
}
);
assert_eq!(error.category(), FailureCategory::NotFound);
assert!(!store.exists(other, &reference).await.unwrap());
assert_eq!(
store.describe(other, &reference).await.unwrap_err(),
SecretError::Ownership {
reference,
owner: other
}
);
assert!(
store
.transition(other, &reference, SecretLifecycle::Revoked)
.await
.is_err()
);
assert!(
store
.rotate(
other,
&reference,
SecretMaterial::new("sk-live-x".to_owned())
)
.await
.is_err()
);
}
assert_eq!(
store.describe(owner(), &reference).await.unwrap().lifecycle,
SecretLifecycle::Active
);
}
#[tokio::test]
async fn only_staged_and_active_material_unwraps() {
let store = InMemorySecrets::new();
let reference = active(&store, owner(), "sk-live-1").await;
store
.transition(owner(), &reference, SecretLifecycle::Disabled)
.await
.unwrap();
let error = store
.resolve(owner(), &reference)
.await
.expect_err("disabled material does not unwrap");
assert_eq!(
error,
SecretError::Lifecycle {
reference,
state: SecretLifecycle::Disabled
}
);
assert_eq!(error.category(), FailureCategory::Denied);
assert!(
!store.exists(owner(), &reference).await.unwrap(),
"disabled material would not resolve"
);
let descriptor = store.describe(owner(), &reference).await.unwrap();
assert_eq!(descriptor.lifecycle, SecretLifecycle::Disabled);
assert!(!descriptor.lifecycle.permits_resolution());
store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.unwrap();
assert_eq!(
store.resolve(owner(), &reference).await.unwrap().expose(),
"sk-live-1"
);
assert!(store.exists(owner(), &reference).await.unwrap());
store
.transition(owner(), &reference, SecretLifecycle::Revoked)
.await
.unwrap();
assert!(store.resolve(owner(), &reference).await.is_err());
assert!(
!store.exists(owner(), &reference).await.unwrap(),
"revoked material would not resolve"
);
assert_eq!(
store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.expect_err("revoked material is never put back in service"),
SecretError::Transition {
reference,
source: ForbiddenTransition {
from: SecretLifecycle::Revoked,
to: SecretLifecycle::Active
}
}
);
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap();
assert!(
!store.holds_material(&reference),
"tombstoning destroys the material, not just its state"
);
assert_eq!(
store.describe(owner(), &reference).await.unwrap().lifecycle,
SecretLifecycle::Tombstoned
);
assert!(
!store.exists(owner(), &reference).await.unwrap(),
"destroyed material would not resolve"
);
assert!(store.resolve(owner(), &reference).await.is_err());
}
#[tokio::test]
async fn a_repeated_lifecycle_request_is_a_no_op_not_a_conflict() {
let store = InMemorySecrets::new();
let staged = store
.stage(owner(), SecretMaterial::new("sk-live-1".to_owned()))
.await
.unwrap();
let reference = staged.reference;
let first = store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.unwrap();
assert_eq!(
first,
LifecycleTransition::Moved {
from: SecretLifecycle::Staged,
to: SecretLifecycle::Active
}
);
for _ in 0..3 {
assert_eq!(
store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.unwrap(),
LifecycleTransition::Unchanged(SecretLifecycle::Active)
);
}
assert_eq!(
store.describe(owner(), &reference).await.unwrap().lifecycle,
SecretLifecycle::Active
);
store
.transition(owner(), &reference, SecretLifecycle::Revoked)
.await
.unwrap();
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap();
assert_eq!(
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap(),
LifecycleTransition::Unchanged(SecretLifecycle::Tombstoned)
);
}
#[tokio::test]
async fn rotation_of_an_unknown_or_tombstoned_version_is_refused() {
let store = InMemorySecrets::new();
let unknown = SecretRef::first(secret_id(4));
assert_eq!(
store
.rotate(owner(), &unknown, SecretMaterial::new("sk-live".to_owned()))
.await
.unwrap_err(),
SecretError::NotFound(unknown)
);
let reference = active(&store, owner(), "sk-live-1").await;
store
.transition(owner(), &reference, SecretLifecycle::Revoked)
.await
.unwrap();
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap();
assert_eq!(
store
.rotate(
owner(),
&reference,
SecretMaterial::new("sk-live-2".to_owned())
)
.await
.unwrap_err()
.category(),
FailureCategory::Denied,
"tombstoned material has no next version"
);
}
#[test]
fn selectable_backends_wrap_or_delegate() {
assert_eq!(SecretBackend::default(), SecretBackend::EncryptedPostgres);
assert_eq!(
SecretBackend::EncryptedPostgres.kind(),
BackendKind::Postgres
);
assert_eq!(
SecretBackend::External.kind(),
BackendKind::ExternalSecretManager
);
let responsibility =
super::super::responsibility("SecretStore").expect("declared responsibility");
for backend in [SecretBackend::EncryptedPostgres, SecretBackend::External] {
assert!(responsibility.permits(backend.kind()));
}
assert!(!responsibility.permits(BackendKind::Redis));
}
#[tokio::test]
async fn encrypted_postgres_declares_envelope_encryption() {
let store = InMemorySecrets::new();
assert!(store.capabilities().has(Capability::EnvelopeEncryption));
assert!(!store.capabilities().has(Capability::ExternalKeyManagement));
}
fn section() -> crate::config::SecretStore {
crate::config::SecretStore {
backend: crate::config::SecretStoreBackend::Postgres,
dsn_env: Some("AXOND_SECRET_STORE_DSN".to_owned()),
kek_env: Some("AXOND_SECRET_STORE_KEK".to_owned()),
kek_file: None,
schema: None,
create_table: true,
}
}
fn control_plane() -> crate::config::ControlPlane {
crate::config::ControlPlane {
dsn_env: Some("AXOND_CONTROL_PLANE_DSN".to_owned()),
schema: None,
migrate: false,
connect_timeout_ms: 5_000,
operation_timeout_ms: 30_000,
}
}
fn encoded_kek() -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode([7u8; envelope::KEY_LEN])
}
#[test]
fn the_connection_string_is_the_store_s_reference_or_the_control_plane_s() {
let env = HashMap::from([
(
"AXOND_SECRET_STORE_DSN".to_owned(),
"postgres://own".to_owned(),
),
(
"AXOND_CONTROL_PLANE_DSN".to_owned(),
"postgres://inherited".to_owned(),
),
]);
assert_eq!(
dsn(§ion(), &control_plane(), &env).expect("its own reference"),
"postgres://own"
);
let inheriting = crate::config::SecretStore {
dsn_env: None,
..section()
};
assert_eq!(
dsn(&inheriting, &control_plane(), &env).expect("the inherited reference"),
"postgres://inherited"
);
}
#[test]
fn a_blank_reference_inherits_the_control_plane_s_instead_of_refusing() {
let env = HashMap::from([(
"AXOND_CONTROL_PLANE_DSN".to_owned(),
"postgres://inherited".to_owned(),
)]);
for blank in ["", " ", "\n"] {
let section = crate::config::SecretStore {
dsn_env: Some(blank.to_owned()),
..section()
};
assert_eq!(
dsn(§ion, &control_plane(), &env).expect("the inherited reference"),
"postgres://inherited",
"a `dsn_env` of {blank:?} should inherit"
);
}
}
#[test]
fn an_unresolvable_connection_string_is_refused_by_name() {
let error = dsn(§ion(), &control_plane(), &HashMap::new())
.expect_err("nothing is set in this environment");
assert_eq!(error.category(), FailureCategory::Denied);
assert!(
error.to_string().contains("AXOND_SECRET_STORE_DSN"),
"{error}"
);
assert!(!error.to_string().contains("postgres://"), "{error}");
}
#[test]
fn the_kek_is_read_from_the_reference_and_never_echoed() {
let encoded = encoded_kek();
let env = HashMap::from([("AXOND_SECRET_STORE_KEK".to_owned(), encoded.clone())]);
let kek = deployment_kek(§ion(), &env).expect("a 32-byte key");
assert_eq!(
kek.reference().to_string(),
"kek_env:AXOND_SECRET_STORE_KEK"
);
let short = HashMap::from([("AXOND_SECRET_STORE_KEK".to_owned(), {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode([7u8; 16])
})]);
let error = deployment_kek(§ion(), &short).expect_err("half a key is not a key");
assert_eq!(error.category(), FailureCategory::Denied);
assert!(!error.to_string().contains(&encoded), "{error}");
let missing = deployment_kek(§ion(), &HashMap::new())
.expect_err("an unset reference cannot be unwrapped with");
assert!(
missing.to_string().contains("AXOND_SECRET_STORE_KEK"),
"{missing}"
);
}
#[test]
fn a_file_referenced_kek_is_read_from_disk() {
let path = std::env::temp_dir().join(format!(
"axond-kek-{}",
crate::desired_state::Uuid7Generator::new().next()
));
std::fs::write(&path, format!("{}\n", encoded_kek())).expect("write the key file");
let section = crate::config::SecretStore {
kek_env: None,
kek_file: Some(path.to_string_lossy().into_owned()),
..section()
};
let kek =
deployment_kek(§ion, &HashMap::new()).expect("a trailing newline is tolerated");
assert!(kek.reference().to_string().starts_with("kek_file:"));
std::fs::remove_file(&path).expect("clean up");
let error = deployment_kek(§ion, &HashMap::new())
.expect_err("the file is gone, so the key cannot be read");
assert_eq!(error.category(), FailureCategory::Denied);
}
#[tokio::test]
async fn the_configured_store_boots_and_serves_material() {
let Some(dsn) = crate::test_services::postgres_dsn() else {
return;
};
let env = HashMap::from([
("AXOND_SECRET_STORE_DSN".to_owned(), dsn),
("AXOND_SECRET_STORE_KEK".to_owned(), encoded_kek()),
]);
let schema = format!(
"axond_secret_boot_{}",
crate::desired_state::Uuid7Generator::new()
.next()
.to_string()
.replace('-', "")
);
let section = crate::config::SecretStore {
schema: Some(schema.clone()),
..section()
};
let (client, connection) = tokio_postgres::connect(
env.get("AXOND_SECRET_STORE_DSN").expect("the test DSN"),
crate::usage::tls_connector(),
)
.await
.expect("connect to the test database");
let cleanup = tokio::spawn(async move {
let _ = connection.await;
});
client
.batch_execute(&format!("CREATE SCHEMA {schema}"))
.await
.expect("create the test schema");
let store = build(§ion, &control_plane(), &env)
.await
.expect("the configured store boots");
let staged = store
.stage(owner(), SecretMaterial::new("sk-live-boot".to_owned()))
.await
.expect("staging through the built store");
assert_eq!(
store
.resolve(owner(), &staged.reference)
.await
.expect("staged material resolves")
.expose(),
"sk-live-boot"
);
client
.batch_execute(&format!("DROP SCHEMA {schema} CASCADE"))
.await
.expect("drop the test schema");
drop(client);
cleanup.abort();
}
}