Add virtual registries setting policy and update service

What does this MR do and why?

This MR adds a policy for the virtual registries setting and a create or update service that calls this policy.

The policy allows users that have admin_group permissions to read and update the virtual registries setting, if the group is a root group.

Database review details

This create_or_update services calls the method create_or_update_by! that has been added to the model. This calls find_or_intialize_by and updates the setting.

The above has been simplified, create_or_update services calls the scope for_group. This calls find_or_intialize_by which returns the existing setting or a new instance, then update! is called. The risk of concurrent requests occurring is low for this table, only one setting can be created per group and only a group owner can update.

The below query plans are still applicable.

Queries

Find:


SELECT "virtual_registries_settings".* FROM "virtual_registries_settings" WHERE "virtual_registries_settings"."group_id" = 2 LIMIT 1;

Insert:


INSERT INTO "virtual_registries_settings" ("group_id", "enabled", "created_at", "updated_at") VALUES (2, true, '2025-08-28 04:29:04.123456', '2025-08-28 04:29:04.123456') RETURNING "id";

Update:


UPDATE "virtual_registries_settings" SET "enabled" = false, "updated_at" = '2025-08-28 04:29:04.123456' WHERE "group_id" = 2;
Explain plan Find:
 Limit  (cost=0.15..3.17 rows=1 width=33) (actual time=0.031..0.031 rows=0 loops=1)
   Buffers: shared hit=4
   I/O Timings: read=0.000 write=0.000
   ->  Index Scan using index_virtual_registries_settings_on_group_id on public.virtual_registries_settings  (cost=0.15..3.17 rows=1 width=33) (actual time=0.030..0.030 rows=0 loops=1)
         Index Cond: (virtual_registries_settings.group_id = 2)
         Buffers: shared hit=4
         I/O Timings: read=0.000 write=0.000
Settings: seq_page_cost = '4', work_mem = '100MB', random_page_cost = '1.5', jit = 'off', effective_cache_size = '472585MB'

https://postgres.ai/console/gitlab/gitlab-production-main/sessions/42891/commands/131315

Insert:

 ModifyTable on public.virtual_registries_settings  (cost=0.00..0.01 rows=1 width=33) (actual time=0.234..0.235 rows=1 loops=1)
   Buffers: shared hit=66 dirtied=2
   WAL: records=4 fpi=1 bytes=384
   I/O Timings: read=0.000 write=0.000
   ->  Result  (cost=0.00..0.01 rows=1 width=33) (actual time=0.090..0.091 rows=1 loops=1)
         Buffers: shared hit=16 dirtied=1
         WAL: records=1 fpi=0 bytes=99
         I/O Timings: read=0.000 write=0.000
Trigger RI_ConstraintTrigger_c_1312386350 for constraint fk_rails_a1646a6b7a: time=5.776 calls=1
Settings: effective_cache_size = '472585MB', seq_page_cost = '4', work_mem = '100MB', random_page_cost = '1.5', jit = 'off'

https://postgres.ai/console/gitlab/gitlab-production-main/sessions/42891/commands/131316

Update:

ModifyTable on public.virtual_registries_settings  (cost=0.15..3.17 rows=0 width=0) (actual time=0.047..0.048 rows=0 loops=1)
   Buffers: shared hit=6
   I/O Timings: read=0.000 write=0.000
   ->  Index Scan using index_virtual_registries_settings_on_group_id on public.virtual_registries_settings  (cost=0.15..3.17 rows=1 width=15) (actual time=0.046..0.046 rows=0 loops=1)
         Index Cond: (virtual_registries_settings.group_id = 2)
         Buffers: shared hit=6
         I/O Timings: read=0.000 write=0.000
Settings: seq_page_cost = '4', work_mem = '100MB', random_page_cost = '1.5', jit = 'off', effective_cache_size = '472585MB'

https://postgres.ai/console/gitlab/gitlab-production-main/sessions/42891/commands/131317

References

Screenshots or screen recordings

n/a

How to set up and validate locally

Validate policy:

Setup

  1. Run gdk rails c

  2. Create a group and a subgroup:.


[1] pry(main)> parent_group = FactoryBot.create(:group, :private, path: "parent_group")
=> #<Group id:104 @parent_group>
[2] pry(main)> sub_group = FactoryBot.create(:group, :private, parent: parent_group, path: "subgroup")
=> #<Group id:105 @parent_group/subgroup>
  1. Create users for parent group:

[6] pry(main)> non_group_member, external, guest, reporter, developer, maintainer, owner = [
  [:user, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, :external, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, guest_of: parent_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, reporter_of: parent_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, developer_of: parent_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, maintainer_of: parent_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, owner_of: parent_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"]
].map { |args| FactoryBot.create(*args) }
=> [#<User id:75 @user_0f6f88fd>,
 #<User id:76 @user_7077efc7>,
 #<User id:77 @user_6c909d88>,
 #<User id:78 @user_d8e6ff7e>,
 #<User id:79 @user_65b3498f>,
 #<User id:80 @user_e5a6ea69>,
 #<User id:81 @user_627d15b2>]
  1. Create users for subgroup:
non_group_member_sub, external_sub, guest_sub, reporter_sub, developer_sub, maintainer_sub, owner_sub = [
  [:user, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, :external, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, guest_of: sub_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, reporter_of: sub_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, developer_of: sub_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, maintainer_of: sub_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"],
  [:user, owner_of: sub_group, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com"]
].map { |args| FactoryBot.create(*args) }

=> [#<User id:82 @user_4ca5eb16>,
 #<User id:83 @user_6b9cc324>,
 #<User id:84 @user_2080e121>,
 #<User id:85 @user_c7bfba3d>,
 #<User id:86 @user_eef0a487>,
 #<User id:87 @user_552a4264>,
 #<User id:88 @user_d9e1abdd>]

Test policy on root group:

  1. Only owner is allowed:

[13] pry(main)> virtual_registry_group = ::VirtualRegistries::Packages::Policies::Group.new(parent_group)
=> #<VirtualRegistries::Packages::Policies::Group:0x0000000332718a38
 @group=#<Group id:104 @parent_group>>

[14] pry(main)> Ability.allowed?(non_group_member, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[16] pry(main)> Ability.allowed?(external, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[17] pry(main)> Ability.allowed?(guest, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[18] pry(main)> Ability.allowed?(reporter, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[19] pry(main)> Ability.allowed?(developer, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[20] pry(main)> Ability.allowed?(maintainer, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[21] pry(main)> Ability.allowed?(owner, :admin_virtual_registries_setting, virtual_registry_group)
=> true

Test policy on subgroup:

  1. None are allowed
[31] pry(main)> virtual_registry_group = ::VirtualRegistries::Packages::Policies::Group.new(sub_group)
=> #<VirtualRegistries::Packages::Policies::Group:0x00000003331b25e8
 @group=#<Group id:105 @parent_group_two>>

[32] pry(main)> Ability.allowed?(owner_sub, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[31] pry(main)> Ability.allowed?(maintainer_sub, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[30] pry(main)> Ability.allowed?(developer_sub, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[28] pry(main)> Ability.allowed?(reporter_sub, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[27] pry(main)> Ability.allowed?(guest_sub, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[26] pry(main)> Ability.allowed?(external_sub, :admin_virtual_registries_setting, virtual_registry_group)
=> false

[25] pry(main)> Ability.allowed?(non_group_member_sub, :admin_virtual_registries_setting, virtual_registry_group)
=> false

Create or update service:

On root group:

  1. Run gdk rails c

  2. Get a root group Group.where(parent_id: nil).first


[2] pry(main)> group = Group.first
=> #<Group id:22 @toolbox>
  1. Get a user user = User.first

[4] pry(main)> user = User.first
=> #<User id:1 @root>
  1. Add as owner of group group.add_owner(user)

[7] pry(main)> group.add_owner(user)
=> #<GroupMember:0x0000000318e1b6b0
 id: 1,
 access_level: 50,
 source_id: 22,
 source_type: "Namespace",
 user_id: 1,
 notification_level: 3,
 type: "GroupMember",
 created_at: Tue, 26 Aug 2025 00:30:34.126476000 UTC +00:00,
 updated_at: Tue, 26 Aug 2025 00:30:34.126476000 UTC +00:00,
 created_by_id: nil,
 invite_email: nil,
 invite_token: nil,
 invite_accepted_at: nil,
 requested_at: nil,
 expires_at: nil,
 ldap: false,
 override: false,
 state: 0,
 invite_email_success: true,
 member_namespace_id: 22,
 member_role_id: nil,
 expiry_notified_at: nil,
 request_accepted_at: nil,
 is_source_accessible_to_current_user: true>
  1. Run VirtualRegistries::Settings::CreateOrUpdateService.new(group:, current_user: user, params: {:enabled => true}).execute

[11] pry(main)> VirtualRegistries::Settings::CreateOrUpdateService.new(group:, current_user: user, params: {:enabled => true}).execute
=> #<ServiceResponse:0x00000003265de8e0
 @http_status=:ok,
 @message=nil,
 @payload=
  {:virtual_registries_setting=>
    #<VirtualRegistries::Setting:0x00000003225d7e90
     id: 1,
     group_id: 22,
     created_at: Thu, 28 Aug 2025 07:16:37.215638000 UTC +00:00,
     updated_at: Thu, 28 Aug 2025 07:16:37.215638000 UTC +00:00,
     enabled: true>},
 @reason=nil,
 @status=:success>
  1. Run VirtualRegistries::Setting.for_group(group) to confirm entry.

[17] pry(main)> VirtualRegistries::Setting.for_group(group)
  VirtualRegistries::Setting Load (0.3ms)  SELECT "virtual_registries_settings".* FROM "virtual_registries_settings" WHERE "virtual_registries_settings"."group_id" = 22 /* loading for pp */ LIMIT 11 /*application:console,db_config_database:gitlabhq_development,db_config_name:main,console_hostname:fmccawley--20250615-27FX9,console_username:fionamccawley,line:<internal:kernel>:187:in `loop'*/
=> [#<VirtualRegistries::Setting:0x00000003112da500
  id: 1,
  group_id: 22,
  created_at: Thu, 28 Aug 2025 07:16:37.215638000 UTC +00:00,
  updated_at: Thu, 28 Aug 2025 07:21:37.704329000 UTC +00:00,
  enabled: true>]
  1. Update enabled to false VirtualRegistries::Settings::CreateOrUpdateService.new(group:, current_user: user, params: {:enabled => false}).execute

[13] pry(main)> VirtualRegistries::Settings::CreateOrUpdateService.new(group:, current_user: user, params: {:enabled => false}).execute
=> #<ServiceResponse:0x00000003283966d0
 @http_status=:ok,
 @message=nil,
 @payload=
  {:virtual_registries_setting=>
    #<VirtualRegistries::Setting:0x0000000319b57f90
     id: 1,
     group_id: 22,
     created_at: Thu, 28 Aug 2025 07:16:37.215638000 UTC +00:00,
     updated_at: Thu, 28 Aug 2025 07:18:10.256793000 UTC +00:00,
     enabled: false>},
 @reason=nil,
 @status=:success>

On subgroup:

  1. Create a subgroup:
[1] pry(main)> group = Group.where(parent_id: nil).first
=> #<Group id:22 @toolbox>


[3] pry(main)> subgroup = FactoryBot.create(:group, parent: group, path: "test_one")
=> #<Group id:120 @toolbox/test_one>
  1. Create a user and add as owner of subgroup
[4] pry(main)> subgroup_owner = FactoryBot.create(:user, owner_of: subgroup, username: "user_#{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com")
=> #<User id:89 @user_f787de35>
  1. Run VirtualRegistries::Settings::CreateOrUpdateService.new(group: subgroup, current_user: subgroup_owner, params: {:enabled => true}).execute. This should return an unauthorized error.

[5] pry(main)> VirtualRegistries::Settings::CreateOrUpdateService.new(group: subgroup, current_user: subgroup_owner, params: {:enabled => true}).execute
=> #<ServiceResponse:0x0000000330831a98
 @http_status=nil,
 @message="Unauthorized",
 @payload={},
 @reason=:unauthorized,
 @status=:error>

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.

Related to #554069 (closed)

Edited by Fiona McCawley

Merge request reports

Loading