Duo Messaging: WorkspaceProjectService for duo-workspace project

Problem

The Duo Messaging Service enables users to interact with Duo from external messaging services (Slack first, then Teams, etc.). Users @mention Duo, give it a task, and Duo works on it asynchronously using the existing Flows API (CI pipeline) infrastructure.

A key challenge is that CI pipelines require a project, but messaging services have no project context. This MR provides that foundation: a per-namespace duo-workspace project that serves as the default execution environment.

Where this fits

This is the PoC MR broken into reviewable pieces:

# Issue MR Description
1 #597569 (closed) This MR Workspace project service
2 #597570 (closed) Orchestrator, adapter base, callback infrastructure
3 #597571 (closed) Slack adapter and AppMentionedService wiring
4 #597573 User-facing documentation

What does this MR do?

Implements Ai::Messaging::WorkspaceProjectService, a find-or-create service that provides a duo-workspace project per namespace:

  • Finds an existing project by path, or creates a new private project in the namespace
  • Initializes with a README linking to AGENTS.md and agent-config.yml customization docs
  • Disables unnecessary features (wiki, snippets, container registry, packages)
  • Enables CI/CD builds (required for flow execution)
  • Handles concurrent creation via ActiveRecord::RecordNotUnique rescue + retry
  • Follows the same auto-creation pattern as Security Policy Projects

No feature flag needed — this service is inert without a caller.

Design decisions

Service account: reuse the developer/v1 catalog SA

During implementation, we investigated three approaches for the messaging service account:

Option Approach Tradeoff
A: Reuse developer/v1 catalog SA Messaging uses the existing SA created when admin enables the Developer flow Messaging triggers developer/v1, so it should use its SA
B: New messaging/v1 foundational flow New catalog entry with its own SA No actual messaging/v1 workflow definition exists — bends the abstraction
C: Self-bootstrapping SA (PoC approach) Messaging creates its own SA on-demand Redundant — creates a second SA for the same flow in the same namespace

We chose Option A. Messaging is a trigger mechanism for developer/v1, not a separate flow. The SA identity should reflect the flow being executed, not the trigger source. This means:

  • No separate ResolveServiceAccountService needed (removed from original issue scope)
  • The TriggerFlowService (MR #2 (closed)) will use Ai::Catalog::ItemConsumers::ResolveServiceAccountService to resolve the existing developer/v1 SA
  • If the Developer flow is not enabled for the namespace, messaging returns a clear error guiding the user

Workspace project creation: tied to flow enablement

The workspace project will be created as part of enabling the Developer flow for a namespace (in ItemConsumers::CreateService), using the admin's permissions. This avoids permission issues at trigger time since regular users may not have create_projects access.

Follow-up needed: backfill for existing enablements

Namespaces that already have developer/v1 enabled before this ships won't have a workspace project. A follow-up is needed — likely a migration that creates the project for existing group-level developer/v1 item consumers.

How to test locally (GDK)

Setup

# In Rails console (bin/rails c)
user = User.find_by(username: 'root')

Scenario 1: Fresh creation (happy path)

group = user.owned_groups.first

# Delete existing workspace project if present
existing = group.projects.find_by_path('duo-workspace')
::Projects::DestroyService.new(existing, user).execute if existing

service = Ai::Messaging::WorkspaceProjectService.new(namespace: group, current_user: user)
result = service.execute

Expected:

  • result.success?true
  • Project created at <group>/duo-workspace
  • Visibility is private
  • Wiki, snippets, container registry, packages disabled
  • CI/CD builds enabled

Scenario 2: Idempotency (project already exists)

# Call the service again on the same group (from scenario 1)
result2 = service.execute

Expected:

  • result2.success?true
  • result2.payload[:project].id == result.payload[:project].idtrue (same project, no new creation)

Scenario 3: Permission denied (non-member, no existing project)

# Find a group with no existing duo-workspace
clean_group = user.owned_groups.detect { |g| g.projects.find_by_path('duo-workspace').nil? }
non_member = User.where.not(id: user.id).where(user_type: :human).first

clean_group.member?(non_member) # => false

result3 = Ai::Messaging::WorkspaceProjectService.new(
  namespace: clean_group, current_user: non_member
).execute

Expected:

  • result3.success?false
  • result3.message includes "permission"

Scenario 4: Existing project accessible regardless of membership

# Use a group where duo-workspace already exists (from scenario 1)
result4 = Ai::Messaging::WorkspaceProjectService.new(
  namespace: group, current_user: non_member
).execute

Expected:

  • result4.success?true
  • Returns the existing project (find succeeds, no creation needed)
  • Note: authorization for what the user can do with the project is the caller's responsibility

Scenario 5: README content

project = result.payload[:project]
readme = project.repository.blob_at(project.default_branch, 'README.md')
puts readme.data

Expected:

Scenario 6: Project settings

project = result.payload[:project]

project.visibility                  # => "private"
project.builds_enabled?             # => true
project.wiki_enabled?               # => false
project.snippets_enabled?           # => false
project.container_registry_enabled  # => false
project.packages_enabled            # => false
project.description                 # => "Duo Agent workspace — customizable agent environment..."
Edited by Thomas Schmidt

Merge request reports

Loading