Implementation: Policy scope project dropdown should show all eligible projects
## Parent Issue
Related to https://gitlab.com/gitlab-org/gitlab/-/issues/593779
---
## Summary
The policy scope "specific projects" dropdown (used across **all policy types** — Scan Execution, Merge Request Approval, Pipeline Execution, Vulnerability Management) only shows projects from **descendant** namespaces of each SPP-linked group. Projects in sibling subgroups or parent groups are not available for selection, even though they are valid targets for policy scope.
This is a **UI-only limitation** — the runtime `PolicyScopeChecker` does not enforce hierarchy restrictions, so manually-edited YAML referencing sibling/parent projects works correctly.
Additionally, the current dropdown hardcodes `first: 20` in the GraphQL query, meaning users cannot see projects beyond the first 20 even if they are otherwise eligible.
---
## Root Cause
When editing a policy at **group level** with an assigned Security Policy Project (SPP), the `ScopeProjectSelector` renders the `LinkedGroupsProjectsDropdown` component, which:
1. Queries `project(fullPath: SPP).securityPolicyProjectLinkedGroups` to get groups linked to the SPP
2. For each linked group, fetches `projects(includeSubgroups: true)`
3. This resolves via `Namespace#all_projects` → `self_and_descendant_ids` — **only projects within and below that group**
### Example
```
topgroup/
├── subgroup-a/ ← SPP linked here
│ ├── project-1 ✅ visible
│ └── child-group/
│ └── project-2 ✅ visible
├── subgroup-b/ ← sibling
│ └── project-3 ❌ NOT visible
└── project-4 ← parent level
❌ NOT visible
```
**Note:** The fallback `GroupProjectsDropdown` (used when no SPP is assigned) already works correctly because it queries from `rootNamespacePath`, which is the top-level group.
### Affected Files
| File | Role |
|------|------|
| `ee/.../components/policy_editor/scope/scope_project_selector.vue` | Chooses which dropdown to render |
| `ee/.../components/shared/linked_groups_projects_dropdown.vue` | The dropdown showing limited projects |
| `ee/.../graphql/queries/get_spp_linked_groups_projects.query.graphql` | GraphQL query scoped per-linked-group |
| `ee/.../components/shared/group_projects_dropdown.vue` | Fallback dropdown (already works correctly) |
---
## Proposed Solution
Add a dedicated backend resolver (`securityPolicyEligibleProjects`) on `ProjectType` that finds all projects eligible for policy scope selection when a project is used as an SPP. The resolver collects root ancestors of all linked groups and returns all projects under those root namespaces with proper server-side pagination and search.
This approach was chosen over the frontend-only alternative (MR 1 in the original plan) based on [team discussion](https://gitlab.com/gitlab-org/gitlab/-/work_items/593858#note_3213783049):
- Avoids N+1-like client-side round-trips (fetch linked groups → extract roots → query per root)
- Server-side pagination + search handles scale regardless of root namespace size
- `traversal_ids` makes root ancestor lookups efficient
- Follows the precedent set by `all_project_ids` in `Security::OrchestrationPolicyConfiguration`
- Fixes the hardcoded `first: 20` pagination limitation by using proper cursor-based pagination
---
## MR Breakdown
### Dependency & Merge Order
```
MR1 (Backend) ──sequential──▶ MR2 (All changes → Frontend after rebase)
```
**Stacking approach:** MR2 contains **all changes** (backend + frontend) so it can be tested end-to-end independently. After MR1 is merged, rebase MR2 — the backend commits are already in `master` and will drop off, leaving only the frontend changes.
### MR 1: Backend — Dedicated resolver for eligible projects
#### Task 1.1: Create `SecurityPolicyEligibleProjectsFinder`
**New file:** `ee/app/finders/security/security_policy_eligible_projects_finder.rb`
A finder that takes an SPP project, finds all linked groups, collects unique root ancestors, and returns all projects under those root ancestors.
**Important:** Do not use a hard `.limit(LIMIT)` in the finder — let GraphQL's `connection_type` handle pagination natively via `default_max_page_size`. This follows the same pattern as `GroupProjectsFinder`, which only applies `Project.with_limit(n)` when explicitly requested via `options[:limit]`, otherwise letting the connection type manage it. ([ref](https://gitlab.com/gitlab-org/gitlab/-/work_items/593858#note_3232504793))
<details>
<summary>Code snippet (click to expand)</summary>
```ruby
# frozen_string_literal: true
module Security
# Finds all projects eligible for policy scope selection for a given
# Security Policy Project (SPP). Collects root ancestors of all linked
# groups, then returns all projects under those root namespaces.
#
# This ensures sibling and parent-level projects are visible, not just
# descendants of each linked group.
#
# rubocop:disable CodeReuse/ActiveRecord -- Finder
class SecurityPolicyEligibleProjectsFinder
def initialize(current_user, security_policy_project, params = {})
@current_user = current_user
@security_policy_project = security_policy_project
@params = params
end
def execute
return Project.none unless security_policy_project
return Project.none unless licensed?
projects = eligible_projects
projects = by_search(projects)
projects
end
private
attr_reader :current_user, :security_policy_project, :params
def licensed?
security_policy_project.licensed_feature_available?(:security_orchestration_policies)
end
def eligible_projects
root_namespace_ids = root_ancestor_ids
return Project.none if root_namespace_ids.empty?
Project
.in_namespace(Namespace.where(id: root_namespace_ids).self_and_descendant_ids)
.non_archived
.without_deleted
.public_or_visible_to_user(current_user)
end
# Uses traversal_ids[1] for efficient root ancestor extraction.
# Deduplicates when multiple linked groups share the same root.
def root_ancestor_ids
security_policy_project
.security_policy_project_linked_groups
.select('DISTINCT ON (traversal_ids[1]) traversal_ids[1] AS root_id')
.map(&:root_id)
end
def by_search(projects)
return projects if params[:search].blank?
projects.search(params[:search])
end
end
# rubocop:enable CodeReuse/ActiveRecord
end
```
</details>
#### Task 1.2: Create GraphQL resolver + field
**New file:** `ee/app/graphql/resolvers/security/security_policy_eligible_projects_resolver.rb`
<details>
<summary>Code snippet (click to expand)</summary>
```ruby
# frozen_string_literal: true
module Resolvers
module Security
class SecurityPolicyEligibleProjectsResolver < BaseResolver
type Types::ProjectType.connection_type, null: true
argument :search, GraphQL::Types::String,
required: false,
description: 'Search query for project name or path.'
def resolve(**args)
return Project.none unless object&.licensed_feature_available?(:security_orchestration_policies)
::Security::SecurityPolicyEligibleProjectsFinder
.new(current_user, object, args)
.execute
end
end
end
end
```
</details>
**Existing file:** `ee/app/graphql/ee/types/project_type.rb` — add after `security_policy_project_linked_groups` field (~line 305):
<details>
<summary>Code snippet (click to expand)</summary>
```ruby
field :security_policy_eligible_projects,
::Types::ProjectType.connection_type,
null: true,
description: 'All projects eligible for policy scope selection when this project ' \
'is used as a Security Policy Project. Includes projects from root ' \
'ancestors of all linked groups.',
resolver: ::Resolvers::Security::SecurityPolicyEligibleProjectsResolver
```
</details>
#### Task 1.3: Backend tests
**New files:**
- `ee/spec/finders/security/security_policy_eligible_projects_finder_spec.rb`
- `ee/spec/graphql/resolvers/security/security_policy_eligible_projects_resolver_spec.rb`
**Test cases:**
- [ ] Returns projects from sibling groups of linked groups
- [ ] Returns projects from parent groups of linked groups
- [ ] Deduplicates when multiple linked groups share the same root ancestor
- [ ] Returns `Project.none` when SPP has no linked groups
- [ ] Returns `Project.none` when 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` (no hard limit interference)
---
### MR 2: Frontend — Update dropdown to use new resolver
> **Note:** This MR is stacked on MR1 and initially contains all changes (backend + frontend) for independent testability. After MR1 is merged and this branch is rebased, only frontend changes will remain.
#### Task 2.1: Create new GraphQL query
**New file:** `ee/.../graphql/queries/get_spp_eligible_projects.query.graphql`
<details>
<summary>Code snippet (click to expand)</summary>
```graphql
query getSecurityPolicyEligibleProjects(
$fullPath: ID!
$search: String
$first: Int
$after: String
) {
project(fullPath: $fullPath) {
id
securityPolicyEligibleProjects(search: $search, first: $first, after: $after) {
nodes {
id
name
fullPath
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
```
</details>
#### Task 2.2: Refactor `LinkedGroupsProjectsDropdown` (or replace with new component)
**File:** `ee/.../components/shared/linked_groups_projects_dropdown.vue`
Replace the multi-query approach (fetch linked groups → fetch projects per group) with a single `securityPolicyEligibleProjects` query. This is now a straightforward query swap since the backend handles all the logic.
**Changes:**
1. Replace the existing GraphQL query with `getSecurityPolicyEligibleProjects`
2. Remove client-side project aggregation/deduplication logic (no longer needed)
3. Use proper `first`/`after` cursor-based pagination instead of hardcoded `first: 20`
4. Maintain existing search debounce and selection behavior
#### Task 2.3: Update `ScopeProjectSelector` logic (if needed)
**File:** `ee/.../components/policy_editor/scope/scope_project_selector.vue`
Update to pass SPP `fullPath` to the refactored dropdown component.
#### Task 2.4: Frontend tests
**Files:**
- `ee/spec/frontend/.../linked_groups_projects_dropdown_spec.js`
- `ee/spec/frontend/.../scope_project_selector_spec.js`
**Test cases:**
- [ ] Projects from sibling groups are visible in the dropdown
- [ ] Projects from parent groups are visible in the dropdown
- [ ] Backward compatibility: existing project selection still works
- [ ] Pagination works correctly (no hardcoded `first: 20` limit)
- [ ] Search works via server-side filtering
- [ ] Empty state when no eligible projects exist
---
## Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| Performance: Root ancestor query may return many projects for large orgs | Low | Server-side pagination via `connection_type` + server-side search; SPPs are typically linked to a small number of groups so `root_ancestor_ids` resolves to few distinct roots; `namespace_descendants` cache works well at this scale |
| Deduplication: Multiple linked groups with same root ancestor | Low | `DISTINCT ON (traversal_ids[1])` deduplicates at the SQL level |
| Authorization: Users seeing projects they shouldn't | Low | `public_or_visible_to_user` applies visibility filters server-side |
| Pagination: Hard limit interfering with cursor-based pagination | Low | No hard `.limit()` in finder; `connection_type` manages pagination natively |
issue