Skip to main content

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