Default the policy store list endpoint to enabled policies only

What does this MR do and why?

The GET /organizations/:id/security/policy_store endpoint accepts an optional lifecycle_state parameter, one of active (default), disabled, or all. active and disabled return only policies in that state; all returns every policy regardless of lifecycle state, so a caller that wants the full list (for example the frontend list page) does not need two requests. This filter threads through Security::SecurityOrchestrationPolicies::PolicyStore::ListService, the Gitlab::PolicyStore.list gem-level facade, and both repository adapters: the ActiveRecord adapter filters in Postgres using the for_lifecycle_state scope from the base MR, and the in-memory adapter filters in Ruby. Both adapters already treat a nil lifecycle_state as "no filter" (from the base MR's pagination work), so all only needed a one-line mapping to nil inside ListService; no adapter or gem change was required.

Resolvers::Govern::PoliciesResolver (GraphQL) shares the list service and still defaults to active-only policies, since it passes no lifecycle_state argument. Exposing this filter over GraphQL is a follow-up, not in scope here.

Design decisions

The parameter started out as a boolean enabled (true/false), but a boolean cannot express "give me everything," which the frontend list page needs. Renaming it to lifecycle_state with an all sentinel value follows the same shape GitLab's own REST API already uses for state=opened|closed|merged|all on issues and merge requests, so this isn't a novel convention. Renaming now, before enabled had a real caller (the frontend follow-up hasn't landed, and GLAZ's client sends neither parameter today), avoids a later breaking change.

The default stays active so that GLAZ, an external Rust service in a separate repository that evaluates policies by calling this endpoint, keeps getting the safe, active-only result with zero changes to its codebase.

The ActiveRecord adapter's query depends on which filters are given.

-- organization_id, trigger_type, and lifecycle_state: the case a specific trigger's evaluation read exercises
SELECT "govern_policies".* FROM "govern_policies"
WHERE "govern_policies"."organization_id" = 1 AND "govern_policies"."trigger_type" = 0 AND "govern_policies"."lifecycle_state" = 0
ORDER BY "govern_policies"."id" ASC;

This exact query and index were already captured and reviewed in !249130 (merged) (merged): an index scan on index_govern_policies_on_org_trigger_lifecycle_and_id, no sort step, at https://postgres.ai/console/gitlab/gitlab-production-sec/sessions/54424/commands/157320. Linking to that rather than recapturing a new plan, since the predicates and index are unchanged from that session.

The two-predicate case, organization_id and lifecycle_state with no trigger_type (the frontend's real call after this merges, since ee/app/assets/javascripts/policy_store/policies.js never sends trigger_type), is the same query the base MR !253088 (merged) documents and captures a plan for. See that MR rather than a second copy here. lifecycle_state=all skips this predicate entirely, so it stays a single-predicate scan on organization_id.

Flagging for a database reviewer regardless, since this is the first real caller of the scope added in the base MR.

How to set up and validate locally

  1. Enable the feature flag and settings the endpoint requires on the rails console:
Feature.enable(:security_policies_v2)
Gitlab::CurrentSettings.current_application_settings.update!(policy_store_experiment_enabled: true)
  1. Verify both predicates the endpoint's own before block reads took effect:
Feature.enabled?(:security_policies_v2, :instance) # => true
Gitlab::CurrentSettings.policy_store_experiment_enabled? # => true
  1. As an organization owner, create an active policy and a disabled policy through the REST API:
curl --include --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
  --data 'name=Active policy&trigger_type=deployment_requested&rules[][type]=custom&rules[][value]=package governance' \
  --url "http://gdk.test:3000/api/v4/organizations/1/security/policy_store"

Verify it returns 201 Created. Repeat with name=Disabled policy&trigger_type=deployment_requested&lifecycle_state=disabled&rules[][type]=custom&rules[][value]=package governance, and verify that one returns 201 Created too.

  1. List with no lifecycle_state param:
curl --include --header "PRIVATE-TOKEN: <your_access_token>" \
  --url "http://gdk.test:3000/api/v4/organizations/1/security/policy_store"

Verify the response contains only "Active policy", because lifecycle_state defaults to active.

  1. List with lifecycle_state=disabled:
curl --include --header "PRIVATE-TOKEN: <your_access_token>" \
  --url "http://gdk.test:3000/api/v4/organizations/1/security/policy_store?lifecycle_state=disabled"

Verify the response contains only "Disabled policy", the opposite of step 4.

  1. List with lifecycle_state=all:
curl --include --header "PRIVATE-TOKEN: <your_access_token>" \
  --url "http://gdk.test:3000/api/v4/organizations/1/security/policy_store?lifecycle_state=all"

Verify the response contains both "Active policy" and "Disabled policy" in a single request.

  1. List with an invalid value:
curl --include --header "PRIVATE-TOKEN: <your_access_token>" \
  --url "http://gdk.test:3000/api/v4/organizations/1/security/policy_store?lifecycle_state=bogus"

Verify the response is 400 Bad Request.

References

Edited by Marcos Rocha

Merge request reports

Loading
Loading