1use anyhow::Result;
8use serde::Serialize;
9
10use crate::datadog::error::DatadogError;
11use crate::utils::env::SystemEnv;
12use crate::utils::secret::Secret;
13use crate::utils::secret_env::{secret_var, secret_var_is_set};
14use crate::utils::settings::{active_profile_from, Settings};
15
16pub const DATADOG_API_KEY: &str = "DATADOG_API_KEY";
18
19pub const DATADOG_APP_KEY: &str = "DATADOG_APP_KEY";
21
22pub const DATADOG_SITE: &str = "DATADOG_SITE";
24
25pub const DATADOG_API_URL: &str = "DATADOG_API_URL";
32
33pub const DEFAULT_SITE: &str = "datadoghq.com";
35
36pub const KNOWN_SITES: &[&str] = &[
42 "datadoghq.com",
43 "us3.datadoghq.com",
44 "us5.datadoghq.com",
45 "datadoghq.eu",
46 "ap1.datadoghq.com",
47 "ddog-gov.com",
48];
49
50#[derive(Debug, Clone)]
52pub struct DatadogCredentials {
53 pub api_key: Secret,
55
56 pub app_key: Secret,
58
59 pub site: String,
61}
62
63pub fn normalize_site(raw: &str) -> String {
68 let trimmed = raw.trim();
69 let no_scheme = trimmed
70 .strip_prefix("https://")
71 .or_else(|| trimmed.strip_prefix("http://"))
72 .unwrap_or(trimmed);
73 let no_api = no_scheme.strip_prefix("api.").unwrap_or(no_scheme);
74 no_api.trim_end_matches('/').to_string()
75}
76
77pub fn base_url_for_site(site: &str) -> String {
79 format!("https://api.{}", normalize_site(site))
80}
81
82pub fn load_credentials() -> Result<DatadogCredentials> {
87 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
88}
89
90pub(crate) fn load_credentials_with(
97 env: &impl crate::utils::env::EnvSource,
98) -> Result<DatadogCredentials> {
99 let api_key = secret_var(env, DATADOG_API_KEY)?.ok_or(DatadogError::CredentialsNotFound)?;
100 let app_key = secret_var(env, DATADOG_APP_KEY)?.ok_or(DatadogError::CredentialsNotFound)?;
101 let site = env
102 .var(DATADOG_SITE)
103 .map(|s| normalize_site(&s))
104 .filter(|s| !s.is_empty())
105 .unwrap_or_else(|| DEFAULT_SITE.to_string());
106
107 if !KNOWN_SITES.iter().any(|k| *k == site) {
108 eprintln!("warning: Datadog site '{site}' is not a known region; proceeding anyway");
109 }
110
111 Ok(DatadogCredentials {
112 api_key,
113 app_key,
114 site,
115 })
116}
117
118#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
123pub struct DatadogScopeStatus {
124 pub name: String,
127 pub has_api_key: bool,
129 pub has_app_key: bool,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub site: Option<String>,
134}
135
136#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
138pub struct AuthStatus {
139 pub scopes: Vec<DatadogScopeStatus>,
142}
143
144pub fn status() -> AuthStatus {
149 status_with(&crate::utils::settings::SettingsEnv::load())
150}
151
152pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> AuthStatus {
157 let has_api_key = secret_var_is_set(env, DATADOG_API_KEY);
158 let has_app_key = secret_var_is_set(env, DATADOG_APP_KEY);
159 let site = env
160 .var(DATADOG_SITE)
161 .map(|s| normalize_site(&s))
162 .filter(|s| !s.is_empty());
163
164 AuthStatus {
165 scopes: vec![DatadogScopeStatus {
166 name: "default".to_string(),
167 has_api_key,
168 has_app_key,
169 site,
170 }],
171 }
172}
173
174pub fn save_credentials(credentials: &DatadogCredentials) -> Result<()> {
180 save_credentials_to(
181 &Settings::get_settings_path()?,
182 active_profile_from(&SystemEnv).as_deref(),
183 credentials,
184 )
185}
186
187pub(crate) fn save_credentials_to(
193 settings_path: &std::path::Path,
194 profile: Option<&str>,
195 credentials: &DatadogCredentials,
196) -> Result<()> {
197 Settings::upsert_env_vars_in(
198 settings_path,
199 profile,
200 &[
201 (DATADOG_API_KEY, credentials.api_key.expose_secret()),
202 (DATADOG_APP_KEY, credentials.app_key.expose_secret()),
203 (DATADOG_SITE, credentials.site.as_str()),
204 ],
205 )
206}
207
208pub fn remove_credentials() -> Result<bool> {
216 remove_credentials_at(
217 &Settings::get_settings_path()?,
218 active_profile_from(&SystemEnv).as_deref(),
219 )
220}
221
222pub(crate) fn remove_credentials_at(
229 settings_path: &std::path::Path,
230 profile: Option<&str>,
231) -> Result<bool> {
232 Settings::remove_env_vars_in(
233 settings_path,
234 profile,
235 &[DATADOG_API_KEY, DATADOG_APP_KEY, DATADOG_SITE],
236 )
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::expect_used)]
241mod tests {
242 use std::fs;
243
244 use super::*;
245
246 #[test]
249 fn normalize_site_strips_scheme_and_api_prefix() {
250 assert_eq!(normalize_site("datadoghq.com"), "datadoghq.com");
251 assert_eq!(normalize_site("https://datadoghq.com"), "datadoghq.com");
252 assert_eq!(normalize_site("http://datadoghq.com"), "datadoghq.com");
253 assert_eq!(normalize_site("api.datadoghq.com"), "datadoghq.com");
254 assert_eq!(normalize_site("https://api.datadoghq.com"), "datadoghq.com");
255 assert_eq!(
256 normalize_site("https://api.us3.datadoghq.com/"),
257 "us3.datadoghq.com"
258 );
259 }
260
261 #[test]
262 fn normalize_site_trims_whitespace() {
263 assert_eq!(normalize_site(" datadoghq.com "), "datadoghq.com");
264 }
265
266 #[test]
267 fn base_url_for_site_builds_api_host() {
268 assert_eq!(
269 base_url_for_site("datadoghq.com"),
270 "https://api.datadoghq.com"
271 );
272 assert_eq!(
273 base_url_for_site("us5.datadoghq.com"),
274 "https://api.us5.datadoghq.com"
275 );
276 assert_eq!(
277 base_url_for_site("datadoghq.eu"),
278 "https://api.datadoghq.eu"
279 );
280 }
281
282 #[test]
283 fn base_url_normalises_input() {
284 assert_eq!(
285 base_url_for_site("https://api.datadoghq.com/"),
286 "https://api.datadoghq.com"
287 );
288 }
289
290 #[test]
291 fn credentials_struct_clone_and_debug() {
292 let creds = DatadogCredentials {
293 api_key: "sekret-api-key-value".into(),
294 app_key: "sekret-app-key-value".into(),
295 site: "datadoghq.com".to_string(),
296 };
297 let cloned = creds.clone();
298 assert_eq!(cloned.api_key, creds.api_key);
299 let debug = format!("{creds:?}");
301 assert!(debug.contains("DatadogCredentials"));
302 assert!(
303 !debug.contains("sekret-api-key-value"),
304 "leaked api_key: {debug}"
305 );
306 assert!(
307 !debug.contains("sekret-app-key-value"),
308 "leaked app_key: {debug}"
309 );
310 assert!(debug.contains("api_key: <redacted>"));
311 assert!(debug.contains("app_key: <redacted>"));
312 }
313
314 #[test]
315 fn constant_key_names() {
316 assert_eq!(DATADOG_API_KEY, "DATADOG_API_KEY");
317 assert_eq!(DATADOG_APP_KEY, "DATADOG_APP_KEY");
318 assert_eq!(DATADOG_SITE, "DATADOG_SITE");
319 assert_eq!(DEFAULT_SITE, "datadoghq.com");
320 }
321
322 #[test]
323 fn known_sites_contains_common_regions() {
324 assert!(KNOWN_SITES.contains(&"datadoghq.com"));
325 assert!(KNOWN_SITES.contains(&"datadoghq.eu"));
326 assert!(KNOWN_SITES.contains(&"us5.datadoghq.com"));
327 }
328
329 use crate::test_support::env::MapEnv;
332
333 #[test]
334 fn keys_can_come_from_files_and_count_as_present() {
335 let (_dir, path) = crate::test_support::env::secret_file("app-from-file\n");
336 let env = MapEnv::new()
337 .with(DATADOG_API_KEY, "api")
338 .with("DATADOG_APP_KEY_FILE", &path);
339 let creds = load_credentials_with(&env).unwrap();
340 assert_eq!(creds.app_key.expose_secret(), "app-from-file");
341 assert!(status_with(&env).scopes[0].has_app_key);
342 }
343
344 #[test]
345 fn status_reports_all_false_when_nothing_configured() {
346 let status = status_with(&MapEnv::new());
347 assert_eq!(status.scopes.len(), 1);
348 let scope = &status.scopes[0];
349 assert_eq!(scope.name, "default");
350 assert!(!scope.has_api_key);
351 assert!(!scope.has_app_key);
352 assert_eq!(scope.site, None);
353 }
354
355 #[test]
356 fn status_reports_presence_flags_without_leaking_secrets() {
357 let env = MapEnv::new()
358 .with(DATADOG_API_KEY, "sekret-api-do-not-leak")
359 .with(DATADOG_APP_KEY, "sekret-app-do-not-leak")
360 .with(DATADOG_SITE, "datadoghq.com");
361
362 let status = status_with(&env);
363 let scope = &status.scopes[0];
364 assert!(scope.has_api_key);
365 assert!(scope.has_app_key);
366 assert_eq!(scope.site.as_deref(), Some("datadoghq.com"));
367
368 let yaml = serde_yaml::to_string(&status).unwrap();
369 assert!(!yaml.contains("sekret-api-do-not-leak"));
370 assert!(!yaml.contains("sekret-app-do-not-leak"));
371 }
372
373 #[test]
374 fn status_normalises_site_value() {
375 let env = MapEnv::new().with(DATADOG_SITE, "https://api.us3.datadoghq.com/");
376 let status = status_with(&env);
377 assert_eq!(status.scopes[0].site.as_deref(), Some("us3.datadoghq.com"));
378 }
379
380 #[test]
381 fn load_credentials_errors_when_api_key_missing() {
382 let env = MapEnv::new().with(DATADOG_APP_KEY, "app");
383 let err = load_credentials_with(&env).unwrap_err();
384 assert!(err.to_string().contains("not configured"));
385 }
386
387 #[test]
388 fn load_credentials_defaults_site_when_unset() {
389 let env = MapEnv::new()
390 .with(DATADOG_API_KEY, "api")
391 .with(DATADOG_APP_KEY, "app");
392 let creds = load_credentials_with(&env).unwrap();
393 assert_eq!(creds.site, DEFAULT_SITE);
394 }
395
396 #[test]
397 fn load_credentials_warns_on_unknown_site_but_succeeds() {
398 let env = MapEnv::new()
399 .with(DATADOG_API_KEY, "api")
400 .with(DATADOG_APP_KEY, "app")
401 .with(DATADOG_SITE, "custom.example");
402 let creds = load_credentials_with(&env).unwrap();
403 assert_eq!(creds.site, "custom.example");
404 }
405
406 #[test]
410 fn save_then_remove_round_trip() {
411 {
413 let temp_dir = {
414 std::fs::create_dir_all("tmp").ok();
415 tempfile::TempDir::new_in("tmp").unwrap()
416 };
417 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
418
419 let creds = DatadogCredentials {
420 api_key: "api-1".into(),
421 app_key: "app-1".into(),
422 site: "datadoghq.com".to_string(),
423 };
424 save_credentials_to(&settings_path, None, &creds).unwrap();
425
426 assert!(settings_path.exists());
427 let content = fs::read_to_string(&settings_path).unwrap();
428 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
429 assert_eq!(val["env"]["DATADOG_API_KEY"], "api-1");
430 assert_eq!(val["env"]["DATADOG_APP_KEY"], "app-1");
431 assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.com");
432
433 #[cfg(unix)]
435 {
436 use std::os::unix::fs::PermissionsExt;
437 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
438 assert_eq!(mode & 0o777, 0o600);
439 }
440 }
441
442 {
444 let temp_dir = {
445 std::fs::create_dir_all("tmp").ok();
446 tempfile::TempDir::new_in("tmp").unwrap()
447 };
448 let omni_dir = temp_dir.path().join(".omni-dev");
449 fs::create_dir_all(&omni_dir).unwrap();
450 let settings_path = omni_dir.join("settings.json");
451 fs::write(
452 &settings_path,
453 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
454 )
455 .unwrap();
456
457 let creds = DatadogCredentials {
458 api_key: "api-2".into(),
459 app_key: "app-2".into(),
460 site: "datadoghq.eu".to_string(),
461 };
462 save_credentials_to(&settings_path, None, &creds).unwrap();
463
464 let val: serde_json::Value =
465 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
466 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
467 assert_eq!(val["extra"], true);
468 assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.eu");
469 }
470
471 {
473 let temp_dir = {
474 std::fs::create_dir_all("tmp").ok();
475 tempfile::TempDir::new_in("tmp").unwrap()
476 };
477 let omni_dir = temp_dir.path().join(".omni-dev");
478 fs::create_dir_all(&omni_dir).unwrap();
479 let settings_path = omni_dir.join("settings.json");
480 fs::write(
481 &settings_path,
482 r#"{"env": {
483 "DATADOG_API_KEY": "a",
484 "DATADOG_APP_KEY": "b",
485 "DATADOG_SITE": "datadoghq.com",
486 "OTHER_KEY": "keep"
487 }}"#,
488 )
489 .unwrap();
490
491 let removed = remove_credentials_at(&settings_path, None).unwrap();
492 assert!(removed);
493
494 let val: serde_json::Value =
495 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
496 assert!(val["env"].get("DATADOG_API_KEY").is_none());
497 assert!(val["env"].get("DATADOG_APP_KEY").is_none());
498 assert!(val["env"].get("DATADOG_SITE").is_none());
499 assert_eq!(val["env"]["OTHER_KEY"], "keep");
500 }
501
502 {
504 let temp_dir = {
505 std::fs::create_dir_all("tmp").ok();
506 tempfile::TempDir::new_in("tmp").unwrap()
507 };
508 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
509 let removed = remove_credentials_at(&settings_path, None).unwrap();
510 assert!(!removed);
511 }
512 }
513
514 #[test]
519 fn save_then_remove_round_trip_in_profile() {
520 let temp_dir = {
521 std::fs::create_dir_all("tmp").ok();
522 tempfile::TempDir::new_in("tmp").unwrap()
523 };
524 let omni_dir = temp_dir.path().join(".omni-dev");
525 fs::create_dir_all(&omni_dir).unwrap();
526 let settings_path = omni_dir.join("settings.json");
527 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
528
529 let creds = DatadogCredentials {
530 api_key: "api-p".into(),
531 app_key: "app-p".into(),
532 site: "datadoghq.eu".to_string(),
533 };
534 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
535
536 let val: serde_json::Value =
537 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
538 assert_eq!(val["profiles"]["work"]["env"]["DATADOG_API_KEY"], "api-p");
539 assert_eq!(
540 val["profiles"]["work"]["env"]["DATADOG_SITE"],
541 "datadoghq.eu"
542 );
543 assert!(val["env"].get("DATADOG_API_KEY").is_none());
544 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
545
546 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
547 assert!(removed);
548 let val: serde_json::Value =
549 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
550 assert!(val["profiles"]["work"]["env"]
551 .get("DATADOG_API_KEY")
552 .is_none());
553
554 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
555 assert!(!removed);
556 }
557
558 #[test]
565 fn save_and_remove_credentials_resolve_default_settings_path() {
566 let guard = crate::atlassian::auth::test_util::EnvGuard::take();
567 let dir = guard.clear_credentials();
568
569 let creds = DatadogCredentials {
570 api_key: "wrapper-api".into(),
571 app_key: "wrapper-app".into(),
572 site: "datadoghq.com".to_string(),
573 };
574 save_credentials(&creds).unwrap();
575
576 let settings_path = dir.path().join(".omni-dev").join("settings.json");
577 let val: serde_json::Value =
578 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
579 assert_eq!(val["env"]["DATADOG_API_KEY"], "wrapper-api");
580
581 assert!(remove_credentials().unwrap());
582 let val: serde_json::Value =
583 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
584 assert!(val["env"].get("DATADOG_API_KEY").is_none());
585 }
586
587 #[test]
590 fn remove_credentials_at_profile_ignores_base_keys() {
591 let temp_dir = {
592 std::fs::create_dir_all("tmp").ok();
593 tempfile::TempDir::new_in("tmp").unwrap()
594 };
595 let omni_dir = temp_dir.path().join(".omni-dev");
596 fs::create_dir_all(&omni_dir).unwrap();
597 let settings_path = omni_dir.join("settings.json");
598 fs::write(
599 &settings_path,
600 r#"{"env": {"DATADOG_API_KEY": "base-a", "DATADOG_APP_KEY": "base-b"}}"#,
601 )
602 .unwrap();
603
604 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
605 assert!(!removed);
606
607 let val: serde_json::Value =
608 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
609 assert_eq!(val["env"]["DATADOG_API_KEY"], "base-a");
610 assert_eq!(val["env"]["DATADOG_APP_KEY"], "base-b");
611 }
612}