Add Duo Workflow server-side execution auth endpoint

What does this MR do and why?

Adds POST /api/v4/ai/duo_workflows/workflows/:workflow_id/execute, the Workhorse pre-authorization counterpart to GET /api/v4/ai/duo_workflows/ws.

:ws exists so Workhorse can proxy between a client and the Duo Workflow Service (DWS): the client drives the flow and executes the actions DWS asks for. That requires the caller to be able to run commands, read files, and answer tool calls — which server side callers such as a Slack chat turn cannot do. Today the only way to run a flow for them is to spin up a CI job whose sole purpose is to hold the gRPC stream and answer RunHTTPRequest / RunMCPTool, which Workhorse already knows how to do.

This endpoint hands Workhorse the same connection payload so it can open the DWS stream itself and stream the actions back over a single chunked HTTP response, with no client in the loop.

Changes

  1. Extract Duo Workflow connection config into a helper — pure refactor. The :ws route built its whole DuoWorkflow payload inline in a ~130-line block; it is now duo_workflow_connection_config(workflow:), shared by both endpoints. Split into a few smaller helpers to stay within Metrics/AbcSize (the payload in one method measures 61.55 against a limit of 54.28). Behaviour-preserving apart from one error ordering change, described in the commit message.

  2. Add Duo Workflow server-side execution auth endpoint — the new route, plus:

    • Authorization reuses find_workflow!, so the existing mechanism carries over. For a composite identity token, current_user is already resolved to the linked human user before the lookup, so ownership and read_duo_workflow are checked against them and not against the service account. Same route_setting :authorization permission as :ws (read_duo_workflow_websocket).
    • WorkflowID is added to the payload. Workhorse authorizes nothing itself, so it must not take the workflow ID from the caller's request body. It is sent only here; on the :ws path the ID comes from the client's own StartWorkflowRequest.
    • params :duo_workflow_connection_params declares the attributes the shared helper reads. :ws reads the same set undeclared; that is left alone because adding Grape coercion there would be a behaviour change.
    • Rate limit reuses duo_workflow_direct_access (50/min/user), already shared by direct_access and list_tools. Every request here makes Workhorse hold a gRPC stream open for the duration of the run.
    • Scope predicate now branches on request method so an ai_workflows token can POST this path, while GET keeps ws and trace.jsonl.
  3. Move Duo Workflow connection config into a service — also a pure refactor, and it supersedes the helper split in item 1: the six private Grape helpers are replaced by Ai::DuoWorkflows::ConnectionConfigService, which sits next to the McpConfigService and DuoAgentPlatformModelMetadataService it already calls. The motivation is testability — while the tool access policy, MCP enforcement namespace, and model metadata rules lived in a Grape helper, they were reachable only through a full request spec.

    • Request-dependent work stays in the API class: resolving and authorizing the namespaces (find_root_namespace!, most_specific_namespace_or_root, request_project_within), minting the GitLab token, pushing feature flags, and compute_server_capabilities, which the direct_access endpoint also uses.
    • The nine request attributes the payload reads are passed as one RequestMetadata value object (Data.define, nested in the service): nine keyword arguments would exceed the Metrics/ParameterLists limit of 8, and the object names the data clump. They stay raw rather than resolved because the outgoing gRPC headers echo caller-supplied ids verbatim, so resolving them here would change what the Duo Workflow Service receives.
    • Net effect on ee/lib/api/ai/duo_workflows/workflows.rb: 185 lines removed, 33 added, no behaviour change.

Not usable yet

Workhorse has no route for this path, and require_gitlab_workhorse! rejects anything that did not transit Workhorse, so the endpoint is unreachable until the Workhorse handler lands. Splitting it out keeps the Rails authorization surface reviewable on its own.

Payload compatibility with the current Workhorse

WorkflowID is added in exactly one place: the post :execute route block in ee/lib/api/ai/duo_workflows/workflows.rb, via .merge(WorkflowID: workflow.id.to_s). It is deliberately not added to the shared duo_workflow_connection_config helper, so the payload returned by the existing, live GET /ai/duo_workflows/ws endpoint is unchanged. Only :execute carries the extra field.

Workhorse's api.DuoWorkflow struct in workhorse/internal/api/api.go has no WorkflowID field yet — it currently has Service, CloudServiceForSelfHosted, McpServers, LockConcurrentFlow, ServerCapabilities, and TimeoutHTTPRequests. Adding the field is follow-up work in a separate MR. The extra key is harmless in the meantime because Workhorse decodes the pre-authorization response with a plain json.NewDecoder(httpResponse.Body).Decode(...), and Go's encoding/json discards unknown object keys by default. Strict decoding requires opting in via Decoder.DisallowUnknownFields(), which does not appear anywhere in Workhorse's Go source.

I verified this empirically rather than by inspection alone: decoding the new payload shape into a copy of the current api.DuoWorkflow struct succeeds with every existing field populated correctly and the WorkflowID key dropped. As a control, the same input with DisallowUnknownFields() enabled fails with json: unknown field "WorkflowID", which confirms the field was actually present in the input being decoded.

One implication for the follow-up MR: because the decode is lenient, a misspelled field name on either the Rails or the Go side fails silently. Workhorse would see a zero-value WorkflowID and start a flow with an empty ID instead of erroring. The Go handler should therefore reject an empty WorkflowID explicitly rather than relying on the decode to catch it.

References

Extracted and reworked from the proof of concept in !246709, which used a cloned :ws action at POST /api/v4/ai/duo_workflows/direct.

Follow-up work, in separate merge requests:

  • The Workhorse handler and route that call this endpoint.
  • Adding WorkflowID to the api.DuoWorkflow struct on the Go side.

Screenshots or screen recordings

Not applicable: no user-visible change.

How to set up and validate locally

The endpoint cannot be exercised end to end until the Workhorse handler exists, so validation is the spec suite plus the generated artifacts:

  1. Run the request specs:

    bundle exec rspec ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb

    The new coverage is under describe 'POST /ai/duo_workflows/workflows/:workflow_id/execute': the owner happy path, another user's workflow, a nonexistent workflow, a request that did not transit Workhorse, a missing Workhorse JWT, unauthenticated, an ai_workflows-scoped token, a token without that scope, a composite identity token resolving to the linked user (and 404 for an unlinked user's workflow), identity verification, and the rate limit. The ai_workflows scope path condition block covers the predicate change.

  2. Confirm the generated documentation is in sync:

    bundle exec rake gitlab:permissions:routes:compile_docs
    GITLAB_SIMULATE_SAAS=false bundle exec rake gitlab:openapi:v3:check_docs
  3. Run the new service unit spec. It covers the payload shape and gRPC header composition, forwarded headers taking precedence over the client_type param, the model metadata stickiness branch, the prompt cache fallback to namespace settings, MCP enforcement namespace anchoring (the project namespace wins; a namespace outside the validated root falls back to the root), and the tool access policy branches (flag off, ungoverned surface, resolution failure, and the MCP pre-approval append with ask/deny subtraction):

    bundle exec rspec ee/spec/services/ai/duo_workflows/connection_config_service_spec.rb

    26 examples, 0 failures.

  4. Confirm neither refactor changed behaviour. Both refactor commits leave ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb alone, so the same request examples run against the code before and after each of them. Rather than checking out single files at HEAD~N, which breaks as commits are added, inspect the spec file's history on this branch and run the suite:

    git log --oneline origin/master..HEAD -- ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb
    bundle exec rspec ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb

    Only the endpoint commit should appear in that log; neither refactor commit should. The suite reports 493 examples, 0 failures, 16 pending.

MR acceptance checklist

  • Tests: 19 new request-spec examples covering the authorization actor matrix, token scopes, the Workhorse JWT boundary, and rate limiting; 6 new scope-predicate examples; 26 new unit examples for Ai::DuoWorkflows::ConnectionConfigService covering the payload shape, gRPC header composition, MCP enforcement namespace anchoring, and the tool access policy branches. Full request file: 493 examples, 0 failures, 16 pending.
  • Changelog: Changelog: other with EE: true, on the first commit. Typed other rather than added because the endpoint is internal Workhorse plumbing with nothing user-visible until the Workhorse handler ships.
  • Documentation: doc/auth/tokens/fine_grained_access_tokens_rest.md and doc/api/openapi/openapi_v3.yaml regenerated with their rake tasks. No prose docs, as this is not a publicly callable endpoint.
  • Database: no migrations, no schema or query changes.
  • Translation: no new user-facing strings; the one error message reuses the existing externalized DuoAgentsPlatform|Identity verification is required... string.
  • Application Security: touches authorization and token handling, so a security review is welcome. The scope-predicate regexes are \A/\z anchored with Regexp.escape on the relative URL root, and are covered by tests for foreign prefixes, extra path segments, and the wrong HTTP verb.
  • Feature flag: none. Flagging for reviewer input — route_setting :lifecycle, :experiment endpoints are meant to sit behind an off-by-default flag, and none of the sibling endpoints in this class (:ws, direct_access, list_tools) has one. Since this route makes the server run agent flows, a kill switch may be worth adding before the Workhorse side lands.
Edited by Igor Drozdov

Merge request reports

Loading
Loading