Commit 7e3f9e25 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Implement initial configuration page

Adds page to add / remove namespaces within Jira
parent f651af6b
......@@ -15,3 +15,5 @@ class NamespacePolicy < BasePolicy
rule { personal_project & ~can_create_personal_project }.prevent :create_projects
end
NamespacePolicy.prepend(EE::NamespacePolicy)
......@@ -1648,6 +1648,16 @@ ActiveRecord::Schema.define(version: 20190408163745) do
t.index ["client_key"], name: "index_jira_connect_installations_on_client_key", unique: true, using: :btree
end
create_table "jira_connect_subscriptions", force: :cascade do |t|
t.bigint "jira_connect_installation_id", null: false
t.integer "namespace_id", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.index ["jira_connect_installation_id", "namespace_id"], name: "idx_jira_connect_subscriptions_on_installation_id_namespace_id", unique: true, using: :btree
t.index ["jira_connect_installation_id"], name: "idx_jira_connect_subscriptions_on_installation_id", using: :btree
t.index ["namespace_id"], name: "index_jira_connect_subscriptions_on_namespace_id", using: :btree
end
create_table "keys", id: :serial, force: :cascade do |t|
t.integer "user_id"
t.datetime "created_at"
......@@ -3578,6 +3588,8 @@ ActiveRecord::Schema.define(version: 20190408163745) do
add_foreign_key "issues", "users", column: "author_id", name: "fk_05f1e72feb", on_delete: :nullify
add_foreign_key "issues", "users", column: "closed_by_id", name: "fk_c63cbf6c25", on_delete: :nullify
add_foreign_key "issues", "users", column: "updated_by_id", name: "fk_ffed080f01", on_delete: :nullify
add_foreign_key "jira_connect_subscriptions", "jira_connect_installations", on_delete: :cascade
add_foreign_key "jira_connect_subscriptions", "namespaces", on_delete: :cascade
add_foreign_key "label_links", "labels", name: "fk_d97dd08678", on_delete: :cascade
add_foreign_key "label_priorities", "labels", on_delete: :cascade
add_foreign_key "label_priorities", "projects", on_delete: :cascade
......
......@@ -104,6 +104,10 @@ description: 'Learn how to contribute to GitLab.'
- [Query Count Limits](query_count_limits.md)
- [Database helper modules](database_helpers.md)
## Integration guides
- [Jira Connect app](integrations/jira_connect.md)
## Testing guides
- [Testing standards and style guidelines](testing_guide/index.md)
......
# Setting up a development environment
The following are required to install and test the app:
1. A Jira Cloud instance
Atlassian provides free instances for development and testing. [Click here to sign up](http://go.atlassian.com/cloud-dev).
1. A GitLab instance available over the internet
For the app to work, Jira Cloud should be able to connect to the GitLab instance through the internet.
To easily expose your local development environment, you can use tools like [serveo](https://serveo.net) or [ngrok](https://ngrok.com).
These also take care of SSL for you because Jira requires all connections to the app host to be over SSL.
> This feature is currently behind the `:jira_connect_app` feature flag
# Installing the app in Jira
1. Enable Jira development mode to install apps that are not from the Atlassian Marketplace
1. Navigate to **Jira settings** (cog icon) > **Apps** > **Manage apps**.
1. Scroll to the bottom of the **Manage apps** page and click **Settings**.
1. Select **Enable development mode** and click **Apply**.
1. Install the app
1. Navigate to Jira, then choose **Jira settings** (cog icon) > **Apps** > **Manage apps**.
1. Click **Upload app**.
1. In the **From this URL** field, provide a link to the app descriptor. The host and port must point to your GitLab instance.
For example:
```
https://xxxx.serveo.net/-/jira_connect/app_descriptor.json
```
1. Click **Upload**.
If the install was successful, you should see the **GitLab for Jira** app under **Manage apps**.
You can also click **Getting Started** to open the configuration page rendered from your GitLab instance.
_Note that any changes to the app descriptor requires you to uninstall then reinstall the app._
......@@ -12,7 +12,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
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,
baseUrl: jira_connect_base_url(protocol: 'https'),
lifecycle: {
installed: relative_to_base_path(jira_connect_events_installed_path),
uninstalled: relative_to_base_path(jira_connect_events_uninstalled_path)
......@@ -44,8 +44,11 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
name: {
value: 'GitLab Configuration'
},
url: relative_to_base_path(jira_connect_configuration_path)
url: relative_to_base_path(jira_connect_subscriptions_path)
}
},
apiMigrations: {
gdpr: true
}
}
end
......
......@@ -4,6 +4,7 @@ class JiraConnect::ApplicationController < ApplicationController
include Gitlab::Utils::StrongMemoize
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
before_action :check_feature_flag_enabled!
before_action :verify_atlassian_jwt!
......@@ -21,14 +22,20 @@ class JiraConnect::ApplicationController < ApplicationController
@current_jira_installation = installation_from_jwt
end
def verify_qsh_claim!
payload, _ = decode_auth_token!
# Make sure `qsh` claim matches the current request
render_403 unless payload['qsh'] == Atlassian::Jwt.create_query_string_hash(request.method, request.url, jira_connect_base_url)
rescue
render_403
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)
decode_auth_token!
rescue JWT::DecodeError
false
end
......@@ -43,6 +50,10 @@ class JiraConnect::ApplicationController < ApplicationController
end
end
def decode_auth_token!
Atlassian::Jwt.decode(auth_token, installation_from_jwt.shared_secret)
end
def auth_token
strong_memoize(:auth_token) do
params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last
......
# 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
before_action :verify_qsh_claim!, only: :uninstalled
def installed
if JiraConnectInstallation.create(install_params)
installation = JiraConnectInstallation.new(install_params)
if installation.save
head :ok
else
head :unprocessable_entity
......
# frozen_string_literal: true
class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
layout 'jira_connect'
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
before_action :authenticate_user!, only: :create
def index
@subscriptions = current_jira_installation.subscriptions.preload_namespace_route
end
def create
result = create_service.execute
if result[:status] == :success
render json: { success: true }
else
render json: { error: result[:message] }, status: result[:http_status]
end
end
def destroy
subscription = current_jira_installation.subscriptions.find(params[:id])
if subscription.destroy
render json: { success: true }
else
render json: { error: subscription.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def create_service
JiraConnectSubscriptions::CreateService.new(current_jira_installation, current_user, namespace_path: params['namespace_path'])
end
def allow_rendering_in_iframe
response.headers.delete('X-Frame-Options')
end
end
......@@ -6,6 +6,9 @@ class JiraConnectInstallation < ApplicationRecord
algorithm: 'aes-256-gcm',
key: Settings.attr_encrypted_db_key_base_32
validates :client_key, :shared_secret, presence: true
has_many :subscriptions, class_name: 'JiraConnectSubscription'
validates :client_key, presence: true, uniqueness: true
validates :shared_secret, presence: true
validates :base_url, presence: true, public_url: true
end
# frozen_string_literal: true
class JiraConnectSubscription < ApplicationRecord
belongs_to :installation, class_name: 'JiraConnectInstallation', foreign_key: 'jira_connect_installation_id'
belongs_to :namespace
validates :installation, presence: true
validates :namespace, presence: true, uniqueness: { scope: :jira_connect_installation_id, message: 'has already been added' }
scope :preload_namespace_route, -> { preload(namespace: :route) }
end
......@@ -26,6 +26,10 @@ module EE
enable :admin_board
end
rule { maintainer }.policy do
enable :create_jira_connect_subscription
end
rule { can?(:read_group) & contribution_analytics_available }
.enable :read_group_contribution_analytics
......
# frozen_string_literal: true
module EE
module NamespacePolicy
extend ActiveSupport::Concern
prepended do
rule { owner | admin }.policy do
enable :create_jira_connect_subscription
end
end
end
end
# frozen_string_literal: true
module JiraConnectSubscriptions
class BaseService < ::BaseService
attr_accessor :jira_connect_installation, :current_user, :params
def initialize(jira_connect_installation, user = nil, params = {})
@jira_connect_installation, @current_user, @params = jira_connect_installation, user, params.dup
end
end
end
# frozen_string_literal: true
module JiraConnectSubscriptions
class CreateService < ::JiraConnectSubscriptions::BaseService
include Gitlab::Utils::StrongMemoize
def execute
unless namespace && can?(current_user, :create_jira_connect_subscription, namespace)
return error('Invalid namespace. Please make sure you have sufficient permissions', 401)
end
return error('This feature is not available', 422) unless namespace.feature_available?(:jira_dev_panel_integration)
create_subscription
end
private
def create_subscription
subscription = JiraConnectSubscription.new(installation: jira_connect_installation, namespace: namespace)
if subscription.save
success
else
error(subscription.errors.full_messages.join(', '), 422)
end
end
def namespace
strong_memoize(:namespace) do
Namespace.find_by_full_path(params[:namespace_path])
end
end
end
end
%h1
GitLab for Jira Configuration
%form#add-subscription-form{ style: 'margin-bottom: 20px;' }
.ak-field-group
%label
Namespace
%input#namespace-input.ak-field-text{ type: 'text', required: true }
.ak-field-group
%button.ak-button.ak-button__appearance-primary{ type: 'submit' }
Link namespace to Jira
%table.subscriptions
%thead
%tr
%th Namespace
%th Added
%th
%tbody
- @subscriptions.each do |subscription|
%tr
%td= subscription.namespace.full_path
%td= subscription.created_at
%td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription'
:javascript
window.onload = function() {
$('#add-subscription-form').submit(function(e) {
e.preventDefault();
AP.context.getToken(function(token) {
$.post('/-/jira_connect/subscriptions', {
jwt: token,
namespace_path: $('#namespace-input').val(),
format: 'json',
})
.done(function(response) {
AP.navigator.reload();
})
.fail(function(xhr) {
alert(xhr.responseJSON.error);
});
});
});
$('.remove-subscription').click(function(e) {
e.preventDefault();
var href = $(this).attr('href');
AP.context.getToken(function(token) {
$.ajax({
url: href,
method: 'DELETE',
data: {
jwt: token,
format: 'json',
},
})
.done(function(response) {
AP.navigator.reload();
})
.fail(function(xhr) {
alert(xhr.responseJSON.error);
});
});
});
};
%html{ lang: "en" }
%head
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%title
GitLab
= stylesheet_link_tag 'https://unpkg.com/@atlaskit/css-reset@3.0.6/dist/bundle.css'
= stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css'
= javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js'
= javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js'
= yield :head
%body
.ac-content{ style: 'margin: 20px;' }
= yield
......@@ -11,5 +11,5 @@ namespace :jira_connect do
post 'uninstalled'
end
get 'configuration' => 'configuration#show'
resources :subscriptions, only: [:index, :create, :destroy]
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 CreateJiraConnectSubscriptions < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :jira_connect_subscriptions, id: :bigserial do |t|
t.references :jira_connect_installation, type: :bigint, foreign_key: { on_delete: :cascade }, index: { name: 'idx_jira_connect_subscriptions_on_installation_id' }, null: false
t.references :namespace, foreign_key: { on_delete: :cascade }, null: false
t.timestamps_with_timezone
end
add_index :jira_connect_subscriptions, [:jira_connect_installation_id, :namespace_id], unique: true, name: 'idx_jira_connect_subscriptions_on_installation_id_namespace_id'
end
end
......@@ -26,7 +26,7 @@ describe JiraConnect::AppDescriptorController do
expect(response).to have_gitlab_http_status(200)
expect(json_response).to include(
'baseUrl' => 'http://test.host/-/jira_connect',
'baseUrl' => 'https://test.host/-/jira_connect',
'lifecycle' => {
'installed' => '/events/installed',
'uninstalled' => '/events/uninstalled'
......
# 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
......@@ -51,6 +51,16 @@ describe JiraConnect::EventsController do
expect(installation.shared_secret).to eq('secret')
expect(installation.base_url).to eq('https://test.atlassian.net')
end
context 'client key already exists' do
it 'returns 422' do
create(:jira_connect_installation, client_key: '1234')
subject
expect(response).to have_gitlab_http_status(422)
end
end
end
describe '#uninstalled' do
......
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnect::SubscriptionsController do
context 'feature disabled' do
before do
stub_feature_flags(jira_connect_app: false)
end
describe '#index' do
it 'returns 404' do
get :index
expect(response).to have_gitlab_http_status(404)
end
end
describe '#create' do
it '#create returns 404' do
post :create
expect(response).to have_gitlab_http_status(404)
end
end
describe '#destroy' do
let(:subscription) { create(:jira_connect_subscription) }
it '#destroy returns 404' do
delete :destroy, params: { id: subscription.id }
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
let(:installation) { create(:jira_connect_installation) }
describe '#index' do
before do
get :index, params: { jwt: jwt }
end
context 'without JWT' do
let(:jwt) { nil }
it 'returns 403' do
expect(response).to have_gitlab_http_status(403)
end
end
context 'with valid JWT' do
let(:qsh) { Atlassian::Jwt.create_query_string_hash('GET', '/subscriptions') }
let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
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
describe '#create' do
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:current_user) { user }
before do
group.add_maintainer(user)
end
subject { post :create, params: { jwt: jwt, namespace_path: group.path, format: :json } }
context 'without JWT' do
let(:jwt) { nil }
it 'returns 403' do
sign_in(user)
subject
expect(response).to have_gitlab_http_status(403)
end
end
context 'with valid JWT' do
let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key }, installation.shared_secret) }
context 'signed in to GitLab' do
before do
sign_in(user)
end
context 'dev panel integration is available' do
before do
stub_licensed_features(jira_dev_panel_integration: true)
end
it 'creates a subscription' do
expect { subject }.to change { installation.subscriptions.count }.from(0).to(1)
end
it 'returns 200' do
subject
expect(response).to have_gitlab_http_status(200)
end
end
context 'dev panel integration is not available' do
before do
stub_licensed_features(jira_dev_panel_integration: false)
end
it 'returns 422' do
subject
expect(response).to have_gitlab_http_status(422)
end
end
end
context 'not signed in to GitLab' do
it 'returns 401' do
subject
expect(response).to have_gitlab_http_status(401)
end
end
end
end
describe '#destroy' do
let(:subscription) { create(:jira_connect_subscription, installation: installation) }
before do
delete :destroy, params: { jwt: jwt, id: subscription.id }
end
context 'without JWT' do
let(:jwt) { nil }
it 'returns 403' do
expect(response).to have_gitlab_http_status(403)
end
end
context 'with valid JWT' do
let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key }, installation.shared_secret) }
it 'deletes the subscription' do
expect { subscription.reload }.to raise_error ActiveRecord::RecordNotFound
expect(response).to have_gitlab_http_status(200)
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :jira_connect_subscription do
association :installation, factory: :jira_connect_installation
association :namespace, factory: :group
end
end
......@@ -3,8 +3,13 @@
require 'spec_helper'
describe JiraConnectInstallation do
describe 'associations' do
it { is_expected.to have_many(:subscriptions).class_name('JiraConnectSubscription') }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:client_key) }
it { is_expected.to validate_uniqueness_of(:client_key) }
it { is_expected.to validate_presence_of(:shared_secret) }
it { is_expected.to validate_presence_of(:base_url) }
......
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnectSubscription do
describe 'associations' do
it { is_expected.to belong_to(:installation).class_name('JiraConnectInstallation') }
it { is_expected.to belong_to(:namespace).class_name('Namespace') }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:installation) }
it { is_expected.to validate_presence_of(:namespace) }
end
end
......@@ -146,6 +146,50 @@ describe GroupPolicy do
end
end
describe 'create_jira_connect_subscription' do
context 'admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:create_jira_connect_subscription) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:create_jira_connect_subscription) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:create_jira_connect_subscription) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
end
end
describe 'read_group_security_dashboard' do
before do
stub_licensed_features(security_dashboard: true)
......
......@@ -3,17 +3,17 @@
require 'spec_helper'
describe NamespacePolicy do
let(:owner) { create(:user) }
let(:namespace) { create(:namespace, owner: owner) }
let(:owner) { build_stubbed(:user) }
let(:namespace) { build_stubbed(:namespace, owner: owner) }
let(:owner_permissions) { [:create_projects, :admin_namespace, :read_namespace] }
subject { described_class.new(current_user, namespace) }
context 'auditor' do
let(:current_user) { create(:user, :auditor) }
let(:current_user) { build_stubbed(:user, :auditor) }
context 'owner' do
let(:namespace) { create(:namespace, owner: current_user) }
let(:namespace) { build_stubbed(:namespace, owner: current_user) }
it { is_expected.to be_allowed(*owner_permissions) }
end
......@@ -22,4 +22,24 @@ describe NamespacePolicy do
it { is_expected.to be_disallowed(*owner_permissions) }
end
end
describe 'create_jira_connect_subscription' do
context 'admin' do
let(:current_user) { build_stubbed(:admin) }
it { is_expected.to be_allowed(:create_jira_connect_subscription) }
end
context 'owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:create_jira_connect_subscription) }
end
context 'other user' do
let(:current_user) { build_stubbed(:user) }
it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnectSubscriptions::CreateService do
let(:installation) { create(:jira_connect_installation) }
let(:current_user) { create(:user) }
let(:group) { create(:group) }
let(:path) { group.full_path }
subject { described_class.new(installation, current_user, namespace_path: path).execute }
before do
group.add_maintainer(current_user)
end
shared_examples 'a failed execution' do
it 'does not create a subscription' do
expect { subject }.not_to change { installation.subscriptions.count }
end
it 'returns an error status' do
expect(subject[:status]).to eq(:error)
end
end
context 'when jira_dev_panel_integration is available' do
before do
stub_licensed_features(jira_dev_panel_integration: true)
end
it 'creates a subscription' do
expect { subject }.to change { installation.subscriptions.count }.from(0).to(1)
end
it 'returns success' do
expect(subject[:status]).to eq(:success)
end
context 'when path is invalid' do
let(:path) { 'some_invalid_namespace_path' }
it_behaves_like 'a failed execution'
end
context 'when user does not have access' do
subject { described_class.new(installation, create(:user), namespace_path: path).execute }
it_behaves_like 'a failed execution'
end
end
context 'when jira_dev_panel_integration is not available' do
before do
stub_licensed_features(jira_dev_panel_integration: false)
end
it_behaves_like 'a failed execution'
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