Allow subgroups in Duo admin group membership access rules
What does this MR do and why?
Admin > GitLab Duo > Configuration lets an admin restrict access based on group membership. The group picker sent topLevelOnly: true, so only top-level groups could be selected. Enforcement in accessible_for_user already accepted a rule on any group and joined direct memberships only. The admin page loaded rules through a root-groups-only scope, so a subgroup rule saved through the REST API, or a top-level group later transferred under another group, was enforced but invisible in the list. The next save from the UI then deleted that invisible rule, because the UI resubmits only what it shows.
- Behind feature flag
duo_admin_access_rules_subgroups(type beta, default off, instance actor, pushed fromAdmin::GitlabDuo::ConfigurationController): the picker sendstopLevelOnly: nullso groups at every level are searchable. This is frontend only. No backend change is gated, because the API already accepted subgroups. - Ungated:
Admin::AiConfigurationPresenternow uses the all-rules scopeduo_namespace_access_rules. The root-only scopeduo_root_namespace_access_rulesis removed. Rules whose namespace row is already gone but whose loose foreign key cleanup has not run yet are filtered out, so they do not render as the default "All eligible users" row. - Ungated: new validation
through_namespace_is_grouprejects a rule whose namespace is a user or project namespace, or does not exist. The API returns 400 with "Through namespace must be a group". - Ungated: a namespace and feature listed twice in one save is rejected before insert with "Accessible entity is listed more than once for the same namespace". Previously this hit the unique index and returned 500.
- Ungated: the save path deletes all rules then bulk inserts. It is now wrapped in a transaction, so a rejected save keeps the existing rules. Previously a validation error left the table empty and the feature failed open.
- Ungated, cosmetic: the selected-group toggle in the shared picker shows "name (full/path)", the format the dropdown rows already used. The picker is used by the admin page and the GitLab.com group settings page, so both get it. Not tied to the feature flag on purpose: it is a consistency fix, and a flag whose off state hides a format the rows already show would guard nothing.
- Ungated: the namespaces a save refers to are loaded once for the whole batch before validation, so the group check runs no query per row.
Deviation from the issue
The issue's Changes section said the rules list already shows the full path. It does not. Rows show the group name and link to the full path. This MR leaves the list as is. The acceptance criterion "show in the admin rules list with full path" is not met as written and needs a follow-up or a wording change on the issue.
The issue listed one spec case: subgroup id accepted, non-group namespace id rejected. This MR also adds the duplicate-namespace rejection, because live testing found the 500 described in change 4 above.
Review notes
Approvals
- Backend maintainer and frontend maintainer, since Ruby and Vue both change. CODEOWNERS matches only the general Maintainers section for every touched file, so no database, docs, or other specialised approval is pulled in.
- The toggle text change is user-visible. A UX look is at the reviewer's discretion.
Database
- No migrations, no schema change. Every statement this MR touches, with its postgres.ai plan, is in the "Database review" section below.
- The only query change is removing a
parent_id IS NULLjoin filter from the admin listing scope on atable_size: smalltable.
Danger
- Will comment on the new
validateline and the newreject_duplicate!method inee/app/models. - Both fire only on the delete-and-reinsert write path, which is the only writer of this model, so existing rows are never re-validated.
Deliberate non-changes
- Membership semantics stay direct members only. Descendant coverage is a separate follow-up decided in the planning issue thread on 2026-09-11.
- The "Create group" link still creates a top-level group.
- The GitLab.com group-level setting is out of scope. Its service
EE::Groups::UpdateServicelikely has the same duplicate 500, and there is no issue for it yet. - The docs page
doc/administration/gitlab_duo/configure/access_control.mdstill says only top-level groups can be selected. The docs child issue under the epic owns that update while the feature flag defaults off. - Members with a pending access request or an expired membership pass the gate. This is a known pre-existing gap, tracked separately, and is not addressed here.
Follow-ups
- Switch the feature flag YAML
introduced_by_urlfrom the planning issue to this MR's URL after creation. - Check
EE::Groups::UpdateServicefor the same duplicate-namespace 500. No issue yet.
Specs run locally
- ee/spec/models/ai/feature_access_rule_spec.rb
- ee/spec/models/concerns/ai/user_authorizable_spec.rb, subgroup contexts for
duo_classicandduo_agent_platform - ee/spec/presenters/admin/ai_configuration_presenter_spec.rb
- ee/spec/requests/admin/gitlab_duo/configuration_controller_spec.rb, needs
ENABLE_RSPACK=truelocally - ee/spec/requests/api/settings_spec.rb, context
duo_namespace_access_rules - ee/spec/services/application_settings/update_service_spec.rb, context when updating duo namespace access rules
- ee/spec/frontend/ai/settings/components/group_selector_spec.js
- Also verified live in GDK through the REST API and a headless-browser run of the admin page for both feature flag states.
Database review
ai_instance_accessible_entity_rules is empty on GitLab.com, since the instance-level admin page these rules come from is self-managed only. The postgres.ai plans linked under each statement were taken on a clone seeded with four rows via exec, matching the size the table stays at in production: at most two rows per group an admin has added a rule for. The inline EXPLAIN output is from a local GDK with the same shape of data.
SQL and postgres.ai plans for every statement, and the picker search for context
Clone seed used before the plans, on gitlab-production-main:
exec INSERT INTO ai_instance_accessible_entity_rules (through_namespace_id, accessible_entity, created_at, updated_at) VALUES (9970, 'duo_classic', NOW(), NOW()), (9970, 'duo_agent_platform', NOW(), NOW()), (6543, 'duo_classic', NOW(), NOW()), (NULL, 'duo_classic', NOW(), NOW())Read: admin page, presenter, REST GET (Ai::FeatureAccessRule.duo_namespace_access_rules, replaces the root-only scope)
SELECT "ai_instance_accessible_entity_rules".*
FROM "ai_instance_accessible_entity_rules"
ORDER BY through_namespace_id NULLS FIRST, "ai_instance_accessible_entity_rules"."accessible_entity" ASCSort (cost=5.73..6.00 rows=108 width=47) (actual time=0.023..0.023 rows=2 loops=1)
Sort Key: through_namespace_id NULLS FIRST, accessible_entity
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=7
-> Seq Scan on ai_instance_accessible_entity_rules (cost=0.00..2.08 rows=108 width=47) (actual time=0.014..0.014 rows=2 loops=1)
Buffers: shared hit=1
Planning Time: 0.263 ms
Execution Time: 0.026 msPlan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161705
Read: namespace preload from includes(:through_namespace) (one query, primary key lookup)
SELECT "namespaces".* FROM "namespaces" WHERE "namespaces"."id" IN (<through_namespace_ids>)Index Scan using namespaces_pkey on namespaces (cost=0.14..2.16 rows=1 width=372) (actual time=0.012..0.012 rows=1 loops=1)
Index Cond: (id = 140)
Buffers: shared hit=5
Planning Time: 1.302 ms
Execution Time: 0.019 msPlan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161707
Write: duo_namespace_access_rules=, unchanged statements, now inside one transaction
DELETE FROM "ai_instance_accessible_entity_rules"Delete on ai_instance_accessible_entity_rules (cost=0.00..2.08 rows=0 width=0) (actual time=0.027..0.028 rows=0 loops=1)
Buffers: shared hit=3 dirtied=1
-> Seq Scan on ai_instance_accessible_entity_rules (cost=0.00..2.08 rows=108 width=6) (actual time=0.004..0.004 rows=2 loops=1)
Buffers: shared hit=1
Planning Time: 0.016 ms
Execution Time: 0.125 msPlan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161710
INSERT INTO "ai_instance_accessible_entity_rules" ("through_namespace_id","accessible_entity","created_at","updated_at")
VALUES (6543, 'duo_agent_platform', NOW(), NOW()), (15846663, 'duo_classic', NOW(), NOW())
RETURNING "id"Insert on ai_instance_accessible_entity_rules (cost=0.00..0.03 rows=2 width=64) (actual time=0.144..0.149 rows=2 loops=1)
Buffers: shared hit=31
-> Values Scan on "*VALUES*" (cost=0.00..0.03 rows=2 width=64) (actual time=0.037..0.039 rows=2 loops=1)
Buffers: shared hit=15
Planning Time: 0.015 ms
Execution Time: 0.155 msPlan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161711
Write: per-row validation queries during bulk_insert!, one pair per rule row, bounded by two rows per group
The uniqueness check on the shared concern is unchanged and already ran per row:
SELECT 1 AS one FROM "ai_instance_accessible_entity_rules"
WHERE "ai_instance_accessible_entity_rules"."accessible_entity" = 'duo_classic'
AND "ai_instance_accessible_entity_rules"."through_namespace_id" = 9970 LIMIT 1Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161709 (index-only scan on the unique index)
New in this MR: the namespaces for the batch are loaded once before validation, the same WHERE "namespaces"."id" IN (...) query as the preload above, so the group check runs no query per row.
Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161707
Removed query (old duo_root_namespace_access_rules, the "before" of the listing change, no longer executed anywhere)
SELECT "ai_instance_accessible_entity_rules"."id"
FROM "ai_instance_accessible_entity_rules"
INNER JOIN "namespaces" ON "namespaces"."id" = "ai_instance_accessible_entity_rules"."through_namespace_id"
WHERE "namespaces"."parent_id" IS NULL AND "namespaces"."type" = 'Group'Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161706
Context, no code change in this MR: the picker's group search. Behind the feature flag the frontend sends topLevelOnly: null, so the existing GroupsFinder search runs without its parent_id IS NULL filter. Same query the groups GraphQL field runs everywhere else; this MR only drops one filter. Plans below use the deliberately broad term gitlab on a cold clone, so the I/O read time dominates.
-- feature flag on: every level
SELECT "namespaces"."id" FROM "namespaces"
WHERE "namespaces"."type" = 'Group'
AND "namespaces"."id" IN (SELECT "routes"."source_id" FROM "routes" WHERE "routes"."source_type" = 'Namespace' AND ("routes"."path" ILIKE '%gitlab%' OR "routes"."name" ILIKE '%gitlab%'))
ORDER BY "namespaces"."name" ASC LIMIT 20Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161712 (9.8 s, 9.7 s of it I/O read; 20 rows)
-- feature flag off, today's behaviour: top-level only
SELECT "namespaces"."id" FROM "namespaces"
WHERE "namespaces"."type" = 'Group' AND "namespaces"."parent_id" IS NULL
AND "namespaces"."id" IN (SELECT "routes"."source_id" FROM "routes" WHERE "routes"."source_type" = 'Namespace' AND ("routes"."path" ILIKE '%gitlab%' OR "routes"."name" ILIKE '%gitlab%'))
ORDER BY "namespaces"."name" ASC LIMIT 20Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57176/commands/161713 (63 s, 62 s of it I/O read; 20 rows)
Dropping the filter makes the search cheaper, not dearer: the limit is met sooner when subgroups count. Both numbers are cold-clone reads and belong to the existing finder, not to this MR.
Table facts from db/docs/ai_instance_accessible_entity_rules.yml: gitlab_schema: gitlab_main_cell_setting, table_size: small. Rows are bounded by two per group an admin adds a rule for. The unique index on (through_namespace_id, accessible_entity) is the one the new duplicate check pre-empts.
References
Resolves https://gitlab.com/gitlab-org/gitlab/-/issues/628450
Epic https://gitlab.com/groups/gitlab-org/-/epics/23483 Planning issue https://gitlab.com/gitlab-org/gitlab/-/issues/628158 Rollout issue https://gitlab.com/gitlab-org/gitlab/-/issues/628453
How to set up and validate locally
- Enable the feature flag in a rails console.
Feature.enable(:duo_admin_access_rules_subgroups)- Visit
/admin/gitlab_duo/configuration, click "Add group", search for a subgroup by name, and select it. The toggle shows "name (full/path)" and "Add" saves a row. - Disable the feature flag and reload. The saved subgroup row is still listed, and the picker offers top-level groups only.
- Check the new validation in a rails console. Expected output: "Validation failed: Through namespace must be a group".
Ai::FeatureAccessRule.duo_namespace_access_rules = [{ through_namespace: { id: User.first.namespace_id }, features: %w[duo_classic] }]- Run the specs.
bin/rspec ee/spec/models/ai/feature_access_rule_spec.rb ee/spec/requests/api/settings_spec.rb -e 'duo_namespace_access_rules'Screenshots or screen recordings
All taken on this branch in a local GDK with test data (groups acme, acme/ai-pilot, zeta).
| Before (feature flag off, today's behaviour) | After (feature flag on) |
|---|---|
|
toggle showed the group name only |
|
|
subgroup rules absent from the rules table |
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.



