Add policy dismissals to group-level dependency list

What does this MR do and why?

Adds security policy dismissal information to the group-level dependency list JSON response and displays a "Policy violation" badge with a tooltip in the group dependency list UI.

Background

The project-level dependency list displays security policy dismissals (violation indicators) for dependencies that have been bypassed via security policies (#562185 (closed)). However, the group-level dependency list was missing this information.

At the group level, dependencies are aggregated by component+version across projects using Sbom::AggregationsFinder. The previous attempt (!218680 (closed)) used MIN(uuid) to get a single representative occurrence UUID per aggregated row, which missed dismissals for other projects' occurrences of the same component. For example, if Project A has lodash@4.17.21 with a dismissal and Project B has the same dependency without one, the group view would show Project B's occurrence and the dismissal would not be visible.

Solution

Instead of matching by individual occurrence UUID, this MR collects all occurrence UUIDs for each component_version_id within the group namespace, then queries PolicyDismissal against the complete set. This ensures that if Project A and Project B both have lodash@4.17.21, and Project A has a policy dismissal, it correctly appears in the aggregated group-level row.

The policy_dismissals field is only exposed when the user has read_security_resource permission (requires security_dashboard licensed feature).

On the frontend, a "Policy violation" warning badge appears in the vulnerabilities column for any dependency with dismissals. On hover, a tooltip shows which projects have the violation (e.g. "A security policy was bypassed for this dependency in the following projects: Project A, Project B").

Demo

📺 https://youtu.be/kQ-i0otONqU

How to set up and validate locally

1. Create test data

Run the following in Rails console (rails c). This creates two projects sharing the same dependency, where only one has a policy dismissal — validating the core fix.

user = User.first
org = Organizations::Organization.default_organization

# Create group
group = Group.new(name: 'test-dismissals-group', path: 'test-dismissals-group', organization: org)
group.save!
group.add_owner(user)

# Create two projects in the group
project_a = Project.create!(name: 'project-a', path: 'project-a', namespace: group, creator: user, organization: org)
project_b = Project.create!(name: 'project-b', path: 'project-b', namespace: group, creator: user, organization: org)

# Shared component + version (lodash@4.17.21)
component = Sbom::Component.find_or_create_by!(name: 'lodash', component_type: 0, organization_id: org.id)
version = Sbom::ComponentVersion.find_or_create_by!(component: component, version: '4.17.21', organization_id: org.id)
source = Sbom::Source.find_or_create_by!(source_type: :dependency_scanning, organization_id: org.id, source: { 'package_manager' => { 'name' => 'npm' }, 'input_file' => { 'path' => 'package-lock.json' } })

# Create occurrence in both projects
[project_a, project_b].each do |project|
  pipeline = Ci::Pipeline.create!(project: project, ref: 'main', sha: SecureRandom.hex(20), source: :push)
  Sbom::Occurrence.create!(
    project: project, pipeline: pipeline, commit_sha: pipeline.sha,
    component: component, component_version: version, source: source,
    traversal_ids: project.namespace.traversal_ids,
    uuid: Sbom::OccurrenceUUID.generate(project_id: project.id, component_id: component.id, component_version_id: version.id, source_id: source.id),
    package_manager: 'npm', component_name: 'lodash',
    licenses: [{ 'spdx_identifier' => 'MIT', 'name' => 'MIT License', 'url' => 'https://spdx.org/licenses/MIT.html' }]
  )
end

# Create security policy + dismissal ONLY on Project A
policy_project = Project.create!(name: 'policy-project', path: 'policy-project', namespace: group, creator: user, organization: org)
config = Security::OrchestrationPolicyConfiguration.create!(
  project: project_a,
  security_policy_management_project: policy_project
)
policy = Security::Policy.create!(
  security_orchestration_policy_configuration: config,
  security_policy_management_project_id: policy_project.id,
  name: 'test-license-policy',
  checksum: Digest::SHA256.hexdigest('test'),
  policy_index: 0,
  type: Security::Policy.types[:approval_policy],
  enabled: true,
  content: { actions: [{ type: 'require_approval', approvals_required: 1, user_approvers: ['owner'] }] }
)

# Insert MR via SQL to avoid Gitaly callbacks on empty repos
mr_id = ActiveRecord::Base.connection.execute(
  "INSERT INTO merge_requests (source_project_id, target_project_id, source_branch, target_branch, author_id, title, created_at, updated_at, state_id) VALUES (#{project_a.id}, #{project_a.id}, 'feature', 'main', #{user.id}, 'Test MR', NOW(), NOW(), 1) RETURNING id"
).first['id']

occ_a = Sbom::Occurrence.find_by(project: project_a, component_name: 'lodash')

Security::PolicyDismissal.create!(
  project: project_a, merge_request_id: mr_id, security_policy: policy,
  user: user, dismissal_types: [0], status: :preserved,
  license_occurrence_uuids: [occ_a.uuid],
  security_findings_uuids: []
)

puts "Group: #{group.full_path}"
puts "Project A: #{project_a.name} (id: #{project_a.id}) | Project B: #{project_b.name} (id: #{project_b.id})"
puts "Dismissal is ONLY on Project A"

2. Verify the JSON response

Navigate to:

http://<your-gdk-host>/groups/test-dismissals-group/-/dependencies.json

3. Confirm the aggregation fix works

Even though lodash@4.17.21 appears as a single aggregated row, the response should include the dismissal from Project A with project_name and project_path:

{
  "dependencies": [
    {
      "name": "lodash",
      "version": "4.17.21",
      "occurrence_count": 2,
      "project_count": 2,
      "policy_dismissals": [
        {
          "id": 1,
          "project_id": "<project_a_id>",
          "project_name": "project-a",
          "project_path": "test-dismissals-group/project-a",
          "dismissal_types": ["Policy false positive"],
          "user_name": "Administrator"
        }
      ]
    }
  ]
}

This proves the fix — the old code would have shown "policy_dismissals": [] because it only checked one representative occurrence UUID which could have been Project B's.

4. Verify the UI

Navigate to the group dependency list page:

http://<your-gdk-host>/groups/test-dismissals-group/-/dependencies
  • Confirm a "Policy violation" warning badge appears in the vulnerabilities column for lodash@4.17.21
  • Hover over the badge — the tooltip should read: "A security policy was bypassed for this dependency in the following projects: project-a"
  • Dependencies without dismissals should not show the badge

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Related to #581242 (closed)

Edited by Andy Schoenen

Merge request reports

Loading
Loading