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