WIP: Spike to allow feature access rules

What does this MR do and why?

Adds support for "default rules" in the Duo feature access rules system, allowing admins to grant access to all eligible users without requiring group membership.

Problem

When group-based access rules are configured, users outside those groups lose all Duo access. This forces admins to maintain a catch-all group containing every user — burdensome for large organizations and incompatible with workflows that don't use LDAP/SAML synced groups.

Solution

Allow a rule with no group (through_namespace_id: NULL) to serve as a default that applies to all eligible users (users with Duo Pro/Enterprise/Core seats).

Group-specific rules continue to work as before. A user matching both a default rule and a group rule receives the union of both.

Example configuration:

  • (default) → duo_classic — all eligible users get Classic
  • pilot-users group → duo_agent_platform — only this group gets Agent Platform

References

Implements Support member access rules with no group to ap... (#591502 - closed)

Screenshots or screen recordings

Screenshot_2026-02-25_at_3.36.10_PM

How to set up and validate locally

Prerequisites:

  • GDK running with DAP enabled and at least 2 users in the database (the root admin plus one other, which db:seed provides).
  • GDK running in SM mode
  1. Apply the migration in this MR

    bundle exec rails db:migrate
  2. Run the setup script

    Save this to tmp/test_default_rules.rb:

    Click to view ruby
    # frozen_string_literal: true
    #
    # Happy path test for the "default rule" spike (issue #591502).
    #
    # Creates real data you can inspect manually in the console or UI.
    # To clean up afterward, run:
    #   bundle exec rails runner tmp/cleanup_default_rules.rb
    #
    # Run with:
    #   bundle exec rails runner tmp/test_default_rules.rb
    
    puts "\n#{'=' * 60}"
    puts "  Duo Default Rule — Happy Path Test"
    puts "#{'=' * 60}\n\n"
    
    $pass = 0
    $fail = 0
    
    def check(label, value)
    if value
      puts "  ✓  #{label}"
      $pass += 1
    else
      puts "  ✗  #{label}   <-- FAILED"
      $fail += 1
    end
    end
    
    Feature.enable(:duo_access_through_namespaces)
    org = Organizations::Organization.default_organization
    
    # Clean up any previous run of this script before creating fresh data
    Group.where("name LIKE 'Spike %'").order(Arel.sql('parent_id NULLS LAST')).each(&:destroy)
    Ai::FeatureAccessRule.delete_all
    Ai::NamespaceFeatureAccessRule.delete_all
    
    user_a = User.first
    user_b = User.offset(1).first || begin
    User.create!(
      username:      "spike_user_b_#{SecureRandom.hex(4)}",
      name:          "Spike User B",
      email:         "spike_user_b_#{SecureRandom.hex(4)}@example.com",
      password:      "passwordABC123!",
      confirmed_at:  Time.current,
      skip_confirmation: true
    )
    end
    
    puts "Users:  #{user_a.username} (A),  #{user_b.username} (B)\n\n"
    
    pilot_group = Group.create!(name: "Spike Pilot Group",  path: "spike-pilot-#{SecureRandom.hex(4)}",    organization: org)
    root_ns     = Group.create!(name: "Spike Root NS",      path: "spike-root-ns-#{SecureRandom.hex(4)}",  organization: org)
    subgroup    = Group.create!(name: "Spike Subgroup",     path: "spike-sub-#{SecureRandom.hex(4)}",      parent: root_ns, organization: org)
    
    # Ensure namespace_settings exists for each group — required by several delegated methods
    # (e.g. prevent_sharing_groups_outside_hierarchy) that the members page and other pages call.
    [pilot_group, root_ns, subgroup].each do |g|
    g.create_namespace_settings unless g.namespace_settings
    end
    
    puts "Groups created:"
    puts "  pilot_group id=#{pilot_group.id}  (#{pilot_group.full_path})"
    puts "  root_ns     id=#{root_ns.id}      (#{root_ns.full_path})"
    puts "  subgroup    id=#{subgroup.id}     (#{subgroup.full_path})\n\n"
    
    # ----------------------------------------------------------------
    puts "── SM/Dedicated: group-only rule (baseline) ──────────────────"
    # ----------------------------------------------------------------
    Ai::FeatureAccessRule.create!(through_namespace: pilot_group, accessible_entity: "duo_agent_platform")
    pilot_group.add_guest(user_a)
    
    check "user_a (in pilot_group) can access duo_agent_platform",
    Ai::FeatureAccessRule.accessible_for_user(user_a, "duo_agent_platform").exists?
    
    check "user_b (not in any group) cannot access duo_agent_platform",
    !Ai::FeatureAccessRule.accessible_for_user(user_b, "duo_agent_platform").exists?
    
    # ----------------------------------------------------------------
    puts "\n── SM/Dedicated: add a default rule ──────────────────────────"
    # ----------------------------------------------------------------
    Ai::FeatureAccessRule.create!(through_namespace_id: nil, accessible_entity: "duo_classic")
    
    check "user_b (not in any group) can access duo_classic via default rule",
    Ai::FeatureAccessRule.accessible_for_user(user_b, "duo_classic").exists?
    
    check "user_a (in pilot_group) can also access duo_classic via default rule",
    Ai::FeatureAccessRule.accessible_for_user(user_a, "duo_classic").exists?
    
    check "user_b cannot access duo_agent_platform (no group membership)",
    !Ai::FeatureAccessRule.accessible_for_user(user_b, "duo_agent_platform").exists?
    
    # ----------------------------------------------------------------
    puts "\n── SM/Dedicated: transformer output ─────────────────────────"
    # ----------------------------------------------------------------
    rules       = Ai::FeatureAccessRule.duo_namespace_access_rules
    transformed = Ai::FeatureAccessRuleTransformer.transform(rules)
    
    default_entry = transformed.find { |r| r[:default_rule] }
    group_entry   = transformed.find { |r| !r[:default_rule] }
    
    check "transformer: default entry present with default_rule: true",
    default_entry&.fetch(:default_rule) == true
    
    check "transformer: default entry has nil through_namespace",
    default_entry&.fetch(:through_namespace).nil?
    
    check "transformer: group entry has default_rule: false and through_namespace details",
    group_entry&.fetch(:default_rule) == false && group_entry&.dig(:through_namespace, :id) == pilot_group.id
    
    # ----------------------------------------------------------------
    puts "\n── SaaS: NamespaceFeatureAccessRule default rule ─────────────"
    # ----------------------------------------------------------------
    Ai::NamespaceFeatureAccessRule.create!(
    through_namespace_id: nil,
    accessible_entity:    "duo_classic",
    root_namespace:       root_ns
    )
    Ai::NamespaceFeatureAccessRule.create!(
    through_namespace: subgroup,
    accessible_entity: "duo_agent_platform",
    root_namespace:    root_ns
    )
    subgroup.add_guest(user_a)
    
    check "SaaS default rule: user_b (no subgroup membership) can access duo_classic",
    Ai::NamespaceFeatureAccessRule.accessible_for_user(user_b, "duo_classic", root_ns).exists?
    
    check "SaaS default rule: user_a (in subgroup) can also access duo_classic",
    Ai::NamespaceFeatureAccessRule.accessible_for_user(user_a, "duo_classic", root_ns).exists?
    
    check "SaaS group rule: user_a (in subgroup) can access duo_agent_platform",
    Ai::NamespaceFeatureAccessRule.accessible_for_user(user_a, "duo_agent_platform", root_ns).exists?
    
    check "SaaS group rule: user_b (not in subgroup) cannot access duo_agent_platform",
    !Ai::NamespaceFeatureAccessRule.accessible_for_user(user_b, "duo_agent_platform", root_ns).exists?
    
    # ----------------------------------------------------------------
    puts "\n── SaaS: by_root_namespace_group_by_through_namespace ────────"
    # ----------------------------------------------------------------
    grouped = Ai::NamespaceFeatureAccessRule.by_root_namespace_group_by_through_namespace(root_ns)
    
    check "grouped result includes nil key (default rule)",
    grouped.key?(nil)
    
    check "grouped result includes subgroup key (group rule)",
    grouped.key?(subgroup.id)
    
    # ----------------------------------------------------------------
    puts "\n#{'─' * 60}"
    puts "  #{$pass} passed   #{$fail > 0 ? "#{$fail} FAILED" : "0 failed"}"
    puts $fail == 0 ? "  ✓ All tests passed." : "  ✗ Some tests failed — see above."
    puts "#{'=' * 60}\n"
    
    puts "Data persisted for manual QA. Inspect with:"
    puts "  Ai::FeatureAccessRule.all"
    puts "  Ai::NamespaceFeatureAccessRule.all"
    puts "  pilot_group_id = #{pilot_group.id}"
    puts "  root_ns_id     = #{root_ns.id}"
    puts "  subgroup_id    = #{subgroup.id}"
    puts "  user_a         = #{user_a.username} (id #{user_a.id})"
    puts "  user_b         = #{user_b.username} (id #{user_b.id})"
    puts "\nTo clean up: bundle exec rails runner tmp/cleanup_default_rules.rb\n\n"

    Now run: bundle exec rails runner tmp/test_default_rules.rb

  3. Validate in the UI

    1. Sign in as an admin and go to Admin area → GitLab Duo (/admin/gitlab_duo)
    2. Scroll to the Member access section
    3. Verify the rules table contains two rows:
    • A default rule row showing "All eligible users (default)" with GitLab Duo Classic checked
    • A Spike Pilot Group row with GitLab Duo Agent Platform checked
    1. Confirm that you have access to Classic features, like Classic Chat
    2. Add yourself as a direct member (any role) to the group that has "Duo Agent Platform" assigned n the table.
    3. Confirm you have access to DAP features, like Agentic Chat. Note: there is a 5 minute cache TTL for changes to these rules.
    4. Confirm that if you remove yourself from the group that has "Duo Agent Platform" assigned, you lose access to Agentic features.
  4. Clean up

    Save this to tmp/test_default_rules.rb:

    Click to view ruby
    # frozen_string_literal: true
    #
    # Cleans up data created by tmp/cleanup_default_rules.rb
    #
    # Run with:
    #   bundle exec rails runner tmp/cleanup_default_rules.rb
    
    Ai::FeatureAccessRule.delete_all
    Ai::NamespaceFeatureAccessRule.delete_all
    # Destroy subgroups (parent_id NOT NULL) before root groups (parent_id IS NULL)
    Group.where("name LIKE 'Spike %'").order(Arel.sql('parent_id NULLS LAST')).each(&:destroy)
    
    puts "Cleaned up all Spike test groups and feature access rules."

    Now run bundle exec rails runner tmp/cleanup_default_rules.rb

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 Jessie Young

Merge request reports

Loading