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
Related issue
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.first1. 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.destroy4. 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? # => true7. 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 << project10. 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 # => 0MR 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.