mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-26 06:52:57 +08:00
* refactor(proto): isolate gateway storage messages Move persistence-only protobufs into a server-private versioned package, remove them from generated public SDKs, and gate durable/public schema compatibility with legacy database fixtures. Closes #3053 Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * docs(gateway): sync protobuf schema inventory Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> --------- Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
2582 lines
85 KiB
Rust
2582 lines
85 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Gateway credential-driver runtime scaffolding.
|
|
//!
|
|
//! This module owns gateway-level credential-driver selection and resolution
|
|
//! dispatch. Concrete production backends and remote UDS transport plug in here
|
|
//! in later implementation slices.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|
#[cfg(unix)]
|
|
use std::future::Future;
|
|
use std::path::{Path, PathBuf};
|
|
#[cfg(unix)]
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
#[cfg(unix)]
|
|
use std::time::{Duration, Instant};
|
|
#[cfg(unix)]
|
|
use std::{
|
|
io::ErrorKind,
|
|
os::unix::fs::{FileTypeExt, MetadataExt},
|
|
};
|
|
|
|
use async_trait::async_trait;
|
|
#[cfg(unix)]
|
|
use hyper_util::rt::TokioIo;
|
|
use openshell_core::proto::credentials::v1::{
|
|
DeleteCredentialRequest, GetCredentialDriverCapabilitiesRequest,
|
|
GetCredentialDriverCapabilitiesResponse, ResolveCredentialRequest, ResolveCredentialsRequest,
|
|
ResolvedCredential, StoreCredentialRequest, credential_driver_client::CredentialDriverClient,
|
|
};
|
|
use openshell_core::proto::{CredentialHandle, Provider};
|
|
use openshell_core::{Config, Error, Result as CoreResult};
|
|
use openshell_driver_db_credstore::{
|
|
CredentialObjectWrite, DbCredstoreCredentialDriver, DbCredstoreObjectStore,
|
|
DbCredstoreWriteCondition, StoredCredentialObject,
|
|
};
|
|
use openshell_driver_kubernetes_secrets::KubernetesSecretsCredentialDriver;
|
|
use openshell_driver_vault::VaultCredentialDriver;
|
|
use sha2::{Digest, Sha256};
|
|
#[cfg(unix)]
|
|
use tokio::net::UnixStream;
|
|
#[cfg(unix)]
|
|
use tokio::process::Command;
|
|
#[cfg(unix)]
|
|
use tonic::transport::{Channel, Endpoint};
|
|
use tonic::{Request, Status};
|
|
#[cfg(unix)]
|
|
use tower::service_fn;
|
|
use tracing::warn;
|
|
|
|
use crate::persistence::{PersistenceError, Store, WriteCondition};
|
|
use crate::storage_proto::StoredRefreshMaterialDeletion;
|
|
|
|
const DEFAULT_CREDENTIAL_DRIVER_STARTUP_TIMEOUT_SECS: u64 = 10;
|
|
const DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS: u64 = 30;
|
|
const REFRESH_MATERIAL_CREDENTIAL_KEY_DOMAIN: &[u8] =
|
|
b"openshell-refresh-material-credential-key-v1";
|
|
const COMMON_CREDENTIAL_DRIVER_FIELDS: &[&str] = &[
|
|
"transport",
|
|
"socket_path",
|
|
"command",
|
|
"args",
|
|
"startup_timeout_secs",
|
|
];
|
|
#[cfg(unix)]
|
|
const CREDENTIAL_DRIVER_CONNECT_INTERVAL: Duration = Duration::from_millis(100);
|
|
|
|
#[async_trait]
|
|
pub trait CredentialDriver: std::fmt::Debug + Send + Sync {
|
|
async fn store_credential(
|
|
&self,
|
|
request: StoreCredentialRequest,
|
|
) -> Result<CredentialHandle, Status>;
|
|
|
|
async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status>;
|
|
|
|
async fn resolve_credentials(
|
|
&self,
|
|
requests: Vec<ResolveCredentialRequest>,
|
|
) -> Result<Vec<ResolvedCredential>, Status>;
|
|
|
|
#[cfg(test)]
|
|
fn stored_credential_count(&self) -> Option<usize> {
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn fail_next_store(&self) {}
|
|
|
|
#[cfg(test)]
|
|
fn fail_next_delete(&self) {}
|
|
|
|
#[cfg(test)]
|
|
fn gate_next_store(
|
|
&self,
|
|
) -> Option<(
|
|
tokio::sync::oneshot::Receiver<()>,
|
|
tokio::sync::oneshot::Sender<()>,
|
|
)> {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ResolvedProviderCredentials {
|
|
pub values: HashMap<String, String>,
|
|
pub expires_at_ms: HashMap<String, i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct RefreshMaterialScope<'a> {
|
|
pub provider_name: &'a str,
|
|
pub workspace: &'a str,
|
|
pub provider_id: &'a str,
|
|
pub credential_key: &'a str,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CredentialRuntime {
|
|
registry: CredentialDriverRegistry,
|
|
drivers: BTreeMap<String, Arc<dyn CredentialDriver>>,
|
|
_driver_processes: Vec<Arc<ManagedCredentialDriverProcess>>,
|
|
}
|
|
|
|
impl CredentialRuntime {
|
|
pub fn from_config(config: &Config) -> CoreResult<Self> {
|
|
Self::from_config_with_optional_store(config, None)
|
|
}
|
|
|
|
pub fn from_config_with_store(config: &Config, store: Arc<Store>) -> CoreResult<Self> {
|
|
Self::from_config_with_optional_store(config, Some(store))
|
|
}
|
|
|
|
fn from_config_with_optional_store(
|
|
config: &Config,
|
|
store: Option<Arc<Store>>,
|
|
) -> CoreResult<Self> {
|
|
let registry = CredentialDriverRegistry::from_config(config)?;
|
|
let mut drivers = BTreeMap::new();
|
|
connect_default_credential_store(
|
|
&mut drivers,
|
|
store.clone(),
|
|
&toml::Table::new(),
|
|
registry.requires_default_store(),
|
|
)?;
|
|
|
|
for driver_name in registry.enabled_driver_names() {
|
|
if let Some(driver) = build_sync_builtin_driver(driver_name, store.clone()) {
|
|
drivers.insert(driver_name.clone(), driver);
|
|
} else if BuiltinCredentialDriverKind::from_name(driver_name).is_none() {
|
|
return Err(unknown_credential_driver_error(driver_name));
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
registry,
|
|
drivers,
|
|
_driver_processes: Vec::new(),
|
|
})
|
|
}
|
|
|
|
pub async fn from_config_file(
|
|
config: &Config,
|
|
config_file: Option<&crate::config_file::ConfigFile>,
|
|
) -> CoreResult<Self> {
|
|
Self::from_config_file_with_optional_store(config, config_file, None).await
|
|
}
|
|
|
|
pub async fn from_config_file_with_store(
|
|
config: &Config,
|
|
config_file: Option<&crate::config_file::ConfigFile>,
|
|
store: Arc<Store>,
|
|
) -> CoreResult<Self> {
|
|
Self::from_config_file_with_optional_store(config, config_file, Some(store)).await
|
|
}
|
|
|
|
async fn from_config_file_with_optional_store(
|
|
config: &Config,
|
|
config_file: Option<&crate::config_file::ConfigFile>,
|
|
store: Option<Arc<Store>>,
|
|
) -> CoreResult<Self> {
|
|
let registry = CredentialDriverRegistry::from_config(config)?;
|
|
let mut drivers = BTreeMap::new();
|
|
let mut driver_processes = Vec::new();
|
|
let empty_config = toml::Table::new();
|
|
let default_store_config = config_file
|
|
.and_then(|file| file.openshell.gateway.credential_storage.as_ref())
|
|
.unwrap_or(&empty_config);
|
|
connect_default_credential_store(
|
|
&mut drivers,
|
|
store.clone(),
|
|
default_store_config,
|
|
registry.requires_default_store(),
|
|
)?;
|
|
|
|
for driver_name in registry.enabled_driver_names() {
|
|
let driver_config = config_file
|
|
.and_then(|file| file.openshell.credential_drivers.get(driver_name))
|
|
.map(|value| parse_driver_table(driver_name, value))
|
|
.transpose()?;
|
|
|
|
if let Some(driver_config) = driver_config {
|
|
let built =
|
|
build_configured_driver(driver_name, driver_config, store.clone()).await?;
|
|
drivers.insert(driver_name.clone(), built.driver);
|
|
if let Some(process) = built.process {
|
|
driver_processes.push(process);
|
|
}
|
|
} else {
|
|
let driver = build_default_in_tree_driver(driver_name, store.clone()).await?;
|
|
drivers.insert(driver_name.clone(), driver);
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
registry,
|
|
drivers,
|
|
_driver_processes: driver_processes,
|
|
})
|
|
}
|
|
|
|
pub fn validate_provider_handles(&self, provider: &Provider) -> Result<(), Status> {
|
|
self.registry.validate_provider_handles(provider)
|
|
}
|
|
|
|
pub fn stores_provider_credentials(&self) -> bool {
|
|
let driver_name = self.registry.storage_owner_name();
|
|
self.drivers.contains_key(&driver_name)
|
|
}
|
|
|
|
pub fn storage_owns_handle(&self, handle: &CredentialHandle) -> bool {
|
|
normalize_driver_name(&handle.driver) == self.registry.storage_owner_name()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn stored_credential_count(&self) -> Option<usize> {
|
|
self.drivers
|
|
.get(&self.registry.storage_owner_name())
|
|
.and_then(|driver| driver.stored_credential_count())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn fail_next_store(&self) {
|
|
if let Some(driver) = self.drivers.get(&self.registry.storage_owner_name()) {
|
|
driver.fail_next_store();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn fail_next_delete(&self) {
|
|
if let Some(driver) = self.drivers.get(&self.registry.storage_owner_name()) {
|
|
driver.fail_next_delete();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn gate_next_store(
|
|
&self,
|
|
) -> (
|
|
tokio::sync::oneshot::Receiver<()>,
|
|
tokio::sync::oneshot::Sender<()>,
|
|
) {
|
|
self.drivers
|
|
.get(&self.registry.storage_owner_name())
|
|
.and_then(|driver| driver.gate_next_store())
|
|
.expect("test credential driver supports store gating")
|
|
}
|
|
|
|
pub async fn store_provider_credentials(
|
|
&self,
|
|
provider_name: &str,
|
|
workspace: &str,
|
|
provider_id: &str,
|
|
credentials: &HashMap<String, String>,
|
|
existing_handles: &HashMap<String, CredentialHandle>,
|
|
) -> Result<HashMap<String, CredentialHandle>, Status> {
|
|
self.store_provider_credentials_with_object_id(
|
|
provider_name,
|
|
workspace,
|
|
provider_id,
|
|
provider_id,
|
|
credentials,
|
|
existing_handles,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn store_provider_credentials_with_object_id(
|
|
&self,
|
|
provider_name: &str,
|
|
workspace: &str,
|
|
provider_id: &str,
|
|
object_id: &str,
|
|
credentials: &HashMap<String, String>,
|
|
existing_handles: &HashMap<String, CredentialHandle>,
|
|
) -> Result<HashMap<String, CredentialHandle>, Status> {
|
|
if credentials.is_empty() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
let driver_name = self.registry.storage_owner_name();
|
|
let driver = self.connected_driver(&driver_name)?;
|
|
|
|
let futures = credentials.iter().map(|(credential_key, value)| {
|
|
let driver_name = driver_name.clone();
|
|
let driver = driver.clone();
|
|
async move {
|
|
let existing_handle = existing_handles
|
|
.get(credential_key)
|
|
.filter(|handle| normalize_driver_name(&handle.driver) == driver_name)
|
|
.cloned();
|
|
let replaced_handle = existing_handles
|
|
.get(credential_key)
|
|
.filter(|handle| normalize_driver_name(&handle.driver) != driver_name)
|
|
.cloned();
|
|
let mut handle = driver
|
|
.store_credential(StoreCredentialRequest {
|
|
provider_name: provider_name.to_string(),
|
|
credential_key: credential_key.clone(),
|
|
value: value.clone(),
|
|
existing_handle,
|
|
workspace: workspace.to_string(),
|
|
provider_id: provider_id.to_string(),
|
|
object_id: object_id.to_string(),
|
|
})
|
|
.await?;
|
|
handle.driver.clone_from(&driver_name);
|
|
if handle.handle.trim().is_empty() {
|
|
return Err(Status::internal(format!(
|
|
"credential driver '{driver_name}' returned an empty handle for provider credential '{credential_key}'"
|
|
)));
|
|
}
|
|
if let Some(replaced_handle) = replaced_handle {
|
|
self.delete_provider_credential_handle(
|
|
provider_name,
|
|
workspace,
|
|
provider_id,
|
|
credential_key,
|
|
replaced_handle,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok::<_, Status>((credential_key.clone(), handle))
|
|
}
|
|
});
|
|
let results = futures::future::join_all(futures).await;
|
|
|
|
let mut successes = HashMap::new();
|
|
let mut first_error: Option<Status> = None;
|
|
for result in results {
|
|
match result {
|
|
Ok((key, handle)) => {
|
|
successes.insert(key, handle);
|
|
}
|
|
Err(err) if first_error.is_none() => {
|
|
first_error = Some(err);
|
|
}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
|
|
if let Some(err) = first_error {
|
|
if !successes.is_empty()
|
|
&& let Err(cleanup_err) = self
|
|
.delete_provider_credential_handles(
|
|
provider_name,
|
|
workspace,
|
|
provider_id,
|
|
&successes,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
provider_name = %provider_name,
|
|
error = %cleanup_err,
|
|
"failed to clean up partially stored credentials after error"
|
|
);
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
Ok(successes)
|
|
}
|
|
|
|
/// Store gateway-only refresh material through the active credential
|
|
/// driver. Material names never become backend keys directly; a
|
|
/// deterministic driver-safe key binds each slot to its injectable
|
|
/// credential without exposing caller-controlled names in backend paths.
|
|
pub async fn store_refresh_material_with_object_id(
|
|
&self,
|
|
scope: RefreshMaterialScope<'_>,
|
|
object_id: &str,
|
|
material: &HashMap<String, String>,
|
|
existing_handles: &HashMap<String, CredentialHandle>,
|
|
) -> Result<HashMap<String, CredentialHandle>, Status> {
|
|
let mut values_by_storage_key = HashMap::with_capacity(material.len());
|
|
let mut handles_by_storage_key = HashMap::with_capacity(existing_handles.len());
|
|
let mut material_by_storage_key = HashMap::with_capacity(material.len());
|
|
|
|
for (material_key, value) in material {
|
|
let storage_key = refresh_material_storage_key(scope.credential_key, material_key);
|
|
material_by_storage_key.insert(storage_key.clone(), material_key.clone());
|
|
values_by_storage_key.insert(storage_key, value.clone());
|
|
}
|
|
for (material_key, handle) in existing_handles {
|
|
handles_by_storage_key.insert(
|
|
refresh_material_storage_key(scope.credential_key, material_key),
|
|
handle.clone(),
|
|
);
|
|
}
|
|
|
|
let stored = self
|
|
.store_provider_credentials_with_object_id(
|
|
scope.provider_name,
|
|
scope.workspace,
|
|
scope.provider_id,
|
|
object_id,
|
|
&values_by_storage_key,
|
|
&handles_by_storage_key,
|
|
)
|
|
.await?;
|
|
stored
|
|
.into_iter()
|
|
.map(|(storage_key, handle)| {
|
|
material_by_storage_key
|
|
.remove(&storage_key)
|
|
.map(|material_key| (material_key, handle))
|
|
.ok_or_else(|| {
|
|
Status::internal(
|
|
"credential driver returned an unknown refresh material key",
|
|
)
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub async fn resolve_refresh_material(
|
|
&self,
|
|
scope: RefreshMaterialScope<'_>,
|
|
handles: &HashMap<String, CredentialHandle>,
|
|
) -> Result<HashMap<String, String>, Status> {
|
|
if handles.is_empty() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
let mut material_by_storage_key = HashMap::with_capacity(handles.len());
|
|
let mut storage_handles = HashMap::with_capacity(handles.len());
|
|
for (material_key, handle) in handles {
|
|
let storage_key = refresh_material_storage_key(scope.credential_key, material_key);
|
|
material_by_storage_key.insert(storage_key.clone(), material_key.clone());
|
|
storage_handles.insert(storage_key, handle.clone());
|
|
}
|
|
// Reuse the normal driver-batched resolver with a synthetic provider.
|
|
// Refresh inputs are gateway-only and have no provider-level expiry;
|
|
// passing zero below prevents an expired injectable credential from
|
|
// suppressing resolution of an otherwise valid refresh-material handle.
|
|
let provider = Provider {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: scope.provider_id.to_string(),
|
|
name: scope.provider_name.to_string(),
|
|
workspace: scope.workspace.to_string(),
|
|
..Default::default()
|
|
}),
|
|
credential_handles: storage_handles,
|
|
..Default::default()
|
|
};
|
|
let resolved = self.resolve_provider_handles(&provider, 0).await?;
|
|
resolved
|
|
.values
|
|
.into_iter()
|
|
.map(|(storage_key, value)| {
|
|
material_by_storage_key
|
|
.remove(&storage_key)
|
|
.map(|material_key| (material_key, value))
|
|
.ok_or_else(|| {
|
|
Status::internal(
|
|
"credential driver resolved an unknown refresh material key",
|
|
)
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub async fn delete_refresh_material_handles(
|
|
&self,
|
|
scope: RefreshMaterialScope<'_>,
|
|
handles: &HashMap<String, CredentialHandle>,
|
|
) -> Result<(), Status> {
|
|
let storage_handles = handles
|
|
.iter()
|
|
.map(|(material_key, handle)| {
|
|
(
|
|
refresh_material_storage_key(scope.credential_key, material_key),
|
|
handle.clone(),
|
|
)
|
|
})
|
|
.collect();
|
|
self.delete_provider_credential_handles(
|
|
scope.provider_name,
|
|
scope.workspace,
|
|
scope.provider_id,
|
|
&storage_handles,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn delete_refresh_material_deletions(
|
|
&self,
|
|
scope: RefreshMaterialScope<'_>,
|
|
deletions: &[StoredRefreshMaterialDeletion],
|
|
) -> Result<(), Status> {
|
|
let futures = deletions.iter().map(|deletion| async move {
|
|
if deletion.material_key.is_empty() {
|
|
return Err(Status::failed_precondition(
|
|
"pending refresh-material deletion has no material key",
|
|
));
|
|
}
|
|
let handle = deletion.handle.clone().ok_or_else(|| {
|
|
Status::failed_precondition(
|
|
"pending refresh-material deletion has no credential handle",
|
|
)
|
|
})?;
|
|
let storage_key =
|
|
refresh_material_storage_key(scope.credential_key, &deletion.material_key);
|
|
self.delete_provider_credential_handle(
|
|
scope.provider_name,
|
|
scope.workspace,
|
|
scope.provider_id,
|
|
&storage_key,
|
|
handle,
|
|
)
|
|
.await
|
|
});
|
|
let results = futures::future::join_all(futures).await;
|
|
let mut first_error = None;
|
|
for result in results {
|
|
if let Err(err) = result
|
|
&& first_error.is_none()
|
|
{
|
|
first_error = Some(err);
|
|
}
|
|
}
|
|
if let Some(err) = first_error {
|
|
return Err(err);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_provider_credential_handles(
|
|
&self,
|
|
provider_name: &str,
|
|
workspace: &str,
|
|
provider_id: &str,
|
|
handles: &HashMap<String, CredentialHandle>,
|
|
) -> Result<(), Status> {
|
|
let futures = handles.iter().map(|(credential_key, handle)| {
|
|
self.delete_provider_credential_handle(
|
|
provider_name,
|
|
workspace,
|
|
provider_id,
|
|
credential_key,
|
|
handle.clone(),
|
|
)
|
|
});
|
|
let results = futures::future::join_all(futures).await;
|
|
let mut first_error: Option<Status> = None;
|
|
for result in results {
|
|
if let Err(err) = result
|
|
&& first_error.is_none()
|
|
{
|
|
first_error = Some(err);
|
|
}
|
|
}
|
|
if let Some(err) = first_error {
|
|
return Err(err);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_provider_credential_handle(
|
|
&self,
|
|
provider_name: &str,
|
|
workspace: &str,
|
|
provider_id: &str,
|
|
credential_key: &str,
|
|
handle: CredentialHandle,
|
|
) -> Result<(), Status> {
|
|
let driver_name = self.registry.driver_for_handle(credential_key, &handle)?;
|
|
let driver = self.connected_driver(&driver_name)?;
|
|
driver
|
|
.delete_credential(DeleteCredentialRequest {
|
|
provider_name: provider_name.to_string(),
|
|
credential_key: credential_key.to_string(),
|
|
handle: Some(handle),
|
|
workspace: workspace.to_string(),
|
|
provider_id: provider_id.to_string(),
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn resolve_provider_handles(
|
|
&self,
|
|
provider: &Provider,
|
|
now_ms: i64,
|
|
) -> Result<ResolvedProviderCredentials, Status> {
|
|
self.registry.validate_provider_handles(provider)?;
|
|
if provider.credential_handles.is_empty() {
|
|
return Ok(ResolvedProviderCredentials::default());
|
|
}
|
|
|
|
let provider_name = provider
|
|
.metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.name.clone())
|
|
.unwrap_or_default();
|
|
let workspace = provider
|
|
.metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.workspace.clone())
|
|
.unwrap_or_default();
|
|
let provider_id = provider
|
|
.metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.id.clone())
|
|
.unwrap_or_default();
|
|
let mut request_keys = HashMap::new();
|
|
let mut requests_by_driver: BTreeMap<String, Vec<ResolveCredentialRequest>> =
|
|
BTreeMap::new();
|
|
|
|
for (credential_key, handle) in &provider.credential_handles {
|
|
let driver_name = self.registry.driver_for_handle(credential_key, handle)?;
|
|
let request_id = format!("credential-{}", request_keys.len());
|
|
request_keys.insert(request_id.clone(), credential_key.clone());
|
|
|
|
let mut selected_handle = handle.clone();
|
|
selected_handle.driver.clone_from(&driver_name);
|
|
requests_by_driver
|
|
.entry(driver_name)
|
|
.or_default()
|
|
.push(ResolveCredentialRequest {
|
|
request_id,
|
|
provider_name: provider_name.clone(),
|
|
credential_key: credential_key.clone(),
|
|
handle: Some(selected_handle),
|
|
workspace: workspace.clone(),
|
|
provider_id: provider_id.clone(),
|
|
});
|
|
}
|
|
|
|
let mut resolved = ResolvedProviderCredentials::default();
|
|
let mut seen_responses = HashSet::new();
|
|
|
|
for (driver_name, requests) in requests_by_driver {
|
|
let expected_request_ids: HashSet<_> = requests
|
|
.iter()
|
|
.map(|request| request.request_id.clone())
|
|
.collect();
|
|
let driver = self.connected_driver(&driver_name)?;
|
|
|
|
let responses = driver.resolve_credentials(requests).await?;
|
|
for response in responses {
|
|
if response.request_id.is_empty() {
|
|
return Err(Status::internal(format!(
|
|
"credential driver '{driver_name}' returned a response without request_id"
|
|
)));
|
|
}
|
|
if !expected_request_ids.contains(&response.request_id) {
|
|
return Err(Status::internal(format!(
|
|
"credential driver '{driver_name}' returned unknown request_id '{}'",
|
|
response.request_id
|
|
)));
|
|
}
|
|
if !seen_responses.insert(response.request_id.clone()) {
|
|
return Err(Status::internal(format!(
|
|
"credential driver '{driver_name}' returned duplicate request_id '{}'",
|
|
response.request_id
|
|
)));
|
|
}
|
|
|
|
let credential_key = request_keys
|
|
.get(&response.request_id)
|
|
.expect("validated response request_id")
|
|
.clone();
|
|
|
|
// Check provider-level expiration
|
|
let provider_expires_at_ms = provider
|
|
.credential_expires_at_ms
|
|
.get(&credential_key)
|
|
.copied()
|
|
.unwrap_or(0);
|
|
|
|
// Compute effective expiration (earliest non-zero timestamp)
|
|
let effective_expires_at_ms = match (provider_expires_at_ms, response.expires_at_ms)
|
|
{
|
|
(0, driver) => driver,
|
|
(provider, 0) => provider,
|
|
(provider, driver) => provider.min(driver),
|
|
};
|
|
|
|
if effective_expires_at_ms > 0 && effective_expires_at_ms <= now_ms {
|
|
warn!(
|
|
provider_name = %provider_name,
|
|
credential_key = %credential_key,
|
|
provider_expires_at_ms,
|
|
driver_expires_at_ms = response.expires_at_ms,
|
|
effective_expires_at_ms,
|
|
"skipping expired handle-backed credential"
|
|
);
|
|
continue;
|
|
}
|
|
if effective_expires_at_ms > 0 {
|
|
resolved
|
|
.expires_at_ms
|
|
.insert(credential_key.clone(), effective_expires_at_ms);
|
|
}
|
|
resolved.values.insert(credential_key, response.value);
|
|
}
|
|
|
|
for request_id in expected_request_ids {
|
|
if !seen_responses.contains(&request_id) {
|
|
return Err(Status::internal(format!(
|
|
"credential driver '{driver_name}' did not return a response for request_id '{request_id}'"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(resolved)
|
|
}
|
|
|
|
fn connected_driver(&self, driver_name: &str) -> Result<&Arc<dyn CredentialDriver>, Status> {
|
|
self.drivers.get(driver_name).ok_or_else(|| {
|
|
Status::failed_precondition(format!(
|
|
"credential driver '{driver_name}' is enabled but not connected"
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
fn refresh_material_storage_key(credential_key: &str, material_key: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(REFRESH_MATERIAL_CREDENTIAL_KEY_DOMAIN);
|
|
hasher.update([0]);
|
|
hasher.update(credential_key.as_bytes());
|
|
hasher.update([0]);
|
|
hasher.update(material_key.as_bytes());
|
|
format!("openshell.refresh.{:x}", hasher.finalize())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum BuiltinCredentialDriverKind {
|
|
KubernetesSecrets,
|
|
Vault,
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
TestStatic,
|
|
}
|
|
|
|
impl BuiltinCredentialDriverKind {
|
|
fn from_name(name: &str) -> Option<Self> {
|
|
match name {
|
|
KubernetesSecretsCredentialDriver::NAME => Some(Self::KubernetesSecrets),
|
|
VaultCredentialDriver::NAME => Some(Self::Vault),
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
TestStaticCredentialDriver::NAME => Some(Self::TestStatic),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
fn builtin_credential_driver_names() -> &'static [&'static str] {
|
|
&[
|
|
KubernetesSecretsCredentialDriver::NAME,
|
|
VaultCredentialDriver::NAME,
|
|
TestStaticCredentialDriver::NAME,
|
|
]
|
|
}
|
|
|
|
#[cfg(not(any(test, feature = "test-support")))]
|
|
fn builtin_credential_driver_names() -> &'static [&'static str] {
|
|
&[
|
|
KubernetesSecretsCredentialDriver::NAME,
|
|
VaultCredentialDriver::NAME,
|
|
]
|
|
}
|
|
|
|
fn unknown_credential_driver_error(driver_name: &str) -> Error {
|
|
Error::config(format!(
|
|
"credential driver '{driver_name}' is not a built-in credential driver and has no [openshell.credential_drivers.{driver_name}] table; configure an external driver with transport = 'uds' or choose one of: {}",
|
|
builtin_credential_driver_names().join(", ")
|
|
))
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CredentialDriverRegistry {
|
|
enabled: BTreeSet<String>,
|
|
default_driver: Option<String>,
|
|
}
|
|
|
|
impl CredentialDriverRegistry {
|
|
pub fn from_config(config: &Config) -> CoreResult<Self> {
|
|
let mut enabled = BTreeSet::new();
|
|
for driver in &config.credential_drivers {
|
|
let driver = normalize_driver_name(driver);
|
|
if driver.is_empty() {
|
|
return Err(Error::config(
|
|
"credential_drivers entries must be non-empty strings",
|
|
));
|
|
}
|
|
enabled.insert(driver);
|
|
}
|
|
|
|
let default_driver = config
|
|
.default_credential_driver
|
|
.as_deref()
|
|
.map(normalize_driver_name)
|
|
.filter(|driver| !driver.is_empty());
|
|
|
|
if default_driver.is_some() && enabled.is_empty() {
|
|
return Err(Error::config(
|
|
"default_credential_driver requires credential_drivers to name an external credential driver",
|
|
));
|
|
}
|
|
|
|
if let Some(default_driver) = default_driver.as_deref()
|
|
&& !enabled.contains(default_driver)
|
|
{
|
|
return Err(Error::config(format!(
|
|
"default_credential_driver '{default_driver}' is not listed in credential_drivers"
|
|
)));
|
|
}
|
|
|
|
if enabled.len() > 1 {
|
|
return Err(Error::config(
|
|
"credential_drivers supports at most one enabled credential driver",
|
|
));
|
|
}
|
|
|
|
Ok(Self {
|
|
enabled,
|
|
default_driver,
|
|
})
|
|
}
|
|
|
|
pub fn storage_owner_name(&self) -> String {
|
|
if self.enabled.is_empty() {
|
|
return DbCredstoreCredentialDriver::NAME.to_string();
|
|
}
|
|
if let Some(default_driver) = self.default_driver.clone() {
|
|
return default_driver;
|
|
}
|
|
self.enabled
|
|
.iter()
|
|
.next()
|
|
.expect("enabled is non-empty")
|
|
.clone()
|
|
}
|
|
|
|
fn requires_default_store(&self) -> bool {
|
|
self.enabled.is_empty()
|
|
}
|
|
|
|
pub fn validate_provider_handles(&self, provider: &Provider) -> Result<(), Status> {
|
|
if provider.credential_handles.is_empty() {
|
|
return Ok(());
|
|
}
|
|
for (credential_key, handle) in &provider.credential_handles {
|
|
self.driver_for_handle(credential_key, handle)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn enabled_driver_names(&self) -> impl Iterator<Item = &String> {
|
|
self.enabled.iter()
|
|
}
|
|
|
|
fn driver_for_handle(
|
|
&self,
|
|
credential_key: &str,
|
|
handle: &CredentialHandle,
|
|
) -> Result<String, Status> {
|
|
let driver = normalize_driver_name(&handle.driver);
|
|
if driver.is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"provider credential_handles['{credential_key}'] is missing driver"
|
|
)));
|
|
}
|
|
if handle.handle.trim().is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"provider credential_handles['{credential_key}'] is missing handle"
|
|
)));
|
|
}
|
|
|
|
if driver == DbCredstoreCredentialDriver::NAME {
|
|
return Ok(driver);
|
|
}
|
|
|
|
if !self.enabled.contains(&driver) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"provider credential_handles['{credential_key}'] references credential driver '{driver}' that is not enabled"
|
|
)));
|
|
}
|
|
|
|
Ok(driver)
|
|
}
|
|
}
|
|
|
|
fn normalize_driver_name(driver: &str) -> String {
|
|
driver.trim().to_string()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum CredentialDriverTransport {
|
|
InTree,
|
|
Uds,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct ConfiguredCredentialDriver {
|
|
transport: CredentialDriverTransport,
|
|
socket_path: Option<PathBuf>,
|
|
command: Option<PathBuf>,
|
|
args: Vec<String>,
|
|
startup_timeout_secs: u64,
|
|
backend_config: toml::Table,
|
|
}
|
|
|
|
fn parse_driver_table(
|
|
driver_name: &str,
|
|
value: &toml::Value,
|
|
) -> CoreResult<ConfiguredCredentialDriver> {
|
|
let table = value.as_table().ok_or_else(|| {
|
|
Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] must be a TOML table"
|
|
))
|
|
})?;
|
|
|
|
let transport = table
|
|
.get("transport")
|
|
.map(|value| string_field(driver_name, "transport", value))
|
|
.transpose()?
|
|
.unwrap_or_else(|| "in_tree".to_string());
|
|
let transport = match transport.as_str() {
|
|
"in_tree" => CredentialDriverTransport::InTree,
|
|
"uds" => CredentialDriverTransport::Uds,
|
|
other => {
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] transport must be 'in_tree' or 'uds', got '{other}'"
|
|
)));
|
|
}
|
|
};
|
|
|
|
let socket_path = table
|
|
.get("socket_path")
|
|
.map(|value| string_field(driver_name, "socket_path", value))
|
|
.transpose()?
|
|
.map(PathBuf::from);
|
|
let command = table
|
|
.get("command")
|
|
.map(|value| string_field(driver_name, "command", value))
|
|
.transpose()?
|
|
.map(PathBuf::from);
|
|
let args = table
|
|
.get("args")
|
|
.map(|value| string_array_field(driver_name, "args", value))
|
|
.transpose()?
|
|
.unwrap_or_default();
|
|
let startup_timeout_secs = table
|
|
.get("startup_timeout_secs")
|
|
.map(|value| positive_integer_field(driver_name, "startup_timeout_secs", value))
|
|
.transpose()?
|
|
.unwrap_or(DEFAULT_CREDENTIAL_DRIVER_STARTUP_TIMEOUT_SECS);
|
|
|
|
if transport == CredentialDriverTransport::Uds {
|
|
let socket_path = socket_path.as_ref().ok_or_else(|| {
|
|
Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] socket_path is required when transport = 'uds'"
|
|
))
|
|
})?;
|
|
if !socket_path.is_absolute() {
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] socket_path must be absolute"
|
|
)));
|
|
}
|
|
if let Some(command) = command.as_ref()
|
|
&& !command.is_absolute()
|
|
{
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] command must be absolute"
|
|
)));
|
|
}
|
|
if command.is_none() && !args.is_empty() {
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] args requires command"
|
|
)));
|
|
}
|
|
if command.is_none() && table.contains_key("startup_timeout_secs") {
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] startup_timeout_secs requires command"
|
|
)));
|
|
}
|
|
} else if command.is_some() || !args.is_empty() || table.contains_key("startup_timeout_secs") {
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] command, args, and startup_timeout_secs require transport = 'uds'"
|
|
)));
|
|
}
|
|
|
|
Ok(ConfiguredCredentialDriver {
|
|
transport,
|
|
socket_path,
|
|
command,
|
|
args,
|
|
startup_timeout_secs,
|
|
backend_config: backend_config_table(table),
|
|
})
|
|
}
|
|
|
|
fn backend_config_table(table: &toml::Table) -> toml::Table {
|
|
let mut backend_config = table.clone();
|
|
for field in COMMON_CREDENTIAL_DRIVER_FIELDS {
|
|
backend_config.remove(*field);
|
|
}
|
|
backend_config
|
|
}
|
|
|
|
fn string_field(
|
|
driver_name: &str,
|
|
field_name: &'static str,
|
|
value: &toml::Value,
|
|
) -> CoreResult<String> {
|
|
let value = value.as_str().ok_or_else(|| {
|
|
Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] {field_name} must be a string"
|
|
))
|
|
})?;
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] {field_name} must not be empty"
|
|
)));
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
fn string_array_field(
|
|
driver_name: &str,
|
|
field_name: &'static str,
|
|
value: &toml::Value,
|
|
) -> CoreResult<Vec<String>> {
|
|
let values = value.as_array().ok_or_else(|| {
|
|
Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] {field_name} must be an array of strings"
|
|
))
|
|
})?;
|
|
|
|
values
|
|
.iter()
|
|
.map(|value| string_field(driver_name, field_name, value))
|
|
.collect()
|
|
}
|
|
|
|
fn positive_integer_field(
|
|
driver_name: &str,
|
|
field_name: &'static str,
|
|
value: &toml::Value,
|
|
) -> CoreResult<u64> {
|
|
let value = value.as_integer().ok_or_else(|| {
|
|
Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] {field_name} must be a positive integer"
|
|
))
|
|
})?;
|
|
if value <= 0 {
|
|
return Err(Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] {field_name} must be a positive integer"
|
|
)));
|
|
}
|
|
u64::try_from(value).map_err(|_| {
|
|
Error::config(format!(
|
|
"[openshell.credential_drivers.{driver_name}] {field_name} is too large"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn connect_default_credential_store(
|
|
drivers: &mut BTreeMap<String, Arc<dyn CredentialDriver>>,
|
|
store: Option<Arc<Store>>,
|
|
config: &toml::Table,
|
|
required: bool,
|
|
) -> CoreResult<()> {
|
|
if !required && config.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(store) = store else {
|
|
if required {
|
|
return Err(Error::config(
|
|
"default encrypted credential storage requires the gateway object store",
|
|
));
|
|
}
|
|
return Ok(());
|
|
};
|
|
|
|
let object_store: Arc<dyn DbCredstoreObjectStore> =
|
|
Arc::new(ServerDbCredstoreObjectStore::new(store));
|
|
let storage: Arc<dyn CredentialDriver> = Arc::new(DbCredstoreCredentialDriver::from_config(
|
|
object_store,
|
|
config,
|
|
)?);
|
|
drivers.insert(DbCredstoreCredentialDriver::NAME.to_string(), storage);
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct BuiltCredentialDriver {
|
|
driver: Arc<dyn CredentialDriver>,
|
|
process: Option<Arc<ManagedCredentialDriverProcess>>,
|
|
}
|
|
|
|
async fn build_configured_driver(
|
|
driver_name: &str,
|
|
config: ConfiguredCredentialDriver,
|
|
store: Option<Arc<Store>>,
|
|
) -> CoreResult<BuiltCredentialDriver> {
|
|
match config.transport {
|
|
CredentialDriverTransport::InTree => {
|
|
let driver = build_in_tree_driver(driver_name, Some(&config.backend_config), store)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::config(format!(
|
|
"credential driver '{driver_name}' is configured with transport = 'in_tree', but no in-tree implementation is available"
|
|
))
|
|
})?;
|
|
Ok(BuiltCredentialDriver {
|
|
driver,
|
|
process: None,
|
|
})
|
|
}
|
|
CredentialDriverTransport::Uds => {
|
|
let socket_path = config
|
|
.socket_path
|
|
.clone()
|
|
.expect("UDS transport requires socket_path during parsing");
|
|
connect_uds_driver(driver_name, config, &socket_path).await
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn build_default_in_tree_driver(
|
|
driver_name: &str,
|
|
store: Option<Arc<Store>>,
|
|
) -> CoreResult<Arc<dyn CredentialDriver>> {
|
|
build_in_tree_driver(driver_name, None, store)
|
|
.await?
|
|
.ok_or_else(|| unknown_credential_driver_error(driver_name))
|
|
}
|
|
|
|
async fn build_in_tree_driver(
|
|
name: &str,
|
|
backend_config: Option<&toml::Table>,
|
|
_store: Option<Arc<Store>>,
|
|
) -> CoreResult<Option<Arc<dyn CredentialDriver>>> {
|
|
let Some(kind) = BuiltinCredentialDriverKind::from_name(name) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let empty_config = toml::Table::new();
|
|
let backend_config = backend_config.unwrap_or(&empty_config);
|
|
let driver: Arc<dyn CredentialDriver> = match kind {
|
|
BuiltinCredentialDriverKind::KubernetesSecrets => {
|
|
Arc::new(KubernetesSecretsCredentialDriver::from_config(backend_config).await?)
|
|
}
|
|
BuiltinCredentialDriverKind::Vault => {
|
|
Arc::new(VaultCredentialDriver::from_config(backend_config)?)
|
|
}
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
BuiltinCredentialDriverKind::TestStatic => Arc::new(TestStaticCredentialDriver::new()),
|
|
};
|
|
Ok(Some(driver))
|
|
}
|
|
|
|
fn build_sync_builtin_driver(
|
|
name: &str,
|
|
_store: Option<Arc<Store>>,
|
|
) -> Option<Arc<dyn CredentialDriver>> {
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
if BuiltinCredentialDriverKind::from_name(name) == Some(BuiltinCredentialDriverKind::TestStatic)
|
|
{
|
|
let driver: Arc<dyn CredentialDriver> = Arc::new(TestStaticCredentialDriver::new());
|
|
return Some(driver);
|
|
}
|
|
|
|
let _ = name;
|
|
None
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ServerDbCredstoreObjectStore {
|
|
store: Arc<Store>,
|
|
}
|
|
|
|
impl ServerDbCredstoreObjectStore {
|
|
fn new(store: Arc<Store>) -> Self {
|
|
Self { store }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DbCredstoreObjectStore for ServerDbCredstoreObjectStore {
|
|
async fn get_credential_object(
|
|
&self,
|
|
object_type: &str,
|
|
id: &str,
|
|
operation: &'static str,
|
|
) -> Result<Option<StoredCredentialObject>, Status> {
|
|
self.store
|
|
.get(object_type, id)
|
|
.await
|
|
.map(|record| {
|
|
record.map(|record| StoredCredentialObject {
|
|
object_type: record.object_type,
|
|
id: record.id,
|
|
payload: record.payload,
|
|
resource_version: record.resource_version,
|
|
})
|
|
})
|
|
.map_err(|err| default_credential_store_persistence_error_to_status(err, operation))
|
|
}
|
|
|
|
async fn put_credential_object(
|
|
&self,
|
|
write: CredentialObjectWrite,
|
|
operation: &'static str,
|
|
) -> Result<(), Status> {
|
|
let condition = match write.condition {
|
|
DbCredstoreWriteCondition::MustCreate => WriteCondition::MustCreate,
|
|
DbCredstoreWriteCondition::MatchResourceVersion(resource_version) => {
|
|
WriteCondition::MatchResourceVersion(resource_version)
|
|
}
|
|
};
|
|
self.store
|
|
.put_if(
|
|
&write.object_type,
|
|
&write.id,
|
|
&write.name,
|
|
"",
|
|
&write.payload,
|
|
write.labels.as_deref(),
|
|
condition,
|
|
)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| default_credential_store_persistence_error_to_status(err, operation))
|
|
}
|
|
|
|
async fn delete_credential_object(
|
|
&self,
|
|
object_type: &str,
|
|
id: &str,
|
|
expected_resource_version: u64,
|
|
operation: &'static str,
|
|
) -> Result<(), Status> {
|
|
self.store
|
|
.delete_if(object_type, id, expected_resource_version)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| default_credential_store_persistence_error_to_status(err, operation))
|
|
}
|
|
}
|
|
|
|
fn default_credential_store_persistence_error_to_status(
|
|
err: PersistenceError,
|
|
operation: &str,
|
|
) -> Status {
|
|
match err {
|
|
PersistenceError::UniqueViolation { .. } => {
|
|
Status::already_exists(format!("default credential already exists: {err}"))
|
|
}
|
|
PersistenceError::Conflict {
|
|
current_resource_version,
|
|
} => Status::aborted(format!(
|
|
"default credential was modified concurrently during {operation} (current resource_version: {})",
|
|
current_resource_version.unwrap_or(0)
|
|
)),
|
|
PersistenceError::Decode(err) => Status::data_loss(format!(
|
|
"default credential decode failed during {operation}: {err}"
|
|
)),
|
|
PersistenceError::Encode(err) => Status::internal(format!(
|
|
"default credential encode failed during {operation}: {err}"
|
|
)),
|
|
other => Status::unavailable(format!("default credential {operation} failed: {other}")),
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl CredentialDriver for KubernetesSecretsCredentialDriver {
|
|
async fn store_credential(
|
|
&self,
|
|
request: StoreCredentialRequest,
|
|
) -> Result<CredentialHandle, Status> {
|
|
Self::store_credential(self, request).await
|
|
}
|
|
|
|
async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> {
|
|
Self::delete_credential(self, request).await
|
|
}
|
|
|
|
async fn resolve_credentials(
|
|
&self,
|
|
requests: Vec<ResolveCredentialRequest>,
|
|
) -> Result<Vec<ResolvedCredential>, Status> {
|
|
Self::resolve_credentials(self, requests).await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl CredentialDriver for DbCredstoreCredentialDriver {
|
|
async fn store_credential(
|
|
&self,
|
|
request: StoreCredentialRequest,
|
|
) -> Result<CredentialHandle, Status> {
|
|
Self::store_credential(self, request).await
|
|
}
|
|
|
|
async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> {
|
|
Self::delete_credential(self, request).await
|
|
}
|
|
|
|
async fn resolve_credentials(
|
|
&self,
|
|
requests: Vec<ResolveCredentialRequest>,
|
|
) -> Result<Vec<ResolvedCredential>, Status> {
|
|
Self::resolve_credentials(self, requests).await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl CredentialDriver for VaultCredentialDriver {
|
|
async fn store_credential(
|
|
&self,
|
|
request: StoreCredentialRequest,
|
|
) -> Result<CredentialHandle, Status> {
|
|
Self::store_credential(self, request).await
|
|
}
|
|
|
|
async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> {
|
|
Self::delete_credential(self, request).await
|
|
}
|
|
|
|
async fn resolve_credentials(
|
|
&self,
|
|
requests: Vec<ResolveCredentialRequest>,
|
|
) -> Result<Vec<ResolvedCredential>, Status> {
|
|
Self::resolve_credentials(self, requests).await
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[cfg(unix)]
|
|
struct RemoteCredentialDriver {
|
|
channel: Channel,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl RemoteCredentialDriver {
|
|
fn new(channel: Channel) -> Self {
|
|
Self { channel }
|
|
}
|
|
|
|
fn client(&self) -> CredentialDriverClient<Channel> {
|
|
CredentialDriverClient::new(self.channel.clone())
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[async_trait]
|
|
impl CredentialDriver for RemoteCredentialDriver {
|
|
async fn store_credential(
|
|
&self,
|
|
request: StoreCredentialRequest,
|
|
) -> Result<CredentialHandle, Status> {
|
|
let mut client = self.client();
|
|
let mut grpc_request = Request::new(request);
|
|
grpc_request.set_timeout(Duration::from_secs(
|
|
DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS,
|
|
));
|
|
|
|
let timeout_duration = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS);
|
|
let response =
|
|
tokio::time::timeout(timeout_duration, client.store_credential(grpc_request))
|
|
.await
|
|
.map_err(|_| {
|
|
Status::deadline_exceeded("credential driver StoreCredential timed out")
|
|
})??;
|
|
|
|
response
|
|
.into_inner()
|
|
.handle
|
|
.ok_or_else(|| Status::internal("credential driver returned no stored handle"))
|
|
}
|
|
|
|
async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> {
|
|
let mut client = self.client();
|
|
let mut grpc_request = Request::new(request);
|
|
grpc_request.set_timeout(Duration::from_secs(
|
|
DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS,
|
|
));
|
|
|
|
let timeout_duration = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS);
|
|
tokio::time::timeout(timeout_duration, client.delete_credential(grpc_request))
|
|
.await
|
|
.map_err(|_| {
|
|
Status::deadline_exceeded("credential driver DeleteCredential timed out")
|
|
})??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn resolve_credentials(
|
|
&self,
|
|
requests: Vec<ResolveCredentialRequest>,
|
|
) -> Result<Vec<ResolvedCredential>, Status> {
|
|
let mut client = self.client();
|
|
let mut grpc_request = Request::new(ResolveCredentialsRequest {
|
|
credentials: requests,
|
|
});
|
|
grpc_request.set_timeout(Duration::from_secs(
|
|
DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS,
|
|
));
|
|
|
|
let timeout_duration = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS);
|
|
let response =
|
|
tokio::time::timeout(timeout_duration, client.resolve_credentials(grpc_request))
|
|
.await
|
|
.map_err(|_| {
|
|
Status::deadline_exceeded("credential driver ResolveCredentials timed out")
|
|
})??;
|
|
Ok(response.into_inner().credentials)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ManagedCredentialDriverProcess {
|
|
child: std::sync::Mutex<Option<tokio::process::Child>>,
|
|
socket_path: PathBuf,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl ManagedCredentialDriverProcess {
|
|
fn new(child: tokio::process::Child, socket_path: PathBuf) -> Self {
|
|
Self {
|
|
child: std::sync::Mutex::new(Some(child)),
|
|
socket_path,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ManagedCredentialDriverProcess {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut child) = self.child.lock() {
|
|
let _ = child.take();
|
|
}
|
|
let _ = std::fs::remove_file(&self.socket_path);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn connect_uds_driver(
|
|
driver_name: &str,
|
|
config: ConfiguredCredentialDriver,
|
|
socket_path: &Path,
|
|
) -> CoreResult<BuiltCredentialDriver> {
|
|
if config.command.is_some() {
|
|
spawn_uds_driver(driver_name, config, socket_path).await
|
|
} else {
|
|
let channel = connect_ready_credential_driver(driver_name, socket_path).await?;
|
|
Ok(BuiltCredentialDriver {
|
|
driver: Arc::new(RemoteCredentialDriver::new(channel)),
|
|
process: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
async fn connect_uds_driver(
|
|
driver_name: &str,
|
|
_config: ConfiguredCredentialDriver,
|
|
_socket_path: &Path,
|
|
) -> CoreResult<BuiltCredentialDriver> {
|
|
Err(Error::config(format!(
|
|
"credential driver '{driver_name}' uses transport = 'uds', but this platform does not support Unix domain sockets"
|
|
)))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn spawn_uds_driver(
|
|
driver_name: &str,
|
|
config: ConfiguredCredentialDriver,
|
|
socket_path: &Path,
|
|
) -> CoreResult<BuiltCredentialDriver> {
|
|
let command_path = config
|
|
.command
|
|
.expect("UDS command exists when spawning credential driver");
|
|
let parent = socket_path.parent().ok_or_else(|| {
|
|
Error::execution(format!(
|
|
"credential driver '{driver_name}' socket path '{}' has no parent directory",
|
|
socket_path.display()
|
|
))
|
|
})?;
|
|
std::fs::create_dir_all(parent).map_err(|err| {
|
|
Error::execution(format!(
|
|
"failed to create credential driver '{driver_name}' socket dir '{}': {err}",
|
|
parent.display()
|
|
))
|
|
})?;
|
|
remove_stale_launched_driver_socket(driver_name, socket_path)?;
|
|
|
|
let mut command = Command::new(&command_path);
|
|
command.kill_on_drop(true);
|
|
command.stdin(Stdio::null());
|
|
command.stdout(Stdio::inherit());
|
|
command.stderr(Stdio::inherit());
|
|
command.args(&config.args);
|
|
command.arg("--bind-socket").arg(socket_path);
|
|
|
|
let mut child = command.spawn().map_err(|err| {
|
|
Error::execution(format!(
|
|
"failed to launch credential driver '{driver_name}' '{}': {err}",
|
|
command_path.display()
|
|
))
|
|
})?;
|
|
let channel = wait_for_launched_credential_driver(
|
|
driver_name,
|
|
socket_path,
|
|
&mut child,
|
|
Duration::from_secs(config.startup_timeout_secs),
|
|
)
|
|
.await?;
|
|
let process = Arc::new(ManagedCredentialDriverProcess::new(
|
|
child,
|
|
socket_path.to_path_buf(),
|
|
));
|
|
Ok(BuiltCredentialDriver {
|
|
driver: Arc::new(RemoteCredentialDriver::new(channel)),
|
|
process: Some(process),
|
|
})
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn remove_stale_launched_driver_socket(driver_name: &str, socket_path: &Path) -> CoreResult<()> {
|
|
let metadata = match std::fs::symlink_metadata(socket_path) {
|
|
Ok(metadata) => metadata,
|
|
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
|
|
Err(err) => {
|
|
return Err(Error::execution(format!(
|
|
"failed to stat credential driver '{driver_name}' socket '{}': {err}",
|
|
socket_path.display()
|
|
)));
|
|
}
|
|
};
|
|
let file_type = metadata.file_type();
|
|
if file_type.is_symlink() {
|
|
return Err(Error::execution(format!(
|
|
"credential driver '{driver_name}' socket '{}' is a symlink; refusing to remove it",
|
|
socket_path.display()
|
|
)));
|
|
}
|
|
if !file_type.is_socket() {
|
|
return Err(Error::execution(format!(
|
|
"credential driver '{driver_name}' socket path '{}' exists but is not a Unix socket",
|
|
socket_path.display()
|
|
)));
|
|
}
|
|
let expected_uid = rustix::process::geteuid().as_raw();
|
|
if metadata.uid() != expected_uid {
|
|
return Err(Error::execution(format!(
|
|
"credential driver '{driver_name}' socket '{}' is owned by uid {} but current euid is {}",
|
|
socket_path.display(),
|
|
metadata.uid(),
|
|
expected_uid
|
|
)));
|
|
}
|
|
std::fs::remove_file(socket_path).map_err(|err| {
|
|
Error::execution(format!(
|
|
"failed to remove stale credential driver '{driver_name}' socket '{}': {err}",
|
|
socket_path.display()
|
|
))
|
|
})
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn wait_for_launched_credential_driver(
|
|
driver_name: &str,
|
|
socket_path: &Path,
|
|
child: &mut tokio::process::Child,
|
|
timeout: Duration,
|
|
) -> CoreResult<Channel> {
|
|
let deadline = Instant::now() + timeout;
|
|
let mut last_error: Option<String> = None;
|
|
|
|
loop {
|
|
let try_wait_result = child.try_wait().map_err(|err| {
|
|
Error::execution(format!(
|
|
"failed to poll credential driver '{driver_name}' process: {err}"
|
|
))
|
|
})?;
|
|
if let Some(status) = try_wait_result {
|
|
return Err(Error::execution(format!(
|
|
"credential driver '{driver_name}' exited before becoming ready with status {status}"
|
|
)));
|
|
}
|
|
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
return Err(Error::execution(format!(
|
|
"timed out waiting for credential driver '{driver_name}' socket '{}': {}",
|
|
socket_path.display(),
|
|
last_error.unwrap_or_else(|| "unknown error".to_string())
|
|
)));
|
|
}
|
|
|
|
match tokio::time::timeout(
|
|
remaining,
|
|
connect_ready_credential_driver(driver_name, socket_path),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(channel)) => return Ok(channel),
|
|
Ok(Err(err)) => last_error = Some(err.to_string()),
|
|
Err(_) => {
|
|
return Err(Error::execution(format!(
|
|
"timed out waiting for credential driver '{driver_name}' to respond to GetCapabilities"
|
|
)));
|
|
}
|
|
}
|
|
|
|
if Instant::now() >= deadline {
|
|
return Err(Error::execution(format!(
|
|
"timed out waiting for credential driver '{driver_name}' socket '{}': {}",
|
|
socket_path.display(),
|
|
last_error.unwrap_or_else(|| "unknown error".to_string())
|
|
)));
|
|
}
|
|
|
|
tokio::time::sleep(CREDENTIAL_DRIVER_CONNECT_INTERVAL).await;
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn connect_ready_credential_driver(
|
|
driver_name: &str,
|
|
socket_path: &Path,
|
|
) -> CoreResult<Channel> {
|
|
let channel = connect_credential_driver_socket(driver_name, socket_path).await?;
|
|
let mut client = CredentialDriverClient::new(channel.clone());
|
|
let mut request = Request::new(GetCredentialDriverCapabilitiesRequest {});
|
|
let timeout = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS);
|
|
request.set_timeout(timeout);
|
|
await_credential_driver_capabilities(driver_name, timeout, client.get_capabilities(request))
|
|
.await?;
|
|
Ok(channel)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn await_credential_driver_capabilities(
|
|
driver_name: &str,
|
|
timeout: Duration,
|
|
response: impl Future<
|
|
Output = Result<tonic::Response<GetCredentialDriverCapabilitiesResponse>, Status>,
|
|
>,
|
|
) -> CoreResult<()> {
|
|
tokio::time::timeout(timeout, response)
|
|
.await
|
|
.map_err(|_| {
|
|
Error::config(format!(
|
|
"credential driver '{driver_name}' GetCapabilities timed out"
|
|
))
|
|
})?
|
|
.map_err(|status| {
|
|
Error::config(format!(
|
|
"credential driver '{driver_name}' GetCapabilities failed: {status}"
|
|
))
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn connect_credential_driver_socket(
|
|
driver_name: &str,
|
|
socket_path: &Path,
|
|
) -> CoreResult<Channel> {
|
|
let socket_path = socket_path.to_path_buf();
|
|
let display_path = socket_path.clone();
|
|
Endpoint::from_static("http://[::]:50051")
|
|
.connect_with_connector(service_fn(move |_: tonic::transport::Uri| {
|
|
let socket_path = socket_path.clone();
|
|
async move { UnixStream::connect(socket_path).await.map(TokioIo::new) }
|
|
}))
|
|
.await
|
|
.map_err(|err| {
|
|
Error::transport(format!(
|
|
"failed to connect to credential driver '{driver_name}' socket '{}': {err}",
|
|
display_path.display()
|
|
))
|
|
})
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
#[derive(Debug)]
|
|
struct TestStaticCredentialDriver {
|
|
values: std::sync::Mutex<HashMap<String, String>>,
|
|
fail_next_store: std::sync::atomic::AtomicBool,
|
|
fail_next_delete: std::sync::atomic::AtomicBool,
|
|
#[cfg(test)]
|
|
store_gate: std::sync::Mutex<
|
|
Option<(
|
|
tokio::sync::oneshot::Sender<()>,
|
|
tokio::sync::oneshot::Receiver<()>,
|
|
)>,
|
|
>,
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl TestStaticCredentialDriver {
|
|
const NAME: &'static str = "test-static";
|
|
|
|
fn new() -> Self {
|
|
Self {
|
|
values: std::sync::Mutex::new(HashMap::new()),
|
|
fail_next_store: std::sync::atomic::AtomicBool::new(false),
|
|
fail_next_delete: std::sync::atomic::AtomicBool::new(false),
|
|
#[cfg(test)]
|
|
store_gate: std::sync::Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
fn handle_from_request(
|
|
request_id: &str,
|
|
handle: Option<CredentialHandle>,
|
|
) -> Result<CredentialHandle, Status> {
|
|
handle.ok_or_else(|| {
|
|
Status::invalid_argument(format!(
|
|
"test-static credential request '{request_id}' is missing handle"
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
#[async_trait]
|
|
impl CredentialDriver for TestStaticCredentialDriver {
|
|
async fn store_credential(
|
|
&self,
|
|
request: StoreCredentialRequest,
|
|
) -> Result<CredentialHandle, Status> {
|
|
#[cfg(test)]
|
|
let gate = self.store_gate.lock().ok().and_then(|mut gate| gate.take());
|
|
#[cfg(test)]
|
|
if let Some((hit, release)) = gate {
|
|
let _ = hit.send(());
|
|
let _ = release.await;
|
|
}
|
|
if self
|
|
.fail_next_store
|
|
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
|
{
|
|
return Err(Status::unavailable("injected credential store failure"));
|
|
}
|
|
let handle = request
|
|
.existing_handle
|
|
.map(|handle| handle.handle)
|
|
.filter(|handle| !handle.trim().is_empty())
|
|
.unwrap_or_else(|| {
|
|
format!(
|
|
"{}:{}:{}",
|
|
request.provider_name, request.credential_key, request.object_id
|
|
)
|
|
});
|
|
self.values
|
|
.lock()
|
|
.map_err(|_| Status::internal("test-static credential store lock poisoned"))?
|
|
.insert(handle.clone(), request.value);
|
|
Ok(CredentialHandle {
|
|
driver: Self::NAME.to_string(),
|
|
handle,
|
|
metadata: HashMap::new(),
|
|
})
|
|
}
|
|
|
|
async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> {
|
|
if self
|
|
.fail_next_delete
|
|
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
|
{
|
|
return Err(Status::unavailable("injected credential delete failure"));
|
|
}
|
|
let handle = Self::handle_from_request("delete", request.handle)?;
|
|
self.values
|
|
.lock()
|
|
.map_err(|_| Status::internal("test-static credential store lock poisoned"))?
|
|
.remove(&handle.handle);
|
|
Ok(())
|
|
}
|
|
|
|
async fn resolve_credentials(
|
|
&self,
|
|
requests: Vec<ResolveCredentialRequest>,
|
|
) -> Result<Vec<ResolvedCredential>, Status> {
|
|
let mut responses = Vec::with_capacity(requests.len());
|
|
for request in requests {
|
|
let handle = Self::handle_from_request(&request.request_id, request.handle)?;
|
|
let value = self
|
|
.values
|
|
.lock()
|
|
.map_err(|_| Status::internal("test-static credential store lock poisoned"))?
|
|
.get(&handle.handle)
|
|
.cloned()
|
|
.ok_or_else(|| Status::not_found("test-static credential handle not found"))?;
|
|
responses.push(ResolvedCredential {
|
|
request_id: request.request_id,
|
|
value,
|
|
expires_at_ms: 0,
|
|
});
|
|
}
|
|
|
|
Ok(responses)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn stored_credential_count(&self) -> Option<usize> {
|
|
self.values.lock().ok().map(|values| values.len())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn fail_next_store(&self) {
|
|
self.fail_next_store
|
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn fail_next_delete(&self) {
|
|
self.fail_next_delete
|
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn gate_next_store(
|
|
&self,
|
|
) -> Option<(
|
|
tokio::sync::oneshot::Receiver<()>,
|
|
tokio::sync::oneshot::Sender<()>,
|
|
)> {
|
|
let (hit_tx, hit_rx) = tokio::sync::oneshot::channel();
|
|
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
|
*self.store_gate.lock().ok()? = Some((hit_tx, release_rx));
|
|
Some((hit_rx, release_tx))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
|
|
use openshell_core::proto::{CredentialHandle, Provider};
|
|
use tonic::Code;
|
|
|
|
use super::*;
|
|
|
|
fn provider_with_handle(driver: &str, handle: &str) -> Provider {
|
|
Provider {
|
|
metadata: Some(openshell_core::proto::ObjectMeta {
|
|
name: "openai-local".to_string(),
|
|
..Default::default()
|
|
}),
|
|
credential_handles: HashMap::from([(
|
|
"OPENAI_API_KEY".to_string(),
|
|
CredentialHandle {
|
|
driver: driver.to_string(),
|
|
handle: handle.to_string(),
|
|
metadata: HashMap::new(),
|
|
},
|
|
)]),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn config_file(toml: &str) -> crate::config_file::ConfigFile {
|
|
toml::from_str(toml).expect("config file TOML")
|
|
}
|
|
|
|
fn driver_table(toml: &str) -> toml::Value {
|
|
toml::from_str(toml).expect("driver table TOML")
|
|
}
|
|
|
|
fn test_absolute_path(file_name: &str) -> PathBuf {
|
|
std::env::temp_dir().join(file_name)
|
|
}
|
|
|
|
fn toml_path(path: &Path) -> String {
|
|
toml::Value::String(path.display().to_string()).to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_credential_driver_kind_resolves_known_names() {
|
|
assert_eq!(
|
|
BuiltinCredentialDriverKind::from_name("kubernetes-secrets"),
|
|
Some(BuiltinCredentialDriverKind::KubernetesSecrets)
|
|
);
|
|
assert_eq!(
|
|
BuiltinCredentialDriverKind::from_name("openshell-gateway"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
BuiltinCredentialDriverKind::from_name("vault"),
|
|
Some(BuiltinCredentialDriverKind::Vault)
|
|
);
|
|
assert_eq!(
|
|
BuiltinCredentialDriverKind::from_name("enterprise-secrets"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn registry_defaults_to_internal_credential_storage() {
|
|
let registry = CredentialDriverRegistry::from_config(&Config::new(None)).unwrap();
|
|
|
|
assert_eq!(
|
|
registry.storage_owner_name().as_str(),
|
|
DbCredstoreCredentialDriver::NAME
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn registry_allows_legacy_inline_credentials_with_default_driver() {
|
|
let registry = CredentialDriverRegistry::from_config(&Config::new(None)).unwrap();
|
|
|
|
registry
|
|
.validate_provider_handles(&Provider::default())
|
|
.expect("legacy inline provider should not require credential handles");
|
|
}
|
|
|
|
#[test]
|
|
fn registry_rejects_default_driver_without_external_driver() {
|
|
let config = Config::new(None).with_default_credential_driver(Some("vault"));
|
|
|
|
let err = CredentialDriverRegistry::from_config(&config).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("default_credential_driver"));
|
|
assert!(err.to_string().contains("requires credential_drivers"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_rejects_empty_handle_driver() {
|
|
let config = Config::new(None).with_credential_drivers(["test-static"]);
|
|
let registry = CredentialDriverRegistry::from_config(&config).unwrap();
|
|
|
|
let err = registry
|
|
.validate_provider_handles(&provider_with_handle("", "openai/API_KEY"))
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("missing driver"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_rejects_empty_handle_value() {
|
|
let config = Config::new(None).with_credential_drivers(["test-static"]);
|
|
let registry = CredentialDriverRegistry::from_config(&config).unwrap();
|
|
|
|
let err = registry
|
|
.validate_provider_handles(&provider_with_handle("test-static", ""))
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("missing handle"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_rejects_unknown_handle_driver() {
|
|
let config = Config::new(None).with_credential_drivers(["test-static"]);
|
|
let registry = CredentialDriverRegistry::from_config(&config).unwrap();
|
|
|
|
let err = registry
|
|
.validate_provider_handles(&provider_with_handle("vault", "openai/API_KEY"))
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("not enabled"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_rejects_default_driver_not_enabled() {
|
|
let config = Config::new(None)
|
|
.with_credential_drivers(["test-static"])
|
|
.with_default_credential_driver(Some("vault"));
|
|
|
|
let err = CredentialDriverRegistry::from_config(&config).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("default_credential_driver"));
|
|
assert!(err.to_string().contains("not listed"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_rejects_multiple_enabled_drivers() {
|
|
let config = Config::new(None).with_credential_drivers(["test-static", "vault"]);
|
|
|
|
let err = CredentialDriverRegistry::from_config(&config).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("at most one"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_stores_and_resolves_test_static_handles() {
|
|
let config = Config::new(None)
|
|
.with_credential_drivers(["test-static"])
|
|
.with_default_credential_driver(Some("test-static"));
|
|
let runtime = CredentialRuntime::from_config(&config).unwrap();
|
|
let stored = runtime
|
|
.store_provider_credentials(
|
|
"openai-local",
|
|
"test-workspace",
|
|
"test-provider-id",
|
|
&HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut provider = Provider {
|
|
metadata: Some(openshell_core::proto::ObjectMeta {
|
|
name: "openai-local".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
provider.credential_handles = stored;
|
|
|
|
let resolved = runtime
|
|
.resolve_provider_handles(&provider, 1_000)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
resolved.values.get("OPENAI_API_KEY").map(String::as_str),
|
|
Some("sk-test")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_overwrites_existing_test_static_handle() {
|
|
let config = Config::new(None).with_credential_drivers(["test-static"]);
|
|
let runtime = CredentialRuntime::from_config(&config).unwrap();
|
|
let first = runtime
|
|
.store_provider_credentials(
|
|
"openai-local",
|
|
"test-workspace",
|
|
"test-provider-id",
|
|
&HashMap::from([("OPENAI_API_KEY".to_string(), "sk-first".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let second = runtime
|
|
.store_provider_credentials(
|
|
"openai-local",
|
|
"test-workspace",
|
|
"test-provider-id",
|
|
&HashMap::from([("OPENAI_API_KEY".to_string(), "sk-second".to_string())]),
|
|
&first,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
first.get("OPENAI_API_KEY").unwrap().handle,
|
|
second.get("OPENAI_API_KEY").unwrap().handle
|
|
);
|
|
|
|
let provider = Provider {
|
|
metadata: Some(openshell_core::proto::ObjectMeta {
|
|
name: "openai-local".to_string(),
|
|
..Default::default()
|
|
}),
|
|
credential_handles: second,
|
|
..Default::default()
|
|
};
|
|
|
|
let resolved = runtime
|
|
.resolve_provider_handles(&provider, 1_000)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
resolved.values.get("OPENAI_API_KEY").map(String::as_str),
|
|
Some("sk-second")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_deletes_stored_test_static_handle() {
|
|
let config = Config::new(None).with_credential_drivers(["test-static"]);
|
|
let runtime = CredentialRuntime::from_config(&config).unwrap();
|
|
let stored = runtime
|
|
.store_provider_credentials(
|
|
"openai-local",
|
|
"test-workspace",
|
|
"test-provider-id",
|
|
&HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
runtime
|
|
.delete_provider_credential_handles(
|
|
"openai-local",
|
|
"test-workspace",
|
|
"test-provider-id",
|
|
&stored,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let provider = Provider {
|
|
metadata: Some(openshell_core::proto::ObjectMeta {
|
|
name: "openai-local".to_string(),
|
|
..Default::default()
|
|
}),
|
|
credential_handles: stored,
|
|
..Default::default()
|
|
};
|
|
let err = runtime
|
|
.resolve_provider_handles(&provider, 1_000)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::NotFound);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_uses_configured_in_tree_driver_table() {
|
|
let config = Config::new(None).with_credential_drivers(["test-static"]);
|
|
let file = config_file(
|
|
r#"
|
|
[openshell.credential_drivers.test-static]
|
|
transport = "in_tree"
|
|
backend_specific = "ignored-by-gateway"
|
|
"#,
|
|
);
|
|
let runtime = CredentialRuntime::from_config_file(&config, Some(&file))
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = runtime
|
|
.store_provider_credentials(
|
|
"openai-local",
|
|
"test-workspace",
|
|
"test-provider-id",
|
|
&HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
stored
|
|
.get("OPENAI_API_KEY")
|
|
.map(|handle| handle.driver.as_str()),
|
|
Some("test-static")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_uses_configured_vault_in_tree_driver_table() {
|
|
let token_file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(token_file.path(), "dev-token").unwrap();
|
|
let config = Config::new(None).with_credential_drivers(["vault"]);
|
|
let token_path = toml_path(token_file.path());
|
|
let file = config_file(&format!(
|
|
r#"
|
|
[openshell.credential_drivers.vault]
|
|
transport = "in_tree"
|
|
address = "http://127.0.0.1:8200"
|
|
auth_method = "token_file"
|
|
token_path = {token_path}
|
|
"#,
|
|
));
|
|
let runtime = CredentialRuntime::from_config_file(&config, Some(&file))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(runtime.stores_provider_credentials());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_uses_configured_default_credential_storage() {
|
|
let storage = tempfile::tempdir().unwrap();
|
|
let key_encryption_key_path = storage.path().join("key-encryption-key.bin");
|
|
let store = Arc::new(crate::persistence::test_store().await);
|
|
let config = Config::new(None);
|
|
let key_encryption_key_path_toml = toml_path(&key_encryption_key_path);
|
|
let file = config_file(&format!(
|
|
r"
|
|
[openshell.gateway.credential_storage]
|
|
key_encryption_key_path = {key_encryption_key_path_toml}
|
|
",
|
|
));
|
|
let runtime = CredentialRuntime::from_config_file_with_store(
|
|
&config,
|
|
Some(&file),
|
|
Arc::clone(&store),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = runtime
|
|
.store_provider_credentials(
|
|
"openai-local",
|
|
"test-workspace",
|
|
"test-provider-id",
|
|
&HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let handle_id = stored
|
|
.get("OPENAI_API_KEY")
|
|
.and_then(|handle| handle.handle.strip_prefix("v1:"))
|
|
.expect("stored db credstore handle id");
|
|
let credential_record = store
|
|
.get(DbCredstoreCredentialDriver::OBJECT_TYPE, handle_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("encrypted credential object");
|
|
assert!(
|
|
!String::from_utf8_lossy(&credential_record.payload).contains("sk-test"),
|
|
"credential object payload must not contain plaintext credentials"
|
|
);
|
|
|
|
let provider = Provider {
|
|
metadata: Some(openshell_core::proto::ObjectMeta {
|
|
name: "openai-local".to_string(),
|
|
..Default::default()
|
|
}),
|
|
credential_handles: stored,
|
|
..Default::default()
|
|
};
|
|
|
|
let resolved = runtime
|
|
.resolve_provider_handles(&provider, 1_000)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
resolved.values.get("OPENAI_API_KEY").map(String::as_str),
|
|
Some("sk-test")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_rejects_in_tree_table_without_builtin_driver() {
|
|
let config = Config::new(None).with_credential_drivers(["enterprise-secrets"]);
|
|
let file = config_file(
|
|
r#"
|
|
[openshell.credential_drivers.enterprise-secrets]
|
|
transport = "in_tree"
|
|
"#,
|
|
);
|
|
|
|
let err = CredentialRuntime::from_config_file(&config, Some(&file))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("no in-tree implementation"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_rejects_unknown_driver_without_driver_table() {
|
|
let config = Config::new(None).with_credential_drivers(["enterprise-secrets"]);
|
|
|
|
let err = CredentialRuntime::from_config_file(&config, None)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("not a built-in credential driver"));
|
|
assert!(err.to_string().contains("transport = 'uds'"));
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_from_config_rejects_unknown_driver_name() {
|
|
let config = Config::new(None).with_credential_drivers(["enterprise-secrets"]);
|
|
|
|
let err = CredentialRuntime::from_config(&config).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("not a built-in credential driver"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_rejects_uds_table_without_socket_path() {
|
|
let config = Config::new(None).with_credential_drivers(["vault"]);
|
|
let file = config_file(
|
|
r#"
|
|
[openshell.credential_drivers.vault]
|
|
transport = "uds"
|
|
"#,
|
|
);
|
|
|
|
let err = CredentialRuntime::from_config_file(&config, Some(&file))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("socket_path is required"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_rejects_relative_uds_socket_path() {
|
|
let config = Config::new(None).with_credential_drivers(["vault"]);
|
|
let file = config_file(
|
|
r#"
|
|
[openshell.credential_drivers.vault]
|
|
transport = "uds"
|
|
socket_path = "vault.sock"
|
|
"#,
|
|
);
|
|
|
|
let err = CredentialRuntime::from_config_file(&config, Some(&file))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("socket_path must be absolute"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_rejects_unknown_transport() {
|
|
let config = Config::new(None).with_credential_drivers(["vault"]);
|
|
let file = config_file(
|
|
r#"
|
|
[openshell.credential_drivers.vault]
|
|
transport = "tcp"
|
|
"#,
|
|
);
|
|
|
|
let err = CredentialRuntime::from_config_file(&config, Some(&file))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("transport must be"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uds_driver_launch_settings() {
|
|
let socket_path = test_absolute_path("openshell-enterprise-secrets.sock");
|
|
let command = test_absolute_path("openshell-credential-driver-enterprise-secrets");
|
|
let socket_path_toml = toml_path(&socket_path);
|
|
let command_toml = toml_path(&command);
|
|
let parsed = parse_driver_table(
|
|
"enterprise-secrets",
|
|
&driver_table(&format!(
|
|
r#"
|
|
transport = "uds"
|
|
socket_path = {socket_path_toml}
|
|
command = {command_toml}
|
|
args = ["--profile", "dev"]
|
|
startup_timeout_secs = 3
|
|
"#,
|
|
)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(parsed.transport, CredentialDriverTransport::Uds);
|
|
assert_eq!(parsed.socket_path.as_deref(), Some(socket_path.as_path()));
|
|
assert_eq!(parsed.command.as_deref(), Some(command.as_path()));
|
|
assert_eq!(parsed.args, ["--profile", "dev"]);
|
|
assert_eq!(parsed.startup_timeout_secs, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uds_driver_defaults_to_connect_only() {
|
|
let socket_path = test_absolute_path("openshell-enterprise-secrets.sock");
|
|
let socket_path_toml = toml_path(&socket_path);
|
|
let parsed = parse_driver_table(
|
|
"enterprise-secrets",
|
|
&driver_table(&format!(
|
|
r#"
|
|
transport = "uds"
|
|
socket_path = {socket_path_toml}
|
|
"#,
|
|
)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(parsed.transport, CredentialDriverTransport::Uds);
|
|
assert!(parsed.command.is_none());
|
|
assert!(parsed.args.is_empty());
|
|
assert_eq!(
|
|
parsed.startup_timeout_secs,
|
|
DEFAULT_CREDENTIAL_DRIVER_STARTUP_TIMEOUT_SECS
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn get_capabilities_has_a_local_timeout() {
|
|
let response = std::future::pending::<
|
|
Result<tonic::Response<GetCredentialDriverCapabilitiesResponse>, Status>,
|
|
>();
|
|
|
|
let err = await_credential_driver_capabilities(
|
|
"enterprise-secrets",
|
|
Duration::from_millis(10),
|
|
response,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("GetCapabilities timed out"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_driver_table_preserves_backend_config_without_transport_fields() {
|
|
let parsed = parse_driver_table(
|
|
"kubernetes-secrets",
|
|
&driver_table(
|
|
r#"
|
|
transport = "in_tree"
|
|
namespace = "openshell"
|
|
allow_reference_namespace = true
|
|
"#,
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
parsed
|
|
.backend_config
|
|
.get("namespace")
|
|
.and_then(toml::Value::as_str),
|
|
Some("openshell")
|
|
);
|
|
assert_eq!(
|
|
parsed
|
|
.backend_config
|
|
.get("allow_reference_namespace")
|
|
.and_then(toml::Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert!(!parsed.backend_config.contains_key("transport"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uds_driver_rejects_relative_command() {
|
|
let socket_path_toml = toml_path(&test_absolute_path("openshell-enterprise-secrets.sock"));
|
|
let err = parse_driver_table(
|
|
"enterprise-secrets",
|
|
&driver_table(&format!(
|
|
r#"
|
|
transport = "uds"
|
|
socket_path = {socket_path_toml}
|
|
command = "openshell-credential-driver-enterprise-secrets"
|
|
"#,
|
|
)),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("command must be absolute"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uds_driver_rejects_args_without_command() {
|
|
let socket_path_toml = toml_path(&test_absolute_path("openshell-enterprise-secrets.sock"));
|
|
let err = parse_driver_table(
|
|
"enterprise-secrets",
|
|
&driver_table(&format!(
|
|
r#"
|
|
transport = "uds"
|
|
socket_path = {socket_path_toml}
|
|
args = ["--profile", "dev"]
|
|
"#,
|
|
)),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("args requires command"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uds_driver_rejects_timeout_without_command() {
|
|
let socket_path_toml = toml_path(&test_absolute_path("openshell-enterprise-secrets.sock"));
|
|
let err = parse_driver_table(
|
|
"enterprise-secrets",
|
|
&driver_table(&format!(
|
|
r#"
|
|
transport = "uds"
|
|
socket_path = {socket_path_toml}
|
|
startup_timeout_secs = 3
|
|
"#,
|
|
)),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("startup_timeout_secs requires command")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_in_tree_driver_rejects_launch_settings() {
|
|
let err = parse_driver_table(
|
|
"test-static",
|
|
&driver_table(
|
|
r#"
|
|
transport = "in_tree"
|
|
command = "/usr/local/libexec/openshell-credential-driver-test"
|
|
"#,
|
|
),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("command, args, and startup_timeout_secs require transport = 'uds'")
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn remove_stale_launched_driver_socket_removes_socket() {
|
|
use std::os::unix::net::UnixListener as StdUnixListener;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let socket_path = dir.path().join("driver.sock");
|
|
let listener = StdUnixListener::bind(&socket_path).unwrap();
|
|
|
|
remove_stale_launched_driver_socket("enterprise-secrets", &socket_path).unwrap();
|
|
|
|
drop(listener);
|
|
assert!(!socket_path.exists());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn remove_stale_launched_driver_socket_rejects_regular_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let socket_path = dir.path().join("driver.sock");
|
|
std::fs::write(&socket_path, "not a socket").unwrap();
|
|
|
|
let err =
|
|
remove_stale_launched_driver_socket("enterprise-secrets", &socket_path).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("not a Unix socket"));
|
|
assert!(socket_path.exists());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn remove_stale_launched_driver_socket_rejects_symlink() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let target = dir.path().join("target.sock");
|
|
let socket_path = dir.path().join("driver.sock");
|
|
std::os::unix::fs::symlink(&target, &socket_path).unwrap();
|
|
|
|
let err =
|
|
remove_stale_launched_driver_socket("enterprise-secrets", &socket_path).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("is a symlink"));
|
|
assert!(std::fs::symlink_metadata(&socket_path).is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_rejects_unconnected_enabled_driver_on_resolution() {
|
|
let config = Config::new(None).with_credential_drivers(["vault"]);
|
|
let runtime = CredentialRuntime::from_config(&config).unwrap();
|
|
|
|
let err = runtime
|
|
.resolve_provider_handles(&provider_with_handle("vault", "v1:providers/openai"), 1_000)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::FailedPrecondition);
|
|
assert!(err.message().contains("not connected"));
|
|
}
|
|
}
|