Add Policy Store v2 authoring services

What does this MR do and why?

Adds the Policy Store v2 authoring services, ported from the R&D experiment (!246660 (closed)) and retargeted at the Gitlab::PolicyStore facade:

  • Security::SecurityOrchestrationPolicies::PolicyStore::{Create,Find,List,Destroy}Service, over a shared BaseService that holds the experiment gate and the organization scoping
  • Gitlab::PolicyStore.reset_configuration! in the gem, for gem specs and console sessions
  • a :policy_store spec shared context that isolates the in-memory store per example, registered in spec/support/known_rspec_metadata_keys.yml

Part 4 of the Policy Store v2 MR split (see &22937).

The services go through the gem facade, which defaults to the in-memory repository while the persistent adapter is still in progress (#606969). They need no table, no model, and no migration to be complete and testable, and they do not change when the real adapter is configured at boot: that is what the port/adapter seam is for. Nothing calls them until the REST API (#606971), so there is no user-facing behaviour here yet.

Every service is scoped to the group organization

ListService hands organization_id to the store, which filters. FindService and DestroyService cannot: the merged facade contract is find(id) / delete(id), so policies are addressed by bare ID. They read the policy first and compare organization_id in BaseService#find_policy instead.

A policy in another organization answers exactly as an ID that exists nowhere: reason: :not_found, message Policy was not found. The response therefore cannot be used to learn whether an ID exists somewhere else.

Pushing organization_id down into the port is still worth doing, so the filter lives in one place rather than in each caller. Noted on #606969.

Only one scope form is accepted

policy_scope (structured) and scope_rego (hand-authored) are alternative authoring forms of a single scope, and the store keeps only scope_rego, clearing policy_scope once Rego is present. A request carrying both would therefore lose the structured scope without saying so, so CreateService refuses it with reason: :invalid.

Deliberately left out

  • The permission YAMLs listed on the issue. bundle exec rake gitlab:permissions:validate exits 1 for both halves: assignable groups are rejected because no REST route references their raw permissions, and raw definitions are rejected because the permissions are "not found in declarative policy". They need the GroupPolicy rules and the routes that check them, so they belong with #606971.
  • current_user / authorization. The services take no current_user; the calling endpoint is the sole enforcement point, consistent with the experiment.

References

Related to https://gitlab.com/gitlab-org/gitlab/-/work_items/606970

Builds on !247327 (merged) (gem write API) and !247674 (merged) (scope transpiler), both now merged, so this diff is the service layer alone.

How to set up and validate locally

bin/rspec ee/spec/services/security/security_orchestration_policies/policy_store
# => 39 examples, 0 failures

cd gems/gitlab-policy-store && bundle exec rspec

Each service spec runs the 'a service gated by the policy store experiment' shared examples, so the four gate conditions and the sub-group case are covered for all four services.

In the Rails console (the store lives in that process only, and is empty on exit):

group = Group.find_by_full_path('your-group')

ApplicationSetting.current.update!(policy_store_experiment_enabled: true)
group.namespace_settings.update!(policy_store_experiment_enabled: true)
Gitlab::CurrentSettings.expire_current_application_settings
group.policy_store_experiment_active?   # => true (needs the security_policies_v2 flag + Ultimate)

create = Security::SecurityOrchestrationPolicies::PolicyStore::CreateService

result = create.new(
  group: group,
  params: { name: 'Console policy', trigger_id: 'deployment_requested',
            actions: [{ 'type' => 'block' }],
            policy_scope: { compliance_frameworks: [{ id: 5 }] } }
).execute

result.payload[:policy]              # => Gitlab::PolicyStore::Policy
result.payload[:policy].scope_rego   # => "package gitlab.scope\n..."
result.payload[:policy].policy_scope # => nil, the transpiled Rego replaces it
result.payload[:policy].mode         # => "enforce", defaulted by the store, not the service

Both scope forms at once are refused:

create.new(
  group: group,
  params: { name: 'Two scopes', trigger_id: 'deployment_requested',
            policy_scope: { compliance_frameworks: [{ id: 5 }] },
            scope_rego: "package gitlab.scope\n\napplies := true\n" }
).execute.reason   # => :invalid

Another organization's policy is invisible, not merely unauthorized:

# Straight to the store, so the other organization needs no experiment opt-in
elsewhere = Gitlab::PolicyStore.create(
  organization_id: Organization.where.not(id: group.organization_id).first.id,
  name: 'Elsewhere', trigger_id: 'deployment_requested'
)

find = Security::SecurityOrchestrationPolicies::PolicyStore::FindService
find.new(group: group, policy_id: elsewhere.id).execute.message # => "Policy was not found"
find.new(group: group, policy_id: -1).execute.message           # => "Policy was not found"
Security::SecurityOrchestrationPolicies::PolicyStore::ListService.new(group: group)
  .execute.payload[:policies]   # => only this organization's policies

Gitlab::PolicyStore.reset_configuration!   # clears the scratch store

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Marcos Rocha

Merge request reports

Loading
Loading