From 77eb056305524c2d8ac7585348a8bf9a5aea527f Mon Sep 17 00:00:00 2001 From: Stephen Rosenthal Date: Wed, 20 May 2026 16:14:38 -0700 Subject: [PATCH 1/4] Allow OAuth on workflow_automation commands The WorkflowAutomationAPI surface (/api/v2/workflows/{...}, including instances list/get/cancel) already accepts OAuth bearer tokens server-side in dd-source domains/workflow/apps/apis/ wf-orchestration-api/main.go. The pup side was the only blocker: the helper used make_api_no_auth! and a top-level pre-flight bailed the whole subcommand if API keys weren't set. Changes: - workflows.rs: WorkflowAutomationAPI calls now go through make_api! (3 sites). The ActionConnectionAPI helper at the bottom stays on make_api_no_auth! since the /api/v2/actions/connections/* routes do not yet accept OAuth. - main.rs: move the validate_api_and_app_keys() guard from the top-level Workflows arm down to the Run arm only. `workflows run` hits the API-trigger endpoint, which is API-key-only. - main.rs: update the AUTHENTICATION doc comment to reflect what works via OAuth and what still requires API+App keys. - auth/types.rs: add workflows_read + workflows_write to default_scopes(), and workflows_read to read_only_scopes(). Test count 85 -> 87, plus containment asserts. --- src/auth/types.rs | 9 ++++++++- src/commands/workflows.rs | 8 ++++---- src/main.rs | 23 ++++++++++++++--------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/auth/types.rs b/src/auth/types.rs index c3d90679..2b371855 100644 --- a/src/auth/types.rs +++ b/src/auth/types.rs @@ -96,6 +96,7 @@ pub fn read_only_scopes() -> Vec<&'static str> { "timeseries_query", "usage_read", "user_access_read", + "workflows_read", ] } @@ -225,6 +226,9 @@ pub fn default_scopes() -> Vec<&'static str> { "usage_read", // Users "user_access_read", + // Workflows + "workflows_read", + "workflows_write", ] } @@ -271,7 +275,7 @@ mod tests { #[test] fn test_default_scopes() { let scopes = default_scopes(); - assert_eq!(scopes.len(), 85); + assert_eq!(scopes.len(), 87); assert!(scopes.contains(&"dashboards_read")); assert!(scopes.contains(&"monitors_read")); assert!(scopes.contains(&"logs_read_data")); @@ -290,6 +294,9 @@ mod tests { assert!(scopes.contains(&"on_call_write")); assert!(scopes.contains(&"aws_configuration_read")); assert!(scopes.contains(&"gcp_configuration_read")); + // Workflows + assert!(scopes.contains(&"workflows_read")); + assert!(scopes.contains(&"workflows_write")); } #[test] diff --git a/src/commands/workflows.rs b/src/commands/workflows.rs index d2f75bdb..6cee242b 100644 --- a/src/commands/workflows.rs +++ b/src/commands/workflows.rs @@ -11,11 +11,11 @@ use crate::formatter::{self, Metadata}; use crate::util; // --------------------------------------------------------------------------- -// Helper: build a WorkflowAutomationAPI (API key auth only) +// Helper: build a WorkflowAutomationAPI // --------------------------------------------------------------------------- fn make_api(cfg: &Config) -> WorkflowAutomationAPI { - crate::make_api_no_auth!(WorkflowAutomationAPI, cfg) + crate::make_api!(WorkflowAutomationAPI, cfg) } // --------------------------------------------------------------------------- @@ -74,7 +74,7 @@ pub async fn run( wait: bool, timeout: &str, ) -> Result<()> { - let api = crate::make_api_no_auth!(WorkflowAutomationAPI, cfg); + let api = crate::make_api!(WorkflowAutomationAPI, cfg); let input_payload: Option> = match (&payload, &payload_file) { @@ -132,7 +132,7 @@ pub async fn run( tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let api = crate::make_api_no_auth!(WorkflowAutomationAPI, cfg); + let api = crate::make_api!(WorkflowAutomationAPI, cfg); let status = api .get_workflow_instance(workflow_id.to_string(), instance_id.clone()) .await diff --git a/src/main.rs b/src/main.rs index c3e6fb80..cee052e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2728,8 +2728,13 @@ enum Commands { /// pup workflows instances cancel /// /// AUTHENTICATION: - /// All workflow commands require DD_API_KEY + DD_APP_KEY. - /// OAuth2 bearer tokens are not supported for workflow operations at this time. + /// Workflow CRUD (`workflows get/create/update/delete`) and + /// workflow instance commands (`workflows instances *`) accept + /// OAuth2 (`pup auth login`) or DD_API_KEY + DD_APP_KEY. + /// `workflows run` is API-key-only: it triggers a workflow via + /// the API trigger endpoint, which requires DD_API_KEY + DD_APP_KEY. + /// `workflows connections *` is API-key-only at this time, pending + /// server-side OAuth enablement on the action-connections API. #[command(verbatim_doc_comment)] Workflows { #[command(subcommand)] @@ -14414,13 +14419,6 @@ async fn main_inner() -> anyhow::Result<()> { }, // --- Workflows --- Commands::Workflows { action } => { - cfg.validate_api_and_app_keys().map_err(|_| { - anyhow::anyhow!( - "workflow commands require DD_API_KEY and DD_APP_KEY with workflow_* scopes\n\ - OAuth2 bearer tokens are not supported for workflow operations.\n\ - See: https://docs.datadoghq.com/api/latest/workflow-automation" - ) - })?; match action { WorkflowActions::Get { workflow_id } => { commands::workflows::get(&cfg, &workflow_id).await?; @@ -14441,6 +14439,13 @@ async fn main_inner() -> anyhow::Result<()> { wait, timeout, } => { + cfg.validate_api_and_app_keys().map_err(|_| { + anyhow::anyhow!( + "`workflows run` triggers a workflow via the API trigger \ + endpoint, which requires DD_API_KEY + DD_APP_KEY.\n\ + See: https://docs.datadoghq.com/api/latest/workflow-automation" + ) + })?; commands::workflows::run( &cfg, &workflow_id, From f831583c84fee35f8ef94afc3b953b731530adb4 Mon Sep 17 00:00:00 2001 From: Stephen Rosenthal Date: Thu, 21 May 2026 10:49:56 -0700 Subject: [PATCH 2/4] Also allow OAuth on workflows run and instances cancel The /api/v2/workflows/{id}/instances POST and the .../instances/{id}/cancel PUT routes both attach defaultAuthnPerms in domains/workflow/apps/apis/wf-orchestration-api/main.go, which includes ValidOAuthAccessToken. The historical API-key-only guidance in pup pre-dated that change and is stale. - Add workflows_run to default_scopes() (not read_only_scopes). - Drop the API-key-only pre-flight from the workflows run dispatch. - Update the Workflows / Run doc-comments to stop claiming OAuth is unsupported, and drop the (now incorrect) section header 'API trigger only -- requires DD_API_KEY + DD_APP_KEY' in commands/workflows.rs. workflows.rs's WorkflowAutomationAPI helpers are already on make_api!, so cancel and run will use the OAuth bearer when present and fall back to DD_API_KEY+DD_APP_KEY otherwise. The ActionConnectionAPI helper stays on make_api_no_auth! until dd-source#426542 lands. Requires: workflows_run added to the datadog-pup-cli OAuth client allowlist per DC, otherwise pup auth login fails with invalid_scope. --- src/auth/types.rs | 4 +++- src/commands/workflows.rs | 2 +- src/main.rs | 29 +++++++++++------------------ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/auth/types.rs b/src/auth/types.rs index 2b371855..9b304809 100644 --- a/src/auth/types.rs +++ b/src/auth/types.rs @@ -228,6 +228,7 @@ pub fn default_scopes() -> Vec<&'static str> { "user_access_read", // Workflows "workflows_read", + "workflows_run", "workflows_write", ] } @@ -275,7 +276,7 @@ mod tests { #[test] fn test_default_scopes() { let scopes = default_scopes(); - assert_eq!(scopes.len(), 87); + assert_eq!(scopes.len(), 88); assert!(scopes.contains(&"dashboards_read")); assert!(scopes.contains(&"monitors_read")); assert!(scopes.contains(&"logs_read_data")); @@ -296,6 +297,7 @@ mod tests { assert!(scopes.contains(&"gcp_configuration_read")); // Workflows assert!(scopes.contains(&"workflows_read")); + assert!(scopes.contains(&"workflows_run")); assert!(scopes.contains(&"workflows_write")); } diff --git a/src/commands/workflows.rs b/src/commands/workflows.rs index 6cee242b..9a7f8cad 100644 --- a/src/commands/workflows.rs +++ b/src/commands/workflows.rs @@ -63,7 +63,7 @@ pub async fn delete(cfg: &Config, workflow_id: &str) -> Result<()> { } // --------------------------------------------------------------------------- -// Workflow execution (API trigger only — requires DD_API_KEY + DD_APP_KEY) +// Workflow execution // --------------------------------------------------------------------------- pub async fn run( diff --git a/src/main.rs b/src/main.rs index cee052e0..81a361e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2708,7 +2708,7 @@ enum Commands { /// CAPABILITIES: /// • Get workflow details /// • Create, update, and delete workflows - /// • Execute workflows via API trigger (requires DD_API_KEY + DD_APP_KEY) + /// • Execute workflows via API trigger /// • List, inspect, and cancel workflow instances (executions) /// /// EXAMPLES: @@ -2728,13 +2728,12 @@ enum Commands { /// pup workflows instances cancel /// /// AUTHENTICATION: - /// Workflow CRUD (`workflows get/create/update/delete`) and - /// workflow instance commands (`workflows instances *`) accept - /// OAuth2 (`pup auth login`) or DD_API_KEY + DD_APP_KEY. - /// `workflows run` is API-key-only: it triggers a workflow via - /// the API trigger endpoint, which requires DD_API_KEY + DD_APP_KEY. - /// `workflows connections *` is API-key-only at this time, pending - /// server-side OAuth enablement on the action-connections API. + /// Workflow CRUD (`workflows get/create/update/delete`), + /// `workflows run`, and `workflows instances *` accept OAuth2 + /// (`pup auth login`) or DD_API_KEY + DD_APP_KEY. + /// `workflows connections *` requires DD_API_KEY + DD_APP_KEY + /// pending server-side OAuth enablement on the action-connections + /// API. #[command(verbatim_doc_comment)] Workflows { #[command(subcommand)] @@ -4264,10 +4263,11 @@ enum WorkflowActions { }, /// Delete a workflow Delete { workflow_id: String }, - /// Execute a workflow via API trigger (requires DD_API_KEY + DD_APP_KEY) + /// Execute a workflow via API trigger /// - /// The workflow must have an API trigger configured. - /// OAuth tokens are not supported — this command requires API key authentication. + /// The workflow must have an API trigger configured. Accepts OAuth2 + /// (`pup auth login`, requires the `workflows_run` scope) or + /// DD_API_KEY + DD_APP_KEY. #[command(verbatim_doc_comment)] Run { workflow_id: String, @@ -14439,13 +14439,6 @@ async fn main_inner() -> anyhow::Result<()> { wait, timeout, } => { - cfg.validate_api_and_app_keys().map_err(|_| { - anyhow::anyhow!( - "`workflows run` triggers a workflow via the API trigger \ - endpoint, which requires DD_API_KEY + DD_APP_KEY.\n\ - See: https://docs.datadoghq.com/api/latest/workflow-automation" - ) - })?; commands::workflows::run( &cfg, &workflow_id, From 7f0853894260abdd7a7a6858025d165e01eb5d56 Mon Sep 17 00:00:00 2001 From: Stephen Rosenthal Date: Thu, 21 May 2026 15:24:04 -0700 Subject: [PATCH 3/4] Drop scopes.len() == 88 assertion to avoid merge-conflict churn Several PRs in flight all add/remove scopes in default_scopes(), each one bumping the hardcoded count. Every collision turns into a trivial 3-way merge resolve. The containment asserts below give us coverage for the scopes this PR cares about; dropping the count assertion costs nothing meaningful. --- src/auth/types.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth/types.rs b/src/auth/types.rs index 9b304809..1eeef8b8 100644 --- a/src/auth/types.rs +++ b/src/auth/types.rs @@ -276,7 +276,6 @@ mod tests { #[test] fn test_default_scopes() { let scopes = default_scopes(); - assert_eq!(scopes.len(), 88); assert!(scopes.contains(&"dashboards_read")); assert!(scopes.contains(&"monitors_read")); assert!(scopes.contains(&"logs_read_data")); From c4532ae461732cd782be6be54809c02f81e9856e Mon Sep 17 00:00:00 2001 From: Stephen Rosenthal Date: Thu, 21 May 2026 15:42:50 -0700 Subject: [PATCH 4/4] =?UTF-8?q?Run=20cargo=20fmt=20=E2=80=94=20collapse=20?= =?UTF-8?q?Workflows=20arm=20after=20removing=20outer=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the top-level validate_api_and_app_keys() pre-flight left the Workflows arm as `=> { match action { ... } }`, which cargo fmt prefers to collapse to `=> match action { ... }`. Pure formatting, no semantic change. CI 'Check, Test & Coverage' was failing on cargo fmt --check; this fixes it. The AI annotation that claimed this was a missing-match- arm compilation error was a misclassification. --- src/main.rs | 123 ++++++++++++++++++++++++---------------------------- 1 file changed, 56 insertions(+), 67 deletions(-) diff --git a/src/main.rs b/src/main.rs index 81a361e7..df85e840 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14418,79 +14418,68 @@ async fn main_inner() -> anyhow::Result<()> { AuthActions::Test => commands::test::run(&cfg)?, }, // --- Workflows --- - Commands::Workflows { action } => { - match action { - WorkflowActions::Get { workflow_id } => { - commands::workflows::get(&cfg, &workflow_id).await?; + Commands::Workflows { action } => match action { + WorkflowActions::Get { workflow_id } => { + commands::workflows::get(&cfg, &workflow_id).await?; + } + WorkflowActions::Create { file } => { + commands::workflows::create(&cfg, &file).await?; + } + WorkflowActions::Update { workflow_id, file } => { + commands::workflows::update(&cfg, &workflow_id, &file).await?; + } + WorkflowActions::Delete { workflow_id } => { + commands::workflows::delete(&cfg, &workflow_id).await?; + } + WorkflowActions::Run { + workflow_id, + payload, + payload_file, + wait, + timeout, + } => { + commands::workflows::run(&cfg, &workflow_id, payload, payload_file, wait, &timeout) + .await?; + } + WorkflowActions::Instances { action } => match action { + WorkflowInstanceActions::List { + workflow_id, + limit, + page, + } => { + commands::workflows::instance_list(&cfg, &workflow_id, limit, page).await?; } - WorkflowActions::Create { file } => { - commands::workflows::create(&cfg, &file).await?; + WorkflowInstanceActions::Get { + workflow_id, + instance_id, + } => { + commands::workflows::instance_get(&cfg, &workflow_id, &instance_id).await?; } - WorkflowActions::Update { workflow_id, file } => { - commands::workflows::update(&cfg, &workflow_id, &file).await?; + WorkflowInstanceActions::Cancel { + workflow_id, + instance_id, + } => { + commands::workflows::instance_cancel(&cfg, &workflow_id, &instance_id).await?; } - WorkflowActions::Delete { workflow_id } => { - commands::workflows::delete(&cfg, &workflow_id).await?; + }, + WorkflowActions::Connections { action } => match action { + WorkflowConnectionActions::Get { connection_id } => { + commands::workflows::connections_get(&cfg, &connection_id).await?; } - WorkflowActions::Run { - workflow_id, - payload, - payload_file, - wait, - timeout, + WorkflowConnectionActions::Create { file } => { + commands::workflows::connections_create(&cfg, &file).await?; + } + WorkflowConnectionActions::Update { + connection_id, + file, } => { - commands::workflows::run( - &cfg, - &workflow_id, - payload, - payload_file, - wait, - &timeout, - ) - .await?; + commands::workflows::connections_update(&cfg, &connection_id, &file).await?; } - WorkflowActions::Instances { action } => match action { - WorkflowInstanceActions::List { - workflow_id, - limit, - page, - } => { - commands::workflows::instance_list(&cfg, &workflow_id, limit, page).await?; - } - WorkflowInstanceActions::Get { - workflow_id, - instance_id, - } => { - commands::workflows::instance_get(&cfg, &workflow_id, &instance_id).await?; - } - WorkflowInstanceActions::Cancel { - workflow_id, - instance_id, - } => { - commands::workflows::instance_cancel(&cfg, &workflow_id, &instance_id) - .await?; - } - }, - WorkflowActions::Connections { action } => match action { - WorkflowConnectionActions::Get { connection_id } => { - commands::workflows::connections_get(&cfg, &connection_id).await?; - } - WorkflowConnectionActions::Create { file } => { - commands::workflows::connections_create(&cfg, &file).await?; - } - WorkflowConnectionActions::Update { - connection_id, - file, - } => { - commands::workflows::connections_update(&cfg, &connection_id, &file) - .await?; - } - WorkflowConnectionActions::Delete { connection_id } => { - commands::workflows::connections_delete(&cfg, &connection_id).await?; - } - }, - } - } + WorkflowConnectionActions::Delete { connection_id } => { + commands::workflows::connections_delete(&cfg, &connection_id).await?; + } + }, + }, // --- LLM Observability --- Commands::LlmObs { action } => { cfg.validate_auth()?;