Resolve the governing namespace to the project root for indirect members with Duo access
What does this MR do and why?
Duo usage inside a project is billed to the project's top-level group. Today that only works when the user has a group membership somewhere in that top-level group's tree. Users who reach the project another way fall through:
- a direct member of the project only
- a member of a group that was invited to the project or to the top-level group
For them, User#governing_namespace returns the user's default Duo namespace instead. Users with several Duo namespaces and no default get nil. Rails then sends no root namespace to the AI gateway, and CustomersDot receives usage events it cannot bill.
The new rule
governing_namespace returns the project's top-level group when the user is an indirect member of it and has Duo access in it: a Duo seat, Duo Core, or a purchased GitLab Credits pack. Otherwise it keeps the default Duo namespace. Direct and inherited group members are unchanged. A user banned from the top-level group is never attributed to it.
| User | master |
This MR, no Duo access in the root | This MR, Duo access in the root |
|---|---|---|---|
| direct or inherited group member | top-level group | top-level group | top-level group |
| direct project member | default | default | top-level group |
| member of an invited group | default | default | top-level group |
| awaiting or minimal-access member | default | default | top-level group |
| banned from the top-level group | top-level group | default | default |
One namespace for billing and for access rules
The governing namespace decides more than billing. It also selects the Duo access rules and the settings for foundational agents, identity verification, model selection, and usage quota. This MR keeps that as one lookup, as on master. The namespace that pays for the usage also supplies the rules.
A consequence for admins: group access rules match direct members of the configured group only. An indirect member with Duo access in the top-level group passes through the All eligible users rule, which the UI adds with the first group rule. A top-level group that removed that rule will deny its indirect seat holders once the flag is on. The rollout issue has a check for this.
Rollout
The change is behind duo_usage_attribution_for_indirect_members, a gitlab_com_derisk flag, off by default, enabled per top-level group. With the flag off, nothing changes. Rollout issue: #628449.
Implementation notes
- Indirect membership comes from the cached
gitlab_com_root_group_idslist. It is built from the user's authorized groups, which include project authorizations and minimal-access groups. It is cached for 10 minutes and isniloff GitLab.com. Inherited membership of an invited group counts because project authorizations include it. Ashare_with_group_lockon the project namespace is honored the same way. - Duo access is checked in cost order: the seat list first, which is cached for an hour; then Duo Core, which also requires an active Duo Core purchase in the same top-level group; then GitLab Credits purchases. The last two run a query each.
- Minimal access counts on GitLab.com only when the top-level group's plan includes
minimal_access_role. - The result is memoized per user and top-level group with
strong_memoize_with. - Specs pin every row in the table above, the access-rule denial, and the All eligible users pass.
Known gaps
- An indirect member with several Duo namespaces, no default, and no Duo access in the top-level group still resolves to
nil. - Included Premium and Ultimate credits are CustomersDot wallets, not
gitlab_creditsadd-on purchases. Only purchased credit packs count. - No access rule can target the contributors of a single project. Group rules match direct members only.
gitlab_com_root_group_idscan be up to 10 minutes stale.- The seat list is cached for one hour and no code clears it when a seat changes. Attribution can lag a seat assignment or removal by up to one hour. Follow-up: #631210.
References
https://gitlab.com/gitlab-org/gitlab/-/work_items/627731+
How to set up and validate locally
Run the script in a Rails console, once on master and once on this branch. The expected output for each numbered case is inside the script section.
Script
Setup
Feature.enable(:duo_usage_attribution_for_indirect_members) # this branch only
org = Organizations::Organization.default_organization
suffix = SecureRandom.hex(3)
tester = User.new(username: "governing-tester-#{suffix}", email: "governing-tester-#{suffix}@example.com", name: 'Governing Tester', password: 'a-strong-password-123', password_confirmation: 'a-strong-password-123', organization: org)
tester.assign_personal_namespace(org)
tester.skip_confirmation!
tester.save!
top_level_group = Group.create!(name: 'duo-top-level', path: "duo-top-level-#{suffix}", organization: org)
subgroup_with_project = Group.create!(name: 'with-project', path: 'with-project', parent: top_level_group, organization: org)
subgroup_without_projects = Group.create!(name: 'without-projects', path: 'without-projects', parent: top_level_group, organization: org)
project = Project.create!(name: 'proj', path: 'proj', namespace: subgroup_with_project, organization: org, creator: tester)
default_duo_group = Group.create!(name: 'duo-default', path: "duo-default-#{suffix}", organization: org)
default_duo_group.add_owner(tester)
tester.user_preference.update!(duo_default_namespace_id: default_duo_group.id)
invited_group = Group.create!(name: 'invited', path: "invited-#{suffix}", organization: org)
invited_group.add_guest(tester)
parent_group = Group.create!(name: 'parent', path: "parent-#{suffix}", organization: org)
parent_group.add_guest(tester)
child_of_parent = Group.create!(name: 'child', path: 'child', parent: parent_group, organization: org)
add_on = GitlabSubscriptions::AddOn.find_or_create_by!(name: 'code_suggestions') { |a| a.description = 'Duo Pro' }
seat = nil
give_seat = -> do
purchase = GitlabSubscriptions::AddOnPurchase.create!(add_on: add_on, namespace: top_level_group, organization: org, quantity: 1, started_at: 1.day.ago, expires_on: 1.year.from_now, purchase_xid: SecureRandom.hex(8))
seat = GitlabSubscriptions::UserAddOnAssignment.create!(user: tester, add_on_purchase: purchase)
Rails.cache.delete(tester.duo_addons_cache_key_formatted)
end
take_seat = -> do
seat&.add_on_purchase&.destroy!
seat = nil
Rails.cache.delete(tester.duo_addons_cache_key_formatted)
end
result = ->(scope) { tester = User.find(tester.id); tester.governing_namespace(scope) == top_level_group ? 'top-level' : 'default' }
row = ->(label, scope, &setup) do
setup.call
without = result.(scope)
give_seat.call
with = result.(scope)
take_seat.call
puts "#{label.ljust(52)} | no seat: #{without.ljust(9)} | seat: #{with}"
endUse cases
row.('0 no access', project) {}
row.('1 direct member of the top-level group', project) { top_level_group.add_developer(tester) }
top_level_group.members.delete_all
row.('2 member of a subgroup without projects', subgroup_without_projects) { subgroup_without_projects.add_developer(tester) }
subgroup_without_projects.members.delete_all
row.('3 direct project member', project) { project.add_developer(tester) }
project.members.delete_all
row.('4 member of a group invited to the top-level group', project) { GroupGroupLink.create!(shared_group: top_level_group, shared_with_group: invited_group, group_access: Gitlab::Access::GUEST) }
GroupGroupLink.where(shared_group: top_level_group).delete_all
row.('5 member of a group invited to the project', project) { ProjectGroupLink.create!(project: project, group: invited_group, group_access: Gitlab::Access::GUEST) }
ProjectGroupLink.where(project: project).delete_all
row.('6 member of a parent of a group invited to the project', project) { ProjectGroupLink.create!(project: project, group: child_of_parent, group_access: Gitlab::Access::GUEST) }
ProjectGroupLink.where(project: project).delete_all
row.('7 banned member of the top-level group', project) { top_level_group.add_developer(tester); Namespaces::NamespaceBan.create!(user: tester, namespace: top_level_group) }
Namespaces::NamespaceBan.where(user: tester).delete_all
top_level_group.members.delete_all
row.('8 awaiting member of the top-level group', project) { m = top_level_group.add_developer(tester); m.update!(state: ::Member::STATE_AWAITING) }
top_level_group.members.delete_allExpected output
"top-level" means the top-level group. "default" means the default Duo namespace. On master, the seat column equals the no-seat column.
| # | Use case | master | this branch, no seat | this branch, seat |
|---|---|---|---|---|
| 0 | no access | default | default | default |
| 1 | direct member of the top-level group | top-level | top-level | top-level |
| 2 | member of a subgroup without projects | top-level | top-level | top-level |
| 3 | direct project member | default | default | top-level |
| 4 | member of a group invited to the top-level group | default | default | top-level |
| 5 | member of a group invited to the project | default | default | top-level |
| 6 | member of a parent of a group invited to the project | default | default | top-level |
| 7 | banned member of the top-level group | top-level | default | default |
| 8 | awaiting member of the top-level group | default | default | top-level |
Cleanup
[invited_group, parent_group, default_duo_group, top_level_group].each { |group| Groups::DestroyService.new(group, tester).execute }
tester.destroy!Query plans
Plans for the two Duo Core queries, measured on postgres.ai
The custom EXISTS queries (member_of_group_under?, member_of_project_under?, member_of_shared_project_under?) are removed after review. The code now reuses the cached gitlab_com_root_group_ids list instead. The list comes from authorized_groups.top_level, which resolves project authorizations to their ancestors. An earlier variant, authorized_groups.by_root_id(...).exists?, took 44 s on GitLab.com. This MR did not measure the cold cost of the cached list. The list already runs on hot Duo paths in production.
duo_core_access_through? runs two queries when the seat list has no match for the root. duo_core_ids_via_namespace_settings joins namespaces and namespace_settings for the cached gitlab_com_root_group_ids list. master already runs this join in check_duo_core_features and in the code suggestions saas_headers. AddOnPurchase.for_duo_core.active.by_namespace(root).exists? is new. It is a point lookup on index_subscription_add_on_purchases_on_namespace_id_add_on_id. Both run at most once for each user and root in a request, because authorized_root_namespace? is memoized.
SELECT 1
FROM subscription_add_on_purchases
WHERE subscription_add_on_purchases.subscription_add_on_uid = 5
AND (started_at IS NULL OR started_at <= '2026-10-01')
AND ('2026-10-01' < expires_on)
AND subscription_add_on_purchases.namespace_id = 9970
LIMIT 1postgres.ai, warm run. The cold run read 5 of the 8 buffers from disk and took 8 ms.
Limit (cost=0.43..3.45 rows=1 width=4) (actual time=0.030..0.031 rows=1 loops=1)
Buffers: shared hit=8
-> Index Scan using index_subscription_add_on_purchases_on_namespace_id_add_on_id on public.subscription_add_on_purchases (cost=0.43..3.45 rows=1 width=4) (actual time=0.029..0.030 rows=1 loops=1)
Index Cond: (subscription_add_on_purchases.namespace_id = 9970)
Filter: (((subscription_add_on_purchases.started_at IS NULL) OR (subscription_add_on_purchases.started_at <= '2026-10-01'::date)) AND ('2026-10-01'::date < subscription_add_on_purchases.expires_on) AND (subscription_add_on_purchases.subscription_add_on_uid = 5))
Rows Removed by Filter: 1
Buffers: shared hit=8duo_core_ids_via_namespace_settings, with two root group IDs. Each ID costs one lookup on namespaces_pkey and one on namespace_settings_pkey. The cost grows with the length of the user's gitlab_com_root_group_ids list. The cold run read 7 of the 19 buffers from disk and took 8.6 ms.
SELECT namespaces.id
FROM namespaces
INNER JOIN namespace_settings ON namespace_settings.namespace_id = namespaces.id
WHERE namespaces.id IN (9970, 6543)
AND namespace_settings.duo_nano_features_enabled = TRUENested Loop (cost=1.13..12.84 rows=1 width=4) (actual time=4.048..8.647 rows=2 loops=1)
Buffers: shared hit=12 read=7
-> Index Only Scan using namespaces_pkey on public.namespaces (cost=0.57..5.67 rows=2 width=4) (actual time=1.310..1.951 rows=2 loops=1)
Index Cond: (namespaces.id = ANY ('{9970,6543}'::integer[]))
Heap Fetches: 0
Buffers: shared hit=6 read=3
-> Index Scan using namespace_settings_pkey on public.namespace_settings (cost=0.56..3.58 rows=1 width=4) (actual time=3.342..3.342 rows=1 loops=2)
Index Cond: (namespace_settings.namespace_id = namespaces.id)
Filter: namespace_settings.duo_nano_features_enabled
Buffers: shared hit=6 read=4MR 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.