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_ids list. It is built from the user's authorized groups, which include project authorizations and minimal-access groups. It is cached for 10 minutes and is nil off GitLab.com. Inherited membership of an invited group counts because project authorizations include it. A share_with_group_lock on 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_credits add-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_ids can 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}"
end

Use 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_all

Expected 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 1

postgres.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=8

duo_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 = TRUE
Nested 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=4

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 Vitaly Slobodin

Merge request reports

Loading
Loading