Draft: Add security scan profile attach mutation

What does this MR do and why?

Introduces bulk mutations to attach security scan profiles to projects. This MR includes:

  • securityScanProfileAttach GraphQL mutation for attaching profiles to multiple projects and groups
  • Security::ScanProfiles::AttachService for handling attachment logic
  • Security::ScanProfiles::FindOrCreateService for creating GitLab-recommended profiles
  • apply_security_scan_profiles permission for authorization
  • security_scan_profiles_feature feature flag

The mutation supports both template based profile ids (e.g., gid://gitlab/Security::ScanProfile/secret_detection) and persisted profile ids for attaching existing custom profiles.

Changelog: added
EE: true

How to set up and validate locally

Setup

  1. Enable the security_scan_profiles feature flag:
    Feature.enable(:security_scan_profiles_feature)
  2. Select a root group rg where you have at least maintainer permissions.
  3. Select (or create) two projects p1 and p2 under rg.

Test 1: Attach template-based profile and verify creation

  1. Use this GraphQL mutation to attach a template-based secret detection profile to p1:
    mutation {
      securityScanProfileAttach(
        input: {
          securityScanProfileId: "gid://gitlab/Security::ScanProfile/secret_detection"
          projectIds: ["gid://gitlab/Project/<P1_ID>"]
        }
      ) {
        errors
      }
    }
  2. Query p1 to verify the GitLab-recommended profile was created and attached:
    {
      project(fullPath: "<p1_full_path>") {
        securityScanProfiles {
          id
          name
          description
          scanType
          gitlabRecommended
        }
      }
    }
  3. Verify the response includes a profile with:
    • gitlabRecommended: true

Test 2: Attach persisted profile to another project

  1. Copy the profile id from Test 1, Step 2.
  2. Use this GraphQL mutation to attach the same profile to p2:
    mutation {
      securityScanProfileAttach(
        input: {
          securityScanProfileId: "<PROFILE_ID_FROM_TEST_1>"
          projectIds: ["gid://gitlab/Project/<P2_ID>"]
        }
      ) {
        errors
      }
    }
  3. Query p2 to verify the profile was attached.

Test 3: Bulk attachment to multiple projects

  1. Use this GraphQL mutation to attach the profile to both projects in a single request:
    mutation {
      securityScanProfileAttach(
        input: {
          securityScanProfileId: "<PROFILE_ID>"
          projectIds: ["gid://gitlab/Project/<P1_ID>", "gid://gitlab/Project/<P2_ID>"]
        }
      ) {
        errors
      }
    }
  2. Verify the mutation creates no duplicates and returns with empty errors.

Query plans

insert_all:

SQL
INSERT INTO "security_scan_profiles_projects"
("project_id", "security_scan_profile_id", "created_at", "updated_at")
VALUES
  (22, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (23, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (24, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (25, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (26, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (27, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (28, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (29, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (30, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040'),
  (31, 1, '2025-12-11 15:49:47.538040', '2025-12-11 15:49:47.538040')
ON CONFLICT ("project_id","security_scan_profile_id") DO NOTHING
RETURNING "id";
Query plan

See details here

 ModifyTable on public.security_scan_profiles_projects  (cost=0.00..0.15 rows=10 width=40) (actual time=0.227..0.333 rows=10 loops=1)
   Buffers: shared hit=107
   WAL: records=50 fpi=0 bytes=3430
   ->  Values Scan on "*VALUES*"  (cost=0.00..0.15 rows=10 width=40) (actual time=0.089..0.100 rows=10 loops=1)
         Buffers: shared hit=26
Trigger RI_ConstraintTrigger_c_4226359321 for constraint fk_rails_36ece30d24: time=0.864 calls=10
Settings: seq_page_cost = '4', effective_cache_size = '338688MB', random_page_cost = '1.5', work_mem = '100MB', jit = 'off'

by_scan_profile:

SQL
SELECT
    "security_scan_profiles_projects".*
FROM
    "security_scan_profiles_projects"
WHERE
    "security_scan_profiles_projects"."security_scan_profile_id" = 1
Query plan

See details here

 Index Scan using idx_security_scan_profiles_projects_on_security_scan_profile_id on public.security_scan_profiles_projects  (cost=0.14..3.16 rows=1 width=40) (actual time=0.034..0.035 rows=0 loops=1)
   Index Cond: (security_scan_profiles_projects.security_scan_profile_id = 1)
   Buffers: shared hit=6
Settings: effective_cache_size = '338688MB', random_page_cost = '1.5', work_mem = '100MB', jit = 'off', seq_page_cost = '4'

by_project:

SQL
SELECT
    "security_scan_profiles_projects".*
FROM
    "security_scan_profiles_projects"
WHERE
    "security_scan_profiles_projects"."project_id" IN (22, 23, 24)
Query plan

See details here

 Index Scan using index_security_scan_profiles_projects_on_unique_project_profile on public.security_scan_profiles_projects  (cost=0.14..3.19 rows=3 width=40) (actual time=0.020..0.021 rows=3 loops=1)
   Index Cond: (security_scan_profiles_projects.project_id = ANY ('{22,23,24}'::bigint[]))
   Buffers: shared hit=2
Settings: seq_page_cost = '4', effective_cache_size = '338688MB', random_page_cost = '1.5', work_mem = '100MB', jit = 'off'

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.

Related to [Backend] Add mutation to bulk apply and remove... (#582824) • Gal Katz • 18.8

Edited by Gal Katz

Merge request reports

Loading