From c5550fa289febdb478dfa39bd094b08137e86fcc Mon Sep 17 00:00:00 2001 From: Jay Swain Date: Tue, 11 Nov 2025 18:31:22 -0800 Subject: [PATCH 1/6] Refactor Authz::Roles class Moving Authz::Role.access_level_encompasses? to Gitlab::Access.level_encompasses? as a first step to refactor role comparison methods. original MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/210959 part of: https://gitlab.com/gitlab-org/gitlab/-/issues/574071 --- app/models/group.rb | 2 +- app/models/member.rb | 2 +- app/models/project.rb | 2 +- .../group_access_tokens/rotate_service.rb | 2 +- .../project_access_tokens/rotate_service.rb | 2 +- .../resource_access_tokens/create_service.rb | 2 +- lib/authz/role.rb | 14 +----- lib/gitlab/access.rb | 12 +++++ spec/lib/authz/role_spec.rb | 46 +------------------ spec/lib/gitlab/access_spec.rb | 39 ++++++++++++++++ spec/support/helpers/roles_helpers.rb | 20 ++++++++ 11 files changed, 80 insertions(+), 63 deletions(-) create mode 100644 spec/support/helpers/roles_helpers.rb diff --git a/app/models/group.rb b/app/models/group.rb index bb4fc39eaa1b13..d1de41a03538b1 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1298,7 +1298,7 @@ def assigning_role_too_high?(current_user, access_level) max_access_level = max_member_access(current_user) - !Authz::Role.access_level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) + !Gitlab::Access.level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) end private diff --git a/app/models/member.rb b/app/models/member.rb index cd8b822ce5c998..4812f56a673769 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -666,7 +666,7 @@ def prevent_role_assignement?(current_user, params) current_access_level = params[:current_access_level] # check if it's a valid downgrade, if the member's current access level encompasses the target level - return false if Authz::Role.access_level_encompasses?( + return false if Gitlab::Access.level_encompasses?( current_access_level: current_access_level, level_to_assign: assigning_access_level ) diff --git a/app/models/project.rb b/app/models/project.rb index a5a5867adf2cdf..e6959736dadbda 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -3704,7 +3704,7 @@ def assigning_role_too_high?(current_user, access_level) max_access_level = max_member_access_for_user(current_user) - !Authz::Role.access_level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) + !Gitlab::Access.level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) end private diff --git a/app/services/group_access_tokens/rotate_service.rb b/app/services/group_access_tokens/rotate_service.rb index ed1ae40993ff05..321b692138848f 100644 --- a/app/services/group_access_tokens/rotate_service.rb +++ b/app/services/group_access_tokens/rotate_service.rb @@ -14,7 +14,7 @@ def valid_access_level? token_access_level = group.max_member_access_for_user(token.user).to_i current_user_access_level = group.max_member_access_for_user(current_user).to_i - Authz::Role.access_level_encompasses?( + Gitlab::Access.level_encompasses?( current_access_level: current_user_access_level, level_to_assign: token_access_level ) diff --git a/app/services/project_access_tokens/rotate_service.rb b/app/services/project_access_tokens/rotate_service.rb index 938a21f9e45e23..2126fe07421d44 100644 --- a/app/services/project_access_tokens/rotate_service.rb +++ b/app/services/project_access_tokens/rotate_service.rb @@ -14,7 +14,7 @@ def valid_access_level? token_access_level = project.team.max_member_access(token.user.id).to_i current_user_access_level = project.team.max_member_access(current_user.id).to_i - Authz::Role.access_level_encompasses?( + Gitlab::Access.level_encompasses?( current_access_level: current_user_access_level, level_to_assign: token_access_level ) diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 9ab0635ce4e11d..575e18e5fdde71 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -149,7 +149,7 @@ def validate_access_level(access_level) user_access_level = resource.max_member_access_for_user(current_user) - Authz::Role.access_level_encompasses?( + Gitlab::Access.level_encompasses?( current_access_level: user_access_level, level_to_assign: access_level.to_i ) diff --git a/lib/authz/role.rb b/lib/authz/role.rb index 65f8a86a02d503..64f98d97127b96 100644 --- a/lib/authz/role.rb +++ b/lib/authz/role.rb @@ -2,23 +2,11 @@ module Authz class Role - def self.access_level_encompasses?(current_access_level:, level_to_assign:) - # Roles that don't follow the inherited hierarchy can only be assigned by owners - # or by users with the same role - non_hierarchy_roles = [Gitlab::Access::PLANNER] - - if non_hierarchy_roles.include?(level_to_assign) - return current_access_level.in?([Gitlab::Access::OWNER, level_to_assign]) - end - - level_to_assign.to_i <= current_access_level.to_i - end - def self.roles_user_can_assign(current_access_level, roles = nil) available_roles = roles || Gitlab::Access.options_with_owner available_roles.select do |_role_name, access_level| - access_level_encompasses?(current_access_level: current_access_level, level_to_assign: access_level) + Gitlab::Access.level_encompasses?(current_access_level: current_access_level, level_to_assign: access_level) end end end diff --git a/lib/gitlab/access.rb b/lib/gitlab/access.rb index dc5fa5bebef369..a74b12701b75c1 100644 --- a/lib/gitlab/access.rb +++ b/lib/gitlab/access.rb @@ -40,6 +40,18 @@ module Access class << self delegate :values, to: :options + def level_encompasses?(current_access_level:, level_to_assign:) + # Roles that don't follow the inherited hierarchy can only be assigned by owners + # or by users with the same role + non_hierarchy_roles = [Gitlab::Access::PLANNER] + + if non_hierarchy_roles.include?(level_to_assign) + return current_access_level.in?([Gitlab::Access::OWNER, level_to_assign]) + end + + level_to_assign.to_i <= current_access_level.to_i + end + def all_values options_with_owner.values end diff --git a/spec/lib/authz/role_spec.rb b/spec/lib/authz/role_spec.rb index 3f49eecaff1f40..f8a86296ba378c 100644 --- a/spec/lib/authz/role_spec.rb +++ b/spec/lib/authz/role_spec.rb @@ -3,52 +3,10 @@ require 'spec_helper' RSpec.describe Authz::Role, feature_category: :system_access do - assignable_roles = { - owner: [:owner, :maintainer, :planner, :developer, :reporter, :guest], - maintainer: [:maintainer, :developer, :reporter, :guest], - developer: [:developer, :reporter, :guest], - reporter: [:reporter, :guest], - planner: [:planner, :guest], - guest: [:guest] - } - - describe '.access_level_encompasses?' do - def access_level_encompasses?(current_level, level_to_assign) - described_class.access_level_encompasses?( - current_access_level: access_level_value(current_level), - level_to_assign: access_level_value(level_to_assign) - ) - end - - assignable_roles.each do |current_level, expected| - context "with #{current_level}" do - not_expected = Gitlab::Access.sym_options_with_owner.keys - expected - - expected.each do |level_to_assign| - it "encompasses #{level_to_assign}" do - expect(access_level_encompasses?(current_level, level_to_assign)).to be(true) - end - end - - not_expected.each do |level_to_assign| - it "does not encompass #{level_to_assign}" do - expect(access_level_encompasses?(current_level, level_to_assign)).to be(false) - end - end - end - end - - it 'returns false when current_access_level is nil' do - result = described_class.access_level_encompasses?( - current_access_level: nil, - level_to_assign: Gitlab::Access::MAINTAINER - ) - expect(result).to be(false) - end - end + include RolesHelpers describe '.roles_user_can_assign' do - assignable_roles.each do |current_level, expected| + RolesHelpers.assignable_roles.each do |current_level, expected| context "with #{current_level}" do it 'returns correct assignable roles' do # Use the actual module's constants directly diff --git a/spec/lib/gitlab/access_spec.rb b/spec/lib/gitlab/access_spec.rb index aee39192e62b83..37fa9e8dcd6b48 100644 --- a/spec/lib/gitlab/access_spec.rb +++ b/spec/lib/gitlab/access_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Access, feature_category: :permissions do + include RolesHelpers + let_it_be(:member) { create(:group_member, :developer) } describe '#role_description' do @@ -12,4 +14,41 @@ expect(member.role_description).to eq(role) end end + + describe '.level_encompasses?' do + def level_encompasses?(current_level, level_to_assign) + described_class.level_encompasses?( + current_access_level: access_level_value(current_level), + level_to_assign: access_level_value(level_to_assign) + ) + end + + RolesHelpers.assignable_roles.each do |current_level, expected| + context "with #{current_level}" do + not_expected = Gitlab::Access.sym_options_with_owner.keys - expected + + expected.each do |level_to_assign| + it "encompasses #{level_to_assign}" do + result = level_encompasses?(current_level, level_to_assign) + expect(result).to be(true) + end + end + + not_expected.each do |level_to_assign| + it "does not encompass #{level_to_assign}" do + result = level_encompasses?(current_level, level_to_assign) + expect(result).to be(false) + end + end + end + end + + it 'returns false when current_access_level is nil' do + result = described_class.level_encompasses?( + current_access_level: nil, + level_to_assign: Gitlab::Access::MAINTAINER + ) + expect(result).to be(false) + end + end end diff --git a/spec/support/helpers/roles_helpers.rb b/spec/support/helpers/roles_helpers.rb new file mode 100644 index 00000000000000..f0eddc74eea593 --- /dev/null +++ b/spec/support/helpers/roles_helpers.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RolesHelpers + module_function + + def assignable_roles + { + owner: [:owner, :maintainer, :planner, :developer, :reporter, :guest], + maintainer: [:maintainer, :developer, :reporter, :guest], + developer: [:developer, :reporter, :guest], + reporter: [:reporter, :guest], + planner: [:planner, :guest], + guest: [:guest] + } + end + + def access_level_value(name) + Gitlab::Access.sym_options_with_owner[name] + end +end -- GitLab From 3c55aa1a83058772fa70fe1b9b2b5d2ea89ade15 Mon Sep 17 00:00:00 2001 From: Jay Swain Date: Tue, 11 Nov 2025 19:08:15 -0800 Subject: [PATCH 2/6] Refactor Authz::Roles class Move assigning_role_too_high? to Authz::HasRoles. Move custom_role_abilities_too_high? to Authz::HasRoles. original MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/210959 part of: https://gitlab.com/gitlab-org/gitlab/-/issues/574071 --- app/models/concerns/authz/has_roles.rb | 18 +++ app/models/group.rb | 10 +- app/models/project.rb | 10 +- .../group_access_tokens/rotate_service.rb | 7 +- .../project_access_tokens/rotate_service.rb | 7 +- .../resource_access_tokens/create_service.rb | 7 +- ee/app/models/concerns/ee/authz/has_roles.rb | 48 +++++++ ee/app/models/ee/member.rb | 48 +------ ee/app/models/ee/user.rb | 2 +- ee/app/services/ee/members/destroy_service.rb | 4 +- spec/models/group_spec.rb | 51 ++----- spec/models/project_spec.rb | 51 ++----- .../authz/has_roles_shared_examples.rb | 126 ++++++++++++++++++ 13 files changed, 221 insertions(+), 168 deletions(-) create mode 100644 app/models/concerns/authz/has_roles.rb create mode 100644 ee/app/models/concerns/ee/authz/has_roles.rb create mode 100644 spec/support/shared_examples/authz/has_roles_shared_examples.rb diff --git a/app/models/concerns/authz/has_roles.rb b/app/models/concerns/authz/has_roles.rb new file mode 100644 index 00000000000000..64b3545a97730d --- /dev/null +++ b/app/models/concerns/authz/has_roles.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Authz + module HasRoles + extend ActiveSupport::Concern + + def assigning_role_too_high?(current_user, access_level) + return false unless access_level + return false if current_user.can_admin_all_resources? + + max_access_level = max_member_access_for_user(current_user) + + !Gitlab::Access.level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) + end + end +end + +Authz::HasRoles.prepend_mod diff --git a/app/models/group.rb b/app/models/group.rb index d1de41a03538b1..64266618f962a0 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -6,6 +6,7 @@ class Group < Namespace include Gitlab::ConfigHelper include AfterCommitQueue include AccessRequestable + include Authz::HasRoles include Avatarable include SelectForProjectAuthorization include LoadedInGroupList @@ -1292,15 +1293,6 @@ def pending_delete? deletion_schedule.marked_for_deletion_on.future? end - def assigning_role_too_high?(current_user, access_level) - return false if current_user.can_admin_all_resources? - return false unless access_level - - max_access_level = max_member_access(current_user) - - !Gitlab::Access.level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) - end - private def feature_flag_enabled_for_self_or_ancestor?(feature_flag, type: :development) diff --git a/app/models/project.rb b/app/models/project.rb index e6959736dadbda..fe183ac66a3411 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,6 +6,7 @@ class Project < ApplicationRecord include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include AccessRequestable + include Authz::HasRoles include Avatarable include CacheMarkdownField include Sortable @@ -3698,15 +3699,6 @@ def ensure_pool_repository pool_repository || create_new_pool_repository end - def assigning_role_too_high?(current_user, access_level) - return false unless access_level - return false if current_user.can_admin_all_resources? - - max_access_level = max_member_access_for_user(current_user) - - !Gitlab::Access.level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) - end - private def check_duplicate_export!(current_user) diff --git a/app/services/group_access_tokens/rotate_service.rb b/app/services/group_access_tokens/rotate_service.rb index 321b692138848f..40a8982bb48221 100644 --- a/app/services/group_access_tokens/rotate_service.rb +++ b/app/services/group_access_tokens/rotate_service.rb @@ -12,12 +12,7 @@ def valid_access_level? return false unless current_user.can?(:manage_resource_access_tokens, group) token_access_level = group.max_member_access_for_user(token.user).to_i - current_user_access_level = group.max_member_access_for_user(current_user).to_i - - Gitlab::Access.level_encompasses?( - current_access_level: current_user_access_level, - level_to_assign: token_access_level - ) + !group.assigning_role_too_high?(current_user, token_access_level) end private diff --git a/app/services/project_access_tokens/rotate_service.rb b/app/services/project_access_tokens/rotate_service.rb index 2126fe07421d44..71fbd18a811056 100644 --- a/app/services/project_access_tokens/rotate_service.rb +++ b/app/services/project_access_tokens/rotate_service.rb @@ -12,12 +12,7 @@ def valid_access_level? return false unless current_user.can?(:manage_resource_access_tokens, project) token_access_level = project.team.max_member_access(token.user.id).to_i - current_user_access_level = project.team.max_member_access(current_user.id).to_i - - Gitlab::Access.level_encompasses?( - current_access_level: current_user_access_level, - level_to_assign: token_access_level - ) + !project.assigning_role_too_high?(current_user, token_access_level) end private diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 575e18e5fdde71..42848deb61db75 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -147,12 +147,7 @@ def success(access_token) def validate_access_level(access_level) return true if current_user.bot? - user_access_level = resource.max_member_access_for_user(current_user) - - Gitlab::Access.level_encompasses?( - current_access_level: user_access_level, - level_to_assign: access_level.to_i - ) + !resource.assigning_role_too_high?(current_user, access_level) end end end diff --git a/ee/app/models/concerns/ee/authz/has_roles.rb b/ee/app/models/concerns/ee/authz/has_roles.rb new file mode 100644 index 00000000000000..ece17641a1e455 --- /dev/null +++ b/ee/app/models/concerns/ee/authz/has_roles.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module EE + module Authz + module HasRoles + def custom_role_abilities_too_high?(current_user:, target_member_role_id:) + return false if current_user.can_admin_all_resources? + return false unless target_member_role_id + + current_user_access_level = max_member_access_for_user(current_user) + + return false if ::Gitlab::Access::OWNER == current_user_access_level + + current_user_member_role = ::Member.highest_role(current_user, self)&.member_role + target_member_role = MemberRole.find_by_id(target_member_role_id) + + current_user_role_abilities = member_role_abilities(current_user_member_role) + + custom_abilities_included_with_base_access_level(current_user_access_level) + + target_member_role_abilities = member_role_abilities(target_member_role) + + (target_member_role_abilities - current_user_role_abilities).present? + end + + private + + def custom_abilities_included_with_base_access_level(access_level) + abilities = [] + customizable_permissions = MemberRole.all_customizable_permissions + enabled_for_key = :"enabled_for_#{self.class.name.demodulize.downcase}_access_levels" + + customizable_permissions.each do |name, definition| + next unless definition.fetch(enabled_for_key, []).include?(access_level) + + abilities << name + end + + abilities + end + + def member_role_abilities(member_role) + return [] unless member_role + + member_role.enabled_permissions.keys + end + end + end +end diff --git a/ee/app/models/ee/member.rb b/ee/app/models/ee/member.rb index 7eb2a67953c32e..fa46af1cd5f1bf 100644 --- a/ee/app/models/ee/member.rb +++ b/ee/app/models/ee/member.rb @@ -242,56 +242,18 @@ def update_user_group_member_roles(old_values_map: nil) override :prevent_role_assignement? def prevent_role_assignement?(current_user, params) - return false if current_user.can_admin_all_resources? + target_member_role_id = params[:member_role_id] || member_role_id - member_role_id = params[:member_role_id] || member_role_id - - # first we need to check if there are possibly more custom abilities than current user has - return true if custom_role_abilities_too_high?(current_user, member_role_id) + return true if source.custom_role_abilities_too_high?( + current_user: current_user, + target_member_role_id: target_member_role_id + ) super end private - def custom_role_abilities_too_high?(current_user, member_role_id) - return false unless member_role_id - - current_user_access_level = source.max_member_access_for_user(current_user) - return false if ::Gitlab::Access::OWNER == current_user_access_level - - current_member_role = ::Member.highest_role(current_user, source)&.member_role - current_member_role_abilities = member_role_abilities( - current_member_role) + custom_abilities_included_with_base_access_level(current_user_access_level) - - new_member_role = MemberRole.find_by_id(member_role_id) - new_member_role_abilities = member_role_abilities(new_member_role) - - (new_member_role_abilities - current_member_role_abilities).present? - end - - def custom_abilities_included_with_base_access_level(current_access_level) - abilities = [] - - customizable_permissions = MemberRole.all_customizable_permissions - - enabled_for_key = :"enabled_for_#{source.class.name.demodulize.downcase}_access_levels" - - customizable_permissions.each do |name, definition| - next unless definition.fetch(enabled_for_key, []).include?(current_access_level) - - abilities << name - end - - abilities - end - - def member_role_abilities(member_role) - return [] unless member_role - - member_role.enabled_permissions.keys - end - def group_allowed_email_domains return [] unless group diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb index 9658f2869b0e06..ae924aad25dd0d 100644 --- a/ee/app/models/ee/user.rb +++ b/ee/app/models/ee/user.rb @@ -960,7 +960,7 @@ def audit_unlock_access(author: self) end def has_admin_custom_permissions? - Authz::Admin.new(self).available_permissions_for_user.present? + ::Authz::Admin.new(self).available_permissions_for_user.present? end end end diff --git a/ee/app/services/ee/members/destroy_service.rb b/ee/app/services/ee/members/destroy_service.rb index 81eabfe6ac1e4d..32de1c9c16caff 100644 --- a/ee/app/services/ee/members/destroy_service.rb +++ b/ee/app/services/ee/members/destroy_service.rb @@ -156,8 +156,8 @@ def destroy_user_group_member_roles(member) return unless group.is_a?(Group) return unless member.member_role_id || - Authz::UserGroupMemberRole.for_user_in_group_and_shared_groups(user, group).exists? || - Authz::UserProjectMemberRole.for_user_shared_with_group(user, group).exists? + ::Authz::UserGroupMemberRole.for_user_in_group_and_shared_groups(user, group).exists? || + ::Authz::UserProjectMemberRole.for_user_shared_with_group(user, group).exists? ::Authz::UserGroupMemberRoles::DestroyForGroupWorker.perform_async(user.id, group.id) end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 4e00c592301bec..834a7a6907beef 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -2302,50 +2302,15 @@ end end - describe '#assigning_role_too_high?' do - let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:member, reload: true) { create(:group_member, :reporter, group: group, user: user) } - - subject(:assigning_role_too_high) { group.assigning_role_too_high?(user, access_level) } - - context 'when the access_level is nil' do - let(:access_level) { nil } - - it 'returns false' do - expect(assigning_role_too_high).to be_falsey - end - end - - context 'when the role being assigned is lower then the role of currect user' do - let(:access_level) { Gitlab::Access::GUEST } - - it { is_expected.to be(false) } - end - - context 'when the role being assigned is equal to the role of currect user' do - let(:access_level) { Gitlab::Access::REPORTER } - - it { is_expected.to be(false) } - end - - context 'when the role being assigned is higher than the role of currect user' do - let(:access_level) { Gitlab::Access::MAINTAINER } - - it 'returns true' do - expect(assigning_role_too_high).to be_truthy - end - - context 'when the current user is admin', :enable_admin_mode do - before do - user.update!(admin: true) - end + it_behaves_like '#assigning_role_too_high' do + let(:user) { create(:user) } + let(:resource) { create(:group) } + let!(:membership) { create(:group_member, :reporter, group: resource, user: user) } + end - it 'returns false' do - expect(assigning_role_too_high).to be_falsey - end - end - end + it_behaves_like '#custom_role_abilities_too_high' do + let(:current_user) { create(:user) } + let(:resource) { create(:group) } end def setup_group_members(group) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 142f893571962d..4cebd68ca2f99c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -10414,50 +10414,15 @@ def create_build(new_pipeline = pipeline, name = 'test') end end - describe '#assigning_role_too_high?' do - let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project) } - let_it_be(:member, reload: true) { create(:project_member, :reporter, project: project, user: user) } - - subject(:assigning_role_too_high) { project.assigning_role_too_high?(user, access_level) } - - context 'when the access_level is nil' do - let(:access_level) { nil } - - it 'returns false' do - expect(assigning_role_too_high).to be_falsey - end - end - - context 'when the role being assigned is lower than the role of current user' do - let(:access_level) { Gitlab::Access::GUEST } - - it { is_expected.to be(false) } - end - - context 'when the role being assigned is equal to the role of current user' do - let(:access_level) { Gitlab::Access::REPORTER } - - it { is_expected.to be(false) } - end - - context 'when the role being assigned is higher than the role of current user' do - let(:access_level) { Gitlab::Access::MAINTAINER } - - it 'returns true' do - expect(assigning_role_too_high).to be_truthy - end - - context 'when the current user is admin', :enable_admin_mode do - before do - user.update!(admin: true) - end + it_behaves_like '#assigning_role_too_high' do + let(:user) { create(:user) } + let(:resource) { create(:project) } + let!(:membership) { create(:project_member, :reporter, project: resource, user: user) } + end - it 'returns false' do - expect(assigning_role_too_high).to be_falsey - end - end - end + it_behaves_like '#custom_role_abilities_too_high' do + let(:current_user) { create(:user) } + let(:resource) { create(:project) } end describe '#owner_entity' do diff --git a/spec/support/shared_examples/authz/has_roles_shared_examples.rb b/spec/support/shared_examples/authz/has_roles_shared_examples.rb new file mode 100644 index 00000000000000..8385456f77f7bd --- /dev/null +++ b/spec/support/shared_examples/authz/has_roles_shared_examples.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +RSpec.shared_examples '#assigning_role_too_high' do + describe '#assigning_role_too_high?' do + subject(:assigning_role_too_high) { resource.assigning_role_too_high?(user, access_level) } + + context 'when the access_level is nil' do + let(:access_level) { nil } + + it 'returns false' do + expect(assigning_role_too_high).to be_falsey + end + end + + context 'when the role being assigned is lower than the role of current user' do + let(:access_level) { Gitlab::Access::GUEST } + + it { is_expected.to be(false) } + end + + context 'when the role being assigned is equal to the role of current user' do + let(:access_level) { Gitlab::Access::REPORTER } + + it { is_expected.to be(false) } + end + + context 'when the role being assigned is higher than the role of current user' do + let(:access_level) { Gitlab::Access::MAINTAINER } + + it 'returns true' do + expect(assigning_role_too_high).to be_truthy + end + + context 'when the current user is admin', :enable_admin_mode do + before do + user.update!(admin: true) + end + + it 'returns false' do + expect(assigning_role_too_high).to be_falsey + end + end + end + end +end + +RSpec.shared_examples '#custom_role_abilities_too_high' do + describe '#custom_role_abilities_too_high?' do + let(:member_role_id) { nil } + let(:access_level) { Gitlab::Access::GUEST } + + subject(:custom_role_abilities_too_high?) do + resource.custom_role_abilities_too_high?(current_user: current_user, target_member_role_id: member_role_id) + end + + context 'for custom roles assignement' do + let(:member_role_current_user) do + create(:member_role, :maintainer, admin_merge_request: true, namespace: resource.root_ancestor) + end + + let(:member_role_less_abilities) do + create(:member_role, :guest, admin_merge_request: true, namespace: resource.root_ancestor) + end + + let(:member_role_more_abilities) do + create(:member_role, :guest, admin_merge_request: true, admin_push_rules: true, remove_project: true, + namespace: resource.root_ancestor) + end + + before do + current_member = resource.add_maintainer(current_user) + current_member.update!(member_role: member_role_current_user) + stub_licensed_features(custom_roles: true) + end + + context 'with the same custom role as current user has' do + let(:member_role_id) { member_role_current_user.id } + + it 'allows role assignement' do + expect(custom_role_abilities_too_high?).to be(false) + end + end + + context "with custom role abilities included in the current user's base access" do + let(:member_role_included_abilities) do + create(:member_role, :guest, admin_merge_request: true, admin_push_rules: true, + namespace: resource.root_ancestor) + end + + let(:member_role_id) { member_role_included_abilities.id } + + it 'allows role assignement' do + expect(custom_role_abilities_too_high?).to be(false) + end + end + + context 'with the custom role having less abilities than current user has' do + let(:member_role_id) { member_role_less_abilities.id } + + it 'returns false' do + expect(custom_role_abilities_too_high?).to be(false) + end + end + + context 'with the custom role having more abilities than current user has' do + let(:member_role_id) { member_role_more_abilities.id } + + it 'returns true' do + expect(custom_role_abilities_too_high?).to be(true) + end + + context 'when current user is an admin', :enable_admin_mode do + before do + current_user.members.delete_all + + current_user.update!(admin: true) + end + + it 'returns false' do + expect(custom_role_abilities_too_high?).to be(false) + end + end + end + end + end +end -- GitLab From 9f2d1f05407b6136ac2740ba25e458666f73e653 Mon Sep 17 00:00:00 2001 From: Jay Swain Date: Wed, 12 Nov 2025 17:36:16 -0800 Subject: [PATCH 3/6] Review Feedback * assigning_role_too_high => can_assign_role * remove can_admin_all_resources check from rotate_services --- app/models/concerns/authz/has_roles.rb | 8 +++---- app/models/member.rb | 2 +- .../group_access_tokens/rotate_service.rb | 3 +-- .../project_access_tokens/rotate_service.rb | 3 +-- .../resource_access_tokens/create_service.rb | 2 +- spec/models/group_spec.rb | 2 +- spec/models/project_spec.rb | 2 +- .../authz/has_roles_shared_examples.rb | 22 +++++++++---------- 8 files changed, 21 insertions(+), 23 deletions(-) diff --git a/app/models/concerns/authz/has_roles.rb b/app/models/concerns/authz/has_roles.rb index 64b3545a97730d..dbbc75635d746f 100644 --- a/app/models/concerns/authz/has_roles.rb +++ b/app/models/concerns/authz/has_roles.rb @@ -4,13 +4,13 @@ module Authz module HasRoles extend ActiveSupport::Concern - def assigning_role_too_high?(current_user, access_level) - return false unless access_level - return false if current_user.can_admin_all_resources? + def can_assign_role?(current_user, access_level) + return true unless access_level + return true if current_user.can_admin_all_resources? max_access_level = max_member_access_for_user(current_user) - !Gitlab::Access.level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) + Gitlab::Access.level_encompasses?(current_access_level: max_access_level, level_to_assign: access_level) end end end diff --git a/app/models/member.rb b/app/models/member.rb index 4812f56a673769..642821bf97d1d1 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -672,7 +672,7 @@ def prevent_role_assignement?(current_user, params) ) # prevent assignement in case the role access level is higher than current user's role - source.assigning_role_too_high?(current_user, assigning_access_level) + !source.can_assign_role?(current_user, assigning_access_level) end private diff --git a/app/services/group_access_tokens/rotate_service.rb b/app/services/group_access_tokens/rotate_service.rb index 40a8982bb48221..35609d2d378d27 100644 --- a/app/services/group_access_tokens/rotate_service.rb +++ b/app/services/group_access_tokens/rotate_service.rb @@ -8,11 +8,10 @@ class RotateService < ::PersonalAccessTokens::RotateService override :valid_access_level? def valid_access_level? - return true if current_user.can_admin_all_resources? return false unless current_user.can?(:manage_resource_access_tokens, group) token_access_level = group.max_member_access_for_user(token.user).to_i - !group.assigning_role_too_high?(current_user, token_access_level) + group.can_assign_role?(current_user, token_access_level) end private diff --git a/app/services/project_access_tokens/rotate_service.rb b/app/services/project_access_tokens/rotate_service.rb index 71fbd18a811056..18b259a7c770a1 100644 --- a/app/services/project_access_tokens/rotate_service.rb +++ b/app/services/project_access_tokens/rotate_service.rb @@ -8,11 +8,10 @@ class RotateService < ::PersonalAccessTokens::RotateService override :valid_access_level? def valid_access_level? - return true if current_user.can_admin_all_resources? return false unless current_user.can?(:manage_resource_access_tokens, project) token_access_level = project.team.max_member_access(token.user.id).to_i - !project.assigning_role_too_high?(current_user, token_access_level) + project.can_assign_role?(current_user, token_access_level) end private diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 42848deb61db75..2414eb64c39486 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -147,7 +147,7 @@ def success(access_token) def validate_access_level(access_level) return true if current_user.bot? - !resource.assigning_role_too_high?(current_user, access_level) + resource.can_assign_role?(current_user, access_level) end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 834a7a6907beef..19bf68cad1fa49 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -2302,7 +2302,7 @@ end end - it_behaves_like '#assigning_role_too_high' do + it_behaves_like '#can_assign_role' do let(:user) { create(:user) } let(:resource) { create(:group) } let!(:membership) { create(:group_member, :reporter, group: resource, user: user) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 4cebd68ca2f99c..d52dd2d9975289 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -10414,7 +10414,7 @@ def create_build(new_pipeline = pipeline, name = 'test') end end - it_behaves_like '#assigning_role_too_high' do + it_behaves_like '#can_assign_role' do let(:user) { create(:user) } let(:resource) { create(:project) } let!(:membership) { create(:project_member, :reporter, project: resource, user: user) } diff --git a/spec/support/shared_examples/authz/has_roles_shared_examples.rb b/spec/support/shared_examples/authz/has_roles_shared_examples.rb index 8385456f77f7bd..b30083a5baf88f 100644 --- a/spec/support/shared_examples/authz/has_roles_shared_examples.rb +++ b/spec/support/shared_examples/authz/has_roles_shared_examples.rb @@ -1,34 +1,34 @@ # frozen_string_literal: true -RSpec.shared_examples '#assigning_role_too_high' do - describe '#assigning_role_too_high?' do - subject(:assigning_role_too_high) { resource.assigning_role_too_high?(user, access_level) } +RSpec.shared_examples '#can_assign_role' do + describe '#can_assign_role?' do + subject(:can_assign_role) { resource.can_assign_role?(user, access_level) } context 'when the access_level is nil' do let(:access_level) { nil } - it 'returns false' do - expect(assigning_role_too_high).to be_falsey + it 'returns true' do + expect(can_assign_role).to be_true end end context 'when the role being assigned is lower than the role of current user' do let(:access_level) { Gitlab::Access::GUEST } - it { is_expected.to be(false) } + it { is_expected.to be(true) } end context 'when the role being assigned is equal to the role of current user' do let(:access_level) { Gitlab::Access::REPORTER } - it { is_expected.to be(false) } + it { is_expected.to be(true) } end context 'when the role being assigned is higher than the role of current user' do let(:access_level) { Gitlab::Access::MAINTAINER } - it 'returns true' do - expect(assigning_role_too_high).to be_truthy + it 'returns false' do + expect(can_assign_role).to be(false) end context 'when the current user is admin', :enable_admin_mode do @@ -36,8 +36,8 @@ user.update!(admin: true) end - it 'returns false' do - expect(assigning_role_too_high).to be_falsey + it 'returns true' do + expect(can_assign_role).to be(true) end end end -- GitLab From 930aaa9d1a8b1db7a5f6558f53f63913fecd5040 Mon Sep 17 00:00:00 2001 From: Jay Swain Date: Wed, 12 Nov 2025 18:21:27 -0800 Subject: [PATCH 4/6] spec fix --- spec/support/shared_examples/authz/has_roles_shared_examples.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/support/shared_examples/authz/has_roles_shared_examples.rb b/spec/support/shared_examples/authz/has_roles_shared_examples.rb index b30083a5baf88f..cd78cbd4adad6d 100644 --- a/spec/support/shared_examples/authz/has_roles_shared_examples.rb +++ b/spec/support/shared_examples/authz/has_roles_shared_examples.rb @@ -8,7 +8,7 @@ let(:access_level) { nil } it 'returns true' do - expect(can_assign_role).to be_true + expect(can_assign_role).to be(true) end end -- GitLab From 7c6ae29947cce211bfbc23a41b2fb3eba71b46f2 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 14 Nov 2025 15:08:37 -0800 Subject: [PATCH 5/6] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Diane Russel --- app/services/project_access_tokens/rotate_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/project_access_tokens/rotate_service.rb b/app/services/project_access_tokens/rotate_service.rb index 18b259a7c770a1..01e10859930332 100644 --- a/app/services/project_access_tokens/rotate_service.rb +++ b/app/services/project_access_tokens/rotate_service.rb @@ -10,7 +10,7 @@ class RotateService < ::PersonalAccessTokens::RotateService def valid_access_level? return false unless current_user.can?(:manage_resource_access_tokens, project) - token_access_level = project.team.max_member_access(token.user.id).to_i + token_access_level = project.max_member_access_for_user(token.user).to_i project.can_assign_role?(current_user, token_access_level) end -- GitLab From 91fc06e3cf6137d017c02c00878fd7abc18f7e52 Mon Sep 17 00:00:00 2001 From: Jay Swain Date: Fri, 14 Nov 2025 15:09:50 -0800 Subject: [PATCH 6/6] Review feedback * remove admin check * remove comment --- app/models/concerns/authz/has_roles.rb | 1 - app/models/member.rb | 1 - 2 files changed, 2 deletions(-) diff --git a/app/models/concerns/authz/has_roles.rb b/app/models/concerns/authz/has_roles.rb index dbbc75635d746f..9f2e36c319a36f 100644 --- a/app/models/concerns/authz/has_roles.rb +++ b/app/models/concerns/authz/has_roles.rb @@ -6,7 +6,6 @@ module HasRoles def can_assign_role?(current_user, access_level) return true unless access_level - return true if current_user.can_admin_all_resources? max_access_level = max_member_access_for_user(current_user) diff --git a/app/models/member.rb b/app/models/member.rb index 642821bf97d1d1..6c6beaafa082e3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -671,7 +671,6 @@ def prevent_role_assignement?(current_user, params) level_to_assign: assigning_access_level ) - # prevent assignement in case the role access level is higher than current user's role !source.can_assign_role?(current_user, assigning_access_level) end -- GitLab