#![allow(clippy::expect_used)]
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use tokio::sync::RwLock;
use zlayer_api::handlers::users::AuthActor;
use zlayer_api::storage::{
InMemoryEnvironmentStore, InMemoryGroupStore, InMemoryPermissionStore, PermissionLevel,
PermissionStorage, StoredPermission, SubjectKind,
};
use zlayer_api::{require_secret_perm, ApiError};
use zlayer_secrets::{
ClusterDek, RaftSecretsHandle, RaftSecretsStore, RecipientPrivateKey, RecipientPublicKey,
Result as SecretsResult, Secret, SecretsError, SecretsProvider, SecretsState, SecretsStore,
};
use zlayer_types::api::internal::SecretsRaftOp;
use zlayer_types::storage::{NodeIdentity, ReplicatedSecret};
struct SharedRaftHandle {
state: Arc<RwLock<SecretsState>>,
}
impl SharedRaftHandle {
fn new(state: Arc<RwLock<SecretsState>>) -> Self {
Self { state }
}
}
#[async_trait]
impl RaftSecretsHandle for SharedRaftHandle {
async fn secrets_state(&self) -> SecretsState {
self.state.read().await.clone()
}
async fn propose_put_secret(&self, secret: ReplicatedSecret) -> SecretsResult<()> {
let mut guard = self.state.write().await;
guard
.apply(SecretsRaftOp::PutSecret { secret })
.map_err(|e| SecretsError::Provider(format!("apply PutSecret failed: {e}")))
}
async fn propose_delete_secret(&self, storage_key: &str) -> SecretsResult<()> {
let mut guard = self.state.write().await;
guard
.apply(SecretsRaftOp::DeleteSecret {
storage_key: storage_key.to_string(),
})
.map_err(|e| SecretsError::Provider(format!("apply DeleteSecret failed: {e}")))
}
}
struct ThreeNodeCluster {
#[allow(dead_code)]
a_id: String,
#[allow(dead_code)]
b_id: String,
#[allow(dead_code)]
c_id: String,
store_a: RaftSecretsStore,
store_b: RaftSecretsStore,
store_c: RaftSecretsStore,
perms_a: InMemoryPermissionStore,
perms_b: InMemoryPermissionStore,
perms_c: InMemoryPermissionStore,
groups_a: InMemoryGroupStore,
groups_b: InMemoryGroupStore,
groups_c: InMemoryGroupStore,
envs_a: InMemoryEnvironmentStore,
envs_b: InMemoryEnvironmentStore,
envs_c: InMemoryEnvironmentStore,
#[allow(dead_code)]
state: Arc<RwLock<SecretsState>>,
}
impl ThreeNodeCluster {
fn per_node_perm_stores(
&self,
) -> [(
&'static str,
&InMemoryPermissionStore,
&InMemoryGroupStore,
&InMemoryEnvironmentStore,
); 3] {
[
("A", &self.perms_a, &self.groups_a, &self.envs_a),
("B", &self.perms_b, &self.groups_b, &self.envs_b),
("C", &self.perms_c, &self.groups_c, &self.envs_c),
]
}
fn per_node_secret_stores(&self) -> [(&'static str, &RaftSecretsStore); 3] {
[
("A", &self.store_a),
("B", &self.store_b),
("C", &self.store_c),
]
}
}
fn setup_three_node_cluster() -> ThreeNodeCluster {
let a_id = "node-a".to_string();
let b_id = "node-b".to_string();
let c_id = "node-c".to_string();
let (sk_a, pk_a) = RecipientPrivateKey::generate();
let (sk_b, pk_b) = RecipientPrivateKey::generate();
let (sk_c, pk_c) = RecipientPrivateKey::generate();
let now = Utc::now();
let identity_a = NodeIdentity {
node_id: a_id.clone(),
secrets_pubkey: *pk_a.as_bytes(),
wg_pubkey: "wg-a".to_string(),
joined_at: now,
revoked_at: None,
};
let identity_b = NodeIdentity {
node_id: b_id.clone(),
secrets_pubkey: *pk_b.as_bytes(),
wg_pubkey: "wg-b".to_string(),
joined_at: now,
revoked_at: None,
};
let identity_c = NodeIdentity {
node_id: c_id.clone(),
secrets_pubkey: *pk_c.as_bytes(),
wg_pubkey: "wg-c".to_string(),
joined_at: now,
revoked_at: None,
};
let dek = ClusterDek::generate();
let mut recipients: HashMap<String, RecipientPublicKey> = HashMap::new();
recipients.insert(
a_id.clone(),
RecipientPublicKey::from_bytes(*pk_a.as_bytes()),
);
recipients.insert(
b_id.clone(),
RecipientPublicKey::from_bytes(*pk_b.as_bytes()),
);
recipients.insert(
c_id.clone(),
RecipientPublicKey::from_bytes(*pk_c.as_bytes()),
);
let envelope = dek
.rewrap_for_set(&recipients, 1)
.expect("rewrap DEK for the three-node set");
let mut initial = SecretsState::default();
initial
.apply(SecretsRaftOp::RegisterNode {
identity: identity_a,
})
.expect("seed register node-a");
initial
.apply(SecretsRaftOp::RegisterNode {
identity: identity_b,
})
.expect("seed register node-b");
initial
.apply(SecretsRaftOp::RegisterNode {
identity: identity_c,
})
.expect("seed register node-c");
initial
.apply(SecretsRaftOp::RotateDek {
new_wraps: envelope,
})
.expect("seed rotate DEK");
let state = Arc::new(RwLock::new(initial));
let handle_a: Arc<dyn RaftSecretsHandle> = Arc::new(SharedRaftHandle::new(state.clone()));
let handle_b: Arc<dyn RaftSecretsHandle> = Arc::new(SharedRaftHandle::new(state.clone()));
let handle_c: Arc<dyn RaftSecretsHandle> = Arc::new(SharedRaftHandle::new(state.clone()));
let store_a = RaftSecretsStore::new(sk_a, a_id.clone(), handle_a);
let store_b = RaftSecretsStore::new(sk_b, b_id.clone(), handle_b);
let store_c = RaftSecretsStore::new(sk_c, c_id.clone(), handle_c);
ThreeNodeCluster {
a_id,
b_id,
c_id,
store_a,
store_b,
store_c,
perms_a: InMemoryPermissionStore::new(),
perms_b: InMemoryPermissionStore::new(),
perms_c: InMemoryPermissionStore::new(),
groups_a: InMemoryGroupStore::new(),
groups_b: InMemoryGroupStore::new(),
groups_c: InMemoryGroupStore::new(),
envs_a: InMemoryEnvironmentStore::new(),
envs_b: InMemoryEnvironmentStore::new(),
envs_c: InMemoryEnvironmentStore::new(),
state,
}
}
fn admin_actor(id: &str) -> AuthActor {
AuthActor {
user_id: id.to_string(),
roles: vec!["admin".to_string()],
email: None,
}
}
fn user_actor(id: &str) -> AuthActor {
AuthActor {
user_id: id.to_string(),
roles: vec!["user".to_string()],
email: None,
}
}
#[tokio::test]
async fn secret_written_on_leader_is_readable_on_all_nodes_with_admin_token() {
let cluster = setup_three_node_cluster();
cluster
.store_a
.set_secret("default", "foo", &Secret::new("bar"))
.await
.expect("leader (A) set_secret");
for (label, store) in cluster.per_node_secret_stores() {
let got = store
.get_secret("default", "foo")
.await
.unwrap_or_else(|e| panic!("node-{label} get_secret failed: {e:?}"));
assert_eq!(got.expose(), "bar", "node-{label} read mismatch");
}
let admin = admin_actor("admin-1");
for (label, perms, groups, envs) in cluster.per_node_perm_stores() {
require_secret_perm(
&admin,
perms,
groups,
Some(envs),
"default",
"foo",
PermissionLevel::Read,
)
.await
.unwrap_or_else(|e| {
panic!("node-{label}: admin shortcut should allow Read on default:foo, got {e:?}")
});
}
}
#[tokio::test]
async fn secret_written_on_leader_is_readable_on_all_nodes_with_user_grant() {
let cluster = setup_three_node_cluster();
cluster
.store_a
.set_secret("default", "foo", &Secret::new("bar"))
.await
.expect("leader (A) set_secret");
let user_id = "u1";
for (label, perms, _, _) in cluster.per_node_perm_stores() {
let grant = StoredPermission::new(
SubjectKind::User,
user_id,
"secret",
Some("default:foo".to_string()),
PermissionLevel::Read,
);
<InMemoryPermissionStore as PermissionStorage>::grant(perms, &grant)
.await
.unwrap_or_else(|e| panic!("node-{label}: seeding grant failed: {e:?}"));
}
let actor = user_actor(user_id);
for (label, perms, groups, envs) in cluster.per_node_perm_stores() {
require_secret_perm(
&actor,
perms,
groups,
Some(envs),
"default",
"foo",
PermissionLevel::Read,
)
.await
.unwrap_or_else(|e| {
panic!(
"node-{label}: user {user_id} with direct Read grant should be allowed, got {e:?}",
)
});
}
for (label, store) in cluster.per_node_secret_stores() {
let got = store
.get_secret("default", "foo")
.await
.unwrap_or_else(|e| panic!("node-{label} get_secret failed: {e:?}"));
assert_eq!(got.expose(), "bar", "node-{label} read mismatch");
}
}
#[tokio::test]
async fn non_admin_without_grant_403_on_all_nodes() {
let cluster = setup_three_node_cluster();
cluster
.store_a
.set_secret("default", "foo", &Secret::new("bar"))
.await
.expect("leader (A) set_secret");
let actor = user_actor("nobody");
for (label, perms, groups, envs) in cluster.per_node_perm_stores() {
let err = require_secret_perm(
&actor,
perms,
groups,
Some(envs),
"default",
"foo",
PermissionLevel::Read,
)
.await
.err()
.unwrap_or_else(|| {
panic!(
"node-{label}: a user with no grants must be Forbidden on default:foo, \
but the call returned Ok(())",
)
});
match err {
ApiError::Forbidden(_) => {}
other => panic!(
"node-{label}: expected ApiError::Forbidden for ungranted Read, got {other:?}",
),
}
}
}
#[tokio::test]
async fn delete_on_leader_propagates_to_all_nodes() {
let cluster = setup_three_node_cluster();
cluster
.store_a
.set_secret("default", "foo", &Secret::new("bar"))
.await
.expect("leader (A) set_secret");
for (label, store) in cluster.per_node_secret_stores() {
let got = store
.get_secret("default", "foo")
.await
.unwrap_or_else(|e| panic!("pre-delete: node-{label} get_secret failed: {e:?}"));
assert_eq!(got.expose(), "bar");
}
cluster
.store_a
.delete_secret("default", "foo")
.await
.expect("leader (A) delete_secret");
for (label, store) in cluster.per_node_secret_stores() {
match store.get_secret("default", "foo").await {
Err(SecretsError::NotFound { .. }) => {}
Err(other) => panic!("node-{label}: expected NotFound after delete, got {other:?}",),
Ok(_) => {
panic!("node-{label}: expected NotFound after delete, but the secret was returned",)
}
}
}
}
#[tokio::test]
async fn rotate_increments_version_visible_on_all_nodes() {
let cluster = setup_three_node_cluster();
cluster
.store_a
.set_secret("default", "foo", &Secret::new("v1"))
.await
.expect("leader (A) set_secret v1");
let rotation = cluster
.store_a
.rotate_secret("default", "foo", &Secret::new("v2"))
.await
.expect("leader (A) rotate_secret");
assert_eq!(
rotation.previous_version,
Some(1),
"previous_version should be 1 after first rotation",
);
assert_eq!(rotation.new_version, 2, "new_version should be 2");
for (label, store) in cluster.per_node_secret_stores() {
let listed = store
.list_secrets("default")
.await
.unwrap_or_else(|e| panic!("node-{label}: list_secrets failed: {e:?}"));
let entry = listed
.iter()
.find(|m| m.name == "foo")
.unwrap_or_else(|| panic!("node-{label}: rotated secret missing from listing"));
assert_eq!(
entry.version, 2,
"node-{label}: version should be 2 after rotation, got {}",
entry.version,
);
}
for (label, store) in cluster.per_node_secret_stores() {
let got = store
.get_secret("default", "foo")
.await
.unwrap_or_else(|e| panic!("node-{label}: get_secret post-rotate failed: {e:?}"));
assert_eq!(got.expose(), "v2", "node-{label}: post-rotate value");
}
}