Back the Policy Store with an ActiveRecord repository
What does this MR do and why?
ee/lib/govern/policy_store/active_record_policy_repository.rb defines Govern::PolicyStore::ActiveRecordPolicyRepository, a subclass of Gitlab::PolicyStore::Ports::PolicyRepository that implements create, update, find, delete, and list against the govern_policies table, backing the facade with real persistence in place of the gem's in-memory default. It is one of several MRs splitting !248000 (closed) (open) into reviewable pieces, and nothing is wired up yet: the facade still defaults to the in-memory adapter until !250282 (merged) (open) configures it.
create and update re-read the row after writing rather than converting the just-saved record, because Rails assigns nanosecond-resolution timestamps while Postgres keeps only microseconds, and Gitlab::PolicyStore::Policy#== compares those timestamps, so a write's return value could never equal a later read. ee/app/models/govern/policy.rb's namespace_matches_organization validation is now guarded to run only when the record is new or the compared columns changed, because that validation reads namespaces on the main connection while update holds a lock on the govern_policies row on the sec connection.
list reads all policies for an organization, optionally narrowed by trigger type. The capped, evaluation-focused read lives separately in !249714 (closed) (open). Scope reconciliation reuses the port's helpers unchanged, and the validation order follows the in-memory adapter's, so both reject the same input with the same message. Error translation is the adapter's own: ActiveRecord::RecordNotFound becomes Gitlab::PolicyStore::NotFound and a failed validation becomes ValidationError, so no persistence exception crosses the port.
The adapter spec runs the gem's 'a policy repository' shared examples, which is what proves the two adapters are interchangeable behind the facade, then adds coverage the shared contract cannot express: cross-organization validation, unknown enum values, and the duplicate-name race caught only by the unique index. The shared spec support gains a matching 'with a persistent policy store' context alongside the existing in-memory one. Four service specs (CreateService, UpdateService, ListService, DestroyService) each gain a 'with the persistent repository' context on top of their in-memory examples, so both backends are covered rather than one replacing the other.
Queries
This is the first code that executes statements against govern_policies, so every statement
is listed even where the shape is not new. The table, its indexes, and the scopes list
composes all pre-exist, so two of the shapes below already have plans on record.
Seeding
govern_policies has no production rows. The recipe is the one from
!249130 (merged), 20,000 rows over 50
organizations, 500 namespaces, and three trigger types, with one row in ten having no
namespace, plus two additions: id is assigned from the series and the sequence is moved past
it, so that the literal ids in the statements below resolve to real rows.
exec INSERT INTO govern_policies (id, organization_id, namespace_id, name, trigger_type, mode, lifecycle_state, version, rules, actions, created_at, updated_at)
SELECT series, (series % 50) + 1,
CASE WHEN series % 10 = 0 THEN NULL ELSE (series % 500) + 1 END,
format('seeded-policy-%s', series),
series % 3, 2, series % 2, 1, '[]'::jsonb, '[]'::jsonb, now(), now()
FROM generate_series(1, 20000) AS series;exec SELECT setval('govern_policies_id_seq', 20001, false);exec ANALYZE govern_policies;Every statement below was executed against this seed to confirm it runs and touches the rows it is meant to. The reads come first, and the three writes use distinct ids, so the list can be run top to bottom without re-seeding.
list for an organization
Unchanged from the statement reviewed in
!249130 (merged). An index scan on the
(organization_id) prefix of index_govern_policies_on_org_trigger_lifecycle_and_id, with a
sort step, because with no predicate on trigger_type or lifecycle_state the index is not
ordered by id.
SELECT "govern_policies".* FROM "govern_policies"
WHERE "govern_policies"."organization_id" = 1
ORDER BY "govern_policies"."id" ASClist narrowed by trigger type
(organization_id, trigger_type) is a prefix of the same composite index, so the filter is an
index scan, and a sort step is expected because lifecycle_state sits between trigger_type
and id. Both scopes pre-exist, and
!249854 (merged) published this SQL when it added
for_trigger_type, anticipating that the composition would arrive with this adapter. This is
where it first executes.
SELECT "govern_policies".* FROM "govern_policies"
WHERE "govern_policies"."organization_id" = 1
AND "govern_policies"."trigger_type" = 0
ORDER BY "govern_policies"."id" ASCUniqueness check on create
Unchanged from !249130 (merged), which added
unique_govern_policies_organization_id_and_name to serve it. That description writes the
statement as SELECT "govern_policies".*. The validator actually issues SELECT 1 AS one,
which changes neither the predicate nor the index.
SELECT 1 AS one FROM "govern_policies"
WHERE "govern_policies"."name" = 'seeded-policy-10'
AND "govern_policies"."organization_id" = 1
LIMIT 1Uniqueness check on update
The one new predicate shape. Rails excludes the record being saved, so the update form carries
an id predicate the create form does not, and it runs only when name or organization_id
changed. unique_govern_policies_organization_id_and_name covers both equality predicates. It
cannot be index-only, because id is not a column of that index, so the inequality is applied
after the heap fetch. The literals name a row that exists and then exclude it, which is the
case that passes validation.
SELECT 1 AS one FROM "govern_policies"
WHERE "govern_policies"."name" = 'seeded-policy-150'
AND "govern_policies"."id" != 150
AND "govern_policies"."organization_id" = 1
LIMIT 1Name availability pre-check on create
Rules and scope now compile before save_record! runs, so a name conflict that the model's
own uniqueness validator would catch at save time could be masked by an earlier compile error.
validate_name_available! runs this predicate through for_organization and for_name before
compiling, so it executes in addition to the "Uniqueness check on create" query above, not
instead of it: save_record! still triggers the validator's own copy when it calls
record.save. The clause order differs from that query only because it composes two named
scopes rather than one where call; unique_govern_policies_organization_id_and_name serves
both.
SELECT 1 AS one FROM "govern_policies"
WHERE "govern_policies"."organization_id" = 1
AND "govern_policies"."name" = 'seeded-policy-10'
LIMIT 1Name availability pre-check on update
The same pre-check, with the excluding_id scope adding the id predicate the create form
does not carry. Runs before save_record! for the same reason as the create form above, and
likewise in addition to the "Uniqueness check on update" query, not instead of it.
SELECT 1 AS one FROM "govern_policies"
WHERE "govern_policies"."organization_id" = 1
AND "govern_policies"."name" = 'seeded-policy-150'
AND "govern_policies"."id" != 150
LIMIT 1Single-row primary key read
Issued by find, by the lookup inside delete, by the lookup before the lock in update, and
by both reload calls. Served by govern_policies_pkey.
SELECT "govern_policies".* FROM "govern_policies" WHERE "govern_policies"."id" = 10000 LIMIT 1Row lock taken by update
The same access path with FOR UPDATE. Together with the read above, update issues three
primary key reads per call: the lookup before the lock, the FOR UPDATE reload that
with_lock performs, and the reload after saving. The third exists because Postgres stores
microsecond timestamps where Rails assigns nanoseconds, so a value object built from the
just-saved record would never equal one a later read returns.
SELECT "govern_policies".* FROM "govern_policies" WHERE "govern_policies"."id" = 10000 LIMIT 1 FOR UPDATEInsert
Maintains unique_govern_policies_organization_id_and_name and
index_govern_policies_on_org_trigger_lifecycle_and_id. The name is free for organization 1
under the seed above, so the unique index does not reject it.
INSERT INTO "govern_policies"
("organization_id", "namespace_id", "created_at", "updated_at", "trigger_type", "name", "scope_rego", "policy_scope", "mode", "lifecycle_state", "version", "rules", "actions")
VALUES (1, 1, now(), now(), 0, 'plan-check-policy', 'package gitlab.scope', '{"compliance_frameworks":[{"id":5}]}'::jsonb, 2, 0, 1, '[]'::jsonb, '[]'::jsonb)
RETURNING "id"Update
Locates the row by primary key. The column list varies with what changed, and a rename is the
widest case, because renaming regenerates scope_rego.
UPDATE "govern_policies"
SET "updated_at" = now(), "version" = 2, "name" = 'renamed-policy-10001', "scope_rego" = 'package gitlab.scope'
WHERE "govern_policies"."id" = 10001Delete
Fires the ON DELETE CASCADE declared by fk_rails_7c42c73888 on
govern_policy_enforcements, served by index_govern_policy_enforcements_on_govern_policy_id.
has_many :enforcements declares no dependent: option, so the cascade runs server side
rather than through ActiveRecord.
DELETE FROM "govern_policies" WHERE "govern_policies"."id" = 10002How to set up and validate locally
On a GDK with an Ultimate license, since security_orchestration_policies is an Ultimate feature. Every step runs in gdk rails console.
- Enable the policy store experiment and confirm the gate is open.
Organizations::Organization#policy_store_experiment_active?reads thesecurity_policies_v2instance flag, thepolicy_store_experiment_enabledapplication setting, and thesecurity_orchestration_policieslicensed feature, so checking the predicate is the only way to know all three are satisfied:
Feature.enable(:security_policies_v2)
ApplicationSetting.current.update!(policy_store_experiment_enabled: true)
organization = Organizations::Organization.default_organization
organization.policy_store_experiment_active? # => true- Confirm the gem's default adapter stores nothing in
govern_policies, so the next step has an in-memory baseline to contrast with:
before_swap_count = Govern::Policy.count
Gitlab::PolicyStore.create(
organization_id: organization.id,
name: "In-memory smoke test",
trigger_type: "deployment_requested",
policy_scope: { compliance_frameworks: [{ id: 5 }] }
)
Govern::Policy.count == before_swap_count # => true- Point the facade at the repository added here. Nothing configures this yet, since the initializer that does it for real ships in !250282 (merged) (open), so assign it directly for this console session, then read it back to confirm the swap took effect before anything depends on it:
Gitlab::PolicyStore.configure { |config| config.repository = Govern::PolicyStore::ActiveRecordPolicyRepository.new }
Gitlab::PolicyStore.configuration.repository.class # => Govern::PolicyStore::ActiveRecordPolicyRepository- Create a policy through the facade and confirm a row appears this time, where the in-memory create in step 2 left the count unchanged:
policy = Gitlab::PolicyStore.create(
organization_id: organization.id,
name: "Adapter smoke test",
trigger_type: "deployment_requested",
policy_scope: { compliance_frameworks: [{ id: 5 }] }
)
policy.class # => Gitlab::PolicyStore::Policy
Govern::Policy.count == before_swap_count + 1 # => true
Govern::Policy.find(policy.id).name # => "Adapter smoke test"
policy.scope_rego # compiled Rego naming the policy- Rename it and confirm the version bumped and the compiled program was regenerated, because the transpiler emits the policy name into it:
updated = Gitlab::PolicyStore.update(policy.id, name: "Renamed smoke test")
updated.version # => 2
updated.scope_rego.include?("Renamed smoke test") # => true- Confirm an unknown trigger type is rejected as the port's domain error rather than a raw setter exception, because
"merge_request"is not in theGovern::Policyenum andGitlab::PolicyStore::ValidationErroris what the facade promises callers instead ofArgumentError:
begin
Gitlab::PolicyStore.create(
organization_id: organization.id,
name: "Enum validation smoke test",
trigger_type: "merge_request",
policy_scope: { compliance_frameworks: [{ id: 5 }] }
)
rescue StandardError => error
puts "#{error.class}: #{error.message}"
end
# => Gitlab::PolicyStore::ValidationError: <message naming the rejected trigger_type value>- Confirm an oversized name comes back in the port's own phrasing rather than ActiveRecord's, because the shared repository contract asserts the port's message text against both adapters:
begin
Gitlab::PolicyStore.create(
organization_id: organization.id,
name: "a" * 256,
trigger_type: "deployment_requested",
policy_scope: { compliance_frameworks: [{ id: 5 }] }
)
rescue StandardError => error
puts "#{error.class}: #{error.message}"
end
# => Gitlab::PolicyStore::ValidationError: name exceeds maximum length of 255 characters- Confirm the trigger-type filter narrows the read, because a caller listing policies for one trigger type must not see another's (
"deployment_promoted"is a validGovern::Policyenum value, but it is not inGitlab::PolicyStore::Triggers::ALL, whichee/lib/api/govern/policies.rbconstrains the REST parameter to, so this filter works through the facade but would be rejected through the API):
Gitlab::PolicyStore.list(organization_id: organization.id, trigger_type: "deployment_requested")
.map(&:name).include?("Renamed smoke test") # => true
Gitlab::PolicyStore.list(organization_id: organization.id, trigger_type: "deployment_promoted")
.map(&:name).include?("Renamed smoke test") # => false- Confirm a missing policy surfaces as the gem's domain error, because the port promises no persistence exception crosses the boundary:
Gitlab::PolicyStore.find(-1)
# raises Gitlab::PolicyStore::NotFound, not ActiveRecord::RecordNotFound- Delete the policy and confirm the row went with it, because
deleteis otherwise the one repository method no step above observes:
Gitlab::PolicyStore.delete(policy.id)
Govern::Policy.count == before_swap_count # => true
Gitlab::PolicyStore.find(policy.id) # raises Gitlab::PolicyStore::NotFound- Reset the facade to the gem's in-memory default and confirm it took, because the configuration is a process-wide singleton and would otherwise carry this MR's adapter into any later command in the same console session:
Gitlab::PolicyStore.reset_configuration!
Gitlab::PolicyStore.configuration.repository.class # => Gitlab::PolicyStore::Adapters::InMemoryPolicyRepositoryReferences
- Related to https://gitlab.com/gitlab-org/gitlab/-/work_items/604367
- Depends on !249133 (merged) (merged)
- Depends on !249854 (merged) (merged), which added the
for_trigger_typescope this adapter'slistcomposes - Depends on !249899 (merged) (merged), which normalizes attribute keys at the repository boundary so the two adapters return the same shape
- Depends on !249130 (merged) (merged)
list_for_evaluationmoved to !249714 (closed) (open), so this adapter does not implement it- Followed by !250282 (merged) (open), which wires the facade to this repository
- Splits out of !248000 (closed) (open)