diff --git a/README.md b/README.md index bbd0e516..bf814e33 100644 --- a/README.md +++ b/README.md @@ -211,9 +211,13 @@ Download pre-built binaries from the [latest release](https://github.com/DataDog ## Authentication -Pup supports two authentication methods. **OAuth2 is preferred** and will be used automatically if you've logged in. +Pup supports three authentication methods, in the order we recommend trying them: -### OAuth2 Authentication (Preferred) +1. **OAuth2** (preferred) -- easiest, no credentials to manage, automatic refresh. API coverage is expanding toward 100%, but a few endpoints are not yet OAuth-compatible. +2. **Personal Access Token (PAT)** or **Service Access Token (SAT)** -- scoped, time-limited (24h to 1 year), supported anywhere `DD_API_KEY` + `DD_APP_KEY` is supported. Prefer this over API+App Key whenever OAuth is not an option. A PAT represents an interactive user; a SAT represents a [service account](https://docs.datadoghq.com/account_management/service-access-tokens/). They are wire-identical and pup handles them the same way. +3. **API Key + Application Key** -- the long-lived classic. Works everywhere. Mildly discouraged in favor of PAT/SAT (which are scoped and expire). + +### OAuth2 (preferred) OAuth2 provides secure, browser-based authentication with automatic token refresh. @@ -236,23 +240,39 @@ pup auth logout **Token Storage**: Tokens are stored securely in your system's keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service). Set `DD_TOKEN_STORAGE=file` to use file-based storage instead. -**Note**: OAuth2 requires Dynamic Client Registration (DCR) to be enabled on your Datadog site. If DCR is not available yet, use API key authentication. +**Note**: OAuth2 requires Dynamic Client Registration (DCR) to be enabled on your Datadog site. If DCR is not available, use a PAT/SAT (next) or API+App Key. See [docs/OAUTH2.md](docs/OAUTH2.md) for detailed OAuth2 documentation. -### API Key Authentication (Fallback) +### Personal & Service Access Tokens -If OAuth2 tokens are not available, Pup automatically falls back to API key authentication. +[Personal Access Tokens](https://docs.datadoghq.com/account_management/personal-access-tokens/) (PATs) and [Service Access Tokens](https://docs.datadoghq.com/account_management/service-access-tokens/) (SATs) are scoped, expiring credentials you create from the Datadog UI. A PAT represents an interactive user; a SAT represents a service account. They are wire-identical and pup handles them the same way. Both work everywhere `DD_API_KEY` + `DD_APP_KEY` are supported, including the few endpoints (api_keys, application_keys, ddsql-editor) that do not accept OAuth bearer tokens. + +```bash +# Personal Access Token +export DD_PAT="your-personal-access-token" +export DD_SITE="datadoghq.com" +pup monitors list + +# OR -- Service Access Token (interchangeable with DD_PAT) +export DD_SAT="your-service-access-token" +pup monitors list +``` + +Pup sends the token as `Authorization: Bearer ` on OAuth-compatible endpoints, and as `DD-APPLICATION-KEY: ` on the OAuth-excluded endpoints (Datadog's PAT migration form). You do not need to set `DD_API_KEY` alongside a PAT or SAT. If both `DD_PAT` and `DD_SAT` are set, `DD_PAT` wins. + +### API Key + Application Key ```bash export DD_API_KEY="your-datadog-api-key" export DD_APP_KEY="your-datadog-application-key" export DD_SITE="datadoghq.com" # Optional, defaults to datadoghq.com -# Use any command - API keys are used automatically pup monitors list ``` +This is the original auth method and works for every endpoint pup supports. Consider switching to a PAT or SAT for the same coverage with the benefit of scopes and expiration. + ### Bearer Token Authentication (WASM / Headless) For WASM builds or environments without keychain access, use a pre-obtained bearer token: @@ -264,14 +284,15 @@ export DD_SITE="datadoghq.com" pup monitors list ``` -API key authentication (`DD_API_KEY` + `DD_APP_KEY`) also works in WASM. See the [WASM](#wasm) section below. +`DD_PAT`, `DD_SAT`, and `DD_API_KEY` + `DD_APP_KEY` also work in WASM. See the [WASM](#wasm) section below. ### Authentication Priority -Pup checks for authentication in this order: -1. **`DD_ACCESS_TOKEN`** - Stateless bearer token (highest priority) -2. **OAuth2 tokens** (from `pup auth login`) - Used if valid tokens exist -3. **API keys** (from `DD_API_KEY` and `DD_APP_KEY`) - Used if OAuth tokens not available +When multiple credentials are set, pup picks in this order: +1. **`DD_ACCESS_TOKEN`** (OAuth bearer from env) -- highest priority +2. **OAuth2 tokens** (from `pup auth login`, stored in keychain) +3. **`DD_PAT`** or **`DD_SAT`** (Personal or Service Access Token; if both set, `DD_PAT` wins) +4. **`DD_API_KEY` + `DD_APP_KEY`** (API + Application key) ## Usage @@ -369,9 +390,11 @@ pup incidents get abc-123-def ## Environment Variables -- `DD_ACCESS_TOKEN`: Bearer token for stateless auth (highest priority) -- `DD_API_KEY`: Datadog API key (optional if using OAuth2 or DD_ACCESS_TOKEN) -- `DD_APP_KEY`: Datadog Application key (optional if using OAuth2 or DD_ACCESS_TOKEN) +- `DD_ACCESS_TOKEN`: OAuth bearer token for stateless auth (highest priority) +- `DD_PAT`: [Personal Access Token](https://docs.datadoghq.com/account_management/personal-access-tokens/) -- scoped, time-limited alternative to API+App Key +- `DD_SAT`: [Service Access Token](https://docs.datadoghq.com/account_management/service-access-tokens/) -- same as `DD_PAT` but tied to a service account +- `DD_API_KEY`: Datadog API key (optional if using OAuth2, DD_ACCESS_TOKEN, DD_PAT, or DD_SAT) +- `DD_APP_KEY`: Datadog Application key (optional if using OAuth2, DD_ACCESS_TOKEN, DD_PAT, or DD_SAT) - `DD_SITE`: Datadog site (default: datadoghq.com) - `DD_AUTO_APPROVE`: Auto-approve destructive operations (true/false) - `DD_TOKEN_STORAGE`: Token storage backend (keychain or file, default: auto-detect) @@ -428,21 +451,24 @@ cargo build --target wasm32-wasip2 --no-default-features --features wasi --relea ### Authentication -The WASM build supports **stateless authentication** — keychain storage and browser-based OAuth login are not available. Use either `DD_ACCESS_TOKEN` or API keys: +The WASM build supports **stateless authentication** — keychain storage and browser-based OAuth login are not available. Use a `DD_ACCESS_TOKEN`, a `DD_PAT`/`DD_SAT`, or `DD_API_KEY` + `DD_APP_KEY`: ```bash -# Option 1: Bearer token +# Option 1: OAuth bearer token DD_ACCESS_TOKEN="your-token" DD_SITE="datadoghq.com" wasmtime run target/wasm32-wasip2/release/pup.wasm -- monitors list -# Option 2: API keys +# Option 2: Personal Access Token (or DD_SAT for a Service Access Token) +DD_PAT="your-pat" DD_SITE="datadoghq.com" wasmtime run target/wasm32-wasip2/release/pup.wasm -- monitors list + +# Option 3: API keys DD_API_KEY="your-api-key" DD_APP_KEY="your-app-key" wasmtime run target/wasm32-wasip2/release/pup.wasm -- monitors list ``` -The `pup auth status` command works in WASM and reports which credentials are configured. The `login`, `logout`, and `refresh` subcommands return guidance to use `DD_ACCESS_TOKEN`. +The `pup auth status` command works in WASM and reports which credentials are configured. The `login`, `logout`, and `refresh` subcommands return guidance to use `DD_ACCESS_TOKEN`, `DD_PAT`, or `DD_SAT`. ### Limitations -- No local token storage (keychain/file) — use `DD_ACCESS_TOKEN` or API keys +- No local token storage (keychain/file) — use `DD_ACCESS_TOKEN`, `DD_PAT`/`DD_SAT`, or API keys - No browser-based OAuth login flow - Networking relies on the host runtime's networking capabilities @@ -452,6 +478,9 @@ The `pup auth status` command works in WASM and reports which credentials are co # Run directly wasmtime run --env DD_ACCESS_TOKEN="your-token" target/wasm32-wasip2/release/pup.wasm -- monitors list +# Or with a PAT (use DD_SAT for a Service Access Token) +wasmtime run --env DD_PAT="your-pat" target/wasm32-wasip2/release/pup.wasm -- monitors list + # Or with API keys wasmtime run --env DD_API_KEY="key" --env DD_APP_KEY="key" target/wasm32-wasip2/release/pup.wasm -- --help ``` diff --git a/docs/OAUTH2.md b/docs/OAUTH2.md index f658b38d..8df2b349 100644 --- a/docs/OAUTH2.md +++ b/docs/OAUTH2.md @@ -377,18 +377,24 @@ This indicates a potential security issue. Run `pup auth login` again to start a - Validated on callback to prevent cross-site request forgery - New state generated for each authorization flow -## Comparison with API Keys - -| Feature | OAuth2 | API Keys | -|---------|--------|----------| -| **Setup** | Browser login | Copy/paste keys | -| **Security** | Short-lived tokens | Long-lived keys | -| **Revocation** | Per-installation | Organization-wide | -| **Scopes** | Granular | All or nothing | -| **Audit Trail** | User-specific | Key-specific | -| **Rotation** | Automatic (refresh) | Manual | -| **PKCE Protection** | Yes | N/A | -| **Token Storage** | Secure local files | Environment variables | +## Comparison with PAT/SAT and API Keys + +Pup supports three auth methods, in order of preference: + +| Feature | OAuth2 (preferred) | PAT / SAT | API + App Key | +|---------|--------------------|------------|----------------| +| **Setup** | Browser login | Create in Datadog UI | Copy/paste keys | +| **Identity** | Interactive user (per installation) | PAT: interactive user. SAT: service account. | Org-level key | +| **Security** | Short-lived tokens, auto-refresh | Time-limited (24h to 1yr) | Long-lived | +| **Revocation** | Per-installation | Per-token | Organization-wide | +| **Scopes** | Granular, OAuth scopes | Granular, Datadog permissions | All or nothing | +| **Audit Trail** | User-specific | User- or service-specific | Key-specific | +| **Rotation** | Automatic (refresh) | Manual (recreate) | Manual | +| **PKCE Protection** | Yes | N/A | N/A | +| **API coverage in pup** | Most endpoints (expanding to 100%) | Full | Full | +| **Token Storage** | Secure local files | Environment variable | Environment variables | + +OAuth2 is the easiest to set up and the safest (short-lived, no local credential management). PAT or SAT is the recommended fallback whenever OAuth is not an option for a particular endpoint or workflow -- use PAT for interactive workflows and SAT for service-account use cases like CI. API + App Key is supported for backward compatibility but mildly discouraged in favor of PAT/SAT, which provide equivalent coverage with scopes and expiration. PATs and SATs are wire-identical; pup accepts `DD_PAT` and `DD_SAT` as interchangeable env vars (the distinction is only surfaced in `pup auth status`). ## Implementation Details diff --git a/src/api.rs b/src/api.rs index 675b9624..013b37d2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -13,7 +13,7 @@ pub async fn get(cfg: &Config, path: &str, query: &[(&str, String)]) -> Result Result< let url = format!("{}{}", cfg.api_base_url(), path); let client = reqwest::Client::new(); let mut req = client.post(&url); - req = apply_auth(req, cfg)?; + req = apply_auth(req, cfg, "POST", path)?; req = req.json(body); send(req).await } @@ -35,7 +35,7 @@ pub async fn put(cfg: &Config, path: &str, body: &serde_json::Value) -> Result Result { let url = format!("{}{}", cfg.api_base_url(), path); let client = reqwest::Client::new(); let mut req = client.delete(&url); - req = apply_auth(req, cfg)?; + req = apply_auth(req, cfg, "DELETE", path)?; send(req).await } @@ -73,22 +73,55 @@ pub async fn delete_with_body( let url = format!("{}{}", cfg.api_base_url(), path); let client = reqwest::Client::new(); let mut req = client.delete(&url); - req = apply_auth(req, cfg)?; + req = apply_auth(req, cfg, "DELETE", path)?; req = req.json(body); send(req).await } -fn apply_auth(req: reqwest::RequestBuilder, cfg: &Config) -> Result { +fn apply_auth( + req: reqwest::RequestBuilder, + cfg: &Config, + method: &str, + path: &str, +) -> Result { + // OAuth-excluded endpoints reject `Authorization: Bearer`. PAT/SAT must + // ride in `DD-APPLICATION-KEY` here (Datadog's migration form). + if crate::oauth_excluded::requires_api_key_fallback(method, path) { + if let Some(pat) = &cfg.pat { + let mut req = req.header("DD-APPLICATION-KEY", pat.as_str()); + if let Some(api_key) = &cfg.api_key { + req = req.header("DD-API-KEY", api_key.as_str()); + } + return Ok(req); + } + if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { + return Ok(req + .header("DD-API-KEY", api_key.as_str()) + .header("DD-APPLICATION-KEY", app_key.as_str())); + } + bail!( + "{method} {path} does not accept OAuth2 bearer tokens; \ + set DD_PAT / DD_SAT or DD_API_KEY + DD_APP_KEY" + ); + } + + // Precedence: OAuth bearer > PAT (as Bearer) > API+App Key. OAuth is the + // preferred auth method per docs/OAUTH2.md; PAT is preferred over the + // long-lived API+App Key pair. if let Some(token) = &cfg.access_token { Ok(req.header("Authorization", format!("Bearer {token}"))) + } else if let Some(pat) = &cfg.pat { + Ok(req.header("Authorization", format!("Bearer {pat}"))) } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { Ok(req .header("DD-API-KEY", api_key.as_str()) .header("DD-APPLICATION-KEY", app_key.as_str())) } else { bail!( - "authentication required: set DD_ACCESS_TOKEN for bearer auth, \ - or set DD_API_KEY and DD_APP_KEY for API+APP key auth" + "authentication required: run 'pup auth login' for OAuth2 (preferred), \ + set DD_PAT (Personal Access Token) or DD_SAT (Service Access Token), \ + set DD_ACCESS_TOKEN for an OAuth bearer, \ + or set DD_API_KEY and DD_APP_KEY" ) } } @@ -128,6 +161,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -163,6 +198,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -202,6 +239,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -236,6 +275,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -270,6 +311,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -304,6 +347,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -337,6 +382,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -371,6 +418,8 @@ mod tests { api_key: None, app_key: None, access_token: Some("test-bearer-token".into()), + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -395,6 +444,126 @@ mod tests { cleanup_env(); } + #[tokio::test] + async fn test_api_pat_auth() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + std::env::set_var("PUP_MOCK_SERVER", server.url()); + + let cfg = Config { + api_key: None, + app_key: None, + access_token: None, + pat: Some("test-pat".into()), + pat_kind: Some(crate::config::PatKind::Personal), + site: "datadoghq.com".into(), + site_explicit: false, + org: None, + output_format: OutputFormat::Json, + auto_approve: false, + agent_mode: false, + read_only: false, + }; + + let mock = server + .mock("GET", "/api/v1/test") + .match_header("Authorization", "Bearer test-pat") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"auth": "pat"}"#) + .create_async() + .await; + + let result = super::get(&cfg, "/api/v1/test", &[]).await; + assert!(result.is_ok(), "PAT auth failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn test_api_oauth_bearer_beats_pat() { + // When both DD_ACCESS_TOKEN and DD_PAT are set, OAuth wins. + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + std::env::set_var("PUP_MOCK_SERVER", server.url()); + + let cfg = Config { + api_key: None, + app_key: None, + access_token: Some("oauth-token".into()), + pat: Some("a-pat".into()), + pat_kind: Some(crate::config::PatKind::Personal), + site: "datadoghq.com".into(), + site_explicit: false, + org: None, + output_format: OutputFormat::Json, + auto_approve: false, + agent_mode: false, + read_only: false, + }; + + let mock = server + .mock("GET", "/api/v1/test") + .match_header("Authorization", "Bearer oauth-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{}"#) + .create_async() + .await; + + let result = super::get(&cfg, "/api/v1/test", &[]).await; + assert!( + result.is_ok(), + "expected OAuth bearer to win: {:?}", + result.err() + ); + mock.assert_async().await; + cleanup_env(); + } + + /// On OAuth-excluded endpoints, a PAT must ride in `DD-APPLICATION-KEY` + /// (not `Authorization: Bearer`). Same wire form the SDK and client.rs + /// use for these paths. + #[tokio::test] + async fn test_api_pat_uses_dd_application_key_on_excluded_endpoint() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + std::env::set_var("PUP_MOCK_SERVER", server.url()); + + let cfg = Config { + api_key: None, + app_key: None, + access_token: None, + pat: Some("test-pat".into()), + pat_kind: Some(crate::config::PatKind::Personal), + site: "datadoghq.com".into(), + site_explicit: false, + org: None, + output_format: OutputFormat::Json, + auto_approve: false, + agent_mode: false, + read_only: false, + }; + + let mock = server + .mock("GET", "/api/v2/api_keys") + .match_header("DD-APPLICATION-KEY", "test-pat") + .with_status(200) + .with_header("content-type", "application/json") + .with_body("{}") + .create_async() + .await; + + let result = super::get(&cfg, "/api/v2/api_keys", &[]).await; + assert!( + result.is_ok(), + "PAT on excluded endpoint should ride as DD-APPLICATION-KEY: {:?}", + result.err() + ); + mock.assert_async().await; + cleanup_env(); + } + #[tokio::test] async fn test_api_no_auth() { let _lock = lock_env().await; @@ -403,6 +572,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -431,6 +602,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -465,6 +638,8 @@ mod tests { api_key: Some("test-key".into()), app_key: Some("test-app".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, diff --git a/src/client.rs b/src/client.rs index 0bc58677..f08442f8 100644 --- a/src/client.rs +++ b/src/client.rs @@ -105,7 +105,27 @@ pub fn make_dd_config(cfg: &Config) -> datadog_api_client::datadog::Configuratio }, ); } - if let Some(app_key) = &cfg.app_key { + // PAT wins over the long-lived app_key per documented precedence + // (OAuth > PAT/SAT > API+App Key). Same order as `apply_auth`. + if let Some(pat) = &cfg.pat { + // PATs/SATs ride in DD-APPLICATION-KEY for OAuth-excluded endpoints + // (api_keys, application_keys, ddsql-editor). Populating the SDK's + // appKeyAuth slot with the PAT lets `make_api_no_auth!`-routed calls + // succeed without DD_APP_KEY. + let pat_apikey = datadog_api_client::datadog::APIKey { + key: pat.clone(), + prefix: "".to_owned(), + }; + dd_cfg.set_auth_key("appKeyAuth", pat_apikey.clone()); + // The SDK may also require the apiKeyAuth slot to be populated before + // dispatching a request (some operations mark DD-API-KEY as required + // in the upstream OpenAPI spec). Per Datadog's PAT spec, DD-API-KEY is + // ignored when DD-APPLICATION-KEY carries a PAT, so reusing the PAT + // value here is safe and unblocks SDK-routed calls. + if cfg.api_key.is_none() { + dd_cfg.set_auth_key("apiKeyAuth", pat_apikey); + } + } else if let Some(app_key) = &cfg.app_key { dd_cfg.set_auth_key( "appKeyAuth", datadog_api_client::datadog::APIKey { @@ -165,10 +185,12 @@ pub fn make_dd_config(cfg: &Config) -> datadog_api_client::datadog::Configuratio /// Builds a reqwest middleware client for SDK API calls. Always installs /// `UserAgentMiddleware` so requests carry pup's branded `User-Agent` /// instead of the SDK's `datadog-api-client-rust/...` default. When -/// `send_bearer` is true and the config has an access token, also installs -/// `BearerAuthMiddleware`. OAuth-incompatible endpoints (see -/// `OAUTH_EXCLUDED_ENDPOINTS`) pass `false` so the SDK falls back to API key -/// headers from the `Configuration`. +/// `send_bearer` is true, installs `BearerAuthMiddleware` populated from the +/// OAuth access token if present, else from the PAT — both flow as +/// `Authorization: Bearer ` per Datadog's PAT spec. OAuth-incompatible +/// endpoints (see `OAUTH_EXCLUDED_ENDPOINTS`) pass `false` so the SDK falls +/// back to header auth from the `Configuration` (DD-API-KEY/DD-APPLICATION-KEY, +/// where DD-APPLICATION-KEY may carry a PAT). /// /// Returns `None` on WASM targets; callers use the SDK default client there. pub fn make_dd_client(cfg: &Config, send_bearer: bool) -> Option { @@ -179,9 +201,11 @@ pub fn make_dd_client(cfg: &Config, send_bearer: bool) -> Option anyhow::Result write!(f, "None"), AuthType::OAuth => write!(f, "OAuth2 Bearer Token"), + AuthType::AccessToken(kind) => { + write!(f, "{} ({})", kind.display_name(), kind.env_var()) + } AuthType::ApiKeys => write!(f, "API Keys (DD_API_KEY + DD_APP_KEY)"), } } @@ -460,8 +491,13 @@ impl std::fmt::Display for AuthType { #[allow(dead_code)] pub fn get_auth_type(cfg: &Config) -> AuthType { + // Match auth-precedence in apply_auth: OAuth > PAT/SAT > API+App Key. + // `pat_kind` is set together with `pat` (see Config::from_env), so binding + // here lets us guarantee a non-None inner value. if cfg.has_bearer_token() { AuthType::OAuth + } else if let Some(kind) = cfg.pat_kind { + AuthType::AccessToken(kind) } else if cfg.has_api_keys() { AuthType::ApiKeys } else { @@ -471,257 +507,14 @@ pub fn get_auth_type(cfg: &Config) -> AuthType { // --------------------------------------------------------------------------- // OAuth-excluded endpoint validation +// +// The canonical list and lookup logic live in `crate::oauth_excluded` so +// the WASM/browser build (which does not import this module) can consult +// the same table. // --------------------------------------------------------------------------- -struct EndpointRequirement { - path: &'static str, - method: &'static str, -} - -/// Returns true if the endpoint doesn't support OAuth and requires API key fallback. #[allow(dead_code)] -pub fn requires_api_key_fallback(method: &str, path: &str) -> bool { - find_endpoint_requirement(method, path).is_some() -} - -fn find_endpoint_requirement(method: &str, path: &str) -> Option<&'static EndpointRequirement> { - OAUTH_EXCLUDED_ENDPOINTS.iter().find(|req| { - if req.method != method { - return false; - } - // Trailing "/" means prefix match (for ID-parameterized paths) - if req.path.ends_with('/') { - path.starts_with(&req.path[..req.path.len() - 1]) - } else { - req.path == path - } - }) -} - -// --------------------------------------------------------------------------- -// Static tables -// --------------------------------------------------------------------------- - -/// Endpoints that don't support OAuth. -/// Trailing "/" means prefix match for ID-parameterized paths. -static OAUTH_EXCLUDED_ENDPOINTS: &[EndpointRequirement] = &[ - // API/App Keys (8) - EndpointRequirement { - path: "/api/v2/api_keys", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/api_keys/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/api_keys", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/api_keys/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/application_keys", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "PATCH", - }, - // DDSQL editor tools (3) - EndpointRequirement { - path: "/api/unstable/ddsql-editor/tools/ddsql-docs", - method: "GET", - }, - EndpointRequirement { - path: "/api/unstable/ddsql-editor/tools/table-names", - method: "GET", - }, - EndpointRequirement { - path: "/api/unstable/ddsql-editor/tools/table-data", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "DELETE", - }, - // Fleet Automation (15) - EndpointRequirement { - path: "/api/v2/fleet/agents", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/agents/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/agents/versions", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/configure", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/upgrade", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "PATCH", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "POST", - }, - // Observability Pipelines (6) — API key only, no OAuth support - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/", - method: "PUT", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/validate", - method: "POST", - }, - // Cost / Billing (9) — API key only, no OAuth support - EndpointRequirement { - path: "/api/v2/usage/projected_cost", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/usage/cost_by_org", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost_by_tag/monthly_cost_attribution", - method: "GET", - }, - // Cloud Cost Management config (12) - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config/", - method: "DELETE", - }, - // Profiling (4) - // No OAuth scope is declared for Continuous Profiler endpoints; force API-key auth. - EndpointRequirement { - path: "/profiling/api/v1/", - method: "POST", - }, - EndpointRequirement { - path: "/profiling/api/v1/", - method: "GET", - }, - EndpointRequirement { - path: "/api/unstable/profiles/", - method: "POST", - }, - EndpointRequirement { - path: "/api/ui/profiling/", - method: "GET", - }, -]; +pub use crate::oauth_excluded::requires_api_key_fallback; // --------------------------------------------------------------------------- // Raw HTTP helpers @@ -944,13 +737,34 @@ async fn raw_post_impl( parse_response_json(resp).await } -fn apply_auth( +/// Attach auth headers (OAuth bearer, PAT/SAT, or API+App Key) to a raw +/// `reqwest::RequestBuilder`. Respects the documented precedence +/// (OAuth > PAT/SAT > API+App Key) and routes PAT/SAT to `DD-APPLICATION-KEY` +/// for OAuth-excluded endpoints. Single source of truth used by every native +/// request path; `commands/*` helpers should delegate here. +pub fn apply_auth( mut req: reqwest::RequestBuilder, cfg: &Config, method: &str, path: &str, ) -> anyhow::Result { if requires_api_key_fallback(method, path) { + // OAuth-excluded endpoints do not accept Bearer tokens. They DO accept + // either DD_API_KEY+DD_APP_KEY or a PAT/SAT in the DD-APPLICATION-KEY + // slot (Datadog's PAT migration form -- DD-API-KEY is optional/ignored). + // + // Precedence here matches the documented tier order: PAT/SAT is preferred + // over the long-lived API+App Key pair, so it wins when both are set. + if let Some(pat) = &cfg.pat { + // PATs/SATs ride in DD-APPLICATION-KEY. DD-API-KEY is optional and + // ignored for these tokens; include it only if explicitly set so we + // don't fabricate a header value. + req = req.header("DD-APPLICATION-KEY", pat.as_str()); + if let Some(api_key) = &cfg.api_key { + req = req.header("DD-API-KEY", api_key.as_str()); + } + return Ok(req); + } if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { req = req .header("DD-API-KEY", api_key.as_str()) @@ -959,7 +773,8 @@ fn apply_auth( } anyhow::bail!( - "{method} {path} requires DD_API_KEY and DD_APP_KEY; OAuth2 bearer tokens are not supported" + "{method} {path} requires DD_PAT/DD_SAT or DD_API_KEY+DD_APP_KEY; \ + OAuth2 bearer tokens are not supported" ); } @@ -968,6 +783,11 @@ fn apply_auth( return Ok(req); } + if let Some(pat) = &cfg.pat { + req = req.header("Authorization", format!("Bearer {pat}")); + return Ok(req); + } + if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { req = req .header("DD-API-KEY", api_key.as_str()) @@ -1020,15 +840,7 @@ pub async fn raw_post_lenient( let client = reqwest::Client::new(); let mut req = client.post(&url); - if let Some(token) = &cfg.access_token { - req = req.header("Authorization", format!("Bearer {token}")); - } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); - } else { - anyhow::bail!("no authentication configured"); - } + req = apply_auth(req, cfg, "POST", path)?; let resp = req .header("Content-Type", "application/json") @@ -1047,15 +859,7 @@ pub async fn raw_delete(cfg: &Config, path: &str) -> anyhow::Result<()> { let client = reqwest::Client::new(); let mut req = client.delete(&url); - if let Some(token) = &cfg.access_token { - req = req.header("Authorization", format!("Bearer {token}")); - } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); - } else { - anyhow::bail!("no authentication configured"); - } + req = apply_auth(req, cfg, "DELETE", path)?; let resp = req .header("Accept", "application/json") @@ -1089,6 +893,8 @@ mod tests { api_key: Some("test".into()), app_key: Some("test".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -1120,6 +926,97 @@ mod tests { assert_eq!(get_auth_type(&cfg), AuthType::None); } + /// Regression: `apply_auth` on OAuth-excluded endpoints must prefer a PAT + /// over API+App Key. Otherwise documented precedence (PAT > API+App Key) + /// is silently reversed for the api_keys / application_keys / ddsql paths. + #[tokio::test] + async fn test_apply_auth_excluded_endpoint_pat_beats_api_app_key() { + let _guard = ENV_LOCK.lock().await; + let mut server = mockito::Server::new_async().await; + std::env::set_var("PUP_MOCK_SERVER", server.url()); + + let mut cfg = test_cfg(); + cfg.pat = Some("the-pat".into()); + cfg.pat_kind = Some(crate::config::PatKind::Personal); + // api_key + app_key are also set (inherited from test_cfg) -- PAT must win. + + // /api/v2/api_keys is in OAUTH_EXCLUDED_ENDPOINTS. + let mock = server + .mock("GET", "/api/v2/api_keys") + .match_header("DD-APPLICATION-KEY", "the-pat") + .with_status(200) + .with_header("content-type", "application/json") + .with_body("{}") + .create_async() + .await; + + let _ = raw_get(&cfg, "/api/v2/api_keys", &[]).await; + mock.assert_async().await; + + std::env::remove_var("PUP_MOCK_SERVER"); + } + + /// `raw_post_lenient` must use the shared `apply_auth` so PAT/SAT callers + /// can hit endpoints routed through it (debugger probe create, etc.). + #[tokio::test] + async fn test_raw_post_lenient_with_pat() { + let _guard = ENV_LOCK.lock().await; + let mut server = mockito::Server::new_async().await; + std::env::set_var("PUP_MOCK_SERVER", server.url()); + + let mut cfg = test_cfg(); + cfg.api_key = None; + cfg.app_key = None; + cfg.pat = Some("the-pat".into()); + cfg.pat_kind = Some(crate::config::PatKind::Personal); + + let mock = server + .mock("POST", "/api/v1/some-endpoint") + .match_header("Authorization", "Bearer the-pat") + .with_status(200) + .with_header("content-type", "application/json") + .with_body("{}") + .create_async() + .await; + + let _ = raw_post_lenient(&cfg, "/api/v1/some-endpoint", serde_json::json!({})).await; + mock.assert_async().await; + + std::env::remove_var("PUP_MOCK_SERVER"); + } + + /// `raw_delete` must use the shared `apply_auth` so PAT/SAT callers can + /// hit endpoints routed through it (cost-CCM delete paths, etc.). + #[tokio::test] + async fn test_raw_delete_with_pat() { + let _guard = ENV_LOCK.lock().await; + let mut server = mockito::Server::new_async().await; + std::env::set_var("PUP_MOCK_SERVER", server.url()); + + let mut cfg = test_cfg(); + cfg.api_key = None; + cfg.app_key = None; + cfg.pat = Some("the-pat".into()); + cfg.pat_kind = Some(crate::config::PatKind::Personal); + + let mock = server + .mock("DELETE", "/api/v1/some-resource") + .match_header("Authorization", "Bearer the-pat") + .with_status(204) + .create_async() + .await; + + let _ = raw_delete(&cfg, "/api/v1/some-resource").await; + mock.assert_async().await; + + std::env::remove_var("PUP_MOCK_SERVER"); + } + + // Note: we don't unit-test that make_dd_config populates appKeyAuth / + // apiKeyAuth slots for a PAT-only Config -- the SDK's `auth_keys` field + // is private. The behavior is exercised indirectly via the manual + // `apply_auth` path tests above, which assert PAT routing on the wire. + /// `make_dd_config` must propagate `cfg.site` into the SDK's `site` /// server variable, otherwise programmatic site resolution (e.g. /// `--org` picking up a saved staging site) silently routes API calls @@ -1254,10 +1151,7 @@ mod tests { assert_eq!(UNSTABLE_OPS.len(), 166); } - #[test] - fn test_oauth_excluded_count() { - assert_eq!(OAUTH_EXCLUDED_ENDPOINTS.len(), 52); - } + // `test_oauth_excluded_count` now lives in `src/oauth_excluded.rs` alongside the canonical list. #[test] fn test_make_dd_client_some_without_token() { diff --git a/src/commands/acp.rs b/src/commands/acp.rs index ef26af56..16f85b71 100644 --- a/src/commands/acp.rs +++ b/src/commands/acp.rs @@ -28,7 +28,12 @@ pub async fn serve(cfg: &Config, port: u16, host: &str, agent_id: Option cfg.validate_auth()?; let app_base = format!("https://app.{}", cfg.site); - let access_token = cfg.access_token.clone(); + // PAT/SAT ride as `Authorization: Bearer` on this endpoint (same wire + // form as OAuth), so we coalesce them into the access_token slot here + // rather than threading a separate parameter through every helper. The + // Bits AI / lassie endpoints are not in OAUTH_EXCLUDED_ENDPOINTS, so + // Bearer auth is the right form for any of OAuth / PAT / SAT. + let access_token = cfg.access_token.clone().or_else(|| cfg.pat.clone()); let api_key = cfg.api_key.clone(); let app_key = cfg.app_key.clone(); diff --git a/src/commands/api.rs b/src/commands/api.rs index 80ba13f4..ec5d443b 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -134,15 +134,17 @@ pub async fn run( .map_err(|_| anyhow::anyhow!("unsupported HTTP method: {method}"))?; let mut req = client.request(method_val, &url); - if let Some(token) = &cfg.access_token { - req = req.header("Authorization", format!("Bearer {token}")); - } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); + // Extract the path component (full URLs and relative endpoints both need + // to feed the same OAuth-excluded check inside apply_auth). + let normalized_path = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + reqwest::Url::parse(&url) + .ok() + .map(|u| u.path().to_string()) + .unwrap_or_else(|| endpoint.to_string()) } else { - bail!("authentication required: run 'pup auth login' or set DD_API_KEY and DD_APP_KEY"); - } + normalize_path(endpoint) + }; + req = crate::client::apply_auth(req, cfg, &method_upper, &normalized_path)?; req = req .header("User-Agent", useragent::get()) diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 0a171b22..4aee39bb 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -441,6 +441,24 @@ fn build_non_oauth_status(cfg: &Config) -> (String, serde_json::Value) { }); (msg, json) } + AuthType::AccessToken(kind) => { + let method = match kind { + crate::config::PatKind::Personal => "pat", + crate::config::PatKind::Service => "sat", + }; + let msg = format!( + "✅ Authenticated for site: {site}{org_label} ({})", + kind.display_name() + ); + let json = serde_json::json!({ + "authenticated": true, + "auth_method": method, + "org": org, + "site": site, + "status": "valid", + }); + (msg, json) + } AuthType::ApiKeys => { let msg = format!("✅ Authenticated for site: {site}{org_label} (DD_API_KEY + DD_APP_KEY)"); @@ -625,6 +643,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, diff --git a/src/commands/bits.rs b/src/commands/bits.rs index 98a5ba59..add2d3a3 100644 --- a/src/commands/bits.rs +++ b/src/commands/bits.rs @@ -27,15 +27,7 @@ pub async fn ask( let agent_id = match agent_id { Some(id) if !id.is_empty() => id, - _ => { - resolve_agent_id( - &app_base, - cfg.access_token.as_deref(), - cfg.api_key.as_deref(), - cfg.app_key.as_deref(), - ) - .await? - } + _ => resolve_agent_id(&app_base, cfg).await?, }; let mut session_id: Option = None; @@ -247,25 +239,11 @@ fn extract_text(val: &serde_json::Value) -> String { /// Resolve the first available Bits AI agent ID from the API. #[cfg(not(target_arch = "wasm32"))] -async fn resolve_agent_id( - app_base: &str, - access_token: Option<&str>, - api_key: Option<&str>, - app_key: Option<&str>, -) -> Result { +async fn resolve_agent_id(app_base: &str, cfg: &Config) -> Result { let url = format!("{app_base}{LASSIE_BASE}/agents?limit=1"); let client = reqwest::Client::new(); let req = client.get(&url).header("Accept", "application/json"); - - let req = req.header("User-Agent", crate::useragent::get()); - let req = if let Some(token) = access_token { - req.header("Authorization", format!("Bearer {token}")) - } else if let (Some(ak), Some(apk)) = (api_key, app_key) { - req.header("DD-API-KEY", ak) - .header("DD-APPLICATION-KEY", apk) - } else { - anyhow::bail!("no authentication configured"); - }; + let req = add_auth(req, cfg)?; let resp = req .send() @@ -309,6 +287,9 @@ fn add_auth(req: reqwest::RequestBuilder, cfg: &Config) -> Result Result<()> { - let url = format!( - "{}/api/v2/incidents/{}/attachments/{}", - cfg.api_base_url(), - incident_id, - attachment_id - ); + let path = format!("/api/v2/incidents/{incident_id}/attachments/{attachment_id}"); + let url = format!("{}{}", cfg.api_base_url(), path); let client = reqwest::Client::new(); let mut req = client.delete(&url); - - if let Some(token) = &cfg.access_token { - req = req.header("Authorization", format!("Bearer {token}")); - } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); - } else { - bail!("no authentication configured"); - } + req = crate::client::apply_auth(req, cfg, "DELETE", &path)?; let resp = req.header("Accept", "application/json").send().await?; if !resp.status().is_success() { diff --git a/src/commands/llm_obs.rs b/src/commands/llm_obs.rs index 72434444..4cdcf8a4 100644 --- a/src/commands/llm_obs.rs +++ b/src/commands/llm_obs.rs @@ -466,6 +466,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -878,6 +880,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -964,6 +968,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -1112,6 +1118,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -1545,6 +1553,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -1626,6 +1636,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, diff --git a/src/commands/logs.rs b/src/commands/logs.rs index baf5bc49..e34e4e49 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -676,6 +676,8 @@ mod tests { api_key: None, app_key: None, access_token: Some("token".into()), + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, diff --git a/src/config.rs b/src/config.rs index 48c462a0..b53fa7fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,18 @@ pub struct Config { pub api_key: Option, pub app_key: Option, pub access_token: Option, + /// Datadog Access Token -- either a Personal Access Token (DD_PAT, tied + /// to a user) or a Service Access Token (DD_SAT, tied to a service + /// account). Both share the same wire protocol: sent as + /// `Authorization: Bearer ` on most endpoints, and as + /// `DD-APPLICATION-KEY: ` on the OAuth-excluded endpoints + /// (api_keys, application_keys, ddsql-editor) using Datadog's migration + /// form. Tracked separately from `access_token` so we can route correctly + /// and surface the auth type in `pup auth status`. + pub pat: Option, + /// Which env var supplied `pat`, for status display. `None` when no PAT + /// or SAT is configured. + pub pat_kind: Option, pub site: String, /// True if `site` was explicitly set via DD_SITE env var, --site flag, or /// config file. False if it was derived from a stored session for the @@ -23,6 +35,33 @@ pub struct Config { pub read_only: bool, } +/// Which env var supplied the access token in `cfg.pat`. PATs and SATs are +/// wire-identical; this is only used for status display so the user can tell +/// which credential they configured. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PatKind { + /// `DD_PAT` -- Personal Access Token (tied to an interactive user). + Personal, + /// `DD_SAT` -- Service Access Token (tied to a service account). + Service, +} + +impl PatKind { + pub fn env_var(self) -> &'static str { + match self { + PatKind::Personal => "DD_PAT", + PatKind::Service => "DD_SAT", + } + } + + pub fn display_name(self) -> &'static str { + match self { + PatKind::Personal => "Personal Access Token", + PatKind::Service => "Service Access Token", + } + } +} + #[derive(Clone, Debug, PartialEq)] pub enum OutputFormat { Json, @@ -73,6 +112,11 @@ struct FileConfig { api_key: Option, app_key: Option, access_token: Option, + /// Personal Access Token. Equivalent to setting `DD_PAT`. + pat: Option, + /// Service Access Token. Equivalent to setting `DD_SAT`. Wire-identical + /// to `pat` -- the field name reflects intent for status display. + sat: Option, site: Option, org: Option, output: Option, @@ -92,6 +136,24 @@ impl Config { let file_cfg = load_config_file().unwrap_or_default(); let access_token = env_or("DD_ACCESS_TOKEN", file_cfg.access_token); + // PAT/SAT resolution. Standard CLI convention: any env var wins over + // any file value (even across PAT/SAT cross-over -- e.g. DD_SAT in env + // overrides `pat:` in the config file). Within each tier, DD_PAT wins + // over DD_SAT (alphabetical, deterministic). Both flow to `cfg.pat`; + // `pat_kind` records which credential supplied it for status display. + let env_pat = std::env::var("DD_PAT").ok().filter(|s| !s.is_empty()); + let env_sat = std::env::var("DD_SAT").ok().filter(|s| !s.is_empty()); + let (pat, pat_kind) = if let Some(v) = env_pat { + (Some(v), Some(PatKind::Personal)) + } else if let Some(v) = env_sat { + (Some(v), Some(PatKind::Service)) + } else if let Some(v) = file_cfg.pat { + (Some(v), Some(PatKind::Personal)) + } else if let Some(v) = file_cfg.sat { + (Some(v), Some(PatKind::Service)) + } else { + (None, None) + }; let explicit_site = env_or("DD_SITE", file_cfg.site); let site_explicit = explicit_site.is_some(); let org = env_or("DD_ORG", file_cfg.org); // flag override applied in main_inner @@ -122,6 +184,8 @@ impl Config { api_key: env_or("DD_API_KEY", file_cfg.api_key), app_key: env_or("DD_APP_KEY", file_cfg.app_key), access_token, + pat, + pat_kind, site, site_explicit, org, @@ -148,11 +212,15 @@ impl Config { access_token: Option, api_key: Option, app_key: Option, + pat: Option, + pat_kind: Option, ) -> Self { Config { api_key, app_key, access_token, + pat, + pat_kind, site: normalize_site(&site), site_explicit: true, org: None, @@ -174,7 +242,10 @@ impl Config { /// Validate that sufficient auth credentials are configured. pub fn validate_auth(&self) -> Result<()> { - if self.access_token.is_none() && (self.api_key.is_none() || self.app_key.is_none()) { + if self.access_token.is_none() + && self.pat.is_none() + && (self.api_key.is_none() || self.app_key.is_none()) + { #[cfg(all(not(feature = "browser"), not(target_arch = "wasm32")))] if has_stored_refresh_token(&self.site, self.org.as_deref()) { bail!( @@ -183,20 +254,27 @@ impl Config { ); } bail!( - "authentication required: set DD_ACCESS_TOKEN for bearer auth, \ - run 'pup auth login' for OAuth2, \ + "authentication required: run 'pup auth login' for OAuth2 (preferred), \ + set DD_PAT (Personal Access Token) or DD_SAT (Service Access Token), \ + set DD_ACCESS_TOKEN for an OAuth bearer token, \ or set DD_API_KEY and DD_APP_KEY for API+APP key auth" ); } Ok(()) } - /// Validate that both DD_API_KEY and DD_APP_KEY are configured. - /// Used for endpoints that require API key auth and do not accept OAuth2 tokens. + /// Validate that credentials accepted by OAuth-excluded endpoints are + /// configured. These endpoints (api_keys, application_keys, ddsql-editor) + /// do not accept OAuth2 bearer tokens, but they DO accept either: + /// - DD_API_KEY + DD_APP_KEY (classic), or + /// - DD_PAT (sent in DD-APPLICATION-KEY, per Datadog's PAT migration form). pub fn validate_api_and_app_keys(&self) -> Result<()> { + if self.pat.is_some() { + return Ok(()); + } if self.api_key.is_none() || self.app_key.is_none() { bail!( - "this command requires both DD_API_KEY and DD_APP_KEY — \ + "this command requires DD_PAT (or DD_SAT) or both DD_API_KEY and DD_APP_KEY — \ OAuth2 bearer tokens are not supported here" ); } @@ -334,11 +412,23 @@ pub fn apply_org_override(cfg: &mut Config, org: String) { cfg.site = normalize_site(&saved_site); } } - if std::env::var("DD_ACCESS_TOKEN") - .ok() - .filter(|s| !s.is_empty()) - .is_none() - { + // Only reload a stored OAuth token when the caller has not supplied an + // explicit env-level bearer credential. DD_ACCESS_TOKEN, DD_PAT, and + // DD_SAT in the environment all qualify: silently loading a saved OAuth + // session over any of them would switch the request to a different + // principal (and possibly a different tenant) than the caller intended. + // + // We deliberately check the env vars (not cfg.pat) so file-config values + // remain overridable by per-org session reloads, matching the existing + // treatment of DD_ACCESS_TOKEN (env-only check, not cfg.access_token). + let has_explicit_env_bearer = [ + "DD_ACCESS_TOKEN", + PatKind::Personal.env_var(), + PatKind::Service.env_var(), + ] + .iter() + .any(|k| std::env::var(k).ok().filter(|s| !s.is_empty()).is_some()); + if !has_explicit_env_bearer { cfg.access_token = load_token_from_storage(&cfg.site, cfg.org.as_deref()); } } @@ -490,6 +580,29 @@ mod tests { api_key: api_key.map(String::from), app_key: app_key.map(String::from), access_token: token.map(String::from), + pat: None, + pat_kind: None, + site: "datadoghq.com".into(), + site_explicit: false, + org: None, + output_format: OutputFormat::Json, + auto_approve: false, + agent_mode: false, + read_only: false, + } + } + + fn make_cfg_pat(pat: Option<&str>) -> Config { + make_cfg_pat_kind(pat, PatKind::Personal) + } + + fn make_cfg_pat_kind(pat: Option<&str>, kind: PatKind) -> Config { + Config { + api_key: None, + app_key: None, + access_token: None, + pat: pat.map(String::from), + pat_kind: pat.map(|_| kind), site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -568,6 +681,129 @@ mod tests { assert!(cfg.validate_auth().is_err()); } + #[test] + fn test_validate_auth_pat() { + let cfg = make_cfg_pat(Some("pat-token")); + assert!(cfg.validate_auth().is_ok()); + } + + #[test] + fn test_validate_auth_sat() { + let cfg = make_cfg_pat_kind(Some("sat-token"), PatKind::Service); + assert!(cfg.validate_auth().is_ok()); + assert_eq!(cfg.pat_kind, Some(PatKind::Service)); + } + + #[test] + fn test_pat_kind_env_var_names() { + assert_eq!(PatKind::Personal.env_var(), "DD_PAT"); + assert_eq!(PatKind::Service.env_var(), "DD_SAT"); + } + + /// DD_PAT and DD_SAT both populate `cfg.pat`. If both are set, DD_PAT wins. + #[test] + fn test_from_env_pat_wins_over_sat() { + let _guard = ENV_LOCK.blocking_lock(); + std::env::remove_var("DD_ACCESS_TOKEN"); + std::env::remove_var("DD_API_KEY"); + std::env::remove_var("DD_APP_KEY"); + std::env::set_var("DD_PAT", "the-pat"); + std::env::set_var("DD_SAT", "the-sat"); + std::env::set_var("PUP_CONFIG_DIR", "/tmp/pup_test_nonexistent_pat_sat"); + + let cfg = Config::from_env().unwrap(); + + std::env::remove_var("DD_PAT"); + std::env::remove_var("DD_SAT"); + std::env::remove_var("PUP_CONFIG_DIR"); + + assert_eq!(cfg.pat.as_deref(), Some("the-pat")); + assert_eq!(cfg.pat_kind, Some(PatKind::Personal)); + } + + /// DD_SAT alone populates `cfg.pat` with `pat_kind=Service`. + #[test] + fn test_from_env_sat_only() { + let _guard = ENV_LOCK.blocking_lock(); + std::env::remove_var("DD_PAT"); + std::env::remove_var("DD_ACCESS_TOKEN"); + std::env::remove_var("DD_API_KEY"); + std::env::remove_var("DD_APP_KEY"); + std::env::set_var("DD_SAT", "the-sat"); + std::env::set_var("PUP_CONFIG_DIR", "/tmp/pup_test_nonexistent_sat_only"); + + let cfg = Config::from_env().unwrap(); + + std::env::remove_var("DD_SAT"); + std::env::remove_var("PUP_CONFIG_DIR"); + + assert_eq!(cfg.pat.as_deref(), Some("the-sat")); + assert_eq!(cfg.pat_kind, Some(PatKind::Service)); + } + + /// Env vars must override any file value across the PAT/SAT cross-over. + /// Example: `pat:` in the config file should NOT take precedence over + /// DD_SAT in the environment. + #[test] + fn test_from_env_env_sat_overrides_file_pat() { + let _guard = ENV_LOCK.blocking_lock(); + std::env::remove_var("DD_PAT"); + std::env::remove_var("DD_ACCESS_TOKEN"); + std::env::remove_var("DD_API_KEY"); + std::env::remove_var("DD_APP_KEY"); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let tmp = std::env::temp_dir().join(format!("pup_cfg_env_sat_over_file_pat_{nanos}")); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("config.yaml"), "pat: file-pat-value\n").unwrap(); + std::env::set_var("PUP_CONFIG_DIR", &tmp); + std::env::set_var("DD_SAT", "env-sat-value"); + + let cfg = Config::from_env().unwrap(); + + std::env::remove_var("DD_SAT"); + std::env::remove_var("PUP_CONFIG_DIR"); + let _ = std::fs::remove_dir_all(&tmp); + + assert_eq!( + cfg.pat.as_deref(), + Some("env-sat-value"), + "env DD_SAT must override file `pat:` (any env var beats any file value)" + ); + assert_eq!(cfg.pat_kind, Some(PatKind::Service)); + } + + #[test] + fn test_validate_api_and_app_keys_accepts_pat() { + let cfg = make_cfg_pat(Some("pat-token")); + assert!( + cfg.validate_api_and_app_keys().is_ok(), + "PATs should satisfy validate_api_and_app_keys (sent in DD-APPLICATION-KEY)" + ); + } + + #[test] + fn test_validate_api_and_app_keys_oauth_bearer_only_fails() { + let cfg = make_cfg(None, None, Some("oauth-bearer")); + assert!( + cfg.validate_api_and_app_keys().is_err(), + "an OAuth bearer alone should not satisfy validate_api_and_app_keys" + ); + } + + #[test] + fn test_pat_kind_set_with_pat() { + let cfg = make_cfg_pat(Some("p")); + assert!(cfg.pat.is_some() && cfg.pat_kind.is_some()); + let empty = make_cfg_pat(None); + assert!(empty.pat.is_none() && empty.pat_kind.is_none()); + let oauth = make_cfg(None, None, Some("oauth")); + assert!(oauth.pat.is_none() && oauth.pat_kind.is_none()); + } + #[test] fn test_validate_auth_error_message_suggests_login_by_default() { // Use a site name that will never have stored tokens. @@ -1028,6 +1264,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "a.datadoghq.com".into(), site_explicit: false, org: Some("org-a".into()), @@ -1072,6 +1310,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "explicit.datadoghq.com".into(), site_explicit: true, org: None, @@ -1109,6 +1349,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -1146,6 +1388,8 @@ mod tests { api_key: None, app_key: None, access_token: Some("env-supplied-token".into()), + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, @@ -1164,6 +1408,88 @@ mod tests { assert_eq!(cfg.access_token.as_deref(), Some("env-supplied-token")); } + /// When DD_PAT is set, `--org` must not silently load a stored OAuth token + /// into `cfg.access_token`. OAuth wins over PAT in apply_auth, so doing so + /// would switch the request to whatever saved principal exists for that + /// org -- a real trust-boundary violation. + /// + /// This test plants a stored OAuth token best-effort and verifies the + /// PAT survives. The token storage is a process-level singleton cached on + /// first use, so planting may fail under parallel-test interleaving; the + /// invariant we assert (access_token stays None when pat is set) holds + /// regardless of whether the plant succeeded. + #[test] + fn test_apply_org_override_preserves_pat() { + let _guard = ENV_LOCK.blocking_lock(); + std::env::remove_var("DD_ACCESS_TOKEN"); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let tmp = std::env::temp_dir().join(format!("pup_cfg_apply_pat_org_{nanos}")); + std::fs::create_dir_all(&tmp).unwrap(); + std::env::set_var("PUP_CONFIG_DIR", &tmp); + + let target_site = "stored-oauth.datadoghq.com"; + let target_org = "stored-oauth-org"; + + // Best-effort plant of a stored OAuth token for the target site/org. + // If the storage singleton was already initialized against a different + // (now-cleaned-up) tmp dir, this write fails -- that's fine; the + // assertion below still validates the no-clobber contract. + if let Ok(store_guard) = crate::auth::storage::get_storage() { + if let Ok(lock) = store_guard.lock() { + if let Some(store) = lock.as_ref() { + let _ = store.save_tokens( + target_site, + Some(target_org), + &crate::auth::types::TokenSet { + access_token: "stored-oauth-token".into(), + refresh_token: String::new(), + token_type: "Bearer".into(), + expires_in: 3600, + issued_at: chrono::Utc::now().timestamp(), + scope: String::new(), + client_id: String::new(), + }, + ); + } + } + } + let _ = crate::auth::storage::save_session(&crate::auth::storage::SessionEntry { + site: target_site.into(), + org: Some(target_org.into()), + org_uuid: None, + }); + + let mut cfg = Config { + api_key: None, + app_key: None, + access_token: None, + pat: Some("explicit-pat".into()), + pat_kind: Some(PatKind::Personal), + site: target_site.into(), + site_explicit: false, + org: None, + output_format: OutputFormat::Json, + auto_approve: false, + agent_mode: false, + read_only: false, + }; + + super::apply_org_override(&mut cfg, target_org.into()); + + std::env::remove_var("PUP_CONFIG_DIR"); + let _ = std::fs::remove_dir_all(&tmp); + + assert_eq!( + cfg.access_token, None, + "stored OAuth token must not clobber explicit PAT/SAT" + ); + assert_eq!(cfg.pat.as_deref(), Some("explicit-pat")); + } + /// `set_site_explicit` keeps `site` and `site_explicit` in lockstep so a /// later `--org` lookup cannot silently overwrite a user-pinned site. #[test] @@ -1172,6 +1498,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, diff --git a/src/extensions/exec.rs b/src/extensions/exec.rs index c6f10658..a599cfc7 100644 --- a/src/extensions/exec.rs +++ b/src/extensions/exec.rs @@ -51,6 +51,14 @@ fn inject_auth_env(cmd: &mut std::process::Command, cfg: &Config) { cmd.env_remove("DD_ACCESS_TOKEN"); } } + // Forward PAT/SAT under the env var name that matches `pat_kind` so the + // child sees the same credential kind the parent resolved. Always remove + // the unused variant so stale values from the parent shell don't leak in. + cmd.env_remove(crate::config::PatKind::Personal.env_var()); + cmd.env_remove(crate::config::PatKind::Service.env_var()); + if let (Some(token), Some(kind)) = (&cfg.pat, cfg.pat_kind) { + cmd.env(kind.env_var(), token); + } match &cfg.api_key { Some(key) => { cmd.env("DD_API_KEY", key); diff --git a/src/formatter.rs b/src/formatter.rs index da3eed64..f70350ed 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -982,6 +982,8 @@ mod tests { api_key: None, app_key: None, access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None, diff --git a/src/lib.rs b/src/lib.rs index 481d5852..bcb7d9a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ mod config; #[cfg(feature = "browser")] mod formatter; #[cfg(feature = "browser")] +mod oauth_excluded; +#[cfg(feature = "browser")] mod version; #[cfg(feature = "browser")] @@ -39,6 +41,11 @@ pub struct PupClientOptions { pub api_key: Option, #[wasm_bindgen(getter_with_clone)] pub app_key: Option, + #[wasm_bindgen(getter_with_clone)] + pub pat: Option, + /// Service Access Token. Wire-identical to `pat`; setting either works. + #[wasm_bindgen(getter_with_clone)] + pub sat: Option, } #[cfg(feature = "browser")] @@ -51,6 +58,8 @@ impl PupClientOptions { access_token: None, api_key: None, app_key: None, + pat: None, + sat: None, } } } @@ -71,8 +80,20 @@ impl PupClient { /// Create a new PupClient from options. #[wasm_bindgen(constructor)] pub fn new(opts: PupClientOptions) -> Result { - let cfg = - config::Config::from_params(opts.site, opts.access_token, opts.api_key, opts.app_key); + // PAT wins over SAT if both supplied -- same precedence as env vars. + let (pat, pat_kind) = match (opts.pat, opts.sat) { + (Some(v), _) => (Some(v), Some(config::PatKind::Personal)), + (None, Some(v)) => (Some(v), Some(config::PatKind::Service)), + (None, None) => (None, None), + }; + let cfg = config::Config::from_params( + opts.site, + opts.access_token, + opts.api_key, + opts.app_key, + pat, + pat_kind, + ); cfg.validate_auth() .map_err(|e| JsError::new(&e.to_string()))?; Ok(PupClient { cfg }) diff --git a/src/main.rs b/src/main.rs index 3ab8a2b1..c6df6814 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod config; #[cfg(not(target_arch = "wasm32"))] mod extensions; mod formatter; +mod oauth_excluded; #[cfg(not(target_arch = "wasm32"))] mod runbooks; mod skills; diff --git a/src/oauth_excluded.rs b/src/oauth_excluded.rs new file mode 100644 index 00000000..fa8f0a31 --- /dev/null +++ b/src/oauth_excluded.rs @@ -0,0 +1,294 @@ +//! Datadog endpoints that do not accept OAuth2 bearer tokens. +//! +//! Auth on these endpoints must use either DD_API_KEY + DD_APP_KEY, or a +//! Personal/Service Access Token in the `DD-APPLICATION-KEY` slot (Datadog's +//! PAT migration form). The list is consulted by `client::apply_auth`, +//! `commands::api::run`, and the WASM `api::apply_auth` so the routing is +//! consistent across paths. +//! +//! This module is intentionally dependency-free so the browser/WASM build can +//! import it alongside `src/api.rs` without dragging in the rest of +//! `src/client.rs`. + +pub struct EndpointRequirement { + pub path: &'static str, + pub method: &'static str, +} + +/// Returns true if the endpoint doesn't support OAuth and requires API key fallback. +pub fn requires_api_key_fallback(method: &str, path: &str) -> bool { + find_endpoint_requirement(method, path).is_some() +} + +fn find_endpoint_requirement(method: &str, path: &str) -> Option<&'static EndpointRequirement> { + OAUTH_EXCLUDED_ENDPOINTS.iter().find(|req| { + if req.method != method { + return false; + } + // Trailing "/" means prefix match (for ID-parameterized paths) + if req.path.ends_with('/') { + path.starts_with(&req.path[..req.path.len() - 1]) + } else { + req.path == path + } + }) +} + +/// Endpoints that don't support OAuth. +/// Trailing "/" means prefix match for ID-parameterized paths. +pub static OAUTH_EXCLUDED_ENDPOINTS: &[EndpointRequirement] = &[ + // API/App Keys (8) + EndpointRequirement { + path: "/api/v2/api_keys", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/api_keys/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/api_keys", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/api_keys/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/application_keys", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "PATCH", + }, + // DDSQL editor tools (3) + EndpointRequirement { + path: "/api/unstable/ddsql-editor/tools/ddsql-docs", + method: "GET", + }, + EndpointRequirement { + path: "/api/unstable/ddsql-editor/tools/table-names", + method: "GET", + }, + EndpointRequirement { + path: "/api/unstable/ddsql-editor/tools/table-data", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "DELETE", + }, + // Fleet Automation (15) + EndpointRequirement { + path: "/api/v2/fleet/agents", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/agents/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/agents/versions", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/configure", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/upgrade", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "PATCH", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "POST", + }, + // Observability Pipelines (6) — API key only, no OAuth support + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/", + method: "PUT", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/validate", + method: "POST", + }, + // Cost / Billing (3) — API key only, no OAuth support + EndpointRequirement { + path: "/api/v2/usage/projected_cost", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/usage/cost_by_org", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost_by_tag/monthly_cost_attribution", + method: "GET", + }, + // Cloud Cost Management config (12) + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config/", + method: "DELETE", + }, + // Profiling (4) + // No OAuth scope is declared for Continuous Profiler endpoints; force API-key auth. + EndpointRequirement { + path: "/profiling/api/v1/", + method: "POST", + }, + EndpointRequirement { + path: "/profiling/api/v1/", + method: "GET", + }, + EndpointRequirement { + path: "/api/unstable/profiles/", + method: "POST", + }, + EndpointRequirement { + path: "/api/ui/profiling/", + method: "GET", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_excluded_endpoints_count() { + // Bumping this requires conscious thought -- it's the canonical list. + assert_eq!(OAUTH_EXCLUDED_ENDPOINTS.len(), 52); + } + + #[test] + fn test_requires_fallback_api_keys_exact_match() { + assert!(requires_api_key_fallback("GET", "/api/v2/api_keys")); + assert!(!requires_api_key_fallback("PUT", "/api/v2/api_keys")); + } + + #[test] + fn test_requires_fallback_prefix_match() { + assert!(requires_api_key_fallback( + "DELETE", + "/api/v2/api_keys/abc-123" + )); + assert!(requires_api_key_fallback( + "GET", + "/api/v2/fleet/agents/some-agent" + )); + } + + #[test] + fn test_requires_fallback_unrelated_path() { + assert!(!requires_api_key_fallback("GET", "/api/v1/monitors")); + assert!(!requires_api_key_fallback( + "POST", + "/api/v2/logs/events/search" + )); + } +} diff --git a/src/test_support.rs b/src/test_support.rs index 7ff3fc42..8a8225ed 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -25,6 +25,8 @@ pub(crate) fn test_config(mock_url: &str) -> Config { api_key: Some("test-api-key".into()), app_key: Some("test-app-key".into()), access_token: None, + pat: None, + pat_kind: None, site: "datadoghq.com".into(), site_explicit: false, org: None,