Soft-delete Security::ScanProfile records

What does this MR do and why?

This MR makes Security::ScanProfile#destroy soft-delete a record (setting deleted_at) and introduces Security::ScanProfiles::DeleteScanProfileService to perform the hard delete asynchronously.

Deleting a scan profile now hides it from every read path immediately, while the expensive cleanup — batch-removing the profile's project associations and hard-deleting the row (which cascades to its triggers and configurations via ON DELETE CASCADE) — runs in the background through Security::ScanProfiles::DeleteScanProfilesWorker. Soft-deleted profiles are excluded from all resolvers, the project scan-profile fields, and status queries through the new not_deleted scope, and they no longer block name reuse thanks to the partial unique index on (namespace_id, scan_type, lower(name)) WHERE deleted_at IS NULL.

This is part of the Security scan profiles series and builds on the earlier MR that added the deleted_at column.

Changelog: changed
EE: true

Add SecurityScanProfileDelete GraphQL mutation ... (#607385 - closed) • rossfuhrman

How to set up and validate locally

The scan profiles feature is behind the security_scan_profiles_feature flag, which is default-enabled, so no flag toggling is required. All steps below run in a Rails console (gdk rails console).

0. Pick a root group and a project inside it
group   = Group.roots.first                 # any root group
project = group.all_projects.first

1. Create a secret detection profile with a trigger and a configuration

profile = Security::ScanProfile.create!(
  namespace: group, scan_type: :secret_detection,
  name: 'Soft delete demo', gitlab_recommended: false
)
config  = Security::ScanProfiles::Configuration.new(
  scan_profile: profile,
  configuration: { 
   log_options: 'HEAD~5..HEAD',
   historic_scan: false,
   excluded_paths: %w[spec tmp] 
  },
  namespace: group
)
trigger = profile.scan_profile_triggers.create!(
  namespace: group, trigger_type: :git_push_event, configuration: config
)

2. Associate a project with the profile

profile.projects << project
profile.scan_profile_projects.reload.map(&:id)           # => [project.id]

3. Manually delete the profile (soft delete)

profile.destroy

4. Confirm it has been soft-deleted (row remains, hidden from reads)

profile.reload.deleted_at                                  # => a timestamp
Security::ScanProfile.unscoped.exists?(profile.id)         # => true  (row still present)
Security::ScanProfile.not_deleted.exists?(profile.id)      # => false (excluded from reads)

5. Confirm the project is no longer associated with the soft-deleted profile

# Reads used by the resolvers/project fields exclude soft-deleted profiles:
Security::ScanProfile.scan_type_names_for_project(project) # => does NOT include "secret_detection"
project.security_scan_profiles.not_deleted.where(id: profile.id).exists? # => false

(The join row still exists until the async cleanup in step 7 removes it; it is simply invisible to every read.)

6. Re-create the same profile — name uniqueness is not blocked by the soft-deleted one

reused = Security::ScanProfile.create!(
  namespace: group, scan_type: :secret_detection,
  name: 'Soft delete demo', gitlab_recommended: false   # same name + scope succeeds
)
reused.persisted?                                          # => true

7. Manually kick off the service to fully delete the original profile

Security::ScanProfiles::DeleteScanProfileService.execute(profile.id)

8. Confirm it has been fully deleted, including its triggers and configurations

Security::ScanProfile.unscoped.exists?(profile.id)                                                # => false
Security::ScanProfileTrigger.where(security_scan_profile_id: profile.id).count                    # => 0 (ON DELETE CASCADE)
Security::ScanProfiles::Configuration.where(security_scan_profile_id: profile.id).count           # => 0 (ON DELETE CASCADE)
Security::ScanProfileProject.where(security_scan_profile_id: profile.id).count                    # => 0 (removed by the service)

9. Create another secret detection profile with a trigger and a configuration

profile2 = Security::ScanProfile.create!(
  namespace: group, scan_type: :secret_detection,
  name: 'Async delete demo', gitlab_recommended: false
)
config2  = Security::ScanProfiles::Configuration.new(
  scan_profile: profile2,
  configuration: { 
   log_options: 'HEAD~5..HEAD',
   historic_scan: false,
   excluded_paths: %w[spec tmp] 
  },
  namespace: group
)
profile2.scan_profile_triggers.create!(
  namespace: group, trigger_type: :git_push_event, configuration: config2
)
profile2.projects << project

10. Delete via the async path (worker → service)

Note: this MR does not add a GraphQL delete mutation — the async hard-delete is driven by DeleteScanProfilesWorker. A future delete mutation would soft-delete the record and enqueue this worker, which is exactly what the two lines below reproduce:

profile2.destroy                                                            # soft delete (what a mutation would do)
Security::ScanProfiles::DeleteScanProfilesWorker.perform_async([profile2.id], group.id)

Let the Sidekiq job run (it is processed automatically in GDK), or run it inline for a deterministic check:

Security::ScanProfiles::DeleteScanProfilesWorker.new.perform([profile2.id], group.id)

11. Confirm the new profile has been fully deleted, including its triggers and configurations

Security::ScanProfile.unscoped.exists?(profile2.id)                                               # => false
Security::ScanProfileTrigger.where(security_scan_profile_id: profile2.id).count                   # => 0
Security::ScanProfiles::Configuration.where(security_scan_profile_id: profile2.id).count          # => 0
Security::ScanProfileProject.where(security_scan_profile_id: profile2.id).count                   # => 0

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by rossfuhrman

Merge request reports

Loading