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.
- !261830 (closed): Table and model (this MR)
- !261845 (closed): Services to turn GitLab Duo on or off
- !262156 (closed): Endpoint and page data
- !262157 (closed): Mention path
- !262158 (closed): GitLab Duo section on the integration page
- !262159 (closed): Claim on the first group install
- !262160 (closed): Admin area picker (self-managed)
- !262161 (closed): Slack Marketplace install page
- !262165 (closed): Audit events
- !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_groupfeature flag (typewip, becomesbetabefore rollout, rolled out per top-level group). The Slack flow has its own flagslack_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_idunique), one group can run several workspaces. Noorganization_idcolumn:namespace_idis the single sharding key. The unique index onteam_idholds 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.
- One Duo group per workspace (
- Test world for manual validation: #633566 (comment 4012898135).
What's in it
slack_workspace_duo_groups:team_id(the Slack workspace, unique) andnamespace_id(the Duo group).- The foreign key to
namespacesis a separate migration usingadd_concurrent_foreign_key, becausenamespacesis a high-traffic table. Integrations::SlackWorkspace::DuoGroupwith 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 therun-dev-fixtures-eejob requires for every new table so thatdb:migrate:multi-version-upgradehas a row to exercise. spec/db/schema_spec.rb:team_idis an external Slack ID, not a foreign key, the same exceptionslack_integrationsalready has.
Design notes for the reviewer
- Not a column on
integrations. Group and instance integrations are copied down to every project byPropagateIntegrationWorker, 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 noorganization_idcolumn. The guideline says to avoid multiple-column sharding keys (sharding guidelines), and the organization is one join away throughnamespaces. - 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_idholds within one cell. Global uniqueness across cells needs a claim, tracked in #633611.
References
- Implementation issue: #633566
- Design discussion and the proof of concept video: gitlab-org#23704 (comment 4003608381)
- Proof of concept, not for merge: !261784 (closed)
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
-
Check out the branch and migrate:
bundle exec rails db:migrate -
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 -
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! -
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 -
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? falseMigration 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.