Add SecurityScanProfileDelete GraphQL mutation (with soft-delete)

Summary

Add a GraphQL mutation to delete Security::ScanProfile records, modeled on the existing Security::Attribute delete flow. Part of epic &20193 (Advanced Configuration Profiles for Security Scanners).

A scan profile is namespace-scoped and can be attached to thousands of projects via the Security::ScanProfileProject join table. To keep the delete request cheap, we adopt the Security::Attribute soft-delete pattern: the mutation soft-deletes the profile synchronously (hiding it immediately) and enqueues a background worker to clean up the join records and hard-delete the row.

Scope

  • New mutation Mutations::Security::ScanProfiles::Delete (graphql_name 'SecurityScanProfileDelete'), mounted with experiment: { milestone: '19.3' }.
  • Argument: id (Global ID of the Security::ScanProfile). Returns deletedScanProfileId + errors.
  • Authorization: authorize :apply_security_scan_profiles (via authorized_find!). The ability is already prevented when the security_scan_profiles licensed feature is off, so the licensed-feature gate is automatic.
  • Gate on the security_scan_profiles_feature feature flag (checked in resolve against the profile's namespace).
  • Disallow deleting GitLab-recommended profiles: if scan_profile.gitlab_recommended?, return a graceful user-facing error (errors: ['Cannot delete a GitLab-recommended scan profile']) and do not soft-delete or enqueue the worker. Mirrors Security::Attribute's non-editable guard.

Soft-delete implementation

  • Migration: add deleted_at column + partial index (WHERE deleted_at IS NULL) to security_scan_profiles. Do NOT change the existing unique index (it is the ON CONFLICT target of FindOrCreateService#upsert); scope name-uniqueness at the Rails layer only for now.
  • Model Security::ScanProfile: not_deleted/deleted scopes, #destroy soft-delete override, #really_destroy!, .really_destroy_all!, #deleted?, uniqueness conditions: deleted_at: nil. No default_scope.
  • Guard all read paths with .not_deleted (resolvers, detach#find_profile!, FindOrCreateService, ScanProfileStatus::UpdateService, Project#security_scan_profile_for, scan_type_names_for_project, attach/detach workers). Leave DeleteScanProfileService#find_by_id unguarded so the worker can locate and hard-delete the soft-deleted row.
  • Reuse existing DeleteScanProfileService + DeleteScanProfilesWorker; change the service's final scan_profile.destroy to really_destroy! so the row is actually removed after join cleanup.
  • Add internal event delete_scan_profile.

Acceptance criteria

  • SecurityScanProfileDelete mutation exists, mounted as an experiment, and is FF- + license-gated.
  • Caller must have apply_security_scan_profiles; unauthorized/anonymous callers get an access error.
  • Deleting a profile attached to projects succeeds immediately (soft-delete) and cleans up join records + hard-deletes asynchronously.
  • Deleting a gitlab_recommended profile is rejected with a user-facing error and no state change.
  • Soft-deleted profiles no longer appear in any read path.
  • Request spec (authorization matrix, FF off, license off, soft-delete, worker enqueue, recommended guard, internal event), model spec, and service/worker spec updates.

Notes

  • Add pipeline:run-as-if-foss (EE overrides of CE GraphQL/type classes touched).
  • Regenerate GraphQL docs/schema; update db/structure.sql.
  • Known limitation (experiment): a name held by a not-yet-hard-deleted tombstone can still collide at the DB unique index; full DB-level scoping is a follow-up when the flag graduates.
Edited by 🤖 GitLab Bot 🤖