Draft: [skip ci] Add slack_workspace_duo_groups table

What does this MR do and why?

Today a GitLab Duo session started from Slack runs and is billed in whatever group the person who mentioned the bot has as their default Duo namespace, so everyone in the same Slack workspace ends up somewhere different. We are changing that to one Slack workspace, one GitLab Duo group, chosen once by an Owner of a top-level group.

This merge request adds only the table and the model that store that choice. Nothing calls it yet and no behaviour changes. The claim rules, the settings UI and the mention path follow in separate merge requests.

Plan and the full list of merge requests: #633566 (this is step 1 of 8).

Where this fits

This is one of a stack of merge requests. Each one targets the branch of the one before it, so review and merge them in this order. Audit events and the documentation follow as separate merge requests.

  1. !261830 (closed): Table and model (this MR)
  2. !261845 (closed): Services to turn GitLab Duo on or off
  3. !262156 (closed): Endpoint and page data
  4. !262157 (closed): Mention path
  5. !262158 (closed): GitLab Duo section on the integration page
  6. !262159 (closed): Claim on the first group install
  7. !262160 (closed): Admin area picker (self-managed)
  8. !262161 (closed): Slack Marketplace install page
  9. !262165 (closed): Audit events
  10. !262171 (closed): Documentation

Plan and decisions: #633566. Rollout of the feature flag: #633582. Design discussion: gitlab-org#23704.

Context for review agents
  • Goal: every GitLab Duo session started from a Slack workspace runs and bills in one top-level group chosen by an Owner (the administrator on self-managed for an instance-wide install), instead of each user's default Duo namespace. Plan: #633566.
  • Everything is behind the default-off slack_duo_group feature flag (type wip, becomes beta before rollout, rolled out per top-level group). The Slack flow has its own flag slack_duo_api_flow; DuoGroup.available? requires both for the same group.
  • Deliberate decisions, do not flag them unless you find a real defect:
    • One Duo group per workspace (team_id unique), one group can run several workspaces. No organization_id column: namespace_id is the single sharding key. The unique index on team_id holds per cell only, tracked in #633611.
    • A group install claims the workspace, a project install does not. Copies of group or instance installs (inherit_from_id) never let a group claim a workspace. A workspace installed for the whole instance (self-managed only) is the administrator's to assign.
    • An unclaimed workspace has no fallback to the user's default namespace once the flag is on: the user gets a reply that says who can set it up.
    • A Duo group always runs the Slack API flow (slack_assistant/v1), the Developer flow is out. Members of the Duo group only, also on self-managed. Guests pass the membership check and are blocked later, documented as not supported for now.
    • Audit events for enable and disable come in a separate small MR before the flag leaves wip.
  • Test world for manual validation: #633566 (comment 4012898135).

What's in it

  • slack_workspace_duo_groups: team_id (the Slack workspace, unique) and namespace_id (the Duo group).
  • The foreign key to namespaces is a separate migration using add_concurrent_foreign_key, because namespaces is a high-traffic table.
  • Integrations::SlackWorkspace::DuoGroup with its validations: the namespace must be a top-level group, and a Slack workspace can have at most one Duo group. The model has no query methods yet, they arrive with their first caller in the next merge request.
  • A development fixture (ee/db/fixtures/development/99_slack_workspace_duo_groups.rb), which the run-dev-fixtures-ee job requires for every new table so that db:migrate:multi-version-upgrade has a row to exercise.
  • spec/db/schema_spec.rb: team_id is an external Slack ID, not a foreign key, the same exception slack_integrations already has.

Design notes for the reviewer

  • Not a column on integrations. Group and instance integrations are copied down to every project by PropagateIntegrationWorker, so a field there would be duplicated hundreds of times. The whole point of the feature is one place per workspace.
  • Sharding key is namespace_id, and no organization_id column. The guideline says to avoid multiple-column sharding keys (sharding guidelines), and the organization is one join away through namespaces.
  • The sharding key is immutable, so a later "move GitLab Duo to another group" feature deletes the row and creates a new one, it never updates namespace_id. That is written into the model comment.
  • One row per workspace is enforced twice, by a model validation and by a unique index. The unique index on team_id holds within one cell. Global uniqueness across cells needs a claim, tracked in #633611.

References

Screenshots or screen recordings

None: this MR adds a table and a model, and nothing uses them yet.

How to set up and validate locally

  1. Check out the branch and migrate:

    bundle exec rails db:migrate
  2. Check that the migration is reversible, which is what a deployment rollback would do:

    bundle exec rake db:migrate:down VERSION=20261013130627
    bundle exec rake db:migrate:down VERSION=20261013130626
    bundle exec rails db:migrate
  3. In rails console, walk through what the table has to guarantee. Pick any two top-level groups of your GDK, plus a subgroup:

    group = Group.find_by_full_path('gitlab-org')     # or any top-level group
    other = Group.find_by_full_path('flightjs')       # another top-level group
    sub   = group.children.first                      # any subgroup
    
    # A top-level group can claim a Slack workspace
    row = Integrations::SlackWorkspace::DuoGroup.create!(team_id: 'T_LOCAL_1', namespace: group)
    
    # This is the lookup the mention path will do for every @GitLab mention
    Integrations::SlackWorkspace::DuoGroup.find_by(team_id: 'T_LOCAL_1').namespace.full_path # => "gitlab-org"
    Integrations::SlackWorkspace::DuoGroup.find_by(team_id: 'T_UNKNOWN')                     # => nil
    
    # A second group cannot take a workspace that is already claimed
    dup = Integrations::SlackWorkspace::DuoGroup.new(team_id: 'T_LOCAL_1', namespace: other)
    dup.valid?               # => false
    dup.errors.full_messages # => ["Team has already been taken"]
    dup.save!(validate: false) # => ActiveRecord::RecordNotUnique, the unique index also refuses
    
    # A subgroup cannot be a Duo group
    bad = Integrations::SlackWorkspace::DuoGroup.new(team_id: 'T_LOCAL_2', namespace: sub)
    bad.valid?               # => false
    bad.errors.full_messages # => ["Namespace must be a top-level group"]
    
    row.destroy!
  4. Optionally, check that deleting the group takes the claim with it, on a throwaway group:

    g = Group.create!(name: 'Throwaway', path: "throwaway-#{SecureRandom.hex(3)}",
                      organization: Organizations::Organization.first)
    id = Integrations::SlackWorkspace::DuoGroup.create!(team_id: 'T_LOCAL_3', namespace: g).id
    ActiveRecord::Base.connection.execute("DELETE FROM namespaces WHERE id = #{g.id}")
    Integrations::SlackWorkspace::DuoGroup.exists?(id)  # => false
  5. Specs:

    bundle exec rspec ee/spec/models/integrations/slack_workspace/duo_group_spec.rb
    bundle exec rspec spec/db/schema_spec.rb -e "table slack_workspace_duo_groups"

My run of the steps above

1. claim a workspace for a top-level group
   created: team_id=T_DEMO_3C86CC namespace=mr1-demo-3c86cc
2. look it up the way the mention path will
   find_by(team_id: T_DEMO_3C86CC) -> mr1-demo-3c86cc
   find_by(team_id: 'T_UNKNOWN') -> nil
3. a second group cannot claim the same workspace
   valid?=false errors=["Team has already been taken"]
   database also refuses: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_slack_workspace_duo_groups_on_team_id"
4. a subgroup cannot be a Duo group
   valid?=false errors=["Namespace must be a top-level group"]
5. deleting the group removes the claim (foreign key on delete cascade)
   claim row still there? false

Migration up, down and up again, and db:test:prepare from the resulting structure.sql, all ran clean. Model spec 12 examples, 0 failures. RuboCop clean.

Database

Migration output

main: == 20261013130626 CreateSlackWorkspaceDuoGroups: migrating ====================
main: == 20261013130626 CreateSlackWorkspaceDuoGroups: migrated (0.0283s) ===========

main: == 20261013130627 AddSlackWorkspaceDuoGroupsNamespaceForeignKey: migrating ====
main: == 20261013130627 AddSlackWorkspaceDuoGroupsNamespaceForeignKey: migrated (0.0508s)

Rollback of both migrations was verified in the same way.

Expected size

One row per Slack workspace that uses GitLab Duo, so a small table. Reads are single-row lookups by the unique team_id index, one per @GitLab mention.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Thomas Schmidt

Merge request reports

Loading
Loading