Add the policy store update service
What does this MR do and why?
Adds UpdateService, which changes a policy through the store's update operation added
in !249133 (merged). The PATCH
endpoint follows in its own MR, so this one has no route and no permission.
Design decisions
Params reach the store uncompacted, unlike CreateService. The store reads key
presence to decide whether to recompile a scope, so dropping a blank scope_rego would
silently lose the caller's request to retire an authored program and fall back to
policy_scope. A spec pins it: adding .compact to the service fails one example and no other.
The spec covers both blank forms, since a form-encoded body sends the empty string and a
JSON one sends null. It also covers the blank value arriving on its own, not just
alongside a policy_scope: with a replacement scope present the store recompiles either
way, so only the lone case actually distinguishes the two behaviours. Adding .compact to
the service fails that example and no other.
A policy owned by another organization returns the same not-found error as a missing
one. The lookup goes through BaseService#find_policy, so an id cannot be used to change
a policy across organizations, matching what show and delete already guarantee.
Gitlab::PolicyStore::NotFound is still rescued around the write, because the lookup and
the update are not atomic.
The conflicting-scope rule and params both move to BaseService. The rule now has
two callers, and holding params in the base class is what lets it read them directly. It
also gives the key form one place to be settled. The store accepts symbols or strings, so a
string-keyed hash used to read as an absent policy_scope, skipping the conflict check and
letting the store discard the scope in favour of the Rego instead of reporting the conflict.
CreateService read every attribute by symbol and had the same gap, so both are now covered
by symbolize_keys in one initializer. A blank scope_rego is deliberately not a conflict: present? is
false for it, which is exactly what makes retiring an authored program work.
How to set up and validate locally
Requires an Ultimate licence. The in-memory store is per-process, so every call below must reach the same Puma worker or console session.
- Enable the feature flag and the instance setting, then confirm the whole gate is open
Feature.enable(:security_policies_v2)
ApplicationSetting.current.update!(policy_store_experiment_enabled: true)
organization = Organizations::Organization.find(Organizations::Organization::DEFAULT_ORGANIZATION_ID)
organization.policy_store_experiment_active? # => trueThat one call ANDs the feature flag, the instance setting and the licence. If it returns false, every step below reports :experiment_not_active without saying which of the three closed.
- On the rails console, create a policy and change its name through the service
policy = Gitlab::PolicyStore.create(
organization_id: organization.id,
name: 'Framework 5 only',
trigger_type: 'deployment_requested',
policy_scope: { compliance_frameworks: [{ id: 5 }] }
)
def rename(organization, policy_id, name)
Security::SecurityOrchestrationPolicies::PolicyStore::UpdateService.new(
organization: organization, policy_id: policy_id, params: { name: name }
).execute
end
result = rename(organization, policy.id, 'Renamed policy')
puts(result.success? ? [result.payload[:policy].name, result.payload[:policy].version].inspect : result.message)-
Verify the name changed and the version is
2, and thatscope_regonow carries the new name, because the transpiler emits it into the compiled program -
Retire the compiled program by sending
scope_regoasnilon its own, the shape a JSON body sends, and verify it recompiles frompolicy_scoperather than clearing the scope. This is the case the uncompacted pass-through exists for, since.compactwould drop the key before the store could read it
result = Security::SecurityOrchestrationPolicies::PolicyStore::UpdateService.new(
organization: organization,
policy_id: policy.id,
params: { scope_rego: nil }
).execute
puts(result.success? ? result.payload[:policy].scope_rego : result.message)
# still includes "framework_id in {5}", not blank- Try to change a policy that belongs to another organization, and verify it reports not found rather than changing it
other_organization = Organizations::Organization.create!(name: 'Other', path: 'other-org')
other_policy = Gitlab::PolicyStore.create(
organization_id: other_organization.id,
name: 'Other organization policy',
trigger_type: 'deployment_requested'
)
result = Security::SecurityOrchestrationPolicies::PolicyStore::UpdateService.new(
organization: organization,
policy_id: other_policy.id,
params: { name: 'Should not apply' }
).execute
puts [result.reason, result.message].inspect # => [:not_found, "Policy was not found"]
puts Gitlab::PolicyStore.find(other_policy.id).name # => "Other organization policy"- Send both a
policy_scopeand ascope_regoand verify it reports:invalidwithOnly one of policy_scope or scope_rego can be provided - Turn the instance setting from step 1 back off, then re-run only the update, not the create, since the policy name is taken by now
ApplicationSetting.current.update!(policy_store_experiment_enabled: false)
result = rename(organization, policy.id, 'Should not apply')
puts [result.reason, result.message].inspect
# => [:experiment_not_active, "Policy Store experiment is not active for this organization"]
puts Gitlab::PolicyStore.find(policy.id).name # => "Renamed policy", untouchedReferences
- Related to https://gitlab.com/gitlab-org/gitlab/-/work_items/606971
- Part of https://gitlab.com/groups/gitlab-org/-/epics/22937
- Depends on !249133 (merged) (merged), which added
updateto the store gem. - The
PATCHendpoint that consumes this service: !249174 (merged) (open) - Raised by @Andyschoenen on !247718 (merged)