use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Map as JsonMap, Value as JsonValue};
use crate::secrets::{SecretId, SecretProvider};
use super::{ClientError, ConnectorClient, ProviderId};
const SECRETS_ARG_KEY: &str = "secrets";
fn arg_key_for(id: &SecretId) -> String {
id.name.replace('-', "_")
}
struct SecretInjectingClient {
inner: Arc<dyn ConnectorClient>,
secrets: Arc<dyn SecretProvider>,
declared: Vec<SecretId>,
}
impl SecretInjectingClient {
async fn resolve_args(&self, args: JsonValue) -> JsonValue {
let mut args = match args {
JsonValue::Object(map) => map,
JsonValue::Null => JsonMap::new(),
other => return other,
};
let mut secrets = match args.get(SECRETS_ARG_KEY) {
Some(JsonValue::Object(existing)) => existing.clone(),
_ => JsonMap::new(),
};
for id in &self.declared {
let key = arg_key_for(id);
if secrets.contains_key(&key) {
continue;
}
let Ok(secret) = self.secrets.get(id).await else {
continue;
};
let rendered =
secret.with_exposed(|bytes| std::str::from_utf8(bytes).map(str::to_string));
if let Ok(value) = rendered {
secrets.insert(key, JsonValue::String(value));
}
}
if !secrets.is_empty() {
args.insert(SECRETS_ARG_KEY.to_string(), JsonValue::Object(secrets));
}
JsonValue::Object(args)
}
}
#[async_trait]
impl ConnectorClient for SecretInjectingClient {
async fn call(&self, method: &str, args: JsonValue) -> Result<JsonValue, ClientError> {
let args = self.resolve_args(args).await;
self.inner.call(method, args).await
}
}
pub(super) fn with_declared_secrets(
client: Arc<dyn ConnectorClient>,
declared: &[SecretId],
secrets: Option<&Arc<dyn SecretProvider>>,
) -> Arc<dyn ConnectorClient> {
let (Some(secrets), false) = (secrets, declared.is_empty()) else {
return client;
};
Arc::new(SecretInjectingClient {
inner: client,
secrets: Arc::clone(secrets),
declared: declared.to_vec(),
})
}
pub fn declared_secret_ids<'a>(raw: impl IntoIterator<Item = &'a str>) -> Vec<SecretId> {
raw.into_iter()
.filter_map(|entry| crate::secrets::parse_secret_id(entry).ok())
.collect()
}
pub type DeclaredConnectorSecrets = BTreeMap<ProviderId, Vec<SecretId>>;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Mutex;
use crate::secrets::MemorySecretProvider;
struct RecordingClient {
seen: Mutex<Vec<JsonValue>>,
}
#[async_trait]
impl ConnectorClient for RecordingClient {
async fn call(&self, _method: &str, args: JsonValue) -> Result<JsonValue, ClientError> {
self.seen.lock().expect("seen poisoned").push(args.clone());
Ok(args)
}
}
fn store_with(entries: &[(&str, &str)]) -> Arc<dyn SecretProvider> {
let mut provider = MemorySecretProvider::new("test-store");
for (id, value) in entries {
let id = crate::secrets::parse_secret_id(id).expect("test secret id parses");
provider.insert(id, value);
}
Arc::new(provider)
}
#[tokio::test]
async fn declared_secrets_reach_the_connector_without_args_or_env() {
let store = store_with(&[("gitlab/access-token", "stored-token")]);
let client = with_declared_secrets(
Arc::new(RecordingClient {
seen: Mutex::new(Vec::new()),
}),
&declared_secret_ids(["gitlab/access-token"]),
Some(&store),
);
let seen = client
.call("graphql", json!({"query": "{ me { id } }"}))
.await
.expect("dispatch succeeds");
assert_eq!(
seen.pointer("/secrets/access_token"),
Some(&JsonValue::String("stored-token".to_string())),
"the connector must receive its declared credential as args.secrets.access_token"
);
}
#[tokio::test]
async fn explicit_args_win_over_the_store() {
let store = store_with(&[("gitlab/access-token", "stored-token")]);
let client = with_declared_secrets(
Arc::new(RecordingClient {
seen: Mutex::new(Vec::new()),
}),
&declared_secret_ids(["gitlab/access-token"]),
Some(&store),
);
let seen = client
.call(
"graphql",
json!({"secrets": {"access_token": "caller-token"}}),
)
.await
.expect("dispatch succeeds");
assert_eq!(
seen.pointer("/secrets/access_token"),
Some(&JsonValue::String("caller-token".to_string())),
"an explicitly passed credential must override the stored one"
);
}
#[tokio::test]
async fn a_declared_secret_the_store_lacks_does_not_fail_dispatch() {
let store = store_with(&[("gitlab/access-token", "stored-token")]);
let client = with_declared_secrets(
Arc::new(RecordingClient {
seen: Mutex::new(Vec::new()),
}),
&declared_secret_ids(["gitlab/access-token", "gitlab/webhook-secret"]),
Some(&store),
);
let seen = client
.call("graphql", JsonValue::Null)
.await
.expect("dispatch succeeds despite the missing declared secret");
assert_eq!(
seen.pointer("/secrets/access_token"),
Some(&JsonValue::String("stored-token".to_string()))
);
assert_eq!(seen.pointer("/secrets/webhook_secret"), None);
}
#[test]
fn a_connector_declaring_no_secrets_keeps_a_bare_client() {
let bare: Arc<dyn ConnectorClient> = Arc::new(RecordingClient {
seen: Mutex::new(Vec::new()),
});
let wrapped = with_declared_secrets(Arc::clone(&bare), &[], None);
assert!(Arc::ptr_eq(&bare, &wrapped));
}
#[test]
fn manifest_secret_names_become_connector_arg_keys() {
let ids = declared_secret_ids(["gitlab/access-token", "circleci/api-token", "malformed"]);
let keys: Vec<String> = ids.iter().map(arg_key_for).collect();
assert_eq!(keys, vec!["access_token", "api_token"]);
}
}