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
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
-
Apply the migration in this MR
bundle exec rails db:migrate -
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 -
Validate in the UI
- Sign in as an admin and go to Admin area → GitLab Duo (/admin/gitlab_duo)
- Scroll to the Member access section
- 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
- Confirm that you have access to Classic features, like Classic Chat
- Add yourself as a direct member (any role) to the group that has "Duo Agent Platform" assigned n the table.
- Confirm you have access to DAP features, like Agentic Chat. Note: there is a 5 minute cache TTL for changes to these rules.
- Confirm that if you remove yourself from the group that has "Duo Agent Platform" assigned, you lose access to Agentic features.
-
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.
