Let an Owner choose the Duo group of a Slack workspace
What does this MR do and why?
MR 3 of the split for #633566 (GitLab Duo in Slack: one Duo group per Slack workspace). It adds the rule for who may claim a Slack workspace for a top-level group, and the services that turn GitLab Duo in Slack on and off. It is the first MR that writes rows to slack_workspaces (table from !263649 (merged)).
Integrations::SlackWorkspace::DuoGroupEligibility: a top-level group may claim a workspace when the app is installed somewhere in its hierarchy (Owner decides) or for the instance (administrator decides, self-managed only). Checked with twoEXISTSqueries, so a mention never loads every install of a workspace.EnableDuoGroupServiceandReleaseDuoGroupService. Enable takes over a claim that no longer holds and refuses a valid claim of another group; a lost race for the same group counts as success. Release is the system path for a claim the group can no longer hold.DisableDuoGroupServicecomes with its caller, the group settings page (MR 5).- An instance-installed workspace (self-managed) is always refused with
:managed_by_admin. Until the Admin area (MR 7) lands, no group can claim such a workspace; that is intended, the administrator decides where GitLab Duo runs for it. - New
update_duo_in_slackability for Owners of top-level groups, listed ingroup:archivedso archived groups and groups scheduled for deletion cannot use it. - Audit events
duo_in_slack_enabledandduo_in_slack_disabled. - Installing the app on a top-level group claims the workspace, so an Owner needs no second step. A reinstall does not claim again. The claim never fails the install, because the OAuth code is single-use by then.
- Two partial indexes on
slack_integrations, so the eligibility check stays cheap for a hierarchy without an install of the workspace (see Database). - Feature flag
slack_duo_group(wip, default off). Actor: the top-level group, so it can be turned on for one group first.
Everything is inert while the flag is off. The mention path (MR 4), the group page (MR 5/6), the Admin area (MR 7) and the install page (MR 8) are the callers; they follow once this is in.
Most of the code comes from the reference implementation !262856 (closed). Changes against it:
- Flag actor is the top-level group, not
:instance. - The
~is_root_namespaceprevent is folded into the existing block inEE::GroupPolicy; scheduled-for-deletion is covered by thegroup:archivedpermission group. - Only code with a caller in this MR is included. The reference implementation's helpers for the pages and the mention path (
root_namespace_ids_by_team,team_names,team_ids_with_scope,with_duo_namespace_details,released_by_removing?, the preloaded arguments ofDuoGroupEligibility) come with MR 4/5/7, so nokeela_excluded.ymlentries are needed here. - Enable rescues
ActiveRecord::RecordInvalidas well asRecordNotUnique: when another group claims the workspace between the lookup and the insert, theteam_iduniqueness validation raises first; the index only raises when both inserts interleave.
References
- Implementation issue: #633566
- Plan handover and what changed in review of MR 1: #633566 (comment 4088584201)
- MR 1, the table (merged): !263649 (merged)
- MR 2, building blocks (merged): !263653 (merged)
- Reference implementation: !262856 (closed)
Screenshots or screen recordings
No UI in this MR.
How to set up and validate locally
The services have no UI yet. Use the Rails console.
- Enable the flag for a top-level group you own:
group = Group.find_by_full_path('your-top-level-group') Feature.enable(:slack_duo_group, group) - Give the group a fake Slack install (or install the real app on the group, which claims the workspace on its own):
integration = Integrations::GitlabSlackApplication.create!(group: group, active: true) SlackIntegration.create!(integration: integration, group: group, team_id: 'T_LOCAL', team_name: 'Local', alias: group.full_path, user_id: 'U1', bot_user_id: 'UBOT', bot_access_token: 'xoxb-local') - Turn GitLab Duo on, then release the claim the way the system does once the group can no longer hold it:
owner = group.owners.first Integrations::SlackWorkspace::EnableDuoGroupService.new(group, current_user: owner, team_id: 'T_LOCAL').execute workspace = Integrations::SlackWorkspace::Workspace.for_team('T_LOCAL') workspace.duo_namespace == group # => true group.namespace_settings.update!(archived: true) Integrations::SlackWorkspace::ReleaseDuoGroupService.for(workspace).execute # => released: true - Check the audit events for the group under Secure > Audit events:
duo_in_slack_enabledandduo_in_slack_disabledby(System). - Try it as a Maintainer (
:forbidden), on a subgroup (:not_top_level_group), or with a second top-level group that has the sameteam_idinstalled (:claimed_by_another_group).
For every state at once, the seed script in #633566 (comment 4012898135) builds a test world.
Database
Below are the migration and all statements this MR adds, captured from Rails and explained on postgres.ai (gitlab-production-main) for the gitlab-org hierarchy (namespace_id = 9970, about 1,640 groups and 9,280 projects). The Slack workspace is written as <team_id> here. The plans for 1 and 2 use the literal value, as Rails sends it. The others pick it with a scalar subquery (InitPlan 1); they are single-row lookups or writes, so their plans do not depend on how many installs a workspace has.
slack_workspaces is empty in production and did not exist on the clone snapshot yet, so for 4–9 the table and its indexes were created from db/structure.sql and seeded with 5,661 rows (one per top-level group) before explaining. The clone was reset afterwards.
Migration
20261101192034_add_team_indexes_with_bot_to_slack_integrations.rb adds two partial indexes for 1 and 2 (requested in !265962 (comment 4123959142)):
CREATE INDEX CONCURRENTLY "index_slack_integrations_on_team_id_and_group_id_with_bot" ON "slack_integrations" ("team_id", "group_id") WHERE bot_user_id IS NOT NULL;
CREATE INDEX CONCURRENTLY "index_slack_integrations_on_team_id_and_project_id_with_bot" ON "slack_integrations" ("team_id", "project_id") WHERE bot_user_id IS NOT NULL;db:gitlabcom-database-testing: 9.1 s, +22.08 MiB (!265962 (comment 4136245735)).slack_integrationshas about 513,000 rows and is not on the large tables list.- Rollback:
downremoves both indexes. Tested locally withdb:migrate:up:mainanddb:migrate:down:main.
1. DuoGroupEligibility#installed_in_hierarchy?, installs on groups
SlackIntegration.with_bot.by_team(team_id).for_groups_in(group).exists?
SELECT 1 AS one FROM "slack_integrations"
WHERE "slack_integrations"."bot_user_id" IS NOT NULL
AND "slack_integrations"."team_id" = '<team_id>'
AND "slack_integrations"."group_id" IN (
SELECT "namespaces"."id" FROM UNNEST(COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = <group_id>) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("namespaces"."id") AS ids FROM (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{<group_id>}'))) namespaces) consistent_query)
)) AS namespaces(id))
LIMIT 1Plans with index_slack_integrations_on_team_id_and_group_id_with_bot:
- Workspace installed in the hierarchy: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/77002/commands/184723 (execution: 7.8 ms, 153 buffers, 1 row)
- Largest workspace on GitLab.com (14,799 installs), none in the hierarchy: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/77002/commands/184724 (execution: 4.9 ms, 4,976 buffers, 0 rows)
The planner estimates the UNNEST at 10 rows and probes the index once per group of the hierarchy, 1,646 times when nothing matches. Every probe is index-only (Heap Fetches: 0). Without the new index the same probes go through index_slack_integrations_on_group_id and read the table row to check team_id and bot_user_id: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/75941/commands/182143 (32 ms, 152 buffers) and https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/75941/commands/182144 (160 ms, 5,003 buffers, 222 of them read).
2. DuoGroupEligibility#installed_in_hierarchy?, installs on projects
SlackIntegration.with_bot.by_team(team_id).for_projects_in(group).exists?
SELECT 1 AS one FROM "slack_integrations"
WHERE "slack_integrations"."bot_user_id" IS NOT NULL
AND "slack_integrations"."team_id" = '<team_id>'
AND "slack_integrations"."project_id" IN (
SELECT "projects"."id" FROM UNNEST(COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."all_project_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = <group_id>) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("projects"."id") AS ids FROM (SELECT "projects"."id" FROM "projects" WHERE "projects"."namespace_id" IN (
SELECT "namespaces"."id" FROM UNNEST(COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = <group_id>) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("namespaces"."id") AS ids FROM (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{<group_id>}'))) namespaces) consistent_query)
)) AS namespaces(id))) projects) consistent_query)
)) AS projects(id))
LIMIT 1Plans with index_slack_integrations_on_team_id_and_project_id_with_bot:
- Workspace installed in the hierarchy: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/77002/commands/184725 (execution: 10 ms, 93 buffers, 1 row)
- Largest workspace on GitLab.com, none in the hierarchy: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/77002/commands/184726 (execution: 23 ms, 27,899 buffers, 0 rows)
Same shape as 1 with the all_project_ids array: 9,285 index-only probes when nothing matches. Without the new index they go through index_slack_integrations_on_project_id and read a table row each: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/75941/commands/182145 (27 ms, 94 buffers) and https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/75941/commands/182146 (1,154 ms, 28,329 buffers, 1,619 of them read). The statement on the clone is this query with ActiveRecord's identifier quoting and aliases shortened; the CLI rejects arguments over about 1 KB.
3. DuoGroupEligibility.instance_install?
SlackIntegration.with_bot.by_team(team_id).instance_level_in(organization).exists?
SELECT 1 AS one FROM "slack_integrations"
WHERE "slack_integrations"."bot_user_id" IS NOT NULL
AND "slack_integrations"."team_id" = '<team_id>'
AND "slack_integrations"."group_id" IS NULL
AND "slack_integrations"."project_id" IS NULL
AND "slack_integrations"."organization_id" = <organization_id>
LIMIT 1Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/74330/commands/178268 (execution: 0.08 ms, 6 buffers, 0 rows)
GitLab.com has no instance-level installs and DuoGroupEligibility.instance_installs? is false there, so this runs on self-managed only. index_slack_integrations_on_organization_id narrows it to the organization's instance-level rows.
4. Workspace.for_team (Enable)
SELECT "slack_workspaces".* FROM "slack_workspaces" WHERE "slack_workspaces"."team_id" = '<team_id>' LIMIT 1Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/74330/commands/178285 (execution: 0.07 ms, 3 buffers, 1 row), on the unique index_slack_workspaces_on_team_id.
5. team_id uniqueness validation before the insert (Enable)
SELECT 1 AS one FROM "slack_workspaces" WHERE "slack_workspaces"."team_id" = '<team_id>' LIMIT 1Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/74330/commands/178286 (execution: 0.05 ms, 2 buffers), index-only scan on the same index.
6. Workspace.create! (Enable)
INSERT INTO "slack_workspaces" ("duo_namespace_id", "created_at", "updated_at", "team_id") VALUES (<group_id>, '<now>', '<now>', '<team_id>') RETURNING "id"Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/74330/commands/178287 (execution: 3.3 ms, 104 buffers). One row, two indexes and the foreign key on duo_namespace_id to maintain.
7. Audit target_details (all three services)
SlackIntegration.by_team(team_id).pick(:team_name)
SELECT "slack_integrations"."team_name" FROM "slack_integrations" WHERE "slack_integrations"."team_id" = '<team_id>' LIMIT 1Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/74330/commands/178288 (execution: 0.3 ms, 49 buffers, 1 row), on index_slack_integrations_on_team_id_alias_project_id.
8. ReleaseDuoGroupService (Workspace.with_duo_namespace(group).for_team(team_id), then destroy!)
SELECT "slack_workspaces".* FROM "slack_workspaces" WHERE "slack_workspaces"."duo_namespace_id" = <group_id> AND "slack_workspaces"."team_id" = '<team_id>' LIMIT 1Then the same DELETE … WHERE "id" = <id> as 9. It was a delete_all before; the row is destroyed through the model so that the Cells claim on team_id (#633611) is released by its callback. At most one row, because team_id is unique.
Plan for the lookup: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/74330/commands/178289 shows the same index scan on the unique team_id index that the former DELETE used (0.14 ms, 5 buffers); the SELECT reads the same row.
9. ReleaseDuoGroupService (workspace.destroy!)
DELETE FROM "slack_workspaces" WHERE "slack_workspaces"."id" = <id>Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/74330/commands/178290 (execution: 0.18 ms, 8 buffers, 1 row deleted), by primary key.
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.