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}