Backend resolver for security policy eligible projects
Summary
Adds a dedicated securityPolicyEligibleProjects GraphQL resolver on ProjectType that returns all projects eligible for policy scope selection when a project is used as a Security Policy Project (SPP).
Currently, the policy scope "specific projects" dropdown only shows projects from descendant namespaces of each SPP-linked group. This resolver fixes that by collecting root ancestors of all linked groups and returning all projects under those root namespaces.
Part 1 of 2 for #593858
Changes
- New finder:
SecurityPolicyEligibleProjectsFinder— takes an SPP project, finds linked groups, extracts unique root ancestor IDs viatraversal_ids[1], returns all projects under those root namespaces with visibility filtering - New resolver:
SecurityPolicyEligibleProjectsResolver— exposes the finder as aconnection_typefield with optionalsearchargument - Field registration: Adds
security_policy_eligible_projectsfield toProjectType - Tests: Finder and resolver specs covering sibling/parent visibility, deduplication, license checks, visibility filtering, search, pagination
How it works
SPP project
→ linked groups (e.g., subgroup-a, subgroup-b)
→ root ancestors via traversal_ids[1] (e.g., topgroup)
→ DISTINCT dedup
→ Project.in_namespace(root.self_and_descendant_ids)
→ .non_archived.without_deleted.public_or_visible_to_userMR Dependencies
This MR (Backend) ──sequential──▶ MR2 (Frontend)Database
Full query
The finder issues three queries. All scopes (in_namespace, non_archived, without_deleted, public_or_visible_to_user, search) and pagination (GraphQL connection_type, default_max_page_size = 100) are reflected below.
1. Collect root ancestor IDs of SPP-linked groups (Namespace.root_ids_for):
SELECT DISTINCT traversal_ids[1]
FROM namespaces
WHERE id IN (
SELECT namespace_id
FROM security_policy_project_linked_groups
WHERE security_policy_project_id = $1
)
AND traversal_ids[1] IS NOT NULL
LIMIT 1500;The LIMIT 1500 is a safety cap inherited from Namespace.root_ids_for — it sits above the heaviest 100 requests observed in production (see #577678 (comment 2841315975)).
2. Expand roots to self_and_descendant_ids (Namespace.by_ids(root_ids).self_and_descendant_ids):
SELECT id
FROM namespaces
WHERE traversal_ids @> ARRAY[$1]::integer[]
OR traversal_ids @> ARRAY[$2]::integer[]
-- ... one clause per root idThis uses the GIN index on namespaces.traversal_ids.
3. Main project query (Project.in_namespace(...).non_archived.without_deleted.public_or_visible_to_user.search(...) plus GraphQL pagination):
Shown with two concrete variants (anonymous and authenticated) in ### Query plan. The ORM emits archived = FALSE, pending_delete = FALSE, the three-branch public_or_visible_to_user (PUBLIC / INTERNAL for non-external users / project_authorizations subquery for the current user), an optional name/path ILIKE $search, and ORDER BY id DESC LIMIT 101 for keyset pagination (the +1 probe is how GraphQL::Pagination::ActiveRecordRelationConnection detects hasNextPage).
Query plan
Two plans to cover both paths through public_or_visible_to_user — anonymous hits only the PUBLIC branch; an authenticated user exercises the project_authorizations subquery.
Main project query, anonymous viewer, using the gitlab-org root namespace (id 9970) on gitlab-production-main:
SELECT projects.*
FROM projects
WHERE projects.namespace_id IN (
SELECT id FROM namespaces WHERE traversal_ids @> ARRAY[9970]::integer[]
)
AND projects.archived = FALSE
AND projects.pending_delete = FALSE
AND projects.visibility_level IN (10, 20)
ORDER BY projects.id DESC
LIMIT 101;Same query, authenticated viewer — exercises the project_authorizations branch. Run with user 10762712 (~12,195 authorizations under gitlab-org):
SELECT projects.*
FROM projects
WHERE projects.namespace_id IN (
SELECT id FROM namespaces WHERE traversal_ids @> ARRAY[9970]::integer[]
)
AND projects.archived = FALSE
AND projects.pending_delete = FALSE
AND (
projects.visibility_level = 20
OR projects.visibility_level = 10
OR projects.id IN (
SELECT project_id
FROM project_authorizations
WHERE user_id = 10762712
)
)
ORDER BY projects.id DESC
LIMIT 101;Plan highlights — project_authorizations is probed as a hashed Index Only Scan on project_authorizations_pkey (~12k rows for this user); outer Parallel Index Scan Backward on projects terminates at LIMIT 101; namespace lookup is a Memoize-cached Index Scan on namespaces_pkey with the traversal_ids @> '{9970}' filter applied per cached row. Total cost 2486.26..227449.09.
EXPLAIN tree
Limit (cost=2486.26..227449.09 rows=101 width=746)
-> Gather Merge (cost=2486.26..7156749.61 rows=3212 width=746)
Workers Planned: 2
-> Nested Loop (cost=1486.24..7155378.84 rows=1338 width=746)
-> Parallel Index Scan Backward using index_projects_on_id_and_archived_and_pending_delete on projects (cost=1485.66..5883313.60 rows=11152829 width=746)
Filter: ((visibility_level = 20) OR (visibility_level = 10) OR (ANY (id = (hashed SubPlan 1).col1)))
SubPlan 1
-> Index Only Scan using project_authorizations_pkey on project_authorizations (cost=0.58..1454.61 rows=12195 width=4)
Index Cond: (user_id = 10762712)
-> Memoize (cost=0.58..0.72 rows=1 width=4)
Cache Key: projects.namespace_id
Cache Mode: logical
-> Index Scan using namespaces_pkey on namespaces (cost=0.57..0.71 rows=1 width=4)
Index Cond: (id = projects.namespace_id)
Filter: (traversal_ids @> '{9970}'::integer[])- https://console.postgres.ai/gitlab/gitlab-production-main/sessions/51196/commands/151509
- https://console.postgres.ai/gitlab/gitlab-production-main/sessions/51196/commands/151510
- Key query:
Database review
Requesting review from the Database team per the scopes and cross-table joins introduced in ee/app/finders/security/security_policy_eligible_projects_finder.rb.
Test plan
- Finder returns projects from sibling groups of linked groups
- Finder returns projects from parent groups of linked groups
- Deduplicates when multiple linked groups share the same root ancestor
- Returns
Project.nonewhen SPP has no linked groups - Returns
Project.nonewhen license is not available - Respects project visibility (only returns projects visible to current user)
- Excludes archived and deleted projects
- Search parameter filters results by project name/path
- Pagination works correctly via
connection_type
Closes #593858