diff --git a/app/assets/javascripts/graphql_shared/fragments/project.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/project.fragment.graphql index deb5b6f308e9c9ee75c566665efa020eb935fe59..62823f842860aebc326b0c6d21fef444816547b6 100644 --- a/app/assets/javascripts/graphql_shared/fragments/project.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/project.fragment.graphql @@ -29,6 +29,7 @@ fragment Project on Project { } maxAccessLevel { integerValue + humanAccess } isCatalogResource exploreCatalogPath diff --git a/app/assets/javascripts/groups/show/graphql/resolvers.js b/app/assets/javascripts/groups/show/graphql/resolvers.js index e7eeb45ad37b3f4e5bb20fc4b5c3ce020fe53e2f..98c3103bf218a6fe3e8f0202e1bb71c6deb8735e 100644 --- a/app/assets/javascripts/groups/show/graphql/resolvers.js +++ b/app/assets/javascripts/groups/show/graphql/resolvers.js @@ -2,7 +2,7 @@ import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; -import { ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants'; +import { ACCESS_LEVEL_NO_ACCESS_INTEGER, ACCESS_LEVEL_LABELS } from '~/access_level/constants'; import { FEATURABLE_DISABLED } from '~/featurable/constants'; import { LIST_ITEM_TYPE_GROUP } from '~/vue_shared/components/nested_groups_projects_list/constants'; @@ -24,6 +24,7 @@ const formatSubgroupsAndProjects = (item) => { permanentDeletionDate: item.permanent_deletion_date, maxAccessLevel: { integerValue: item.permission_integer ?? ACCESS_LEVEL_NO_ACCESS_INTEGER, + humanAccess: ACCESS_LEVEL_LABELS[item.permission_integer] || null, }, webUrl: item.web_url, }; @@ -33,6 +34,9 @@ const formatSubgroupsAndProjects = (item) => { return { ...baseItem, + maxAccessLevel: { + integerValue: item.permission_integer ?? ACCESS_LEVEL_NO_ACCESS_INTEGER, + }, __typename: TYPENAME_GROUP, id: convertToGraphQLId(TYPENAME_GROUP, item.id), fullName: item.full_name, diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index a305243e989450e9969ad413a9c6b964e709ba55..04426dd8b61f880ae12e6f821eefada027334c81 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -8,7 +8,7 @@ import { import ProjectListItemActions from '~/vue_shared/components/projects_list/project_list_item_actions.vue'; import ListItemInactiveBadge from '~/vue_shared/components/resource_lists/list_item_inactive_badge.vue'; import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants'; -import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants'; +import { ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants'; import { FEATURABLE_ENABLED } from '~/featurable/constants'; import { __, s__, n__, sprintf } from '~/locale'; import { numberToHumanSize, numberToMetricPrefix } from '~/lib/utils/number_utils'; @@ -118,7 +118,7 @@ export default { return this.project.accessLevel?.integerValue; }, accessLevelLabel() { - return ACCESS_LEVEL_LABELS[this.accessLevel]; + return this.project.accessLevel?.humanAccess; }, shouldShowAccessLevel() { return this.accessLevel !== undefined && this.accessLevel !== ACCESS_LEVEL_NO_ACCESS_INTEGER; diff --git a/app/graphql/types/access_level_type.rb b/app/graphql/types/access_level_type.rb index 67a6a624d45bf19d690066caf0d92da1e52036cf..452ff026f361a8605a3c874f33b1f341001f2659 100644 --- a/app/graphql/types/access_level_type.rb +++ b/app/graphql/types/access_level_type.rb @@ -23,3 +23,5 @@ def human_access end end end + +Types::AccessLevelType.prepend_mod_with('Types::Projects::AccessLevelType') diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb index 61a1b9af8eee71a66d80c013ec78f5b921e58648..7b7552ea7785fbb1888ba15617b67578d946e3ed 100644 --- a/ee/app/graphql/ee/types/project_type.rb +++ b/ee/app/graphql/ee/types/project_type.rb @@ -6,6 +6,12 @@ module ProjectType extend ActiveSupport::Concern extend ::Gitlab::Utils::Override + AccessLevelInfo = Struct.new(:access_level, :member_role) do + def to_i + access_level + end + end + prepended do field :security_scanners, ::Types::SecurityScanners, null: true, @@ -771,6 +777,23 @@ def container_protection_tag_rules # mutable tag rules come first before immutable super + object.container_registry_protection_tag_rules.immutable end + + override :max_access_level + def max_access_level + return ::Gitlab::Access::NO_ACCESS if current_user.nil? + + BatchLoader::GraphQL.for(object.id).batch do |project_ids, loader| + access_levels = current_user.max_member_access_for_project_ids(project_ids) + member_roles = current_user.member_roles_for_project_ids(project_ids) + + project_ids.each do |project_id| + access_level = access_levels[project_id] + member_role = member_roles[project_id] + access_info = AccessLevelInfo.new(access_level, member_role) + loader.call(project_id, access_info) + end + end + end end end end diff --git a/ee/app/graphql/ee/types/projects/access_level_type.rb b/ee/app/graphql/ee/types/projects/access_level_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..b8f5ce1159780227b2c72cbc9b258decf556bfcc --- /dev/null +++ b/ee/app/graphql/ee/types/projects/access_level_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module EE + module Types + module Projects + module AccessLevelType + extend ActiveSupport::Concern + extend ::Gitlab::Utils::Override + + override :human_access + def human_access + if object.respond_to?(:member_role) && object.respond_to?(:access_level) + ::Gitlab::Access.human_access_with_none(object.access_level, object.member_role) + else + super + end + end + end + end + end +end diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb index 35f245cb5ab5008f061c2ca4fdedfd40435b0549..e8a7efa09156369c26ce54529d3f1f0385d8c604 100644 --- a/ee/app/models/ee/user.rb +++ b/ee/app/models/ee/user.rb @@ -329,6 +329,17 @@ def filter_items(filter_name) end end + def member_roles_for_project_ids(project_ids) + return {} if project_ids.empty? + + direct_roles = find_direct_project_custom_roles(project_ids) + + remaining_ids = project_ids - direct_roles.keys + root_group_roles = find_root_group_custom_roles(remaining_ids) + + direct_roles.merge(root_group_roles) + end + def should_use_security_policy_bot_avatar? security_policy_bot? end @@ -786,6 +797,43 @@ def ci_namespace_mirrors_permitted_to(permission) ) end + def find_direct_project_custom_roles(project_ids) + return {} if project_ids.empty? + + project_members.joins(:member_role) + .where(source_id: project_ids) + .includes(:member_role) + .index_by(&:source_id) + .transform_values(&:member_role) + end + + def find_root_group_custom_roles(project_ids) + return {} if project_ids.empty? + + project_root_group_mapping = ::Project + .joins(:group) + .where(id: project_ids) + .select('projects.id as project_id, COALESCE(namespaces.traversal_ids[1], namespaces.id) as root_group_id') + + root_group_ids = project_root_group_mapping.map(&:root_group_id).uniq + + root_group_custom_roles = user_group_member_roles + .joins(:member_role) + .where(group_id: root_group_ids, shared_with_group_id: nil) + .includes(:member_role) + .index_by(&:group_id) + + result = {} + project_root_group_mapping.each do |project_record| + project_id, root_group_id = project_record.attributes.values_at('project_id', 'root_group_id') + + user_membership = root_group_custom_roles[root_group_id] + result[project_id] = user_membership.member_role if user_membership + end + + result + end + def enterprise_user_email_change return if user_detail.enterprise_group.owner_of_email?(email) diff --git a/ee/spec/graphql/types/project_type_spec.rb b/ee/spec/graphql/types/project_type_spec.rb index e9224ebda52ade3960177f1d70965cfa45de3fc1..01c3650992cd6a7e4a53006f7e7dceb76e023ca5 100644 --- a/ee/spec/graphql/types/project_type_spec.rb +++ b/ee/spec/graphql/types/project_type_spec.rb @@ -1058,4 +1058,39 @@ it { is_expected.to have_graphql_resolver(Resolvers::Ai::DuoWorkflows::WorkflowEventsResolver) } it { is_expected.to have_graphql_arguments(:workflow_id) } end + + describe 'max_access_level' do + let_it_be(:custom_group) { create(:group) } + let_it_be(:custom_project) { create(:project, group: custom_group) } + let_it_be(:custom_user) { create(:user) } + let_it_be(:guest_custom_role) { create(:member_role, :guest, :admin_protected_branch, namespace: custom_group, name: "Custom Guest Role") } + let_it_be(:user_membership) { create(:project_member, :guest, member_role: guest_custom_role, user: custom_user, project: custom_project) } + + let(:access_level_query) do + %( + query { + project(fullPath: "#{custom_project.full_path}") { + maxAccessLevel { + integerValue + stringValue + humanAccess + } + } + } + ) + end + + subject do + GitlabSchema.execute(access_level_query, context: { current_user: custom_user }).as_json + .dig('data', 'project', 'maxAccessLevel') + end + + it 'returns custom role name with guest access level' do + expect(subject).to eq({ + 'integerValue' => Gitlab::Access::GUEST, + 'stringValue' => 'GUEST', + 'humanAccess' => guest_custom_role.name + }) + end + end end diff --git a/ee/spec/models/ee/user_spec.rb b/ee/spec/models/ee/user_spec.rb index befcfe84c951e66fd0785167b204e5de9628f215..29fcef94109751d96aa4838aefc88fb299e7105b 100644 --- a/ee/spec/models/ee/user_spec.rb +++ b/ee/spec/models/ee/user_spec.rb @@ -4107,4 +4107,78 @@ end end end + + describe '#member_roles_for_project_ids' do + let_it_be(:user) { create(:user, :with_namespace) } + let_it_be(:group) { create(:group) } + let_it_be(:project1) { create(:project, group: group) } + let_it_be(:project2) { create(:project, group: group) } + let_it_be(:member_role) { create(:member_role, namespace: group) } + + context 'when projects do not exist' do + it 'returns empty hash' do + result = user.member_roles_for_project_ids([non_existing_record_id]) + expect(result).to eq({}) + end + end + + context 'when projects are in personal namespace' do + let_it_be(:personal_user) { create(:user, :with_namespace) } + let_it_be(:personal_project) { create(:project, namespace: personal_user.namespace) } + + it 'returns empty hash' do + result = personal_user.member_roles_for_project_ids([personal_project.id]) + expect(result).to eq({}) + end + end + + context 'when user has no membership' do + it 'returns empty hash' do + result = user.member_roles_for_project_ids([project1.id, project2.id]) + expect(result).to eq({}) + end + end + + context 'when user has direct project membership with member role' do + before do + create(:project_member, user: user, source: project1, member_role: member_role) + end + + it 'returns the project member role' do + result = user.member_roles_for_project_ids([project1.id, project2.id]) + expect(result).to eq({ project1.id => member_role }) + end + end + + context 'when user has group membership with member role' do + before do + create(:user_group_member_role, user: user, group: group, member_role: member_role) + end + + it 'returns the group member role for all group projects' do + result = user.member_roles_for_project_ids([project1.id, project2.id]) + expect(result).to eq({ + project1.id => member_role, + project2.id => member_role + }) + end + end + + context 'when user has both direct and group memberships' do + let_it_be(:direct_role) { create(:member_role, namespace: group, name: 'Direct Role') } + + before do + create(:project_member, user: user, source: project1, member_role: direct_role) + create(:user_group_member_role, user: user, group: group, member_role: member_role) + end + + it 'prioritizes direct project member role' do + result = user.member_roles_for_project_ids([project1.id, project2.id]) + expect(result).to eq({ + project1.id => direct_role, + project2.id => member_role + }) + end + end + end end diff --git a/spec/frontend/groups/show/graphql/resolvers_spec.js b/spec/frontend/groups/show/graphql/resolvers_spec.js index 9c69ae450d625c2154c2b45218d7bd6f9f2b70a0..fb5813d4b7cf189d7350b77ff6571b1db7357207 100644 --- a/spec/frontend/groups/show/graphql/resolvers_spec.js +++ b/spec/frontend/groups/show/graphql/resolvers_spec.js @@ -90,7 +90,7 @@ describe('groups show resolver', () => { removeProject: true, viewEditPage: true, }, - maxAccessLevel: { integerValue: 0 }, + maxAccessLevel: { integerValue: 0, humanAccess: null }, isCatalogResource: false, exploreCatalogPath: '', pipeline: null, @@ -129,7 +129,7 @@ describe('groups show resolver', () => { removeProject: true, viewEditPage: true, }, - maxAccessLevel: { integerValue: 0 }, + maxAccessLevel: { integerValue: 0, humanAccess: null }, isCatalogResource: false, exploreCatalogPath: '', pipeline: null, @@ -202,7 +202,7 @@ describe('groups show resolver', () => { removeProject: true, viewEditPage: true, }, - maxAccessLevel: { integerValue: 0 }, + maxAccessLevel: { integerValue: 0, humanAccess: null }, isCatalogResource: false, exploreCatalogPath: '', pipeline: null, @@ -275,7 +275,7 @@ describe('groups show resolver', () => { removeProject: true, viewEditPage: true, }, - maxAccessLevel: { integerValue: 0 }, + maxAccessLevel: { integerValue: 0, humanAccess: null }, isCatalogResource: false, exploreCatalogPath: '', pipeline: null,