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}