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