qcs_api_client_common/configuration/
secrets.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use async_tempfile::TempFile;
7use figment::providers::{Format, Toml};
8use figment::Figment;
9use serde::{Deserialize, Serialize};
10use time::format_description::well_known::Rfc3339;
11use time::{OffsetDateTime, PrimitiveDateTime};
12use tokio::io::AsyncWriteExt;
13use toml_edit::{DocumentMut, Item};
14
15use crate::configuration::LoadError;
16
17use super::error::{IoErrorWithPath, IoOperation, WriteError};
18use super::{expand_path_from_env_or_default, DEFAULT_PROFILE_NAME};
19
20pub use super::secret_string::{SecretAccessToken, SecretRefreshToken};
21
22pub const SECRETS_PATH_VAR: &str = "QCS_SECRETS_FILE_PATH";
24pub const SECRETS_READ_ONLY_VAR: &str = "QCS_SECRETS_READ_ONLY";
28pub const DEFAULT_SECRETS_PATH: &str = "~/.qcs/secrets.toml";
30
31#[derive(Deserialize, Debug, PartialEq, Eq, Serialize)]
33pub struct Secrets {
34 #[serde(default = "default_credentials")]
36 pub credentials: HashMap<String, Credential>,
37 #[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 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 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 pub async fn is_read_only(
90 secrets_path: impl AsRef<Path> + Send + Sync,
91 ) -> Result<bool, WriteError> {
92 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 let is_read_only = tokio::fs::metadata(&secrets_path)
101 .await
102 .map_err(|error| IoErrorWithPath {
103 error,
104 path: secrets_path.as_ref().to_path_buf(),
105 operation: IoOperation::GetMetadata,
106 })?
107 .permissions()
108 .readonly();
109 Ok(is_read_only)
110 }
111
112 pub(crate) async fn write_tokens(
122 secrets_path: impl AsRef<Path> + Send + Sync + std::fmt::Debug,
123 profile_name: &str,
124 refresh_token: Option<&SecretRefreshToken>,
125 access_token: &SecretAccessToken,
126 updated_at: OffsetDateTime,
127 ) -> Result<(), WriteError> {
128 let secrets_string = tokio::fs::read_to_string(&secrets_path)
130 .await
131 .map_err(|error| IoErrorWithPath {
132 error,
133 path: secrets_path.as_ref().to_path_buf(),
134 operation: IoOperation::Read,
135 })?;
136
137 let mut secrets_toml = secrets_string.parse::<DocumentMut>()?;
139
140 let token_payload = Self::get_token_payload_table(&mut secrets_toml, profile_name)?;
142
143 let current_updated_at = token_payload
144 .get("updated_at")
145 .and_then(|v| v.as_str())
146 .and_then(|s| PrimitiveDateTime::parse(s, &Rfc3339).ok())
147 .map(PrimitiveDateTime::assume_utc);
148
149 let did_update_access_token = if current_updated_at.is_none_or(|dt| dt < updated_at) {
150 token_payload["access_token"] = access_token.secret().into();
151 token_payload["updated_at"] = updated_at.format(&Rfc3339)?.into();
152 true
153 } else {
154 false
155 };
156
157 let did_update_refresh_token = refresh_token.is_some_and(|new_refresh_token| {
158 let current_refresh_token = token_payload.get("refresh_token").and_then(|v| v.as_str());
159 let new_refresh_token = new_refresh_token.secret();
160
161 let is_changed = current_refresh_token != Some(new_refresh_token);
162 if is_changed {
163 token_payload["refresh_token"] = new_refresh_token.into();
164 }
165 is_changed
166 });
167
168 if did_update_access_token || did_update_refresh_token {
169 let mut temp_file = TempFile::new().await?;
174 #[cfg(feature = "tracing")]
175 tracing::debug!(
176 "Created temporary QCS secrets file at {:?}",
177 temp_file.file_path()
178 );
179 let secrets_file_permissions = tokio::fs::metadata(&secrets_path)
181 .await
182 .map_err(|error| IoErrorWithPath {
183 error,
184 path: secrets_path.as_ref().to_path_buf(),
185 operation: IoOperation::GetMetadata,
186 })?
187 .permissions();
188 temp_file
189 .set_permissions(secrets_file_permissions)
190 .await
191 .map_err(|error| IoErrorWithPath {
192 error,
193 path: temp_file.file_path().clone(),
194 operation: IoOperation::SetPermissions,
195 })?;
196
197 temp_file
199 .write_all(secrets_toml.to_string().as_bytes())
200 .await
201 .map_err(|error| IoErrorWithPath {
202 error,
203 path: temp_file.file_path().clone(),
204 operation: IoOperation::Write,
205 })?;
206 temp_file.flush().await.map_err(|error| IoErrorWithPath {
207 error,
208 path: temp_file.file_path().clone(),
209 operation: IoOperation::Flush,
210 })?;
211
212 #[cfg(feature = "tracing")]
215 tracing::debug!(
216 "Overwriting QCS secrets file at {secrets_path:?} with temporary file at {:?}",
217 temp_file.file_path()
218 );
219 tokio::fs::rename(temp_file.file_path(), &secrets_path)
220 .await
221 .map_err(|error| IoErrorWithPath {
222 error,
223 path: temp_file.file_path().clone(),
224 operation: IoOperation::Rename {
225 dest: secrets_path.as_ref().to_path_buf(),
226 },
227 })?;
228 }
229
230 Ok(())
231 }
232
233 fn get_token_payload_table<'a>(
235 secrets_toml: &'a mut DocumentMut,
236 profile_name: &str,
237 ) -> Result<&'a mut Item, WriteError> {
238 secrets_toml
239 .get_mut("credentials")
240 .and_then(|credentials| credentials.get_mut(profile_name)?.get_mut("token_payload"))
241 .ok_or_else(|| {
242 WriteError::MissingTable(format!("credentials.{profile_name}.token_payload",))
243 })
244 }
245}
246
247#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
249pub struct Credential {
250 pub token_payload: Option<TokenPayload>,
252}
253
254#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
256pub struct TokenPayload {
257 pub refresh_token: Option<SecretRefreshToken>,
259 pub access_token: Option<SecretAccessToken>,
261 #[serde(
263 default,
264 deserialize_with = "time::serde::rfc3339::option::deserialize",
265 serialize_with = "time::serde::rfc3339::option::serialize"
266 )]
267 pub updated_at: Option<OffsetDateTime>,
268
269 scope: Option<String>,
272 expires_in: Option<u32>,
273 id_token: Option<String>,
274 token_type: Option<String>,
275}
276
277#[cfg(test)]
278mod describe_load {
279 #[cfg(unix)]
280 use std::os::unix::fs::PermissionsExt;
281 use std::path::PathBuf;
282
283 use time::{macros::datetime, OffsetDateTime};
284
285 use crate::configuration::secrets::SecretAccessToken;
286
287 use super::{Credential, Secrets, SECRETS_PATH_VAR};
288
289 #[test]
290 fn returns_err_if_invalid_path_env() {
291 figment::Jail::expect_with(|jail| {
292 jail.set_env(SECRETS_PATH_VAR, "/blah/doesnt_exist.toml");
293 Secrets::load().expect_err("Should return error when a file cannot be found.");
294 Ok(())
295 });
296 }
297
298 #[test]
299 fn loads_from_env_var_path() {
300 figment::Jail::expect_with(|jail| {
301 let mut secrets = Secrets {
302 file_path: Some(PathBuf::from("env_secrets.toml")),
303 ..Secrets::default()
304 };
305 secrets
306 .credentials
307 .insert("test".to_string(), Credential::default());
308 let secrets_string =
309 toml::to_string(&secrets).expect("Should be able to serialize secrets");
310
311 _ = jail.create_file("env_secrets.toml", &secrets_string)?;
312 jail.set_env(SECRETS_PATH_VAR, "env_secrets.toml");
313
314 assert_eq!(secrets, Secrets::load().unwrap());
315
316 Ok(())
317 });
318 }
319
320 const fn max_rfc3339() -> OffsetDateTime {
321 datetime!(9999-12-31 23:59:59.999_999_999).assume_utc()
324 }
325
326 #[test]
327 fn test_write_access_token() {
328 figment::Jail::expect_with(|jail| {
329 let secrets_file_contents = r#"
330[credentials]
331[credentials.test]
332[credentials.test.token_payload]
333access_token = "old_access_token"
334expires_in = 3600
335id_token = "id_token"
336refresh_token = "refresh_token"
337scope = "offline_access openid profile email"
338token_type = "Bearer"
339"#;
340
341 jail.create_file("secrets.toml", secrets_file_contents)
342 .expect("should create test secrets.toml");
343 let mut original_permissions = std::fs::metadata("secrets.toml")
344 .expect("Should be able to get file metadata")
345 .permissions();
346 #[cfg(unix)]
347 {
348 assert_ne!(
349 0o666,
350 original_permissions.mode(),
351 "Initial file mode should not be 666"
352 );
353 original_permissions.set_mode(0o100_666);
354 std::fs::set_permissions("secrets.toml", original_permissions.clone())
355 .expect("Should be able to set file permissions");
356 }
357 jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
358 jail.set_env("QCS_PROFILE_NAME", "test");
359
360 let rt = tokio::runtime::Runtime::new().unwrap();
361 rt.block_on(async {
362 let token_updates = [
364 ("new_access_token", max_rfc3339()),
365 ("stale_access_token", OffsetDateTime::now_utc()),
366 ];
367
368 for (access_token, updated_at) in token_updates {
369 Secrets::write_tokens(
370 "secrets.toml",
371 "test",
372 None,
373 &SecretAccessToken::from(access_token),
374 updated_at,
375 )
376 .await
377 .expect("Should be able to write access token");
378 }
379
380 let mut secrets = Secrets::load_from_path(&"secrets.toml".into()).unwrap();
382 let payload = secrets
383 .credentials
384 .remove("test")
385 .unwrap()
386 .token_payload
387 .unwrap();
388
389 assert_eq!(
390 payload.access_token.unwrap(),
391 SecretAccessToken::from("new_access_token")
392 );
393 assert_eq!(payload.updated_at.unwrap(), max_rfc3339());
394 let new_permissions = std::fs::metadata("secrets.toml")
395 .expect("Should be able to get file metadata")
396 .permissions();
397 assert_eq!(
398 original_permissions, new_permissions,
399 "Final file permissions should not be changed"
400 );
401 });
402
403 Ok(())
404 });
405 }
406}