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        profile_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.<profile_name>.token_payload]` table
146        let token_payload = Self::get_token_payload_table(&mut secrets_toml, profile_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.<profile_name>.token_payload]` table from the TOML document
185    fn get_token_payload_table<'a>(
186        secrets_toml: &'a mut DocumentMut,
187        profile_name: &str,
188    ) -> Result<&'a mut Item, WriteError> {
189        secrets_toml
190            .get_mut("credentials")
191            .and_then(|credentials| credentials.get_mut(profile_name)?.get_mut("token_payload"))
192            .ok_or_else(|| {
193                WriteError::MissingTable(format!("credentials.{profile_name}.token_payload",))
194            })
195    }
196}
197
198/// A QCS credential, containing sensitive authentication secrets.
199#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
200pub struct Credential {
201    /// The [`TokenPayload`] for this credential.
202    pub token_payload: Option<TokenPayload>,
203}
204
205/// A QCS token payload, containing sensitive authentication secrets.
206#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
207pub struct TokenPayload {
208    /// The refresh token for this credential.
209    pub refresh_token: Option<SecretRefreshToken>,
210    /// The access token for this credential.
211    pub access_token: Option<SecretAccessToken>,
212    /// The time at which this token was last updated.
213    #[serde(
214        default,
215        deserialize_with = "time::serde::rfc3339::option::deserialize",
216        serialize_with = "time::serde::rfc3339::option::serialize"
217    )]
218    pub updated_at: Option<OffsetDateTime>,
219
220    // The below fields are retained for (de)serialization for compatibility with other
221    // libraries that use token payloads, but are not relevant here.
222    scope: Option<String>,
223    expires_in: Option<u32>,
224    id_token: Option<String>,
225    token_type: Option<String>,
226}
227
228#[cfg(test)]
229mod describe_load {
230    #![allow(clippy::result_large_err, reason = "happens in figment tests")]
231
232    #[cfg(unix)]
233    use std::os::unix::fs::PermissionsExt;
234    use std::path::PathBuf;
235
236    use time::{OffsetDateTime, macros::datetime};
237
238    use crate::configuration::secrets::{SECRETS_READ_ONLY_VAR, SecretAccessToken};
239
240    use super::{Credential, SECRETS_PATH_VAR, Secrets};
241
242    #[test]
243    fn returns_err_if_invalid_path_env() {
244        figment::Jail::expect_with(|jail| {
245            jail.set_env(SECRETS_PATH_VAR, "/blah/doesnt_exist.toml");
246            Secrets::load().expect_err("Should return error when a file cannot be found.");
247            Ok(())
248        });
249    }
250
251    #[test]
252    fn loads_from_env_var_path() {
253        figment::Jail::expect_with(|jail| {
254            let mut secrets = Secrets {
255                file_path: Some(PathBuf::from("env_secrets.toml")),
256                ..Secrets::default()
257            };
258            secrets
259                .credentials
260                .insert("test".to_string(), Credential::default());
261            let secrets_string =
262                toml::to_string(&secrets).expect("Should be able to serialize secrets");
263
264            _ = jail.create_file("env_secrets.toml", &secrets_string)?;
265            jail.set_env(SECRETS_PATH_VAR, "env_secrets.toml");
266
267            assert_eq!(secrets, Secrets::load().unwrap());
268
269            Ok(())
270        });
271    }
272
273    const fn max_rfc3339() -> OffsetDateTime {
274        // PrimitiveDateTime::MAX can be larger than what can fit in a RFC3339 timestamp if the `time` crate's `large-dates` feature is enabled.
275        // Instead of asserting that the `time` crate's `large-dates` feature is disabled, we use a hardcoded max value here.
276        datetime!(9999-12-31 23:59:59.999_999_999).assume_utc()
277    }
278
279    #[test]
280    fn test_write_access_token() {
281        figment::Jail::expect_with(|jail| {
282            let secrets_file_contents = r#"
283[credentials]
284[credentials.test]
285[credentials.test.token_payload]
286access_token = "old_access_token"
287expires_in = 3600
288id_token = "id_token"
289refresh_token = "refresh_token"
290scope = "offline_access openid profile email"
291token_type = "Bearer"
292"#;
293
294            jail.create_file("secrets.toml", secrets_file_contents)
295                .expect("should create test secrets.toml");
296            let mut original_permissions = std::fs::metadata("secrets.toml")
297                .expect("Should be able to get file metadata")
298                .permissions();
299            #[cfg(unix)]
300            {
301                assert_ne!(
302                    0o666,
303                    original_permissions.mode(),
304                    "Initial file mode should not be 666"
305                );
306                original_permissions.set_mode(0o100_666);
307                std::fs::set_permissions("secrets.toml", original_permissions.clone())
308                    .expect("Should be able to set file permissions");
309            }
310            jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
311            jail.set_env("QCS_PROFILE_NAME", "test");
312
313            let rt = tokio::runtime::Runtime::new().unwrap();
314            rt.block_on(async {
315                // Create array of token updates with different timestamps
316                let token_updates = [
317                    ("new_access_token", max_rfc3339()),
318                    ("stale_access_token", OffsetDateTime::now_utc()),
319                ];
320
321                for (access_token, updated_at) in token_updates {
322                    Secrets::write_tokens(
323                        "secrets.toml",
324                        "test",
325                        None,
326                        &SecretAccessToken::from(access_token),
327                        updated_at,
328                    )
329                    .await
330                    .expect("Should be able to write access token");
331                }
332
333                // Verify the final state
334                let mut secrets = Secrets::load_from_path(&"secrets.toml".into()).unwrap();
335                let payload = secrets
336                    .credentials
337                    .remove("test")
338                    .unwrap()
339                    .token_payload
340                    .unwrap();
341
342                assert_eq!(
343                    payload.access_token.unwrap(),
344                    SecretAccessToken::from("new_access_token")
345                );
346                assert_eq!(payload.updated_at.unwrap(), max_rfc3339());
347                let new_permissions = std::fs::metadata("secrets.toml")
348                    .expect("Should be able to get file metadata")
349                    .permissions();
350                assert_eq!(
351                    original_permissions, new_permissions,
352                    "Final file permissions should not be changed"
353                );
354            });
355
356            Ok(())
357        });
358    }
359
360    /// Set file permissions on Unix systems for jail-created files and directories
361    fn set_mode(path: &PathBuf, mode: u32) {
362        #[cfg(unix)]
363        {
364            use std::os::unix::fs::PermissionsExt;
365            let perms = std::fs::Permissions::from_mode(mode);
366            std::fs::set_permissions(path, perms).expect("Should be able to set permissions");
367        }
368    }
369
370    #[test]
371    fn test_is_read_only_missing_file_checks_parent_dir() {
372        figment::Jail::expect_with(|jail| {
373            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
374
375            let writable_dir = jail.create_dir("writable_dir")?;
376            let readonly_dir = jail.create_dir("readonly_dir")?;
377
378            set_mode(&writable_dir, 0o777);
379            set_mode(&readonly_dir, 0o555);
380
381            let rt = tokio::runtime::Runtime::new().unwrap();
382            rt.block_on(async {
383                // Missing file in writable directory should be writable (not read-only)
384                let writable_path = writable_dir.join("missing_secrets.toml");
385                let is_ro = Secrets::is_read_only(&writable_path)
386                    .await
387                    .expect("Should not error");
388                assert!(
389                    !is_ro,
390                    "Missing file in writable directory should not be read-only: {}",
391                    writable_path.display()
392                );
393
394                // Missing file in read-only directory should be read-only
395                let readonly_path = readonly_dir.join("missing_secrets.toml");
396                let is_ro = Secrets::is_read_only(&readonly_path)
397                    .await
398                    .expect("Should not error");
399                assert!(
400                    is_ro,
401                    "Missing file in read-only directory should be read-only: {}",
402                    readonly_path.display()
403                );
404            });
405
406            Ok(())
407        });
408    }
409
410    #[test]
411    fn test_is_read_only_existing_file() {
412        figment::Jail::expect_with(|jail| {
413            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
414
415            jail.create_file("writable_secrets.toml", "")?;
416            jail.create_file("readonly_secrets.toml", "")?;
417
418            let writable_path = jail.directory().join("writable_secrets.toml");
419            let readonly_path = jail.directory().join("readonly_secrets.toml");
420
421            set_mode(&writable_path, 0o666);
422            set_mode(&readonly_path, 0o444);
423
424            let rt = tokio::runtime::Runtime::new().unwrap();
425            rt.block_on(async {
426                // Existing writable file should not be read-only
427                let is_ro = Secrets::is_read_only(&writable_path)
428                    .await
429                    .expect("Should not error");
430                assert!(
431                    !is_ro,
432                    "Writable file should not be read-only: {}",
433                    writable_path.display()
434                );
435
436                // Existing read-only file should be read-only
437                let is_ro = Secrets::is_read_only(&readonly_path)
438                    .await
439                    .expect("Should not error");
440                assert!(
441                    is_ro,
442                    "Read-only file should be read-only: {}",
443                    readonly_path.display()
444                );
445            });
446
447            Ok(())
448        });
449    }
450}