Allow administrators in Admin Mode to open job terminals
What does this MR do and why?
An instance administrator in Admin Mode cannot open the interactive web terminal on a job they did not start. This makes tech support for the GitLab administrators harder. When a job hangs or misbehaves, the administrator handling the ticket cannot open its terminal and look. Furthermore, it also contradicts what the documentation promises, see Roles and permissions.
This MR lets an administrator in Admin Mode open the terminal on any job.
CVE-2022-1944 is still addressed
owner_of_job was added to this rule in 15.1 when fixing
the CVE-2022-1944. That report is about peer access inside a project. This MR does not touch it:
- A user with the Developer role who did not start the job is still denied, and so is a Maintainer,
and so is an Owner.
owner_of_jobstays on the rule and keeps doing its job. - The only condition added is
admin, which no project role can reach. It is not grantable through membership, an invitation, or a custom role. - The same person already gets the same capability, a shell in a running job container, on a Web IDE terminal job through L123 and L132 of the same file. This closes that gap rather than opening a new one.
References
Screenshots or screen recordings
GDK, 19.3.0-pre.
| Before | After | |
|---|---|---|
| Administrator, Admin Mode on, not a project member | ![]() |
![]() |
| Job owner with the Developer role | ![]() |
![]() |
How to set up and validate locally
You do not need a runner. Ci::Build#has_terminal? is running? && runner_session_url.present?, so
a Ci::BuildRunnerSession row is enough to draw the button.
protected_ref condition
removes update_build, which this rule depends on, so the ownership check is never reached and the
change looks like it does nothing. The script below takes care of that.
-
Turn on Admin Mode for the instance, in Admin > Settings > General > Sign-in restrictions, so
condition(:admin)actually checks the session. -
Run the setup script below. It prints the job page and terminal page URLs.
-
Open both URLs as each user in the table.
| Signed in as | Before | After |
|---|---|---|
root, Admin Mode on, not a project member |
no button, terminal 404 | button, terminal 200 |
mara.maintainer, group Owner, did not start the job |
no button, terminal 404 | unchanged |
dennis.developer, started the job |
button, terminal 200 | unchanged |
root, Admin Mode off |
job page 404, not a member | unchanged |
Opening the terminal itself fails, the runner session URL is fake.
Setup script
# frozen_string_literal: true
# Builds a private group that @root is not a member of, a project inside it, and a running job on an
# unprotected branch, triggered by @dennis.developer, that reports a terminal.
#
# bundle exec rails runner /tmp/terminal_repro.rb
#
# Re-runnable. Sidekiq drops a running job with no runner heartbeat after about an hour, so run it
# again if the job is no longer running.
Gitlab::Seeder.quiet do
group_path = 'admin-mode-repro'
project_path = 'terminal-demo'
job_name = 'debug-with-terminal'
branch_name = 'debug-terminal'
maintainer = User.find_by!(username: 'mara.maintainer')
developer = User.find_by!(username: 'dennis.developer')
organization = Organizations::Organization.default_organization
group = Group.find_by_full_path(group_path) || Groups::CreateService.new(
maintainer,
name: 'Admin mode reproduction',
path: group_path,
visibility_level: Gitlab::VisibilityLevel::PRIVATE,
organization_id: organization.id
).execute.payload[:group]
raise "group creation failed: #{group.errors.full_messages.join(', ')}" unless group.persisted?
project = Project.find_by_full_path("#{group_path}/#{project_path}") || Projects::CreateService.new(
maintainer,
name: 'Terminal demo',
path: project_path,
namespace_id: group.id,
visibility_level: Gitlab::VisibilityLevel::PRIVATE,
initialize_with_readme: true,
organization_id: organization.id
).execute
raise "project creation failed: #{project.errors.full_messages.join(', ')}" unless project.persisted?
project.reset
project.add_developer(developer) unless project.member?(developer)
if project.empty_repo?
# initialize_with_readme does not always land a commit, and a pipeline needs a SHA.
project.repository.create_file(
maintainer, 'README.md', "# Terminal demo\n", message: 'Add README',
branch_name: project.default_branch_or_main
)
project.reset
end
unless project.repository.branch_exists?(branch_name)
project.repository.add_branch(maintainer, branch_name, project.default_branch)
project.reset
end
commit = project.commit(branch_name)
# Pipelines on the protected default branch cannot show the button, so drop them.
project.ci_pipelines.where(ref: project.default_branch).each(&:destroy)
pipeline = project.ci_pipelines.find_by(ref: branch_name, sha: commit.sha) ||
project.ci_pipelines.create!(
sha: commit.sha, ref: branch_name, source: :push, user: developer, status: 'running'
)
pipeline.update!(status: 'running') unless pipeline.running?
stage = pipeline.stages.find_by(name: 'debug') ||
Ci::Stage.create!(pipeline: pipeline, project: project, name: 'debug', position: 1, status: 'running')
build = pipeline.builds.find_by(name: job_name)
if build
build.update_columns(status: 'running', started_at: Time.current, finished_at: nil, updated_at: Time.current)
build.reset
else
build = Ci::Build.create!(
name: job_name, ci_stage: stage, stage_idx: stage.position, ref: pipeline.ref, tag: false,
user: developer, project: project, pipeline: pipeline, scheduling_type: :stage,
status: 'running', started_at: Time.current
)
definition = Ci::JobDefinition.fabricate(
config: { options: { script: ['sleep 3600'] } },
project_id: project.id,
partition_id: pipeline.partition_id
)
definition = Ci::JobDefinition.find_by(
checksum: definition.checksum, project_id: project.id, partition_id: pipeline.partition_id
) || definition.tap(&:save!)
build.create_job_definition_instance!(job_definition: definition, project: project)
end
session = build.runner_session || build.build_runner_session
session.url = 'https://runner.example.com:8093/session/local-repro'
# PublicUrlValidator resolves the host. Nothing connects to this URL, it only has to be present.
session.save || session.save!(validate: false)
build.reset
job_url = Gitlab::Routing.url_helpers.project_job_url(project, build)
puts "has_terminal?: #{build.has_terminal?}"
puts "Job page: #{job_url}"
puts "Terminal page: #{job_url}/terminal"
endMR 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.



