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