Fix Code Suggestions ignoring group Duo availability

What does this MR do and why?

On GitLab.com, a group Owner can turn GitLab Duo availability off for their group. Today this does not stop Code Suggestions. Members of the group keep getting completions, and the usage is still billed to that namespace. A customer reported unexpected credit usage caused by this.

Why it happens

Nothing in the Code Suggestions permission path reads duo_features_enabled:

  • :access_code_suggestions is a global ability. Its only availability check is ai_features_banned, which reads the instance-level duo_never_on?. On GitLab.com that value is always false.
  • code_suggestions_enabled_for_user calls allowed_to_use?(:code_suggestions). This method looks at add-on seats, Duo Core, credits and free access, but it never reads duo_features_enabled on any branch.
  • Duo Chat enforces the setting in the subject-scoped GroupPolicy and ProjectPolicy rules, where the resource is known. Code Suggestions has no equivalent, so the namespace setting is never used.

The cascading setting itself works correctly. It is simply never read.

This is an accidental regression, not intended behavior. The old group-level rule was removed in 7efa62e6 together with the retired namespace_settings.code_suggestions column. No replacement was added when the shared duo_features_enabled setting landed later.

What this MR changes

The completions and direct_access endpoints now resolve the namespace that the request belongs to, and return 401 when GitLab Duo is off for it. These are the only two paths that generate suggestions or mint an AI Gateway token.

  • When the client sends project_path, the check reads the project, so a project that opted back in still works even if its parent group is off by default. Only "Always off" locks descendants.
  • When there is no usable project_path, it falls back to the user's governing namespace, which is where the usage is billed.
  • If no namespace can be determined, the request is allowed. We cannot attribute it to an owner who turned Duo off.

The check in direct_access runs before the token is minted. The AI Gateway signs those tokens itself and cannot revoke them, so a token issued here would keep working, and billing, until it expires.

The change only affects GitLab.com. Both new model methods return early unless the instance is SaaS, so self-managed behavior does not change. There the instance-level setting already works as a kill switch.

Behind the block_code_suggestions_when_duo_disabled feature flag (gitlab_com_derisk, default off). The flag actor is the root namespace, so a whole group flips together and we can enable it for one customer at a time with --group=.

Why the global ability is left alone

An earlier version of this MR also added a prevent rule on :access_code_suggestions so the UI would stop advertising the feature. That was removed after review, for two reasons.

  1. It does not match how Duo works elsewhere. access_duo_classic_chat and access_duo_agentic_chat are subjectless and gate on user-level licensing only. duo_features_enabled is enforced at the resource layer, where the cascaded value for that exact project or group can be read.
  2. It created an override bug. A subjectless rule can only look at each root group's own column, so it treated an unlocked "Off by default" group as fully off even when a project underneath had opted back in. Because the API checks that ability in its before block, such a user got a 401 before the project-aware check could run.

So the setting is now enforced in exactly one place: the per-request check, which is the only place the resource is actually known.

If we do want to stop advertising Code Suggestions in the IDE for a user who is off everywhere, that is better done as a separate, project-aware availability signal.

Out of scope

Found during the investigation, and better handled separately:

  • Fold the per-request check into a subject-scoped policy once the feature flag is removed, to match how Duo Chat checks access. Tracked in . A code comment on `duo_disabled_for_request?` points to this.
  • No AI Gateway change needed. The gateway never reads a namespace Duo setting. It trusts the JWT scopes that the monolith creates. Namespace headers are only used for logs, Snowplow and billing context.
  • Direct access tokens live for one hour. The AI Gateway signs them itself and cannot revoke them. This MR stops new tokens from being created, but a token that already exists keeps working until it expires.
  • strong_memoize_with(:project, …) in ee/lib/api/code_suggestions.rb sets @project to a Hash. ApplicationContext expects a Project, so project is missing from all Code Suggestions logs.
  • root_namespace_from_application_context reads the context key :group, which does not exist. Only :project and :root_namespace do. The code is never reached in production.
  • never_on does not reset duo_agent_platform_enabled, and check_gitlab_credits_access only checks that setting for DAP_ENFORCED_UNIT_PRIMITIVES. Credit-funded Code Suggestions have no such gate. This is a product question.

References

  • Issue: #606495
  • Rollout issue: #607850
  • Earlier discussion of the same gap: #535507 (moved)
  • Commit that removed the old group-level rule: 7efa62e6
  • Expected behavior is already documented here, and this MR makes the code match it: doc/development/ai_features/availability.md

Screenshots or screen recordings

Backend only, so there is no visual change to show. The user-visible effect is that Code Suggestions returns 401 in the IDE for a group that has GitLab Duo turned off.

How to set up and validate locally

completions cannot be called with plain curl, because it runs verify_workhorse_api!. Use the Rails console and the direct_access endpoint instead.

  1. Seed a Duo group and project in GitLab.com mode. Run this from the Rails root directory:

    GITLAB_SIMULATE_SAAS=1 bundle exec 'rake gitlab:duo:setup[duo_pro]'
  2. Start GDK with SaaS simulation on, so Gitlab::Saas.feature_available?(:gitlab_com_subscriptions) is true.

  3. Open the Rails console and set up the case from the issue:

    user    = User.find_by_username('root')
    group   = Group.find_by_full_path('gitlab-duo')
    project = group.all_projects.first
    
    Feature.enable(:block_code_suggestions_when_duo_disabled)
    
    # Before: not blocked
    user.code_suggestions_blocked_for?(project)  # => false
    
    # Owner sets "Always off"
    Groups::UpdateService.new(group, user, duo_availability: 'never_on').execute
    
    # After: blocked
    user.code_suggestions_blocked_for?(Project.find(project.id))  # => true
  4. Confirm the API rejects the request. This should return 401:

    curl --request POST --header "PRIVATE-TOKEN: <your_token>" \
      "http://gdk.test:3000/api/v4/code_suggestions/direct_access"
  5. Confirm that a project can still override an unlocked parent group:

    Groups::UpdateService.new(group, user, duo_availability: 'default_off').execute
    Project.find(project.id).project_setting.update!(duo_features_enabled: true)
    
    user.code_suggestions_blocked_for?(Project.find(project.id))  # => false
  6. Set the group back to default_on and check that everything works again:

    Groups::UpdateService.new(group, user, duo_availability: 'default_on').execute
    user.code_suggestions_blocked_for?(Project.find(project.id))  # => false
  7. Turn the feature flag off and confirm the old behavior returns:

    Feature.disable(:block_code_suggestions_when_duo_disabled)

Test coverage

New tests cover: the project-scoped case, the governing-namespace fallback, an ambiguous namespace (still allowed), a locked parent that overrides a project opt-in, self-managed (no change), and the feature flag being off or enabled for a different namespace.

Two cases check that the project the request is about wins over the governing namespace, in both directions:

  • A project with Duo off while the user's default Duo namespace has it on is blocked.
  • A project_path for a project the user cannot read with Duo off, while the governing namespace has it on, is allowed (we fall back to the governing namespace, which is what the rest of the endpoint already does).

There is also a regression guard for the override bug found in review: an unlocked "Off by default" group with a project that opted back in still returns 200.

Local runs: 771 examples, 0 failures across the changed and related spec files. RuboCop and scripts/lint-doc.sh are clean.

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 Vitali Tatarintev

Merge request reports

Loading
Loading