feat: WIP add secrets module and macro crate

This commit is contained in:
2025-08-15 14:40:39 -04:00
parent 67f3a23071
commit 2a6a233fb2
11 changed files with 629 additions and 19 deletions

View File

@@ -67,6 +67,7 @@ bollard.workspace = true
tar.workspace = true
base64.workspace = true
once_cell = "1.21.3"
thiserror = "2.0.14"
[dev-dependencies]
pretty_assertions.workspace = true

BIN
harmony/harmony.rlib Normal file

Binary file not shown.

View File

@@ -9,3 +9,4 @@ pub mod inventory;
pub mod maestro;
pub mod score;
pub mod topology;
pub mod secrets;

View File

@@ -0,0 +1,265 @@
//! # Harmony Secrets Module
//!
//! This module provides core abstractions for type-safe secret management within the Harmony framework.
//!
//! ## Design Philosophy
//!
//! The design is centered around three key components:
//!
//! 1. **The `Secret` Trait:** This is the heart of the module. Instead of using strings to identify
//! secrets, we use dedicated, zero-sized types (ZSTs). Each ZST represents a single secret
//! and implements the `Secret` trait to provide metadata (like its namespace and key) and
//! its associated `Value` type. This enables full compile-time verification.
//!
//! 2. **The `Secrets` Struct:** This is the primary user-facing API. It provides the `get` and `set`
//! methods that are generic over any type implementing `Secret`. It's the high-level,
//! easy-to-use entry point for all secret operations.
//!
//! 3. **The `SecretStore` Trait:** This is the low-level backend interface. It defines the contract
//! for how the `Secrets` struct will interact with an actual storage system (like Infisical,
//! a local file, or a database). This decouples the high-level API from the implementation details.
//!
//! ## Example Usage
//!
//! ```
//! // In an external crate (e.g., harmony-okd):
//! use harmony_secrets::{Secret, StoreError};
//!
//! // 1. Define a zero-sized struct for each secret.
//! pub struct KubeadminPassword;
//!
//! // 2. Implement the `Secret` trait to provide metadata.
//! impl Secret for KubeadminPassword {
//! // The associated type defines what you get back.
//! type Value = String;
//!
//! const NAMESPACE: &'static str = "okd-installation";
//! const KEY: &'static str = "kubeadmin-password";
//! }
//!
//! // 3. Use it with the `Secrets` struct.
//! async fn example(secrets: &harmony_secrets::Secrets) -> Result<(), StoreError> {
//! // The API is type-safe. The compiler knows what `Value` to expect.
//! secrets.set::<KubeadminPassword>("password123".to_string()).await?;
//! let password = secrets.get::<KubeadminPassword>().await?;
//! assert_eq!(password, "password123");
//! Ok(())
//! }
//! ```
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Serialize};
use std::sync::Arc;
use thiserror::Error;
/// Defines the set of errors that can occur during secret operations.
/// Using `thiserror` provides a great developer experience for error handling.
#[derive(Debug, Error)]
pub enum StoreError {
#[error("Secret not found in store: namespace='{namespace}', key='{key}'")]
NotFound { namespace: String, key: String },
#[error("Permission denied for secret: namespace='{namespace}', key='{key}'")]
PermissionDenied { namespace: String, key: String },
#[error("Failed to deserialize secret value: {0}")]
Deserialization(String),
#[error("Failed to serialize secret value: {0}")]
Serialization(String),
#[error("A backend-specific error occurred: {0}")]
Backend(String),
}
/// A trait that marks a type as representing a single, retrievable secret.
///
/// This trait should be implemented on a unique, zero-sized struct for each secret
/// your module needs to manage. This pattern ensures that all secret access is
/// validated at compile time.
pub trait Secret: 'static + Send + Sync {
/// The data type of the secret's value. This ensures that `get` and `set`
/// operations are fully type-safe. The value must be serializable.
type Value: Serialize + DeserializeOwned + Send;
/// A logical grouping for secrets, similar to a Kubernetes namespace or a
/// directory path. This will be used by the `SecretStore` to organize data.
const NAMESPACE: &'static str;
/// The unique key for the secret within its namespace.
const KEY: &'static str;
}
/// The low-level storage trait that concrete secret backends must implement.
///
/// This trait operates on raw bytes (`Vec<u8>`), keeping it decoupled from any
/// specific serialization format. The `Secrets` struct will handle the
// serialization/deserialization boundary.
#[async_trait]
pub trait SecretStore: Send + Sync {
/// Retrieves the raw byte value of a secret from the backend.
async fn get(&self, namespace: &str, key: &str) -> Result<Vec<u8>, StoreError>;
/// Saves the raw byte value of a secret to the backend.
async fn set(&self, namespace: &str, key: &str, value: Vec<u8>) -> Result<(), StoreError>;
}
/// The primary, user-facing struct for interacting with secrets.
///
/// It provides a high-level, type-safe API that is decoupled from the
/// underlying storage mechanism.
#[derive(Clone)]
pub struct Secrets {
/// A shared, thread-safe reference to the underlying secret store.
store: Arc<dyn SecretStore>,
}
impl Secrets {
/// Creates a new `Secrets` instance with the given store implementation.
pub fn new(store: Arc<dyn SecretStore>) -> Self {
Self { store }
}
/// Retrieves a secret from the store in a fully type-safe manner.
///
/// The type of the secret to retrieve is specified using a generic parameter `S`,
/// which must implement the `Secret` trait. The method returns the `S::Value`
/// associated type, ensuring you always get the data type you expect.
///
/// # Example
/// `let admin_pass = secrets.get::<my_secrets::AdminPassword>().await?;`
pub async fn get<S: Secret>(&self) -> Result<S::Value, StoreError> {
let bytes = self
.store
.get(S::NAMESPACE, S::KEY)
.await
.map_err(|e| match e {
// Preserve the NotFound error for better diagnostics.
StoreError::NotFound { .. } => e,
_ => StoreError::Backend(e.to_string()),
})?;
// The public API uses JSON for serialization. It's robust and human-readable.
serde_json::from_slice(&bytes)
.map_err(|e| StoreError::Deserialization(e.to_string()))
}
/// Saves a secret to the store in a fully type-safe manner.
///
/// The method is generic over the secret type `S`, and the `value` parameter
/// must match the `S::Value` associated type, preventing type mismatch errors
/// at compile time.
///
/// # Example
/// `secrets.set::<my_secrets::AdminPassword>("new-password".to_string()).await?;`
pub async fn set<S: Secret>(&self, value: S::Value) -> Result<(), StoreError> {
let bytes = serde_json::to_vec(&value)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
self.store
.set(S::NAMESPACE, S::KEY, bytes)
.await
.map_err(|e| StoreError::Backend(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
// Define a dummy secret for testing purposes.
struct TestApiKey;
impl Secret for TestApiKey {
type Value = String;
const NAMESPACE: &'static str = "global-tests";
const KEY: &'static str = "api-key";
}
struct ComplexSecret;
#[derive(Serialize, serde::Deserialize, PartialEq, Debug, Clone)]
struct ComplexValue {
user: String,
permissions: Vec<String>,
}
impl Secret for ComplexSecret {
type Value = ComplexValue;
const NAMESPACE: &'static str = "complex-tests";
const KEY: &'static str = "user-data";
}
// A mock implementation of the `SecretStore` that uses an in-memory HashMap.
#[derive(Default)]
struct MockStore {
data: Mutex<HashMap<String, Vec<u8>>>,
}
#[async_trait]
impl SecretStore for MockStore {
async fn get(&self, namespace: &str, key: &str) -> Result<Vec<u8>, StoreError> {
let path = format!("{}/{}", namespace, key);
let data = self.data.lock().unwrap();
data.get(&path)
.cloned()
.ok_or_else(|| StoreError::NotFound {
namespace: namespace.to_string(),
key: key.to_string(),
})
}
async fn set(&self, namespace: &str, key: &str, value: Vec<u8>) -> Result<(), StoreError> {
let path = format!("{}/{}", namespace, key);
let mut data = self.data.lock().unwrap();
data.insert(path, value);
Ok(())
}
}
#[tokio::test]
async fn test_set_and_get_simple_secret() {
let store = Arc::new(MockStore::default());
let secrets = Secrets::new(store);
let api_key_value = "secret-key-12345".to_string();
secrets
.set::<TestApiKey>(api_key_value.clone())
.await
.unwrap();
let retrieved_key = secrets.get::<TestApiKey>().await.unwrap();
assert_eq!(retrieved_key, api_key_value);
}
#[tokio::test]
async fn test_set_and_get_complex_secret() {
let store = Arc::new(MockStore::default());
let secrets = Secrets::new(store);
let complex_value = ComplexValue {
user: "test-user".to_string(),
permissions: vec!["read".to_string(), "write".to_string()],
};
secrets
.set::<ComplexSecret>(complex_value.clone())
.await
.unwrap();
let retrieved_value = secrets.get::<ComplexSecret>().await.unwrap();
assert_eq!(retrieved_value, complex_value);
}
#[tokio::test]
async fn test_get_nonexistent_secret() {
let store = Arc::new(MockStore::default());
let secrets = Secrets::new(store);
let result = secrets.get::<TestApiKey>().await;
assert!(matches!(result, Err(StoreError::NotFound { .. })));
if let Err(StoreError::NotFound { namespace, key }) = result {
assert_eq!(namespace, TestApiKey::NAMESPACE);
assert_eq!(key, TestApiKey::KEY);
}
}
}

View File

@@ -22,12 +22,18 @@ pub struct OPNSenseFirewall {
host: LogicalHost,
}
// TODO figure out a design to have a unique identifiere for this firewall
// I think a project identifier would be good enough, then the secrets module configuration will
// point to the project's vault and this opnsense modules doesn't need to know anything about it
const OPNSENSE_CREDENTIALS: &str = "OPNSENSE_CREDENTIALS";
impl OPNSenseFirewall {
pub fn get_ip(&self) -> IpAddress {
self.host.ip
}
pub async fn new(host: LogicalHost, port: Option<u16>, username: &str, password: &str) -> Self {
// let credentials = Secrets::get_by_name(OPNSENSE_CREDENTIALS)
Self {
opnsense_config: Arc::new(RwLock::new(
opnsense_config::Config::from_credentials(host.ip, port, username, password).await,

View File

@@ -0,0 +1,51 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::{interpret::InterpretError, score::Score, topology::Topology};
/// Create and manage Tenant Credentials.
///
/// This is meant to be used by cluster administrators who need to provide their tenant users and
/// services with credentials to access their resources.
#[derive(Debug, Clone, Serialize)]
pub struct TenantCredentialScore;
impl<T: Topology + TenantCredentialManager> Score<T> for TenantCredentialScore {
fn create_interpret(&self) -> Box<dyn crate::interpret::Interpret<T>> {
todo!()
}
fn name(&self) -> String {
todo!()
}
}
#[async_trait]
pub trait TenantCredentialManager {
async fn create_user(&self) -> Result<TenantCredentialBundle, InterpretError>;
}
#[derive(Debug, Clone)]
pub struct CredentialMetadata {
pub tenant_id: String,
pub credential_id: String,
pub description: String,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub enum CredentialData {
/// Used to store login instructions destined to a human. Akin to AWS login instructions email
/// upon new console user creation.
PlainText(String),
}
pub struct TenantCredentialBundle {
_metadata: CredentialMetadata,
_content: CredentialData,
}
impl TenantCredentialBundle {}