diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c11f651
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+/.bundle
+/cookbook/.kitchen
+/config/database.yml
+/config/secrets.yml
+/coverage
+/doc
+/ldap/slapd-test.conf
+/ldap/openldap-data
+/log/*.log
+/public/assets
+/public/favicon.ico
+/tmp
+/.yardoc
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..83e16f8
--- /dev/null
+++ b/.rspec
@@ -0,0 +1,2 @@
+--color
+--require spec_helper
diff --git a/.ruby-gemset b/.ruby-gemset
new file mode 100644
index 0000000..3608b5c
--- /dev/null
+++ b/.ruby-gemset
@@ -0,0 +1 @@
+github-connector
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..f01bc44
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+ruby-2.1.4
diff --git a/.yardopts b/.yardopts
new file mode 100644
index 0000000..cc365c8
--- /dev/null
+++ b/.yardopts
@@ -0,0 +1 @@
+--markup=markdown
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e010435
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,6 @@
+GitHub Connector CHANGELOG
+==========================
+
+v0.1.0
+------
+- Initial open source release
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..311280a
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,47 @@
+source 'https://rubygems.org'
+
+# TODO: Upgrade to rails 4.2.0 when it is released and get rid
+# of these beta/git gem definitions:
+gem 'rails', '~> 4.2.0.beta4'
+gem 'sass-rails', '~> 5.0.0.beta'
+
+gem 'autoprefixer-rails'
+gem 'bootstrap-sass'
+gem 'coffee-rails', '~> 4.0.0'
+gem 'compass-rails'
+gem 'daemons'
+gem 'delayed_job_active_record'
+gem 'devise', '>= 3.4.0'
+# We can switch to upstream when version > 0.8.1 is released
+# see: https://github.com/cschiewek/devise_ldap_authenticatable/pull/172
+# https://github.com/cschiewek/devise_ldap_authenticatable/pull/171
+# https://github.com/cschiewek/devise_ldap_authenticatable/pull/170
+gem 'devise_ldap_authenticatable', git: 'git://github.com/blt04/devise_ldap_authenticatable.git', branch: 'patches'
+gem 'friendly_id'
+gem 'font-awesome-rails'
+gem 'jquery-rails'
+gem 'oauth2'
+gem 'octokit', '> 3.3.1'
+gem 'pg'
+gem 'puma'
+gem 'sanitize'
+gem 'state_machine'
+gem 'turbolinks'
+gem 'uglifier', '>= 1.3.0'
+
+group :development do
+ gem 'foreman'
+ gem 'spring'
+ gem 'therubyracer'
+ gem 'yard'
+end
+
+group :development, :test do
+ gem 'database_cleaner'
+ gem 'rspec-rails'
+end
+
+group :test do
+ gem 'simplecov', :require => false
+ gem 'factory_girl_rails'
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..9b195f8
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,280 @@
+GIT
+ remote: git://github.com/blt04/devise_ldap_authenticatable.git
+ revision: 76db2d04b5d11e5f01b8bb83da9eca83f0ef6fad
+ branch: patches
+ specs:
+ devise_ldap_authenticatable (0.8.3)
+ devise (>= 3.0)
+ net-ldap (>= 0.3.1, < 0.6.0)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actionmailer (4.2.0.beta4)
+ actionpack (= 4.2.0.beta4)
+ actionview (= 4.2.0.beta4)
+ activejob (= 4.2.0.beta4)
+ mail (~> 2.5, >= 2.5.4)
+ rails-dom-testing (~> 1.0, >= 1.0.4)
+ actionpack (4.2.0.beta4)
+ actionview (= 4.2.0.beta4)
+ activesupport (= 4.2.0.beta4)
+ rack (~> 1.6.0.beta)
+ rack-test (~> 0.6.2)
+ rails-dom-testing (~> 1.0, >= 1.0.4)
+ rails-html-sanitizer (~> 1.0, >= 1.0.1)
+ actionview (4.2.0.beta4)
+ activesupport (= 4.2.0.beta4)
+ builder (~> 3.1)
+ erubis (~> 2.7.0)
+ rails-dom-testing (~> 1.0, >= 1.0.4)
+ rails-html-sanitizer (~> 1.0, >= 1.0.1)
+ activejob (4.2.0.beta4)
+ activesupport (= 4.2.0.beta4)
+ globalid (>= 0.3.0)
+ activemodel (4.2.0.beta4)
+ activesupport (= 4.2.0.beta4)
+ builder (~> 3.1)
+ activerecord (4.2.0.beta4)
+ activemodel (= 4.2.0.beta4)
+ activesupport (= 4.2.0.beta4)
+ arel (>= 6.0.0.beta2, < 6.1)
+ activesupport (4.2.0.beta4)
+ i18n (>= 0.7.0.beta1, < 0.8)
+ json (~> 1.7, >= 1.7.7)
+ minitest (~> 5.1)
+ thread_safe (~> 0.1)
+ tzinfo (~> 1.1)
+ addressable (2.3.6)
+ arel (6.0.0.beta2)
+ autoprefixer-rails (3.1.2.20141016)
+ execjs
+ bcrypt (3.1.9)
+ bootstrap-sass (3.3.0.1)
+ sass (~> 3.2)
+ builder (3.2.2)
+ chunky_png (1.3.3)
+ coffee-rails (4.0.1)
+ coffee-script (>= 2.2.0)
+ railties (>= 4.0.0, < 5.0)
+ coffee-script (2.3.0)
+ coffee-script-source
+ execjs
+ coffee-script-source (1.8.0)
+ compass (1.0.1)
+ chunky_png (~> 1.2)
+ compass-core (~> 1.0.1)
+ compass-import-once (~> 1.0.5)
+ rb-fsevent (>= 0.9.3)
+ rb-inotify (>= 0.9)
+ sass (>= 3.3.13, < 3.5)
+ compass-core (1.0.1)
+ multi_json (~> 1.0)
+ sass (>= 3.3.0, < 3.5)
+ compass-import-once (1.0.5)
+ sass (>= 3.2, < 3.5)
+ compass-rails (2.0.1)
+ compass (~> 1.0.0)
+ crass (0.2.1)
+ daemons (1.1.9)
+ database_cleaner (1.3.0)
+ delayed_job (4.0.4)
+ activesupport (>= 3.0, < 4.2)
+ delayed_job_active_record (4.0.2)
+ activerecord (>= 3.0, < 4.2)
+ delayed_job (>= 3.0, < 4.1)
+ devise (3.4.1)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 3.2.6, < 5)
+ responders
+ thread_safe (~> 0.1)
+ warden (~> 1.2.3)
+ diff-lcs (1.2.5)
+ docile (1.1.5)
+ dotenv (0.11.1)
+ dotenv-deployment (~> 0.0.2)
+ dotenv-deployment (0.0.2)
+ erubis (2.7.0)
+ execjs (2.2.2)
+ factory_girl (4.5.0)
+ activesupport (>= 3.0.0)
+ factory_girl_rails (4.5.0)
+ factory_girl (~> 4.5.0)
+ railties (>= 3.0.0)
+ faraday (0.9.0)
+ multipart-post (>= 1.2, < 3)
+ ffi (1.9.6)
+ font-awesome-rails (4.2.0.0)
+ railties (>= 3.2, < 5.0)
+ foreman (0.75.0)
+ dotenv (~> 0.11.1)
+ thor (~> 0.19.1)
+ friendly_id (5.0.4)
+ activerecord (>= 4.0.0)
+ globalid (0.3.0)
+ activesupport (>= 4.1.0)
+ hike (1.2.3)
+ i18n (0.7.0.beta1)
+ jquery-rails (3.1.2)
+ railties (>= 3.0, < 5.0)
+ thor (>= 0.14, < 2.0)
+ json (1.8.1)
+ jwt (1.0.0)
+ libv8 (3.16.14.7)
+ loofah (2.0.1)
+ nokogiri (>= 1.5.9)
+ mail (2.6.1)
+ mime-types (>= 1.16, < 3)
+ mime-types (2.4.3)
+ mini_portile (0.6.0)
+ minitest (5.4.2)
+ multi_json (1.10.1)
+ multi_xml (0.5.5)
+ multipart-post (2.0.0)
+ net-ldap (0.5.1)
+ nokogiri (1.6.3.1)
+ mini_portile (= 0.6.0)
+ nokogumbo (1.1.12)
+ nokogiri
+ oauth2 (1.0.0)
+ faraday (>= 0.8, < 0.10)
+ jwt (~> 1.0)
+ multi_json (~> 1.3)
+ multi_xml (~> 0.5)
+ rack (~> 1.2)
+ octokit (3.5.2)
+ sawyer (~> 0.5.3)
+ orm_adapter (0.5.0)
+ pg (0.17.1)
+ puma (2.9.2)
+ rack (>= 1.1, < 2.0)
+ rack (1.6.0.beta)
+ rack-test (0.6.2)
+ rack (>= 1.0)
+ rails (4.2.0.beta4)
+ actionmailer (= 4.2.0.beta4)
+ actionpack (= 4.2.0.beta4)
+ actionview (= 4.2.0.beta4)
+ activejob (= 4.2.0.beta4)
+ activemodel (= 4.2.0.beta4)
+ activerecord (= 4.2.0.beta4)
+ activesupport (= 4.2.0.beta4)
+ bundler (>= 1.3.0, < 2.0)
+ railties (= 4.2.0.beta4)
+ sprockets-rails (~> 3.0.0.beta1)
+ rails-deprecated_sanitizer (1.0.3)
+ activesupport (>= 4.2.0.alpha)
+ rails-dom-testing (1.0.4)
+ activesupport (>= 4.2.0.beta, < 5.0)
+ nokogiri (~> 1.6.0)
+ rails-deprecated_sanitizer (>= 1.0.1)
+ rails-html-sanitizer (1.0.1)
+ loofah (~> 2.0)
+ railties (4.2.0.beta4)
+ actionpack (= 4.2.0.beta4)
+ activesupport (= 4.2.0.beta4)
+ rake (>= 0.8.7)
+ thor (>= 0.18.1, < 2.0)
+ rake (10.3.2)
+ rb-fsevent (0.9.4)
+ rb-inotify (0.9.5)
+ ffi (>= 0.5.0)
+ ref (1.0.5)
+ responders (2.0.1)
+ railties (>= 4.2.0.alpha, < 5)
+ rspec-core (3.1.7)
+ rspec-support (~> 3.1.0)
+ rspec-expectations (3.1.2)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.1.0)
+ rspec-mocks (3.1.3)
+ rspec-support (~> 3.1.0)
+ rspec-rails (3.1.0)
+ actionpack (>= 3.0)
+ activesupport (>= 3.0)
+ railties (>= 3.0)
+ rspec-core (~> 3.1.0)
+ rspec-expectations (~> 3.1.0)
+ rspec-mocks (~> 3.1.0)
+ rspec-support (~> 3.1.0)
+ rspec-support (3.1.2)
+ sanitize (3.0.3)
+ crass (~> 0.2.0)
+ nokogiri (>= 1.4.4)
+ nokogumbo (= 1.1.12)
+ sass (3.4.7)
+ sass-rails (5.0.0.beta1)
+ railties (>= 4.0.0, < 5.0)
+ sass (~> 3.2)
+ sprockets (~> 2.12)
+ sprockets-rails (>= 2.0, < 4.0)
+ sawyer (0.5.5)
+ addressable (~> 2.3.5)
+ faraday (~> 0.8, < 0.10)
+ simplecov (0.9.1)
+ docile (~> 1.1.0)
+ multi_json (~> 1.0)
+ simplecov-html (~> 0.8.0)
+ simplecov-html (0.8.0)
+ spring (1.1.3)
+ sprockets (2.12.3)
+ hike (~> 1.2)
+ multi_json (~> 1.0)
+ rack (~> 1.0)
+ tilt (~> 1.1, != 1.3.0)
+ sprockets-rails (3.0.0.beta1)
+ actionpack (>= 4.0)
+ activesupport (>= 4.0)
+ sprockets (~> 2.8)
+ state_machine (1.2.0)
+ therubyracer (0.12.1)
+ libv8 (~> 3.16.14.0)
+ ref
+ thor (0.19.1)
+ thread_safe (0.3.4)
+ tilt (1.4.1)
+ turbolinks (2.5.1)
+ coffee-rails
+ tzinfo (1.2.2)
+ thread_safe (~> 0.1)
+ uglifier (2.5.3)
+ execjs (>= 0.3.0)
+ json (>= 1.8.0)
+ warden (1.2.3)
+ rack (>= 1.0)
+ yard (0.8.7.6)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ autoprefixer-rails
+ bootstrap-sass
+ coffee-rails (~> 4.0.0)
+ compass-rails
+ daemons
+ database_cleaner
+ delayed_job_active_record
+ devise (>= 3.4.0)
+ devise_ldap_authenticatable!
+ factory_girl_rails
+ font-awesome-rails
+ foreman
+ friendly_id
+ jquery-rails
+ oauth2
+ octokit (> 3.3.1)
+ pg
+ puma
+ rails (~> 4.2.0.beta4)
+ rspec-rails
+ sanitize
+ sass-rails (~> 5.0.0.beta)
+ simplecov
+ spring
+ state_machine
+ therubyracer
+ turbolinks
+ uglifier (>= 1.3.0)
+ yard
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..51e991d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2014 Rapid7, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..688740d
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,3 @@
+web: bundle exec rails s -p $PORT
+ldap: ldap/run-server
+worker: rake jobs:work
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0d0ec23
--- /dev/null
+++ b/README.md
@@ -0,0 +1,130 @@
+# GitHub Active Directory Connector
+
+GitHub Connector is a simple application for connecting GitHub.com organizations to
+internal Active Directory accounts. It grants access to new hires, removes access
+from terminated employees, and enforces a set of GitHub membership rules.
+
+## Table of contents
+
+- [Quick Start](#quick_start)
+- [Installation](#installation)
+- [Running](#running)
+- [Configuration](#configuration)
+- [Synchronization](#synchornization)
+- [Rules](#rules)
+- [Tests](#tests)
+- [Contributing](#contributing)
+- [Copyright and License](#license)
+
+## Quick start
+
+- Clone the repo: `git clone git://github.com/rapid7/github-connector.git`
+- Run bundler: `bundle install`
+- Copy `config/secrets.yml.example` to `config/secrets.yml`
+- Copy `config/database.yml.example` to `config/database.yml` and update
+- Create database: `rake db:create db:migrate`
+- Start application: `foreman start`
+- Navigate to [http://localhost:5000](http://localhost:5000)
+
+## Installation
+
+GitHub Connector is a Rails 4 application. It runs on Ruby > 2.0. All settings are stored in a PostgreSQL database.
+
+1. Install Ruby 2.x. We recommend [RVM](https://rvm.io/).
+2. If using RVM, create a gemset: `rvm gemset create github-connector && rvm gemset use github-connector`.
+3. Install required gem dependencies: `bundle install`
+4. Copy the `config/secrets.yml.example` file to `config/secrets.yml`. Generate new random secrets with `rake secret` and paste them in `config/secrets.yml`
+5. Copy the `config/database.yml.example` file to `config/database.yml`. Update the file with your database settings.
+6. Create the database: `rake db:create db:migrate`
+
+### Development Environment
+
+#### OpenLDAP
+
+To ease development, GitHub Connector emulates Active Directory using OpenLDAP. In development, OpenLDAP will automatically be populated with fake data.
+
+OpenLDAP is pre-installed on OSX. On Linux, install OpenLDAP. For example, on Ubuntu use:
+
+1. Install OpenLDAP: `sudo apt-get install slapd ldap-utils`
+2. Stop `slapd` as we will run our own copy: `service slapd stop`
+3. Apparmor prevents us from running the OpenLDAP server with custom a configuration. To get around this, put apparmor into complain mode: `sudo apt-get install apparmor-utils && sudo aa-complain /usr/sbin/slapd`
+
+## Running
+
+### Production
+
+There are several ways to run a Rails application in production. We include a [chef cookbook](cookbook/) that installs and
+configures the GitHub Active Directory Connector.
+
+### Development
+
+In a development environment, use `foreman` to start Rails (via [Puma](http://puma.io/)) and LDAP:
+
+```
+foreman start
+```
+
+Visit [http://localhost:5000](http://localhost:5000) in your favorite browser.
+
+## Configuration
+
+The first time you access the application you will be greeted with the Setup Wizard. Please prevent others from accessing the application until you complete the Setup Wizard, as there is no authentication/authorization until the wizard is complete.
+
+The Setup Wizard defaults to the built-in LDAP configuration. Continue with the test configuration, or update the settings to use your Active Directory server.
+
+### Development user accounts
+
+When using the built-in LDAP configuration, the following accounts exist (username / password):
+
+- hsimpson / 123456
+- msimpson / 123456
+- bsimpson / 123456
+- lsimpson / 123456
+
+### Connecting to GitHub
+
+Visit the Settings page ([/settings](http://localhost:5000/settings)) to configure your connection with GitHub.com.
+
+TODO - More information on configuring GitHub.
+
+## Synchronization
+
+GitHub Connector syncs information from Active Directory and the [GitHub API](https://developer.github.com/v3/) to the local database. Synchronization is triggered with:
+
+```
+rake sync
+```
+
+## Rules
+
+GitHub Connector disables organization access based on rules. Rules are configured via the Settings page. New rules can be added by extending the `Rules::Base` class in the `lib/rules` directory.
+
+## Tests
+
+Run tests with:
+
+```
+rspec
+```
+
+Coverage reports are generated in the `coverage` directory.
+
+## Documentation
+
+Generate documentation with:
+
+```
+yard
+```
+
+Open `doc/index.html` with your favorite browser.
+
+## Contributing
+
+Pull requests welcome!
+
+## Copyright and License
+
+Copyright 2014 Rapid7, Inc.
+
+Released under the [MIT License](http://www.opensource.org/licenses/MIT).
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..ba6b733
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,6 @@
+# Add your own tasks in files placed in lib/tasks ending in .rake,
+# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
+
+require File.expand_path('../config/application', __FILE__)
+
+Rails.application.load_tasks
diff --git a/app/assets/images/.keep b/app/assets/images/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
new file mode 100644
index 0000000..04c7d4a
--- /dev/null
+++ b/app/assets/javascripts/application.js
@@ -0,0 +1,17 @@
+// This is a manifest file that'll be compiled into application.js, which will include all the files
+// listed below.
+//
+// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
+// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
+//
+// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
+// compiled file.
+//
+// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
+// about supported directives.
+//
+//= require jquery
+//= require jquery_ujs
+//= require bootstrap-sprockets
+//= require turbolinks
+//= require_tree .
diff --git a/app/assets/javascripts/time.js.coffee b/app/assets/javascripts/time.js.coffee
new file mode 100644
index 0000000..47c1098
--- /dev/null
+++ b/app/assets/javascripts/time.js.coffee
@@ -0,0 +1,15 @@
+ready = ->
+ $("span[data-time]").each (i, element) ->
+ data = $(element).data()
+ if data.time
+ date = new Date(data.time)
+ timezone = /\((.*)\)/.exec(date.toString())
+ if timezone
+ formatted_date = date.toLocaleString() + " " + timezone[1]
+ else
+ formatted_date = date.toString()
+ $(element).html(formatted_date)
+
+
+$(document).ready(ready)
+$(document).on('page:load', ready)
diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss
new file mode 100644
index 0000000..b627bb8
--- /dev/null
+++ b/app/assets/stylesheets/application.css.scss
@@ -0,0 +1,70 @@
+/*
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
+ * listed below.
+ *
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
+ * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
+ *
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
+ * compiled file so the styles you add here take precedence over styles defined in any styles
+ * defined in the other CSS/SCSS files in this directory. It is generally better to create a new
+ * file per style scope.
+ *
+ *=require_self
+ *=require_tree
+ */
+$container-large-desktop: 970px;
+@import "compass/css3";
+@import "bootstrap-sprockets";
+@import "bootstrap";
+@import "font-awesome";
+
+.gh-main-nav {
+ margin-bottom: 0;
+}
+
+.gh-main-content {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ &::before {
+ display: block;
+ content: "";
+ }
+}
+
+.max-col-xs { max-width: 360px; }
+.max-col-sm { max-width: $container-sm; }
+.max-col-md { max-width: $container-md; }
+.max-col-lg { max-width: $container-lg; }
+
+.jumbotron {
+ $jumbotron-color: darken($brand-primary, 15%);
+
+ padding: 20px 0;
+ margin-bottom: 0;
+ color: lighten($brand-primary, 40%);
+ @include single-text-shadow(0, 1px, 0, false, rgba(black, 0.1));
+ @include background-image(linear-gradient(bottom, $jumbotron-color, darken($jumbotron-color, 5%)));
+
+ h1 {
+ color: white;
+ font-size: 50px;
+ margin-top: 0;
+ &:last-child { margin-bottom: 0; }
+ }
+
+ .btn-default {
+ color: white;
+ background-color: transparent;
+ &:hover {
+ color: $jumbotron-color;
+ background-color: white;
+ }
+ }
+}
+
+@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
+ .nav-sm-hide {
+ display: none;
+ }
+}
diff --git a/app/assets/stylesheets/connect.css.scss b/app/assets/stylesheets/connect.css.scss
new file mode 100644
index 0000000..81f64c3
--- /dev/null
+++ b/app/assets/stylesheets/connect.css.scss
@@ -0,0 +1,37 @@
+@import "bootstrap/variables";
+
+.connect-steps {
+ .list-group-item {
+ padding-left: 32px + $grid-gutter-width;
+
+ &.active {
+ color: inherit;
+ background-color: $list-group-bg;
+ border-color: $list-group-border;
+ }
+
+ .step-icon {
+ display: none;
+ float: left;
+ font-size: 30px;
+ margin-left: -(32px + ($grid-gutter-width / 2));
+ &.step-icon-complete { color: green; }
+ &.step-icon-error { color: red; }
+ }
+ span.step-icon {
+ width: 32px;
+ }
+
+ &.active .step-icon-active { display: inline-block; }
+ &.complete .step-icon-complete { display: inline-block; }
+ &.complete .step-icon-active { display: none; }
+ &.error .step-icon-error { display: inline-block; }
+ }
+
+ &.inprogress {
+ .list-group-item.active {
+ .step-icon-active { display: none; }
+ .step-icon-loading { display: inline-block; }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/devise/sessions.css.scss b/app/assets/stylesheets/devise/sessions.css.scss
new file mode 100644
index 0000000..897d8e0
--- /dev/null
+++ b/app/assets/stylesheets/devise/sessions.css.scss
@@ -0,0 +1,45 @@
+// Place all the styles related to the devise/sessions controller here.
+// They will automatically be included in application.css.
+// You can use Sass (SCSS) here: http://sass-lang.com/
+body.sessions {
+ background-color: #eee;
+}
+
+.form-signin {
+ max-width: 330px;
+ padding: 15px;
+ margin: 0 auto;
+ &.left {
+ margin: 0;
+ padding-left: 0;
+ }
+}
+.form-signin .form-signin-heading,
+.form-signin .checkbox {
+ margin-bottom: 10px;
+}
+.form-signin .checkbox {
+ font-weight: normal;
+}
+.form-signin .form-control {
+ position: relative;
+ height: auto;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ padding: 10px;
+ font-size: 16px;
+}
+.form-signin .form-control:focus {
+ z-index: 2;
+}
+.form-signin input[type="email"] {
+ margin-bottom: -1px;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+.form-signin input[type="password"] {
+ margin-bottom: 10px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
diff --git a/app/assets/stylesheets/users.css.scss b/app/assets/stylesheets/users.css.scss
new file mode 100644
index 0000000..99df301
--- /dev/null
+++ b/app/assets/stylesheets/users.css.scss
@@ -0,0 +1,9 @@
+// Place all the styles related to the users controller here.
+// They will automatically be included in application.css.
+// You can use Sass (SCSS) here: http://sass-lang.com/
+.ldap-account, .github-account {
+ h1, h2, h3, h4, h5, h6 {
+ a { color: inherit; }
+ a:hover { text-decoration: none; }
+ }
+}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
new file mode 100644
index 0000000..327e6b5
--- /dev/null
+++ b/app/controllers/application_controller.rb
@@ -0,0 +1,24 @@
+class ApplicationController < ActionController::Base
+ rescue_from DeviseLdapAuthenticatable::LdapException do |exception|
+ render :text => exception, :status => 500
+ end
+ # Prevent CSRF attacks by raising an exception.
+ # For APIs, you may want to use :null_session instead.
+ protect_from_forgery with: :exception
+
+ before_action :check_configured
+ before_action :authenticate_user!
+
+ private
+ def check_configured
+ unless Rails.application.settings.configured?
+ redirect_to setup_url
+ end
+ end
+
+ def require_admin
+ return true if current_user.admin?
+ render :status => :forbidden, :text => 'Forbidden'
+ false
+ end
+end
diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/controllers/concerns/github_oauth_concern.rb b/app/controllers/concerns/github_oauth_concern.rb
new file mode 100644
index 0000000..0358d37
--- /dev/null
+++ b/app/controllers/concerns/github_oauth_concern.rb
@@ -0,0 +1,46 @@
+module GithubOauthConcern
+ extend ActiveSupport::Concern
+
+ protected
+
+ def oauth_authenticity_token
+ session[:_oauth_state] ||= SecureRandom.base64(32)
+ end
+
+ def oauth_client
+ settings = Rails.application.settings
+ @oauth_client ||= OAuth2::Client.new(settings.github_client_id, settings.github_client_secret,
+ site: 'https://github.com/',
+ authorize_url: '/login/oauth/authorize',
+ token_url: '/login/oauth/access_token'
+ )
+ end
+
+ def oauth_process_auth_code
+ octokit = Octokit::Client.new(access_token: oauth_auth_code.token)
+ ghuser = octokit.user
+
+ github_user = GithubUser.find_or_initialize_by(id: ghuser.id)
+ github_user.login = ghuser.login
+ github_user.token = oauth_auth_code.token
+ github_user.user = current_user
+ github_user.sync!
+ github_user
+ end
+
+ def oauth_scope
+ 'user:email,read:public_key,write:org'
+ end
+
+ def oauth_validate_authenticity_token
+ if oauth_state != oauth_authenticity_token
+ raise ActionController::InvalidAuthenticityToken
+ end
+ end
+
+ private
+
+ def oauth_auth_code
+ @oauth_auth_code ||= oauth_client.auth_code.get_token(oauth_code)
+ end
+end
diff --git a/app/controllers/concerns/github_settings_mixin.rb b/app/controllers/concerns/github_settings_mixin.rb
new file mode 100644
index 0000000..b8fb77d
--- /dev/null
+++ b/app/controllers/concerns/github_settings_mixin.rb
@@ -0,0 +1,29 @@
+module GithubSettingsMixin
+ extend ActiveSupport::Concern
+
+ def github_admin
+ redirect_to oauth_client.auth_code.authorize_url(
+ state: oauth_authenticity_token,
+ scope: "#{oauth_scope},admin:org",
+ redirect_uri: url_for(action: 'github_auth_code')
+ )
+ end
+
+ def github_auth_code
+ oauth_validate_authenticity_token
+ @github_user = oauth_process_auth_code
+ Rails.application.settings.github_admin_token = oauth_auth_code.token
+ flash.notice = "GitHub admin token updated successfully."
+ redirect_to action: 'edit'
+ end
+
+ private
+
+ def oauth_code
+ params[:code]
+ end
+
+ def oauth_state
+ params[:state]
+ end
+end
diff --git a/app/controllers/concerns/settings_mixin.rb b/app/controllers/concerns/settings_mixin.rb
new file mode 100644
index 0000000..0e5574b
--- /dev/null
+++ b/app/controllers/concerns/settings_mixin.rb
@@ -0,0 +1,53 @@
+module SettingsMixin
+ extend ActiveSupport::Concern
+
+ PASSWORD_PLACEHOLDER = '|||PWPLACEHOLDER|||'
+
+ included do
+ before_filter :load_settings
+ end
+
+ def scrub_password(key)
+ if @settings.dirty?(key)
+ @settings.send(key)
+ else
+ PASSWORD_PLACEHOLDER
+ end
+ end
+
+ private
+ def keys
+ Rails.application.settings.keys
+ end
+
+ def load_settings
+ @settings = Rails.application.settings.load(keys).disconnect
+ params = self.params[:settings] || {}
+ keys.each do |key|
+ if params.has_key?(key)
+ next if params[key] == PASSWORD_PLACEHOLDER
+ if @settings.definition(key).type == :array
+ params[key] = params[key].split(/\r?\n/).map(&:strip).compact
+ end
+ @settings.send("#{key}=", params[key])
+ end
+ end
+ end
+
+ def test_ldap_connection
+ ldap = Net::LDAP.new
+ ldap.host = @settings.ldap_host
+ ldap.port = @settings.ldap_port
+ ldap.encryption :simple_tls if @settings.ldap_ssl
+ ldap.auth @settings.ldap_admin_user, @settings.ldap_admin_password
+ begin
+ ldap.bind.tap do |result|
+ @error = "Invalid admin user or password." unless result
+ end
+ rescue => e
+ @error = e.message
+ Rails.logger.warn "Cannot LDAP bind: #{e.class} - #{e.message}"
+ false
+ end
+ end
+end
diff --git a/app/controllers/concerns/setup_mixin.rb b/app/controllers/concerns/setup_mixin.rb
new file mode 100644
index 0000000..478a7b9
--- /dev/null
+++ b/app/controllers/concerns/setup_mixin.rb
@@ -0,0 +1,33 @@
+module SetupMixin
+ extend ActiveSupport::Concern
+
+ included do
+ skip_before_filter :authenticate_user!
+ before_filter :check_configured
+ end
+
+ private
+ def apply_defaults
+ default_settings.each do |key, val|
+ @settings.send("#{key}=", val) unless @settings.send("#{key}")
+ end
+ end
+
+ def check_configured
+ if Rails.application.settings.configured?
+ redirect_to settings_url
+ end
+ end
+
+ # Attempts to figure out the domain name based on the
+ # URL or company name
+ #
+ # @return [String]
+ def default_domain
+ if request.host == 'localhost' && !Rails.application.settings.company.blank?
+ "#{Rails.application.settings.company.downcase.gsub(' ', '_')}.com"
+ else
+ request.host
+ end
+ end
+end
diff --git a/app/controllers/connect_controller.rb b/app/controllers/connect_controller.rb
new file mode 100644
index 0000000..44d2a1c
--- /dev/null
+++ b/app/controllers/connect_controller.rb
@@ -0,0 +1,58 @@
+require 'oauth2'
+
+class ConnectController < ApplicationController
+ include GithubOauthConcern
+ before_filter :load_connect_status, only: [:status]
+
+ def index
+ @connect_status = ConnectGithubUserStatus.new(
+ step: :request
+ )
+ end
+
+ def status
+ render :index
+ end
+
+ def start
+ redirect_to oauth_client.auth_code.authorize_url(
+ state: oauth_authenticity_token,
+ scope: oauth_scope,
+ redirect_uri: oauth_redirect_uri
+ )
+ end
+
+ def auth_code
+ if params[:state] != oauth_authenticity_token
+ raise ActionController::InvalidAuthenticityToken
+ end
+
+ connect_job_status = ConnectGithubUserStatus.create!(
+ user: current_user,
+ oauth_code: params[:code],
+ status: :queued,
+ step: :grant
+ )
+ ConnectGithubUserJob.perform_later(connect_job_status)
+ redirect_to connect_status_path(connect_job_status)
+ end
+
+ protected
+
+ def oauth_redirect_uri
+ url_for action: 'auth_code'
+ end
+
+ private
+
+ def load_connect_status
+ @connect_status = ConnectGithubUserStatus.find(params[:id])
+
+ if @connect_status.user_id != current_user.id
+ render :status => :forbidden, :text => 'Forbidden'
+ return false
+ end
+
+ true
+ end
+end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
new file mode 100644
index 0000000..391fa2e
--- /dev/null
+++ b/app/controllers/dashboard_controller.rb
@@ -0,0 +1,4 @@
+class DashboardController < ApplicationController
+ def index
+ end
+end
diff --git a/app/controllers/github_users_controller.rb b/app/controllers/github_users_controller.rb
new file mode 100644
index 0000000..0449f6e
--- /dev/null
+++ b/app/controllers/github_users_controller.rb
@@ -0,0 +1,19 @@
+class GithubUsersController < ApplicationController
+ before_filter :load_github_user, except: [:index]
+ before_filter :require_admin
+
+ def index
+ # TODO: Pagination
+ @github_users = GithubUser.includes(:user).order(:login)
+ end
+
+ def show
+ end
+
+ private
+
+ def load_github_user
+ @github_user = GithubUser.friendly.find(params[:id])
+ end
+
+end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
new file mode 100644
index 0000000..20d45ed
--- /dev/null
+++ b/app/controllers/settings_controller.rb
@@ -0,0 +1,24 @@
+class SettingsController < ApplicationController
+ include SettingsMixin
+ include GithubOauthConcern
+ include GithubSettingsMixin
+ before_filter :require_admin
+
+ def edit
+ end
+
+ def update
+ unless test_ldap_connection
+ render :edit
+ return
+ end
+ @settings.save
+
+ if params[:connect_github]
+ github_admin
+ else
+ flash.notice = "Settings saved successfully."
+ redirect_to action: :edit
+ end
+ end
+end
diff --git a/app/controllers/setup/admin_user_controller.rb b/app/controllers/setup/admin_user_controller.rb
new file mode 100644
index 0000000..bc7d511
--- /dev/null
+++ b/app/controllers/setup/admin_user_controller.rb
@@ -0,0 +1,24 @@
+class Setup::AdminUserController < Devise::SessionsController
+ include SetupMixin
+ prepend_before_filter :sign_out_if_signed_in, only: [:new]
+
+ def create
+ super do |resource|
+ resource.admin = true
+ resource.save!
+ flash.notice = ''
+ end
+ end
+
+ protected
+
+ def after_sign_in_path_for(resource)
+ setup_github_path
+ end
+
+ def sign_out_if_signed_in
+ if signed_in?
+ Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
+ end
+ end
+end
diff --git a/app/controllers/setup/company_controller.rb b/app/controllers/setup/company_controller.rb
new file mode 100644
index 0000000..35dfcb4
--- /dev/null
+++ b/app/controllers/setup/company_controller.rb
@@ -0,0 +1,21 @@
+class Setup::CompanyController < ApplicationController
+ include SetupMixin
+ include SettingsMixin
+
+ def edit
+ apply_defaults unless @settings.company
+ end
+
+ def update
+ @settings.save
+
+ redirect_to setup_ldap_url
+ end
+
+ private
+
+ def default_settings
+ {
+ }
+ end
+end
diff --git a/app/controllers/setup/email_controller.rb b/app/controllers/setup/email_controller.rb
new file mode 100644
index 0000000..3fc9690
--- /dev/null
+++ b/app/controllers/setup/email_controller.rb
@@ -0,0 +1,26 @@
+class Setup::EmailController < ApplicationController
+ include SetupMixin
+ include SettingsMixin
+
+ def edit
+ apply_defaults unless @settings.smtp_address
+ end
+
+ def update
+ @settings.save
+
+ redirect_to setup_rules_url
+ end
+
+ private
+
+ def default_settings
+ {
+ email_from: "github@#{default_domain}",
+ email_base_url: root_url,
+ smtp_address: "smtp.#{default_domain}",
+ smtp_port: '25',
+ smtp_enable_starttls_auto: true,
+ }
+ end
+end
diff --git a/app/controllers/setup/github_controller.rb b/app/controllers/setup/github_controller.rb
new file mode 100644
index 0000000..438fec9
--- /dev/null
+++ b/app/controllers/setup/github_controller.rb
@@ -0,0 +1,34 @@
+class Setup::GithubController < ApplicationController
+ include SetupMixin
+ include SettingsMixin
+ include GithubOauthConcern
+ include GithubSettingsMixin
+
+ def edit
+ apply_defaults unless @settings.github_orgs
+ end
+
+ def update
+ @settings.save
+
+ if params[:connect_github]
+ github_admin
+ else
+ redirect_to setup_email_url
+ end
+ end
+
+ private
+
+ def default_settings
+ s = {
+ github_check_mfa_team: 'github-connector-2fa-check',
+ }
+ unless Rails.application.settings.company.blank?
+ s[:github_orgs] = [Rails.application.settings.company.downcase.gsub(' ', '-')]
+ s[:github_default_teams] = ["#{Rails.application.settings.company.downcase.gsub(' ', '-')}-employees"]
+ end
+
+ s
+ end
+end
diff --git a/app/controllers/setup/ldap_controller.rb b/app/controllers/setup/ldap_controller.rb
new file mode 100644
index 0000000..071e123
--- /dev/null
+++ b/app/controllers/setup/ldap_controller.rb
@@ -0,0 +1,45 @@
+class Setup::LdapController < ApplicationController
+ include SetupMixin
+ include SettingsMixin
+
+ def edit
+ apply_defaults unless @settings.ldap_host
+ end
+
+ def update
+ unless test_ldap_connection
+ render :edit
+ return
+ end
+ @settings.save
+
+ redirect_to setup_admin_url
+ end
+
+
+ private
+
+ def default_settings
+ {
+ ldap_host: 'localhost',
+ ldap_port: 3268,
+ ldap_ssl: false,
+ ldap_admin_user: "cn=admin,#{default_base}",
+ ldap_admin_password: 'secret',
+ ldap_base: default_base,
+ ldap_attribute: 'sAMAccountName',
+ }
+ end
+
+ def keys
+ Rails.application.settings.ldap_keys
+ end
+
+ def default_base
+ if request.host == 'localhost'
+ 'dc=example,dc=com'
+ else
+ default_domain.split('.').map {|s| "dc=#{s}"}.join(',')
+ end
+ end
+end
diff --git a/app/controllers/setup/rules_controller.rb b/app/controllers/setup/rules_controller.rb
new file mode 100644
index 0000000..e1e6369
--- /dev/null
+++ b/app/controllers/setup/rules_controller.rb
@@ -0,0 +1,31 @@
+class Setup::RulesController < ApplicationController
+ include SetupMixin
+ include SettingsMixin
+
+ def edit
+ apply_defaults unless @settings.rule_max_sync_age
+ end
+
+ def update
+ @settings.save
+
+ Rails.application.settings.configured = true
+ flash.notice = "Setup Wizard completed successfully. You may verify settings below."
+
+ redirect_to settings_url
+ end
+
+
+ private
+
+ def default_settings
+ {
+ rule_max_sync_age: 86400,
+ rule_email_regex: "@(#{default_domain.gsub('.', '\.')}|users\\.noreply\\.github\\.com)$",
+ github_user_requirements: [
+ 'Must enable two factor authentication',
+ 'Must only associate your company email address',
+ ]
+ }
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
new file mode 100644
index 0000000..55da54a
--- /dev/null
+++ b/app/controllers/users_controller.rb
@@ -0,0 +1,36 @@
+class UsersController < ApplicationController
+ before_filter :load_user, except: [:index]
+ before_filter :require_admin, except: [:show]
+ before_filter :require_admin_or_user, only: [:show]
+
+ def index
+ # TODO: Pagination
+ @users = User.includes(:github_users).order(:name)
+ end
+
+ def show
+ end
+
+ def edit
+ end
+
+ def update
+ @user.update!(user_params)
+ redirect_to @user
+ end
+
+ private
+
+ def load_user
+ @user = User.friendly.find(params[:id])
+ end
+
+ def require_admin_or_user
+ return true if @user == current_user
+ require_admin
+ end
+
+ def user_params
+ params.require(:user).permit(:admin)
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
new file mode 100644
index 0000000..5a036fd
--- /dev/null
+++ b/app/helpers/application_helper.rb
@@ -0,0 +1,26 @@
+module ApplicationHelper
+ def current_user_path
+ url_for controller: :users, action: :show, id: current_user ? current_user.username : nil
+ end
+
+ def jumbotron(&block)
+ content_for(:jumbotron, &block)
+ end
+
+ def settings
+ Rails.application.settings
+ end
+
+ def title(page_title)
+ content_for(:title, page_title.to_s)
+ end
+
+ def nav_section(nav_section)
+ content_for(:nav_section, nav_section)
+ end
+
+ def format_time(time)
+ return nil unless time
+ content_tag(:span, time.to_s, data: { time: time.utc.iso8601 })
+ end
+end
diff --git a/app/helpers/github_users_helper.rb b/app/helpers/github_users_helper.rb
new file mode 100644
index 0000000..e786b5f
--- /dev/null
+++ b/app/helpers/github_users_helper.rb
@@ -0,0 +1,13 @@
+module GithubUsersHelper
+ def github_user_state_label(github_user)
+ state_class = case github_user.state
+ when 'disabled' then 'label-danger'
+ when 'unknown' then 'label-warning'
+ when 'enabled' then 'label-success'
+ when 'excluded' then 'label-info'
+ when 'external' then 'label-info'
+ end
+
+ content_tag :span, github_user.human_state_name.capitalize, class: ['label', state_class].compact
+ end
+end
diff --git a/app/jobs/connect_github_user_job.rb b/app/jobs/connect_github_user_job.rb
new file mode 100644
index 0000000..b4ab9c4
--- /dev/null
+++ b/app/jobs/connect_github_user_job.rb
@@ -0,0 +1,64 @@
+class ConnectGithubUserJob < ActiveJob::Base
+ include GithubOauthConcern
+ queue_as :default
+
+ def perform(connect_status)
+ @connect_status = connect_status
+
+ @connect_status.update_attributes!(
+ status: :running,
+ step: :grant
+ )
+
+ # Process the user's token
+ begin
+ @github_user = oauth_process_auth_code
+ rescue OAuth2::Error => e
+ Rails.logger.warn "Cannot establish OAuth token: #{e.message}"
+ @connect_status.update_attributes!(
+ status: :error,
+ error_message: e.description
+ )
+ return
+ end
+
+ @connect_status.update_attributes!(
+ step: :add,
+ github_user: @github_user
+ )
+
+ # Add to organizations
+ unless @github_user.add_to_organizations
+ @connect_status.update_attributes!(
+ status: :error
+ )
+ return
+ end
+
+ # Enable user
+ @github_user.enable if @github_user.can_enable?
+
+ # Mark complete
+ @connect_status.update_attributes!(
+ status: :complete,
+ step: :teams
+ )
+
+ rescue => e
+ Rails.logger.error "Error running ConnectGithubUserJob: #{e}"
+ @connect_status.update_attributes!(
+ status: :error,
+ error_message: e.message
+ )
+ end
+
+ private
+
+ def current_user
+ @connect_status.user
+ end
+
+ def oauth_code
+ @connect_status.oauth_code
+ end
+end
diff --git a/app/mailers/.keep b/app/mailers/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
new file mode 100644
index 0000000..517db13
--- /dev/null
+++ b/app/mailers/user_mailer.rb
@@ -0,0 +1,7 @@
+class UserMailer < ActionMailer::Base
+ def access_revoked(user, github_user)
+ @user = user
+ @github_user = github_user
+ mail(to: @user.email, subject: 'GitHub Access Revoked')
+ end
+end
diff --git a/app/models/.keep b/app/models/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/concerns/encryptable.rb b/app/models/concerns/encryptable.rb
new file mode 100644
index 0000000..409b1ce
--- /dev/null
+++ b/app/models/concerns/encryptable.rb
@@ -0,0 +1,37 @@
+module Encryptable
+ extend ActiveSupport::Concern
+
+ # The encrypted database salt environment variable.
+ ENCRYPTED_DATABASE_SALT = 'encryptable.encrypted_database_salt'.freeze
+
+ module ClassMethods
+ def attr_encryptor(attr)
+ field = "encrypted_#{attr}"
+ define_method("#{attr}=") { |val|
+ unless val == self.send("#{attr}")
+ self.send("#{field}=", encrypt(val))
+ end
+ }
+ define_method("#{attr}") { decrypt(self.send(field)) }
+ end
+
+ def crypt
+ @crypt ||= begin
+ salt = ENV[ENCRYPTED_DATABASE_SALT] || ''
+ key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secrets.database_key, iterations: 2000)
+ key = key_generator.generate_key(salt)
+ ActiveSupport::MessageEncryptor.new(key)
+ end
+ end
+ end
+
+ def encrypt(data)
+ return nil if data.nil?
+ self.class.crypt.encrypt_and_sign(data)
+ end
+
+ def decrypt(data)
+ return nil if data.nil?
+ self.class.crypt.decrypt_and_verify(data)
+ end
+end
diff --git a/app/models/connect_github_user_status.rb b/app/models/connect_github_user_status.rb
new file mode 100644
index 0000000..d037908
--- /dev/null
+++ b/app/models/connect_github_user_status.rb
@@ -0,0 +1,58 @@
+class ConnectGithubUserStatus < ActiveRecord::Base
+ belongs_to :user
+ belongs_to :github_user
+
+ def step_complete?(step)
+ steps_completed.include?(step)
+ end
+
+ def step_disabled?(step)
+ steps_disabled.include?(step)
+ end
+
+ def step_error?(step)
+ self.step == step && status == :error
+ end
+
+ def in_progress?
+ %i(queued running).include?(status)
+ end
+
+ def complete?
+ %i(complete).include?(status)
+ end
+
+ def status
+ status = read_attribute(:status)
+ status ? status.to_sym : nil
+ end
+
+ def step
+ step = read_attribute(:step)
+ step ? step.to_sym : nil
+ end
+
+ def steps
+ %i(create request grant add teams)
+ end
+
+ def steps_completed
+ if step == :request && !github_user_id
+ []
+ elsif status == :complete
+ steps
+ else
+ steps.first(step_index)
+ end
+ end
+
+ def steps_disabled
+ steps.last(steps.count - step_index - 1)
+ end
+
+ private
+
+ def step_index
+ steps.index(step)
+ end
+end
diff --git a/app/models/github_email.rb b/app/models/github_email.rb
new file mode 100644
index 0000000..fb40bdf
--- /dev/null
+++ b/app/models/github_email.rb
@@ -0,0 +1,5 @@
+class GithubEmail < ActiveRecord::Base
+ belongs_to :github_user
+
+ default_scope { order(:created_at) }
+end
diff --git a/app/models/github_team.rb b/app/models/github_team.rb
new file mode 100644
index 0000000..6452b1d
--- /dev/null
+++ b/app/models/github_team.rb
@@ -0,0 +1,93 @@
+class GithubTeam < ActiveRecord::Base
+ has_and_belongs_to_many :github_users, join_table: :github_user_teams
+
+ attr_accessor :github_admin
+
+ # Finds a GithubTeam using a "full" slug. A full slug
+ # is the organization and team slug combined with a slash, for example:
+ # org1/myteam
+ #
+ # @param full_slug [String] an organization and team slug separated with a slash
+ # @return [GithubTeam]
+ def self.find_by_full_slug(full_slug)
+ (org, slug) = full_slug.split('/', 2)
+ where(organization: org, slug: slug).first
+ end
+
+ # Does this team allow external users?
+ #
+ # @return [Boolean]
+ # @see {GithubConnector::Settings#github_external_users}
+ def external?
+ external_teams = Rails.application.settings.github_external_teams
+ external_teams && (external_teams.include?(slug) || external_teams.include?(full_slug))
+ end
+
+ # Returns the "full" slug for this team. A full slug
+ # is the organization and team slug combined with a slash, for example:
+ # org1/myteam
+ #
+ # @return [String] an organization and team slug separated with a slash
+ def full_slug
+ "#{organization}/#{slug}"
+ end
+
+ def github_admin
+ @github_admin ||= GithubAdmin.new
+ end
+
+ # Synchronizes {GithubTeam} attributes and members from Github.
+ #
+ # @return [Boolean] true if saved successfully. NOTE: This method returns
+ # true even if GitHub API errors occur, as long as the error is successfully
+ # saved to the `sync_error` attribute.
+ def sync
+ # TODO: Handle errors
+ sync_github_team & sync_github_members
+ end
+
+ # Synchronizes {GithubTeam} attributes and members from GitHub.
+ # An `ActiveRecord::RecordNotSaved` error is raised if the save
+ # fails.
+ #
+ # @return [void]
+ def sync!
+ sync || raise(ActiveRecord::RecordNotSaved)
+ end
+
+ protected
+
+ def sync_github_team
+ data = github_admin.team(id)
+ self.id = data[:id]
+ self.name = data[:name]
+ self.organization = data[:organization]
+ self.slug = data[:slug]
+ if changed?
+ save
+ else
+ true
+ end
+ end
+
+ def sync_github_members
+ members = github_admin.team_members(id)
+ added_members = []
+ removed_users = []
+
+ github_users.each do |user|
+ next if members.has_key?(user.login)
+ # TODO: Don't remove disabled users???
+ removed_users << user
+ end
+ github_users.delete(*removed_users) unless removed_users.empty?
+
+ members.each do |login, member|
+ next if github_users.any? { |user| user.login == login }
+ added_members << login
+ end
+ github_users << GithubUser.where(login: added_members) unless added_members.empty?
+
+ true
+ end
+end
diff --git a/app/models/github_user.rb b/app/models/github_user.rb
new file mode 100644
index 0000000..5ba367f
--- /dev/null
+++ b/app/models/github_user.rb
@@ -0,0 +1,444 @@
+class GithubUser < ActiveRecord::Base
+ include Encryptable
+ include FriendlyId
+ friendly_id :login
+
+ attr_accessor :github_admin
+
+ attr_encryptor :token
+
+ belongs_to :user
+ has_many :emails, class_name: 'GithubEmail', dependent: :destroy
+ has_and_belongs_to_many :teams, class_name: 'GithubTeam', join_table: :github_user_teams
+ has_and_belongs_to_many :disabled_teams, class_name: 'GithubTeam', join_table: :github_user_disabled_teams
+
+ validates :login, uniqueness: true
+
+ scope :active, -> { where.not(state: :disabled) }
+ scope :disabled, -> { where(state: :disabled) }
+ scope :enabled, -> { where(state: :enabled) }
+ scope :external, -> { where(state: :external) }
+ scope :excluded, -> { where(state: :excluded) }
+ scope :linked, -> { where.not(user_id: nil) }
+ scope :unlinked, -> { where(user_id: nil) }
+
+ # Each user can be in one of the following states:
+ # * enabled - user meets all rules, and can be a member of any team
+ # * external - user only meets external rules and can only be a member
+ # of external teams
+ # * disabled - user fails one or more rules and should not be a member
+ # of our organizations
+ # * excluded - user is excluded from rules matching
+ # * unknown - user has not yet been tracked
+ state_machine :state, initial: :unknown do
+ event :enable do
+ transition any - :enabled => :enabled
+ end
+
+ event :restrict do
+ transition any - :external => :external, unless: :global_excluded_user?
+ end
+
+ event :disable do
+ transition any - :disabled => :disabled, unless: :global_excluded_user?
+ end
+
+ event :exclude do
+ transition any - :excluded => :excluded
+ end
+
+ before_transition any => :enabled, do: :do_enable
+
+ before_transition any => :external, do: :do_restrict
+ after_transition :enabled => :external do |user, transition|
+ user.send(:do_notify_restricted, transition) if user.failing_rules.any? { |rule| rule.notify? }
+ end
+
+ before_transition any => :disabled, do: :do_disable
+ after_transition [:enabled, :external] => :disabled do |user, transition|
+ user.send(:do_notify_disabled, transition) if user.failing_rules.any? { |rule| rule.notify? }
+ end
+ end
+
+ # Add the user to our managed organizations. This performs the
+ # following steps:
+ # 1. Invites user to our organization
+ # 2. Accepts invitation
+ # 3. Verifies all rules pass
+ # 4. Adds to default teams
+ #
+ # @return [Boolean] true if successful, false otherwise
+ def add_to_organizations
+ orgs = Rails.application.settings.github_orgs || []
+ return true if orgs.empty?
+ check_mfa_team = Rails.application.settings.github_check_mfa_team
+ default_teams = Rails.application.settings.github_default_teams
+ raise "Must set github_check_mfa_team setting!" unless check_mfa_team
+ raise "Must set github_default_teams setting!" unless default_teams
+
+ # Add user to our organizations
+ added_orgs = []
+ checked_mfa = false
+ orgs.each do |org|
+ unless github_admin.octokit.organization_member?(org, login)
+ Rails.logger.info "Adding #{login} to organization #{org}."
+ team = GithubTeam.find_by_full_slug("#{org}/#{check_mfa_team}")
+ raise "Cannot find github_check_mfa_team for #{org}" unless team
+
+ # Generate the invitation
+ github_admin.octokit.add_team_membership(team.id, login)
+
+ # Accept the invitation
+ octokit.update_organization_membership(org, {state: 'active'})
+
+ # MFA status can only be verified once the user is a member of
+ # our organization. If we haven't checked mfa yet, check it now.
+ if Rules::GithubMfa.enabled? && !checked_mfa
+ self.mfa = github_admin.user_mfa?(login, org)
+ save
+ checked_mfa = true
+
+ # No use continuing if we don't have MFA enabled.
+ break unless mfa
+ end
+
+ added_orgs << org
+ end
+ end
+
+ # Check mfa if disabled and not already checked
+ if Rules::GithubMfa.enabled? && !checked_mfa && !mfa
+ self.mfa = github_admin.user_mfa?(login)
+ save
+ checked_mfa = true
+ end
+
+ # Check for failing rules
+ valid_user = failing_rules.empty?
+
+ # Add to default teams
+ if valid_user
+ add_to_teams(default_teams)
+ end
+
+ # Remove from the temporary MFA check team
+ orgs.each do |org|
+ team = GithubTeam.find_by_full_slug("#{org}/#{check_mfa_team}")
+ raise "Cannot find github_check_mfa_team for #{org}" unless team
+ github_admin.octokit.remove_team_member(team.id, login)
+ end
+
+ valid_user
+ end
+
+ # Adds the user to the given Github teams.
+ #
+ # @params teams [Array|Array] list of {GithubTeam}s or
+ # team slugs
+ # @return [Array]
+ def add_to_teams(*teams)
+ # Remove teams we're already a member of
+ teams = normalize_teams(*teams).reject do |team|
+ self.teams.include?(team)
+ end
+
+ # Add the teams to Github
+ teams.each do |team|
+ Rails.logger.info "Adding #{login} to team #{team.full_slug}."
+ github_admin.octokit.add_team_membership(team.id, login)
+ end
+
+ # Cache the membership in the database
+ self.teams += teams
+
+ teams
+ end
+ alias :add_to_team :add_to_teams
+
+ # Adds the user to {disabled_teams}, if any, and clears the {disabled_teams}
+ # list. This is useful to add the user to his previous teams after re-enabling
+ # the user.
+ #
+ # @return [Array] the teams the user was added to
+ def add_back_disabled_teams
+ return [] if disabled_teams.empty?
+ add_to_teams(disabled_teams).tap do
+ disabled_teams.clear
+ end
+ end
+
+ # Should the Github user be excluded from processing by global settings?
+ #
+ # @return [Boolean]
+ # @see {GithubConnector::Settings#github_exclude_users}
+ def global_excluded_user?
+ exclude_users = Rails.application.settings.github_exclude_users
+ exclude_users && exclude_users.include?(login)
+ end
+
+ # Returns a list of failing rules for this User.
+ #
+ # @return [Rules::Iterator]
+ def failing_rules
+ @failing_rules ||= rules.dup.failing
+ end
+
+ def github_admin
+ @github_admin ||= GithubAdmin.new
+ end
+
+ # The GitHub API client
+ #
+ # @return [Octokit::Client]
+ def octokit
+ @octokit ||= Octokit::Client.new(access_token: token)
+ end
+
+ # The GitHub organizations this user is a member of.
+ #
+ # @return [Array]
+ def organizations
+ teams.map do |team|
+ team.organization
+ end.compact.uniq
+ end
+
+ # Returns a list of passing rules for this User.
+ #
+ # @return [Rules::Iterator]
+ def passing_rules
+ @passing_rules ||= rules.dup.passing
+ end
+
+ # Remove this user from all organizations, including normally excluded teams.
+ #
+ # @return [Array] list of {GithubTeam}s the user was removed from
+ def remove_from_organizations
+ orgs = Rails.application.settings.github_orgs || []
+ remove_teams = teams.to_a
+ return [] if orgs.empty?
+
+ Rails.logger.info "Removing #{login} from organizations #{orgs.join(', ')}. Removing from teams: #{remove_teams.map {|team| team.full_slug}.join(', ')}."
+
+ orgs.each do |org|
+ github_admin.octokit.remove_organization_member(org, login)
+ end
+ teams.clear
+
+ remove_teams
+ end
+
+ # Remove this user from all non-external teams.
+ #
+ # @return [Array] list of {GithubTeam}s the user was removed from
+ def remove_from_internal_teams
+ remove_teams = teams.reject { |team| team.external? }
+ return [] if remove_teams.empty?
+
+ Rails.logger.info "Removing #{login} from teams: #{remove_teams.map {|team| team.full_slug}.join(', ')}"
+ remove_teams.each do |team|
+ if github_admin.octokit.remove_team_member(team.id, login)
+ teams.destroy(team)
+ end
+ end
+
+ remove_teams
+ end
+
+ # Returns a list of rules required for external users.
+ #
+ # @return [Rules::Iterator]
+ def external_rules
+ @external_rules ||= rules.dup.external
+ end
+
+ # Returns a list of enabled rules for this User. All rules must pass
+ # in order to gain full access to GitHub.
+ #
+ # @return [Rules::Iterator]
+ def rules
+ @rules ||= Rules.for_github_user(self)
+ end
+
+ # Synchronizes {GithubUser} attributes from GitHub. This sets the attributes
+ # and saves the +GithubUser+. If GitHub API errors are encountered, they are
+ # recorded in `sync_error` and logged to the Rails logger.
+ #
+ # @return [Boolean] true if saved successfully. NOTE: This method returns
+ # true even if GitHub API errors occur, as long as the error is successfully
+ # saved to the `sync_error` attribute.
+ def sync
+ unless token
+ self.sync_error = 'notoken'
+ return save
+ end
+
+ # Pull data from GitHub API
+ begin
+ ghuser = octokit.user
+ ghemails = octokit.emails.map { |h| h[:email] }
+ rescue Octokit::Error => e
+ Rails.logger.error "Error syncing #{login} with GitHub: #{e}"
+ self.sync_error = e.class.name.demodulize.underscore
+ return save
+ end
+
+ # Save results
+ transaction do
+ # Force association reload just in case
+ emails(true)
+
+ # Remove old email addresses
+ removed = emails.select do |email|
+ !ghemails.include?(email.address)
+ end
+ emails.destroy(removed)
+
+ # Add new email addresses
+ existing_emails = emails.map(&:address)
+ (ghemails - existing_emails).each do |added|
+ emails.build(address: added)
+ end
+
+ self.login = ghuser.login
+ self.last_sync_at = Time.now
+ self.sync_error = nil
+ save
+ end
+ end
+
+ # Synchronizes {GithubUser} attributes from GitHub.
+ # An `ActiveRecord::RecordNotSaved` error is raised if the save
+ # fails.
+ #
+ # @return [void]
+ def sync!
+ sync || raise(ActiveRecord::RecordNotSaved)
+ end
+
+ def sync_error=(val)
+ self.sync_error_at = val ? Time.now : nil
+ super
+ end
+
+ # Transitions to the correct state based on the {User#rules} and
+ # the current attributes.
+ #
+ # @return [Symbol] the event that was executed, or nil if no
+ # transition occured
+ def transition
+ new_state = case
+ when global_excluded_user?
+ :excluded
+ when rules.valid?
+ :enabled
+ when external_rules.valid? && teams.any? { |team| team.external? }
+ :external
+ else
+ :disabled
+ end
+ return nil if state == new_state
+
+ transition = state_transitions.find do |t|
+ t.from_name == state.to_sym && t.to_name == new_state
+ end
+ return nil unless transition && transition.event
+ event = transition.event
+ return nil unless send("can_#{event}?")
+
+ self.send(event)
+ event.to_sym
+ end
+
+ # Does the user have a valid GitHub token?
+ #
+ # @return [Boolean]
+ def valid_token?
+ return false unless token
+ begin
+ # We use rate limit as its a fast and free way to
+ # test the GitHub token.
+ octokit.rate_limit
+ rescue Octokit::ClientError
+ return false
+ end
+ true
+ end
+
+ private
+
+ # Normalizes a list of slugs, full sligs, or {GithubTeam}s into
+ # a single array of {GithubTeam}s.
+ #
+ # @params teams [Array|Array] list of {GithubTeam}s or
+ # team slugs
+ # @return [Array]
+ def normalize_teams(*teams)
+ teams.flatten.inject([]) do |new_teams, team|
+ if team.is_a?(GithubTeam)
+ new_teams << team
+ elsif team.include?('/')
+ new_teams << GithubTeam.find_by_full_slug(team)
+ else
+ # Unqualified slugs may exist in multiple organizations
+ new_teams += GithubTeam.where(slug: team)
+ end
+ new_teams
+ end.compact.uniq
+ end
+
+ # Removes the GithubUser's GitHub access. Removes the user from all
+ # GitHub organizations and teams.
+ #
+ # @param transition [StateMachine::Transition]
+ # @return [void]
+ def do_disable(transition)
+ Rails.logger.info "Transitioning #{login} from #{transition.from} to #{transition.to} via #{transition.event} event. Failing rules: #{failing_rules.map(&:name).join(', ')}."
+ if Rails.application.settings.enforce_rules
+ self.disabled_teams = remove_from_organizations
+ end
+ end
+
+ # Restricts the GithubUser's GitHub access to external teams.
+ #
+ # @param transition [StateMachine::Transition]
+ # @return [void]
+ def do_restrict(transition)
+ Rails.logger.info "Transitioning #{login} from #{transition.from} to #{transition.to} via #{transition.event} event. Failing rules: #{failing_rules.map(&:name).join(', ')}."
+ if Rails.application.settings.enforce_rules
+ self.disabled_teams = remove_from_internal_teams
+ end
+ end
+
+ # Sends an email to the User indicating that their GitHub access
+ # has been revoked.
+ #
+ # @param transition [StateMachine::Transition]
+ # @return [void]
+ def do_notify_disabled(transition)
+ if user && Rails.application.settings.enforce_rules
+ UserMailer.access_revoked(user, self).deliver_later
+ end
+ end
+
+ # Sends an email to the User indicating that their GitHub access
+ # has been restricted to external teams.
+ #
+ # @param transition [StateMachine::Transition]
+ # @return [void]
+ def do_notify_restricted(transition)
+ if user && Rails.application.settings.enforce_rules
+ UserMailer.access_revoked(user, self).deliver_later
+ end
+ end
+
+ # Grants the user GitHub access
+ #
+ # @param transition [StateMachine::Transition]
+ # @return [void]
+ def do_enable(transition)
+ Rails.logger.info "Transitioning #{login} from #{transition.from} to #{transition.to} via #{transition.event} event. Passing rules: #{passing_rules.map(&:name).join(', ')}."
+ add_back_disabled_teams
+ end
+
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
new file mode 100644
index 0000000..cfd8c00
--- /dev/null
+++ b/app/models/setting.rb
@@ -0,0 +1,2 @@
+class Setting < ActiveRecord::Base
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..cec8dd0
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,210 @@
+class User < ActiveRecord::Base
+ include FriendlyId
+ friendly_id :username
+
+ # Include default devise modules. Others available are:
+ # :confirmable, :lockable, :timeoutable and :omniauthable
+ devise :ldap_authenticatable, :rememberable, :trackable
+
+ has_many :github_users
+
+ validates :username, uniqueness: true
+
+ scope :linked, -> { joins(:github_users) }
+ scope :unlinked, -> { joins('LEFT OUTER JOIN github_users ON github_users.user_id = users.id').where(github_users: {id: nil}) }
+
+ # UserAccountControl flags
+ # @see http://support.microsoft.com/kb/305144
+ module AccountControl
+ SCRIPT = 0x0001
+ ACCOUNT_DISABLED = 0x0002
+ HOMEDIR_REQUIRED = 0x0008
+ LOCKOUT = 0x0010
+ PASSWD_NOTREQD = 0x0020
+ PASSWD_CANT_CHANGE = 0x0040
+ ENCRYPTED_TEXT_PWD_ALLOWED = 0x0080
+ TEMP_DUPLICATE_ACCOUNT = 0x0100
+ NORMAL_ACCOUNT = 0x0200
+ INTERDOMAIN_TRUST_ACCOUNT = 0x0800
+ WORKSTATION_TRUST_ACCOUNT = 0x1000
+ SERVER_TRUST_ACCOUNT = 0x2000
+ DONT_EXPIRE_PASSWORD = 0x10000
+ MNS_LOGON_ACCOUNT = 0x20000
+ SMARTCARD_REQUIRED = 0x40000
+ TRUSTED_FOR_DELEGATION = 0x80000
+ NOT_DELEGATED = 0x100000
+ USE_DES_KEY_ONLY = 0x200000
+ DONT_REQ_PREAUTH = 0x400000
+ PASSWORD_EXPIRED = 0x800000
+ TRUSTED_TO_AUTH_FOR_DELEGATION = 0x1000000
+ PARTIAL_SECRETS_ACCOUNT = 0x04000000
+ end
+
+ # Callback from Devise. This synchronizes the user data from
+ # LDAP to our local database.
+ #
+ # @return [void]
+ def after_ldap_authentication
+ if ldap_entry
+ sync_from_ldap!
+ end
+ end
+
+ # Returns a list of Github email addresses
+ #
+ # @return [Array]
+ def github_emails
+ github_users.inject([]) do |emails, github_user|
+ emails += github_user.emails.map(&:address)
+ end
+ end
+
+ def ldap_account_control_flags
+ AccountControl.constants.inject([]) do |flags, const|
+ if (AccountControl.const_get(const) & ldap_account_control) != 0
+ flags << const.downcase
+ end
+ flags
+ end
+ end
+
+ # Returns a single parameter from LDAP or nil. Normally, Net::LDAP::Entry
+ # returns an array of values or nil if the parameter doesn't exist. This
+ # returns the first value from the array, or nil if it doesn't exist.
+ #
+ # @param param [String] parameter to retrieve from LDAP
+ # @return [Object] first value for the given parameter
+ def ldap_get_single_param(param)
+ value = ldap_get_param(param)
+ if value.is_a?(Array)
+ value = value.first
+ end
+ value
+ end
+
+ def ldap_sync_error=(val)
+ self.ldap_sync_error_at = val ? Time.now : nil
+ super
+ end
+
+ # Synchronizes {User} attributes from Active Directory and GitHub.
+ #
+ # @return [Boolean] true if saved successfully
+ def sync
+ sync_from_ldap & sync_from_github
+ end
+
+ # Synchronizes {User} attributes from Active Directory and GitHub.
+ # An `ActiveRecord::RecordNotSaved` error is raised if the save
+ # fails.
+ #
+ # @return [void]
+ def sync!
+ sync || raise(ActiveRecord::RecordNotSaved)
+ end
+
+ def sync_from_github
+ github_users.inject(true) do |result, github_user|
+ result & github_user.sync
+ end
+ end
+
+ # Syncrhonizes {User} attributes from GitHub. This sets the attributes
+ # and saves the +User+. A `ActiveRecord::RecordNotSaved` error is raised
+ # if the save fails.
+ #
+ # @return [void]
+ def sync_from_github!
+ sync_from_github || raise(ActiveRecord::RecordNotSaved)
+ end
+
+ # Synchronizes {User} attributes from LDAP. This sets the attributes
+ # and saves the +User+. If LDAP errors are encountered, they are
+ # recorded in `ldap_sync_error` and logged to the Rails logger.
+ #
+ # @return [Boolean] true if saved successfully. NOTE: This method returns
+ # true even if LDAP errors occur, as long as the error is successfully
+ # saved to the `ldap_sync_error` attribute.
+ def sync_from_ldap
+ begin
+ self.name = ldap_get_single_param('name')
+ self.email = ldap_get_single_param('mail')
+ self.ldap_account_control = ldap_get_single_param('userAccountControl')
+ self.last_ldap_sync = Time.now
+ self.ldap_sync_error = nil
+ save
+ rescue Net::LDAP::LdapError, Net::LDAP::PDU::Error => e
+ Rails.logger.error "Error syncing #{username} with Active Directory: #{e}"
+ self.ldap_sync_error = e.message
+ return save
+ end
+ end
+
+ # Synchronizes {User} attributes from LDAP. This sets the attributes
+ # and saves the +User+. An `ActiveRecord::RecordNotSaved` error is
+ # raised if the save fails.
+ #
+ # @return [void]
+ def sync_from_ldap!
+ sync_from_ldap || raise(ActiveRecord::RecordNotSaved)
+ end
+
+ private
+
+ # Finds the User using the normalized ldap username.
+ #
+ # @param attributes [Hash] Devise attributes
+ # @return User
+ # @see normalize_ldap_username
+ def self.find_for_ldap_authentication_with_normalize(attributes={})
+ auth_key = self.authentication_keys.first
+ return nil unless attributes[auth_key].present?
+
+ auth_key_value = (self.case_insensitive_keys || []).include?(auth_key) ? attributes[auth_key].downcase : attributes[auth_key]
+ auth_key_value = (self.strip_whitespace_keys || []).include?(auth_key) ? auth_key_value.strip : auth_key_value
+
+ # Strip AD domain if given
+ if auth_key_value.include?('\\')
+ auth_key_value = auth_key_value.split('\\', 2)[1]
+ end
+
+ resource = where(auth_key => auth_key_value).first
+ if resource.blank?
+ # If we can't find the resource using the given username
+ # try searching different attributes using ldap.
+ auth_key_value = normalize_ldap_username(auth_key_value)
+ return nil unless auth_key_value
+ end
+
+ attrs = attributes.dup
+ attrs[auth_key] = auth_key_value
+ find_for_ldap_authentication_without_normalize(attrs)
+ end
+
+ # Searches for the username in common username attributes
+ # (sAMAccountName, userPrincipalName, mail) and if found returns
+ # the normalized username attribute (sAMAccountName).
+ #
+ # @param username [String] the username to normalize
+ # @return [String] normalized username
+ def self.normalize_ldap_username(username)
+ ldap = Devise::LDAP::Adapter.ldap_connect(username).ldap
+ ldap_entry = nil
+ %w(sAMAccountName mail userPrincipalName).find do |ldap_attr|
+ filter = Net::LDAP::Filter.eq(ldap_attr.to_s, username)
+ ldap_entry = ldap.search(:filter => filter)
+ ldap_entry = ldap_entry.first if ldap_entry
+ DeviseLdapAuthenticatable::Logger.send("LDAP search for #{ldap_attr}=#{username}: #{ldap_entry ? "found match" : "no matches"}")
+ ldap_entry
+ end
+ return nil unless ldap_entry
+
+ username = ldap_entry['sAMAccountName']
+ username = username.first if username.is_a?(Enumerable)
+ username
+ end
+
+ class << self
+ alias_method_chain :find_for_ldap_authentication, :normalize
+ end
+end
diff --git a/app/views/connect/_connect_step.html.erb b/app/views/connect/_connect_step.html.erb
new file mode 100644
index 0000000..fb17a2f
--- /dev/null
+++ b/app/views/connect/_connect_step.html.erb
@@ -0,0 +1,18 @@
+<%
+ classes = %w(list-group-item step-request)
+ classes << 'disabled' if @connect_status.step_disabled?(step)
+ if @connect_status.step_error?(step)
+ classes << 'error'
+ else
+ classes << 'complete' if @connect_status.step_complete?(step)
+ classes << 'active' if @connect_status.step == step
+ end
+%>
+
+
+
+
+
+
+ <%= yield %>
+
diff --git a/app/views/connect/index.html.erb b/app/views/connect/index.html.erb
new file mode 100644
index 0000000..8461b63
--- /dev/null
+++ b/app/views/connect/index.html.erb
@@ -0,0 +1,159 @@
+<% title "Add GitHub Account" %>
+<% nav_section :connect %>
+<% jumbotron do %>
+
Add GitHub Account
+
Connect your GitHub.com account<%= " to #{settings.company}" if settings.company.present? %>
+<% end %>
+
+
Adding a GitHub.com account
+
+
Connecting a GitHub account to <%= settings.company.present? ? settings.company : "Active Directory" %> establishes an OAuth connection between your account on GitHub.com and the <%= settings.company || "" %> GitHub Connector. This allows <%= settings.company.present? ? settings.company : "us" %> to view certain attributes of your GitHub account (email address, public ssh keys, etc.) to ensure they meet our policy. It also allows <%= settings.company.present? ? settings.company : "us" %> to control organization memberships.
+ <% end %>
+ <% end %>
+
+
+ <%= render layout: 'connect_step', locals: {step: :request} do %>
+
Step 2: Request GitHub permissions
+
+
+ <% if @connect_status.step == :request %>
+ Click the Request Permissions button below.
+ <% else %>
+ Request permissions from your GitHub account.
+ <% end %>
+ This will take you to GitHub and ask you to authorize the GitHub Connector application. Ensure that you are logged in to GitHub with the account from Step 1 before starting this process.
+
You will be added to the <%= " #{settings.company}" if settings.company.present? %> GitHub <%= "organization".pluralize((settings.github_orgs || []).count) %>.
+ <% end %>
+
+
+ <%= render layout: 'connect_step', locals: {step: :teams} do %>
+
Step 5: Add to<%= " #{settings.company}" %> GitHub teams
Your GitHub user, <%= @connect_status.github_user.login %>, is a member of the following <%= "team".pluralize(@connect_status.github_user.teams.count) %>:
+
+ <% @connect_status.github_user.teams.each do |team| %>
+
If you need additional access, contact your <%= settings.company %> GitHub administrator.
+
+ <% else %>
+
You will be automatically added to certain GitHub teams. If you need additional access, contact your <%= settings.company %> GitHub administrator.
+ <% end %>
+ <% end %>
+
+
+<% if @connect_status.complete? %>
+
Success!
+
You've successfully added your GitHub account. Visit GitHub.com to view repositories:
+
<%= link_to "Visit#{" #{settings.company} on" if settings.company.present?} GitHub.com", "https://github.com/#{settings.github_orgs.first if settings.github_orgs}", class: 'btn btn-primary' %>
+
Note: You may safely ignore any email invitiations from GitHub asking you to join the Rapid7 <%= "organization".pluralize((settings.github_orgs || []).count) %>.
+<% end %>
+
+<% if @connect_status.in_progress? %>
+
+<% end %>
+
+
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb
new file mode 100644
index 0000000..a57a90d
--- /dev/null
+++ b/app/views/dashboard/index.html.erb
@@ -0,0 +1,18 @@
+<% jumbotron do %>
+
GitHub Connector
+
Connect your GitHub.com account(s) to <%= settings.company.present? ? settings.company : "Active Directory" %>
+ <% if current_user.github_users.empty? %>
+ <%= link_to("Connect your GitHub.com account", connect_path, class: "btn btn-default btn-lg") %>
+ <% else %>
+ <%= link_to("Add another GitHub.com account", connect_path, class: "btn btn-default btn-lg") %>
+ <% end %>
+<% end %>
+
+
+<%= render partial: 'users/github_users', locals: {user: current_user} %>
+
+<% if current_user.github_users.empty? %>
+ <%= link_to('Connect your GitHub Account', connect_path, class: 'btn btn-default') %>
+<% else %>
+ <%= link_to('Add another GitHub Account', connect_path, class: 'btn btn-default') %>
+<% end %>
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb
new file mode 100644
index 0000000..a82f464
--- /dev/null
+++ b/app/views/devise/sessions/new.html.erb
@@ -0,0 +1,27 @@
+<% title "Sign In" %>
+<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: {class: 'form-signin'}) do |f| %>
+
+ <%= text_field(:settings, :ldap_base, class: 'form-control') %>
+ Root node in LDAP from which to search for users and groups. Example: cn=users,dc=example,dc=com.
+
+ <%= text_field(:settings, :github_check_mfa_team, class: 'form-control') %>
+ A GitHub team with no privileges used to check 2FA for new users. When a GitHub user is first added, they will be temporarily added to this team so we can check 2FA status.
+
+ <%= label_tag(:settings_enforce_rules) do %>
+ <%= check_box(:settings, :enforce_rules) %> Enforce rules?
+ <% end %>
+
+ If checked, GitHub users will be removed or restricted to external teams depending on their state. If unchecked, the GitHub connector runs in dry-run mode - no changes to GitHub users will be made.
+
+ <%= text_area(:settings,
+ :github_user_requirements,
+ value: @settings.github_user_requirements ? @settings.github_user_requirements.join("\r\n") : nil,
+ class: 'form-control') %>
+ A list of user requirements, one per line. These will be displayed to users when adding new GitHub accounts.
+
Let's get started configuring the Github Connector.
+
+
This wizard will guide you through configuring the Github Connector. Along the way, we'll make some educated guesses for defaults, but please review each configuration option as you go. You may always update settings after completing the wizard.
+
+
First, enter some information about your company below.
+
+<%= form_tag({action: :update}, method: :put, class: 'form-horizontal max-col-sm') do %>
+ <% if @error %>
+
+<% end %>
diff --git a/app/views/setup/ldap/edit.html.erb b/app/views/setup/ldap/edit.html.erb
new file mode 100644
index 0000000..fdc4830
--- /dev/null
+++ b/app/views/setup/ldap/edit.html.erb
@@ -0,0 +1,23 @@
+<% title "Setup Wizard" %>
+<% nav_section :settings %>
+<% jumbotron do %>
+
Setup Wizard
+
Step 2: Active Directory settings
+<% end %>
+
+
The Github Connector connects to Active Directory using a service account to read information about employees. Enter your Active Directory settings below.
+
+<%= form_tag({action: :update}, method: :put, class: 'form-horizontal max-col-sm') do %>
+ <% if @error %>
+
+<% end %>
diff --git a/app/views/user_mailer/access_revoked.html.erb b/app/views/user_mailer/access_revoked.html.erb
new file mode 100644
index 0000000..4476dd8
--- /dev/null
+++ b/app/views/user_mailer/access_revoked.html.erb
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
GitHub access revoked!
+
+ Your <%= Rails.application.settings.company %> GitHub access has
+ been revoked due to the following <%= "error".pluralize(@github_user.failing_rules.count) %>:
+
+
+ <% @github_user.failing_rules.each do |rule| %>
+
<%= rule.error_msg %>
+ <% end %>
+
+
+
+ Please correct the <%= "error".pluralize(@github_user.failing_rules.count) %>
+ and reconnect your GitHub account using this link:
+ <%= link_to(connect_url, connect_url) %>
+
+
+
This message was sent from an automated mailbox. Please do not reply.
+
+
diff --git a/app/views/user_mailer/access_revoked.text.erb b/app/views/user_mailer/access_revoked.text.erb
new file mode 100644
index 0000000..43cb121
--- /dev/null
+++ b/app/views/user_mailer/access_revoked.text.erb
@@ -0,0 +1,17 @@
+GitHub access revoked!
+======================
+
+Your <%= Rails.application.settings.company %> GitHub access has been revoked
+due to the following <%= "error".pluralize(@github_user.failing_rules.count) %>:
+
+<% @github_user.failing_rules.each do |rule| -%>
+* <%= rule.error_msg %>
+<% end -%>
+
+Please correct the <%= "error".pluralize(@github_user.failing_rules.count) %> and reconnect your GitHub
+account using this link:
+<%= connect_url %>
+
+
+---
+This message was sent from an automated mailbox. Please do not reply.
diff --git a/app/views/users/_github_user.html.erb b/app/views/users/_github_user.html.erb
new file mode 100644
index 0000000..c5cceee
--- /dev/null
+++ b/app/views/users/_github_user.html.erb
@@ -0,0 +1,60 @@
+
+
+
+<% end %>
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb
new file mode 100644
index 0000000..c0443e4
--- /dev/null
+++ b/app/views/users/index.html.erb
@@ -0,0 +1,44 @@
+<% title "Users" %>
+<% nav_section :users %>
+<% jumbotron do %>
+
Users
+
Active Directory users who have logged in to the GitHub Connector
+<% end %>
+
+<%
+ # Users can have more than one GitHub user, which we want to display like:
+ #
+ # LDAP User | GitHub User | State
+ # -----------+---------------+---------
+ # User 1 | GH User 1a | Enabled
+ # | GH User 1b | Enabled
+ # User 2 | GH User 2 | Enabled
+ # User 3 | |
+%>
+
+
+<% if current_user.admin? %>
+ <%= link_to "Edit", edit_user_path(@user), class: 'btn btn-default' %>
+<% end %>
diff --git a/bin/bundle b/bin/bundle
new file mode 100755
index 0000000..66e9889
--- /dev/null
+++ b/bin/bundle
@@ -0,0 +1,3 @@
+#!/usr/bin/env ruby
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
+load Gem.bin_path('bundler', 'bundle')
diff --git a/bin/delayed_job b/bin/delayed_job
new file mode 100755
index 0000000..edf1959
--- /dev/null
+++ b/bin/delayed_job
@@ -0,0 +1,5 @@
+#!/usr/bin/env ruby
+
+require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment'))
+require 'delayed/command'
+Delayed::Command.new(ARGV).daemonize
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 0000000..728cd85
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+APP_PATH = File.expand_path('../../config/application', __FILE__)
+require_relative '../config/boot'
+require 'rails/commands'
diff --git a/bin/rake b/bin/rake
new file mode 100755
index 0000000..1724048
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+require_relative '../config/boot'
+require 'rake'
+Rake.application.run
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 0000000..acdb2c1
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,29 @@
+#!/usr/bin/env ruby
+require 'pathname'
+
+# path to your application root.
+APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
+
+Dir.chdir APP_ROOT do
+ # This script is a starting point to setup your application.
+ # Add necessary setup steps to this file:
+
+ puts "== Installing dependencies =="
+ system "gem install bundler --conservative"
+ system "bundle check || bundle install"
+
+ # puts "\n== Copying sample files =="
+ # unless File.exist?("config/database.yml")
+ # system "cp config/database.yml.sample config/database.yml"
+ # end
+
+ puts "\n== Preparing database =="
+ system "bin/rake db:setup"
+
+ puts "\n== Removing old logs and tempfiles =="
+ system "rm -f log/*"
+ system "rm -rf tmp/cache"
+
+ puts "\n== Restarting application server =="
+ system "touch tmp/restart.txt"
+end
diff --git a/bin/spring b/bin/spring
new file mode 100755
index 0000000..253ec37
--- /dev/null
+++ b/bin/spring
@@ -0,0 +1,18 @@
+#!/usr/bin/env ruby
+
+# This file loads spring without using Bundler, in order to be fast
+# It gets overwritten when you run the `spring binstub` command
+
+unless defined?(Spring)
+ require "rubygems"
+ require "bundler"
+
+ if match = Bundler.default_lockfile.read.match(/^GEM$.*?^ spring \((.*?)\)$.*?^$/m)
+ ENV["GEM_PATH"] = ([Bundler.bundle_path.to_s] + Gem.path).join(File::PATH_SEPARATOR)
+ ENV["GEM_HOME"] = ""
+ Gem.paths = ENV
+
+ gem "spring", match[1]
+ require "spring/binstub"
+ end
+end
diff --git a/config.ru b/config.ru
new file mode 100644
index 0000000..5bc2a61
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,4 @@
+# This file is used by Rack-based servers to start the application.
+
+require ::File.expand_path('../config/environment', __FILE__)
+run Rails.application
diff --git a/config/application.rb b/config/application.rb
new file mode 100644
index 0000000..5cf4dd8
--- /dev/null
+++ b/config/application.rb
@@ -0,0 +1,35 @@
+require File.expand_path('../boot', __FILE__)
+
+require 'rails/all'
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module GithubConnector
+ class Application < Rails::Application
+ # Settings in config/environments/* take precedence over those specified here.
+ # Application configuration should go into files in config/initializers
+ # -- all .rb files in that directory are automatically loaded.
+
+ # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
+ # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
+ # config.time_zone = 'Central Time (US & Canada)'
+
+ # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
+ # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
+ # config.i18n.default_locale = :de
+
+ # For not swallow errors in after_commit/after_rollback callbacks.
+ config.active_record.raise_in_transactional_callbacks = true
+
+ config.active_job.queue_adapter = :delayed_job
+
+ config.autoload_paths << Rails.root.join('lib')
+
+ def settings
+ require 'settings'
+ @settings ||= Settings.new
+ end
+ end
+end
diff --git a/config/boot.rb b/config/boot.rb
new file mode 100644
index 0000000..6b750f0
--- /dev/null
+++ b/config/boot.rb
@@ -0,0 +1,3 @@
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
+
+require 'bundler/setup' # Set up gems listed in the Gemfile.
diff --git a/config/database.yml.example b/config/database.yml.example
new file mode 100644
index 0000000..0e20e30
--- /dev/null
+++ b/config/database.yml.example
@@ -0,0 +1,15 @@
+development: &default
+ adapter: postgresql
+ database: github_connector
+ pool: 5
+ timeout: 5
+
+production:
+ <<: *default
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ database: github_connector_test
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100644
index 0000000..ee8d90d
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,5 @@
+# Load the Rails application.
+require File.expand_path('../application', __FILE__)
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/config/environments/development.rb b/config/environments/development.rb
new file mode 100644
index 0000000..f84b25f
--- /dev/null
+++ b/config/environments/development.rb
@@ -0,0 +1,41 @@
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # In the development environment your application's code is reloaded on
+ # every request. This slows down response time but is perfect for development
+ # since you don't have to restart the web server when you make code changes.
+ config.cache_classes = false
+
+ # Do not eager load code on boot.
+ config.eager_load = false
+
+ # Show full error reports and disable caching.
+ config.consider_all_requests_local = true
+ config.action_controller.perform_caching = false
+
+ # Don't care if the mailer can't send.
+ #config.action_mailer.raise_delivery_errors = false
+
+ # Print deprecation notices to the Rails logger.
+ config.active_support.deprecation = :log
+
+ # Raise an error on page load if there are pending migrations.
+ config.active_record.migration_error = :page_load
+
+ # Debug mode disables concatenation and preprocessing of assets.
+ # This option may cause significant delays in view rendering with a large
+ # number of complex assets.
+ config.assets.debug = true
+
+ # Asset digests allow you to set far-future HTTP expiration dates on all assets,
+ # yet still be able to expire them through the digest params.
+ config.assets.digest = true
+
+ # Adds additional error checking when serving assets at runtime.
+ # Checks for improperly declared sprockets dependencies.
+ # Raises helpful error messages.
+ config.assets.raise_runtime_errors = true
+
+ # Raises error for missing translations
+ # config.action_view.raise_on_missing_translations = true
+end
diff --git a/config/environments/production.rb b/config/environments/production.rb
new file mode 100644
index 0000000..d36b37f
--- /dev/null
+++ b/config/environments/production.rb
@@ -0,0 +1,76 @@
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ config.cache_classes = true
+
+ # Eager load code on boot. This eager loads most of Rails and
+ # your application in memory, allowing both threaded web servers
+ # and those relying on copy on write to perform better.
+ # Rake tasks automatically ignore this option for performance.
+ config.eager_load = true
+
+ # Full error reports are disabled and caching is turned on.
+ config.consider_all_requests_local = false
+ config.action_controller.perform_caching = true
+
+ # Enable Rack::Cache to put a simple HTTP cache in front of your application
+ # Add `rack-cache` to your Gemfile before enabling this.
+ # For large-scale production use, consider using a caching reverse proxy like NGINX, varnish or squid.
+ # config.action_dispatch.rack_cache = true
+
+ # Disable Rails's static asset server (Apache or NGINX will already do this).
+ config.serve_static_assets = false
+
+ # Compress JavaScripts and CSS.
+ config.assets.js_compressor = :uglifier
+ # config.assets.css_compressor = :sass
+
+ # Do not fallback to assets pipeline if a precompiled asset is missed.
+ config.assets.compile = false
+
+ # Asset digests allow you to set far-future HTTP expiration dates on all assets,
+ # yet still be able to expire them through the digest params.
+ config.assets.digest = true
+
+ # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
+
+ # Specifies the header that your server uses for sending files.
+ # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache
+ # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
+
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+ # config.force_ssl = true
+
+ # Set to :info to decrease the log volume.
+ config.log_level = :warn
+
+ # Prepend all log lines with the following tags.
+ # config.log_tags = [ :subdomain, :uuid ]
+
+ # Use a different logger for distributed setups.
+ # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
+
+ # Use a different cache store in production.
+ # config.cache_store = :mem_cache_store
+
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+ # config.action_controller.asset_host = "http://assets.example.com"
+
+ # Ignore bad email addresses and do not raise email delivery errors.
+ # Set this to true and configure the email server for immediate delivery to raise delivery errors.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Send deprecation notices to registered listeners.
+ config.active_support.deprecation = :notify
+
+ # Use default logging formatter so that PID and timestamp are not suppressed.
+ config.log_formatter = ::Logger::Formatter.new
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+end
diff --git a/config/environments/test.rb b/config/environments/test.rb
new file mode 100644
index 0000000..053f5b6
--- /dev/null
+++ b/config/environments/test.rb
@@ -0,0 +1,39 @@
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # The test environment is used exclusively to run your application's
+ # test suite. You never need to work with it otherwise. Remember that
+ # your test database is "scratch space" for the test suite and is wiped
+ # and recreated between test runs. Don't rely on the data there!
+ config.cache_classes = true
+
+ # Do not eager load code on boot. This avoids loading your whole application
+ # just for the purpose of running a single test. If you are using a tool that
+ # preloads Rails for running tests, you may have to set it to true.
+ config.eager_load = false
+
+ # Configure static asset server for tests with Cache-Control for performance.
+ config.serve_static_assets = true
+ config.static_cache_control = 'public, max-age=3600'
+
+ # Show full error reports and disable caching.
+ config.consider_all_requests_local = true
+ config.action_controller.perform_caching = false
+
+ # Raise exceptions instead of rendering exception templates.
+ config.action_dispatch.show_exceptions = false
+
+ # Disable request forgery protection in test environment.
+ config.action_controller.allow_forgery_protection = false
+
+ # Tell Action Mailer not to deliver emails to the real world.
+ # The :test delivery method accumulates sent emails in the
+ # ActionMailer::Base.deliveries array.
+ config.action_mailer.delivery_method = :test
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Raises error for missing translations
+ # config.action_view.raise_on_missing_translations = true
+end
diff --git a/config/initializers/action_mailer.rb b/config/initializers/action_mailer.rb
new file mode 100644
index 0000000..2396221
--- /dev/null
+++ b/config/initializers/action_mailer.rb
@@ -0,0 +1,13 @@
+module ActionMailer
+ class Base
+
+ # Read mailer configuration settings from the database every time
+ # we instantiate a new mailer.
+ def initialize_with_config(*args)
+ Rails.application.settings.apply_to_action_mailer
+ initialize_without_config(*args)
+ end
+ alias_method_chain :initialize, :config
+
+ end
+end
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
new file mode 100644
index 0000000..01ef3e6
--- /dev/null
+++ b/config/initializers/assets.rb
@@ -0,0 +1,11 @@
+# Be sure to restart your server when you modify this file.
+
+# Version of your assets, change this if you want to expire all your assets.
+Rails.application.config.assets.version = '1.0'
+
+# Add additional assets to the asset load path
+# Rails.application.config.assets.paths << Emoji.images_path
+
+# Precompile additional assets.
+# application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
+# Rails.application.config.assets.precompile += %w( search.js )
diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb
new file mode 100644
index 0000000..59385cd
--- /dev/null
+++ b/config/initializers/backtrace_silencers.rb
@@ -0,0 +1,7 @@
+# Be sure to restart your server when you modify this file.
+
+# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
+# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
+
+# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
+# Rails.backtrace_cleaner.remove_silencers!
diff --git a/config/initializers/cookies_serializer.rb b/config/initializers/cookies_serializer.rb
new file mode 100644
index 0000000..7f70458
--- /dev/null
+++ b/config/initializers/cookies_serializer.rb
@@ -0,0 +1,3 @@
+# Be sure to restart your server when you modify this file.
+
+Rails.application.config.action_dispatch.cookies_serializer = :json
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
new file mode 100644
index 0000000..3d6c5ca
--- /dev/null
+++ b/config/initializers/devise.rb
@@ -0,0 +1,267 @@
+# Use this hook to configure devise mailer, warden hooks and so forth.
+# Many of these configuration options can be set straight in your model.
+Devise.setup do |config|
+ # ==> LDAP Configuration
+ config.ldap_logger = true
+ config.ldap_create_user = true
+ config.ldap_update_password = false
+ config.ldap_check_attributes = false
+ config.ldap_use_admin_to_bind = true
+ config.ldap_ad_group_check = true
+ config.ldap_config = Proc.new() { Rails.application.settings.ldap_config }
+ # config.ldap_check_group_membership = false
+ #config.ldap_auth_username_builder = Proc.new() { |attribute, login, ldap| "#{attribute}=#{login}" }
+
+ # The secret key used by Devise. Devise uses this key to generate
+ # random tokens. Changing this key will render invalid all existing
+ # confirmation, reset password and unlock tokens in the database.
+ # config.secret_key = ''
+
+ # ==> Mailer Configuration
+ # Configure the e-mail address which will be shown in Devise::Mailer,
+ # note that it will be overwritten if you use your own mailer class
+ # with default "from" parameter.
+ config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com'
+
+ # Configure the class responsible to send e-mails.
+ # config.mailer = 'Devise::Mailer'
+
+ # ==> ORM configuration
+ # Load and configure the ORM. Supports :active_record (default) and
+ # :mongoid (bson_ext recommended) by default. Other ORMs may be
+ # available as additional gems.
+ require 'devise/orm/active_record'
+
+ # ==> Configuration for any authentication mechanism
+ # Configure which keys are used when authenticating a user. The default is
+ # just :email. You can configure it to use [:username, :subdomain], so for
+ # authenticating a user, both parameters are required. Remember that those
+ # parameters are used only when authenticating and not when retrieving from
+ # session. If you need permissions, you should implement that in a before filter.
+ # You can also supply a hash where the value is a boolean determining whether
+ # or not authentication should be aborted when the value is not present.
+ config.authentication_keys = [ :username ]
+
+ # Configure parameters from the request object used for authentication. Each entry
+ # given should be a request method and it will automatically be passed to the
+ # find_for_authentication method and considered in your model lookup. For instance,
+ # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
+ # The same considerations mentioned for authentication_keys also apply to request_keys.
+ # config.request_keys = []
+
+ # Configure which authentication keys should be case-insensitive.
+ # These keys will be downcased upon creating or modifying a user and when used
+ # to authenticate or find a user. Default is :email.
+ config.case_insensitive_keys = [ :username ]
+
+ # Configure which authentication keys should have whitespace stripped.
+ # These keys will have whitespace before and after removed upon creating or
+ # modifying a user and when used to authenticate or find a user. Default is :email.
+ config.strip_whitespace_keys = [ :username ]
+
+ # Tell if authentication through request.params is enabled. True by default.
+ # It can be set to an array that will enable params authentication only for the
+ # given strategies, for example, `config.params_authenticatable = [:database]` will
+ # enable it only for database (email + password) authentication.
+ # config.params_authenticatable = true
+
+ # Tell if authentication through HTTP Auth is enabled. False by default.
+ # It can be set to an array that will enable http authentication only for the
+ # given strategies, for example, `config.http_authenticatable = [:database]` will
+ # enable it only for database authentication. The supported strategies are:
+ # :database = Support basic authentication with authentication key + password
+ # config.http_authenticatable = false
+
+ # If http headers should be returned for AJAX requests. True by default.
+ # config.http_authenticatable_on_xhr = true
+
+ # The realm used in Http Basic Authentication. 'Application' by default.
+ # config.http_authentication_realm = 'Application'
+
+ # It will change confirmation, password recovery and other workflows
+ # to behave the same regardless if the e-mail provided was right or wrong.
+ # Does not affect registerable.
+ # config.paranoid = true
+
+ # By default Devise will store the user in session. You can skip storage for
+ # particular strategies by setting this option.
+ # Notice that if you are skipping storage for all authentication paths, you
+ # may want to disable generating routes to Devise's sessions controller by
+ # passing skip: :sessions to `devise_for` in your config/routes.rb
+ config.skip_session_storage = [:http_auth]
+
+ # By default, Devise cleans up the CSRF token on authentication to
+ # avoid CSRF token fixation attacks. This means that, when using AJAX
+ # requests for sign in and sign up, you need to get a new CSRF token
+ # from the server. You can disable this option at your own risk.
+ # config.clean_up_csrf_token_on_authentication = true
+
+ # ==> Configuration for :database_authenticatable
+ # For bcrypt, this is the cost for hashing the password and defaults to 10. If
+ # using other encryptors, it sets how many times you want the password re-encrypted.
+ #
+ # Limiting the stretches to just one in testing will increase the performance of
+ # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use
+ # a value less than 10 in other environments. Note that, for bcrypt (the default
+ # encryptor), the cost increases exponentially with the number of stretches (e.g.
+ # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation).
+ config.stretches = Rails.env.test? ? 1 : 10
+
+ # Setup a pepper to generate the encrypted password.
+ # config.pepper = ''
+
+ # ==> Configuration for :confirmable
+ # A period that the user is allowed to access the website even without
+ # confirming their account. For instance, if set to 2.days, the user will be
+ # able to access the website for two days without confirming their account,
+ # access will be blocked just in the third day. Default is 0.days, meaning
+ # the user cannot access the website without confirming their account.
+ # config.allow_unconfirmed_access_for = 2.days
+
+ # A period that the user is allowed to confirm their account before their
+ # token becomes invalid. For example, if set to 3.days, the user can confirm
+ # their account within 3 days after the mail was sent, but on the fourth day
+ # their account can't be confirmed with the token any more.
+ # Default is nil, meaning there is no restriction on how long a user can take
+ # before confirming their account.
+ # config.confirm_within = 3.days
+
+ # If true, requires any email changes to be confirmed (exactly the same way as
+ # initial account confirmation) to be applied. Requires additional unconfirmed_email
+ # db field (see migrations). Until confirmed, new email is stored in
+ # unconfirmed_email column, and copied to email column on successful confirmation.
+ config.reconfirmable = true
+
+ # Defines which key will be used when confirming an account
+ # config.confirmation_keys = [ :email ]
+
+ # ==> Configuration for :rememberable
+ # The time the user will be remembered without asking for credentials again.
+ # config.remember_for = 2.weeks
+
+ # If true, extends the user's remember period when remembered via cookie.
+ # config.extend_remember_period = false
+
+ # Options to be passed to the created cookie. For instance, you can set
+ # secure: true in order to force SSL only cookies.
+ # config.rememberable_options = {}
+
+ # ==> Configuration for :validatable
+ # Range for password length.
+ config.password_length = 8..128
+
+ # Email regex used to validate email formats. It simply asserts that
+ # one (and only one) @ exists in the given string. This is mainly
+ # to give user feedback and not to assert the e-mail validity.
+ # config.email_regexp = /\A[^@]+@[^@]+\z/
+
+ # ==> Configuration for :timeoutable
+ # The time you want to timeout the user session without activity. After this
+ # time the user will be asked for credentials again. Default is 30 minutes.
+ # config.timeout_in = 30.minutes
+
+ # If true, expires auth token on session timeout.
+ # config.expire_auth_token_on_timeout = false
+
+ # ==> Configuration for :lockable
+ # Defines which strategy will be used to lock an account.
+ # :failed_attempts = Locks an account after a number of failed attempts to sign in.
+ # :none = No lock strategy. You should handle locking by yourself.
+ # config.lock_strategy = :failed_attempts
+
+ # Defines which key will be used when locking and unlocking an account
+ # config.unlock_keys = [ :email ]
+
+ # Defines which strategy will be used to unlock an account.
+ # :email = Sends an unlock link to the user email
+ # :time = Re-enables login after a certain amount of time (see :unlock_in below)
+ # :both = Enables both strategies
+ # :none = No unlock strategy. You should handle unlocking by yourself.
+ # config.unlock_strategy = :both
+
+ # Number of authentication tries before locking an account if lock_strategy
+ # is failed attempts.
+ # config.maximum_attempts = 20
+
+ # Time interval to unlock the account if :time is enabled as unlock_strategy.
+ # config.unlock_in = 1.hour
+
+ # Warn on the last attempt before the account is locked.
+ # config.last_attempt_warning = false
+
+ # ==> Configuration for :recoverable
+ #
+ # Defines which key will be used when recovering the password for an account
+ # config.reset_password_keys = [ :email ]
+
+ # Time interval you can reset your password with a reset password key.
+ # Don't put a too small interval or your users won't have the time to
+ # change their passwords.
+ config.reset_password_within = 6.hours
+
+ # ==> Configuration for :encryptable
+ # Allow you to use another encryption algorithm besides bcrypt (default). You can use
+ # :sha1, :sha512 or encryptors from others authentication tools as :clearance_sha1,
+ # :authlogic_sha512 (then you should set stretches above to 20 for default behavior)
+ # and :restful_authentication_sha1 (then you should set stretches to 10, and copy
+ # REST_AUTH_SITE_KEY to pepper).
+ #
+ # Require the `devise-encryptable` gem when using anything other than bcrypt
+ # config.encryptor = :sha512
+
+ # ==> Scopes configuration
+ # Turn scoped views on. Before rendering "sessions/new", it will first check for
+ # "users/sessions/new". It's turned off by default because it's slower if you
+ # are using only default views.
+ # config.scoped_views = false
+
+ # Configure the default scope given to Warden. By default it's the first
+ # devise role declared in your routes (usually :user).
+ # config.default_scope = :user
+
+ # Set this configuration to false if you want /users/sign_out to sign out
+ # only the current scope. By default, Devise signs out all scopes.
+ # config.sign_out_all_scopes = true
+
+ # ==> Navigation configuration
+ # Lists the formats that should be treated as navigational. Formats like
+ # :html, should redirect to the sign in page when the user does not have
+ # access, but formats like :xml or :json, should return 401.
+ #
+ # If you have any extra navigational formats, like :iphone or :mobile, you
+ # should add them to the navigational formats lists.
+ #
+ # The "*/*" below is required to match Internet Explorer requests.
+ # config.navigational_formats = ['*/*', :html]
+
+ # The default HTTP method used to sign out a resource. Default is :delete.
+ config.sign_out_via = :delete
+
+ # ==> OmniAuth
+ # Add a new OmniAuth provider. Check the wiki for more information on setting
+ # up on your models and hooks.
+ # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
+
+ # ==> Warden configuration
+ # If you want to use other strategies, that are not supported by Devise, or
+ # change the failure app, you can configure them inside the config.warden block.
+ #
+ # config.warden do |manager|
+ # manager.intercept_401 = false
+ # manager.default_strategies(scope: :user).unshift :some_external_strategy
+ # end
+
+ # ==> Mountable engine configurations
+ # When using Devise inside an engine, let's call it `MyEngine`, and this engine
+ # is mountable, there are some extra configurations to be taken into account.
+ # The following options are available, assuming the engine is mounted as:
+ #
+ # mount MyEngine, at: '/my_engine'
+ #
+ # The router that invoked `devise_for`, in the example above, would be:
+ # config.router_name = :my_engine
+ #
+ # When using omniauth, Devise cannot automatically set Omniauth path,
+ # so you need to do it manually. For the users scope, it would be:
+ # config.omniauth_path_prefix = '/my_engine/users/auth'
+end
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000..4a994e1
--- /dev/null
+++ b/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,4 @@
+# Be sure to restart your server when you modify this file.
+
+# Configure sensitive parameters which will be filtered from the log file.
+Rails.application.config.filter_parameters += [:password]
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
new file mode 100644
index 0000000..ac033bf
--- /dev/null
+++ b/config/initializers/inflections.rb
@@ -0,0 +1,16 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.plural /^(ox)$/i, '\1en'
+# inflect.singular /^(ox)en/i, '\1'
+# inflect.irregular 'person', 'people'
+# inflect.uncountable %w( fish sheep )
+# end
+
+# These inflection rules are supported but not enabled by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.acronym 'RESTful'
+# end
diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb
new file mode 100644
index 0000000..dc18996
--- /dev/null
+++ b/config/initializers/mime_types.rb
@@ -0,0 +1,4 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new mime types for use in respond_to blocks:
+# Mime::Type.register "text/richtext", :rtf
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
new file mode 100644
index 0000000..10af09d
--- /dev/null
+++ b/config/initializers/session_store.rb
@@ -0,0 +1,3 @@
+# Be sure to restart your server when you modify this file.
+
+Rails.application.config.session_store :cookie_store, key: '_github_connector_session'
diff --git a/config/initializers/state_machine_patch.rb b/config/initializers/state_machine_patch.rb
new file mode 100644
index 0000000..2d1b701
--- /dev/null
+++ b/config/initializers/state_machine_patch.rb
@@ -0,0 +1,26 @@
+# The state_machine gem doesn't support Rails 4.1 out of the box.
+# This patches stuff to work.
+#
+# See: https://github.com/pluginaweek/state_machine/issues/251
+module StateMachine
+ module Integrations
+ module ActiveModel
+ public :around_validation
+ end
+
+ module ActiveRecord
+ public :around_save
+ end
+ end
+end
+module StateMachine
+ module Integrations
+ module ActiveModel
+ public :around_validation
+ end
+
+ module ActiveRecord
+ public :around_save
+ end
+ end
+end
diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb
new file mode 100644
index 0000000..33725e9
--- /dev/null
+++ b/config/initializers/wrap_parameters.rb
@@ -0,0 +1,14 @@
+# Be sure to restart your server when you modify this file.
+
+# This file contains settings for ActionController::ParamsWrapper which
+# is enabled by default.
+
+# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
+ActiveSupport.on_load(:action_controller) do
+ wrap_parameters format: [:json] if respond_to?(:wrap_parameters)
+end
+
+# To enable root element in JSON for ActiveRecord objects.
+# ActiveSupport.on_load(:active_record) do
+# self.include_root_in_json = true
+# end
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
new file mode 100644
index 0000000..b7ebc47
--- /dev/null
+++ b/config/locales/devise.en.yml
@@ -0,0 +1,59 @@
+# Additional translations at https://github.com/plataformatec/devise/wiki/I18n
+
+en:
+ devise:
+ confirmations:
+ confirmed: "Your account was successfully confirmed."
+ send_instructions: "You will receive an email with instructions about how to confirm your account in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions about how to confirm your account in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid email or password."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account will be locked."
+ not_found_in_database: "Invalid email or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: ""
+ unconfirmed: "You have to confirm your account before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock Instructions"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password was changed successfully. You are now signed in."
+ updated_not_active: "Your password was changed successfully."
+ registrations:
+ destroyed: "Bye! Your account was successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please open the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and click on the confirm link to finalize confirming your new email address."
+ updated: "You updated your account successfully."
+ sessions:
+ signed_in: ""
+ signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions about how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions about how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/en.yml b/config/locales/en.yml
new file mode 100644
index 0000000..0653957
--- /dev/null
+++ b/config/locales/en.yml
@@ -0,0 +1,23 @@
+# Files in the config/locales directory are used for internationalization
+# and are automatically loaded by Rails. If you want to use locales other
+# than English, add the necessary files in this directory.
+#
+# To use the locales, use `I18n.t`:
+#
+# I18n.t 'hello'
+#
+# In views, this is aliased to just `t`:
+#
+# <%= t('hello') %>
+#
+# To use a different locale, set it with `I18n.locale`:
+#
+# I18n.locale = :es
+#
+# This would use the information in config/locales/es.yml.
+#
+# To learn more, please read the Rails Internationalization guide
+# available at http://guides.rubyonrails.org/i18n.html.
+
+en:
+ hello: "Hello world"
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 0000000..c6000dd
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,43 @@
+Rails.application.routes.draw do
+ get 'settings', to: 'settings#edit'
+ put 'settings', to: 'settings#update'
+ get 'settings/github_admin', to: 'settings#github_admin'
+ get 'settings/github_auth_code', to: 'settings#github_auth_code'
+
+ get 'setup', to: redirect('setup/company')
+ namespace :setup do
+ # Step 1
+ get 'company', to: 'company#edit'
+ put 'company', to: 'company#update'
+ # Step 2
+ get 'ldap', to: 'ldap#edit'
+ put 'ldap', to: 'ldap#update'
+ # Step 3
+ devise_scope :user do
+ get 'admin', to: 'admin_user#new'
+ post 'admin', to: 'admin_user#create'
+ end
+ # Step 4
+ get 'github', to: 'github#edit'
+ get 'github_auth_code', to: 'github#github_auth_code'
+ put 'github', to: 'github#update'
+ # Step 5
+ get 'email', to: 'email#edit'
+ put 'email', to: 'email#update'
+ # Step 6
+ get 'rules', to: 'rules#edit'
+ put 'rules', to: 'rules#update'
+ end
+
+ get 'connect', to: 'connect#index'
+ get 'connect/start', to: 'connect#start'
+ get 'connect/auth_code', to: 'connect#auth_code'
+ get 'connect/:id', to: 'connect#status', as: 'connect_status', constraints: { id: /\d+/ }
+
+ devise_for :users
+
+ resources :users, only: [:index, :show, :edit, :update]
+ resources :github_users, only: [:index, :show]
+
+ root 'dashboard#index'
+end
diff --git a/config/secrets.yml.example b/config/secrets.yml.example
new file mode 100644
index 0000000..59ea592
--- /dev/null
+++ b/config/secrets.yml.example
@@ -0,0 +1,23 @@
+# Be sure to restart your server when you modify this file.
+#
+# Make sure the secret is at least 30 characters and all random,
+# no regular words or you'll be exposed to dictionary attacks.
+# You can use `rake secret` to generate a secure secret key.
+#
+# Make sure the secrets in this file are kept private
+# if you're sharing your code publicly.
+
+development: &default
+ # Your secret key is used for verifying the integrity of signed cookies.
+ # If you change this key, all old signed cookies will become invalid!
+ secret_key_base: GENERATE A SECRET WITH rake secret AND PASTE IT HERE
+
+ # The secret key used for encrypting sensitive database info.
+ # If you change this key, all user OAuth tokens will become unreadable!
+ database_key: GENERATE A SECRET WITH rake secret AND PASTE IT HERE
+
+production:
+ <<: *default
+
+test:
+ <<: *default
diff --git a/cookbook/.kitchen.yml b/cookbook/.kitchen.yml
new file mode 100644
index 0000000..db3830e
--- /dev/null
+++ b/cookbook/.kitchen.yml
@@ -0,0 +1,38 @@
+---
+driver:
+ name: vagrant
+ customize:
+ memory: 1024
+ # Use the host's DNS. This ensures everything resolves correctly
+ # inside the guest when the host is connected to VPN.
+ natdnshostresolver1: 'on'
+
+provisioner:
+ name: chef_solo
+
+platforms:
+ - name: ubuntu-14.04
+
+suites:
+ - name: default
+ driver:
+ network:
+ - [forwarded_port, {guest: 8080, host: 8080}]
+ - [forwarded_port, {guest: 8443, host: 8443}]
+ data_bags_path: test_data_bags
+ run_list:
+ - recipe[github_connector::default]
+ attributes:
+ authorization:
+ sudo:
+ users: [vagrant]
+ agent_forwarding: true
+ postgresql:
+ password:
+ postgres: insecurepassword
+ github_connector:
+ http:
+ port: 8080
+ ssl:
+ port: 8443
+ enabled: true
diff --git a/cookbook/Berksfile b/cookbook/Berksfile
new file mode 100644
index 0000000..5602c69
--- /dev/null
+++ b/cookbook/Berksfile
@@ -0,0 +1,10 @@
+source "https://supermarket.getchef.com"
+
+# Can switch to upstream once these land:
+# https://github.com/fnichol/chef-rvm/pull/186
+# https://github.com/fnichol/chef-rvm/pull/212
+# https://github.com/fnichol/chef-rvm/pull/247
+cookbook 'rvm', '>= 0.9.0', github: 'rapid7-cookbooks/rvm', branch: 'patches_0.9.0'
+
+metadata
+
diff --git a/cookbook/Berksfile.lock b/cookbook/Berksfile.lock
new file mode 100644
index 0000000..5285704
--- /dev/null
+++ b/cookbook/Berksfile.lock
@@ -0,0 +1,67 @@
+DEPENDENCIES
+ github_connector
+ path: .
+ metadata: true
+ rvm
+ git: git://github.com/rapid7-cookbooks/rvm.git
+ revision: a797c8713568eaad67cebb6bc95bb7cf684e8988
+ branch: patches_0.9.0
+
+GRAPH
+ apt (2.6.0)
+ aws (2.4.0)
+ bluepill (2.3.1)
+ rsyslog (>= 0.0.0)
+ build-essential (2.0.6)
+ chef-sugar (2.3.0)
+ chef_gem (0.1.0)
+ database (2.3.0)
+ aws (>= 0.0.0)
+ mysql (>= 5.0.0)
+ mysql-chef_gem (>= 0.0.0)
+ postgresql (>= 1.0.0)
+ xfs (>= 0.0.0)
+ github_connector (0.0.1)
+ apt (>= 2.3.10)
+ database (>= 2.0.0)
+ nginx (>= 2.0.0)
+ postgresql (>= 3.4.0)
+ rvm (= 0.9.0)
+ ssh_known_hosts (>= 0.0.0)
+ java (1.28.0)
+ mysql (5.5.3)
+ yum-mysql-community (>= 0.0.0)
+ mysql-chef_gem (0.0.5)
+ build-essential (>= 0.0.0)
+ mysql (>= 0.0.0)
+ nginx (2.7.4)
+ apt (~> 2.2)
+ bluepill (~> 2.3)
+ build-essential (~> 2.0)
+ ohai (~> 2.0)
+ runit (~> 1.2)
+ yum-epel (~> 0.3)
+ ohai (2.0.1)
+ openssl (2.0.0)
+ chef-sugar (>= 0.0.0)
+ partial_search (1.0.8)
+ postgresql (3.4.6)
+ apt (>= 1.9.0)
+ build-essential (>= 0.0.0)
+ openssl (>= 0.0.0)
+ rsyslog (1.12.2)
+ runit (1.5.10)
+ build-essential (>= 0.0.0)
+ yum (~> 3.0)
+ yum-epel (>= 0.0.0)
+ rvm (0.9.0)
+ chef_gem (>= 0.0.0)
+ java (>= 0.0.0)
+ ssh_known_hosts (1.3.2)
+ partial_search (>= 0.0.0)
+ xfs (1.1.0)
+ yum (3.3.2)
+ yum-epel (0.5.1)
+ yum (~> 3.0)
+ yum-mysql-community (0.1.10)
+ yum (>= 3.0)
diff --git a/cookbook/Gemfile b/cookbook/Gemfile
new file mode 100644
index 0000000..7e74f0a
--- /dev/null
+++ b/cookbook/Gemfile
@@ -0,0 +1,5 @@
+source 'https://rubygems.org'
+
+gem 'berkshelf'
+gem 'kitchen-vagrant'
+gem 'test-kitchen'
diff --git a/cookbook/Gemfile.lock b/cookbook/Gemfile.lock
new file mode 100644
index 0000000..ab0a6ef
--- /dev/null
+++ b/cookbook/Gemfile.lock
@@ -0,0 +1,106 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ addressable (2.3.6)
+ berkshelf (3.1.5)
+ addressable (~> 2.3.4)
+ berkshelf-api-client (~> 1.2)
+ buff-config (~> 1.0)
+ buff-extensions (~> 1.0)
+ buff-shell_out (~> 0.1)
+ celluloid (~> 0.16.0.pre)
+ celluloid-io (~> 0.16.0.pre)
+ faraday (~> 0.9.0)
+ minitar (~> 0.5.4)
+ octokit (~> 3.0)
+ retryable (~> 1.3.3)
+ ridley (~> 4.0)
+ solve (~> 1.1)
+ thor (~> 0.18)
+ berkshelf-api-client (1.2.0)
+ faraday (~> 0.9.0)
+ buff-config (1.0.1)
+ buff-extensions (~> 1.0)
+ varia_model (~> 0.4)
+ buff-extensions (1.0.0)
+ buff-ignore (1.1.1)
+ buff-ruby_engine (0.1.0)
+ buff-shell_out (0.2.0)
+ buff-ruby_engine (~> 0.1.0)
+ celluloid (0.16.0)
+ timers (~> 4.0.0)
+ celluloid-io (0.16.0)
+ celluloid (>= 0.16.0)
+ nio4r (>= 1.0.0)
+ dep-selector-libgecode (1.0.2)
+ dep_selector (1.0.3)
+ dep-selector-libgecode (~> 1.0)
+ ffi (~> 1.9)
+ erubis (2.7.0)
+ faraday (0.9.0)
+ multipart-post (>= 1.2, < 3)
+ ffi (1.9.5)
+ hashie (2.1.2)
+ hitimes (1.2.2)
+ json (1.8.1)
+ kitchen-vagrant (0.15.0)
+ test-kitchen (~> 1.0)
+ minitar (0.5.4)
+ mixlib-authentication (1.3.0)
+ mixlib-log
+ mixlib-log (1.6.0)
+ mixlib-shellout (1.4.0)
+ multipart-post (2.0.0)
+ net-http-persistent (2.9.4)
+ net-scp (1.2.0)
+ net-ssh (>= 2.6.5)
+ net-ssh (2.8.0)
+ nio4r (1.0.1)
+ octokit (3.4.0)
+ sawyer (~> 0.5.3)
+ retryable (1.3.6)
+ ridley (4.0.0)
+ addressable
+ buff-config (~> 1.0)
+ buff-extensions (~> 1.0)
+ buff-ignore (~> 1.1)
+ buff-shell_out (~> 0.1)
+ celluloid (~> 0.16.0.pre)
+ celluloid-io (~> 0.16.0.pre)
+ erubis
+ faraday (~> 0.9.0)
+ hashie (>= 2.0.2, < 3.0.0)
+ json (>= 1.7.7)
+ mixlib-authentication (>= 1.3.0)
+ net-http-persistent (>= 2.8)
+ retryable
+ semverse (~> 1.1)
+ varia_model (~> 0.4)
+ safe_yaml (1.0.2)
+ sawyer (0.5.5)
+ addressable (~> 2.3.5)
+ faraday (~> 0.8, < 0.10)
+ semverse (1.2.1)
+ solve (1.2.1)
+ dep_selector (~> 1.0)
+ semverse (~> 1.1)
+ test-kitchen (1.2.1)
+ mixlib-shellout (~> 1.2)
+ net-scp (~> 1.1)
+ net-ssh (~> 2.7)
+ safe_yaml (~> 1.0)
+ thor (~> 0.18)
+ thor (0.19.1)
+ timers (4.0.1)
+ hitimes
+ varia_model (0.4.0)
+ buff-extensions (~> 1.0)
+ hashie (>= 2.0.2, < 3.0.0)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ berkshelf
+ kitchen-vagrant
+ test-kitchen
diff --git a/cookbook/README.md b/cookbook/README.md
new file mode 100644
index 0000000..f730456
--- /dev/null
+++ b/cookbook/README.md
@@ -0,0 +1,13 @@
+GitHub Active Directory Connector Cookbook
+==========================================
+
+Installs and configures the GitHub Active Directory Connector via Chef.
+
+This performs the following actions:
+
+1. Creates a `github` user
+2. Installs PostgreSQL and creates a database
+3. Installs RVM, installs ruby, and configures a `github-connector` gemset
+4. Clones the `github-connector` repository from GitHub
+5. Creates upstart jobs for the web and worker processes
+6. Creates a cron job to synchronize users
diff --git a/cookbook/attributes/database.rb b/cookbook/attributes/database.rb
new file mode 100644
index 0000000..cc5448e
--- /dev/null
+++ b/cookbook/attributes/database.rb
@@ -0,0 +1,4 @@
+default['github_connector']['db']['host'] = 'localhost'
+default['github_connector']['db']['port'] = node['postgresql']['config']['port']
+default['github_connector']['db']['name'] = 'github-connector'
+default['github_connector']['db']['user'] = 'github-connector'
diff --git a/cookbook/attributes/default.rb b/cookbook/attributes/default.rb
new file mode 100644
index 0000000..74f99c0
--- /dev/null
+++ b/cookbook/attributes/default.rb
@@ -0,0 +1,14 @@
+default['github_connector']['user'] = 'github'
+default['github_connector']['group'] = node['github_connector']['user']
+default['github_connector']['install_dir'] = '/var/www/github-connector'
+
+default['github_connector']['repo']['url'] = 'git://github.com/rapid7/github-connector.git'
+default['github_connector']['repo']['revision'] = 'v0.1.0'
+
+# The secrets databag can contain the following keys:
+# * database_password
+# * database_key
+# * secrets_key_base
+default['github_connector']['secrets_databag'] = 'github_connector'
+default['github_connector']['secrets_databag_item'] = 'secrets'
+default['github_connector']['secrets'] = {}
diff --git a/cookbook/attributes/nginx.rb b/cookbook/attributes/nginx.rb
new file mode 100644
index 0000000..cf98ca2
--- /dev/null
+++ b/cookbook/attributes/nginx.rb
@@ -0,0 +1,11 @@
+default['nginx']['default_site_enabled'] = false
+
+default['github_connector']['http']['host_name'] = node['fqdn']
+default['github_connector']['http']['host_aliases'] = []
+default['github_connector']['http']['port'] = 80
+default['github_connector']['http']['ssl']['port'] = 443
+default['github_connector']['http']['ssl']['enabled'] = true
+
+# The cert databag should have `cert` and `key` keys
+default['github_connector']['http']['ssl']['cert_databag'] = 'github_connector'
+default['github_connector']['http']['ssl']['cert_databag_item'] = 'ssl_cert'
diff --git a/cookbook/attributes/ruby.rb b/cookbook/attributes/ruby.rb
new file mode 100644
index 0000000..9033d9a
--- /dev/null
+++ b/cookbook/attributes/ruby.rb
@@ -0,0 +1,8 @@
+default['github_connector']['ruby_version'] = 'ruby-2.1.4'
+default['github_connector']['ruby_gemset'] = 'github-connector'
+default['github_connector']['rvm_alias'] = 'github-connector'
+
+default['rvm']['version'] = '1.26.0'
+default['rvm']['user_rubies'] = [node['github_connector']['ruby_version']]
+default['rvm']['user_default_ruby'] = node['github_connector']['ruby_version']
+default['rvm']['user_autolibs'] = 'read-fail'
diff --git a/cookbook/attributes/ssh.rb b/cookbook/attributes/ssh.rb
new file mode 100644
index 0000000..2da4946
--- /dev/null
+++ b/cookbook/attributes/ssh.rb
@@ -0,0 +1,7 @@
+# To pull from GitHub via ssh, add a data bag with a "private_key" attribute
+# containing an SSH private key. Then list the data bag info here:
+default['github_connector']['ssh_databag'] = nil
+default['github_connector']['ssh_databag_item'] = nil
+
+
+default['github_connector']['github_host_key'] = 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ=='
diff --git a/cookbook/libraries/github_connector_helpers.rb b/cookbook/libraries/github_connector_helpers.rb
new file mode 100644
index 0000000..f48f46a
--- /dev/null
+++ b/cookbook/libraries/github_connector_helpers.rb
@@ -0,0 +1,55 @@
+module GithubConnector
+ class Helpers
+ class << self
+ include Opscode::OpenSSL::Password
+
+ # Loads the given data bag. The databag can be encrypted or unencrypted.
+ def load_data_bag(data_bag, name)
+ raw_hash = Chef::DataBagItem.load(data_bag, name)
+ encrypted = raw_hash.detect do |key, value|
+ if value.is_a?(Hash)
+ value.has_key?("encrypted_data")
+ end
+ end
+ if encrypted
+ secret = Chef::EncryptedDataBagItem.load_secret
+ Chef::EncryptedDataBagItem.new(raw_hash, secret)
+ else
+ raw_hash
+ end
+ end
+
+ def database_password(node)
+ secret('database_password', secure_password, node)
+ end
+
+ def database_key(node)
+ secret('database_key', SecureRandom.hex(64), node)
+ end
+
+ def secret_key_base(node)
+ secret('secret_key_base', SecureRandom.hex(64), node)
+ end
+
+ def secret(key, default, node)
+ data_bag = GithubConnector::Helpers.load_data_bag(
+ node['github_connector']['secrets_databag'],
+ node['github_connector']['secrets_databag_item']
+ ) rescue nil
+
+ if data_bag && data_bag[key]
+ return data_bag[key]
+ end
+
+ unless Chef::Config[:solo]
+ node.set_unless['github_connector']['secrets'][key] = default
+ node.save
+ end
+
+ raise "Must set github_connector.secrets.#{key}!" unless node['github_connector']['secrets'][key]
+
+ node['github_connector']['secrets'][key]
+ end
+ end
+ end
+end
diff --git a/cookbook/metadata.rb b/cookbook/metadata.rb
new file mode 100644
index 0000000..e93a75c
--- /dev/null
+++ b/cookbook/metadata.rb
@@ -0,0 +1,19 @@
+name 'github_connector'
+maintainer "Rapid7, Inc."
+maintainer_email "engineeringservices@rapid7.com"
+license "All rights reserved"
+description "Installs and configures the GitHub Active Directory Connector"
+long_description IO.read(File.join(File.dirname(__FILE__), 'README.md'))
+version "0.0.1"
+
+supports 'ubuntu'
+
+depends 'apt', '>= 2.3.10'
+depends 'database', '>= 2.0'
+depends 'logrotate', '>= 1.7.0'
+depends 'nginx', '>= 2.0'
+depends 'postgresql', '>= 3.4.0'
+depends 'ssh_known_hosts'
+
+# rvm is a rapid7 patched version, see Berksfile
+depends 'rvm', '= 0.9.0'
diff --git a/cookbook/recipes/cron.rb b/cookbook/recipes/cron.rb
new file mode 100644
index 0000000..5e6f4e5
--- /dev/null
+++ b/cookbook/recipes/cron.rb
@@ -0,0 +1,26 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: cron
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+cron 'github-connector-cron' do
+ user node['github_connector']['user']
+ minute 56
+ hour '*/4'
+ home "/home/#{node['github_connector']['user']}"
+ command "cd \"#{node['github_connector']['install_dir']}\" && /home/#{node['github_connector']['user']}/.rvm/bin/rvm #{node['github_connector']['rvm_alias']} do rake github:transition_users RAILS_ENV=production >> \"#{node['github_connector']['install_dir']}/log/cron.log\""
+end
diff --git a/cookbook/recipes/database.rb b/cookbook/recipes/database.rb
new file mode 100644
index 0000000..83d7bbf
--- /dev/null
+++ b/cookbook/recipes/database.rb
@@ -0,0 +1,43 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: database
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+include_recipe 'postgresql::server'
+include_recipe 'database::postgresql'
+
+
+postgresql_connection_info = {
+ :host => "localhost",
+ :port => node['postgresql']['config']['port'],
+ :username => 'postgres',
+ :password => node['postgresql']['password']['postgres']
+}
+
+# Create database user
+postgresql_database_user 'github-connector-database-user' do
+ connection postgresql_connection_info
+ username node['github_connector']['db']['user']
+ password GithubConnector::Helpers.database_password(node)
+end
+
+# Create database
+postgresql_database 'github-connector-database' do
+ connection postgresql_connection_info
+ database_name node['github_connector']['db']['name']
+ owner node['github_connector']['db']['user']
+end
diff --git a/cookbook/recipes/default.rb b/cookbook/recipes/default.rb
new file mode 100644
index 0000000..bb5ac71
--- /dev/null
+++ b/cookbook/recipes/default.rb
@@ -0,0 +1,28 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: default
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+include_recipe 'apt'
+
+package 'git'
+
+include_recipe 'github_connector::user'
+include_recipe 'github_connector::ssh'
+include_recipe 'github_connector::database'
+include_recipe 'github_connector::ruby'
+include_recipe 'github_connector::server'
diff --git a/cookbook/recipes/nginx.rb b/cookbook/recipes/nginx.rb
new file mode 100644
index 0000000..d553ffd
--- /dev/null
+++ b/cookbook/recipes/nginx.rb
@@ -0,0 +1,65 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: nginx
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+include_recipe 'nginx'
+
+if node['github_connector']['http']['ssl']['enabled']
+ ssl_data_bag = GithubConnector::Helpers.load_data_bag(
+ node['github_connector']['http']['ssl']['cert_databag'],
+ node['github_connector']['http']['ssl']['cert_databag_item']
+ )
+
+ # Public key.
+ file "/etc/ssl/certs/#{node['github_connector']['http']['host_name']}.crt" do
+ mode 0644
+ user 'root'
+ group 'root'
+ content "#{ssl_data_bag['cert']}"
+ notifies :reload, 'service[nginx]', :delayed
+ end
+
+ # Private key.
+ file "/etc/ssl/private/#{node['github_connector']['http']['host_name']}.key" do
+ mode 0600
+ user 'root'
+ group 'root'
+ content "#{ssl_data_bag['key']}"
+ notifies :reload, 'service[nginx]', :delayed
+ end
+end
+
+template ::File.join(node['nginx']['dir'], 'sites-available', 'github_connector') do
+ source 'nginx-github-connector.conf.erb'
+ notifies :reload, 'service[nginx]', :delayed
+ mode 0644
+ owner 'root'
+ group 'root'
+ action :create
+ variables(
+ :host_name => node['github_connector']['http']['host_name'],
+ :host_aliases => node['github_connector']['http']['host_aliases'] || [],
+ :ssl_enabled => node['github_connector']['http']['ssl']['enabled'],
+ :redirect_http => node['github_connector']['http']['ssl']['enabled'],
+ :listen_port => node['github_connector']['http']['port'],
+ :ssl_listen_port => node['github_connector']['http']['ssl']['port'],
+ :install_dir => node['github_connector']['install_dir']
+ )
+end
+
+nginx_site 'github_connector'
diff --git a/cookbook/recipes/ruby.rb b/cookbook/recipes/ruby.rb
new file mode 100644
index 0000000..f104e60
--- /dev/null
+++ b/cookbook/recipes/ruby.rb
@@ -0,0 +1,51 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: ruby
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# gawk is needed to install ruby 2.1.x but is not installed by RVM
+package 'gawk'
+
+node.default['rvm']['user_installs'] = [{
+ user: node['github_connector']['user'],
+ home: "/home/#{node['github_connector']['user']}",
+ upgrade: node['rvm']['version']
+}]
+include_recipe 'rvm::user'
+
+rvm_gemset node['github_connector']['ruby_gemset'] do
+ user node['github_connector']['user']
+ ruby_string node['github_connector']['ruby_version']
+end
+
+# Create an alias that remains consistent across version/gemset changes
+execute 'github-connector-alias' do
+ rvm_cmd = "/home/#{node['github_connector']['user']}/.rvm/bin/rvm"
+ rvm_alias = node['github_connector']['rvm_alias']
+ ruby_string = "#{node['github_connector']['ruby_version']}@#{node['github_connector']['ruby_gemset']}"
+
+ user node['github_connector']['user']
+ group node['github_connector']['group']
+ command "#{rvm_cmd} alias create #{rvm_alias} #{ruby_string}"
+ not_if do
+ cmd = Mixlib::ShellOut.new("#{rvm_cmd} alias show #{rvm_alias}")
+ cmd.run_command
+ !cmd.error? && (cmd.stdout.strip == ruby_string)
+ end
+ notifies :reload, 'service[github-connector-web]', :delayed
+ notifies :restart, 'service[github-connector-worker]', :delayed
+end
diff --git a/cookbook/recipes/server.rb b/cookbook/recipes/server.rb
new file mode 100644
index 0000000..208e205
--- /dev/null
+++ b/cookbook/recipes/server.rb
@@ -0,0 +1,139 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: server
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+#
+# Install Directory
+#
+
+install_dir = node['github_connector']['install_dir']
+ruby_string = "#{node['github_connector']['ruby_version']}@#{node['github_connector']['ruby_gemset']}"
+
+directory File.dirname(install_dir) do
+ recursive true
+end
+
+directory install_dir do
+ mode 0755
+ owner node['github_connector']['user']
+ group node['github_connector']['group']
+end
+
+#
+# Source code
+#
+
+git 'github-connector' do
+ destination install_dir
+ user node['github_connector']['user']
+ group node['github_connector']['group']
+ repository node['github_connector']['repo']['url']
+ revision node['github_connector']['repo']['revision']
+ ssh_wrapper "/home/#{node['github_connector']['user']}/.ssh/github_connector_ssh_wrapper.sh"
+ action :sync
+ # Notify configuration files immediately so they are available before
+ # migrating database
+ notifies :create, 'template[github-connector-databaseyml]', :immediately
+ notifies :create, 'template[github-connector-secretsyml]', :immediately
+ notifies :reload, 'service[github-connector-web]', :delayed
+ notifies :restart, 'service[github-connector-worker]', :delayed
+end
+
+
+#
+# Configuration files
+#
+
+template 'github-connector-databaseyml' do
+ path ::File.join(install_dir, 'config', 'database.yml')
+ mode 0600
+ owner node['github_connector']['user']
+ group node['github_connector']['group']
+ source 'database.yml.erb'
+ only_if { ::File.directory?(::File.join(install_dir, 'config')) }
+end
+
+template 'github-connector-secretsyml' do
+ path ::File.join(install_dir, 'config', 'secrets.yml')
+ mode 0600
+ owner node['github_connector']['user']
+ group node['github_connector']['group']
+ source 'secrets.yml.erb'
+ only_if { ::File.directory?(::File.join(install_dir, 'config')) }
+end
+
+
+#
+# Install gems
+#
+
+rvm_shell 'github-connector-gems' do
+ ruby_string ruby_string
+ user node['github_connector']['user']
+ group node['github_connector']['group']
+ cwd install_dir
+ code %{bundle install}
+ action :nothing
+ subscribes :run, 'git[github-connector]', :immediately
+ subscribes :run, 'execute[github-connector-alias]', :immediately
+end
+
+#
+# Migrate database
+#
+
+rvm_shell 'github-connector-database-migration' do
+ ruby_string ruby_string
+ user node['github_connector']['user']
+ group node['github_connector']['group']
+ cwd install_dir
+ code %{rake db:migrate RAILS_ENV=production}
+ action :nothing
+ subscribes :run, 'git[github-connector]', :immediately
+end
+
+#
+# Compile assets
+#
+
+rvm_shell 'github-connector-assets' do
+ ruby_string ruby_string
+ user node['github_connector']['user']
+ group node['github_connector']['group']
+ cwd install_dir
+ code %{rake assets:precompile RAILS_ENV=production}
+ action :nothing
+ subscribes :run, 'git[github-connector]', :immediately
+end
+
+# Logrotate
+include_recipe 'logrotate'
+logrotate_app 'github-connector' do
+ path "#{install_dir}/log/*.log"
+ create "0644 #{node['github_connector']['user']} #{node['github_connector']['group']}"
+ options %w(missingok delaycompress notifempty copytruncate)
+end
+
+# Nginx proxy
+include_recipe 'github_connector::nginx'
+
+# Upstart services
+include_recipe 'github_connector::upstart'
+
+# Cron
+include_recipe 'github_connector::cron'
diff --git a/cookbook/recipes/ssh.rb b/cookbook/recipes/ssh.rb
new file mode 100644
index 0000000..3123e04
--- /dev/null
+++ b/cookbook/recipes/ssh.rb
@@ -0,0 +1,65 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: ssh
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+if node['github_connector']['ssh_databag'] && node['github_connector']['ssh_databag_item']
+ ssh_data_bag = GithubConnector::Helpers.load_data_bag(
+ node['github_connector']['ssh_databag'],
+ node['github_connector']['ssh_databag_item']
+ )
+
+ if ssh_data_bag && ssh_data_bag['private_key']
+ require 'net/ssh'
+ private_key = OpenSSL::PKey::RSA.new(ssh_data_bag['private_key'])
+ public_key = private_key.public_key
+ ssh_dir = "/home/#{node['github_connector']['user']}/.ssh"
+
+ directory ssh_dir do
+ mode 0700
+ owner node['github_connector']['user']
+ group node['github_connector']['group']
+ end
+
+ file ::File.join(ssh_dir, 'github_connector_id_rsa') do
+ content private_key.to_pem
+ owner node['github_connector']['user']
+ group node['github_connector']['group']
+ mode 0600
+ end
+
+ file ::File.join(ssh_dir, 'github_connector_id_rsa.pub') do
+ content "#{public_key.ssh_type} #{[public_key.to_blob].pack('m0')}\n"
+ owner node['github_connector']['user']
+ group node['github_connector']['group']
+ mode 0644
+ end
+
+ file ::File.join(ssh_dir, 'github_connector_ssh_wrapper.sh') do
+ content "#!/bin/sh -e\nexec ssh -i #{::File.join(ssh_dir, 'github_connector_id_rsa')} $@\n"
+ owner node['github_connector']['user']
+ group node['github_connector']['group']
+ mode 0755
+ end
+ end
+end
+
+if node['github_connector']['github_host_key']
+ ssh_known_hosts_entry 'github.com' do
+ key node['github_connector']['github_host_key']
+ end
+end
diff --git a/cookbook/recipes/upstart.rb b/cookbook/recipes/upstart.rb
new file mode 100644
index 0000000..3c41313
--- /dev/null
+++ b/cookbook/recipes/upstart.rb
@@ -0,0 +1,54 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: upstart
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+template "/etc/init/github-connector-web.conf" do
+ source 'upstart-github-connector-web.conf.erb'
+ mode 0644
+ owner 'root'
+ group 'root'
+ action :create
+ variables(
+ :home_path => "/home/#{node['github_connector']['user']}",
+ :rvm_path => "/home/#{node['github_connector']['user']}/.rvm"
+ )
+end
+
+template "/etc/init/github-connector-worker.conf" do
+ source 'upstart-github-connector-worker.conf.erb'
+ mode 0644
+ owner 'root'
+ group 'root'
+ action :create
+ variables(
+ :home_path => "/home/#{node['github_connector']['user']}",
+ :rvm_path => "/home/#{node['github_connector']['user']}/.rvm"
+ )
+end
+
+service 'github-connector-web' do
+ provider Chef::Provider::Service::Upstart
+ supports :status => true, :restart => true, :reload => true
+ action :start
+end
+
+service 'github-connector-worker' do
+ provider Chef::Provider::Service::Upstart
+ supports :status => true, :restart => true, :reload => false
+ action :start
+end
diff --git a/cookbook/recipes/user.rb b/cookbook/recipes/user.rb
new file mode 100644
index 0000000..6d1b494
--- /dev/null
+++ b/cookbook/recipes/user.rb
@@ -0,0 +1,27 @@
+#
+# Cookbook Name:: github_connector
+# Recipe:: user
+#
+# Copyright (C) 2014 Brandon Turner
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+group node['github_connector']['group']
+
+user node['github_connector']['user'] do
+ gid node['github_connector']['group']
+ shell '/bin/bash'
+ home "/home/#{node['github_connector']['user']}"
+ supports :manage_home => true
+end
diff --git a/cookbook/templates/default/database.yml.erb b/cookbook/templates/default/database.yml.erb
new file mode 100644
index 0000000..9284e51
--- /dev/null
+++ b/cookbook/templates/default/database.yml.erb
@@ -0,0 +1,22 @@
+# THIS FILE IS MANAGED BY CHEF
+# Local modifications will be discarded.
+
+development: &default
+ adapter: postgresql
+ host: <%= node['github_connector']['db']['host'] %>
+ port: <%= node['github_connector']['db']['port'] %>
+ database: <%= node['github_connector']['db']['name'] %>
+ user: <%= node['github_connector']['db']['user'] %>
+ password: "<%= GithubConnector::Helpers.database_password(node) %>"
+ pool: 25
+ timeout: 5
+
+production:
+ <<: *default
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ database: github_connector_test
diff --git a/cookbook/templates/default/nginx-github-connector.conf.erb b/cookbook/templates/default/nginx-github-connector.conf.erb
new file mode 100644
index 0000000..45b3617
--- /dev/null
+++ b/cookbook/templates/default/nginx-github-connector.conf.erb
@@ -0,0 +1,50 @@
+# THIS FILE IS MANAGED BY CHEF
+# Local modifications will be discarded.
+
+upstream github_connector_server {
+ server unix://<%= @install_dir %>/tmp/sockets/puma.sock fail_timeout=0;
+}
+
+<% if @redirect_http %>
+server {
+ listen <%= @listen_port %>;
+ server_name <%= @host_name %> <%= @host_aliases.join(' ') %>;
+ rewrite ^(.*) https://$host<%= ":#{@ssl_listen_port}" unless @ssl_listen_port == 443 %>$1 permanent;
+ }
+<% end -%>
+
+server {
+<% if @ssl_enabled -%>
+ listen <%= @ssl_listen_port %>;
+<% else -%>
+ listen <%= @listen_port %>;
+<% end -%>
+
+ server_name <%= @host_name %> <%= @host_aliases.join(' ') %>;
+ root <%= @install_dir %>/public;
+
+ keepalive_timeout 5s;
+
+<% if @ssl_enabled -%>
+ ssl on;
+ ssl_certificate /etc/ssl/certs/<%= @host_name %>.crt;
+ ssl_certificate_key /etc/ssl/private/<%= @host_name %>.key;
+
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_ciphers EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
+ ssl_prefer_server_ciphers on;
+
+ ssl_session_cache shared:SSL:10m;
+ ssl_session_timeout 10m;
+<% end -%>
+
+ try_files $uri/index.html $uri.html $uri @app;
+
+ location @app {
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Host $http_host;
+ proxy_redirect off;
+ proxy_pass http://github_connector_server;
+ }
+}
diff --git a/cookbook/templates/default/secrets.yml.erb b/cookbook/templates/default/secrets.yml.erb
new file mode 100644
index 0000000..8212abb
--- /dev/null
+++ b/cookbook/templates/default/secrets.yml.erb
@@ -0,0 +1,26 @@
+# THIS FILE IS MANAGED BY CHEF
+# Local modifications will be discarded.
+
+# Be sure to restart your server when you modify this file.
+#
+# Make sure the secret is at least 30 characters and all random,
+# no regular words or you'll be exposed to dictionary attacks.
+# You can use `rake secret` to generate a secure secret key.
+#
+# Make sure the secrets in this file are kept private
+# if you're sharing your code publicly.
+
+development: &default
+ # Your secret key is used for verifying the integrity of signed cookies.
+ # If you change this key, all old signed cookies will become invalid!
+ secret_key_base: <%= GithubConnector::Helpers.secret_key_base(node) %>
+
+ # The secret key used for encrypting sensitive database info.
+ # If you change this key, all user OAuth tokens will become unreadable!
+ database_key: <%= GithubConnector::Helpers.database_key(node) %>
+
+production:
+ <<: *default
+
+test:
+ <<: *default
diff --git a/cookbook/templates/default/upstart-github-connector-web.conf.erb b/cookbook/templates/default/upstart-github-connector-web.conf.erb
new file mode 100644
index 0000000..0da515e
--- /dev/null
+++ b/cookbook/templates/default/upstart-github-connector-web.conf.erb
@@ -0,0 +1,14 @@
+# THIS FILE IS MANAGED BY CHEF
+# Local modifications will be discarded.
+
+description "GitHub Connector"
+
+start on runlevel [2]
+stop on runlevel [016]
+
+setuid <%= node['github_connector']['user'] %>
+chdir <%= node['github_connector']['install_dir'] %>
+exec <%= @rvm_path %>/bin/rvm <%= node['github_connector']['rvm_alias'] %> do puma -e production -b unix://<%= node['github_connector']['install_dir'] %>/tmp/sockets/puma.sock
+reload signal SIGUSR2
+
+respawn
diff --git a/cookbook/templates/default/upstart-github-connector-worker.conf.erb b/cookbook/templates/default/upstart-github-connector-worker.conf.erb
new file mode 100644
index 0000000..7339c39
--- /dev/null
+++ b/cookbook/templates/default/upstart-github-connector-worker.conf.erb
@@ -0,0 +1,13 @@
+# THIS FILE IS MANAGED BY CHEF
+# Local modifications will be discarded.
+
+description "GitHub Connector Worker"
+
+start on runlevel [2]
+stop on runlevel [016]
+
+setuid <%= node['github_connector']['user'] %>
+chdir <%= node['github_connector']['install_dir'] %>
+exec env HOME=<%= @home_path %> RAILS_ENV=production <%= @rvm_path %>/bin/rvm <%= node['github_connector']['rvm_alias'] %> do <%= node['github_connector']['install_dir'] %>/bin/delayed_job run
+
+respawn
diff --git a/cookbook/test_data_bags/github_connector/secrets.json b/cookbook/test_data_bags/github_connector/secrets.json
new file mode 100644
index 0000000..68f4cde
--- /dev/null
+++ b/cookbook/test_data_bags/github_connector/secrets.json
@@ -0,0 +1,6 @@
+{
+ "id": "secrets",
+ "database_password": "badpass_db",
+ "secret_key_base": "badkey_secret",
+ "database_key": "badkey_db"
+}
diff --git a/cookbook/test_data_bags/github_connector/ssh.json b/cookbook/test_data_bags/github_connector/ssh.json
new file mode 100644
index 0000000..6018a77
--- /dev/null
+++ b/cookbook/test_data_bags/github_connector/ssh.json
@@ -0,0 +1,4 @@
+{
+ "id": "ssh",
+ "private_key": "-----BEGIN RSA PRIVATE KEY-----\nTHIS_IS_A_FAKE_KEY\n-----END RSA PRIVATE KEY-----\n"
+}
diff --git a/cookbook/test_data_bags/github_connector/ssl_cert.json b/cookbook/test_data_bags/github_connector/ssl_cert.json
new file mode 100644
index 0000000..8d0a513
--- /dev/null
+++ b/cookbook/test_data_bags/github_connector/ssl_cert.json
@@ -0,0 +1,5 @@
+{
+ "id": "ssl_cert",
+ "cert": "-----BEGIN CERTIFICATE-----\nMIIEMzCCAxugAwIBAgIJAK/3uISa+d4LMA0GCSqGSIb3DQEBBQUAMG4xCzAJBgNV\nBAYTAlVTMQ4wDAYDVQQIEwVUZXhhczEPMA0GA1UEBxMGQXVzdGluMSowKAYDVQQK\nEyFHaXRodWIgQWN0aXZlIERpcmVjdG9yeSBDb25uZWN0b3IxEjAQBgNVBAMTCWxv\nY2FsaG9zdDAeFw0xNDEwMDYwMjI3MjFaFw0xNTEwMDYwMjI3MjFaMG4xCzAJBgNV\nBAYTAlVTMQ4wDAYDVQQIEwVUZXhhczEPMA0GA1UEBxMGQXVzdGluMSowKAYDVQQK\nEyFHaXRodWIgQWN0aXZlIERpcmVjdG9yeSBDb25uZWN0b3IxEjAQBgNVBAMTCWxv\nY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALQRWnGOJWvY\nQwXO7gBZ7BVH8WOh4Vdvsy+Smw7kjukf3ZeXJrCs2B6cQYLKkDbelX/C0YIGp3w2\nGTFCY1mgsmpZI+svzMrZ8hHi00sEUkXekRxWgT/Bbo2AirP/Fz/r0d8fYle5D+0n\nriBQP3il6ZkYUAlJ0tDlsqCv2oEXxb7bIH88/lwwRXkxidr0GBdTG7HWGGABzG+B\n775DVL5RYMkOLJa7sKP3PGVK1nKIubaVWiQ+jhdiaHOPk2VymWhw3r3tV9YExxYP\n3vwcPS/Of/5EGo/WJ/a5MW57uTb90rWamBDMLMjBJPUXKLiDpNAdW0GFKw+PdWGX\n8/wyqMNrDpUCAwEAAaOB0zCB0DAdBgNVHQ4EFgQURShade6A1YmMODPSNViGEoty\nnGwwgaAGA1UdIwSBmDCBlYAURShade6A1YmMODPSNViGEotynGyhcqRwMG4xCzAJ\nBgNVBAYTAlVTMQ4wDAYDVQQIEwVUZXhhczEPMA0GA1UEBxMGQXVzdGluMSowKAYD\nVQQKEyFHaXRodWIgQWN0aXZlIERpcmVjdG9yeSBDb25uZWN0b3IxEjAQBgNVBAMT\nCWxvY2FsaG9zdIIJAK/3uISa+d4LMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF\nBQADggEBALEAKoLnLNcm4+oEMs97LV9FspaqJMbZSJbzQaPUN6DlQF9o09HgaEgU\nKI4GGuPUEt+wU9OQBDhWmbkJgyqPl3L6Mq8YazadPvIPwVEzTGcUWfeBDdrcmH5T\nNTimbVQgwliArbpXI/kgWJw4G7e3wn5ZptdFr3YscdwE1ki4vIYgXlIBKqXgW+Wh\nlF/T+s1cRPmoX24M1G5A3wgngLGshIVvv+Xr//keLvWpmS5z3uXUzBH3HeLwxrjO\nHzrjOnwi6OvSoBqMZjvLLv/yP9g0hh9DmiqKh0QmB90Vlp/bx5Dv1g5Fup3alNdp\nxjsyvSde1fKm3xSpFh+bXwiYvLHh8LY=\n-----END CERTIFICATE-----\n",
+ "key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAtBFacY4la9hDBc7uAFnsFUfxY6HhV2+zL5KbDuSO6R/dl5cm\nsKzYHpxBgsqQNt6Vf8LRgganfDYZMUJjWaCyalkj6y/MytnyEeLTSwRSRd6RHFaB\nP8FujYCKs/8XP+vR3x9iV7kP7SeuIFA/eKXpmRhQCUnS0OWyoK/agRfFvtsgfzz+\nXDBFeTGJ2vQYF1MbsdYYYAHMb4HvvkNUvlFgyQ4slruwo/c8ZUrWcoi5tpVaJD6O\nF2Joc4+TZXKZaHDeve1X1gTHFg/e/Bw9L85//kQaj9Yn9rkxbnu5Nv3StZqYEMws\nyMEk9RcouIOk0B1bQYUrD491YZfz/DKow2sOlQIDAQABAoIBABWj0D69Wnnvb36P\nM8MPC3QzRSs4FSCw59Pbxo6voQ0bK0JAhAHPg9mJ5cWWGma9sTG9c/gwXIhs5/In\njFEFIuvs8ogdIntuXc0QeVwWlNyYts+1Batnz6VpwUGIcn7YFEzANM1eDC/wCNkR\nS89wAPbJGTVEjfVU5XayK4xAEx+weh704U75pGJqI+N/Cd4aBIA8rLQ8S5cRLRRs\nx6HUvuysK1A92RTUS3Cvu8UR8lJYzmkgGGf72iZjtqx7IqKSeKLNNE+t1d4Vtigi\nhC8egVlRxFnG3rdrssUqT1+7i7T6RAOCiD8WK1U+fjSmhz6/n88heZ5XpKeUdkiQ\ny1xJsLECgYEA6GP1haXQvg/FIl7HBJw4BJgHUrlMEkwyfvFOF1UYacjHN7uNXV1B\nsSe+gm/MI2uiiNayjJChy7r3pSfckNdauI+0zxsysj6dSXp6B0/1YiKAAJXm3X5i\nx1F+mxWWaMUlyW7YEBO0I4j0bcS0knO6NffWs61OtqTw8OI4uqzYIHsCgYEAxlyU\nTUrBMPMoVNb2KlOVvXJwnEbbLVom3wBiMNg+RVqqzwIl1NB7Bz45n6dHuSCXhbOH\nA/5871aAXQHZq4zj2+aFyr+xAdypcU1Yez/Xio6Q0jkEd6QS/tKVvPFVXCQCGfvX\nYv4rvZLSI4MxG0paF2qp5DZlkm1Oios/4XEHyC8CgYEA1IeWY0PiQ+/oOiaznGPC\nV3EyQVV1XMaS58WHxY7tZNFaYH4GKvy+t2XBtUjJSRuG6d5wLF2ZmtjC4ygxb8WE\nEoZatY4KLzlUX37DWyylHbqvldmB6c9MRz0grHRxuh+TD0VwFEPw2w7FfB4JhmaQ\nRgsDMA+vjRoLwEEj4JVyk0ECgYEAnPcdk5wYDDgeLiR8XzoNQACTA9c+EUFJiSWw\njZ5QiGkayPyWGzVuZWjkCGZC50fXH0HVEWAMVQhKQ073hDzVAmoEbVALLcIDg1kF\nL2JxmX7/MptT4ajAL01MmFsQhP0pfI5A/mDLFBRenSNvdHz9lZIeJiy1a417nT5b\nqnXbBpkCgYBAKidtSdQ2a97HuO1Jctw4B1xOFlJwp36Bi3b3PN14l/hfnbVNg0KS\nols8DZZ7nh7hRjcds5w4174YUYkLfQZxfI5Wi48BoRdB+ruZR4DlGL+tWll28L/z\nQTUsuIdsNVvrv524BSW5lYv9ocpCjw23eGuLAkFnea7Thztg/Ez8jg==\n-----END RSA PRIVATE KEY-----\n"
+}
diff --git a/db/migrate/20140619160007_devise_create_users.rb b/db/migrate/20140619160007_devise_create_users.rb
new file mode 100644
index 0000000..dce05ca
--- /dev/null
+++ b/db/migrate/20140619160007_devise_create_users.rb
@@ -0,0 +1,38 @@
+class DeviseCreateUsers < ActiveRecord::Migration
+ def change
+ create_table(:users) do |t|
+ ## LDAP authenticatable
+ t.string :username, null: false, default: ""
+ t.string :email
+ t.string :name
+
+ ## Rememberable
+ t.datetime :remember_created_at
+
+ ## Trackable
+ t.integer :sign_in_count, default: 0, null: false
+ t.datetime :current_sign_in_at
+ t.datetime :last_sign_in_at
+ t.string :current_sign_in_ip
+ t.string :last_sign_in_ip
+
+ ## Confirmable
+ # t.string :confirmation_token
+ # t.datetime :confirmed_at
+ # t.datetime :confirmation_sent_at
+ # t.string :unconfirmed_email # Only if using reconfirmable
+
+ ## Lockable
+ # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
+ # t.string :unlock_token # Only if unlock strategy is :email or :both
+ # t.datetime :locked_at
+
+
+ t.timestamps
+ end
+
+ add_index :users, :username, unique: true
+ # add_index :users, :confirmation_token, unique: true
+ # add_index :users, :unlock_token, unique: true
+ end
+end
diff --git a/db/migrate/20140624041139_add_github_attrs_to_user.rb b/db/migrate/20140624041139_add_github_attrs_to_user.rb
new file mode 100644
index 0000000..420cf87
--- /dev/null
+++ b/db/migrate/20140624041139_add_github_attrs_to_user.rb
@@ -0,0 +1,6 @@
+class AddGithubAttrsToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :encrypted_github_token, :string
+ add_column :users, :github_login, :string
+ end
+end
diff --git a/db/migrate/20140626181353_create_settings.rb b/db/migrate/20140626181353_create_settings.rb
new file mode 100644
index 0000000..d66d386
--- /dev/null
+++ b/db/migrate/20140626181353_create_settings.rb
@@ -0,0 +1,11 @@
+class CreateSettings < ActiveRecord::Migration
+ def change
+ create_table :settings do |t|
+ t.string :key
+ t.string :value
+
+ t.timestamps
+ end
+ add_index :settings, :key, unique: true
+ end
+end
diff --git a/db/migrate/20140708224056_create_emails.rb b/db/migrate/20140708224056_create_emails.rb
new file mode 100644
index 0000000..86fddd1
--- /dev/null
+++ b/db/migrate/20140708224056_create_emails.rb
@@ -0,0 +1,23 @@
+class CreateEmails < ActiveRecord::Migration
+ def change
+ create_table :emails do |t|
+ t.references :user, index: true
+ t.string :address
+ t.string :source
+
+ t.timestamps
+ end
+ add_index :emails, :source
+
+ reversible do |dir|
+ dir.up do
+ execute "INSERT INTO emails (user_id, address, source, created_at, updated_at) SELECT id, email, 'ldap', NOW(), NOW() FROM users WHERE email IS NOT NULL"
+ remove_column :users, :email
+ end
+ dir.down do
+ add_column :users, :email, :string
+ execute "UPDATE users AS u SET email=emails.address, updated_at=NOW() FROM users INNER JOIN emails ON users.id=emails.user_id"
+ end
+ end
+ end
+end
diff --git a/db/migrate/20140709045852_add_last_sync_to_user.rb b/db/migrate/20140709045852_add_last_sync_to_user.rb
new file mode 100644
index 0000000..6d3cd74
--- /dev/null
+++ b/db/migrate/20140709045852_add_last_sync_to_user.rb
@@ -0,0 +1,6 @@
+class AddLastSyncToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :last_ldap_sync, :datetime
+ add_column :users, :last_github_sync, :datetime
+ end
+end
diff --git a/db/migrate/20140709191104_add_state_attrs_to_user.rb b/db/migrate/20140709191104_add_state_attrs_to_user.rb
new file mode 100644
index 0000000..3655cbe
--- /dev/null
+++ b/db/migrate/20140709191104_add_state_attrs_to_user.rb
@@ -0,0 +1,7 @@
+class AddStateAttrsToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :state, :string, null: false, default: :unknown
+ add_column :users, :ldap_account_control, :integer
+ add_column :users, :github_mfa, :boolean
+ end
+end
diff --git a/db/migrate/20140714210644_add_sync_errors_to_user.rb b/db/migrate/20140714210644_add_sync_errors_to_user.rb
new file mode 100644
index 0000000..2931886
--- /dev/null
+++ b/db/migrate/20140714210644_add_sync_errors_to_user.rb
@@ -0,0 +1,8 @@
+class AddSyncErrorsToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :github_sync_error, :string
+ add_column :users, :github_sync_error_at, :datetime
+ add_column :users, :ldap_sync_error, :string
+ add_column :users, :ldap_sync_error_at, :datetime
+ end
+end
diff --git a/db/migrate/20140722192112_add_github_teams.rb b/db/migrate/20140722192112_add_github_teams.rb
new file mode 100644
index 0000000..61cede3
--- /dev/null
+++ b/db/migrate/20140722192112_add_github_teams.rb
@@ -0,0 +1,15 @@
+class AddGithubTeams < ActiveRecord::Migration
+ def change
+ create_table(:teams) do |t|
+ t.string :slug
+ t.string :organization
+ t.string :name
+ t.timestamps
+ end
+
+ create_table :user_teams, id: false do |t|
+ t.belongs_to :user
+ t.belongs_to :team
+ end
+ end
+end
diff --git a/db/migrate/20140724141457_refactor_github_tables.rb b/db/migrate/20140724141457_refactor_github_tables.rb
new file mode 100644
index 0000000..d574e9a
--- /dev/null
+++ b/db/migrate/20140724141457_refactor_github_tables.rb
@@ -0,0 +1,113 @@
+class RefactorGithubTables < ActiveRecord::Migration
+ def change
+ rename_table :teams, :github_teams
+ rename_table :user_teams, :github_user_teams
+ create_table(:github_users) do |t|
+ t.belongs_to :user, index: true
+ t.string :login, null: false
+ t.boolean :mfa
+ t.string :encrypted_token
+ t.datetime :last_sync_at
+ t.string :sync_error
+ t.datetime :sync_error_at
+ t.timestamps
+ end
+ add_index :github_users, :login, unique: true
+
+ create_table :github_emails do |t|
+ t.references :github_user, index: true, null: false
+ t.string :address
+ t.timestamps
+ end
+ add_column :users, :email, :string
+
+ rename_column :github_user_teams, :user_id, :github_user_id
+ rename_column :github_user_teams, :team_id, :github_team_id
+
+ reversible do |dir|
+ dir.up do
+ execute <<-SQL
+ INSERT INTO github_users
+ (user_id, login, mfa, encrypted_token, last_sync_at, sync_error, sync_error_at)
+ (SELECT id, github_login, github_mfa, encrypted_github_token, last_github_sync, github_sync_error, github_sync_error_at
+ FROM users
+ WHERE github_login IS NOT NULL
+ )
+ SQL
+ execute <<-SQL
+ INSERT INTO github_emails
+ (github_user_id, address, created_at, updated_at)
+ (SELECT github_users.id, emails.address, emails.created_at, emails.updated_at
+ FROM emails
+ INNER JOIN users ON emails.user_id = users.id
+ INNER JOIN github_users ON github_users.user_id = users.id
+ WHERE emails.source = 'github')
+ SQL
+ execute <<-SQL
+ UPDATE users AS u
+ SET email = emails.address
+ FROM users
+ INNER JOIN emails ON users.id = emails.user_id
+ WHERE emails.source = 'ldap'
+ SQL
+ execute <<-SQL
+ UPDATE github_user_teams AS user_team
+ SET github_user_id = github_users.id
+ FROM github_user_teams
+ INNER JOIN users ON github_user_teams.github_user_id = users.id
+ INNER JOIN github_users ON users.id = github_users.user_id
+ SQL
+ end
+
+ dir.down do
+ execute <<-SQL
+ UPDATE users AS u
+ SET github_login = github_users.login,
+ github_mfa = github_users.mfa,
+ encrypted_github_token = github_users.encrypted_token,
+ last_github_sync = github_users.last_sync_at,
+ github_sync_error = github_users.sync_error,
+ github_sync_error_at = github_users.sync_error_at
+ FROM users
+ INNER JOIN github_users ON github_users.user_id = users.id
+ SQL
+ execute <<-SQL
+ UPDATE github_user_teams AS user_team
+ SET github_user_id = users.id
+ FROM github_user_teams
+ INNER JOIN github_users ON github_users.id = github_user_teams.github_user_id
+ INNER JOIN users ON users.id = github_users.user_id
+ SQL
+ execute <<-SQL
+ INSERT INTO emails
+ (user_id, address, source, created_at, updated_at)
+ (SELECT id, email, 'ldap', NOW(), NOW() FROM users)
+ SQL
+ execute <<-SQL
+ INSERT INTO emails
+ (user_id, address, source, created_at, updated_at)
+ (SELECT github_users.user_id, github_emails.address, 'github', github_emails.created_at, github_emails.updated_at
+ FROM github_emails
+ INNER JOIN github_users ON github_emails.github_user_id = github_users.id)
+ SQL
+ end
+ end
+
+ remove_column :users, :github_login, :string
+ remove_column :users, :github_mfa, :boolean
+ remove_column :users, :encrypted_github_token, :string
+ remove_column :users, :last_github_sync, :datetime
+ remove_column :users, :github_sync_error, :string
+ remove_column :users, :github_sync_error_at, :datetime
+
+ revert do
+ create_table :emails do |t|
+ t.references :user, index: true
+ t.string :address
+ t.string :source
+ t.timestamps
+ end
+ add_index :emails, :source
+ end
+ end
+end
diff --git a/db/migrate/20140726214806_move_state_to_github_user.rb b/db/migrate/20140726214806_move_state_to_github_user.rb
new file mode 100644
index 0000000..0a9766d
--- /dev/null
+++ b/db/migrate/20140726214806_move_state_to_github_user.rb
@@ -0,0 +1,6 @@
+class MoveStateToGithubUser < ActiveRecord::Migration
+ def change
+ add_column :github_users, :state, :string, null: false, default: :unknown
+ remove_column :users, :state, :string, null: false, default: :unknown
+ end
+end
diff --git a/db/migrate/20140811194159_add_github_urls.rb b/db/migrate/20140811194159_add_github_urls.rb
new file mode 100644
index 0000000..cf6ff63
--- /dev/null
+++ b/db/migrate/20140811194159_add_github_urls.rb
@@ -0,0 +1,6 @@
+class AddGithubUrls < ActiveRecord::Migration
+ def change
+ add_column :github_users, :avatar_url, :string
+ add_column :github_users, :html_url, :string
+ end
+end
diff --git a/db/migrate/20140818012538_add_admin_flag_to_user.rb b/db/migrate/20140818012538_add_admin_flag_to_user.rb
new file mode 100644
index 0000000..8319be3
--- /dev/null
+++ b/db/migrate/20140818012538_add_admin_flag_to_user.rb
@@ -0,0 +1,14 @@
+class AddAdminFlagToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :admin, :bool
+
+ reversible do |dir|
+ dir.up do
+ execute <<-SQL
+ UPDATE users SET admin='t'
+ WHERE users.id IN (SELECT id FROM users ORDER BY created_at LIMIT 1)
+ SQL
+ end
+ end
+ end
+end
diff --git a/db/migrate/20140915164525_convert_settings_value_to_text.rb b/db/migrate/20140915164525_convert_settings_value_to_text.rb
new file mode 100644
index 0000000..8fd6473
--- /dev/null
+++ b/db/migrate/20140915164525_convert_settings_value_to_text.rb
@@ -0,0 +1,5 @@
+class ConvertSettingsValueToText < ActiveRecord::Migration
+ def change
+ change_column :settings, :value, :text
+ end
+end
diff --git a/db/migrate/20140917184213_create_delayed_jobs.rb b/db/migrate/20140917184213_create_delayed_jobs.rb
new file mode 100644
index 0000000..f7de70b
--- /dev/null
+++ b/db/migrate/20140917184213_create_delayed_jobs.rb
@@ -0,0 +1,22 @@
+class CreateDelayedJobs < ActiveRecord::Migration
+ def self.up
+ create_table :delayed_jobs, :force => true do |table|
+ table.integer :priority, :default => 0, :null => false # Allows some jobs to jump to the front of the queue
+ table.integer :attempts, :default => 0, :null => false # Provides for retries, but still fail eventually.
+ table.text :handler, :null => false # YAML-encoded string of the object that will do work
+ table.text :last_error # reason for last failure (See Note below)
+ table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future.
+ table.datetime :locked_at # Set when a client is working on this object
+ table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead)
+ table.string :locked_by # Who is working on this object (if locked)
+ table.string :queue # The name of the queue this job is in
+ table.timestamps
+ end
+
+ add_index :delayed_jobs, [:priority, :run_at], :name => 'delayed_jobs_priority'
+ end
+
+ def self.down
+ drop_table :delayed_jobs
+ end
+end
diff --git a/db/migrate/20140917184236_add_connect_github_user_statuses.rb b/db/migrate/20140917184236_add_connect_github_user_statuses.rb
new file mode 100644
index 0000000..5d4a1fc
--- /dev/null
+++ b/db/migrate/20140917184236_add_connect_github_user_statuses.rb
@@ -0,0 +1,13 @@
+class AddConnectGithubUserStatuses < ActiveRecord::Migration
+ def change
+ create_table(:connect_github_user_statuses) do |t|
+ t.belongs_to :user
+ t.belongs_to :github_user
+ t.string :oauth_code
+ t.string :status
+ t.string :step
+ t.text :error_message
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20140920200517_add_remember_token_to_user.rb b/db/migrate/20140920200517_add_remember_token_to_user.rb
new file mode 100644
index 0000000..a906712
--- /dev/null
+++ b/db/migrate/20140920200517_add_remember_token_to_user.rb
@@ -0,0 +1,5 @@
+class AddRememberTokenToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :remember_token, :string
+ end
+end
diff --git a/db/migrate/20141018212156_add_github_user_disabled_teams.rb b/db/migrate/20141018212156_add_github_user_disabled_teams.rb
new file mode 100644
index 0000000..2993415
--- /dev/null
+++ b/db/migrate/20141018212156_add_github_user_disabled_teams.rb
@@ -0,0 +1,8 @@
+class AddGithubUserDisabledTeams < ActiveRecord::Migration
+ def change
+ create_table :github_user_disabled_teams, id: false do |t|
+ t.belongs_to :github_user
+ t.belongs_to :github_team
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..39b4c1c
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,122 @@
+# encoding: UTF-8
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# Note that this schema.rb definition is the authoritative source for your
+# database schema. If you need to create the application database on another
+# system, you should be using db:schema:load, not running all the migrations
+# from scratch. The latter is a flawed and unsustainable approach (the more migrations
+# you'll amass, the slower it'll run and the greater likelihood for issues).
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema.define(version: 20141018212156) do
+
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "plpgsql"
+
+ create_table "connect_github_user_statuses", force: true do |t|
+ t.integer "user_id"
+ t.integer "github_user_id"
+ t.string "oauth_code"
+ t.string "status"
+ t.string "step"
+ t.text "error_message"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ create_table "delayed_jobs", force: true do |t|
+ t.integer "priority", default: 0, null: false
+ t.integer "attempts", default: 0, null: false
+ t.text "handler", null: false
+ t.text "last_error"
+ t.datetime "run_at"
+ t.datetime "locked_at"
+ t.datetime "failed_at"
+ t.string "locked_by"
+ t.string "queue"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree
+
+ create_table "github_emails", force: true do |t|
+ t.integer "github_user_id", null: false
+ t.string "address", limit: 255
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "github_emails", ["github_user_id"], name: "index_github_emails_on_github_user_id", using: :btree
+
+ create_table "github_teams", force: true do |t|
+ t.string "slug", limit: 255
+ t.string "organization", limit: 255
+ t.string "name", limit: 255
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ create_table "github_user_disabled_teams", id: false, force: true do |t|
+ t.integer "github_user_id"
+ t.integer "github_team_id"
+ end
+
+ create_table "github_user_teams", id: false, force: true do |t|
+ t.integer "github_user_id"
+ t.integer "github_team_id"
+ end
+
+ create_table "github_users", force: true do |t|
+ t.integer "user_id"
+ t.string "login", limit: 255, null: false
+ t.boolean "mfa"
+ t.string "encrypted_token", limit: 255
+ t.datetime "last_sync_at"
+ t.string "sync_error", limit: 255
+ t.datetime "sync_error_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "state", limit: 255, default: "unknown", null: false
+ t.string "avatar_url", limit: 255
+ t.string "html_url", limit: 255
+ end
+
+ add_index "github_users", ["login"], name: "index_github_users_on_login", unique: true, using: :btree
+ add_index "github_users", ["user_id"], name: "index_github_users_on_user_id", using: :btree
+
+ create_table "settings", force: true do |t|
+ t.string "key", limit: 255
+ t.text "value"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "settings", ["key"], name: "index_settings_on_key", unique: true, using: :btree
+
+ create_table "users", force: true do |t|
+ t.string "username", limit: 255, default: "", null: false
+ t.string "name", limit: 255
+ t.datetime "remember_created_at"
+ t.integer "sign_in_count", default: 0, null: false
+ t.datetime "current_sign_in_at"
+ t.datetime "last_sign_in_at"
+ t.string "current_sign_in_ip", limit: 255
+ t.string "last_sign_in_ip", limit: 255
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.datetime "last_ldap_sync"
+ t.integer "ldap_account_control"
+ t.string "ldap_sync_error", limit: 255
+ t.datetime "ldap_sync_error_at"
+ t.string "email", limit: 255
+ t.boolean "admin"
+ t.string "remember_token"
+ end
+
+ add_index "users", ["username"], name: "index_users_on_username", unique: true, using: :btree
+
+end
diff --git a/db/seeds.rb b/db/seeds.rb
new file mode 100644
index 0000000..4edb1e8
--- /dev/null
+++ b/db/seeds.rb
@@ -0,0 +1,7 @@
+# This file should contain all the record creation needed to seed the database with its default values.
+# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup).
+#
+# Examples:
+#
+# cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }])
+# Mayor.create(name: 'Emanuel', city: cities.first)
diff --git a/ldap/README.md b/ldap/README.md
new file mode 100644
index 0000000..02427c0
--- /dev/null
+++ b/ldap/README.md
@@ -0,0 +1,42 @@
+Development LDAP Server
+=======================
+
+The code in this directory uses OpenLDAP to emulate the Active
+Directory records needed for this application to work. This is
+helpful in development and testing if you do not want to connect
+to a real Active Directory server.
+
+## Install prerequisites
+
+### Ubuntu
+
+Install OpenLDAP's slapd:
+
+ sudo apt-get install slapd ldap-utils
+
+You may also need to put apparmor into complain mode:
+
+ sudo apt-get install apparmor-utils
+ sudo aa-complain /usr/sbin/slapd
+
+### OSX
+
+OpenLDAP is installed on OSX by default. There is nothing else
+you need to do.
+
+## Run test server
+
+To run the server:
+
+ ./run-server
+
+## Accounts
+
+Several accounts are available:
+
+* hsimpson - Normal account
+* msimpson - Locked account
+* bsimpson - Disabled account
+* lsimpson - Password expired
+
+All accounts use password 123456.
diff --git a/ldap/base.ldif b/ldap/base.ldif
new file mode 100644
index 0000000..c46821d
--- /dev/null
+++ b/ldap/base.ldif
@@ -0,0 +1,74 @@
+
+dn: dc=example,dc=com
+objectClass: dcObject
+objectClass: organizationalUnit
+dc: example
+ou: example
+
+# Normal account
+dn: cn=Homer Simpson,dc=example,dc=com
+objectclass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: user
+displayName: Homer Simpson
+name: Homer Simpson
+givenName: Homer
+sn: Simpson
+mail: Homer_Simpson@example.com
+userPrincipalName: hsimpson@example.com
+userAccountControl: 512
+sAMAccountName: hsimpson
+# userPassword: 123456
+userPassword: {SSHA}1j5ho2mHI6fHgwQOjBk9aRHF47FzYWx0
+
+# Locked account
+dn: cn=Marge Simpson,dc=example,dc=com
+objectclass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: user
+displayName: Homer Simpson
+name: Marge Simpson
+givenName: Marge
+sn: Simpson
+mail: Marge_Simpson@example.com
+userPrincipalName: msimpson@example.com
+userAccountControl: 528
+sAMAccountName: msimpson
+# userPassword: 123456
+userPassword: {SSHA}1j5ho2mHI6fHgwQOjBk9aRHF47FzYWx0
+
+# Disabled account
+dn: cn=Bart Simpson,dc=example,dc=com
+objectclass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: user
+displayName: Homer Simpson
+name: Bart Simpson
+givenName: Bart
+sn: Simpson
+mail: Bart_Simpson@example.com
+userPrincipalName: bsimpson@example.com
+userAccountControl: 514
+sAMAccountName: bsimpson
+# userPassword: 123456
+userPassword: {SSHA}1j5ho2mHI6fHgwQOjBk9aRHF47FzYWx0
+
+# Password expired
+dn: cn=Lisa Simpson,dc=example,dc=com
+objectclass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: user
+displayName: Homer Simpson
+name: Lisa Simpson
+givenName: Lisa
+sn: Simpson
+mail: Lisa_Simpson@example.com
+userPrincipalName: lsimpson@example.com
+userAccountControl: 8389120
+sAMAccountName: lsimpson
+# userPassword: 123456
+userPassword: {SSHA}1j5ho2mHI6fHgwQOjBk9aRHF47FzYWx0
diff --git a/ldap/clear.ldif b/ldap/clear.ldif
new file mode 100644
index 0000000..770ed02
--- /dev/null
+++ b/ldap/clear.ldif
@@ -0,0 +1,14 @@
+dn: cn=Lisa Simpson,dc=example,dc=com
+changetype: delete
+
+dn: cn=Bart Simpson,dc=example,dc=com
+changetype: delete
+
+dn: cn=Marge Simpson,dc=example,dc=com
+changetype: delete
+
+dn: cn=Homer Simpson,dc=example,dc=com
+changetype: delete
+
+dn: dc=example,dc=com
+changetype: delete
diff --git a/ldap/local.schema b/ldap/local.schema
new file mode 100644
index 0000000..95b2943
--- /dev/null
+++ b/ldap/local.schema
@@ -0,0 +1,27 @@
+
+attributetype ( 1.2.840.113556.1.4.656
+ NAME 'userPrincipalName'
+ EQUALITY caseIgnoreMatch
+ SUBSTR caseIgnoreSubstringsMatch
+ SYNTAX '1.3.6.1.4.1.1466.115.121.1.15'
+ SINGLE-VALUE )
+
+attributetype ( 1.2.840.113556.1.4.221
+ NAME 'sAMAccountName'
+ EQUALITY caseIgnoreMatch
+ SUBSTR caseIgnoreSubstringsMatch
+ SYNTAX '1.3.6.1.4.1.1466.115.121.1.15'
+ SINGLE-VALUE )
+
+attributetype ( 1.2.840.113556.1.4.8
+ NAME 'userAccountControl'
+ EQUALITY integerMatch
+ SYNTAX '1.3.6.1.4.1.1466.115.121.1.27'
+ SINGLE-VALUE )
+
+objectclass ( 1.2.840.113556.1.5.9
+ NAME 'user'
+ SUP organizationalPerson
+ STRUCTURAL
+ MUST ( sAMAccountName $ userAccountControl $ userPrincipalName )
+ MAY ( displayName $ givenName $ mail $ name ) )
diff --git a/ldap/run-server b/ldap/run-server
new file mode 100755
index 0000000..810f8e6
--- /dev/null
+++ b/ldap/run-server
@@ -0,0 +1,63 @@
+#!/usr/bin/env ruby
+
+require 'erb'
+require 'fileutils'
+require 'open3'
+
+FileUtils.chdir(File.dirname(__FILE__))
+
+## For OSX:
+ENV['PATH'] = "#{ENV['PATH']}:/usr/libexec"
+
+template = File.read('slapd-test.conf.erb')
+normal_out = 'slapd-test.conf'
+
+File.open(normal_out, 'w') do |f|
+ f.write ERB.new(template).result(binding)
+end
+
+cmd = "slapd -d 32768 -f #{normal_out} -h ldap://localhost:3268"
+
+started = false
+@slap_pid = nil
+slap_thread = Thread.new do
+ Thread.current.abort_on_exception = true
+ Open3.popen2(cmd) do |stdin, stdout, wait_thr|
+ @slap_pid = wait_thr.pid
+ stdin.close
+ begin
+ while data = stdout.readpartial(1024)
+ print data
+ end
+ rescue EOFError
+ # Ignore EOF
+ end
+ stdout.close
+ exit_status = wait_thr.value
+ end
+end
+
+def kill_slapd
+ if @slap_pid
+ Process.kill('TERM', @slap_pid) rescue nil
+ end
+end
+
+Signal.trap('INT') { kill_slapd }
+Signal.trap('TERM') { kill_slapd }
+
+begin
+ # TODO: Better test for slapd started
+ sleep 0.5
+
+ if slap_thread.alive?
+ ldap_connect_string = "-x -H ldap://localhost:3268 -D 'cn=admin,dc=example,dc=com' -w secret"
+ system("ldapmodify #{ldap_connect_string} -f clear.ldif")
+ system("ldapadd #{ldap_connect_string} -f base.ldif")
+ end
+
+ slap_thread.join
+ensure
+ kill_slapd
+end
+
diff --git a/ldap/slapd-test.conf.erb b/ldap/slapd-test.conf.erb
new file mode 100644
index 0000000..5c4f9ad
--- /dev/null
+++ b/ldap/slapd-test.conf.erb
@@ -0,0 +1,44 @@
+#
+# See slapd.conf(5) for details on configuration options.
+# This file should NOT be world readable.
+#
+<% ldapdir = RUBY_PLATFORM.match(/linux/) ? 'ldap' : 'openldap' %>
+include /etc/<%= ldapdir %>/schema/core.schema
+include /etc/<%= ldapdir %>/schema/cosine.schema
+include /etc/<%= ldapdir %>/schema/inetorgperson.schema
+#include /etc/<%= ldapdir %>/schema/nis.schema
+#include /etc/<%= ldapdir %>/schema/microsoft.std.schema
+#include /etc/<%= ldapdir %>/schema/microsoft.schema
+
+## Local definitions
+include <%= File.expand_path('local.schema', @conf_root) %>
+
+# Allow LDAPv2 client connections. This is NOT the default.
+allow bind_v2
+
+# Do not enable referrals until AFTER you have a working directory
+# service AND an understanding of referrals.
+#referral ldap://root.openldap.org
+
+pidfile <%= File.expand_path('openldap-data/run/slapd.pid', @conf_root) %>
+argsfile <%= File.expand_path('openldap-data/run/slapd.args', @conf_root) %>
+
+# Load dynamic backend modules:
+modulepath /usr/lib/openldap
+
+access to *
+ by self write
+ by * read
+ by anonymous auth
+
+#######################################################################
+# ldbm and/or bdb database definitions
+#######################################################################
+
+database ldif
+
+suffix "dc=example,dc=com"
+directory openldap-data
+rootdn "cn=admin,dc=example,dc=com"
+## rootpw = secret
+rootpw {SSHA}fFjKcZb4cfOAcwSjJer8nCGOEVRUnwCC
diff --git a/lib/assets/.keep b/lib/assets/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/lib/base_executor.rb b/lib/base_executor.rb
new file mode 100644
index 0000000..2af4854
--- /dev/null
+++ b/lib/base_executor.rb
@@ -0,0 +1,56 @@
+class BaseExecutor
+
+ # A list of errors that occurred while running this synchronizer
+ # @return [Array]
+ attr_reader :errors
+
+ # The number of threads to use when running.
+ # @return [Fixnum]
+ attr_accessor :thread_count
+
+ # Runs the executor.
+ #
+ # @return [BaseExecutor] instance of the executor that ran the task
+ def self.run!
+ new.tap { |instance| instance.run! }
+ end
+
+ def initialize
+ @thread_count = [5, ActiveRecord::Base.connection_pool.size - 1].min
+ @semaphore = Mutex.new
+ @errors = []
+ end
+
+ private
+
+ # Obtains a lock, runs the block, and releases the lock when the block completes.
+ #
+ # @see `Mutex#synchronize`
+ def synchronize(&block)
+ @semaphore.synchronize(&block)
+ end
+
+ # Runs the given block for each object in parallel. Up to
+ # {#thread_count} threads are used to run the block. This waits
+ # for all threads to complete before returning.
+ #
+ # @param objs [Enumerable