Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,11 @@ secrets-vault = ["secrets", "vaultrs"]
secrets-aws = ["secrets", "aws-config", "aws-sdk-secretsmanager"]
secrets-all = ["secrets-vault", "secrets-aws"]

# Credential spec resolution (env:/vault:/literal)
credential = []

# Full feature set
full = ["config", "config-reload", "logger", "metrics", "metrics-dfe", "otel", "otel-metrics", "otel-tracing", "runtime", "shutdown", "health", "http", "http-server", "spool", "tiered-sink", "database", "cache", "transport-all", "transport-trace", "transport-grpc-vector-compat", "secrets-all", "directory-config", "directory-config-git", "deployment", "version-check", "scaling", "memory", "worker", "cli-service", "io", "dlq", "dlq-kafka", "dlq-http", "dlq-redis", "output-file", "expression"]
full = ["config", "config-reload", "logger", "metrics", "metrics-dfe", "otel", "otel-metrics", "otel-tracing", "runtime", "shutdown", "health", "http", "http-server", "spool", "tiered-sink", "database", "cache", "transport-all", "transport-trace", "transport-grpc-vector-compat", "secrets-all", "directory-config", "directory-config-git", "deployment", "version-check", "scaling", "memory", "worker", "cli-service", "io", "dlq", "dlq-kafka", "dlq-http", "dlq-redis", "output-file", "expression", "credential"]

[dependencies]
# Serialisation (always needed)
Expand Down
157 changes: 157 additions & 0 deletions src/credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Project: hyperi-rustlib
// File: src/credential.rs
// Purpose: Credential spec resolution (env, vault, literal)
// Language: Rust
//
// License: FSL-1.1-ALv2
// Copyright: (c) 2026 HYPERI PTY LIMITED

//! Credential specification resolution.
//!
//! Resolves credential specs in three formats:
//! - `vault:path:key` — fetch from OpenBao via [`crate::secrets`] (requires `secrets` feature)
//! - `env:VAR_NAME` — read from the environment; hard error if unset
//! - any other string — used as a literal value
//!
//! Extracted from `dfe-fetcher/src/credential.rs` so that other DFE
//! services (dfe-loader, etc.) can share the same syntax.

use thiserror::Error;

/// Errors that can arise resolving a credential spec.
#[derive(Debug, Error)]
pub enum CredentialError {
#[error("environment variable '{name}' is not set")]
MissingEnvVar { name: String },

#[error("vault resolution failed: {0}")]
Vault(String),

#[error("invalid credential spec: {0}")]
BadSpec(String),

#[error("vault: spec requires the `secrets` feature to be enabled")]
VaultUnsupported,
}

/// Resolve a credential spec to its plaintext value.
pub async fn resolve(spec: &str) -> Result<String, CredentialError> {
if let Some(rest) = spec.strip_prefix("vault:") {
resolve_vault(rest).await
} else if let Some(var_name) = spec.strip_prefix("env:") {
resolve_env(var_name)
} else {
Ok(spec.to_string())
}
}

/// Resolve an optional credential spec — returns `None` for `None`/empty.
pub async fn resolve_optional(spec: Option<&str>) -> Result<Option<String>, CredentialError> {
match spec {
Some("") | None => Ok(None),
Some(s) => Ok(Some(resolve(s).await?)),
}
}

fn resolve_env(var_name: &str) -> Result<String, CredentialError> {
std::env::var(var_name).map_err(|_| CredentialError::MissingEnvVar {
name: var_name.to_string(),
})
}

#[cfg(feature = "secrets")]
async fn resolve_vault(path_key: &str) -> Result<String, CredentialError> {
use crate::secrets::{SecretSource, SecretsConfig, SecretsManager};
use std::collections::HashMap;

let parts: Vec<&str> = path_key.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(CredentialError::BadSpec(format!(
"invalid vault spec '{path_key}', expected 'path:key'"
)));
}
let path = parts[0];
let key = parts[1];

let mut sources = HashMap::new();
sources.insert(
"_vault_lookup".to_string(),
SecretSource::OpenBao {
path: path.to_string(),
key: key.to_string(),
},
);
let config = SecretsConfig {
sources,
..Default::default()
};

let secrets = SecretsManager::new(config).map_err(|e| {
CredentialError::Vault(format!("failed to initialise secrets manager: {e}"))
})?;
let value = secrets
.get("_vault_lookup")
.await
.map_err(|e| CredentialError::Vault(format!("lookup failed for {path}:{key}: {e}")))?;
let text = value
.as_str()
.map_err(|e| CredentialError::Vault(format!("vault secret not valid UTF-8: {e}")))?;
tracing::debug!(path = path, key = key, "resolved vault credential");
Ok(text.to_string())
}

#[cfg(not(feature = "secrets"))]
async fn resolve_vault(_path_key: &str) -> Result<String, CredentialError> {
Err(CredentialError::VaultUnsupported)
}

#[cfg(test)]
#[allow(unsafe_code, clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;

#[tokio::test]
async fn resolve_literal() {
let v = resolve("my-secret-value").await.unwrap();
assert_eq!(v, "my-secret-value");
}

#[tokio::test]
async fn resolve_env_set() {
// SAFETY: test-only; uses a unique var name to avoid interference with parallel tests
unsafe { std::env::set_var("HYPERI_RUSTLIB_TEST_CRED", "value-123") };
let v = resolve("env:HYPERI_RUSTLIB_TEST_CRED").await.unwrap();
assert_eq!(v, "value-123");
unsafe { std::env::remove_var("HYPERI_RUSTLIB_TEST_CRED") };
}

#[tokio::test]
async fn resolve_env_missing() {
let err = resolve("env:HYPERI_RUSTLIB_NONEXISTENT_XYZ")
.await
.unwrap_err();
match err {
CredentialError::MissingEnvVar { name } => {
assert_eq!(name, "HYPERI_RUSTLIB_NONEXISTENT_XYZ");
}
other => panic!("expected MissingEnvVar, got {other:?}"),
}
}

#[tokio::test]
async fn resolve_optional_none_returns_none() {
assert!(resolve_optional(None).await.unwrap().is_none());
}

#[tokio::test]
async fn resolve_optional_empty_returns_none() {
assert!(resolve_optional(Some("")).await.unwrap().is_none());
}

#[tokio::test]
#[cfg(not(feature = "secrets"))]
async fn vault_without_feature_returns_clear_error() {
let err = resolve("vault:secret/x:k").await.unwrap_err();
assert!(matches!(err, CredentialError::VaultUnsupported));
}
}
8 changes: 8 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ pub mod tiered_sink;
#[cfg_attr(docsrs, doc(cfg(feature = "secrets")))]
pub mod secrets;

#[cfg(feature = "credential")]
#[cfg_attr(docsrs, doc(cfg(feature = "credential")))]
pub mod credential;

#[cfg(feature = "directory-config")]
#[cfg_attr(docsrs, doc(cfg(feature = "directory-config")))]
pub mod directory_config;
Expand Down Expand Up @@ -289,6 +293,10 @@ pub use secrets::{
SecretValue, SecretsConfig, SecretsError, SecretsManager, SecretsResult,
};

#[cfg(feature = "credential")]
#[cfg_attr(docsrs, doc(cfg(feature = "credential")))]
pub use credential::{CredentialError, resolve, resolve_optional};

#[cfg(feature = "secrets-vault")]
#[cfg_attr(docsrs, doc(cfg(feature = "secrets-vault")))]
pub use secrets::{OpenBaoAuth, OpenBaoConfig, OpenBaoProvider};
Expand Down
Loading