qcs_api_client_common/configuration/
secrets.rs

1//! Models and utilities for managing QCS secret credentials.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use async_tempfile::TempFile;
7use figment::providers::{Format, Toml};
8use figment::Figment;
9use serde::{Deserialize, Serialize};
10use time::format_description::well_known::Rfc3339;
11use time::{OffsetDateTime, PrimitiveDateTime};
12use tokio::io::AsyncWriteExt;
13use toml_edit::{DocumentMut, Item};
14
15use crate::configuration::LoadError;
16
17use super::error::{IoErrorWithPath, IoOperation, WriteError};
18use super::{expand_path_from_env_or_default, DEFAULT_PROFILE_NAME};
19
20pub use super::secret_string::{SecretAccessToken, SecretRefreshToken};
21
22/// Setting the `QCS_SECRETS_FILE_PATH` environment variable will change which file is used for loading secrets
23pub const SECRETS_PATH_VAR: &str = "QCS_SECRETS_FILE_PATH";
24/// `QCS_SECRETS_READ_ONLY` indicates whether to treat the `secrets.toml` file as read-only. Disabled by default.
25/// * Access token updates will _not_ be persisted to the secrets file, regardless of file permissions, for any of the following values (case insensitive): "true", "yes", "1".  
26/// * Access token updates will be persisted to the secrets file if it is writeable for any other value or if unset.
27pub const SECRETS_READ_ONLY_VAR: &str = "QCS_SECRETS_READ_ONLY";
28/// The default path that [`Secrets`] will be loaded from
29pub const DEFAULT_SECRETS_PATH: &str = "~/.qcs/secrets.toml";
30
31/// The structure of QCS secrets, typically serialized as a TOML file at [`DEFAULT_SECRETS_PATH`].
32#[derive(Deserialize, Debug, PartialEq, Eq, Serialize)]
33pub struct Secrets {
34    /// All named [`Credential`]s defined in the secrets file.
35    #[serde(default = "default_credentials")]
36    pub credentials: HashMap<String, Credential>,
37    /// The path to the secrets file this [`Secrets`] was loaded from,
38    /// if it was loaded from a file. This is not stored in the secrets file itself.
39    #[serde(skip)]
40    pub file_path: Option<PathBuf>,
41}
42
43fn default_credentials() -> HashMap<String, Credential> {
44    HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), Credential::default())])
45}
46
47impl Default for Secrets {
48    fn default() -> Self {
49        Self {
50            credentials: default_credentials(),
51            file_path: None,
52        }
53    }
54}
55
56impl Secrets {
57    /// Load [`Secrets`] from the path specified by the [`SECRETS_PATH_VAR`] environment variable if set,
58    /// or else the default path at [`DEFAULT_SECRETS_PATH`].
59    ///
60    /// # Errors
61    ///
62    /// [`LoadError`] if the secrets file cannot be loaded.
63    pub fn load() -> Result<Self, LoadError> {
64        let path = expand_path_from_env_or_default(SECRETS_PATH_VAR, DEFAULT_SECRETS_PATH)?;
65        #[cfg(feature = "tracing")]
66        tracing::debug!("loading QCS secrets from {path:?}");
67        Self::load_from_path(&path)
68    }
69
70    /// Load [`Secrets`] from the path specified by `path`.
71    ///
72    /// # Errors
73    ///
74    /// [`LoadError`] if the secrets file cannot be loaded.
75    pub fn load_from_path(path: &PathBuf) -> Result<Self, LoadError> {
76        let mut secrets: Self = Figment::from(Toml::file(path)).extract()?;
77        secrets.file_path = Some(path.into());
78        Ok(secrets)
79    }
80
81    /// Returns a bool indicating whether or not the QCS [`Secrets`] file is read-only.
82    ///
83    /// The file is considered read-only if the [`SECRETS_READ_ONLY_VAR`] environment variable is set,
84    /// or if the file permissions indicate that it is read-only.
85    ///
86    /// # Errors
87    ///
88    /// [`WriteError`] if the file permissions cannot be checked.
89    pub async fn is_read_only(
90        secrets_path: impl AsRef<Path> + Send + Sync,
91    ) -> Result<bool, WriteError> {
92        // Check if the QCS_SECRETS_READ_ONLY environment variable is set
93        let ro_env = std::env::var(SECRETS_READ_ONLY_VAR);
94        let ro_env_lowercase = ro_env.as_deref().map(str::to_lowercase);
95        if let Ok("true" | "yes" | "1") = ro_env_lowercase.as_deref() {
96            return Ok(true);
97        }
98
99        // Check file permissions
100        let is_read_only = tokio::fs::metadata(&secrets_path)
101            .await
102            .map_err(|error| IoErrorWithPath {
103                error,
104                path: secrets_path.as_ref().to_path_buf(),
105                operation: IoOperation::GetMetadata,
106            })?
107            .permissions()
108            .readonly();
109        Ok(is_read_only)
110    }
111
112    /// Attempts to write a refresh and access token to the QCS [`Secrets`] file at
113    /// the given path.
114    ///
115    /// The access token will only be updated if the access token currently stored in the file is
116    /// older than the provided `updated_at` timestamp.
117    ///
118    /// # Errors
119    ///
120    /// - [`TokenError`] for possible errors.
121    pub(crate) async fn write_tokens(
122        secrets_path: impl AsRef<Path> + Send + Sync + std::fmt::Debug,
123        profile_name: &str,
124        refresh_token: Option<&SecretRefreshToken>,
125        access_token: &SecretAccessToken,
126        updated_at: OffsetDateTime,
127    ) -> Result<(), WriteError> {
128        // Read the current contents of the secrets file
129        let secrets_string = tokio::fs::read_to_string(&secrets_path)
130            .await
131            .map_err(|error| IoErrorWithPath {
132                error,
133                path: secrets_path.as_ref().to_path_buf(),
134                operation: IoOperation::Read,
135            })?;
136
137        // Parse the TOML content into a mutable document
138        let mut secrets_toml = secrets_string.parse::<DocumentMut>()?;
139
140        // Navigate to the `[credentials.<profile_name>.token_payload]` table
141        let token_payload = Self::get_token_payload_table(&mut secrets_toml, profile_name)?;
142
143        let current_updated_at = token_payload
144            .get("updated_at")
145            .and_then(|v| v.as_str())
146            .and_then(|s| PrimitiveDateTime::parse(s, &Rfc3339).ok())
147            .map(PrimitiveDateTime::assume_utc);
148
149        let did_update_access_token = if current_updated_at.is_none_or(|dt| dt < updated_at) {
150            token_payload["access_token"] = access_token.secret().into();
151            token_payload["updated_at"] = updated_at.format(&Rfc3339)?.into();
152            true
153        } else {
154            false
155        };
156
157        let did_update_refresh_token = refresh_token.is_some_and(|new_refresh_token| {
158            let current_refresh_token = token_payload.get("refresh_token").and_then(|v| v.as_str());
159            let new_refresh_token = new_refresh_token.secret();
160
161            let is_changed = current_refresh_token != Some(new_refresh_token);
162            if is_changed {
163                token_payload["refresh_token"] = new_refresh_token.into();
164            }
165            is_changed
166        });
167
168        if did_update_access_token || did_update_refresh_token {
169            // Create a temporary file
170            // Write the updated TOML content to a temporary file.
171            // The file is named using a newly generated UUIDv4 to avoid collisions
172            // with other processes that may also be attempting to update the secrets file.
173            let mut temp_file = TempFile::new().await?;
174            #[cfg(feature = "tracing")]
175            tracing::debug!(
176                "Created temporary QCS secrets file at {:?}",
177                temp_file.file_path()
178            );
179            // Set the same permissions as the original file
180            let secrets_file_permissions = tokio::fs::metadata(&secrets_path)
181                .await
182                .map_err(|error| IoErrorWithPath {
183                    error,
184                    path: secrets_path.as_ref().to_path_buf(),
185                    operation: IoOperation::GetMetadata,
186                })?
187                .permissions();
188            temp_file
189                .set_permissions(secrets_file_permissions)
190                .await
191                .map_err(|error| IoErrorWithPath {
192                    error,
193                    path: temp_file.file_path().clone(),
194                    operation: IoOperation::SetPermissions,
195                })?;
196
197            // Write the updated TOML content to the temporary file
198            temp_file
199                .write_all(secrets_toml.to_string().as_bytes())
200                .await
201                .map_err(|error| IoErrorWithPath {
202                    error,
203                    path: temp_file.file_path().clone(),
204                    operation: IoOperation::Write,
205                })?;
206            temp_file.flush().await.map_err(|error| IoErrorWithPath {
207                error,
208                path: temp_file.file_path().clone(),
209                operation: IoOperation::Flush,
210            })?;
211
212            // Atomically replace the original file with the temporary file.
213            // Note that this will fail if the secrets file is on a different mount-point from `std::env::temp_dir()`.
214            #[cfg(feature = "tracing")]
215            tracing::debug!(
216                "Overwriting QCS secrets file at {secrets_path:?} with temporary file at {:?}",
217                temp_file.file_path()
218            );
219            tokio::fs::rename(temp_file.file_path(), &secrets_path)
220                .await
221                .map_err(|error| IoErrorWithPath {
222                    error,
223                    path: temp_file.file_path().clone(),
224                    operation: IoOperation::Rename {
225                        dest: secrets_path.as_ref().to_path_buf(),
226                    },
227                })?;
228        }
229
230        Ok(())
231    }
232
233    /// Get the `[credentials.<profile_name>.token_payload]` table from the TOML document
234    fn get_token_payload_table<'a>(
235        secrets_toml: &'a mut DocumentMut,
236        profile_name: &str,
237    ) -> Result<&'a mut Item, WriteError> {
238        secrets_toml
239            .get_mut("credentials")
240            .and_then(|credentials| credentials.get_mut(profile_name)?.get_mut("token_payload"))
241            .ok_or_else(|| {
242                WriteError::MissingTable(format!("credentials.{profile_name}.token_payload",))
243            })
244    }
245}
246
247/// A QCS credential, containing sensitive authentication secrets.
248#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
249pub struct Credential {
250    /// The [`TokenPayload`] for this credential.
251    pub token_payload: Option<TokenPayload>,
252}
253
254/// A QCS token payload, containing sensitive authentication secrets.
255#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
256pub struct TokenPayload {
257    /// The refresh token for this credential.
258    pub refresh_token: Option<SecretRefreshToken>,
259    /// The access token for this credential.
260    pub access_token: Option<SecretAccessToken>,
261    /// The time at which this token was last updated.
262    #[serde(
263        default,
264        deserialize_with = "time::serde::rfc3339::option::deserialize",
265        serialize_with = "time::serde::rfc3339::option::serialize"
266    )]
267    pub updated_at: Option<OffsetDateTime>,
268
269    // The below fields are retained for (de)serialization for compatibility with other
270    // libraries that use token payloads, but are not relevant here.
271    scope: Option<String>,
272    expires_in: Option<u32>,
273    id_token: Option<String>,
274    token_type: Option<String>,
275}
276
277#[cfg(test)]
278mod describe_load {
279    #[cfg(unix)]
280    use std::os::unix::fs::PermissionsExt;
281    use std::path::PathBuf;
282
283    use time::{macros::datetime, OffsetDateTime};
284
285    use crate::configuration::secrets::SecretAccessToken;
286
287    use super::{Credential, Secrets, SECRETS_PATH_VAR};
288
289    #[test]
290    fn returns_err_if_invalid_path_env() {
291        figment::Jail::expect_with(|jail| {
292            jail.set_env(SECRETS_PATH_VAR, "/blah/doesnt_exist.toml");
293            Secrets::load().expect_err("Should return error when a file cannot be found.");
294            Ok(())
295        });
296    }
297
298    #[test]
299    fn loads_from_env_var_path() {
300        figment::Jail::expect_with(|jail| {
301            let mut secrets = Secrets {
302                file_path: Some(PathBuf::from("env_secrets.toml")),
303                ..Secrets::default()
304            };
305            secrets
306                .credentials
307                .insert("test".to_string(), Credential::default());
308            let secrets_string =
309                toml::to_string(&secrets).expect("Should be able to serialize secrets");
310
311            _ = jail.create_file("env_secrets.toml", &secrets_string)?;
312            jail.set_env(SECRETS_PATH_VAR, "env_secrets.toml");
313
314            assert_eq!(secrets, Secrets::load().unwrap());
315
316            Ok(())
317        });
318    }
319
320    const fn max_rfc3339() -> OffsetDateTime {
321        // PrimitiveDateTime::MAX can be larger than what can fit in a RFC3339 timestamp if the `time` crate's `large-dates` feature is enabled.
322        // Instead of asserting that the `time` crate's `large-dates` feature is disabled, we use a hardcoded max value here.
323        datetime!(9999-12-31 23:59:59.999_999_999).assume_utc()
324    }
325
326    #[test]
327    fn test_write_access_token() {
328        figment::Jail::expect_with(|jail| {
329            let secrets_file_contents = r#"
330[credentials]
331[credentials.test]
332[credentials.test.token_payload]
333access_token = "old_access_token"
334expires_in = 3600
335id_token = "id_token"
336refresh_token = "refresh_token"
337scope = "offline_access openid profile email"
338token_type = "Bearer"
339"#;
340
341            jail.create_file("secrets.toml", secrets_file_contents)
342                .expect("should create test secrets.toml");
343            let mut original_permissions = std::fs::metadata("secrets.toml")
344                .expect("Should be able to get file metadata")
345                .permissions();
346            #[cfg(unix)]
347            {
348                assert_ne!(
349                    0o666,
350                    original_permissions.mode(),
351                    "Initial file mode should not be 666"
352                );
353                original_permissions.set_mode(0o100_666);
354                std::fs::set_permissions("secrets.toml", original_permissions.clone())
355                    .expect("Should be able to set file permissions");
356            }
357            jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
358            jail.set_env("QCS_PROFILE_NAME", "test");
359
360            let rt = tokio::runtime::Runtime::new().unwrap();
361            rt.block_on(async {
362                // Create array of token updates with different timestamps
363                let token_updates = [
364                    ("new_access_token", max_rfc3339()),
365                    ("stale_access_token", OffsetDateTime::now_utc()),
366                ];
367
368                for (access_token, updated_at) in token_updates {
369                    Secrets::write_tokens(
370                        "secrets.toml",
371                        "test",
372                        None,
373                        &SecretAccessToken::from(access_token),
374                        updated_at,
375                    )
376                    .await
377                    .expect("Should be able to write access token");
378                }
379
380                // Verify the final state
381                let mut secrets = Secrets::load_from_path(&"secrets.toml".into()).unwrap();
382                let payload = secrets
383                    .credentials
384                    .remove("test")
385                    .unwrap()
386                    .token_payload
387                    .unwrap();
388
389                assert_eq!(
390                    payload.access_token.unwrap(),
391                    SecretAccessToken::from("new_access_token")
392                );
393                assert_eq!(payload.updated_at.unwrap(), max_rfc3339());
394                let new_permissions = std::fs::metadata("secrets.toml")
395                    .expect("Should be able to get file metadata")
396                    .permissions();
397                assert_eq!(
398                    original_permissions, new_permissions,
399                    "Final file permissions should not be changed"
400                );
401            });
402
403            Ok(())
404        });
405    }
406}