Allow security policy bot cross-project container registry access

What does this MR do and why?

The security policy bot's permissions in ee/app/policies/ee/project_policy.rb are gated by a team_member? check:

condition(:security_policy_bot) { user&.security_policy_bot? && team_member? }

This means the bot's YAML-allowlisted permissions (including build_read_container_image) only apply to projects where the bot is a member. For cross-project container registry access:

  1. Bot runs a job in Project A (where it IS a member)
  2. Job tries to pull images from Project B's registry (where the bot is NOT a member)
  3. When evaluating permissions on Project B, team_member? is false
  4. The security_policy_bot condition evaluates to false
  5. The bot's permissions don't apply, even though Project B is in the job token allowlist

This MR adds a new security_policy_bot_via_job_token condition that grants build_read_container_image when:

  • The user is a security policy bot
  • The user is authenticated via CI job token
  • The target project is in the job token scope

This enables scan execution policies to pull scanner images from a different project's container registry without requiring the bot to be a member of that project.

Changelog: fixed EE: true

How to set up and validate locally

Prerequisites

  1. Create a group (e.g., test-mr-257932)

  2. Create two private projects in the group:

    • project_a - where security scans will run
    • project_b - simulates a container registry project
  3. Create a security policy on each project to generate the security policy bot:

    • Go to Secure → Policies
    • Click New policy → Scan execution policy
    • Enter any name (e.g., "test policy")
    • Click "Configure with a merge request"
    • Merge the MR to activate the policy
    • Repeat for both projects

Validate via Rails console

Open Rails console: bin/rails console

Step 1: Configure your project paths

# Change these to match your setup
PROJECT_A_PATH = 'test-mr-257932/project_a'
PROJECT_B_PATH = 'test-mr-257932/project_b'

Step 2: Verify setup

PROJECT_A_PATH = 'test-mr-257932/project_a'
PROJECT_B_PATH = 'test-mr-257932/project_b'

project_a = Project.find_by_full_path(PROJECT_A_PATH)
project_b = Project.find_by_full_path(PROJECT_B_PATH)
bot_a = project_a.security_policy_bot

puts "Project A: #{project_a.full_path}"
puts "Project B: #{project_b.full_path}"
puts "Security Bot: #{bot_a.username}"
puts "Bot member of Project A: #{project_a.member?(bot_a)}"
puts "Bot member of Project B: #{project_b.member?(bot_a)}"

Step 3: Test WITHOUT allowlist (should return false)

PROJECT_A_PATH = 'test-mr-257932/project_a'
PROJECT_B_PATH = 'test-mr-257932/project_b'

Gitlab::SafeRequestStore.ensure_request_store do
  project_a = Project.find_by_full_path(PROJECT_A_PATH)
  project_b = Project.find_by_full_path(PROJECT_B_PATH)
  bot_a = project_a.security_policy_bot

  pipeline = Ci::Pipeline.new(project: project_a, ref: 'main', sha: 'test123')
  build = Ci::Build.new(project: project_a, user: bot_a, pipeline: pipeline, name: 'scan')
  bot_a.set_ci_job_token_scope!(build)

  can_pull = Ability.allowed?(bot_a, :build_read_container_image, project_b)
  puts "Can bot pull from Project B? #{can_pull}"
  puts can_pull ? "❌ UNEXPECTED" : "✅ EXPECTED: false (no allowlist)"
end

Step 4: Add allowlist entry

PROJECT_A_PATH = 'test-mr-257932/project_a'
PROJECT_B_PATH = 'test-mr-257932/project_b'

project_a = Project.find_by_full_path(PROJECT_A_PATH)
project_b = Project.find_by_full_path(PROJECT_B_PATH)
admin = User.find_by(admin: true)

link = Ci::JobToken::ProjectScopeLink.find_or_create_by!(
  source_project: project_b,
  target_project: project_a,
  direction: :inbound
) { |l| l.added_by = admin }

puts "✓ Allowlist entry exists (ID: #{link.id})"

Step 5: Test WITH allowlist (should return true - THE FIX)

PROJECT_A_PATH = 'test-mr-257932/project_a'
PROJECT_B_PATH = 'test-mr-257932/project_b'

Gitlab::SafeRequestStore.ensure_request_store do
  project_a = Project.find_by_full_path(PROJECT_A_PATH)
  project_b = Project.find_by_full_path(PROJECT_B_PATH)
  bot_a = project_a.security_policy_bot

  pipeline = Ci::Pipeline.new(project: project_a, ref: 'main', sha: 'test456')
  build = Ci::Build.new(project: project_a, user: bot_a, pipeline: pipeline, name: 'scan')
  bot_a.set_ci_job_token_scope!(build)

  can_pull = Ability.allowed?(bot_a, :build_read_container_image, project_b)
  puts "Can bot pull from Project B? #{can_pull}"
  puts can_pull ? "✅ SUCCESS: Fix works!" : "❌ FAILED: Fix not working"
end

Step 6: Cleanup (optional)

PROJECT_A_PATH = 'test-mr-257932/project_a'
PROJECT_B_PATH = 'test-mr-257932/project_b'

project_a = Project.find_by_full_path(PROJECT_A_PATH)
project_b = Project.find_by_full_path(PROJECT_B_PATH)

Ci::JobToken::ProjectScopeLink.find_by(
  source_project: project_b,
  target_project: project_a,
  direction: :inbound
)&.destroy!

puts "✓ Cleaned up"

Alternative: Run specs

bundle exec rspec ee/spec/policies/project_policy_spec.rb -e "security_policy_bot"

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.

Edited by Andy Schoenen

Merge request reports

Loading
Loading