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
- 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)- Verify both predicates the endpoint's own
beforeblock reads took effect:
Feature.enabled?(:security_policies_v2, :instance) # => true
Gitlab::CurrentSettings.policy_store_experiment_enabled? # => true- 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.
- List with no
lifecycle_stateparam:
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.
- 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.
- 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.
- 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
- Depends on !253088 (merged) (merged)
- Related to !252366 (merged) (merged)
- Related to gitlab-org/auth/glaz!139 (closed) (open)