Commit 35ab80d9 authored by Sean McGivern's avatar Sean McGivern

Merge branch '9641-jira-connect-integration' into 'master'

Create Atlassian Connect app descriptor for Jira integration

See merge request gitlab-org/gitlab-ee!9593
parents 87e70f18 406ce19d
......@@ -111,6 +111,7 @@ module Gitlab
# - Webhook URLs (:hook)
# - Sentry DSN (:sentry_dsn)
# - File content from Web Editor (:content)
# - Jira shared secret (:sharedSecret)
#
# NOTE: It is **IMPORTANT** to also update gitlab-workhorse's filter when adding parameters here to not
# introduce another security vulnerability: https://gitlab.com/gitlab-org/gitlab-workhorse/issues/182
......@@ -125,6 +126,7 @@ module Gitlab
trace
variables
content
sharedSecret
)
# Enable escaping HTML in JSON.
......
......@@ -91,6 +91,7 @@ Rails.application.routes.draw do
draw :operations
draw :instance_statistics
draw :smartcard
draw :jira_connect
if ENV['GITLAB_ENABLE_CHAOS_ENDPOINTS']
get '/chaos/leakmem' => 'chaos#leakmem'
......
......@@ -1598,6 +1598,14 @@ ActiveRecord::Schema.define(version: 20190305162221) do
t.index ["updated_by_id"], name: "index_issues_on_updated_by_id", where: "(updated_by_id IS NOT NULL)", using: :btree
end
create_table "jira_connect_installations", id: :bigserial, force: :cascade do |t|
t.string "client_key"
t.string "encrypted_shared_secret"
t.string "encrypted_shared_secret_iv"
t.string "base_url"
t.index ["client_key"], name: "index_jira_connect_installations_on_client_key", unique: true, using: :btree
end
create_table "keys", force: :cascade do |t|
t.integer "user_id"
t.datetime "created_at"
......
# frozen_string_literal: true
# This returns an app descriptor for use with Jira in development mode
# For the Atlassian Marketplace, a static copy of this JSON is uploaded to the marketplace
# https://developer.atlassian.com/cloud/jira/platform/app-descriptor/
class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
skip_before_action :verify_atlassian_jwt!
def show
render json: {
name: "GitLab for Jira (#{Gitlab.config.gitlab.host})",
description: 'Integrate commits, branches and merge requests from GitLab into Jira',
key: "gitlab-jira-connect-#{Gitlab.config.gitlab.host}",
baseUrl: jira_connect_base_url,
lifecycle: {
installed: relative_to_base_path(jira_connect_events_installed_path),
uninstalled: relative_to_base_path(jira_connect_events_uninstalled_path)
},
vendor: {
name: 'GitLab',
url: 'https://gitlab.com'
},
authentication: {
type: 'jwt'
},
scopes: %w(READ WRITE DELETE),
apiVersion: 1,
modules: {
jiraDevelopmentTool: {
key: 'gitlab-development-tool',
application: {
value: 'GitLab'
},
name: {
value: 'GitLab'
},
url: 'https://gitlab.com',
logoUrl: view_context.image_url('gitlab_logo.png'),
capabilities: %w(branch commit pull_request)
},
postInstallPage: {
key: 'gitlab-configuration',
name: {
value: 'GitLab Configuration'
},
url: relative_to_base_path(jira_connect_configuration_path)
}
}
}
end
private
def relative_to_base_path(full_path)
full_path.sub(/^#{jira_connect_base_path}/, '')
end
end
# frozen_string_literal: true
class JiraConnect::ApplicationController < ApplicationController
include Gitlab::Utils::StrongMemoize
skip_before_action :authenticate_user!
before_action :check_feature_flag_enabled!
before_action :verify_atlassian_jwt!
attr_reader :current_jira_installation
private
def check_feature_flag_enabled!
render_404 unless Feature.enabled?(:jira_connect_app)
end
def verify_atlassian_jwt!
return render_403 unless atlassian_jwt_valid?
@current_jira_installation = installation_from_jwt
end
def atlassian_jwt_valid?
return false unless installation_from_jwt
# Verify JWT signature with our stored `shared_secret`
payload, _ = Atlassian::Jwt.decode(auth_token, installation_from_jwt.shared_secret)
# Make sure `qsh` claim matches the current request
payload['qsh'] == Atlassian::Jwt.create_query_string_hash(request.method, request.url, jira_connect_base_url)
rescue JWT::DecodeError
false
end
def installation_from_jwt
return unless auth_token
strong_memoize(:installation_from_jwt) do
# Decode without verification to get `client_key` in `iss`
payload, _ = Atlassian::Jwt.decode(auth_token, nil, false)
JiraConnectInstallation.find_by_client_key(payload['iss'])
end
end
def auth_token
strong_memoize(:auth_token) do
params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last
end
end
end
# frozen_string_literal: true
class JiraConnect::ConfigurationController < JiraConnect::ApplicationController
before_action :allow_rendering_in_iframe
def show
sample_html = <<~HEREDOC
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="https://unpkg.com/@atlaskit/css-reset@2.0.0/dist/bundle.css" media="all">
<script src="https://connect-cdn.atl-paas.net/all.js" async></script>
</head>
<body>
<section id="content" class="ac-content" style="padding: 20px;">
<h1>Hello from GitLab!</h1>
</section>
</body>
</html>
HEREDOC
render html: sample_html.html_safe
end
private
def allow_rendering_in_iframe
response.headers.delete('X-Frame-Options')
end
end
# frozen_string_literal: true
class JiraConnect::EventsController < JiraConnect::ApplicationController
skip_before_action :verify_authenticity_token
skip_before_action :verify_atlassian_jwt!, only: :installed
def installed
if JiraConnectInstallation.create(install_params)
head :ok
else
head :unprocessable_entity
end
end
def uninstalled
if current_jira_installation.destroy
head :ok
else
head :unprocessable_entity
end
end
private
def install_params
params.permit(:clientKey, :sharedSecret, :baseUrl).transform_keys(&:underscore)
end
end
# frozen_string_literal: true
class JiraConnectInstallation < ApplicationRecord
attr_encrypted :shared_secret,
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
key: Settings.attr_encrypted_db_key_base_32
validates :client_key, :shared_secret, presence: true
validates :base_url, presence: true, public_url: true
end
# frozen_string_literal: true
namespace :jira_connect do
# This is so we can have a named route helper for the base URL
root to: proc { [404, {}, ['']] }, as: 'base'
get 'app_descriptor' => 'app_descriptor#show'
namespace :events do
post 'installed'
post 'uninstalled'
end
get 'configuration' => 'configuration#show'
end
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CreateJiraConnectInstallations < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :jira_connect_installations, id: :bigserial do |t|
t.string :client_key
t.string :encrypted_shared_secret
t.string :encrypted_shared_secret_iv
t.string :base_url
end
add_index :jira_connect_installations, :client_key, unique: true
end
end
# frozen_string_literal: true
# This is based on https://bitbucket.org/atlassian/atlassian-jwt-ruby
# which is unmaintained and incompatible with later versions of jwt-ruby
module Atlassian
module Jwt
class << self
CANONICAL_QUERY_SEPARATOR = '&'
ESCAPED_CANONICAL_QUERY_SEPARATOR = '%26'
def decode(token, secret, validate = true, options = {})
options = { algorithm: 'HS256' }.merge(options)
::JWT.decode(token, secret, validate, options)
end
def encode(payload, secret, algorithm = 'HS256', header_fields = {})
::JWT.encode(payload, secret, algorithm, header_fields)
end
def create_query_string_hash(http_method, uri, base_uri = '')
Digest::SHA256.hexdigest(
create_canonical_request(http_method, uri, base_uri)
)
end
private
def create_canonical_request(http_method, uri, base_uri)
uri = URI.parse(uri) unless uri.is_a?(URI)
base_uri = URI.parse(base_uri) unless base_uri.is_a?(URI)
[
http_method.upcase,
canonicalize_uri(uri, base_uri),
canonicalize_query_string(uri.query)
].join(CANONICAL_QUERY_SEPARATOR)
end
def canonicalize_uri(uri, base_uri)
path = uri.path.sub(/^#{base_uri.path}/, '')
path = '/' if path.nil? || path.empty?
path = '/' + path unless path.start_with? '/'
path.chomp!('/') if path.length > 1
path.gsub(CANONICAL_QUERY_SEPARATOR, ESCAPED_CANONICAL_QUERY_SEPARATOR)
end
def canonicalize_query_string(query)
return '' if query.nil? || query.empty?
query = CGI.parse(query)
query.delete('jwt')
query.each do |k, v|
query[k] = v.map { |a| CGI.escape a }.join(',') if v.is_a?(Array)
query[k].gsub!('+', '%20') # Use %20, not CGI.escape default of "+"
query[k].gsub!('%7E', '~') # Unescape "~"
end
query = Hash[query.sort]
query.map { |k, v| "#{CGI.escape k}=#{v}" }.join('&')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnect::AppDescriptorController do
describe '#show' do
context 'feature disabled' do
before do
stub_feature_flags(jira_connect_app: false)
end
it 'returns 404' do
get :show
expect(response).to have_gitlab_http_status(404)
end
end
context 'feature enabled' do
before do
stub_feature_flags(jira_connect_app: true)
end
it 'returns JSON app descriptor' do
get :show
expect(response).to have_gitlab_http_status(200)
expect(json_response).to include(
'baseUrl' => 'http://test.host/-/jira_connect',
'lifecycle' => {
'installed' => '/events/installed',
'uninstalled' => '/events/uninstalled'
}
)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnect::ConfigurationController do
describe '#show' do
context 'feature disabled' do
before do
stub_feature_flags(jira_connect_app: false)
end
it 'returns 404' do
get :show
expect(response).to have_gitlab_http_status(404)
end
end
context 'feature enabled' do
before do
stub_feature_flags(jira_connect_app: true)
end
context 'without JWT' do
it 'returns 403' do
get :show
expect(response).to have_gitlab_http_status(403)
end
end
context 'with correct JWT' do
let(:installation) { create(:jira_connect_installation) }
let(:qsh) { Atlassian::Jwt.create_query_string_hash('GET', '/configuration') }
before do
get :show, params: {
jwt: Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret)
}
end
it 'returns 200' do
expect(response).to have_gitlab_http_status(200)
end
it 'removes X-Frame-Options to allow rendering in iframe' do
expect(response.headers['X-Frame-Options']).to be_nil
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnect::EventsController do
context 'feature disabled' do
before do
stub_feature_flags(jira_connect_app: false)
end
describe '#installed' do
it 'returns 404' do
post :installed
expect(response).to have_gitlab_http_status(404)
end
end
describe '#uninstalled' do
it 'returns 404' do
post :uninstalled
expect(response).to have_gitlab_http_status(404)
end
end
end
context 'feature enabled' do
before do
stub_feature_flags(jira_connect_app: true)
end
describe '#installed' do
subject do
post :installed, params: {
clientKey: '1234',
sharedSecret: 'secret',
baseUrl: 'https://test.atlassian.net'
}
end
it 'saves the jira installation data' do
expect { subject }.to change { JiraConnectInstallation.count }.by(1)
end
it 'saves the correct values' do
subject
installation = JiraConnectInstallation.find_by_client_key('1234')
expect(installation.shared_secret).to eq('secret')
expect(installation.base_url).to eq('https://test.atlassian.net')
end
end
describe '#uninstalled' do
let!(:installation) { create(:jira_connect_installation) }
let(:qsh) { Atlassian::Jwt.create_query_string_hash('POST', '/events/uninstalled') }
before do
request.headers['Authorization'] = "JWT #{auth_token}"
end
subject { post :uninstalled }
context 'when JWT is invalid' do
let(:auth_token) { 'invalid_token' }
it 'returns 403' do
subject
expect(response).to have_gitlab_http_status(403)
end
it 'does not delete the installation' do
expect { subject }.not_to change { JiraConnectInstallation.count }
end
end
context 'when JWT is valid' do
let(:auth_token) do
Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret)
end
it 'deletes the installation' do
expect { subject }.to change { JiraConnectInstallation.count }.by(-1)
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :jira_connect_installation do
sequence(:client_key) { |n| "atlassian-client-key-#{n}" }
shared_secret 'jrNarHaRYaumMvfV3UnYpwt8'
base_url 'https://sample.atlassian.net'
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Atlassian::Jwt do
describe '#create_query_string_hash' do
using RSpec::Parameterized::TableSyntax
let(:base_uri) { 'https://example.com/-/jira_connect' }
where(:path, :method, :expected_hash) do
'/events/uninstalled' | 'POST' | '57d5306d4c520456ebb58ac802779232a941e583589354b8a31aa949cdd4c9ae'
'/events/uninstalled/' | 'post' | '57d5306d4c520456ebb58ac802779232a941e583589354b8a31aa949cdd4c9ae'
'/configuration' | 'GET' | 'be30d9dc39ca6a6543a0b05a253ed9aa36d282311af4cecad54b487dffa62769'
'/' | 'PUT' | 'c88c7735138a8806c60f95f0d3e133d1d3d313e2a9d590abbb5f898dabad7b62'
'' | 'PUT' | 'c88c7735138a8806c60f95f0d3e133d1d3d313e2a9d590abbb5f898dabad7b62'
end
with_them do
it 'generates correct hash with base URI' do
hash = subject.create_query_string_hash(method, base_uri + path, base_uri)
expect(hash).to eq(expected_hash)
end
it 'generates correct hash with base URI already removed' do
hash = subject.create_query_string_hash(method, path)
expect(hash).to eq(expected_hash)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnectInstallation do
describe 'validations' do
it { is_expected.to validate_presence_of(:client_key) }
it { is_expected.to validate_presence_of(:shared_secret) }
it { is_expected.to validate_presence_of(:base_url) }
it { is_expected.to allow_value('https://test.atlassian.net').for(:base_url) }
it { is_expected.not_to allow_value('not/a/url').for(:base_url) }
end
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