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:
securityScanProfileAttachGraphQL mutation for attaching profiles to multiple projects and groupsSecurity::ScanProfiles::AttachServicefor handling attachment logicSecurity::ScanProfiles::FindOrCreateServicefor creating GitLab-recommended profilesapply_security_scan_profilespermission for authorization
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
- Enable the
security_scan_profilesfeature flag:Feature.enable(:security_scan_profiles_feature) - Select a root group
rgwhere you have at least maintainer permissions. - Select (or create) two projects
p1andp2underrg.
Test 1: Attach template-based profile and verify creation
- 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 } } - Query
p1to verify the GitLab-recommended profile was created and attached:{ project(fullPath: "<p1_full_path>") { securityScanProfiles { id name description scanType gitlabRecommended } } } - Verify the response includes a profile with:
gitlabRecommended: true
Test 2: Attach persisted profile to another project
- Copy the profile
idfrom Test 1, Step 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 } } - Query
p2to verify the profile was attached.
Test 3: Bulk attachment to multiple projects
- 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 } } - Verify the mutation creates no duplicates and returns with empty errors.
Query plans
Project.root_ids_for:
This method fetches root namespace ids in two queries for better performance:
- Get distinct project
namespace_ids Extract root namespace ids from those namespaces onlyNow using existing scope.
This approach avoids nested loop joins and should scale linearly with project count.
First part:
SQL
SELECT DISTINCT
"projects"."namespace_id"
FROM
"projects"
WHERE
"projects"."id" IN (70472537, 69782606, 71329491, 68794259, 66101996)
LIMIT 1000Query plan
See details here
Limit (cost=16.54..16.56 rows=5 width=4) (actual time=0.069..0.072 rows=2 loops=1)
Buffers: shared hit=31
I/O Timings: read=0.000 write=0.000
-> Unique (cost=16.54..16.56 rows=5 width=4) (actual time=0.068..0.071 rows=2 loops=1)
Buffers: shared hit=31
I/O Timings: read=0.000 write=0.000
-> Sort (cost=16.54..16.55 rows=5 width=4) (actual time=0.067..0.068 rows=5 loops=1)
Sort Key: projects.namespace_id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=31
I/O Timings: read=0.000 write=0.000
-> Index Scan using projects_pkey on public.projects (cost=0.56..16.48 rows=5 width=4) (actual time=0.022..0.050 rows=5 loops=1)
Index Cond: (projects.id = ANY ('{70472537,69782606,71329491,68794259,66101996}'::integer[]))
Buffers: shared hit=28
I/O Timings: read=0.000 write=0.000
Settings: jit = 'off', work_mem = '100MB', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB'insert_under_limit:
SQL
INSERT INTO security_scan_profiles_projects (project_id, security_scan_profile_id, created_at, updated_at)
SELECT
candidate_project_id,
1,
NOW(),
NOW()
FROM
UNNEST(ARRAY[66101996, 69782606, 69782162, 68794259, 68571417]) AS candidate_project_id
WHERE (
SELECT
COUNT(*)
FROM
security_scan_profiles_projects
WHERE
project_id = candidate_project_id) < 10
ON CONFLICT (project_id,
security_scan_profile_id)
DO NOTHING
RETURNING
project_id;
Query plan
See details here
ModifyTable on public.security_scan_profiles_projects (cost=0.00..15.95 rows=2 width=40) (actual time=0.245..0.336 rows=5 loops=1)
Buffers: shared hit=71
WAL: records=26 fpi=4 bytes=4052
-> Function Scan on unnest candidate_project_id (cost=0.00..15.95 rows=2 width=40) (actual time=0.166..0.179 rows=5 loops=1)
Filter: ((SubPlan 1) < 10)
Rows Removed by Filter: 0
Buffers: shared hit=34
WAL: records=1 fpi=0 bytes=99
SubPlan 1
-> Aggregate (cost=3.16..3.17 rows=1 width=8) (actual time=0.014..0.014 rows=1 loops=5)
Buffers: shared hit=13
-> Index Only Scan using index_security_scan_profiles_projects_on_unique_project_profile on public.security_scan_profiles_projects security_scan_profiles_projects_1 (cost=0.14..3.16 rows=1 width=0) (actual time=0.013..0.013 rows=0 loops=5)
Index Cond: (security_scan_profiles_projects_1.project_id = candidate_project_id.candidate_project_id)
Heap Fetches: 5
Buffers: shared hit=13
Trigger RI_ConstraintTrigger_c_4226359321 for constraint fk_rails_36ece30d24: time=0.519 calls=5
Settings: random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '338688MB', jit = 'off', work_mem = '100MB'upsert_profile:
SQL
INSERT INTO "security_scan_profiles" ("namespace_id", "scan_type", "gitlab_recommended", "name", "description", "created_at", "updated_at")
VALUES (1, 1, TRUE, 'Secret Push Protection (default)', 'GitLab''s recommended', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (namespace_id, scan_type, lower(name))
DO UPDATE SET
updated_at = (
CASE WHEN ("security_scan_profiles"."namespace_id" IS NOT DISTINCT FROM excluded."namespace_id"
AND "security_scan_profiles"."scan_type" IS NOT DISTINCT FROM excluded."scan_type"
AND "security_scan_profiles"."gitlab_recommended" IS NOT DISTINCT FROM excluded."gitlab_recommended"
AND "security_scan_profiles"."name" IS NOT DISTINCT FROM excluded."name"
AND "security_scan_profiles"."description" IS NOT DISTINCT FROM excluded."description") THEN
"security_scan_profiles".updated_at
ELSE
CURRENT_TIMESTAMP
END),
"namespace_id" = excluded."namespace_id",
"scan_type" = excluded."scan_type",
"gitlab_recommended" = excluded."gitlab_recommended",
"name" = excluded."name",
"description" = excluded."description"
RETURNING
"id"Query plan
See details here
ModifyTable on public.security_scan_profiles (cost=0.00..0.02 rows=1 width=99) (actual time=0.289..0.290 rows=1 loops=1)
Buffers: shared hit=38 dirtied=4
WAL: records=5 fpi=2 bytes=838
-> Result (cost=0.00..0.02 rows=1 width=99) (actual time=0.096..0.096 rows=1 loops=1)
Buffers: shared hit=15 dirtied=1
WAL: records=1 fpi=0 bytes=99
Settings: work_mem = '100MB', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '338688MB', 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 - closed) • Gal Katz • 18.9