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:
- Bot runs a job in Project A (where it IS a member)
- Job tries to pull images from Project B's registry (where the bot is NOT a member)
- When evaluating permissions on Project B,
team_member?is false - The
security_policy_botcondition evaluates to false - 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.
Related issues
- Closes #630692
- Related to #606691 (closed) (same-project case fixed in !247445 (merged))
- Part of security policy bot minimal access epic #594741 (closed)
Changelog: fixed EE: true
How to set up and validate locally
Prerequisites
-
Create a group (e.g.,
test-mr-257932) -
Create two private projects in the group:
project_a- where security scans will runproject_b- simulates a container registry project
-
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)"
endStep 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"
endStep 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.