feat: WIP add secrets module and macro crate
This commit is contained in:
@@ -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
BIN
harmony/harmony.rlib
Normal file
Binary file not shown.
@@ -9,3 +9,4 @@ pub mod inventory;
|
||||
pub mod maestro;
|
||||
pub mod score;
|
||||
pub mod topology;
|
||||
pub mod secrets;
|
||||
|
||||
265
harmony/src/domain/secrets/mod.rs
Normal file
265
harmony/src/domain/secrets/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
51
harmony/src/modules/tenant/credentials.rs.bak
Normal file
51
harmony/src/modules/tenant/credentials.rs.bak
Normal 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 {}
|
||||
Reference in New Issue
Block a user