wasmcloud_host/nats/secrets.rs
1//! Module with structs for use in managing and accessing secrets in a wasmCloud lattice
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use anyhow::{bail, ensure, Context as _};
6use async_nats::Client;
7use futures::stream;
8use futures::stream::{StreamExt, TryStreamExt};
9use secrecy::SecretBox;
10use tokio::sync::RwLock;
11use tracing::instrument;
12use wasmcloud_runtime::capability::secrets::store::SecretValue;
13use wasmcloud_secrets_client::Client as WasmcloudSecretsClient;
14use wasmcloud_secrets_types::{Secret as WasmcloudSecret, SecretConfig};
15
16use crate::secrets::SecretsManager;
17use crate::store::StoreManager;
18
19/// A manager for fetching secrets from a secret store, caching secrets clients for efficiency.
20pub struct NatsSecretsManager {
21 config_store: Arc<dyn StoreManager>,
22 /// The topic to use for configuring clients to fetch secrets from the secret store.
23 secret_store_topic: Option<String>,
24 nats_client: Client,
25 /// A map of backend names, e.g. nats-kv or vault, to secrets clients, used to cache clients for efficiency.
26 backend_clients: Arc<RwLock<HashMap<String, Arc<WasmcloudSecretsClient>>>>,
27}
28
29impl NatsSecretsManager {
30 /// Create a new secret manager with the given configuration store, secret store topic, and NATS client.
31 ///
32 /// All secret references will be fetched from this configuration store and the actual secrets will be
33 /// fetched by sending requests to the configured topic. If the provided secret_store_topic is None, this manager
34 /// will always return an error if [`Self::fetch_secrets`] is called with a list of secrets.
35 pub fn new(
36 config_store: Arc<dyn StoreManager>,
37 secret_store_topic: Option<&String>,
38 nats_client: &Client,
39 ) -> Self {
40 Self {
41 config_store,
42 secret_store_topic: secret_store_topic.cloned(),
43 nats_client: nats_client.clone(),
44 backend_clients: Arc::new(RwLock::new(HashMap::new())),
45 }
46 }
47
48 /// Get the secrets client for the provided backend, creating a new client if one does not already exist.
49 ///
50 /// Returns an error if the secret store topic is not configured, or if the client could not be created.
51 async fn get_or_create_secrets_client(
52 &self,
53 backend: &str,
54 ) -> anyhow::Result<Arc<WasmcloudSecretsClient>> {
55 let Some(secret_store_topic) = self.secret_store_topic.as_ref() else {
56 return Err(anyhow::anyhow!(
57 "secret store not configured, could not create secrets client"
58 ));
59 };
60
61 // If we already have a client for this backend, return it
62 // NOTE(brooksmtownsend): This is block scoped to ensure we drop the read lock
63 let client = {
64 match self.backend_clients.read().await.get(backend) {
65 Some(existing) => return Ok(existing.clone()),
66 None => Arc::new(
67 WasmcloudSecretsClient::new(
68 backend,
69 secret_store_topic,
70 self.nats_client.clone(),
71 )
72 .await
73 .context("failed to create secrets client")?,
74 ),
75 }
76 };
77
78 self.backend_clients
79 .write()
80 .await
81 .insert(backend.to_string(), client.clone());
82 Ok(client)
83 }
84}
85
86#[async_trait::async_trait]
87impl SecretsManager for NatsSecretsManager {
88 /// Fetches secret references from the CONFIGDATA bucket by name and then fetches the actual secrets
89 /// from the configured secret store. Any error returned from this function should result in a failure
90 /// to start a component, start a provider, or establish a link as a missing secret is a critical
91 /// error.
92 ///
93 /// # Arguments
94 /// * `secret_names` - A list of secret names to fetch from the secret store
95 /// * `entity_jwt` - The JWT of the entity requesting the secrets. Must be provided unless this [SecretsManager] is not
96 /// configured with a secret store topic.
97 /// * `host_jwt` - The JWT of the host requesting the secrets
98 /// * `application` - The name of the application the entity is a part of, if any
99 ///
100 /// # Returns
101 /// A HashMap from secret name to the [SecretBox] wrapped [SecretValue].
102 #[instrument(level = "debug", skip(self, host_jwt))]
103 async fn fetch_secrets(
104 &self,
105 secret_names: Vec<String>,
106 entity_jwt: Option<&String>,
107 host_jwt: &str,
108 application: Option<&String>,
109 ) -> anyhow::Result<HashMap<String, SecretBox<SecretValue>>> {
110 // If we're not fetching any secrets, return empty map successfully
111 if secret_names.is_empty() {
112 return Ok(HashMap::with_capacity(0));
113 }
114
115 // Attempting to fetch secrets without a secret store topic is always an error
116 ensure!(
117 self.secret_store_topic.is_some(),
118 "secret store not configured, could not fetch secrets"
119 );
120
121 // If we don't have an entity JWT, we can't provide its identity to the secrets backend
122 let entity_jwt = entity_jwt.context("entity did not have an embedded JWT, required to fetch secrets (was this entity signed during build?)")?;
123
124 let secrets = stream::iter(secret_names.into_iter())
125 // Fetch the secret reference from the config store
126 .then(|secret_name| async move {
127 match self.config_store.get(&secret_name).await {
128 Ok(Some(secret)) => serde_json::from_slice::<SecretConfig>(&secret)
129 .with_context(|| format!("failed to deserialize secret reference from config store, ensure {secret_name} is a secret reference and not configuration")),
130 Ok(None) => bail!(
131 "Secret config {secret_name} not found in config store, could not create secret request"
132 ),
133 Err(e) => bail!(e),
134 }
135 })
136 // Retrieve the actual secret from the secrets backend
137 .and_then(|secret_config| async move {
138 let secrets_client = self
139 .get_or_create_secrets_client(&secret_config.backend)
140 .await?;
141 let secret_name = secret_config.name.clone();
142 let request = secret_config.try_into_request(entity_jwt, host_jwt, application).context("failed to create secret request")?;
143 secrets_client
144 .get(request, nkeys::XKey::new())
145 .await
146 .map(|secret| (secret_name, secret))
147 .map_err(|e| anyhow::anyhow!(e))
148 })
149 // Build the map of secrets depending on if the secret is a string or bytes
150 .try_fold(HashMap::new(), |mut secrets, (secret_name, secret_result)| async move {
151 match secret_result {
152 // NOTE(brooksmtownsend): We create this map using the `secret_name` passed in on from the secret reference
153 // because that's the name that the component/provider will use to look up the secret.
154 WasmcloudSecret {
155 string_secret: Some(string_secret),
156 ..
157 } => secrets.insert(
158 secret_name,
159 SecretBox::new(SecretValue::String(string_secret).into()),
160 ),
161 WasmcloudSecret {
162 binary_secret: Some(binary_secret),
163 ..
164 } => {
165 secrets.insert(
166 secret_name,
167 SecretBox::new(SecretValue::Bytes(binary_secret).into()),
168 )
169 }
170 WasmcloudSecret {
171 string_secret: None,
172 binary_secret: None,
173 ..
174 } => bail!("secret {secret_name} did not contain a value"),
175 };
176 Ok(secrets)
177 })
178 .await?;
179
180 Ok(secrets)
181 }
182}