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) }