qcs_api_client_common/configuration/
secrets.rs1use 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::settings::AuthServer;
17use super::tokens::ClientCredentials;
18use super::{DEFAULT_PROFILE_NAME, expand_path_from_env_or_default};
19
20pub use super::external_command::{ExternalCommandError, ExternallyManagedCredential};
21pub use super::secret_string::{ClientSecret, SecretAccessToken, SecretRefreshToken};
22
23pub const SECRETS_PATH_VAR: &str = "QCS_SECRETS_FILE_PATH";
25pub const SECRETS_READ_ONLY_VAR: &str = "QCS_SECRETS_READ_ONLY";
29pub const DEFAULT_SECRETS_PATH: &str = "~/.qcs/secrets.toml";
31
32#[derive(Deserialize, Debug, PartialEq, Eq, Serialize)]
34pub struct Secrets {
35 #[serde(default = "default_credentials")]
37 pub credentials: HashMap<String, Credential>,
38 #[serde(skip)]
41 pub file_path: Option<PathBuf>,
42}
43
44fn default_credentials() -> HashMap<String, Credential> {
45 HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), Credential::default())])
46}
47
48impl Default for Secrets {
49 fn default() -> Self {
50 Self {
51 credentials: default_credentials(),
52 file_path: None,
53 }
54 }
55}
56
57impl Secrets {
58 pub fn load() -> Result<Self, LoadError> {
65 let path = expand_path_from_env_or_default(SECRETS_PATH_VAR, DEFAULT_SECRETS_PATH)?;
66 #[cfg(feature = "tracing")]
67 tracing::debug!("loading QCS secrets from {path:?}");
68 Self::load_from_path(&path)
69 }
70
71 pub fn load_from_path(path: &PathBuf) -> Result<Self, LoadError> {
77 let mut secrets: Self = Figment::from(Toml::file(path)).extract()?;
78 secrets.file_path = Some(path.into());
79 Ok(secrets)
80 }
81
82 pub async fn is_read_only(
91 secrets_path: impl AsRef<Path> + Send + Sync,
92 ) -> Result<bool, WriteError> {
93 let ro_env = std::env::var(SECRETS_READ_ONLY_VAR);
95 let ro_env_lowercase = ro_env.as_deref().map(str::to_lowercase);
96 if let Ok("true" | "yes" | "1") = ro_env_lowercase.as_deref() {
97 return Ok(true);
98 }
99
100 for (i, ancestor) in secrets_path.as_ref().ancestors().enumerate() {
103 match tokio::fs::metadata(ancestor).await {
104 Ok(metadata) => return Ok(metadata.permissions().readonly()),
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), }
116 }
117 Ok(true) }
119
120 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 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 let mut secrets_toml = secrets_string.parse::<DocumentMut>()?;
147
148 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 super::fs::atomic_write(&secrets_path, secrets_toml.to_string().as_bytes()).await?;
182 }
183
184 Ok(())
185 }
186
187 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#[derive(Deserialize, Debug, PartialEq, Eq, Serialize)]
207#[serde(rename_all = "snake_case")]
208pub enum Credential {
209 TokenPayload(TokenPayload),
211 ExternallyManaged(ExternallyManagedCredential),
213 ClientCredentials(ClientCredentials),
215}
216
217impl Default for Credential {
218 fn default() -> Self {
219 Self::TokenPayload(TokenPayload::default())
220 }
221}
222
223#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
225pub struct TokenPayload {
226 pub refresh_token: Option<SecretRefreshToken>,
228 pub access_token: Option<SecretAccessToken>,
230 #[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 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 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 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 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 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 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 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 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 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}