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::collections::HashMap;
7use std::fs;
8
9use anyhow::{Context, Result};
10use serde::Serialize;
11
12use crate::datadog::error::DatadogError;
13use crate::utils::settings::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; required for every call).
53    pub api_key: String,
54
55    /// Application key (user-scoped secret; required for every call).
56    pub app_key: String,
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    let settings = Settings::load().unwrap_or(Settings {
87        env: HashMap::new(),
88    });
89
90    let api_key = settings
91        .get_env_var(DATADOG_API_KEY)
92        .ok_or(DatadogError::CredentialsNotFound)?;
93    let app_key = settings
94        .get_env_var(DATADOG_APP_KEY)
95        .ok_or(DatadogError::CredentialsNotFound)?;
96    let site = settings
97        .get_env_var(DATADOG_SITE)
98        .map(|s| normalize_site(&s))
99        .filter(|s| !s.is_empty())
100        .unwrap_or_else(|| DEFAULT_SITE.to_string());
101
102    if !KNOWN_SITES.iter().any(|k| *k == site) {
103        eprintln!("warning: Datadog site '{site}' is not a known region; proceeding anyway");
104    }
105
106    Ok(DatadogCredentials {
107        api_key,
108        app_key,
109        site,
110    })
111}
112
113/// Summary of a single Datadog credential scope.
114///
115/// Reports which credential keys are present without exposing their values.
116/// Safe to serialise and return over the MCP surface.
117#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
118pub struct DatadogScopeStatus {
119    /// Scope name (currently always `"default"`; forward-compatible for
120    /// per-instance scopes).
121    pub name: String,
122    /// Whether [`DATADOG_API_KEY`] is present.
123    pub has_api_key: bool,
124    /// Whether [`DATADOG_APP_KEY`] is present.
125    pub has_app_key: bool,
126    /// Configured site (non-secret; normalised). `None` when unset.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub site: Option<String>,
129}
130
131/// Aggregate credential status across every known Datadog scope.
132#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
133pub struct AuthStatus {
134    /// One entry per scope. Currently a single default scope; kept as a list
135    /// so future multi-instance support does not require a schema change.
136    pub scopes: Vec<DatadogScopeStatus>,
137}
138
139/// Builds an [`AuthStatus`] from the current settings / environment.
140///
141/// Reports credential presence without leaking any secret values. Safe to
142/// call with no credentials configured.
143pub fn status() -> AuthStatus {
144    let settings = Settings::load().unwrap_or(Settings {
145        env: HashMap::new(),
146    });
147
148    let has_api_key = settings.get_env_var(DATADOG_API_KEY).is_some();
149    let has_app_key = settings.get_env_var(DATADOG_APP_KEY).is_some();
150    let site = settings
151        .get_env_var(DATADOG_SITE)
152        .map(|s| normalize_site(&s))
153        .filter(|s| !s.is_empty());
154
155    AuthStatus {
156        scopes: vec![DatadogScopeStatus {
157            name: "default".to_string(),
158            has_api_key,
159            has_app_key,
160            site,
161        }],
162    }
163}
164
165/// Saves Datadog credentials to `~/.omni-dev/settings.json`.
166///
167/// Reads the existing settings file, merges the three credential keys into
168/// the `env` map, and writes back. Preserves all other settings.
169pub fn save_credentials(credentials: &DatadogCredentials) -> Result<()> {
170    let settings_path = Settings::get_settings_path()?;
171    let mut settings_value = read_or_default_settings(&settings_path)?;
172    ensure_env_object(&mut settings_value);
173
174    let Some(env) = settings_value["env"].as_object_mut() else {
175        anyhow::bail!("Internal error: env key is not an object after initialization");
176    };
177    env.insert(
178        DATADOG_API_KEY.to_string(),
179        serde_json::Value::String(credentials.api_key.clone()),
180    );
181    env.insert(
182        DATADOG_APP_KEY.to_string(),
183        serde_json::Value::String(credentials.app_key.clone()),
184    );
185    env.insert(
186        DATADOG_SITE.to_string(),
187        serde_json::Value::String(credentials.site.clone()),
188    );
189
190    write_settings(&settings_path, &settings_value)
191}
192
193/// Removes Datadog credential keys from `~/.omni-dev/settings.json`.
194///
195/// Leaves all other settings intact. Returns `true` if any Datadog key was
196/// present and removed, `false` when the file was already free of them (or
197/// did not exist).
198pub fn remove_credentials() -> Result<bool> {
199    let settings_path = Settings::get_settings_path()?;
200    if !settings_path.exists() {
201        return Ok(false);
202    }
203    let mut settings_value = read_or_default_settings(&settings_path)?;
204
205    let mut removed = false;
206    if let Some(env) = settings_value
207        .get_mut("env")
208        .and_then(serde_json::Value::as_object_mut)
209    {
210        for key in [DATADOG_API_KEY, DATADOG_APP_KEY, DATADOG_SITE] {
211            if env.remove(key).is_some() {
212                removed = true;
213            }
214        }
215    }
216
217    if removed {
218        write_settings(&settings_path, &settings_value)?;
219    }
220    Ok(removed)
221}
222
223fn read_or_default_settings(path: &std::path::Path) -> Result<serde_json::Value> {
224    if path.exists() {
225        let content = fs::read_to_string(path)
226            .with_context(|| format!("Failed to read {}", path.display()))?;
227        serde_json::from_str(&content)
228            .with_context(|| format!("Failed to parse {}", path.display()))
229    } else {
230        Ok(serde_json::json!({}))
231    }
232}
233
234fn ensure_env_object(value: &mut serde_json::Value) {
235    if !value.get("env").is_some_and(serde_json::Value::is_object) {
236        value["env"] = serde_json::json!({});
237    }
238}
239
240fn write_settings(path: &std::path::Path, value: &serde_json::Value) -> Result<()> {
241    if let Some(parent) = path.parent() {
242        fs::create_dir_all(parent)
243            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
244    }
245    let formatted =
246        serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
247    fs::write(path, formatted).with_context(|| format!("Failed to write {}", path.display()))?;
248    Ok(())
249}
250
251#[cfg(test)]
252#[allow(clippy::unwrap_used, clippy::expect_used)]
253mod tests {
254    use super::*;
255
256    // ── Pure helpers (safe to run in parallel) ─────────────────────────
257
258    #[test]
259    fn normalize_site_strips_scheme_and_api_prefix() {
260        assert_eq!(normalize_site("datadoghq.com"), "datadoghq.com");
261        assert_eq!(normalize_site("https://datadoghq.com"), "datadoghq.com");
262        assert_eq!(normalize_site("http://datadoghq.com"), "datadoghq.com");
263        assert_eq!(normalize_site("api.datadoghq.com"), "datadoghq.com");
264        assert_eq!(normalize_site("https://api.datadoghq.com"), "datadoghq.com");
265        assert_eq!(
266            normalize_site("https://api.us3.datadoghq.com/"),
267            "us3.datadoghq.com"
268        );
269    }
270
271    #[test]
272    fn normalize_site_trims_whitespace() {
273        assert_eq!(normalize_site("  datadoghq.com  "), "datadoghq.com");
274    }
275
276    #[test]
277    fn base_url_for_site_builds_api_host() {
278        assert_eq!(
279            base_url_for_site("datadoghq.com"),
280            "https://api.datadoghq.com"
281        );
282        assert_eq!(
283            base_url_for_site("us5.datadoghq.com"),
284            "https://api.us5.datadoghq.com"
285        );
286        assert_eq!(
287            base_url_for_site("datadoghq.eu"),
288            "https://api.datadoghq.eu"
289        );
290    }
291
292    #[test]
293    fn base_url_normalises_input() {
294        assert_eq!(
295            base_url_for_site("https://api.datadoghq.com/"),
296            "https://api.datadoghq.com"
297        );
298    }
299
300    #[test]
301    fn credentials_struct_clone_and_debug() {
302        let creds = DatadogCredentials {
303            api_key: "a".to_string(),
304            app_key: "b".to_string(),
305            site: "datadoghq.com".to_string(),
306        };
307        let cloned = creds.clone();
308        assert_eq!(cloned.api_key, creds.api_key);
309        assert!(format!("{creds:?}").contains("DatadogCredentials"));
310    }
311
312    #[test]
313    fn constant_key_names() {
314        assert_eq!(DATADOG_API_KEY, "DATADOG_API_KEY");
315        assert_eq!(DATADOG_APP_KEY, "DATADOG_APP_KEY");
316        assert_eq!(DATADOG_SITE, "DATADOG_SITE");
317        assert_eq!(DEFAULT_SITE, "datadoghq.com");
318    }
319
320    #[test]
321    fn known_sites_contains_common_regions() {
322        assert!(KNOWN_SITES.contains(&"datadoghq.com"));
323        assert!(KNOWN_SITES.contains(&"datadoghq.eu"));
324        assert!(KNOWN_SITES.contains(&"us5.datadoghq.com"));
325    }
326
327    // ── Tests that mutate process-wide env ────────────────────────────
328
329    use crate::datadog::test_support::{with_empty_home, EnvGuard};
330
331    #[test]
332    fn status_reports_all_false_when_nothing_configured() {
333        let guard = EnvGuard::take();
334        let _dir = with_empty_home(&guard);
335
336        let status = status();
337        assert_eq!(status.scopes.len(), 1);
338        let scope = &status.scopes[0];
339        assert_eq!(scope.name, "default");
340        assert!(!scope.has_api_key);
341        assert!(!scope.has_app_key);
342        assert_eq!(scope.site, None);
343    }
344
345    #[test]
346    fn status_reports_presence_flags_without_leaking_secrets() {
347        let guard = EnvGuard::take();
348        let dir = with_empty_home(&guard);
349        let omni_dir = dir.path().join(".omni-dev");
350        fs::create_dir_all(&omni_dir).unwrap();
351        fs::write(
352            omni_dir.join("settings.json"),
353            r#"{"env":{
354                "DATADOG_API_KEY":"sekret-api-do-not-leak",
355                "DATADOG_APP_KEY":"sekret-app-do-not-leak",
356                "DATADOG_SITE":"datadoghq.com"
357            }}"#,
358        )
359        .unwrap();
360
361        let status = status();
362        let scope = &status.scopes[0];
363        assert!(scope.has_api_key);
364        assert!(scope.has_app_key);
365        assert_eq!(scope.site.as_deref(), Some("datadoghq.com"));
366
367        let yaml = serde_yaml::to_string(&status).unwrap();
368        assert!(!yaml.contains("sekret-api-do-not-leak"));
369        assert!(!yaml.contains("sekret-app-do-not-leak"));
370    }
371
372    #[test]
373    fn status_normalises_site_value() {
374        let guard = EnvGuard::take();
375        let _dir = with_empty_home(&guard);
376        std::env::set_var(DATADOG_SITE, "https://api.us3.datadoghq.com/");
377
378        let status = status();
379        assert_eq!(status.scopes[0].site.as_deref(), Some("us3.datadoghq.com"));
380    }
381
382    #[test]
383    fn load_credentials_errors_when_api_key_missing() {
384        let guard = EnvGuard::take();
385        let _dir = with_empty_home(&guard);
386        std::env::set_var(DATADOG_APP_KEY, "app");
387
388        let err = load_credentials().unwrap_err();
389        assert!(err.to_string().contains("not configured"));
390    }
391
392    #[test]
393    fn load_credentials_defaults_site_when_unset() {
394        let guard = EnvGuard::take();
395        let _dir = with_empty_home(&guard);
396        std::env::set_var(DATADOG_API_KEY, "api");
397        std::env::set_var(DATADOG_APP_KEY, "app");
398
399        let creds = load_credentials().unwrap();
400        assert_eq!(creds.site, DEFAULT_SITE);
401    }
402
403    #[test]
404    fn load_credentials_warns_on_unknown_site_but_succeeds() {
405        let guard = EnvGuard::take();
406        let _dir = with_empty_home(&guard);
407        std::env::set_var(DATADOG_API_KEY, "api");
408        std::env::set_var(DATADOG_APP_KEY, "app");
409        std::env::set_var(DATADOG_SITE, "custom.example");
410
411        let creds = load_credentials().unwrap();
412        assert_eq!(creds.site, "custom.example");
413    }
414
415    /// Single test for save + remove credentials to avoid HOME races.
416    /// Covers fresh-file creation, merge-with-existing, and removal.
417    #[test]
418    fn save_then_remove_round_trip() {
419        let _guard = EnvGuard::take();
420
421        // ── Part 1: creates file from scratch ──────────────────────
422        {
423            let temp_dir = {
424                std::fs::create_dir_all("tmp").ok();
425                tempfile::TempDir::new_in("tmp").unwrap()
426            };
427            std::env::set_var("HOME", temp_dir.path());
428
429            let creds = DatadogCredentials {
430                api_key: "api-1".to_string(),
431                app_key: "app-1".to_string(),
432                site: "datadoghq.com".to_string(),
433            };
434            save_credentials(&creds).unwrap();
435
436            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
437            assert!(settings_path.exists());
438            let content = fs::read_to_string(&settings_path).unwrap();
439            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
440            assert_eq!(val["env"]["DATADOG_API_KEY"], "api-1");
441            assert_eq!(val["env"]["DATADOG_APP_KEY"], "app-1");
442            assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.com");
443        }
444
445        // ── Part 2: merges into existing settings ──────────────────
446        {
447            let temp_dir = {
448                std::fs::create_dir_all("tmp").ok();
449                tempfile::TempDir::new_in("tmp").unwrap()
450            };
451            let omni_dir = temp_dir.path().join(".omni-dev");
452            fs::create_dir_all(&omni_dir).unwrap();
453            let settings_path = omni_dir.join("settings.json");
454            fs::write(
455                &settings_path,
456                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
457            )
458            .unwrap();
459
460            std::env::set_var("HOME", temp_dir.path());
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(&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            std::env::set_var("HOME", temp_dir.path());
496
497            let removed = remove_credentials().unwrap();
498            assert!(removed);
499
500            let val: serde_json::Value =
501                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
502            assert!(val["env"].get("DATADOG_API_KEY").is_none());
503            assert!(val["env"].get("DATADOG_APP_KEY").is_none());
504            assert!(val["env"].get("DATADOG_SITE").is_none());
505            assert_eq!(val["env"]["OTHER_KEY"], "keep");
506        }
507
508        // ── Part 4: remove returns false when nothing to remove ────
509        {
510            let temp_dir = {
511                std::fs::create_dir_all("tmp").ok();
512                tempfile::TempDir::new_in("tmp").unwrap()
513            };
514            std::env::set_var("HOME", temp_dir.path());
515            let removed = remove_credentials().unwrap();
516            assert!(!removed);
517        }
518    }
519}