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_job stays 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.

🛠️ with ❤️ at Siemens

References

Screenshots or screen recordings

GDK, 19.3.0-pre.

Before After
Administrator, Admin Mode on, not a project member administrator in Admin Mode, no debug button administrator in Admin Mode, debug button next to Cancel
Job owner with the Developer role job owner before, debug button next to Cancel job owner after, debug button still there

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.

🗒️ Note: The job has to sit on an unprotected branch. On a protected branch the 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.

  1. Turn on Admin Mode for the instance, in Admin > Settings > General > Sign-in restrictions, so condition(:admin) actually checks the session.

  2. Run the setup script below. It prints the job page and terminal page URLs.

  3. 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"
end

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 Gerardo Navarro

Merge request reports

Loading
Loading