Skip to main content

omni_dev/datadog/
auth.rs

1//! Datadog credential management.
2//!
3//! Loads and saves Datadog API credentials from/to the
4//! `~/.omni-dev/settings.json` file — the active profile's `env` map when a
5//! profile is selected, the base `env` map otherwise (issue #1116).
6
7use 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
15/// Environment variable / settings key for the Datadog API key.
16pub const DATADOG_API_KEY: &str = "DATADOG_API_KEY";
17
18/// Environment variable / settings key for the Datadog application key.
19pub const DATADOG_APP_KEY: &str = "DATADOG_APP_KEY";
20
21/// Environment variable / settings key for the Datadog site (e.g. `datadoghq.com`).
22pub const DATADOG_SITE: &str = "DATADOG_SITE";
23
24/// Environment variable / settings key for an explicit Datadog API base URL.
25///
26/// When set, overrides [`DATADOG_SITE`] entirely — the client uses this URL
27/// verbatim instead of deriving `https://api.{site}`. Useful for:
28/// - Tests that point at a wiremock server (e.g. `http://127.0.0.1:PORT`).
29/// - On-prem / proxied Datadog installs that don't match `api.{site}`.
30pub const DATADOG_API_URL: &str = "DATADOG_API_URL";
31
32/// Default Datadog site when none is configured (US1 region).
33pub const DEFAULT_SITE: &str = "datadoghq.com";
34
35/// Datadog sites recognised as non-warning.
36///
37/// Any other value is accepted but logs a warning on [`load_credentials`] —
38/// Datadog adds regions occasionally and rejecting unknown values would
39/// break the CLI on each new region.
40pub 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/// Datadog API credentials.
50#[derive(Debug, Clone)]
51pub struct DatadogCredentials {
52    /// API key (organisation-scoped secret; redacted in `Debug` output).
53    pub api_key: Secret,
54
55    /// Application key (user-scoped secret; redacted in `Debug` output).
56    pub app_key: Secret,
57
58    /// Site identifier, e.g. `datadoghq.com`. Determines the base URL.
59    pub site: String,
60}
61
62/// Normalises a user-supplied site string.
63///
64/// Strips any `https://` or `http://` scheme, any `api.` subdomain prefix
65/// (users sometimes paste the full API host), and trailing slashes.
66pub 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
76/// Returns the Datadog API base URL for a given site.
77pub fn base_url_for_site(site: &str) -> String {
78    format!("https://api.{}", normalize_site(site))
79}
80
81/// Loads Datadog credentials from environment variables or settings.json.
82///
83/// Environment variables take precedence over the settings file. Emits a
84/// warning on stderr when the configured site is not in [`KNOWN_SITES`].
85pub fn load_credentials() -> Result<DatadogCredentials> {
86    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
87}
88
89/// [`load_credentials`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
90///
91/// The production wrapper passes `&SettingsEnv::load()` (process env with a
92/// settings.json fallback); tests pass a pure `MapEnv`, so credential
93/// resolution is exercised without mutating the process environment or `HOME`
94/// (issue #1030).
95pub(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/// Summary of a single Datadog credential scope.
122///
123/// Reports which credential keys are present without exposing their values.
124/// Safe to serialise and return over the MCP surface.
125#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
126pub struct DatadogScopeStatus {
127    /// Scope name (currently always `"default"`; forward-compatible for
128    /// per-instance scopes).
129    pub name: String,
130    /// Whether [`DATADOG_API_KEY`] is present.
131    pub has_api_key: bool,
132    /// Whether [`DATADOG_APP_KEY`] is present.
133    pub has_app_key: bool,
134    /// Configured site (non-secret; normalised). `None` when unset.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub site: Option<String>,
137}
138
139/// Aggregate credential status across every known Datadog scope.
140#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
141pub struct AuthStatus {
142    /// One entry per scope. Currently a single default scope; kept as a list
143    /// so future multi-instance support does not require a schema change.
144    pub scopes: Vec<DatadogScopeStatus>,
145}
146
147/// Builds an [`AuthStatus`] from the current settings / environment.
148///
149/// Reports credential presence without leaking any secret values. Safe to
150/// call with no credentials configured.
151pub fn status() -> AuthStatus {
152    status_with(&crate::utils::settings::SettingsEnv::load())
153}
154
155/// [`status`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
156///
157/// Tests pass a pure `MapEnv` to report presence without mutating the process
158/// environment or `HOME` (issue #1030).
159pub(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
177/// Saves Datadog credentials to `~/.omni-dev/settings.json`.
178///
179/// Reads the existing settings file, merges the three credential keys into
180/// the active profile's `env` map (the base `env` when no profile is active
181/// — issue #1116), and writes back. Preserves all other settings.
182pub 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
190/// [`save_credentials`], writing to an explicit settings-file path and env
191/// map (`profiles.<name>.env` when `profile` is `Some`, base `env` otherwise).
192///
193/// Tests inject a tempdir path and an explicit profile instead of mutating
194/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
195pub(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
211/// Removes Datadog credential keys from `~/.omni-dev/settings.json` — from
212/// the active profile's `env` map when a profile is active, the base `env`
213/// otherwise (issue #1116).
214///
215/// Leaves all other settings intact. Returns `true` if any Datadog key was
216/// present and removed, `false` when the targeted map was already free of
217/// them (or the file did not exist).
218pub fn remove_credentials() -> Result<bool> {
219    remove_credentials_at(
220        &Settings::get_settings_path()?,
221        active_profile_from(&SystemEnv).as_deref(),
222    )
223}
224
225/// [`remove_credentials`], operating on an explicit settings-file path and
226/// env map (`profiles.<name>.env` when `profile` is `Some`, base `env`
227/// otherwise).
228///
229/// Tests inject a tempdir path and an explicit profile instead of mutating
230/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
231pub(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    // ── Pure helpers (safe to run in parallel) ─────────────────────────
250
251    #[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        // Debug must never print the key values (#1131).
303        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    // ── Env-parsing boundary tests (injected, no process-env mutation) ──
333
334    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    /// Save + remove round-trip against injected settings-file paths — no
399    /// `HOME` mutation, so the test needs no lock. Covers fresh-file creation,
400    /// merge-with-existing, and removal.
401    #[test]
402    fn save_then_remove_round_trip() {
403        // ── Part 1: creates file from scratch ──────────────────────
404        {
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            // The credential store is created owner-only (issue #1128).
426            #[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        // ── Part 2: merges into existing settings ──────────────────
435        {
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        // ── Part 3: remove clears the three keys, preserves others ─
464        {
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        // ── Part 4: remove returns false when nothing to remove ────
495        {
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    /// Save + remove round-trip against a profile-targeted env map — the
507    /// credentials land under `profiles.<name>.env` where the profile-aware
508    /// read side will find them, and the base `env` is untouched
509    /// (issue #1116).
510    #[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    /// The production wrappers resolve `~/.omni-dev/settings.json` from
551    /// `HOME` and the active profile from `OMNI_DEV_PROFILE`, so this one
552    /// test must redirect both (under the shared
553    /// [`crate::atlassian::auth::test_util::EnvGuard`], which serialises all
554    /// env-mutating auth tests). Every other save/remove test injects a path
555    /// and profile instead (issue #1030).
556    #[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    /// A profile-targeted logout must not clear base-`env` credentials
580    /// belonging to the default bundle (issue #1116).
581    #[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}