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 using the existing `env` map.
5
6use std::fs;
7
8use anyhow::{Context, Result};
9use serde::Serialize;
10
11use crate::datadog::error::DatadogError;
12use crate::utils::settings::Settings;
13
14/// Environment variable / settings key for the Datadog API key.
15pub const DATADOG_API_KEY: &str = "DATADOG_API_KEY";
16
17/// Environment variable / settings key for the Datadog application key.
18pub const DATADOG_APP_KEY: &str = "DATADOG_APP_KEY";
19
20/// Environment variable / settings key for the Datadog site (e.g. `datadoghq.com`).
21pub const DATADOG_SITE: &str = "DATADOG_SITE";
22
23/// Environment variable / settings key for an explicit Datadog API base URL.
24///
25/// When set, overrides [`DATADOG_SITE`] entirely — the client uses this URL
26/// verbatim instead of deriving `https://api.{site}`. Useful for:
27/// - Tests that point at a wiremock server (e.g. `http://127.0.0.1:PORT`).
28/// - On-prem / proxied Datadog installs that don't match `api.{site}`.
29pub const DATADOG_API_URL: &str = "DATADOG_API_URL";
30
31/// Default Datadog site when none is configured (US1 region).
32pub const DEFAULT_SITE: &str = "datadoghq.com";
33
34/// Datadog sites recognised as non-warning.
35///
36/// Any other value is accepted but logs a warning on [`load_credentials`] —
37/// Datadog adds regions occasionally and rejecting unknown values would
38/// break the CLI on each new region.
39pub const KNOWN_SITES: &[&str] = &[
40    "datadoghq.com",
41    "us3.datadoghq.com",
42    "us5.datadoghq.com",
43    "datadoghq.eu",
44    "ap1.datadoghq.com",
45    "ddog-gov.com",
46];
47
48/// Datadog API credentials.
49#[derive(Debug, Clone)]
50pub struct DatadogCredentials {
51    /// API key (organisation-scoped secret; required for every call).
52    pub api_key: String,
53
54    /// Application key (user-scoped secret; required for every call).
55    pub app_key: String,
56
57    /// Site identifier, e.g. `datadoghq.com`. Determines the base URL.
58    pub site: String,
59}
60
61/// Normalises a user-supplied site string.
62///
63/// Strips any `https://` or `http://` scheme, any `api.` subdomain prefix
64/// (users sometimes paste the full API host), and trailing slashes.
65pub fn normalize_site(raw: &str) -> String {
66    let trimmed = raw.trim();
67    let no_scheme = trimmed
68        .strip_prefix("https://")
69        .or_else(|| trimmed.strip_prefix("http://"))
70        .unwrap_or(trimmed);
71    let no_api = no_scheme.strip_prefix("api.").unwrap_or(no_scheme);
72    no_api.trim_end_matches('/').to_string()
73}
74
75/// Returns the Datadog API base URL for a given site.
76pub fn base_url_for_site(site: &str) -> String {
77    format!("https://api.{}", normalize_site(site))
78}
79
80/// Loads Datadog credentials from environment variables or settings.json.
81///
82/// Environment variables take precedence over the settings file. Emits a
83/// warning on stderr when the configured site is not in [`KNOWN_SITES`].
84pub fn load_credentials() -> Result<DatadogCredentials> {
85    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
86}
87
88/// [`load_credentials`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
89///
90/// The production wrapper passes `&SettingsEnv::load()` (process env with a
91/// settings.json fallback); tests pass a pure `MapEnv`, so credential
92/// resolution is exercised without mutating the process environment or `HOME`
93/// (issue #1030).
94pub(crate) fn load_credentials_with(
95    env: &impl crate::utils::env::EnvSource,
96) -> Result<DatadogCredentials> {
97    let api_key = env
98        .var(DATADOG_API_KEY)
99        .ok_or(DatadogError::CredentialsNotFound)?;
100    let app_key = env
101        .var(DATADOG_APP_KEY)
102        .ok_or(DatadogError::CredentialsNotFound)?;
103    let site = env
104        .var(DATADOG_SITE)
105        .map(|s| normalize_site(&s))
106        .filter(|s| !s.is_empty())
107        .unwrap_or_else(|| DEFAULT_SITE.to_string());
108
109    if !KNOWN_SITES.iter().any(|k| *k == site) {
110        eprintln!("warning: Datadog site '{site}' is not a known region; proceeding anyway");
111    }
112
113    Ok(DatadogCredentials {
114        api_key,
115        app_key,
116        site,
117    })
118}
119
120/// Summary of a single Datadog credential scope.
121///
122/// Reports which credential keys are present without exposing their values.
123/// Safe to serialise and return over the MCP surface.
124#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
125pub struct DatadogScopeStatus {
126    /// Scope name (currently always `"default"`; forward-compatible for
127    /// per-instance scopes).
128    pub name: String,
129    /// Whether [`DATADOG_API_KEY`] is present.
130    pub has_api_key: bool,
131    /// Whether [`DATADOG_APP_KEY`] is present.
132    pub has_app_key: bool,
133    /// Configured site (non-secret; normalised). `None` when unset.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub site: Option<String>,
136}
137
138/// Aggregate credential status across every known Datadog scope.
139#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
140pub struct AuthStatus {
141    /// One entry per scope. Currently a single default scope; kept as a list
142    /// so future multi-instance support does not require a schema change.
143    pub scopes: Vec<DatadogScopeStatus>,
144}
145
146/// Builds an [`AuthStatus`] from the current settings / environment.
147///
148/// Reports credential presence without leaking any secret values. Safe to
149/// call with no credentials configured.
150pub fn status() -> AuthStatus {
151    status_with(&crate::utils::settings::SettingsEnv::load())
152}
153
154/// [`status`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
155///
156/// Tests pass a pure `MapEnv` to report presence without mutating the process
157/// environment or `HOME` (issue #1030).
158pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> AuthStatus {
159    let has_api_key = env.var(DATADOG_API_KEY).is_some();
160    let has_app_key = env.var(DATADOG_APP_KEY).is_some();
161    let site = env
162        .var(DATADOG_SITE)
163        .map(|s| normalize_site(&s))
164        .filter(|s| !s.is_empty());
165
166    AuthStatus {
167        scopes: vec![DatadogScopeStatus {
168            name: "default".to_string(),
169            has_api_key,
170            has_app_key,
171            site,
172        }],
173    }
174}
175
176/// Saves Datadog credentials to `~/.omni-dev/settings.json`.
177///
178/// Reads the existing settings file, merges the three credential keys into
179/// the `env` map, and writes back. Preserves all other settings.
180pub fn save_credentials(credentials: &DatadogCredentials) -> Result<()> {
181    save_credentials_to(&Settings::get_settings_path()?, credentials)
182}
183
184/// [`save_credentials`], writing to an explicit settings-file path.
185///
186/// Tests inject a tempdir path instead of redirecting `HOME` (issue #1030).
187pub(crate) fn save_credentials_to(
188    settings_path: &std::path::Path,
189    credentials: &DatadogCredentials,
190) -> Result<()> {
191    let mut settings_value = read_or_default_settings(settings_path)?;
192    ensure_env_object(&mut settings_value);
193
194    let Some(env) = settings_value["env"].as_object_mut() else {
195        anyhow::bail!("Internal error: env key is not an object after initialization");
196    };
197    env.insert(
198        DATADOG_API_KEY.to_string(),
199        serde_json::Value::String(credentials.api_key.clone()),
200    );
201    env.insert(
202        DATADOG_APP_KEY.to_string(),
203        serde_json::Value::String(credentials.app_key.clone()),
204    );
205    env.insert(
206        DATADOG_SITE.to_string(),
207        serde_json::Value::String(credentials.site.clone()),
208    );
209
210    write_settings(settings_path, &settings_value)
211}
212
213/// Removes Datadog credential keys from `~/.omni-dev/settings.json`.
214///
215/// Leaves all other settings intact. Returns `true` if any Datadog key was
216/// present and removed, `false` when the file was already free of them (or
217/// did not exist).
218pub fn remove_credentials() -> Result<bool> {
219    remove_credentials_at(&Settings::get_settings_path()?)
220}
221
222/// [`remove_credentials`], operating on an explicit settings-file path.
223///
224/// Tests inject a tempdir path instead of redirecting `HOME` (issue #1030).
225pub(crate) fn remove_credentials_at(settings_path: &std::path::Path) -> Result<bool> {
226    if !settings_path.exists() {
227        return Ok(false);
228    }
229    let mut settings_value = read_or_default_settings(settings_path)?;
230
231    let mut removed = false;
232    if let Some(env) = settings_value
233        .get_mut("env")
234        .and_then(serde_json::Value::as_object_mut)
235    {
236        for key in [DATADOG_API_KEY, DATADOG_APP_KEY, DATADOG_SITE] {
237            if env.remove(key).is_some() {
238                removed = true;
239            }
240        }
241    }
242
243    if removed {
244        write_settings(settings_path, &settings_value)?;
245    }
246    Ok(removed)
247}
248
249fn read_or_default_settings(path: &std::path::Path) -> Result<serde_json::Value> {
250    if path.exists() {
251        let content = fs::read_to_string(path)
252            .with_context(|| format!("Failed to read {}", path.display()))?;
253        serde_json::from_str(&content)
254            .with_context(|| format!("Failed to parse {}", path.display()))
255    } else {
256        Ok(serde_json::json!({}))
257    }
258}
259
260fn ensure_env_object(value: &mut serde_json::Value) {
261    if !value.get("env").is_some_and(serde_json::Value::is_object) {
262        value["env"] = serde_json::json!({});
263    }
264}
265
266fn write_settings(path: &std::path::Path, value: &serde_json::Value) -> Result<()> {
267    if let Some(parent) = path.parent() {
268        fs::create_dir_all(parent)
269            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
270    }
271    let formatted =
272        serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
273    fs::write(path, formatted).with_context(|| format!("Failed to write {}", path.display()))?;
274    Ok(())
275}
276
277#[cfg(test)]
278#[allow(clippy::unwrap_used, clippy::expect_used)]
279mod tests {
280    use super::*;
281
282    // ── Pure helpers (safe to run in parallel) ─────────────────────────
283
284    #[test]
285    fn normalize_site_strips_scheme_and_api_prefix() {
286        assert_eq!(normalize_site("datadoghq.com"), "datadoghq.com");
287        assert_eq!(normalize_site("https://datadoghq.com"), "datadoghq.com");
288        assert_eq!(normalize_site("http://datadoghq.com"), "datadoghq.com");
289        assert_eq!(normalize_site("api.datadoghq.com"), "datadoghq.com");
290        assert_eq!(normalize_site("https://api.datadoghq.com"), "datadoghq.com");
291        assert_eq!(
292            normalize_site("https://api.us3.datadoghq.com/"),
293            "us3.datadoghq.com"
294        );
295    }
296
297    #[test]
298    fn normalize_site_trims_whitespace() {
299        assert_eq!(normalize_site("  datadoghq.com  "), "datadoghq.com");
300    }
301
302    #[test]
303    fn base_url_for_site_builds_api_host() {
304        assert_eq!(
305            base_url_for_site("datadoghq.com"),
306            "https://api.datadoghq.com"
307        );
308        assert_eq!(
309            base_url_for_site("us5.datadoghq.com"),
310            "https://api.us5.datadoghq.com"
311        );
312        assert_eq!(
313            base_url_for_site("datadoghq.eu"),
314            "https://api.datadoghq.eu"
315        );
316    }
317
318    #[test]
319    fn base_url_normalises_input() {
320        assert_eq!(
321            base_url_for_site("https://api.datadoghq.com/"),
322            "https://api.datadoghq.com"
323        );
324    }
325
326    #[test]
327    fn credentials_struct_clone_and_debug() {
328        let creds = DatadogCredentials {
329            api_key: "a".to_string(),
330            app_key: "b".to_string(),
331            site: "datadoghq.com".to_string(),
332        };
333        let cloned = creds.clone();
334        assert_eq!(cloned.api_key, creds.api_key);
335        assert!(format!("{creds:?}").contains("DatadogCredentials"));
336    }
337
338    #[test]
339    fn constant_key_names() {
340        assert_eq!(DATADOG_API_KEY, "DATADOG_API_KEY");
341        assert_eq!(DATADOG_APP_KEY, "DATADOG_APP_KEY");
342        assert_eq!(DATADOG_SITE, "DATADOG_SITE");
343        assert_eq!(DEFAULT_SITE, "datadoghq.com");
344    }
345
346    #[test]
347    fn known_sites_contains_common_regions() {
348        assert!(KNOWN_SITES.contains(&"datadoghq.com"));
349        assert!(KNOWN_SITES.contains(&"datadoghq.eu"));
350        assert!(KNOWN_SITES.contains(&"us5.datadoghq.com"));
351    }
352
353    // ── Env-parsing boundary tests (injected, no process-env mutation) ──
354
355    use crate::test_support::env::MapEnv;
356
357    #[test]
358    fn status_reports_all_false_when_nothing_configured() {
359        let status = status_with(&MapEnv::new());
360        assert_eq!(status.scopes.len(), 1);
361        let scope = &status.scopes[0];
362        assert_eq!(scope.name, "default");
363        assert!(!scope.has_api_key);
364        assert!(!scope.has_app_key);
365        assert_eq!(scope.site, None);
366    }
367
368    #[test]
369    fn status_reports_presence_flags_without_leaking_secrets() {
370        let env = MapEnv::new()
371            .with(DATADOG_API_KEY, "sekret-api-do-not-leak")
372            .with(DATADOG_APP_KEY, "sekret-app-do-not-leak")
373            .with(DATADOG_SITE, "datadoghq.com");
374
375        let status = status_with(&env);
376        let scope = &status.scopes[0];
377        assert!(scope.has_api_key);
378        assert!(scope.has_app_key);
379        assert_eq!(scope.site.as_deref(), Some("datadoghq.com"));
380
381        let yaml = serde_yaml::to_string(&status).unwrap();
382        assert!(!yaml.contains("sekret-api-do-not-leak"));
383        assert!(!yaml.contains("sekret-app-do-not-leak"));
384    }
385
386    #[test]
387    fn status_normalises_site_value() {
388        let env = MapEnv::new().with(DATADOG_SITE, "https://api.us3.datadoghq.com/");
389        let status = status_with(&env);
390        assert_eq!(status.scopes[0].site.as_deref(), Some("us3.datadoghq.com"));
391    }
392
393    #[test]
394    fn load_credentials_errors_when_api_key_missing() {
395        let env = MapEnv::new().with(DATADOG_APP_KEY, "app");
396        let err = load_credentials_with(&env).unwrap_err();
397        assert!(err.to_string().contains("not configured"));
398    }
399
400    #[test]
401    fn load_credentials_defaults_site_when_unset() {
402        let env = MapEnv::new()
403            .with(DATADOG_API_KEY, "api")
404            .with(DATADOG_APP_KEY, "app");
405        let creds = load_credentials_with(&env).unwrap();
406        assert_eq!(creds.site, DEFAULT_SITE);
407    }
408
409    #[test]
410    fn load_credentials_warns_on_unknown_site_but_succeeds() {
411        let env = MapEnv::new()
412            .with(DATADOG_API_KEY, "api")
413            .with(DATADOG_APP_KEY, "app")
414            .with(DATADOG_SITE, "custom.example");
415        let creds = load_credentials_with(&env).unwrap();
416        assert_eq!(creds.site, "custom.example");
417    }
418
419    /// Save + remove round-trip against injected settings-file paths — no
420    /// `HOME` mutation, so the test needs no lock. Covers fresh-file creation,
421    /// merge-with-existing, and removal.
422    #[test]
423    fn save_then_remove_round_trip() {
424        // ── Part 1: creates file from scratch ──────────────────────
425        {
426            let temp_dir = {
427                std::fs::create_dir_all("tmp").ok();
428                tempfile::TempDir::new_in("tmp").unwrap()
429            };
430            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
431
432            let creds = DatadogCredentials {
433                api_key: "api-1".to_string(),
434                app_key: "app-1".to_string(),
435                site: "datadoghq.com".to_string(),
436            };
437            save_credentials_to(&settings_path, &creds).unwrap();
438
439            assert!(settings_path.exists());
440            let content = fs::read_to_string(&settings_path).unwrap();
441            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
442            assert_eq!(val["env"]["DATADOG_API_KEY"], "api-1");
443            assert_eq!(val["env"]["DATADOG_APP_KEY"], "app-1");
444            assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.com");
445        }
446
447        // ── Part 2: merges into existing settings ──────────────────
448        {
449            let temp_dir = {
450                std::fs::create_dir_all("tmp").ok();
451                tempfile::TempDir::new_in("tmp").unwrap()
452            };
453            let omni_dir = temp_dir.path().join(".omni-dev");
454            fs::create_dir_all(&omni_dir).unwrap();
455            let settings_path = omni_dir.join("settings.json");
456            fs::write(
457                &settings_path,
458                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
459            )
460            .unwrap();
461
462            let creds = DatadogCredentials {
463                api_key: "api-2".to_string(),
464                app_key: "app-2".to_string(),
465                site: "datadoghq.eu".to_string(),
466            };
467            save_credentials_to(&settings_path, &creds).unwrap();
468
469            let val: serde_json::Value =
470                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
471            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
472            assert_eq!(val["extra"], true);
473            assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.eu");
474        }
475
476        // ── Part 3: remove clears the three keys, preserves others ─
477        {
478            let temp_dir = {
479                std::fs::create_dir_all("tmp").ok();
480                tempfile::TempDir::new_in("tmp").unwrap()
481            };
482            let omni_dir = temp_dir.path().join(".omni-dev");
483            fs::create_dir_all(&omni_dir).unwrap();
484            let settings_path = omni_dir.join("settings.json");
485            fs::write(
486                &settings_path,
487                r#"{"env": {
488                    "DATADOG_API_KEY": "a",
489                    "DATADOG_APP_KEY": "b",
490                    "DATADOG_SITE": "datadoghq.com",
491                    "OTHER_KEY": "keep"
492                }}"#,
493            )
494            .unwrap();
495
496            let removed = remove_credentials_at(&settings_path).unwrap();
497            assert!(removed);
498
499            let val: serde_json::Value =
500                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
501            assert!(val["env"].get("DATADOG_API_KEY").is_none());
502            assert!(val["env"].get("DATADOG_APP_KEY").is_none());
503            assert!(val["env"].get("DATADOG_SITE").is_none());
504            assert_eq!(val["env"]["OTHER_KEY"], "keep");
505        }
506
507        // ── Part 4: remove returns false when nothing to remove ────
508        {
509            let temp_dir = {
510                std::fs::create_dir_all("tmp").ok();
511                tempfile::TempDir::new_in("tmp").unwrap()
512            };
513            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
514            let removed = remove_credentials_at(&settings_path).unwrap();
515            assert!(!removed);
516        }
517    }
518}