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::CallbackWorkerandProgressDeliveryWorkerload workflows directly and never go through the read policy. - Executor/internal API keeps working: the
is_workflow_ownercondition already passes for composite-identity service accounts. - Admins: the policy never granted admins
read_duo_workflowon 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
- Resolves https://gitlab.com/gitlab-org/gitlab/-/work_items/613920
- Threat model (T-03, Critical): https://gitlab.com/gitlab-com/gl-security/product-security/appsec/threat-models/-/merge_requests/84
How to set up and validate locally
- Set up GitLab for Slack app on GDK and mention
@GitLabin Slack to create a session (or create one in the console): - As a non-invoker, open
/<duo-workspace-path>/-/automate/agent-sessions/<id>→ 404. - As the invoker, the same URL renders the session normally.
- 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.
- Recovery: the deleted rows are denormalized copies of
duo_workflows_workflows(synced bySyncSessionArtifactWorker). Every deleted row can be regenerated from its source workflow viaAi::DuoWorkflows::SessionArtifact.sync_from_workflow!(workflow)— no information is lost. Recovery is intentionally not desired here: the rows expose private session metadata and thedownmethod is a deliberate no-op. - Approximate records affected: only artifacts whose workflow has
messaging_callback_context ->> 'adapter' = 'slack'. The Slack@GitLabmention 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). - 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
@GitLabDuonote-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" = 12345Plan: 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- Plan with the new condition (cold cache): https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55086/commands/158511 — 75,190 buffers
- Plan without the new condition (baseline, warm cache): https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55086/commands/158512 — 75,151 buffers, 137 ms
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.