Add a for_lifecycle_state scope to Govern::Policy

What does this MR do and why?

Adds a new scope for_lifecycle_state to the Govern::Policy model, right next to the existing for_trigger_type scope. It filters policies by their lifecycle_state column, accepting active or disabled.

This scope has no caller yet in this MR. A second, stacked MR wires it into the policy store's REST list endpoint to add an enabled filter there. Landing the scope as its own small MR keeps the two changes independently reviewable: a pure model and query change here, and the endpoint and service wiring in the next MR.

The policy store's list endpoint (GET /organizations/:id/security/policy_store) currently returns both active and disabled policies with no way to filter by lifecycle state. GLAZ, an external Rust service, calls this exact endpoint to load policies for security policy evaluation and needs a way to ask for active policies only. No database migration is included or needed: the index index_govern_policies_on_org_trigger_lifecycle_and_id already exists in the schema on (organization_id, trigger_type, lifecycle_state, id) and is currently unused. This scope's follow-up caller will be the first to use it.

The query that will actually run once this scope is used, and the one the console steps below exercise, chains it onto for_organization the way any real caller must:

SELECT "govern_policies".* FROM "govern_policies"
WHERE "govern_policies"."organization_id" = 1 AND "govern_policies"."lifecycle_state" = 0
ORDER BY "govern_policies"."id" ASC;

plan

organization_id is the leading column of index_govern_policies_on_org_trigger_lifecycle_and_id, so Postgres can use it as an index condition, but trigger_type (the next column) isn't in this WHERE clause, so lifecycle_state can only apply as a row filter on top of that scan, not as its own index condition. govern_policies has no production rows yet, so a meaningful plan needs the synthetic seed described at !249574 (merged), the same one !252366 (merged) used: 20,000 rows across 50 organizations, 500 namespaces, and three trigger types.

Flagging this for a database reviewer regardless, since it's a new scope.

This MR also adds an entry to scripts/lint/keela_excluded.yml under a new scopes: section, allowlisting for_lifecycle_state as intentionally unused for now.

How to set up and validate locally

  1. Open a Rails console and set up an organization with an active and a disabled policy for the same trigger:
organization = Organizations::Organization.default_organization
group = Group.first || FactoryBot.create(:group, organization: organization)

active_policy = Govern::Policy.create!(organization: organization, namespace: group,
  name: "Active policy", trigger_type: :deployment_requested)
disabled_policy = Govern::Policy.create!(organization: organization, namespace: group,
  name: "Disabled policy", trigger_type: :deployment_requested, lifecycle_state: :disabled)

[active_policy.persisted?, disabled_policy.persisted?]
# => [true, true]
  1. Verify the scope returns only the active policy, scoped to the organization the way any real caller must:
Govern::Policy.for_organization(organization.id).for_lifecycle_state(:active).pluck(:name)
# => ["Active policy"]
  1. Flip the argument and verify it returns only the disabled policy:
Govern::Policy.for_organization(organization.id).for_lifecycle_state(:disabled).pluck(:name)
# => ["Disabled policy"]

References

Edited by Marcos Rocha

Merge request reports

Loading
Loading