Add the policy store update endpoint
What does this MR do and why?
Adds PATCH /organizations/278964/security/policy_store/:policy_id to API::Govern::Policies, gated by update_govern_policy for organization owners and instance admins. This completes the Policy Store REST surface: the list, read, create and delete routes have already merged, and this MR adds the last one, the update route.
UpdateService came from !249171 (merged), and the organization lookup and error mapping helpers are already on master, shared by the routes that merged before this one. What this MR adds is the route, its granular token permission group entry, and its specs. !249442 (merged) has since merged and supplied the raw permission and the organization policy grant. The permission stays update-specific rather than one write ability spanning create, update and delete, so a granular token can be scoped to updating without also being able to create or delete.
Design decisions
Every attribute is optional, and at_least_one_of rejects a request that names none. An update with no changes would otherwise succeed and bump version, a change the caller can observe and did not ask for.
A blank scope_rego retires a hand-authored program, but does not clear a policy's scope. The store recompiles the program from whatever policy_scope is still stored, so clearing the scope requires sending an empty policy_scope, which only a JSON body can express.
mode and lifecycle_state read their allowed values from the store. MODES and LIFECYCLE_STATES on Ports::PolicyRepository also supply the in-memory adapter's defaults, so the vocabulary lives in one place rather than one copy per endpoint. The values were chosen to match the Govern::Policy enums already on master (lifecycle_state: active/disabled, mode: audit/warn/enforce), since the ActiveRecord-backed repository (https://gitlab.com/gitlab-org/gitlab/-/work_items/606969) builds Govern::Policy records and a value outside those enums would raise ArgumentError. Nothing in the store validates against either list yet, so the API remains the only gate, tracked in https://gitlab.com/gitlab-org/gitlab/-/work_items/613662.
name, description, and scope_rego carry a limit: read from TEXT_LIMITS. doc/development/api_styleguide.md asks for every free-form String parameter to be constrained so a client cannot send an unbounded payload before anything validates it. The numbers come from the store rather than being restated here, so the two cannot fork.
rules carries neither a count bound nor allow_blank: false. The store treats an empty rules array as legitimate and defaults to it, so bounding it here made the endpoint stricter than what it writes to. Nothing bounds rules today: TEXT_LIMITS[:scope_rego] covers only the compiled scope program, which compiled_scope_rego builds from policy_scope and name alone. A count bound belongs next to TEXT_LIMITS in the repository rather than at one endpoint, tracked in https://gitlab.com/gitlab-org/gitlab/-/work_items/612905.
boundary_type: :instance is wider than the resource, and is a workaround: Authz::Boundary supports project, group, user and instance only, so an organization-scoped route has nowhere else to sit. Per-organization enforcement still happens in authorized_organization!.
doc/api/openapi/openapi_v3.yaml does not change because the route is hidden true, and doc/auth/tokens/fine_grained_access_tokens_rest.md gains one row, regenerated with bundle exec rake gitlab:permissions:routes:compile_docs.
How to set up and validate locally
The store is in-memory and per process. The rails console holds its own copy that the web server never sees, so seeding with Gitlab::PolicyStore.create on the console and then calling the API always answers 404. The seed therefore goes in an initializer, which runs inside the Puma process, and every check below goes through the API.
Requires an Ultimate licence and an organization owner. Run GDK with a single Puma worker, since without preloading each worker seeds and holds its own copy:
gdk config set gitlab.rails.puma.workers 1
gdk reconfigure- Enable the experiment on the rails console, confirm the gate is open, and note the base URL
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? # => true
puts "organization #{organization.id}, url #{Gitlab.config.gitlab.url}"That one call ANDs the feature flag, the instance setting and the licence. If it returns false, every step below answers 403 or 404 without saying which gate closed.
- Seed a scoped policy into the web process, in
config/initializers/zz_temporary_policy_store_seed.rb
# frozen_string_literal: true
Rails.application.config.after_initialize do
next unless Rails.env.development?
Gitlab::PolicyStore.create(
organization_id: Organizations::Organization::DEFAULT_ORGANIZATION_ID,
name: 'Framework 5 only',
trigger_type: 'deployment_requested',
policy_scope: { compliance_frameworks: [{ id: 5 }] }
)
end- Restart the web process so the initializer runs
gdk restart rails-web- List the policies with a personal access token that has the
apiscope, and note theid. This is also what proves the web process can see the seed, which the console cannot tell you
curl --header "PRIVATE-TOKEN: <your_token>" \
"<your GDK URL>/api/v4/organizations/<organization_id>/security/policy_store"- Rename it
curl --request PATCH --header "PRIVATE-TOKEN: <your_token>" --write-out '\nHTTP %{http_code}\n' \
--data-urlencode "name=Renamed policy" \
"<your GDK URL>/api/v4/organizations/<organization_id>/security/policy_store/<policy_id>"- Verify
HTTP 200, thatversionis now2, and thatscope_regocarries the new name, because the transpiler emits the policy name into the compiled program - Remove
config/initializers/zz_temporary_policy_store_seed.rband put the worker count back
gdk config set gitlab.rails.puma.workers 2
gdk reconfigure
gdk restart rails-webThe rest is covered by the request spec:
- Sending a hand-authored
scope_regoclearspolicy_scope, so the two cannot describe different sets of projects - Sending a blank
scope_regoretires the hand-authored program rather than keeping it, and the policy falls back to a program compiled from whateverpolicy_scopeis stored - A request naming no changeable attribute returns
400, rather than succeeding and incrementing the policy'sversionfor nothing - An organization member who is not an owner gets
403, so theupdate_govern_policypermission gates the route - A value outside the allowed list for
trigger_type,mode,lifecycle_state,rules, oractionsreturns400 - A policy owned by another organization returns the same
404 Policy Not Foundas a missing one and stays unchanged - Renaming onto a name another policy in the organization already uses returns
400
References
- Part of https://gitlab.com/gitlab-org/gitlab/-/work_items/606971
- Part of https://gitlab.com/groups/gitlab-org/-/epics/22937
- Depends on !249171 (merged) (merged), which adds
UpdateService - The create endpoint, which this MR was stacked on until it merged: !249155 (merged) (merged)
- Uses the same per-action permission split as !249442 (merged) (merged), which landed the raw permission and the organization policy grant this MR used to carry
- Raised by @Andyschoenen on !247718 (merged)
- Follow-up, a blank
ruleselement bypassing nested validation: https://gitlab.com/gitlab-org/gitlab/-/work_items/613680 - Follow-up, validating
modeandlifecycle_statein the store: https://gitlab.com/gitlab-org/gitlab/-/work_items/613662 - Audit events for policy store writes, which this endpoint will need: #608265