Fix MR approval policy not unblocking after vulnerabilities are dismissed
Problem
Issue #560563 (closed): MR approval policies keep blocking an MR even after the relevant vulnerabilities are dismissed. Developers are forced to re-run the pipeline for the policy to clear. There are two distinct causes behind the reports, addressed together here.
Root cause
Cause A — UUID-cascade mismatch (re-run does NOT unblock; tracked-context / VAC projects).
Policy evaluation now compares findings using the context-unaware UUID (COALESCE(context_unaware_uuid, uuid) via Security::Finding#distinct_context_unaware_uuids), but the downstream consumers VulnerabilitiesCountService and UpdateApprovalsService#uuids_matching_policy_states still looked reads up by vulnerability_reads.uuid, which stores the context-aware UUID. When a tracked context makes the two UUIDs diverge, the dismissed/pre-existing read can no longer be matched, the count comes back as 0, and the violation data is wrong, leaving the MR blocked. This affects rules with previously-existing / mixed vulnerability states.
Cause B — stale trigger pipeline (re-run DOES unblock).
DismissService triggers re-evaluation against the dismissed finding's scan pipeline. When that pipeline's security report artifacts have expired/been erased (common on older MRs), UpdateApprovalsService#execute silently early-returned with "No security reports found for the pipeline", leaving the rule untouched. A manual pipeline re-run regenerates artifacts, which is why the re-run "fixes" it.
Changes
- Cause A: Add
Vulnerabilities::Read.by_uuid_or_context_unaware_uuidscope andmatching_context_unaware_uuidshelper that resolve reads through the finding cascade (new_uuid/context_unaware_uuid). RouteVulnerabilitiesCountServiceanduuids_matching_policy_statesthrough them so counts and violation data stay correct when the UUID variants diverge. - Cause B:
UpdateApprovalsServicenow resolves the evaluation pipeline: when the supplied pipeline cannot store security reports but the merge request's current head pipeline can, it evaluates against the head pipeline instead of aborting, keeping dismissal-triggered re-evaluation in sync with what a manual re-run would compare.
Testing
Vulnerabilities::Readscope + helper specs for the context-aware vs context-unaware lookup.VulnerabilitiesCountServiceregression spec for divergent UUIDs (previously returnedcount: 0).UpdateApprovalsServicespec for the head-pipeline fallback, plus the divergent-contextnew_dismissedcoverage.- End-to-end
dismiss_service_reevaluation_integration_spec.rb: dismissing a finding re-evaluates the rule without a pipeline re-run.
Database review
This MR adds one new scope, Vulnerabilities::Read.by_uuid_or_context_unaware_uuid, in ee/app/models/vulnerabilities/read.rb. No migrations, no bulk writes, read-only.
The scope matches reads either by their own context-aware uuid, or via the related finding's context-unaware new_uuid:
scope :by_uuid_or_context_unaware_uuid, ->(uuids) do
where(uuid: uuids).or(
where(
vulnerability_occurrence_id: Vulnerabilities::Finding.where(new_uuid: uuids).select(:id)
)
)
endGenerated SQL
SELECT "vulnerability_reads".*
FROM "vulnerability_reads"
WHERE (
"vulnerability_reads"."uuid" IN ($1, $2, ...)
OR "vulnerability_reads"."vulnerability_occurrence_id" IN (
SELECT "vulnerability_occurrences"."id"
FROM "vulnerability_occurrences"
WHERE "vulnerability_occurrences"."new_uuid" IN ($1, $2, ...)
)
);In practice the scope is always chained after a project/pipeline scope (e.g. by_projects(...).by_uuid_or_context_unaware_uuid(uuids)), so the outer table is constrained by project_id before this predicate is applied, and uuids is bounded by the finite set of findings in a single pipeline comparison.
Indexes used
vulnerability_reads.uuidis backed by the unique indexindex_vulnerability_reads_on_uuid(uuidis unique per read), serving the first branch and thevulnerability_occurrence_idlookup.- The subquery filters
vulnerability_occurrences.new_uuid, backed byindex_vulnerability_occurrences_on_uuid_and_new_uuid(or the dedicatednew_uuidindex), so the innerSELECT idis index-only / index scan.
Execution plan
Note: this is the expected plan shape; a maintainer with production-like data should confirm via the Database Lab /
@gitlab-botplan on a representative dataset. Requesting database review.
Bitmap Heap Scan on vulnerability_reads
Recheck Cond: ((uuid = ANY ('{...}')) OR (vulnerability_occurrence_id = ANY (...)))
-> BitmapOr
-> Bitmap Index Scan on index_vulnerability_reads_on_uuid
Index Cond: (uuid = ANY ('{...}'))
-> Bitmap Index Scan on index_vulnerability_reads_on_vulnerability_occurrence_id
Index Cond: (vulnerability_occurrence_id = ANY (
SubPlan: Index Scan on index_vulnerability_occurrences_on_new_uuid
Index Cond: (new_uuid = ANY ('{...}'))))Both branches resolve through indexes; no sequential scans on vulnerability_reads or vulnerability_occurrences. The uuids array is bounded per evaluation, so the = ANY lists stay small.
Related Issues
- Fixes #560563 (closed)
- Related to #585328 (closed) (feature flag rollout)
- Related to #601290 (similar reports)