Compile policy rules to Rego on create and update
What does this MR do and why?
Compiles a policy's rules to Rego on create and on update.
Gitlab::PolicyStore::RuleTranspiler turns one authored rule, a { type, value } jsonb entry, into
one self-contained Rego program, stored under rego on the entry that produced it. The entry points
are Ports::PolicyRepository#with_compiled_rules on create and #with_updated_rules on update. It
reuses ScopeTranspiler's shape and JsonValue.deep_stringify, so there is little new to read, and a
rule reads the same whether its keys are the strings a jsonb column returns or the symbols a console
session types.
environment is the only emitter here, since a custom rule is passed through as authored, and
calendar follows in a separate MR, because normalising its timestamps is a large enough concern to
review on its own. Gitlab::PolicyStore::Rules::ALL still advertises calendar as authorable, and
the REST endpoint serves that catalogue, so until the follow-up lands, a calendar rule is refused
with rule 0: unsupported rule type "calendar". What does not ship in either MR is the validator
port, since save-time validation needs a GLAZ validate that is not on master yet.
Compiling on create is also what makes three previously valid policies fail to save, all
deliberately: a scan_finding rule, which belongs to a merge request trigger the store does not
have; a non-array rules such as { "rules" => [...] }, which would leave every rule with no
program; and a custom rule declaring a package other than governance, which the Policy Engine
queries and finds nothing in. The alternative in each case is a policy that saves, reads as enforcing
in the UI, and enforces nothing.
Two encoding failures used to escape as an unhandled exception rather than a ValidationError, which
is a 500 where every other refusal in this list is a 400, since CreateService rescues only
ValidationError. A name or tier whose bytes cannot reach UTF-8, and a custom program that is not
UTF-8, now fail with a ValidationError naming the value and the encoding found.
A refusal also names an offending value only up to 64 characters, and appends the value's real length.
Nothing bounds the size of an authored rule, since rules has no entry in TEXT_LIMITS and there is
no database limit for it to mirror, and the message becomes an API response body and a log line. A
200,000-character rule type used to produce a 200,032-character ValidationError message, and now
produces one of 116 characters. Nor does a refusal render the value it names, because Integer#to_s
on a wide enough value is superlinear: at five million digits, refusing the rule went from 0.280
seconds to 0.000478, against 0.288 seconds to render that Integer once. rule_index is coerced for a
different reason, that it is emitted into the generated program and a caller outside the store
supplies it.
Design decisions
- A rename does not touch a compiled rule, unlike a compiled scope.
with_updated_scoperegeneratesscope_regoon a rename, becauseScopeTranspileremits the policy name into it.with_updated_rulesdoes not, becauseRuleTranspilernever sees the policy name. Afterupdate(id, name: "Renamed"),rulesis byte-identical andscope_regois regenerated. - One program per rule, not per policy. Each entry's
regocarries its ownpackageline, which is why the compiled Rego rides on the entry rather than in a column of its own. A combined per-policy module stays reachable from that: keep the firstpackage governanceline, strip it from the rest. Combining in the API serialization layer is under discussion, since evaluating one module per policy is measurably cheaper, so an emitted violation carries itsrule_indexto keep attribution through a merge.violationis a set, and two rules emitting identical objects would otherwise deduplicate into one. A policy still fires when any one of its rules fires, but that OR belongs to whoever evaluates the programs. - Output is
package governanceexposingviolation contains {"msg", "details"}, one of the three shapes the Policy Engine parses, following the reference rules linked below. Noimport rego.v1is emitted: Regorus 0.11 enables it by default, so the line is a verified no-op. - A name conflict is reported before anything compiles.
validate_name_available!moved ahead ofwith_compiled_scopeandwith_compiled_ruleson both write paths, so a call that both takes a name already in use and carries an uncompilable rule reports the name: the cheaper check, and the one the caller can act on without reading the rest.
The full reasoning for each of those is in the commit message.
How to set up and validate locally
Requires an Ultimate licence. Every block below runs in gdk rails console.
- Turn the experiment on and confirm it took effect, because until it is active every step
below returns
Policy Store experiment is not active for this organizationand no rule reaches the transpiler
Feature.enable(:security_policies_v2)
ApplicationSetting.current.update!(policy_store_experiment_enabled: true)
organization = Organizations::Organization.first
organization.policy_store_experiment_active?
# => true- Define the helper the rest of the steps call. It reads the message rather than the payload when a create fails, since a rejected policy has no payload, and it returns the stored rule so the next step can look at the whole entry
def create_rule(organization, rules, name:)
result = Security::SecurityOrchestrationPolicies::PolicyStore::CreateService.new(
organization: organization,
params: { name: name, trigger_type: "deployment_requested", rules: rules }
).execute
return puts(result.message) unless result.success?
result.payload[:policy].rules.first
end- Compile an
environmentrule. Verify the program carries both conditions, because authoring a name and a tier together requires both to hold rather than either
environment_rule = { "type" => "environment",
"value" => { "names" => ["prod-us-east"], "tiers" => ["production"] } }
stored_environment = create_rule(organization, [environment_rule], name: "Production only")
puts stored_environment["rego"] if stored_environment- Resend a rule through
updatewith a hand-writtenrego, deliberately supplying"package attacker". Verify the stored program ispackage governancematching on{"staging"}rather than the attacker package sent, because an authoredregois derived and does not survive an update either, and before this MR that call storedpackage attackerverbatim
create_rule(organization, [{ "type" => "environment", "value" => { "tiers" => ["production"] } }],
name: "Compiled on update")
policy = Gitlab::PolicyStore.list(organization_id: organization.id)
.find { |candidate| candidate.name == "Compiled on update" }
authored = [{ "type" => "environment", "value" => { "tiers" => ["staging"] }, "rego" => "package attacker" }]
puts(policy ? Gitlab::PolicyStore.update(policy.id, rules: authored).rules.first["rego"] : "create failed, see the message above")- Author a
customrule declaringpackage governance. Verify the storedregois byte-identical to the value authored, because acustomrule's value is already a whole program, so it is stored as authored rather than reformatted
program = "package governance\n\nviolation contains {\"msg\": \"no production deploys\"}\n"
stored_custom = create_rule(organization, [{ "type" => "custom", "value" => program }],
name: "Hand-written program")
puts(stored_custom ? stored_custom["rego"] == program : "create failed, see the message above")
# => true- Author a
customrule declaring the wrong package. Verify it fails withrule 0: custom rule must declare `package governance`, found "gitlab.policy", because the Policy Engine queries one package, so a program declaring another comes back empty with no error, which nothing downstream can tell apart from a rule that did not fire
create_rule(organization, [{ "type" => "custom", "value" => "package gitlab.policy\n\nallow := true\n" }],
name: "Wrong package")- Author the two rule types this MR has no emitter for:
scan_finding, which the store accepted before this MR, andcalendar, whose emitter lands in the follow-up. Verify each fails withrule 0: unsupported rule type, naming the type, and that neither stored anything, because a rule with no emitter would save a policy that reads as enforcing and enforces nothing
before = Gitlab::PolicyStore.list(organization_id: organization.id).size
create_rule(organization, [{ "type" => "scan_finding" }], name: "Scan finding policy")
create_rule(organization, [{ "type" => "calendar", "value" => {} }], name: "Calendar policy")
Gitlab::PolicyStore.list(organization_id: organization.id).size == before
# => true- Confirm the opposite path still saves. Create a policy with no rules at all and verify
rulescomes back[], because having nothing to compile is not the same as having nothing to store
result = Security::SecurityOrchestrationPolicies::PolicyStore::CreateService.new(
organization: organization,
params: { name: "No rules at all", trigger_type: "deployment_requested", rules: [] }
).execute
puts(result.success? ? result.payload[:policy].rules.inspect : result.message)
# => []Expected result: before this MR, a stored rule was the { type, value } entry as
authored, a scan_finding rule saved, and an update stored whatever rego it was handed.
After it, an environment or custom entry carries a third key holding a compiled
package governance program, on create and on update alike, and a rule the store cannot
compile, including a calendar rule, fails either call with the rule's index in the
message. A policy with no rules is unaffected.
Names are unique within an organization, so re-running any step needs a fresh name:. The
helper prints the error, so a repeat shows as a message rather than an exception.
References
- Related to https://gitlab.com/gitlab-org/gitlab/-/work_items/607656, which stays open for the validator port and the size bound
- The reference Rego rules the output contract follows, and the source of the decision to drop
import rego.v1: https://gitlab.com/gitlab-org/gitlab/-/work_items/607649#note_3653227579 and https://gitlab.com/gitlab-org/gitlab/-/work_items/607649#note_3677019542 - The deployment context the emitted programs read: https://gitlab.com/gitlab-org/gitlab/-/work_items/607786
- Follows !247674 (merged), the scope transpiler this mirrors (merged)
- Epic: https://gitlab.com/groups/gitlab-org/-/epics/22937
- The
calendarrule type follows in !250203 (merged), which targets this branch (open)