Commit e4d07947 authored by Krasimir Angelov's avatar Krasimir Angelov

Implement generating signed JWT to be used in CI

and add it to predefined CI variables as CI_JOB_JWT.

It can be used to authenticate with 3rd parties like Vault.

Related to https://gitlab.com/gitlab-org/gitlab/-/issues/207125.
parent 3b1a068f
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -526,6 +526,7 @@ module Ci
strong_memoize(:variables) do
Gitlab::Ci::Variables::Collection.new
.concat(persisted_variables)
.concat(job_jwt_variables)
.concat(scoped_variables)
.concat(job_variables)
.concat(environment_changed_page_variables)
......@@ -981,6 +982,15 @@ module Ci
def has_expiring_artifacts?
artifacts_expire_at.present? && artifacts_expire_at > Time.now
end
def job_jwt_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
break variables unless Feature.enabled?(:ci_job_jwt, project, default_enabled: true)
jwt = Gitlab::Ci::Jwt.for_build(self)
variables.append(key: 'CI_JOB_JWT', value: jwt, public: false, masked: true)
end
end
end
end
......
---
title: Generate JWT and provide it to CI jobs for integration with other systems
merge_request: 28063
author:
type: added
# frozen_string_literal: true
module Gitlab
module Ci
class Jwt < JSONWebToken::RSAToken
include Gitlab::Utils::StrongMemoize
def self.for_build(build)
self.new(build, ttl: build.metadata_timeout).encoded
end
def initialize(build, ttl: nil)
super(nil)
@build = build
@key_data = Rails.application.secrets.openid_connect_signing_key
# Reserved claims
self.issuer = Settings.gitlab.host
self.issued_at = Time.now
self.expire_time = issued_at + (ttl || DEFAULT_EXPIRE_TIME)
self.subject = project.id.to_s
# Custom claims
self[:namespace_id] = namespace.id.to_s
self[:namespace_path] = namespace.full_path
self[:project_id] = project.id.to_s
self[:project_path] = project.full_path
self[:user_id] = user&.id.to_s
self[:user_login] = user&.username
self[:user_email] = user&.email
self[:pipeline_id] = build.pipeline.id.to_s
self[:job_id] = build.id.to_s
self[:ref] = source_ref
self[:ref_type] = ref_type
self[:ref_protected] = build.protected.to_s
end
private
attr_reader :build, :key_data
def kid
public_key.to_jwk[:kid]
end
def project
build.project
end
def namespace
project.namespace
end
def user
build.user
end
def source_ref
build.pipeline.source_ref
end
def ref_type
::Ci::BuildRunnerPresenter.new(build).ref_type
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Jwt do
let(:namespace) { build_stubbed(:namespace) }
let(:project) { build_stubbed(:project, namespace: namespace) }
let(:user) { build_stubbed(:user) }
let(:pipeline) { build_stubbed(:ci_pipeline, ref: 'auto-deploy-2020-03-19') }
let(:build) do
build_stubbed(
:ci_build,
project: project,
user: user,
pipeline: pipeline
)
end
describe '#payload' do
subject(:payload) { described_class.new(build, ttl: 30).payload }
it 'has correct values for the standard JWT attributes' do
Timecop.freeze do
now = Time.now.to_i
aggregate_failures do
expect(payload[:iss]).to eq(Settings.gitlab.host)
expect(payload[:iat]).to eq(now)
expect(payload[:exp]).to eq(now + 30)
expect(payload[:sub]).to eq(project.id.to_s)
end
end
end
it 'has correct values for the custom attributes' do
aggregate_failures do
expect(payload[:namespace_id]).to eq(namespace.id.to_s)
expect(payload[:namespace_path]).to eq(namespace.full_path)
expect(payload[:project_id]).to eq(project.id.to_s)
expect(payload[:project_path]).to eq(project.full_path)
expect(payload[:user_id]).to eq(user.id.to_s)
expect(payload[:user_email]).to eq(user.email)
expect(payload[:user_login]).to eq(user.username)
expect(payload[:pipeline_id]).to eq(pipeline.id.to_s)
expect(payload[:job_id]).to eq(build.id.to_s)
expect(payload[:ref]).to eq(pipeline.source_ref)
end
end
it 'skips user related custom attributes if build has no user assigned' do
allow(build).to receive(:user).and_return(nil)
expect { payload }.not_to raise_error
end
describe 'ref type' do
context 'branches' do
it 'is "branch"' do
expect(payload[:ref_type]).to eq('branch')
end
end
context 'tags' do
let(:build) { build_stubbed(:ci_build, :on_tag, project: project) }
it 'is "tag"' do
expect(payload[:ref_type]).to eq('tag')
end
end
context 'merge requests' do
let(:pipeline) { build_stubbed(:ci_pipeline, :detached_merge_request_pipeline) }
it 'is "branch"' do
expect(payload[:ref_type]).to eq('branch')
end
end
end
describe 'ref_protected' do
it 'is false when ref is not protected' do
expect(build).to receive(:protected).and_return(false)
expect(payload[:ref_protected]).to eq('false')
end
it 'is true when ref is protected' do
expect(build).to receive(:protected).and_return(true)
expect(payload[:ref_protected]).to eq('true')
end
end
end
describe '.for_build' do
let(:rsa_key) { OpenSSL::PKey::RSA.new(Rails.application.secrets.openid_connect_signing_key) }
subject(:jwt) { described_class.for_build(build) }
it 'generates JWT with key id' do
_payload, headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
expect(headers['kid']).to eq(rsa_key.public_key.to_jwk['kid'])
end
it 'generates JWT for the given job with ttl equal to build timeout' do
expect(build).to receive(:metadata_timeout).and_return(3_600)
payload, _headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
ttl = payload["exp"] - payload["iat"]
expect(ttl).to eq(3_600)
end
it 'generates JWT for the given job with default ttl if build timeout is not set' do
expect(build).to receive(:metadata_timeout).and_return(nil)
payload, _headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
ttl = payload["exp"] - payload["iat"]
expect(ttl).to eq(60)
end
end
end
......@@ -2281,6 +2281,7 @@ describe Ci::Build do
{ key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true, masked: false },
{ key: 'CI_REGISTRY_PASSWORD', value: 'my-token', public: false, masked: true },
{ key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false, masked: false },
{ key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true },
{ key: 'CI_JOB_NAME', value: 'test', public: true, masked: false },
{ key: 'CI_JOB_STAGE', value: 'test', public: true, masked: false },
{ key: 'CI_NODE_TOTAL', value: '1', public: true, masked: false },
......@@ -2333,23 +2334,36 @@ describe Ci::Build do
end
before do
allow(Gitlab::Ci::Jwt).to receive(:for_build).with(build).and_return('ci.job.jwt')
build.set_token('my-token')
build.yaml_variables = []
end
it { is_expected.to eq(predefined_variables) }
context 'when ci_job_jwt feature flag is disabled' do
before do
stub_feature_flags(ci_job_jwt: false)
end
it 'CI_JOB_JWT is not included' do
expect(subject.pluck(:key)).not_to include('CI_JOB_JWT')
end
end
describe 'variables ordering' do
context 'when variables hierarchy is stubbed' do
let(:build_pre_var) { { key: 'build', value: 'value', public: true, masked: false } }
let(:project_pre_var) { { key: 'project', value: 'value', public: true, masked: false } }
let(:pipeline_pre_var) { { key: 'pipeline', value: 'value', public: true, masked: false } }
let(:build_yaml_var) { { key: 'yaml', value: 'value', public: true, masked: false } }
let(:job_jwt_var) { { key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true } }
before do
allow(build).to receive(:predefined_variables) { [build_pre_var] }
allow(build).to receive(:yaml_variables) { [build_yaml_var] }
allow(build).to receive(:persisted_variables) { [] }
allow(build).to receive(:job_jwt_variables) { [job_jwt_var] }
allow_any_instance_of(Project)
.to receive(:predefined_variables) { [project_pre_var] }
......@@ -2362,7 +2376,8 @@ describe Ci::Build do
it 'returns variables in order depending on resource hierarchy' do
is_expected.to eq(
[build_pre_var,
[job_jwt_var,
build_pre_var,
project_pre_var,
pipeline_pre_var,
build_yaml_var,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment