Make Slack-originated Duo flow sessions private to the invoker

What does this MR do and why?

When a user mentions @GitLab in Slack, the resulting Duo developer/v1 flow session is stored in a shared duo-workspace project. The forwarded Slack conversation (workflow goal) and the agent transcript were readable by any group member with Duo access — but Slack channel membership and GitLab group membership are unrelated sets of people.

This MR makes Slack-originated sessions readable only by the invoking user, enforced at the policy layer so it applies uniformly across all surfaces.

Surface Enforcement
GraphQL (WorkflowType, checkpoints/events, links, notes, todos) New policy prevent rule on read_duo_workflow
REST (trace.jsonl non-owner path) Same policy rule
Session listing (Project.duoWorkflowWorkflows) Finder excludes other users' messaging sessions
Session detail page shell Controller returns 404 when read_duo_workflow is denied, so unauthorized users cannot confirm the session exists
Compliance agent-artifact dashboards/downloads (authorize via read_agent_artifacts on the parent, bypassing the per-workflow policy) Artifact sync worker skips messaging sessions, ClickHouse finder filters them, artifact download returns 404

Not affected by design:

  • Slack post-back keeps working: Ai::Messaging::CallbackWorker and ProgressDeliveryWorker load workflows directly and never go through the read policy.
  • Executor/internal API keeps working: the is_workflow_owner condition already passes for composite-identity service accounts.
  • Admins: the policy never granted admins read_duo_workflow on foreign sessions; verified admin (including admin mode) is denied.

Also fixes a misleading GraphQL error: the "Please select a default namespace" hint was appended to permission denials for other users' workflows, where selecting a namespace cannot change the outcome. The hint is now only emitted when the denied workflow belongs to the current user.

Demo

References

How to set up and validate locally

  1. Set up GitLab for Slack app on GDK and mention @GitLab in Slack to create a session (or create one in the console):
  2. As a non-invoker, open /<duo-workspace-path>/-/automate/agent-sessions/<id> → 404.
  3. As the invoker, the same URL renders the session normally.
  4. Verify the agent's answer is still posted back to the Slack thread.

Data deletion

This MR includes a post-deploy migration (DeleteMessagingSessionArtifacts) and a worker change that delete rows from duo_workflow_session_artifacts for Slack-originated sessions.

  1. Recovery: the deleted rows are denormalized copies of duo_workflows_workflows (synced by SyncSessionArtifactWorker). Every deleted row can be regenerated from its source workflow via Ai::DuoWorkflows::SessionArtifact.sync_from_workflow!(workflow) — no information is lost. Recovery is intentionally not desired here: the rows expose private session metadata and the down method is a deliberate no-op.
  2. Approximate records affected: only artifacts whose workflow has messaging_callback_context ->> 'adapter' = 'slack'. The Slack @GitLab mention flow is a pre-GA surface enabled on a small number of namespaces; expected row count on GitLab.com is in the hundreds at most (will be confirmed by the database testing pipeline).
  3. User experience impact: Slack-originated sessions disappear from compliance agent-artifact dashboards (project/group) and their artifact download returns 404 — this is the intended behavior of this security fix. Regular and @GitLabDuo note-mention sessions are unaffected.

Database

Two delete_all call sites on duo_workflow_session_artifacts, plus one changed SELECT in WorkflowsFinder.

1. Worker: SyncSessionArtifactWorker (SessionArtifact.for_workflow(workflow.id).delete_all)

DELETE FROM "duo_workflow_session_artifacts"
WHERE "duo_workflow_session_artifacts"."workflow_id" = 12345

Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55044/commands/158372 (execution: 0.110 ms)

workflow_id has a unique index (index_duo_wf_session_artifacts_on_workflow_id), so this deletes at most one row per worker run via an index lookup. The clone plan shows a Seq Scan only because the table is currently near-empty; with data the unique index is used.

2. Post-deploy migration: DeleteMessagingSessionArtifacts (batched, 1,000 rows per batch)

DELETE FROM "duo_workflow_session_artifacts"
WHERE "duo_workflow_session_artifacts"."id" BETWEEN 1 AND 1000
  AND "duo_workflow_session_artifacts"."workflow_id" IN (
    SELECT "duo_workflows_workflows"."id"
    FROM "duo_workflows_workflows"
    WHERE "duo_workflows_workflows"."id" IN (
      SELECT "duo_workflow_session_artifacts"."workflow_id"
      FROM "duo_workflow_session_artifacts"
      WHERE "duo_workflow_session_artifacts"."id" BETWEEN 1 AND 1000)
    AND (messaging_callback_context ->> 'adapter') IN ('slack'))

Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55044/commands/158374 (execution: 0.113 ms)

Batches iterate over the primary key (each_batch), the inner lookups use the unique workflow_id index and the duo_workflows_workflows primary key. Expected total rows deleted on GitLab.com: hundreds at most (pre-GA surface, see Data deletion section).

3. Finder: WorkflowsFinder#base_query (.without_other_users_private_sessions)

The MR adds one condition to the existing session-listing query: AND ((messaging_callback_context ->> 'adapter') IS NULL OR (messaging_callback_context ->> 'adapter') NOT IN ('slack') OR user_id = ?). Full query (project 278964, GraphQL page size 101):

SELECT "duo_workflows_workflows".*
FROM "duo_workflows_workflows"
WHERE "duo_workflows_workflows"."project_id" = 278964
  AND "duo_workflows_workflows"."workflow_definition" NOT IN ('chat', 'orbit_agent/v1', 'duo_planner/v1', 'security_analyst_agent/v1', 'analytics_agent/v1', 'ci_expert_agent/v1', 'duo_permissions_assistant/v1', 'support_assistant/v1', 'agentic_chat/v1', 'flow_creator/v1')
  AND "duo_workflows_workflows"."environment" IN (2, 5)
  AND ((messaging_callback_context ->> 'adapter') IS NULL
    OR (messaging_callback_context ->> 'adapter') NOT IN ('slack')
    OR user_id = 42)
ORDER BY "duo_workflows_workflows"."created_at" DESC
LIMIT 101

Both plans are identical in shape (Gather Merge over the existing index_duo_workflows_workflows_project_environment_created_at index) and read effectively the same number of buffers (75,190 vs 75,151). The new predicate is a filter applied to rows the index scan already visits, so this MR adds no additional I/O; the 12.5 s wall time on the first run is cold-cache disk I/O on the clone (the baseline run, hitting the then-warm cache, shows the same buffer count at 137 ms).

MR acceptance checklist

Evaluated against the MR acceptance checklist.

Edited by Jannik Lehmann

Merge request reports

Loading
Loading