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
  1. 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.

  1. 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
  1. Restart the web process so the initializer runs
gdk restart rails-web
  1. List the policies with a personal access token that has the api scope, and note the id. 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"
  1. 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>"
  1. Verify HTTP 200, that version is now 2, and that scope_rego carries the new name, because the transpiler emits the policy name into the compiled program
  2. Remove config/initializers/zz_temporary_policy_store_seed.rb and put the worker count back
gdk config set gitlab.rails.puma.workers 2
gdk reconfigure
gdk restart rails-web

The rest is covered by the request spec:

  • Sending a hand-authored scope_rego clears policy_scope, so the two cannot describe different sets of projects
  • Sending a blank scope_rego retires the hand-authored program rather than keeping it, and the policy falls back to a program compiled from whatever policy_scope is stored
  • A request naming no changeable attribute returns 400, rather than succeeding and incrementing the policy's version for nothing
  • An organization member who is not an owner gets 403, so the update_govern_policy permission gates the route
  • A value outside the allowed list for trigger_type, mode, lifecycle_state, rules, or actions returns 400
  • A policy owned by another organization returns the same 404 Policy Not Found as a missing one and stays unchanged
  • Renaming onto a name another policy in the organization already uses returns 400

References

Edited by Marcos Rocha

Merge request reports

Loading
Loading