diff --git a/app/models/namespace.rb b/app/models/namespace.rb index cf9077f3f8bd52f3f56a206896e2f3117bcbc82d..48cad7f12f648ea927fff5e7bea8c8d85cf9dbee 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -412,6 +412,12 @@ def self_or_ancestors_archived_setting_subquery ) .where(namespace_setting_table[:archived].eq(true)) end + + def root_namespace_ids + # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- False positive: MAX_PLUCK ensures we have a limit + limit(Namespace::MAX_PLUCK).distinct.pluck(Arel.sql('traversal_ids[1]')) + # rubocop:enable Database/AvoidUsingPluckWithoutLimit + end end def archived? diff --git a/app/models/project.rb b/app/models/project.rb index 5d55da4dd0e016c25acff202c4502676acf3f0ec..1a0f5722e046e4ba04a68c4be5601ed0ac7f5519 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1344,6 +1344,10 @@ def group_by_namespace_traversal_ids(project_batch) .group_by(&:last) .transform_values { |projects| projects.map(&:first) } end + + def root_namespace_ids + joins(:namespace).limit(Project::MAX_PLUCK).distinct.pluck(Arel.sql('namespaces.traversal_ids[1]')) + end end def initialize(attributes = nil) diff --git a/config/authz/permissions/security_scan_profiles/apply.yml b/config/authz/permissions/security_scan_profiles/apply.yml new file mode 100644 index 0000000000000000000000000000000000000000..e3db4b4f3296b85cc2d470ca2de0059f85c665a9 --- /dev/null +++ b/config/authz/permissions/security_scan_profiles/apply.yml @@ -0,0 +1,4 @@ +--- +name: apply_security_scan_profiles +description: Apply security scan profiles. +feature_category: security_asset_inventories diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 9d669ffed4fe392572a99563b3c1181e1df54c5d..75b2b7740e00de0868e58f61a33fb9be77d52250 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -12417,6 +12417,31 @@ Input type: `SecurityPolicyProjectUnassignInput` | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. | +### `Mutation.securityScanProfileAttach` + +{{< details >}} +**Introduced** in GitLab 18.7. +**Status**: Experiment. +{{< /details >}} + +Input type: `SecurityScanProfileAttachInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `groupIds` | [`[GroupID!]`](#groupid) | Group IDs to attach the profile to. | +| `projectIds` | [`[ProjectID!]`](#projectid) | Project IDs to attach the profile to. | +| `securityScanProfileId` | [`SecurityScanProfileID!`](#securityscanprofileid) | Security scan profile ID to attach. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. | + ### `Mutation.securityTrainingUpdate` Input type: `SecurityTrainingUpdateInput` @@ -52752,6 +52777,7 @@ Member role permission. | `ADMIN_TERRAFORM_STATE` | Execute terraform commands, lock/unlock terraform state files, and remove file versions. | | `ADMIN_VULNERABILITY` | Edit the status, linked issue, and severity of a vulnerability object. Also requires the `read_vulnerability` permission. | | `ADMIN_WEB_HOOK` | Manage webhooks. | +| `APPLY_SECURITY_SCAN_PROFILES` | Apply security scan profiles. | | `ARCHIVE_PROJECT` | Allows archiving of projects. | | `MANAGE_DEPLOY_TOKENS` | Manage deploy tokens at the group or project level. | | `MANAGE_GROUP_ACCESS_TOKENS` | Create, read, update, and delete group access tokens. When creating a token, users with this custom permission must select a role for that token that has the same or fewer permissions as the default role used as the base for the custom role. | @@ -52796,6 +52822,7 @@ Member role standard permission. | `ADMIN_TERRAFORM_STATE` | Execute terraform commands, lock/unlock terraform state files, and remove file versions. | | `ADMIN_VULNERABILITY` | Edit the status, linked issue, and severity of a vulnerability object. Also requires the `read_vulnerability` permission. | | `ADMIN_WEB_HOOK` | Manage webhooks. | +| `APPLY_SECURITY_SCAN_PROFILES` | Apply security scan profiles. | | `ARCHIVE_PROJECT` | Allows archiving of projects. | | `MANAGE_DEPLOY_TOKENS` | Manage deploy tokens at the group or project level. | | `MANAGE_GROUP_ACCESS_TOKENS` | Create, read, update, and delete group access tokens. When creating a token, users with this custom permission must select a role for that token that has the same or fewer permissions as the default role used as the base for the custom role. | diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index 8d26fce8f89d012ad6a71e8bf7863811e0ce2d9b..f5dfbd3ab03d0262ab3003395551a1bd397d52d6 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -53009,6 +53009,8 @@ definitions: - 30 - 40 example: 40 + apply_security_scan_profiles: + type: boolean admin_merge_request: type: boolean archive_project: diff --git a/doc/user/custom_roles/abilities.md b/doc/user/custom_roles/abilities.md index 5c33d9ec1df2dea7d1c442ee5498657da97435d7..9d4482e21461f78f5b6da28fbbb93b7c95b9b9c2 100644 --- a/doc/user/custom_roles/abilities.md +++ b/doc/user/custom_roles/abilities.md @@ -102,6 +102,7 @@ Any dependencies are noted in the `Description` column for each permission. | Permission | Description | API Attribute | Scope | Introduced | |:-----------|:------------|:--------------|:------|:-----------| +| Apply security scan profiles | Apply security scan profiles. | [`apply_security_scan_profiles`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/215433) | Group,
Project | GitLab [18.7](https://gitlab.com/groups/gitlab-org/-/epics/19802) | | Read security scan profiles | Read security scan profiles. | [`read_security_scan_profiles`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/213203) | Group,
Project | GitLab [18.7](https://gitlab.com/groups/gitlab-org/-/epics/19802) | ## Security policy management diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 8b0f72b5548a7cc512f51956c0943620d03ffa9e..cf01147b0ee1b664e7115bd9a6ed17a17b3bba01 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -215,6 +215,7 @@ def self.authorization_scopes mount_mutation ::Mutations::Security::Categories::Create mount_mutation ::Mutations::Security::Categories::Update mount_mutation ::Mutations::Security::Categories::Destroy + mount_mutation ::Mutations::Security::ScanProfiles::Attach, experiment: { milestone: '18.7' } mount_mutation ::Mutations::Users::Abuse::NamespaceBans::Destroy mount_mutation ::Mutations::Users::MemberRoles::Assign, experiment: { milestone: '17.7' } mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Create diff --git a/ee/app/graphql/mutations/security/scan_profiles/attach.rb b/ee/app/graphql/mutations/security/scan_profiles/attach.rb new file mode 100644 index 0000000000000000000000000000000000000000..9ee6d29a4a1f64baeb8e3c14c41c926bc18dbb17 --- /dev/null +++ b/ee/app/graphql/mutations/security/scan_profiles/attach.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Mutations + module Security + module ScanProfiles + class Attach < BaseMutation + graphql_name 'SecurityScanProfileAttach' + MAX_IDS = 100 + + argument :security_scan_profile_id, Types::GlobalIDType[::Security::ScanProfile], + required: true, + description: 'Security scan profile ID to attach.', + prepare: ->(global_id, _ctx) { global_id.model_id } + + argument :project_ids, [Types::GlobalIDType[::Project]], + required: false, + description: 'Project IDs to attach the profile to.', + prepare: ->(global_ids, _ctx) { global_ids.map(&:model_id) } + + argument :group_ids, [Types::GlobalIDType[::Group]], + required: false, + description: 'Group IDs to attach the profile to.', + prepare: ->(global_ids, _ctx) { global_ids.map(&:model_id) } + + def resolve(security_scan_profile_id:, project_ids: nil, group_ids: nil) + validate_id_limit!(project_ids, group_ids) + + root_namespace = shared_root_namespace!(project_ids, group_ids) + raise_resource_not_available_error! unless Feature.enabled?(:security_scan_profiles_feature, root_namespace) + + authorized_projects = load_and_authorize_projects(project_ids) + authorized_groups = load_and_authorize_groups(group_ids) + if (authorized_projects.size != project_ids.to_a.size) || (authorized_groups.size != group_ids.to_a.size) + raise_resource_not_available_error! + end + + profile = resolve_profile(security_scan_profile_id, root_namespace) + raise_resource_not_available_error! unless profile + + result = ::Security::ScanProfiles::AttachService.execute(profile: profile, projects: authorized_projects) + schedule_group_workers(authorized_groups, profile) + + { + errors: result[:errors] + } + end + + private + + def resolve_profile(security_scan_profile_id, root_namespace) + if Enums::Security.scan_profile_types.key?(security_scan_profile_id.to_sym) + profile_result = ::Security::ScanProfiles::FindOrCreateService.execute( + namespace: root_namespace, + scan_type: security_scan_profile_id.to_sym + ) + + profile_result.payload[:scan_profile] if profile_result.success? + else + ::Security::ScanProfile.by_namespace(root_namespace).id_in(security_scan_profile_id).first + end + end + + def load_and_authorize_projects(project_ids) + return [] unless project_ids&.any? + + all_project = Project.id_in(project_ids) + Project.projects_user_can(all_project, current_user, :apply_security_scan_profiles) + end + + def load_and_authorize_groups(group_ids) + return [] unless group_ids&.any? + + all_groups = Group.id_in(group_ids) + Group.groups_user_can(all_groups, current_user, :apply_security_scan_profiles) + end + + def schedule_group_workers(groups, profile) + return if groups.empty? + + # groups.each do |group| + # SecurityScanProfiles::AttachWorker.perform_async( + # group.id, + # profile.id + # ) + # end + + groups && profile # for rubocop + end + + def validate_id_limit!(project_ids, group_ids) + total = project_ids.to_a.size + group_ids.to_a.size + return if total <= MAX_IDS + + raise Gitlab::Graphql::Errors::ArgumentError, "Too many ids (maximum: #{MAX_IDS})" + end + + def shared_root_namespace!(project_ids, group_ids) + root_namespace_ids = [] + root_namespace_ids += Project.id_in(project_ids).root_namespace_ids if project_ids&.any? + root_namespace_ids += Group.id_in(group_ids).root_namespace_ids if group_ids&.any? + root_namespace_ids.uniq! + + if root_namespace_ids.empty? || root_namespace_ids.size > 1 + raise Gitlab::Graphql::Errors::ArgumentError, "All items should belong to the same root namespace" + end + + Group.find(root_namespace_ids.first) + end + end + end + end +end diff --git a/ee/app/models/security/scan_profile_project.rb b/ee/app/models/security/scan_profile_project.rb index 664ba25548130916b18c64b0ec55aaf879edcaca..44448bf4804c0d9d8c772d3328c00e09706ed566 100644 --- a/ee/app/models/security/scan_profile_project.rb +++ b/ee/app/models/security/scan_profile_project.rb @@ -4,6 +4,8 @@ module Security class ScanProfileProject < ::SecApplicationRecord self.table_name = 'security_scan_profiles_projects' + MAX_PROFILES_PER_PROJECT = 10 + belongs_to :project, optional: false belongs_to :scan_profile, class_name: 'Security::ScanProfile', foreign_key: :security_scan_profile_id, inverse_of: :scan_profile_projects, optional: false @@ -13,5 +15,12 @@ class ScanProfileProject < ::SecApplicationRecord scope :not_in_root_namespace, ->(root_namespace) { joins(:scan_profile).where.not(security_scan_profiles: { namespace: root_namespace }) } + scope :by_scan_profile, ->(profile_id) { where(security_scan_profile_id: profile_id) } + scope :by_project, ->(project_ids) { where(project_id: project_ids) } + + def self.project_ids_at_limit + group(:project_id).having('COUNT(*) >= ?', MAX_PROFILES_PER_PROJECT) + .limit(MAX_PLUCK).pluck(:project_id) + end end end diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb index 94719f5e4862a9681ef27ebefbbbbe4a4af90fe1..188c72a54519285b3a4cf9fad9f557de44b0a030 100644 --- a/ee/app/policies/ee/group_policy.rb +++ b/ee/app/policies/ee/group_policy.rb @@ -729,8 +729,15 @@ module GroupPolicy rule { custom_role_enables_read_security_scan_profiles }.enable(:read_security_scan_profiles) + rule { can?(:maintainer_access) }.policy do + enable :apply_security_scan_profiles + end + + rule { custom_role_enables_apply_security_scan_profiles }.enable(:apply_security_scan_profiles) + rule { ~security_scan_profiles_available }.policy do prevent :read_security_scan_profiles + prevent :apply_security_scan_profiles end rule { ~security_inventory_available }.prevent :read_security_inventory diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index 3d1c5f9582e27e305032353fe802a4b85443180f..44f439ed15e75b7505e0d7dd810afa76e6a3ce65 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -610,8 +610,15 @@ module ProjectPolicy rule { custom_role_enables_read_security_scan_profiles }.enable(:read_security_scan_profiles) + rule { can?(:maintainer_access) }.policy do + enable :apply_security_scan_profiles + end + + rule { custom_role_enables_apply_security_scan_profiles }.enable(:apply_security_scan_profiles) + rule { ~security_scan_profiles_available }.policy do prevent :read_security_scan_profiles + prevent :apply_security_scan_profiles end rule { ~validity_checks_available }.policy do diff --git a/ee/app/services/security/scan_profiles/attach_service.rb b/ee/app/services/security/scan_profiles/attach_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..1efbe778e2eb41f5859248195483303e7e4cb8ca --- /dev/null +++ b/ee/app/services/security/scan_profiles/attach_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Security + module ScanProfiles + class AttachService + MAX_PROJECTS = 500 + TooManyProjectsError = Class.new(StandardError) + + def self.execute(profile:, projects: []) + new(profile: profile, projects: projects).execute + end + + def initialize(profile:, projects: []) + if projects.size > MAX_PROJECTS + raise TooManyProjectsError, "Cannot attach profile to more than #{MAX_PROJECTS} items at once." + end + + @profile = profile + @projects = projects + @errors = [] + end + + def execute + return error_result('At least one project must be provided') if projects.empty? + + attach_to_projects + + { + errors: errors + } + rescue StandardError => e + error_result(e.message) + end + + attr_reader :profile, :projects, :errors + + private + + def attach_to_projects + return [] if projects.empty? + + project_ids_at_limit = Security::ScanProfileProject.by_project(projects.map(&:id)).project_ids_at_limit + projects_at_limit = projects.select { |p| project_ids_at_limit.include?(p.id) } + projects_to_attach = projects.reject { |p| project_ids_at_limit.include?(p.id) } + + projects_at_limit.each do |project| + errors << "Project #{project.id} has reached the maximum limit of scan profiles" + end + + bulk_attach_to_projects(projects_to_attach) if projects_to_attach.any? + end + + def bulk_attach_to_projects(projects_to_attach) + timestamp = Time.current + + records = projects_to_attach.map do |project| + { + project_id: project.id, + security_scan_profile_id: profile.id, + created_at: timestamp, + updated_at: timestamp + } + end + + Security::ScanProfileProject.insert_all(records, unique_by: [:project_id, :security_scan_profile_id]) + end + + def error_result(message) + { + errors: [message] + } + end + end + end +end diff --git a/ee/app/services/security/scan_profiles/find_or_create_service.rb b/ee/app/services/security/scan_profiles/find_or_create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..e95b901668efa7f8c4662285ca9f4a134c4d119f --- /dev/null +++ b/ee/app/services/security/scan_profiles/find_or_create_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Security + module ScanProfiles + class FindOrCreateService + def self.execute(namespace:, scan_type:) + new(namespace: namespace, scan_type: scan_type).execute + end + + def initialize(namespace:, scan_type:) + @namespace = namespace + @scan_type = scan_type + end + + def execute + return error_response('Namespace must be a root namespace') unless namespace.root? + + profile = Security::ScanProfile.by_namespace(namespace).by_type(scan_type).by_gitlab_recommended.first + return success_response(profile) if profile + + profile = create_profile + return error_response('Could not find a default scan profile for this type') unless profile.present? + + success_response(profile) + end + + private + + attr_reader :namespace, :scan_type + + def create_profile + default_profile = Security::DefaultScanProfilesHelper.default_scan_profiles.find do |profile| + profile.scan_type == scan_type.to_s + end + return unless default_profile + + default_profile.namespace = namespace + default_profile.save! + default_profile + end + + def success_response(profile) + ServiceResponse.success(payload: { scan_profile: profile }) + end + + def error_response(message) + ServiceResponse.error(message: message) + end + end + end +end diff --git a/ee/config/custom_abilities/apply_security_scan_profiles.yml b/ee/config/custom_abilities/apply_security_scan_profiles.yml new file mode 100644 index 0000000000000000000000000000000000000000..aacb99e5c0b5579327b675fc9bc1879ae05d9609 --- /dev/null +++ b/ee/config/custom_abilities/apply_security_scan_profiles.yml @@ -0,0 +1,12 @@ +--- +title: Apply security scan profiles +name: apply_security_scan_profiles +description: Apply security scan profiles. +introduced_by_issue: https://gitlab.com/groups/gitlab-org/-/epics/19802 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/215433 +feature_category: security_asset_inventories +milestone: '18.7' +group_ability: true +enabled_for_group_access_levels: [40, 50] +project_ability: true +enabled_for_project_access_levels: [40, 50] diff --git a/ee/config/feature_flags/wip/security_scan_profiles_feature.yml b/ee/config/feature_flags/wip/security_scan_profiles_feature.yml new file mode 100644 index 0000000000000000000000000000000000000000..5522daf460253972ec5cc2a6072ec60a348b626f --- /dev/null +++ b/ee/config/feature_flags/wip/security_scan_profiles_feature.yml @@ -0,0 +1,10 @@ +--- +name: security_scan_profiles_feature +description: +feature_issue_url: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/215433 +rollout_issue_url: +milestone: '18.7' +group: group::security platform management +type: wip +default_enabled: false diff --git a/ee/spec/models/security/scan_profile_project_spec.rb b/ee/spec/models/security/scan_profile_project_spec.rb index 7418568270d0b5aae905961751611f34fffe4fa7..ca98b213529d122c6390172fc9f5e101429c0a1e 100644 --- a/ee/spec/models/security/scan_profile_project_spec.rb +++ b/ee/spec/models/security/scan_profile_project_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Security::ScanProfileProject, feature_category: :security_asset_inventories do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, namespace: group) } - let_it_be(:scan_profile) { create(:security_scan_profile, namespace: group) } + let_it_be(:scan_profile) { create(:security_scan_profile, namespace: group, name: 'Profile 1') } describe 'associations' do it { is_expected.to belong_to(:scan_profile).class_name('Security::ScanProfile').required } @@ -19,33 +19,78 @@ end describe 'scopes' do - describe '.not_in_root_namespace' do - let_it_be(:root_namespace) { create(:group) } - let_it_be(:other_namespace) { create(:group) } - let_it_be(:project_in_root) { create(:project, namespace: root_namespace) } - let_it_be(:project_in_other) { create(:project, namespace: other_namespace) } + let_it_be(:root_namespace) { create(:group) } + let_it_be(:other_namespace) { create(:group) } - let_it_be(:scan_profile_in_root) { create(:security_scan_profile, namespace: root_namespace) } - let_it_be(:scan_profile_in_other) { create(:security_scan_profile, namespace: other_namespace) } + let_it_be(:project) { create(:project, namespace: root_namespace) } + let_it_be(:project2) { create(:project, namespace: root_namespace) } + let_it_be(:project_in_other) { create(:project, namespace: other_namespace) } - let_it_be(:association_in_root) do - create(:security_scan_profile_project, scan_profile: scan_profile_in_root, project: project_in_root) - end + let_it_be(:scan_profile) { create(:security_scan_profile, namespace: root_namespace) } + let_it_be(:scan_profile2) { create(:security_scan_profile, namespace: root_namespace, name: 'Profile 2') } + let_it_be(:scan_profile_in_other) { create(:security_scan_profile, namespace: other_namespace) } - let_it_be(:association_in_other) do - create(:security_scan_profile_project, scan_profile: scan_profile_in_other, project: project_in_other) - end + let_it_be(:profile_project1) do + create(:security_scan_profile_project, scan_profile: scan_profile, project: project) + end + + let_it_be(:profile_project2) do + create(:security_scan_profile_project, scan_profile: scan_profile2, project: project2) + end + let_it_be(:association_in_other) do + create(:security_scan_profile_project, scan_profile: scan_profile_in_other, project: project_in_other) + end + + describe '.not_in_root_namespace' do it 'returns associations where scan profile is not in the given root namespace' do result = described_class.not_in_root_namespace(root_namespace) expect(result).to contain_exactly(association_in_other) end - it 'returns empty when all associations are in the root namespace' do + it 'returns associations not in the specified namespace' do result = described_class.not_in_root_namespace(other_namespace) - expect(result).to contain_exactly(association_in_root) + expect(result).to contain_exactly(profile_project1, profile_project2) + end + end + + describe '.by_scan_profile' do + it 'returns correct associations' do + expect(described_class.by_scan_profile(scan_profile.id)).to contain_exactly(profile_project1) + expect(described_class.by_scan_profile(scan_profile2.id)).to contain_exactly(profile_project2) + expect(described_class.by_scan_profile(nil)).to be_empty + end + end + + describe '.by_project' do + it 'returns correct associations' do + expect(described_class.by_project(project.id)).to contain_exactly(profile_project1) + expect(described_class.by_project(project2.id)).to contain_exactly(profile_project2) + expect(described_class.by_project([project.id, project2.id])) + .to contain_exactly(profile_project1, profile_project2) + expect(described_class.by_project(nil)).to be_empty + end + end + end + + describe 'class methods' do + describe '.project_ids_at_limit' do + let_it_be(:project_at_limit) { create(:project, namespace: group) } + let_it_be(:project_under_limit) { create(:project, namespace: group) } + let_it_be(:scan_profile2) { create(:security_scan_profile, namespace: group, name: 'Profile 2') } + + before do + stub_const("#{described_class}::MAX_PROFILES_PER_PROJECT", 2) + + create(:security_scan_profile_project, scan_profile: scan_profile, project: project_at_limit) + create(:security_scan_profile_project, scan_profile: scan_profile2, project: project_at_limit) + create(:security_scan_profile_project, scan_profile: scan_profile, project: project_under_limit) + end + + it 'returns project ids that have reached the limit' do + expect(described_class.project_ids_at_limit).to contain_exactly(project_at_limit.id) end end end diff --git a/ee/spec/models/security/scan_profile_spec.rb b/ee/spec/models/security/scan_profile_spec.rb index c6534ea58fbab3466dd972d3fa11745169cc1aa8..b27b73b0d868763d7becbedd92054bbb46c68435 100644 --- a/ee/spec/models/security/scan_profile_spec.rb +++ b/ee/spec/models/security/scan_profile_spec.rb @@ -52,6 +52,24 @@ end end + describe 'scopes' do + describe '.by_gitlab_recommended' do + let_it_be(:profile) { create(:security_scan_profile, namespace: root_level_group, scan_type: :secret_detection) } + let_it_be(:gitlab_recommended_profile) do + create(:security_scan_profile, + namespace: root_level_group, + scan_type: :secret_detection, + gitlab_recommended: true, + name: "gitlab_recommended_profile" + ) + end + + it 'returns gitlab recommended profiles' do + expect(described_class.by_gitlab_recommended).to match_array([gitlab_recommended_profile]) + end + end + end + context 'with loose foreign key on security_scan_profiles.namespace_id' do it_behaves_like 'cleanup by a loose foreign key' do let_it_be(:parent) { root_level_group } diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb index 9663be459d6dac26a72122568422776e71c63102..994913212b124aa0541b5e38c9c78aa3079ee42d 100644 --- a/ee/spec/policies/group_policy_spec.rb +++ b/ee/spec/policies/group_policy_spec.rb @@ -4475,6 +4475,16 @@ def create_member_role(member, abilities = member_role_abilities) it_behaves_like 'does not call custom role query', [:developer, :maintainer, :owner] end + + context 'for a member role with the `apply_security_scan_profiles` ability' do + let(:licensed_features) { { security_scan_profiles: true } } + let(:member_role_abilities) { { apply_security_scan_profiles: true } } + let(:allowed_abilities) { [:apply_security_scan_profiles] } + + it_behaves_like 'custom roles abilities' + + it_behaves_like 'does not call custom role query', [:developer, :maintainer, :owner] + end end end end @@ -5276,6 +5286,50 @@ def create_member_role(member, abilities = member_role_abilities) end end + describe 'apply_security_scan_profiles' do + let(:policy) { :apply_security_scan_profiles } + + context 'when security_scan_profiles is available' do + before do + stub_licensed_features(security_scan_profiles: true) + enable_admin_mode!(current_user) if role == :admin + end + + where(:role, :allowed) do + :guest | false + :planner | false + :reporter | false + :developer | false + :maintainer | true + :owner | true + :admin | true + :auditor | false + end + + with_them do + let(:current_user) { public_send(role) } + + it { is_expected.to(allowed ? be_allowed(policy) : be_disallowed(policy)) } + end + end + + context 'when security_scan_profiles is not available' do + where(:role) do + [:guest, :planner, :reporter, :developer, :maintainer, :auditor, :owner, :admin] + end + + with_them do + let(:current_user) { public_send(role) } + + before do + enable_admin_mode!(current_user) if role == :admin + end + + it { is_expected.to be_disallowed(policy) } + end + end + end + describe 'AI catalog abilities' do let(:ai_catalog_available) { true } let(:flows_available) { true } diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index 92f3f31a84d2734c74072f20921a25e2c6c09c0e..4040ccddfcac27eac245ce91f6dea99dcfb460c7 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -1097,6 +1097,50 @@ end end + describe 'apply_security_scan_profiles' do + let(:policy) { :apply_security_scan_profiles } + + context 'when security_scan_profiles is available' do + before do + stub_licensed_features(security_scan_profiles: true) + enable_admin_mode!(current_user) if role == :admin + end + + where(:role, :allowed) do + :guest | false + :planner | false + :reporter | false + :developer | false + :maintainer | true + :owner | true + :admin | true + :auditor | false + end + + with_them do + let(:current_user) { public_send(role) } + + it { is_expected.to(allowed ? be_allowed(policy) : be_disallowed(policy)) } + end + end + + context 'when security_scan_profiles is not available' do + where(:role) do + [:guest, :planner, :reporter, :developer, :maintainer, :auditor, :owner, :admin] + end + + with_them do + let(:current_user) { public_send(role) } + + before do + enable_admin_mode!(current_user) if role == :admin + end + + it { is_expected.to be_disallowed(policy) } + end + end + end + describe 'remove_project when default_project_deletion_protection is set to true' do before do stub_application_setting(default_project_deletion_protection: true) diff --git a/ee/spec/requests/api/graphql/mutations/security/scan_profiles/attach_spec.rb b/ee/spec/requests/api/graphql/mutations/security/scan_profiles/attach_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2b1eb51d50e0bdd66b0445948c0ca659d4bc02e6 --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/security/scan_profiles/attach_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'SecurityScanProfileAttach', feature_category: :security_asset_inventories do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:root_group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: root_group) } + let_it_be(:another_root_group) { create(:group) } + let_it_be(:project1) { create(:project, namespace: root_group) } + let_it_be(:project2) { create(:project, namespace: root_group) } + let_it_be(:subgroup_project) { create(:project, namespace: subgroup) } + let_it_be(:group1) { create(:group, parent: root_group) } + let_it_be(:profile) do + create(:security_scan_profile, + namespace: root_group, + scan_type: :secret_detection, + name: 'Test Profile') + end + + let(:security_scan_profile_id) { profile.to_global_id.to_s } + let(:project_ids) { [project1.to_global_id.to_s, project2.to_global_id.to_s] } + let(:group_ids) { nil } + + let(:mutation) do + graphql_mutation( + :security_scan_profile_attach, + { + security_scan_profile_id: security_scan_profile_id, + project_ids: project_ids, + group_ids: group_ids + }.compact + ) + end + + def mutation_result + graphql_mutation_response(:security_scan_profile_attach) + end + + describe 'GraphQL mutation' do + context 'when security_scan_profiles feature is not available' do + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when security_scan_profiles feature is available' do + before do + stub_licensed_features(security_scan_profiles: true) + end + + context 'when user does not have permission' do + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permission' do + before_all do + root_group.add_maintainer(current_user) + end + + context 'when security_scan_profiles_feature feature flag is disabled' do + before do + stub_feature_flags(security_scan_profiles_feature: false) + end + + it 'returns a top-level access error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + end + end + + context 'when security_scan_profiles_feature feature flag is enabled' do + context 'with persisted profile id' do + it 'attaches the profile to projects' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .to change { Security::ScanProfileProject.count }.by(2) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_result['errors']).to be_empty + expect(Security::ScanProfileProject.by_project(project1).by_scan_profile(profile)).to exist + expect(Security::ScanProfileProject.by_project(project2).by_scan_profile(profile)).to exist + end + end + + context 'with template based profile id (scan type)' do + let(:security_scan_profile_id) { 'gid://gitlab/Security::ScanProfile/secret_detection' } + let(:project_ids) { [project1.to_global_id.to_s] } + + context 'when gitlab_recommended profile does not exist' do + it 'creates a new gitlab_recommended profile and attaches it' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .to change { Security::ScanProfile.by_type("secret_detection").by_gitlab_recommended.count }.by(1) + .and change { Security::ScanProfileProject.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + scan_profile = Security::ScanProfile.by_type("secret_detection").by_gitlab_recommended.first + expect(Security::ScanProfileProject.by_project(project1).by_scan_profile(scan_profile)).to exist + end + end + + context 'when gitlab_recommended profile already exists' do + let_it_be(:existing_recommended_profile) do + create(:security_scan_profile, + namespace: root_group, + scan_type: :secret_detection, + gitlab_recommended: true) + end + + it 'does not create a new profile' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .not_to change { Security::ScanProfile.count } + end + + it 'attaches the existing profile' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(Security::ScanProfileProject.by_project(project1) + .by_scan_profile(existing_recommended_profile)).to exist + end + end + end + + context 'with invalid scan type' do + let(:security_scan_profile_id) { 'invalid_type' } + let(:project_ids) { [project1.to_global_id.to_s] } + + it 'returns GraphQL error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + end + end + + context 'with groups in items' do + let(:project_ids) { nil } + let(:group_ids) { [group1.to_global_id.to_s] } + + # it 'enqueues worker for groups' do + # expect(SecurityScanProfiles::AttachWorker).to receive(:perform_async).with(group1.id, profile.id) + # + # post_graphql_mutation(mutation, current_user: current_user) + # + # expect(response).to have_gitlab_http_status(:success) + # end + + it 'does not create project associations' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .not_to change { Security::ScanProfileProject.count } + end + end + + context 'with mixed groups and projects' do + let(:project_ids) { [project1.to_global_id.to_s] } + let(:group_ids) { [group1.to_global_id.to_s] } + + it 'attaches to projects and enqueues workers for groups' do + # expect(SecurityScanProfiles::AttachWorker).to receive(:perform_async).with(group1.id, profile.id) + + expect { post_graphql_mutation(mutation, current_user: current_user) } + .to change { Security::ScanProfileProject.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'when validating arguments' do + context 'when no project_ids or group_ids provided' do + let(:project_ids) { nil } + let(:group_ids) { nil } + + it 'returns GraphQL error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + end + end + + context 'when too many IDs provided' do + let(:project_ids) do + Array.new(101) { project1.to_global_id.to_s } + end + + it 'returns validation error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + expect(graphql_errors.first['message']).to include('Too many ids') + end + end + + context 'when items are from different root namespaces' do + let_it_be(:other_project) { create(:project, namespace: another_root_group) } + let(:project_ids) { [project1.to_global_id.to_s, other_project.to_global_id.to_s] } + + before_all do + another_root_group.add_maintainer(current_user) + end + + it 'returns validation error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + expect(graphql_errors.first['message']).to include('same root namespace') + end + end + end + + context 'when profile does not exist' do + let(:security_scan_profile_id) { "gid://gitlab/Security::ScanProfile/#{non_existing_record_id}" } + + it 'returns GraphQL error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + end + end + + context 'when profile is from different root namespace' do + let_it_be(:other_profile) do + create(:security_scan_profile, + namespace: another_root_group, + scan_type: :secret_detection) + end + + let(:security_scan_profile_id) { other_profile.to_global_id.to_s } + + it 'returns GraphQL error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + end + end + + context 'when user lacks permission for some projects' do + let_it_be(:other_namespace) { create(:group) } + let_it_be(:other_project) { create(:project, namespace: other_namespace) } + let(:project_ids) { [project1.to_global_id.to_s, other_project.to_global_id.to_s] } + + it 'returns GraphQL error and does not attach to any projects' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .not_to change { Security::ScanProfileProject.count } + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_present + end + end + + context 'when using subgroup project' do + let(:project_ids) { [subgroup_project.to_global_id.to_s] } + let(:security_scan_profile_id) { 'gid://gitlab/Security::ScanProfile/secret_detection' } + + it 'uses the root namespace for profile resolution and attaches successfully' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .to change { Security::ScanProfileProject.count }.by(1) + + profile = Security::ScanProfile.last + expect(profile.namespace).to eq(root_group) + end + end + + context 'when profile is already attached to some projects' do + before do + create(:security_scan_profile_project, scan_profile: profile, project: project1) + end + + it 'is idempotent and does not duplicate' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .to change { Security::ScanProfileProject.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_result['errors']).to be_empty + end + end + + context 'when project has reached profile limit' do + before do + allow(Security::ScanProfileProject).to receive(:project_ids_at_limit).and_return([project1.id]) + end + + it 'returns error for project at limit and attaches to projects not at limit' do + expect { post_graphql_mutation(mutation, current_user: current_user) } + .to change { Security::ScanProfileProject.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_result['errors']).to include(match(/Project #{project1.id}.*maximum limit/)) + expect(Security::ScanProfileProject.by_project(project2).by_scan_profile(profile)).to exist + end + end + end + end + end + end +end diff --git a/ee/spec/services/security/scan_profiles/attach_service_spec.rb b/ee/spec/services/security/scan_profiles/attach_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a10928c960f3378a1c0c355c2c56f827e56863ae --- /dev/null +++ b/ee/spec/services/security/scan_profiles/attach_service_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ScanProfiles::AttachService, feature_category: :security_asset_inventories do + let_it_be(:root_group) { create(:group) } + let_it_be(:project1) { create(:project, namespace: root_group) } + let_it_be(:project2) { create(:project, namespace: root_group) } + let_it_be(:project3) { create(:project, namespace: root_group) } + let_it_be(:profile) do + create(:security_scan_profile, + namespace: root_group, + scan_type: :secret_detection, + name: 'Test Profile') + end + + shared_examples 'returns empty errors' do + it 'returns empty errors' do + result = execute_service + + expect(result[:errors]).to be_empty + end + end + + describe '.execute' do + subject(:execute_service) do + described_class.execute(profile: profile, projects: projects) + end + + context 'when no projects are provided' do + let(:projects) { [] } + + it 'returns an error' do + result = execute_service + + expect(result[:errors]).to include('At least one project must be provided') + end + + it 'does not create any associations' do + expect { execute_service }.not_to change { Security::ScanProfileProject.count } + end + end + + context 'when projects are provided' do + let(:projects) { [project1, project2] } + + it 'creates associations for all projects' do + expect { execute_service }.to change { Security::ScanProfileProject.count }.by(projects.count) + end + + it 'creates correct associations' do + execute_service + + expect(Security::ScanProfileProject.where(project: project1, scan_profile: profile)).to exist + expect(Security::ScanProfileProject.where(project: project2, scan_profile: profile)).to exist + end + + it_behaves_like 'returns empty errors' + end + + context 'when a project has reached the profile limit' do + let(:projects) { [project1, project2] } + + before do + allow(Security::ScanProfileProject).to receive(:project_ids_at_limit).and_return([project1.id]) + end + + it 'does not attach to the project at limit' do + expect { execute_service }.to change { Security::ScanProfileProject.count }.by(1) + end + + it 'attaches to projects not at limit' do + execute_service + + expect(Security::ScanProfileProject.where(project: project2, scan_profile: profile)).to exist + expect(Security::ScanProfileProject.where(project: project1, scan_profile: profile)).not_to exist + end + + it 'returns an error for the project at limit' do + result = execute_service + + expect(result[:errors]).to include("Project #{project1.id} has reached the maximum limit of scan profiles") + end + end + + context 'when multiple projects have reached the limit' do + let(:projects) { [project1, project2, project3] } + + before do + allow(Security::ScanProfileProject).to receive(:project_ids_at_limit).and_return([project1.id, project2.id]) + end + + it 'only attaches to projects not at limit' do + expect { execute_service }.to change { Security::ScanProfileProject.count }.by(1) + end + + it 'attaches only to project3' do + execute_service + + expect(Security::ScanProfileProject.where(project: project3, scan_profile: profile)).to exist + expect(Security::ScanProfileProject.where(project: project1, scan_profile: profile)).not_to exist + expect(Security::ScanProfileProject.where(project: project2, scan_profile: profile)).not_to exist + end + + it 'returns errors for all projects at limit' do + result = execute_service + + expect(result[:errors]).to include("Project #{project1.id} has reached the maximum limit of scan profiles") + expect(result[:errors]).to include("Project #{project2.id} has reached the maximum limit of scan profiles") + end + end + + context 'when profile is already attached to a project' do + let(:projects) { [project1, project2] } + + before do + create(:security_scan_profile_project, scan_profile: profile, project: project1) + end + + it 'attaches to projects not yet attached' do + expect { execute_service }.to change { Security::ScanProfileProject.count }.by(1) + + expect(Security::ScanProfileProject.where(project: project2, scan_profile: profile)).to exist + end + + it_behaves_like 'returns empty errors' + end + + context 'when all projects already have the profile attached' do + let(:projects) { [project1, project2] } + + before do + create(:security_scan_profile_project, scan_profile: profile, project: project1) + create(:security_scan_profile_project, scan_profile: profile, project: project2) + end + + it 'does not create duplicate associations' do + expect { execute_service }.not_to change { Security::ScanProfileProject.count } + end + + it_behaves_like 'returns empty errors' + end + + context 'when some projects are at limit and some already attached' do + let(:projects) { [project1, project2, project3] } + + before do + # project1: already attached + create(:security_scan_profile_project, scan_profile: profile, project: project1) + + # project2: at limit + allow(Security::ScanProfileProject).to receive(:project_ids_at_limit).and_return([project2.id]) + end + + it 'only attaches to project3' do + expect { execute_service }.to change { Security::ScanProfileProject.count }.by(1) + end + + it 'returns error only for project at limit' do + result = execute_service + + expect(result[:errors]).to include("Project #{project2.id} has reached the maximum limit of scan profiles") + expect(result[:errors].size).to eq(1) + end + end + + context 'when more than MAX_PROJECTS are provided' do + let(:projects) { [project1, project2, project3] } + + before do + stub_const("#{described_class}::MAX_PROJECTS", 2) + end + + it 'raises TooManyProjectsError' do + expect { execute_service }.to raise_error do |error| + expect(error.class).to eql(described_class::TooManyProjectsError) + expect(error.message).to eql('Cannot attach profile to more than 2 items at once.') + end + end + end + end +end diff --git a/ee/spec/services/security/scan_profiles/find_or_create_service_spec.rb b/ee/spec/services/security/scan_profiles/find_or_create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..93d457e3ee75d9d743d35c9fc9735861b75fc8ee --- /dev/null +++ b/ee/spec/services/security/scan_profiles/find_or_create_service_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ScanProfiles::FindOrCreateService, feature_category: :security_asset_inventories do + let_it_be(:root_group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: root_group) } + let_it_be(:user) { create(:user) } + let_it_be(:another_root_group) { create(:group) } + + describe '.execute' do + subject(:execute_service) do + described_class.execute(namespace: namespace, scan_type: scan_type) + end + + context 'when namespace is not a root namespace' do + let(:namespace) { subgroup } + let(:scan_type) { :secret_detection } + + it 'returns an error' do + result = execute_service + + expect(result).to be_error + expect(result.message).to eq('Namespace must be a root namespace') + end + + it 'does not create a scan profile' do + expect { execute_service }.not_to change { Security::ScanProfile.count } + end + end + + context 'when namespace is a root namespace' do + let(:namespace) { root_group } + + context 'with valid scan_type' do + let(:scan_type) { :secret_detection } + + context 'when no gitlab_recommended profile exists' do + it 'creates a new gitlab_recommended profile' do + expect { execute_service }.to change { Security::ScanProfile.count }.by(1) + end + + it 'returns success with the created profile' do + result = execute_service + + expect(result).to be_success + expect(result.payload[:scan_profile]).to be_a(Security::ScanProfile) + expect(result.payload[:scan_profile]).to be_persisted + end + + it 'creates profile with default attributes' do + result = execute_service + profile = result.payload[:scan_profile] + default_profile = Security::DefaultScanProfilesHelper.default_scan_profiles + .find { |p| p.scan_type == scan_type.to_s } + + expect(profile).to have_attributes( + scan_type: default_profile.scan_type, + name: default_profile.name, + description: default_profile.description, + gitlab_recommended: true, + namespace: namespace + ) + end + end + + context 'when a gitlab_recommended profile already exists' do + let_it_be(:existing_profile) do + create(:security_scan_profile, + namespace: root_group, + scan_type: :secret_detection, + name: 'Existing Secret Detection (default)', + gitlab_recommended: true + ) + end + + it 'does not create a new profile' do + expect { execute_service }.not_to change { Security::ScanProfile.count } + end + + it 'returns the existing profile' do + result = execute_service + + expect(result).to be_success + expect(result.payload[:scan_profile]).to eq(existing_profile) + end + end + + context 'when custom profiles exist but no gitlab_recommended' do + let_it_be(:custom_profile) do + create(:security_scan_profile, + namespace: root_group, + scan_type: :secret_detection, + name: 'Custom Secret Detection', + gitlab_recommended: false + ) + end + + it 'creates a new gitlab_recommended profile' do + expect { execute_service }.to change { Security::ScanProfile.count }.by(1) + end + + it 'returns the newly created gitlab_recommended profile' do + result = execute_service + profile = result.payload[:scan_profile] + + expect(profile).not_to eq(custom_profile) + expect(profile.gitlab_recommended).to be(true) + end + end + end + + context 'with invalid scan_type' do + let(:scan_type) { :invalid_type } + + it 'returns an error' do + result = execute_service + + expect(result).to be_error + expect(result.message).to eq('Could not find a default scan profile for this type') + end + + it 'does not create a scan profile' do + expect { execute_service }.not_to change { Security::ScanProfile.count } + end + end + end + end +end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 3ddd9a55fac147b64d666166357d8c8bce4ad082..aea320f4cd2c341a250307b18c19dfc27dc06258 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -2018,6 +2018,21 @@ end end + describe '.root_namespace_ids' do + let_it_be(:namespace1) { create(:group, name: 'Namespace 1', path: 'namespace-1') } + let_it_be(:namespace2) { create(:group, name: 'Namespace 2', path: 'namespace-2') } + let_it_be(:namespace1sub) { create(:group, name: 'Sub Namespace', path: 'sub-namespace', parent: namespace1) } + let_it_be(:namespace2sub) { create(:group, name: 'Sub Namespace', path: 'sub-namespace', parent: namespace2) } + + it 'returns unique root namespace ids' do + expect(described_class.id_in([namespace1.id, namespace1sub.id]).root_namespace_ids).to match_array([namespace1.id]) + expect(described_class.id_in([namespace2.id, namespace2sub.id]).root_namespace_ids).to match_array([namespace2.id]) + expect(described_class.id_in([namespace1sub.id, namespace2sub.id]).root_namespace_ids).to match_array([namespace1.id, namespace2.id]) + expect(described_class.id_in(namespace1sub.id).root_namespace_ids).to eq([namespace1.id]) + expect(described_class.id_in(nil).root_namespace_ids).to be_empty + end + end + shared_examples 'disabled feature flag when traversal_ids is blank' do before do namespace.traversal_ids = [] diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 51b7cf0955f162e08793b7f783ad3b2265ac81ee..787ffc3a9aa65b1ee9c3475e83e4ad5897b16b5e 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -3318,6 +3318,22 @@ def create_project_statistics_with_size(project, size) end end + describe '.root_namespace_ids' do + let_it_be(:group1) { create(:group) } + let_it_be(:group2) { create(:group) } + let_it_be(:group1_project) { create(:project, namespace: group1) } + let_it_be(:group2_project) { create(:project, namespace: group2) } + let_it_be(:subgroup) { create(:group, parent: group1) } + let_it_be(:subgroup_project) { create(:project, namespace: subgroup) } + + it 'returns unique root namespace ids' do + expect(described_class.where(id: [group1_project.id, subgroup_project.id]).root_namespace_ids).to match_array([group1.id]) + expect(described_class.where(id: group2_project.id).root_namespace_ids).to eq([group2.id]) + expect(described_class.where(id: subgroup_project.id).root_namespace_ids).to eq([group1.id]) + expect(described_class.where(id: nil).root_namespace_ids).to be_empty + end + end + context 'repository storage by default' do let(:project) { build(:project) }