use crate::fleet_secrets::{
decrypt_api_key, encrypt_api_key, EncryptedPayload, FleetSecretsError, MasterKey,
ProviderOverride,
};
use ares_types::types::{AppError, Result};
use sqlx::{PgPool, Row};
use std::collections::HashMap;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct StoredProviderOverride {
pub provider_name: String,
pub ciphertext: Option<Vec<u8>>,
pub nonce: Option<Vec<u8>>,
pub api_base: Option<String>,
pub default_model: Option<String>,
pub fallback_providers: Vec<String>,
pub has_api_key: bool,
pub updated_at: i64,
pub updated_by: String,
}
pub struct FleetProviderSecretsStore<'a> {
pool: &'a PgPool,
}
impl<'a> FleetProviderSecretsStore<'a> {
pub fn new(pool: &'a PgPool) -> Self {
Self { pool }
}
pub async fn load_all(
&self,
master: Option<&MasterKey>,
) -> Result<HashMap<String, ProviderOverride>> {
let rows = sqlx::query(
"SELECT provider_name, ciphertext, nonce, api_base, default_model, \
fallback_providers, has_api_key, updated_at, updated_by \
FROM fleet_provider_secrets",
)
.fetch_all(self.pool)
.await
.map_err(|e| AppError::Database(e.to_string()))?;
let mut out = HashMap::with_capacity(rows.len());
for row in rows {
let stored = row_to_stored(&row)?;
let mut entry = ProviderOverride {
api_key: None,
api_base: stored.api_base.clone(),
default_model: stored.default_model.clone(),
fallback_providers: stored.fallback_providers.clone(),
updated_at: stored.updated_at,
updated_by: stored.updated_by.clone(),
..Default::default()
};
if stored.has_api_key {
let Some(master) = master else {
warn!(
provider = %stored.provider_name,
"FLEET_SECRETS_KEY is unset; cannot decrypt stored key. Skipping."
);
continue;
};
let (Some(ciphertext), Some(nonce)) = (stored.ciphertext, stored.nonce) else {
warn!(
provider = %stored.provider_name,
"Row has has_api_key=true but missing ciphertext/nonce; skipping."
);
continue;
};
let payload = EncryptedPayload { nonce, ciphertext };
match decrypt_api_key(&payload, master) {
Ok(plain) => entry.api_key = Some(plain),
Err(e) => {
warn!(
provider = %stored.provider_name,
error = %e,
"Failed to decrypt stored API key (probably FLEET_SECRETS_KEY \
changed since the row was written). Row is unreadable until \
the original key is restored. Skipping."
);
continue;
}
}
}
out.insert(stored.provider_name, entry);
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
pub async fn upsert(
&self,
provider_name: &str,
api_key: Option<&str>,
api_base: Option<&str>,
default_model: Option<&str>,
fallback_providers: Option<&[String]>,
master: Option<&MasterKey>,
updated_by: &str,
) -> Result<StoredProviderOverride> {
if provider_name.is_empty() {
return Err(AppError::InvalidInput(
"provider_name must not be empty".into(),
));
}
if api_key.is_none()
&& api_base.is_none()
&& default_model.is_none()
&& fallback_providers.is_none()
{
return Err(AppError::InvalidInput(
"At least one of api_key, api_base, default_model, fallback_providers must be provided".into(),
));
}
let (ciphertext, nonce, has_api_key) = match api_key {
Some(plain) => {
let master = master.as_ref().ok_or(AppError::Configuration(
FleetSecretsError::MasterKeyUnset.to_string(),
))?;
let payload = encrypt_api_key(plain, master)
.map_err(|e| AppError::Configuration(format!("encrypt_api_key failed: {e}")))?;
(Some(payload.ciphertext), Some(payload.nonce), true)
}
None => (None, None, false),
};
let existing = if !has_api_key {
self.fetch_stored(provider_name).await?
} else {
None
};
let final_ciphertext: Option<Vec<u8>>;
let final_nonce: Option<Vec<u8>>;
let final_has_api_key: bool;
if has_api_key {
final_ciphertext = ciphertext;
final_nonce = nonce;
final_has_api_key = true;
} else if let Some(prev) = existing {
final_ciphertext = prev.ciphertext;
final_nonce = prev.nonce;
final_has_api_key = prev.has_api_key;
} else {
final_ciphertext = None;
final_nonce = None;
final_has_api_key = false;
}
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let fallback_json = fallback_providers.and_then(|v| serde_json::to_value(v).ok());
sqlx::query(
"INSERT INTO fleet_provider_secrets \
(provider_name, ciphertext, nonce, api_base, default_model, fallback_providers, has_api_key, updated_at, updated_by) \
VALUES ($1, $2, $3, $4, $5, $6, $7, to_timestamp($8), $9) \
ON CONFLICT (provider_name) DO UPDATE SET \
ciphertext = EXCLUDED.ciphertext, \
nonce = EXCLUDED.nonce, \
api_base = EXCLUDED.api_base, \
default_model = EXCLUDED.default_model, \
fallback_providers = EXCLUDED.fallback_providers, \
has_api_key = EXCLUDED.has_api_key, \
updated_at = EXCLUDED.updated_at, \
updated_by = EXCLUDED.updated_by",
)
.bind(provider_name)
.bind(&final_ciphertext)
.bind(&final_nonce)
.bind(api_base)
.bind(default_model)
.bind(&fallback_json)
.bind(final_has_api_key)
.bind(now_secs as f64)
.bind(updated_by)
.execute(self.pool)
.await
.map_err(|e| AppError::Database(e.to_string()))?;
self.fetch_stored(provider_name)
.await?
.ok_or_else(|| AppError::Database("upsert succeeded but row not found".into()))
}
pub async fn delete(&self, provider_name: &str) -> Result<u64> {
let res = sqlx::query("DELETE FROM fleet_provider_secrets WHERE provider_name = $1")
.bind(provider_name)
.execute(self.pool)
.await
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(res.rows_affected())
}
pub async fn fetch_stored(
&self,
provider_name: &str,
) -> Result<Option<StoredProviderOverride>> {
let row = sqlx::query(
"SELECT provider_name, ciphertext, nonce, api_base, default_model, \
fallback_providers, has_api_key, updated_at, updated_by \
FROM fleet_provider_secrets WHERE provider_name = $1",
)
.bind(provider_name)
.fetch_optional(self.pool)
.await
.map_err(|e| AppError::Database(e.to_string()))?;
row.map(|r| row_to_stored(&r)).transpose()
}
pub async fn list_metadata(
&self,
) -> Result<
Vec<(
String,
bool,
Option<String>,
Option<String>,
Vec<String>,
i64,
String,
)>,
> {
let rows = sqlx::query(
"SELECT provider_name, has_api_key, api_base, default_model, fallback_providers, updated_at, updated_by \
FROM fleet_provider_secrets ORDER BY provider_name",
)
.fetch_all(self.pool)
.await
.map_err(|e| AppError::Database(e.to_string()))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let name: String = row.try_get("provider_name").map_err(sqlx_err)?;
let has: bool = row.try_get("has_api_key").map_err(sqlx_err)?;
let api_base: Option<String> = row.try_get("api_base").map_err(sqlx_err)?;
let default_model: Option<String> = row.try_get("default_model").map_err(sqlx_err)?;
let fallback_json: Option<serde_json::Value> =
row.try_get("fallback_providers").map_err(sqlx_err)?;
let fallback_providers: Vec<String> = fallback_json
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
let updated_at: chrono::DateTime<chrono::Utc> =
row.try_get("updated_at").map_err(sqlx_err)?;
let updated_by: String = row.try_get("updated_by").map_err(sqlx_err)?;
out.push((
name,
has,
api_base,
default_model,
fallback_providers,
updated_at.timestamp(),
updated_by,
));
}
Ok(out)
}
}
fn row_to_stored(row: &sqlx::postgres::PgRow) -> Result<StoredProviderOverride> {
let provider_name: String = row.try_get("provider_name").map_err(sqlx_err)?;
let ciphertext: Option<Vec<u8>> = row.try_get("ciphertext").map_err(sqlx_err)?;
let nonce: Option<Vec<u8>> = row.try_get("nonce").map_err(sqlx_err)?;
let api_base: Option<String> = row.try_get("api_base").map_err(sqlx_err)?;
let default_model: Option<String> = row.try_get("default_model").map_err(sqlx_err)?;
let fallback_json: Option<serde_json::Value> =
row.try_get("fallback_providers").map_err(sqlx_err)?;
let fallback_providers: Vec<String> = fallback_json
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
let has_api_key: bool = row.try_get("has_api_key").map_err(sqlx_err)?;
let updated_at: chrono::DateTime<chrono::Utc> = row.try_get("updated_at").map_err(sqlx_err)?;
let updated_by: String = row.try_get("updated_by").map_err(sqlx_err)?;
Ok(StoredProviderOverride {
provider_name,
ciphertext,
nonce,
api_base,
default_model,
fallback_providers,
has_api_key,
updated_at: updated_at.timestamp(),
updated_by,
})
}
fn sqlx_err(e: sqlx::Error) -> AppError {
AppError::Database(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fleet_secrets::{last_n_visible, MasterKey};
#[test]
fn provider_override_carries_all_fields() {
let entry = ProviderOverride {
api_key: Some("nvapi-X".into()),
api_base: Some("https://example.com/v1".into()),
default_model: Some("meta/llama-3.3-70b-instruct".into()),
updated_at: 1,
updated_by: "admin".into(),
..Default::default()
};
assert_eq!(entry.api_key.as_deref(), Some("nvapi-X"));
let truncated = last_n_visible(entry.api_key.as_deref().unwrap(), 4);
assert_eq!(truncated.as_deref(), Some("…pi-X"));
}
#[test]
fn master_key_from_env_handles_missing() {
let prev = std::env::var("FLEET_SECRETS_KEY").ok();
std::env::remove_var("FLEET_SECRETS_KEY");
assert!(MasterKey::from_env().is_none());
if let Some(p) = prev {
std::env::set_var("FLEET_SECRETS_KEY", p);
}
}
}