Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions app/controllers/api/v1/accounts/integrations/github_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::Integrations::BaseController
include Github::IntegrationHelper

before_action :check_authorization
before_action :fetch_hook, except: [:create]

# Completes an install that GitHub redirected through Github::CallbacksController.
def create
return render_connect_error('connection_failed') unless verify_github_state(params[:state]) == Current.account.id
return render_connect_error('installation_not_verified') unless installation_visible_to_user?

connect_installation
render :update
rescue Integrations::Github::AppClient::Error => e
Rails.logger.error("GitHub connect failed for account #{Current.account.id}: #{e.message}")
render_connect_error('connection_failed')
end

def repositories
render json: installation_repositories
rescue Integrations::Github::AppClient::AuthorizationError => e
render_access_lost(e)
end

def update
repository = installation_repositories.find { |name| name.casecmp?(params[:repository].to_s) }
return render json: { error: I18n.t('integration_apps.github.errors.repository_not_granted') }, status: :unprocessable_entity if repository.blank?

@hook.update!(settings: { 'repository' => repository, 'label' => params[:label].presence }.compact)
rescue Integrations::Github::AppClient::AuthorizationError => e
render_access_lost(e)
end

# Unbinds the installation from this account only. The app stays installed on
# GitHub, where the organization may still use it for other accounts.
def destroy
@hook.destroy!
head :ok
end

private

# A leftover personal-token hook has no installation to list or configure;
# it can only be deleted or replaced by installing the app.
def fetch_hook
hooks = Current.account.hooks.where(app_id: 'github')
hooks = hooks.where.not(reference_id: nil) unless action_name == 'destroy'
@hook = hooks.first!
end

def installation_id
params[:installation_id].to_s
end

# `installation_id` arrives through a browser redirect, so anyone can put any id
# there. Binding it unchecked would let one account open issues in another
# organization's repositories. The user who just authorized must be able to see
# the installation on GitHub's side.
def installation_visible_to_user?
return false if params[:code].blank? || installation_id.blank?

user_token = app_client.exchange_code(params[:code])
return true if app_client.user_installation_ids(user_token).map(&:to_s).include?(installation_id)

Rails.logger.warn("GitHub connect for account #{Current.account.id} named installation #{installation_id}, which the authorizing user cannot see")
false
end

def connect_installation
@hook = Current.account.hooks.find_or_initialize_by(app_id: 'github')
previous = @hook.settings.to_h
settings = { 'label' => previous['label'] }
settings['repository'] = previous['repository'] if repository_still_granted?(previous['repository'])

@hook.update!(reference_id: installation_id, status: 'enabled', settings: settings.compact_blank)
@hook.reauthorized!
end

def repository_still_granted?(repository)
repository.present? && app_client.repositories(installation_id).any? { |name| name.casecmp?(repository) }
end

def installation_repositories
@installation_repositories ||= app_client.repositories(@hook.reference_id)
end

def app_client
@app_client ||= Integrations::Github::AppClient.new
end

def render_connect_error(reason)
render json: { error: I18n.t("integration_apps.github.errors.#{reason}"), reason: reason }, status: :unprocessable_entity
end

def render_access_lost(error)
Rails.logger.warn("GitHub installation #{@hook.reference_id} refused account #{Current.account.id}: #{error.message}")
@hook.prompt_reauthorization!
render json: { error: I18n.t('integration_apps.github.errors.access_lost') }, status: :unprocessable_entity
end
end
41 changes: 41 additions & 0 deletions app/controllers/github/callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# GitHub sends the browser here after an install. Nothing is bound yet: the
# settings page posts `code`, `installation_id` and `state` back from inside the
# admin's authenticated session (Integrations::GithubController#create). Binding
# here instead would let an admin of one account hand their `state` to an org
# owner elsewhere and capture that org's installation when the owner installs.
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper

def show
account_id = verify_github_state(params[:state])
return redirect_expired_state if account_id.blank?

query = if params[:setup_action] == 'request'
# An org member without admin rights only *requested* the install;
# GitHub waits for an org owner, and nothing is installed yet.
{ setup_action: 'request' }
else
params.permit(:code, :installation_id, :state).to_h
end
redirect_to settings_url(account_id, query)
end

private

# The install itself may already have finished on GitHub, so the admin is
# told to press Connect again rather than dropped on the app root.
def redirect_expired_state
account_id = expired_github_state_account_id(params[:state])
return redirect_to(frontend_url) if account_id.blank?

redirect_to settings_url(account_id, { error: 'state_expired' })
end

def settings_url(account_id, query)
"#{frontend_url}/app/accounts/#{account_id}/settings/integrations/github?#{query.to_query}"
end

def frontend_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
2 changes: 2 additions & 0 deletions app/controllers/super_admin/app_configs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def allowed_configs
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'github' => %w[GITHUB_APP_ID GITHUB_APP_SLUG GITHUB_APP_CLIENT_ID GITHUB_APP_CLIENT_SECRET GITHUB_APP_PRIVATE_KEY
GITHUB_APP_WEBHOOK_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
Expand Down
55 changes: 55 additions & 0 deletions app/controllers/webhooks/github_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
class Webhooks::GithubController < ActionController::API
before_action :verify_signature!

EVENT_HANDLERS = {
'installation' => :handle_installation,
'installation_repositories' => :handle_repositories_removed
}.freeze
INSTALLATION_ACTIONS = {
'deleted' => :prompt_reauthorization!,
'suspend' => :prompt_reauthorization!,
'unsuspend' => :reauthorized!
}.freeze

def events
handler = EVENT_HANDLERS[request.headers['X-GitHub-Event']]
send(handler) if handler

head :ok
end

private

def verify_signature!
secret = GlobalConfigService.load('GITHUB_APP_WEBHOOK_SECRET', nil)
signature = request.headers['X-Hub-Signature-256']
return head :unauthorized if secret.blank? || signature.blank?

expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end

def handle_installation
hook_action = INSTALLATION_ACTIONS[payload['action']]
installation_hooks.find_each(&hook_action) if hook_action
end

def handle_repositories_removed
removed = Array(payload['repositories_removed']).map { |repository| repository['full_name'].downcase }
return if removed.empty?

installation_hooks.find_each do |hook|
hook.prompt_reauthorization! if removed.include?(hook.settings['repository']&.downcase)
end
end

def installation_hooks
Integrations::Hook.where(app_id: 'github', reference_id: payload.dig('installation', 'id').to_s)
end

# Parsed from the raw body because Rails reserves params[:action] for the
# controller action, which would hide the GitHub event's own `action`.
def payload
@payload ||= JSON.parse(request.raw_post)
end
end
42 changes: 42 additions & 0 deletions app/helpers/github/integration_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Signs the account id into the `state` of the GitHub App install URL. GitHub
# hands `state` back on the callback, and it is the only thing that tells us
# which account started the install.
module Github::IntegrationHelper
# The state only names the account; binding is gated separately by the
# admin session, so a long TTL costs nothing and survives slow installs.
STATE_TTL = 1.hour

def generate_github_state(account_id)
secret = github_client_secret
return if secret.blank?

now = Time.current
JWT.encode({ sub: account_id, iat: now.to_i, exp: (now + STATE_TTL).to_i }, secret, 'HS256')
end

def verify_github_state(token)
decode_github_state(token, verify_expiration: true)
end

# Signed by us but past its expiry: good enough to send the admin back to
# their own settings page to retry, never to bind anything.
def expired_github_state_account_id(token)
decode_github_state(token, verify_expiration: false)
end

private

def decode_github_state(token, verify_expiration:)
secret = github_client_secret
return if token.blank? || secret.blank?

JWT.decode(token, secret, true, { algorithm: 'HS256', required_claims: %w[exp], verify_expiration: verify_expiration }).first['sub']
rescue JWT::DecodeError => e
Rails.logger.warn("Rejected GitHub install state: #{e.message}")
nil
end

def github_client_secret
GlobalConfigService.load('GITHUB_APP_CLIENT_SECRET', nil)
end
end
6 changes: 6 additions & 0 deletions app/helpers/super_admin/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
github:
name: 'GitHub'
description: 'Configuration for the GitHub App that opens issues from tickets'
enabled: true
icon: 'icon-github'
config_key: 'github'
notion:
name: 'Notion'
description: 'Configuration for setting up Notion Integration'
Expand Down
27 changes: 27 additions & 0 deletions app/javascript/dashboard/api/integrations/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* global axios */

import ApiClient from '../ApiClient';

class GithubAPI extends ApiClient {
constructor() {
super('integrations/github', { accountScoped: true });
}

connect({ code, installationId, state }) {
return axios.post(this.url, {
code,
installation_id: installationId,
state,
});
}

getRepositories() {
return axios.get(`${this.url}/repositories`);
}

updateSettings({ repository, label }) {
return axios.patch(this.url, { repository, label });
}
}

export default new GithubAPI();
66 changes: 66 additions & 0 deletions app/javascript/dashboard/api/specs/integrations/github.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import GithubAPIClient from '../../integrations/github';
import ApiClient from '../../ApiClient';

describe('#githubAPI', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
post: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
};

beforeEach(() => {
window.axios = axiosMock;
});

afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});

it('creates correct instance', () => {
expect(GithubAPIClient).toBeInstanceOf(ApiClient);
});

it('completes an installation', () => {
GithubAPIClient.connect({
code: 'oauth-code',
installationId: '81234567',
state: 'signed-state',
});
expect(axiosMock.post).toHaveBeenCalledWith('/api/v1/integrations/github', {
code: 'oauth-code',
installation_id: '81234567',
state: 'signed-state',
});
});

it('fetches the repositories of the installation', () => {
GithubAPIClient.getRepositories();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/github/repositories'
);
});

it('saves the repository and label', () => {
GithubAPIClient.updateSettings({
repository: 'pathorsAI/inbox',
label: 'support',
});
expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/integrations/github',
{ repository: 'pathorsAI/inbox', label: 'support' }
);
});

it('sends an empty label to clear it', () => {
GithubAPIClient.updateSettings({
repository: 'pathorsAI/inbox',
label: '',
});
expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/integrations/github',
{ repository: 'pathorsAI/inbox', label: '' }
);
});
});
Loading
Loading