qcs-api-client-common 0.17.3

Common code for QCS API clients
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
//! Models and utilities for managing QCS secret credentials.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use async_tempfile::TempFile;
use figment::providers::{Format, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use time::{OffsetDateTime, PrimitiveDateTime};
use tokio::io::AsyncWriteExt;
use toml_edit::{DocumentMut, Item};

use crate::configuration::LoadError;

use super::error::{IoErrorWithPath, IoOperation, WriteError};
use super::{expand_path_from_env_or_default, DEFAULT_PROFILE_NAME};

pub use super::secret_string::{SecretAccessToken, SecretRefreshToken};

/// Setting the `QCS_SECRETS_FILE_PATH` environment variable will change which file is used for loading secrets
pub const SECRETS_PATH_VAR: &str = "QCS_SECRETS_FILE_PATH";
/// `QCS_SECRETS_READ_ONLY` indicates whether to treat the `secrets.toml` file as read-only. Disabled by default.
/// * 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".  
/// * Access token updates will be persisted to the secrets file if it is writeable for any other value or if unset.
pub const SECRETS_READ_ONLY_VAR: &str = "QCS_SECRETS_READ_ONLY";
/// The default path that [`Secrets`] will be loaded from
pub const DEFAULT_SECRETS_PATH: &str = "~/.qcs/secrets.toml";

/// The structure of QCS secrets, typically serialized as a TOML file at [`DEFAULT_SECRETS_PATH`].
#[derive(Deserialize, Debug, PartialEq, Eq, Serialize)]
pub struct Secrets {
    /// All named [`Credential`]s defined in the secrets file.
    #[serde(default = "default_credentials")]
    pub credentials: HashMap<String, Credential>,
    /// The path to the secrets file this [`Secrets`] was loaded from,
    /// if it was loaded from a file. This is not stored in the secrets file itself.
    #[serde(skip)]
    pub file_path: Option<PathBuf>,
}

fn default_credentials() -> HashMap<String, Credential> {
    HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), Credential::default())])
}

impl Default for Secrets {
    fn default() -> Self {
        Self {
            credentials: default_credentials(),
            file_path: None,
        }
    }
}

impl Secrets {
    /// Load [`Secrets`] from the path specified by the [`SECRETS_PATH_VAR`] environment variable if set,
    /// or else the default path at [`DEFAULT_SECRETS_PATH`].
    ///
    /// # Errors
    ///
    /// [`LoadError`] if the secrets file cannot be loaded.
    pub fn load() -> Result<Self, LoadError> {
        let path = expand_path_from_env_or_default(SECRETS_PATH_VAR, DEFAULT_SECRETS_PATH)?;
        #[cfg(feature = "tracing")]
        tracing::debug!("loading QCS secrets from {path:?}");
        Self::load_from_path(&path)
    }

    /// Load [`Secrets`] from the path specified by `path`.
    ///
    /// # Errors
    ///
    /// [`LoadError`] if the secrets file cannot be loaded.
    pub fn load_from_path(path: &PathBuf) -> Result<Self, LoadError> {
        let mut secrets: Self = Figment::from(Toml::file(path)).extract()?;
        secrets.file_path = Some(path.into());
        Ok(secrets)
    }

    /// Returns a bool indicating whether or not the QCS [`Secrets`] file is read-only.
    ///
    /// The file is considered read-only if the [`SECRETS_READ_ONLY_VAR`] environment variable is set,
    /// or if the file permissions indicate that it is read-only.
    ///
    /// # Errors
    ///
    /// [`WriteError`] if the file permissions cannot be checked.
    pub async fn is_read_only(
        secrets_path: impl AsRef<Path> + Send + Sync,
    ) -> Result<bool, WriteError> {
        // Check if the QCS_SECRETS_READ_ONLY environment variable is set
        let ro_env = std::env::var(SECRETS_READ_ONLY_VAR);
        let ro_env_lowercase = ro_env.as_deref().map(str::to_lowercase);
        if let Ok("true" | "yes" | "1") = ro_env_lowercase.as_deref() {
            return Ok(true);
        }

        // Check file permissions - a non-existent file is treated as writable if its
        // parent directory is writable
        for (i, ancestor) in secrets_path.as_ref().ancestors().enumerate() {
            match tokio::fs::metadata(ancestor).await {
                Ok(metadata) => return Ok(metadata.permissions().readonly()),
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
                Err(error) if i == 0 => {
                    return Err(IoErrorWithPath {
                        error,
                        path: secrets_path.as_ref().to_path_buf(),
                        operation: IoOperation::GetMetadata,
                    }
                    .into());
                }
                Err(_) => return Ok(true), // Can't access ancestor = read-only
            }
        }
        Ok(true) // No existing ancestor found = read-only
    }

    /// Attempts to write a refresh and access token to the QCS [`Secrets`] file at
    /// the given path.
    ///
    /// The access token will only be updated if the access token currently stored in the file is
    /// older than the provided `updated_at` timestamp.
    ///
    /// # Errors
    ///
    /// - [`TokenError`] for possible errors.
    pub(crate) async fn write_tokens(
        secrets_path: impl AsRef<Path> + Send + Sync + std::fmt::Debug,
        profile_name: &str,
        refresh_token: Option<&SecretRefreshToken>,
        access_token: &SecretAccessToken,
        updated_at: OffsetDateTime,
    ) -> Result<(), WriteError> {
        // Read the current contents of the secrets file
        let secrets_string = tokio::fs::read_to_string(&secrets_path)
            .await
            .map_err(|error| IoErrorWithPath {
                error,
                path: secrets_path.as_ref().to_path_buf(),
                operation: IoOperation::Read,
            })?;

        // Parse the TOML content into a mutable document
        let mut secrets_toml = secrets_string.parse::<DocumentMut>()?;

        // Navigate to the `[credentials.<profile_name>.token_payload]` table
        let token_payload = Self::get_token_payload_table(&mut secrets_toml, profile_name)?;

        let current_updated_at = token_payload
            .get("updated_at")
            .and_then(|v| v.as_str())
            .and_then(|s| PrimitiveDateTime::parse(s, &Rfc3339).ok())
            .map(PrimitiveDateTime::assume_utc);

        let did_update_access_token = if current_updated_at.is_none_or(|dt| dt < updated_at) {
            token_payload["access_token"] = access_token.secret().into();
            token_payload["updated_at"] = updated_at.format(&Rfc3339)?.into();
            true
        } else {
            false
        };

        let did_update_refresh_token = refresh_token.is_some_and(|new_refresh_token| {
            let current_refresh_token = token_payload.get("refresh_token").and_then(|v| v.as_str());
            let new_refresh_token = new_refresh_token.secret();

            let is_changed = current_refresh_token != Some(new_refresh_token);
            if is_changed {
                token_payload["refresh_token"] = new_refresh_token.into();
            }
            is_changed
        });

        if did_update_access_token || did_update_refresh_token {
            // Create a temporary file
            // Write the updated TOML content to a temporary file.
            // The file is named using a newly generated UUIDv4 to avoid collisions
            // with other processes that may also be attempting to update the secrets file.
            let mut temp_file = TempFile::new().await?;
            #[cfg(feature = "tracing")]
            tracing::debug!(
                "Created temporary QCS secrets file at {:?}",
                temp_file.file_path()
            );
            // Set the same permissions as the original file
            let secrets_file_permissions = tokio::fs::metadata(&secrets_path)
                .await
                .map_err(|error| IoErrorWithPath {
                    error,
                    path: secrets_path.as_ref().to_path_buf(),
                    operation: IoOperation::GetMetadata,
                })?
                .permissions();
            temp_file
                .set_permissions(secrets_file_permissions)
                .await
                .map_err(|error| IoErrorWithPath {
                    error,
                    path: temp_file.file_path().clone(),
                    operation: IoOperation::SetPermissions,
                })?;

            // Write the updated TOML content to the temporary file
            temp_file
                .write_all(secrets_toml.to_string().as_bytes())
                .await
                .map_err(|error| IoErrorWithPath {
                    error,
                    path: temp_file.file_path().clone(),
                    operation: IoOperation::Write,
                })?;
            temp_file.flush().await.map_err(|error| IoErrorWithPath {
                error,
                path: temp_file.file_path().clone(),
                operation: IoOperation::Flush,
            })?;

            // Atomically replace the original file with the temporary file.
            // Note that this will fail if the secrets file is on a different mount-point from `std::env::temp_dir()`.
            #[cfg(feature = "tracing")]
            tracing::debug!(
                "Overwriting QCS secrets file at {secrets_path:?} with temporary file at {:?}",
                temp_file.file_path()
            );
            tokio::fs::rename(temp_file.file_path(), &secrets_path)
                .await
                .map_err(|error| IoErrorWithPath {
                    error,
                    path: temp_file.file_path().clone(),
                    operation: IoOperation::Rename {
                        dest: secrets_path.as_ref().to_path_buf(),
                    },
                })?;
        }

        Ok(())
    }

    /// Get the `[credentials.<profile_name>.token_payload]` table from the TOML document
    fn get_token_payload_table<'a>(
        secrets_toml: &'a mut DocumentMut,
        profile_name: &str,
    ) -> Result<&'a mut Item, WriteError> {
        secrets_toml
            .get_mut("credentials")
            .and_then(|credentials| credentials.get_mut(profile_name)?.get_mut("token_payload"))
            .ok_or_else(|| {
                WriteError::MissingTable(format!("credentials.{profile_name}.token_payload",))
            })
    }
}

/// A QCS credential, containing sensitive authentication secrets.
#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Credential {
    /// The [`TokenPayload`] for this credential.
    pub token_payload: Option<TokenPayload>,
}

/// A QCS token payload, containing sensitive authentication secrets.
#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
pub struct TokenPayload {
    /// The refresh token for this credential.
    pub refresh_token: Option<SecretRefreshToken>,
    /// The access token for this credential.
    pub access_token: Option<SecretAccessToken>,
    /// The time at which this token was last updated.
    #[serde(
        default,
        deserialize_with = "time::serde::rfc3339::option::deserialize",
        serialize_with = "time::serde::rfc3339::option::serialize"
    )]
    pub updated_at: Option<OffsetDateTime>,

    // The below fields are retained for (de)serialization for compatibility with other
    // libraries that use token payloads, but are not relevant here.
    scope: Option<String>,
    expires_in: Option<u32>,
    id_token: Option<String>,
    token_type: Option<String>,
}

#[cfg(test)]
mod describe_load {
    #![allow(clippy::result_large_err, reason = "happens in figment tests")]

    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;
    use std::path::PathBuf;

    use time::{macros::datetime, OffsetDateTime};

    use crate::configuration::secrets::{SecretAccessToken, SECRETS_READ_ONLY_VAR};

    use super::{Credential, Secrets, SECRETS_PATH_VAR};

    #[test]
    fn returns_err_if_invalid_path_env() {
        figment::Jail::expect_with(|jail| {
            jail.set_env(SECRETS_PATH_VAR, "/blah/doesnt_exist.toml");
            Secrets::load().expect_err("Should return error when a file cannot be found.");
            Ok(())
        });
    }

    #[test]
    fn loads_from_env_var_path() {
        figment::Jail::expect_with(|jail| {
            let mut secrets = Secrets {
                file_path: Some(PathBuf::from("env_secrets.toml")),
                ..Secrets::default()
            };
            secrets
                .credentials
                .insert("test".to_string(), Credential::default());
            let secrets_string =
                toml::to_string(&secrets).expect("Should be able to serialize secrets");

            _ = jail.create_file("env_secrets.toml", &secrets_string)?;
            jail.set_env(SECRETS_PATH_VAR, "env_secrets.toml");

            assert_eq!(secrets, Secrets::load().unwrap());

            Ok(())
        });
    }

    const fn max_rfc3339() -> OffsetDateTime {
        // PrimitiveDateTime::MAX can be larger than what can fit in a RFC3339 timestamp if the `time` crate's `large-dates` feature is enabled.
        // Instead of asserting that the `time` crate's `large-dates` feature is disabled, we use a hardcoded max value here.
        datetime!(9999-12-31 23:59:59.999_999_999).assume_utc()
    }

    #[test]
    fn test_write_access_token() {
        figment::Jail::expect_with(|jail| {
            let secrets_file_contents = r#"
[credentials]
[credentials.test]
[credentials.test.token_payload]
access_token = "old_access_token"
expires_in = 3600
id_token = "id_token"
refresh_token = "refresh_token"
scope = "offline_access openid profile email"
token_type = "Bearer"
"#;

            jail.create_file("secrets.toml", secrets_file_contents)
                .expect("should create test secrets.toml");
            let mut original_permissions = std::fs::metadata("secrets.toml")
                .expect("Should be able to get file metadata")
                .permissions();
            #[cfg(unix)]
            {
                assert_ne!(
                    0o666,
                    original_permissions.mode(),
                    "Initial file mode should not be 666"
                );
                original_permissions.set_mode(0o100_666);
                std::fs::set_permissions("secrets.toml", original_permissions.clone())
                    .expect("Should be able to set file permissions");
            }
            jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
            jail.set_env("QCS_PROFILE_NAME", "test");

            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async {
                // Create array of token updates with different timestamps
                let token_updates = [
                    ("new_access_token", max_rfc3339()),
                    ("stale_access_token", OffsetDateTime::now_utc()),
                ];

                for (access_token, updated_at) in token_updates {
                    Secrets::write_tokens(
                        "secrets.toml",
                        "test",
                        None,
                        &SecretAccessToken::from(access_token),
                        updated_at,
                    )
                    .await
                    .expect("Should be able to write access token");
                }

                // Verify the final state
                let mut secrets = Secrets::load_from_path(&"secrets.toml".into()).unwrap();
                let payload = secrets
                    .credentials
                    .remove("test")
                    .unwrap()
                    .token_payload
                    .unwrap();

                assert_eq!(
                    payload.access_token.unwrap(),
                    SecretAccessToken::from("new_access_token")
                );
                assert_eq!(payload.updated_at.unwrap(), max_rfc3339());
                let new_permissions = std::fs::metadata("secrets.toml")
                    .expect("Should be able to get file metadata")
                    .permissions();
                assert_eq!(
                    original_permissions, new_permissions,
                    "Final file permissions should not be changed"
                );
            });

            Ok(())
        });
    }

    /// Set file permissions on Unix systems for jail-created files and directories
    fn set_mode(path: &PathBuf, mode: u32) {
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = std::fs::Permissions::from_mode(mode);
            std::fs::set_permissions(path, perms).expect("Should be able to set permissions");
        }
    }

    #[test]
    fn test_is_read_only_missing_file_checks_parent_dir() {
        figment::Jail::expect_with(|jail| {
            jail.set_env(SECRETS_READ_ONLY_VAR, "false");

            let writable_dir = jail.create_dir("writable_dir")?;
            let readonly_dir = jail.create_dir("readonly_dir")?;

            set_mode(&writable_dir, 0o777);
            set_mode(&readonly_dir, 0o555);

            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async {
                // Missing file in writable directory should be writable (not read-only)
                let writable_path = writable_dir.join("missing_secrets.toml");
                let is_ro = Secrets::is_read_only(&writable_path)
                    .await
                    .expect("Should not error");
                assert!(
                    !is_ro,
                    "Missing file in writable directory should not be read-only: {}",
                    writable_path.display()
                );

                // Missing file in read-only directory should be read-only
                let readonly_path = readonly_dir.join("missing_secrets.toml");
                let is_ro = Secrets::is_read_only(&readonly_path)
                    .await
                    .expect("Should not error");
                assert!(
                    is_ro,
                    "Missing file in read-only directory should be read-only: {}",
                    readonly_path.display()
                );
            });

            Ok(())
        });
    }

    #[test]
    fn test_is_read_only_existing_file() {
        figment::Jail::expect_with(|jail| {
            jail.set_env(SECRETS_READ_ONLY_VAR, "false");

            jail.create_file("writable_secrets.toml", "")?;
            jail.create_file("readonly_secrets.toml", "")?;

            let writable_path = jail.directory().join("writable_secrets.toml");
            let readonly_path = jail.directory().join("readonly_secrets.toml");

            set_mode(&writable_path, 0o666);
            set_mode(&readonly_path, 0o444);

            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async {
                // Existing writable file should not be read-only
                let is_ro = Secrets::is_read_only(&writable_path)
                    .await
                    .expect("Should not error");
                assert!(
                    !is_ro,
                    "Writable file should not be read-only: {}",
                    writable_path.display()
                );

                // Existing read-only file should be read-only
                let is_ro = Secrets::is_read_only(&readonly_path)
                    .await
                    .expect("Should not error");
                assert!(
                    is_ro,
                    "Read-only file should be read-only: {}",
                    readonly_path.display()
                );
            });

            Ok(())
        });
    }
}