Validate govern_policies rules at the persistence layer
What does this MR do and why?
Govern::Policy#rules now validates its shape at both the Rails model and database layers. A new JsonSchemaValidator validation on the model rejects a rules value that is not an array, contains an element missing type or rego, contains an element with blank rego, or carries any key beyond type, value, and rego. A CHECK constraint on the govern_policies table (check_govern_policies_rules_is_array) enforces the array shape at the database level too, so even a write that bypasses ActiveRecord validations cannot store a non-array rules value.
Gitlab::PolicyStore::RuleProgramMerger#merge reads rules and expects a well-formed shape, but until now that guarantee only held for writes that went through Gitlab::PolicyStore::Ports::PolicyRepository#with_compiled_rules. Govern::Policy itself had no equivalent guarantee, so anything that wrote to the model directly (a console session, a future import path, a background job) had nothing stopping it from persisting a malformed rules array. This MR closes that gap at the model and database layer. RuleProgramMerger#merge keeps its own top-level array guard, and API::Entities::Govern::Policy keeps its own handling, because RuleProgramMerger lives in a standalone gem (gems/gitlab-policy-store) with no dependency on Govern::Policy or the govern_policies table, so a constraint scoped to one column of one table cannot stand in for that class's own input validation.
The scope is deliberately limited to the rules column. The jsonb columns, actions and policy_scope, remain a separate future improvement.
How to set up and validate locally
- In
gdk rails console, build a policy with well-formed rules and an explicit unique name, since the factory's name sequence restarts each console session and step 3 persists a record:
rules = [{ "type" => "custom", "value" => "package governance", "rego" => "package governance" }]
policy = FactoryBot.build(:govern_policy, name: "rules-validation-#{SecureRandom.hex(4)}", rules: rules)
policy.valid? # => true- Set
rulesto an entry missing the requiredregokey, then confirm the model rejects it, because nothing on the model caught this before this change:
policy.rules = [{ "type" => "custom" }]
policy.valid? # => false
policy.errors[:rules] # => ["must be a valid json schema"]- Restore
rules, save, then bypass validations withupdate_columnand setrulesto aHash, because that proves the database constraint is the real backstop, not just the model validator:
policy.rules = rules
policy.save!
policy.update_column(:rules, { "type" => "custom" })
# raises ActiveRecord::StatementInvalid (PG::CheckViolation on check_govern_policies_rules_is_array)- Assign a different well-formed program and save, because reassigning the same value leaves the record unchanged and would prove nothing:
policy.rules = [{ "type" => "custom", "value" => "package governance", "rego" => "package governance\n\nviolation contains \"blocked\"" }]
policy.save! # => true
policy.reload.rules.first["rego"] # => the program above, accepted by both layersReferences
- Closes #618427
- Related to !250949 (merged) (merged)
- Client-side counterpart, blocking the same shapes in the wizard: !251426 (merged) (merged)