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