From 0d3f00478d8eb4cac838836cc91456bce4fd3473 Mon Sep 17 00:00:00 2001 From: Lucas Kretvix Date: Sat, 9 May 2026 09:36:07 -0700 Subject: [PATCH] feat(auth): expose OAuth scope contract --- CHANGELOG.md | 6 ++++ src/commands/auth.rs | 36 +++++++++++++++++++ src/main.rs | 41 +++++++++++++++++++-- tests/auth_scopes_json.rs | 76 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 tests/auth_scopes_json.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1b6a8d..7a16a4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # Changelog +## Next release + +- Add `pup auth scopes --output=json` to expose the default `pup auth login` + OAuth scope set without requiring authentication. The first compatible + version for Bits feature-probing will be the next release after `0.58.5`. + See the [GitHub Releases](https://github.com/DataDog/pup/releases) page for the authoritative list of changes per release. diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 0a171b22..4bf0d17e 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -1,7 +1,43 @@ use anyhow::{bail, Result}; +use serde::Serialize; use crate::auth::storage; use crate::config::Config; +use crate::config::OutputFormat; + +#[derive(Debug, Serialize)] +pub(crate) struct AuthScopesContract { + pub schema_version: u8, + pub scopes: Vec, + pub source: &'static str, +} + +pub(crate) fn default_login_scopes() -> Vec { + crate::auth::types::default_scopes() + .into_iter() + .map(String::from) + .collect() +} + +pub(crate) fn default_login_scope_contract() -> AuthScopesContract { + AuthScopesContract { + schema_version: 1, + scopes: default_login_scopes(), + source: "pup auth login", + } +} + +pub(crate) fn scopes(output_format: &OutputFormat) -> Result<()> { + if *output_format != OutputFormat::Json { + bail!("pup auth scopes only supports --output=json"); + } + + println!( + "{}", + serde_json::to_string_pretty(&default_login_scope_contract())? + ); + Ok(()) +} /// Helper to run a closure with the storage lock held (non-async to avoid holding lock across await). fn with_storage(f: F) -> Result diff --git a/src/main.rs b/src/main.rs index d3a22604..b3bf7f51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9263,6 +9263,8 @@ enum AuthActions { Refresh, /// List all stored org sessions List, + /// Print the default OAuth scopes requested by `pup auth login` + Scopes, /// Test connection and credentials Test, } @@ -10027,7 +10029,7 @@ fn resolve_login_scopes( org: Option<&str>, read_only: bool, ) -> Vec { - use crate::auth::types::{all_known_scopes, default_scopes, read_only_scopes}; + use crate::auth::types::{all_known_scopes, read_only_scopes}; if let Some(raw) = cli_scopes { // User explicitly specified scopes — validate against known list, warn on unknowns @@ -10063,7 +10065,7 @@ fn resolve_login_scopes( if read_only { read_only_scopes().into_iter().map(String::from).collect() } else { - default_scopes().into_iter().map(String::from).collect() + crate::commands::auth::default_login_scopes() } } @@ -10079,6 +10081,33 @@ fn resolve_login_scopes( .collect() } +#[cfg(test)] +mod resolve_login_scopes_tests { + use super::resolve_login_scopes; + use crate::test_utils::ENV_LOCK; + + #[test] + fn default_login_scopes_match_auth_scopes_contract() { + let _g = ENV_LOCK.blocking_lock(); + let config_dir = std::env::temp_dir().join(format!( + "pup-default-login-scopes-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&config_dir).unwrap(); + std::env::set_var("PUP_CONFIG_DIR", &config_dir); + + let contract = crate::commands::auth::default_login_scope_contract(); + let login_scopes = resolve_login_scopes(None, None, false); + + std::env::remove_var("PUP_CONFIG_DIR"); + assert_eq!(login_scopes, contract.scopes); + } +} + /// Resolve the OAuth callback port. CLI flag wins over `PUP_OAUTH_CALLBACK_PORT`; /// when both are unset, returns `None` so the callback server scans the DCR /// allowlist as before. The port must be one of `DCR_REDIRECT_PORTS` — those @@ -10374,6 +10403,13 @@ async fn main_inner() -> anyhow::Result<()> { } return Ok(()); } + if let Commands::Auth { + action: AuthActions::Scopes, + } = &cli.command + { + commands::auth::scopes(&cli.output.parse()?)?; + return Ok(()); + } let mut cfg = config::Config::from_env()?; @@ -14033,6 +14069,7 @@ async fn main_inner() -> anyhow::Result<()> { AuthActions::Token => commands::auth::token(&cfg)?, AuthActions::Refresh => commands::auth::refresh(&cfg).await?, AuthActions::List => commands::auth::list(&cfg)?, + AuthActions::Scopes => commands::auth::scopes(&cfg.output_format)?, AuthActions::Test => commands::test::run(&cfg)?, }, // --- Workflows --- diff --git a/tests/auth_scopes_json.rs b/tests/auth_scopes_json.rs new file mode 100644 index 00000000..3323355a --- /dev/null +++ b/tests/auth_scopes_json.rs @@ -0,0 +1,76 @@ +use serde_json::Value; +use std::process::Command; + +fn run_auth_scopes(output: &str) -> std::process::Output { + let config_dir = + std::env::temp_dir().join(format!("pup-auth-scopes-json-{}", std::process::id())); + std::fs::create_dir_all(&config_dir).expect("create isolated config dir"); + + Command::new(env!("CARGO_BIN_EXE_pup")) + .args(["auth", "scopes", &format!("--output={output}")]) + .env("PUP_CONFIG_DIR", config_dir) + .env_remove("DD_ACCESS_TOKEN") + .env_remove("DD_API_KEY") + .env_remove("DD_APP_KEY") + .env_remove("DD_SITE") + .env_remove("DD_ORG") + .output() + .expect("run pup auth scopes") +} + +#[test] +fn auth_scopes_json_emits_default_oauth_scope_contract_without_auth() { + let output = run_auth_scopes("json"); + + assert!( + output.status.success(), + "expected pup auth scopes to succeed without auth; status: {:?}, stderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "scope discovery should not start OAuth or print auth diagnostics: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let value: Value = serde_json::from_slice(&output.stdout).expect("valid JSON scope contract"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["source"], "pup auth login"); + + let scopes = value["scopes"] + .as_array() + .expect("scopes should be a JSON array"); + assert!(!scopes.is_empty(), "scopes should not be empty"); + assert!( + scopes.iter().all(|scope| scope.as_str().is_some()), + "all scopes should be strings: {scopes:?}" + ); + + let scope_strings: Vec<&str> = scopes.iter().filter_map(Value::as_str).collect(); + assert!(scope_strings.contains(&"metrics_read")); + assert!(scope_strings.contains(&"timeseries_query")); + assert!(scope_strings.contains(&"dashboards_write")); + assert!(scope_strings.contains(&"org_management")); +} + +#[test] +fn auth_scopes_json_rejects_non_json_output() { + let output = run_auth_scopes("table"); + + assert!( + !output.status.success(), + "expected pup auth scopes --output=table to fail" + ); + assert!( + output.stdout.is_empty(), + "scope discovery should not emit table output: {}", + String::from_utf8_lossy(&output.stdout) + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("pup auth scopes only supports --output=json"), + "expected non-json diagnostic, got: {stderr}" + ); +}