Commit bf5b5cf9 authored by Thong Kuah's avatar Thong Kuah Committed by Vitali Tatarintev

Add JWT signing for internal cluster agent API

This ensures only gitlab-kas can make requests to the internal API,
which is necessary as sensitive info is returned in response.

Update instances where we explicitly want to use the top-level
::Clusters module
parent 0e0f3adf
......@@ -71,6 +71,7 @@ eslint-report.html
/builds*
/.gitlab_workhorse_secret
/.gitlab_pages_secret
/.gitlab_kas_secret
/webpack-report/
/knapsack/
/rspec_flaky/
......
......@@ -1093,6 +1093,11 @@ production: &base
# Default is '.gitlab_workhorse_secret' relative to Rails.root (i.e. root of the GitLab app).
# secret_file: /home/git/gitlab/.gitlab_workhorse_secret
gitlab_kas:
# File that contains the secret key for verifying access for gitlab-kas.
# Default is '.gitlab_kas_secret' relative to Rails.root (i.e. root of the GitLab app).
# secret_file: /home/git/gitlab/.gitlab_kas_secret
## GitLab Elasticsearch settings
elasticsearch:
indexer_path: /home/git/gitlab-elasticsearch-indexer/
......
......@@ -634,6 +634,12 @@ ObjectStoreSettings.new(Settings).parse!
Settings['workhorse'] ||= Settingslogic.new({})
Settings.workhorse['secret_file'] ||= Rails.root.join('.gitlab_workhorse_secret')
#
# GitLab KAS
#
Settings['gitlab_kas'] ||= Settingslogic.new({})
Settings.gitlab_kas['secret_file'] ||= Rails.root.join('.gitlab_kas_secret')
#
# Repositories
#
......
......@@ -26,8 +26,8 @@ file, and include the token Base64 encoded in a `secret_token` parameter
or in the `Gitlab-Shared-Secret` header.
NOTE: **Note:**
The internal API used by GitLab Pages uses a different kind of
authentication.
The internal API used by GitLab Pages, and GitLab Kubernetes Agent Server (kas) uses JSON Web Token (JWT)
authentication, which is different from GitLab Shell.
## Git Authentication
......
......@@ -4,7 +4,15 @@ module API
# Kubernetes Internal API
module Internal
class Kubernetes < Grape::API::Instance
before do
authenticate_gitlab_kas_request!
end
helpers do
def authenticate_gitlab_kas_request!
unauthorized! unless Gitlab::Kas.verify_api_request(headers)
end
def agent_token
@agent_token ||= cluster_agent_token_from_authorization_token
end
......
......@@ -33,7 +33,7 @@ module Gitlab
def kubernetes_namespace
strong_memoize(:kubernetes_namespace) do
Clusters::KubernetesNamespaceFinder.new(
::Clusters::KubernetesNamespaceFinder.new(
deployment_cluster,
project: environment.project,
environment_name: environment.name,
......@@ -47,7 +47,7 @@ module Gitlab
return if conflicting_ci_namespace_requested?(namespace)
Clusters::Kubernetes::CreateOrUpdateNamespaceService.new(
::Clusters::Kubernetes::CreateOrUpdateNamespaceService.new(
cluster: deployment_cluster,
kubernetes_namespace: namespace
).execute
......@@ -71,7 +71,7 @@ module Gitlab
end
def build_namespace_record
Clusters::BuildKubernetesNamespaceService.new(
::Clusters::BuildKubernetesNamespaceService.new(
deployment_cluster,
environment: environment
).execute
......
# frozen_string_literal: true
module Gitlab
module Kas
INTERNAL_API_REQUEST_HEADER = 'Gitlab-Kas-Api-Request'
JWT_ISSUER = 'gitlab-kas'
include JwtAuthenticatable
class << self
def verify_api_request(request_headers)
decode_jwt_for_issuer(JWT_ISSUER, request_headers[INTERNAL_API_REQUEST_HEADER])
rescue JWT::DecodeError
nil
end
def secret_path
Gitlab.config.gitlab_kas.secret_file
end
def ensure_secret!
return if File.exist?(secret_path)
write_secret
end
end
end
end
......@@ -409,7 +409,7 @@ module Gitlab
def successful_deployments_with_cluster(scope)
scope
.joins(cluster: :deployments)
.merge(Clusters::Cluster.enabled)
.merge(::Clusters::Cluster.enabled)
.merge(Deployment.success)
end
# rubocop: enable UsageData/LargeTable
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Kas do
let(:jwt_secret) { SecureRandom.random_bytes(described_class::SECRET_LENGTH) }
before do
allow(described_class).to receive(:secret).and_return(jwt_secret)
end
describe '.verify_api_request' do
let(:payload) { { 'iss' => described_class::JWT_ISSUER } }
it 'returns nil if fails to validate the JWT' do
encoded_token = JWT.encode(payload, 'wrongsecret', 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers)).to be_nil
end
it 'returns the decoded JWT' do
encoded_token = JWT.encode(payload, described_class.secret, 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers)).to eq([{ "iss" => described_class::JWT_ISSUER }, { "alg" => "HS256" }])
end
end
describe '.secret_path' do
it 'returns default gitlab config' do
expect(described_class.secret_path).to eq(Gitlab.config.gitlab_kas.secret_file)
end
end
describe '.ensure_secret!' do
context 'secret file exists' do
before do
allow(File).to receive(:exist?).with(Gitlab.config.gitlab_kas.secret_file).and_return(true)
end
it 'does not call write_secret' do
expect(described_class).not_to receive(:write_secret)
described_class.ensure_secret!
end
end
context 'secret file does not exist' do
before do
allow(File).to receive(:exist?).with(Gitlab.config.gitlab_kas.secret_file).and_return(false)
end
it 'calls write_secret' do
expect(described_class).to receive(:write_secret)
described_class.ensure_secret!
end
end
end
end
......@@ -3,21 +3,45 @@
require 'spec_helper'
RSpec.describe API::Internal::Kubernetes do
let(:jwt_auth_headers) do
jwt_token = JWT.encode({ 'iss' => Gitlab::Kas::JWT_ISSUER }, Gitlab::Kas.secret, 'HS256')
{ Gitlab::Kas::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
let(:jwt_secret) { SecureRandom.random_bytes(Gitlab::Kas::SECRET_LENGTH) }
before do
allow(Gitlab::Kas).to receive(:secret).and_return(jwt_secret)
end
describe "GET /internal/kubernetes/agent_info" do
def send_request(headers: {}, params: {})
get api('/internal/kubernetes/agent_info'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
end
context 'not authenticated' do
it 'returns 401' do
send_request(headers: { Gitlab::Kas::INTERNAL_API_REQUEST_HEADER => '' })
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'kubernetes_agent_internal_api feature flag disabled' do
before do
stub_feature_flags(kubernetes_agent_internal_api: false)
end
it 'returns 404' do
get api('/internal/kubernetes/agent_info')
send_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'returns 403 if Authorization header not sent' do
get api('/internal/kubernetes/agent_info')
send_request
expect(response).to have_gitlab_http_status(:forbidden)
end
......@@ -29,7 +53,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { agent.project }
it 'returns expected data', :aggregate_failures do
get api('/internal/kubernetes/agent_info'), headers: { 'Authorization' => "Bearer #{agent_token.token}" }
send_request(headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:success)
......@@ -56,7 +80,7 @@ RSpec.describe API::Internal::Kubernetes do
context 'no such agent exists' do
it 'returns 404' do
get api('/internal/kubernetes/agent_info'), headers: { 'Authorization' => 'Bearer ABCD' }
send_request(headers: { 'Authorization' => 'Bearer ABCD' })
expect(response).to have_gitlab_http_status(:forbidden)
end
......@@ -64,27 +88,39 @@ RSpec.describe API::Internal::Kubernetes do
end
describe 'GET /internal/kubernetes/project_info' do
def send_request(headers: {}, params: {})
get api('/internal/kubernetes/project_info'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
end
context 'not authenticated' do
it 'returns 401' do
send_request(headers: { Gitlab::Kas::INTERNAL_API_REQUEST_HEADER => '' })
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'kubernetes_agent_internal_api feature flag disabled' do
before do
stub_feature_flags(kubernetes_agent_internal_api: false)
end
it 'returns 404' do
get api('/internal/kubernetes/project_info')
send_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'returns 403 if Authorization header not sent' do
get api('/internal/kubernetes/project_info')
send_request
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'no such agent exists' do
it 'returns 404' do
get api('/internal/kubernetes/project_info'), headers: { 'Authorization' => 'Bearer ABCD' }
send_request(headers: { 'Authorization' => 'Bearer ABCD' })
expect(response).to have_gitlab_http_status(:forbidden)
end
......@@ -99,7 +135,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { create(:project, :public) }
it 'returns expected data', :aggregate_failures do
get api('/internal/kubernetes/project_info'), params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:success)
......@@ -126,7 +162,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { create(:project, :private) }
it 'returns 404' do
get api('/internal/kubernetes/project_info'), params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:not_found)
end
......@@ -136,7 +172,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { create(:project, :internal) }
it 'returns 404' do
get api('/internal/kubernetes/project_info'), params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:not_found)
end
......@@ -144,7 +180,7 @@ RSpec.describe API::Internal::Kubernetes do
context 'project does not exist' do
it 'returns 404' do
get api('/internal/kubernetes/project_info'), params: { id: 0 }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
send_request(params: { id: 0 }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:not_found)
end
......
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