From 77386d48afd486d77942a598745cd28c312d9e42 Mon Sep 17 00:00:00 2001 From: Brandon Turner Date: Tue, 11 Nov 2014 00:53:11 -0600 Subject: [PATCH] Initial open source commit This squashes all previous commits in anticipation of open sourcing this repository. --- .gitignore | 13 + .rspec | 2 + .ruby-gemset | 1 + .ruby-version | 1 + .yardopts | 1 + CHANGELOG.md | 6 + Gemfile | 47 ++ Gemfile.lock | 280 +++++++++ LICENSE | 21 + Procfile | 3 + README.md | 130 ++++ Rakefile | 6 + app/assets/images/.keep | 0 app/assets/javascripts/application.js | 17 + app/assets/javascripts/time.js.coffee | 15 + app/assets/stylesheets/application.css.scss | 70 +++ app/assets/stylesheets/connect.css.scss | 37 ++ .../stylesheets/devise/sessions.css.scss | 45 ++ app/assets/stylesheets/users.css.scss | 9 + app/controllers/application_controller.rb | 24 + app/controllers/concerns/.keep | 0 .../concerns/github_oauth_concern.rb | 46 ++ .../concerns/github_settings_mixin.rb | 29 + app/controllers/concerns/settings_mixin.rb | 53 ++ app/controllers/concerns/setup_mixin.rb | 33 + app/controllers/connect_controller.rb | 58 ++ app/controllers/dashboard_controller.rb | 4 + app/controllers/github_users_controller.rb | 19 + app/controllers/settings_controller.rb | 24 + .../setup/admin_user_controller.rb | 24 + app/controllers/setup/company_controller.rb | 21 + app/controllers/setup/email_controller.rb | 26 + app/controllers/setup/github_controller.rb | 34 ++ app/controllers/setup/ldap_controller.rb | 45 ++ app/controllers/setup/rules_controller.rb | 31 + app/controllers/users_controller.rb | 36 ++ app/helpers/application_helper.rb | 26 + app/helpers/github_users_helper.rb | 13 + app/jobs/connect_github_user_job.rb | 64 ++ app/mailers/.keep | 0 app/mailers/user_mailer.rb | 7 + app/models/.keep | 0 app/models/concerns/.keep | 0 app/models/concerns/encryptable.rb | 37 ++ app/models/connect_github_user_status.rb | 58 ++ app/models/github_email.rb | 5 + app/models/github_team.rb | 93 +++ app/models/github_user.rb | 444 ++++++++++++++ app/models/setting.rb | 2 + app/models/user.rb | 210 +++++++ app/views/connect/_connect_step.html.erb | 18 + app/views/connect/index.html.erb | 159 +++++ app/views/dashboard/index.html.erb | 18 + app/views/devise/sessions/new.html.erb | 27 + app/views/devise/shared/_links.erb | 25 + app/views/github_users/index.html.erb | 29 + app/views/github_users/show.html.erb | 7 + app/views/layouts/application.html.erb | 101 ++++ app/views/settings/_active_directory.html.erb | 54 ++ app/views/settings/_company.html.erb | 7 + app/views/settings/_email.html.erb | 86 +++ app/views/settings/_github.html.erb | 61 ++ app/views/settings/_rules.html.erb | 63 ++ app/views/settings/edit.html.erb | 32 + app/views/setup/admin_user/new.html.erb | 24 + app/views/setup/company/edit.html.erb | 27 + app/views/setup/email/edit.html.erb | 23 + app/views/setup/github/edit.html.erb | 23 + app/views/setup/ldap/edit.html.erb | 23 + app/views/setup/rules/edit.html.erb | 23 + app/views/user_mailer/access_revoked.html.erb | 26 + app/views/user_mailer/access_revoked.text.erb | 17 + app/views/users/_github_user.html.erb | 60 ++ app/views/users/_github_users.html.erb | 8 + app/views/users/_ldap_user.html.erb | 32 + app/views/users/edit.html.erb | 28 + app/views/users/index.html.erb | 44 ++ app/views/users/show.html.erb | 21 + bin/bundle | 3 + bin/delayed_job | 5 + bin/rails | 4 + bin/rake | 4 + bin/setup | 29 + bin/spring | 18 + config.ru | 4 + config/application.rb | 35 ++ config/boot.rb | 3 + config/database.yml.example | 15 + config/environment.rb | 5 + config/environments/development.rb | 41 ++ config/environments/production.rb | 76 +++ config/environments/test.rb | 39 ++ config/initializers/action_mailer.rb | 13 + config/initializers/assets.rb | 11 + config/initializers/backtrace_silencers.rb | 7 + config/initializers/cookies_serializer.rb | 3 + config/initializers/devise.rb | 267 +++++++++ .../initializers/filter_parameter_logging.rb | 4 + config/initializers/inflections.rb | 16 + config/initializers/mime_types.rb | 4 + config/initializers/session_store.rb | 3 + config/initializers/state_machine_patch.rb | 26 + config/initializers/wrap_parameters.rb | 14 + config/locales/devise.en.yml | 59 ++ config/locales/en.yml | 23 + config/routes.rb | 43 ++ config/secrets.yml.example | 23 + cookbook/.kitchen.yml | 38 ++ cookbook/Berksfile | 10 + cookbook/Berksfile.lock | 67 +++ cookbook/Gemfile | 5 + cookbook/Gemfile.lock | 106 ++++ cookbook/README.md | 13 + cookbook/attributes/database.rb | 4 + cookbook/attributes/default.rb | 14 + cookbook/attributes/nginx.rb | 11 + cookbook/attributes/ruby.rb | 8 + cookbook/attributes/ssh.rb | 7 + .../libraries/github_connector_helpers.rb | 55 ++ cookbook/metadata.rb | 19 + cookbook/recipes/cron.rb | 26 + cookbook/recipes/database.rb | 43 ++ cookbook/recipes/default.rb | 28 + cookbook/recipes/nginx.rb | 65 ++ cookbook/recipes/ruby.rb | 51 ++ cookbook/recipes/server.rb | 139 +++++ cookbook/recipes/ssh.rb | 65 ++ cookbook/recipes/upstart.rb | 54 ++ cookbook/recipes/user.rb | 27 + cookbook/templates/default/database.yml.erb | 22 + .../default/nginx-github-connector.conf.erb | 50 ++ cookbook/templates/default/secrets.yml.erb | 26 + .../upstart-github-connector-web.conf.erb | 14 + .../upstart-github-connector-worker.conf.erb | 13 + .../github_connector/secrets.json | 6 + .../test_data_bags/github_connector/ssh.json | 4 + .../github_connector/ssl_cert.json | 5 + .../20140619160007_devise_create_users.rb | 38 ++ ...20140624041139_add_github_attrs_to_user.rb | 6 + db/migrate/20140626181353_create_settings.rb | 11 + db/migrate/20140708224056_create_emails.rb | 23 + .../20140709045852_add_last_sync_to_user.rb | 6 + .../20140709191104_add_state_attrs_to_user.rb | 7 + .../20140714210644_add_sync_errors_to_user.rb | 8 + db/migrate/20140722192112_add_github_teams.rb | 15 + .../20140724141457_refactor_github_tables.rb | 113 ++++ ...0140726214806_move_state_to_github_user.rb | 6 + db/migrate/20140811194159_add_github_urls.rb | 6 + .../20140818012538_add_admin_flag_to_user.rb | 14 + ...15164525_convert_settings_value_to_text.rb | 5 + .../20140917184213_create_delayed_jobs.rb | 22 + ...184236_add_connect_github_user_statuses.rb | 13 + ...140920200517_add_remember_token_to_user.rb | 5 + ...18212156_add_github_user_disabled_teams.rb | 8 + db/schema.rb | 122 ++++ db/seeds.rb | 7 + ldap/README.md | 42 ++ ldap/base.ldif | 74 +++ ldap/clear.ldif | 14 + ldap/local.schema | 27 + ldap/run-server | 63 ++ ldap/slapd-test.conf.erb | 44 ++ lib/assets/.keep | 0 lib/base_executor.rb | 56 ++ lib/github_admin.rb | 161 +++++ lib/github_synchronizer.rb | 179 ++++++ lib/ldap_synchronizer.rb | 76 +++ lib/rules.rb | 99 +++ lib/rules/active_ldap.rb | 51 ++ lib/rules/base.rb | 75 +++ lib/rules/email.rb | 51 ++ lib/rules/github_mfa.rb | 23 + lib/rules/github_oauth.rb | 37 ++ lib/rules/last_github_sync.rb | 41 ++ lib/rules/last_ldap_sync.rb | 51 ++ lib/settings.rb | 210 +++++++ lib/settings/base.rb | 219 +++++++ lib/settings/definition.rb | 66 ++ lib/tasks/.keep | 0 lib/tasks/github.rake | 53 ++ lib/tasks/sync.rake | 36 ++ lib/transition_github_users.rb | 207 +++++++ log/.keep | 0 public/404.html | 67 +++ public/422.html | 67 +++ public/500.html | 66 ++ public/robots.txt | 5 + spec/controllers/connect_controller_spec.rb | 120 ++++ spec/controllers/dashboard_controller_spec.rb | 28 + .../github_users_controller_spec.rb | 26 + spec/controllers/settings_controller_spec.rb | 138 +++++ .../setup/admin_user_controller_spec.rb | 34 ++ .../setup/company_controller_spec.rb | 21 + .../setup/email_controller_spec.rb | 34 ++ .../setup/github_controller_spec.rb | 41 ++ .../controllers/setup/ldap_controller_spec.rb | 43 ++ .../setup/rules_controller_spec.rb | 21 + spec/controllers/users_controller_spec.rb | 69 +++ spec/factories/github_email.rb | 6 + spec/factories/github_team.rb | 5 + spec/factories/github_user.rb | 19 + spec/factories/user.rb | 15 + spec/helpers/application_helper_spec.rb | 10 + spec/helpers/github_users_helper_spec.rb | 37 ++ spec/jobs/connect_github_user_job_spec.rb | 60 ++ spec/lib/github_admin_spec.rb | 137 +++++ spec/lib/github_synchronizer_spec.rb | 183 ++++++ spec/lib/ldap_synchronizer_spec.rb | 55 ++ spec/lib/rules/active_ldap_spec.rb | 53 ++ spec/lib/rules/base_spec.rb | 39 ++ spec/lib/rules/email_spec.rb | 56 ++ spec/lib/rules/github_mfa_spec.rb | 36 ++ spec/lib/rules/github_oauth_spec.rb | 53 ++ spec/lib/rules/last_github_sync_spec.rb | 60 ++ spec/lib/rules/last_ldap_sync_spec.rb | 65 ++ spec/lib/rules_spec.rb | 60 ++ spec/lib/settings/base_spec.rb | 206 +++++++ spec/lib/settings_spec.rb | 75 +++ spec/lib/transition_github_users_spec.rb | 183 ++++++ spec/mailers/user_mailer_spec.rb | 29 + .../models/connect_github_user_status_spec.rb | 39 ++ spec/models/github_team_spec.rb | 83 +++ spec/models/github_user_spec.rb | 562 ++++++++++++++++++ spec/models/setting_spec.rb | 4 + spec/models/user_spec.rb | 135 +++++ spec/rails_helper.rb | 61 ++ spec/spec_helper.rb | 77 +++ spec/support/controller_helpers.rb | 10 + spec/views/connect/index.html.erb_spec.rb | 15 + .../layouts/application.html.erb_spec.rb | 27 + spec/views/settings/edit.html.erb_spec.rb | 42 ++ vendor/assets/javascripts/.keep | 0 vendor/assets/stylesheets/.keep | 0 233 files changed, 10695 insertions(+) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .ruby-gemset create mode 100644 .ruby-version create mode 100644 .yardopts create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 Procfile create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/images/.keep create mode 100644 app/assets/javascripts/application.js create mode 100644 app/assets/javascripts/time.js.coffee create mode 100644 app/assets/stylesheets/application.css.scss create mode 100644 app/assets/stylesheets/connect.css.scss create mode 100644 app/assets/stylesheets/devise/sessions.css.scss create mode 100644 app/assets/stylesheets/users.css.scss create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/github_oauth_concern.rb create mode 100644 app/controllers/concerns/github_settings_mixin.rb create mode 100644 app/controllers/concerns/settings_mixin.rb create mode 100644 app/controllers/concerns/setup_mixin.rb create mode 100644 app/controllers/connect_controller.rb create mode 100644 app/controllers/dashboard_controller.rb create mode 100644 app/controllers/github_users_controller.rb create mode 100644 app/controllers/settings_controller.rb create mode 100644 app/controllers/setup/admin_user_controller.rb create mode 100644 app/controllers/setup/company_controller.rb create mode 100644 app/controllers/setup/email_controller.rb create mode 100644 app/controllers/setup/github_controller.rb create mode 100644 app/controllers/setup/ldap_controller.rb create mode 100644 app/controllers/setup/rules_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/helpers/github_users_helper.rb create mode 100644 app/jobs/connect_github_user_job.rb create mode 100644 app/mailers/.keep create mode 100644 app/mailers/user_mailer.rb create mode 100644 app/models/.keep create mode 100644 app/models/concerns/.keep create mode 100644 app/models/concerns/encryptable.rb create mode 100644 app/models/connect_github_user_status.rb create mode 100644 app/models/github_email.rb create mode 100644 app/models/github_team.rb create mode 100644 app/models/github_user.rb create mode 100644 app/models/setting.rb create mode 100644 app/models/user.rb create mode 100644 app/views/connect/_connect_step.html.erb create mode 100644 app/views/connect/index.html.erb create mode 100644 app/views/dashboard/index.html.erb create mode 100644 app/views/devise/sessions/new.html.erb create mode 100644 app/views/devise/shared/_links.erb create mode 100644 app/views/github_users/index.html.erb create mode 100644 app/views/github_users/show.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/settings/_active_directory.html.erb create mode 100644 app/views/settings/_company.html.erb create mode 100644 app/views/settings/_email.html.erb create mode 100644 app/views/settings/_github.html.erb create mode 100644 app/views/settings/_rules.html.erb create mode 100644 app/views/settings/edit.html.erb create mode 100644 app/views/setup/admin_user/new.html.erb create mode 100644 app/views/setup/company/edit.html.erb create mode 100644 app/views/setup/email/edit.html.erb create mode 100644 app/views/setup/github/edit.html.erb create mode 100644 app/views/setup/ldap/edit.html.erb create mode 100644 app/views/setup/rules/edit.html.erb create mode 100644 app/views/user_mailer/access_revoked.html.erb create mode 100644 app/views/user_mailer/access_revoked.text.erb create mode 100644 app/views/users/_github_user.html.erb create mode 100644 app/views/users/_github_users.html.erb create mode 100644 app/views/users/_ldap_user.html.erb create mode 100644 app/views/users/edit.html.erb create mode 100644 app/views/users/index.html.erb create mode 100644 app/views/users/show.html.erb create mode 100755 bin/bundle create mode 100755 bin/delayed_job create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/setup create mode 100755 bin/spring create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/database.yml.example create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/action_mailer.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/backtrace_silencers.rb create mode 100644 config/initializers/cookies_serializer.rb create mode 100644 config/initializers/devise.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/mime_types.rb create mode 100644 config/initializers/session_store.rb create mode 100644 config/initializers/state_machine_patch.rb create mode 100644 config/initializers/wrap_parameters.rb create mode 100644 config/locales/devise.en.yml create mode 100644 config/locales/en.yml create mode 100644 config/routes.rb create mode 100644 config/secrets.yml.example create mode 100644 cookbook/.kitchen.yml create mode 100644 cookbook/Berksfile create mode 100644 cookbook/Berksfile.lock create mode 100644 cookbook/Gemfile create mode 100644 cookbook/Gemfile.lock create mode 100644 cookbook/README.md create mode 100644 cookbook/attributes/database.rb create mode 100644 cookbook/attributes/default.rb create mode 100644 cookbook/attributes/nginx.rb create mode 100644 cookbook/attributes/ruby.rb create mode 100644 cookbook/attributes/ssh.rb create mode 100644 cookbook/libraries/github_connector_helpers.rb create mode 100644 cookbook/metadata.rb create mode 100644 cookbook/recipes/cron.rb create mode 100644 cookbook/recipes/database.rb create mode 100644 cookbook/recipes/default.rb create mode 100644 cookbook/recipes/nginx.rb create mode 100644 cookbook/recipes/ruby.rb create mode 100644 cookbook/recipes/server.rb create mode 100644 cookbook/recipes/ssh.rb create mode 100644 cookbook/recipes/upstart.rb create mode 100644 cookbook/recipes/user.rb create mode 100644 cookbook/templates/default/database.yml.erb create mode 100644 cookbook/templates/default/nginx-github-connector.conf.erb create mode 100644 cookbook/templates/default/secrets.yml.erb create mode 100644 cookbook/templates/default/upstart-github-connector-web.conf.erb create mode 100644 cookbook/templates/default/upstart-github-connector-worker.conf.erb create mode 100644 cookbook/test_data_bags/github_connector/secrets.json create mode 100644 cookbook/test_data_bags/github_connector/ssh.json create mode 100644 cookbook/test_data_bags/github_connector/ssl_cert.json create mode 100644 db/migrate/20140619160007_devise_create_users.rb create mode 100644 db/migrate/20140624041139_add_github_attrs_to_user.rb create mode 100644 db/migrate/20140626181353_create_settings.rb create mode 100644 db/migrate/20140708224056_create_emails.rb create mode 100644 db/migrate/20140709045852_add_last_sync_to_user.rb create mode 100644 db/migrate/20140709191104_add_state_attrs_to_user.rb create mode 100644 db/migrate/20140714210644_add_sync_errors_to_user.rb create mode 100644 db/migrate/20140722192112_add_github_teams.rb create mode 100644 db/migrate/20140724141457_refactor_github_tables.rb create mode 100644 db/migrate/20140726214806_move_state_to_github_user.rb create mode 100644 db/migrate/20140811194159_add_github_urls.rb create mode 100644 db/migrate/20140818012538_add_admin_flag_to_user.rb create mode 100644 db/migrate/20140915164525_convert_settings_value_to_text.rb create mode 100644 db/migrate/20140917184213_create_delayed_jobs.rb create mode 100644 db/migrate/20140917184236_add_connect_github_user_statuses.rb create mode 100644 db/migrate/20140920200517_add_remember_token_to_user.rb create mode 100644 db/migrate/20141018212156_add_github_user_disabled_teams.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 ldap/README.md create mode 100644 ldap/base.ldif create mode 100644 ldap/clear.ldif create mode 100644 ldap/local.schema create mode 100755 ldap/run-server create mode 100644 ldap/slapd-test.conf.erb create mode 100644 lib/assets/.keep create mode 100644 lib/base_executor.rb create mode 100644 lib/github_admin.rb create mode 100644 lib/github_synchronizer.rb create mode 100644 lib/ldap_synchronizer.rb create mode 100644 lib/rules.rb create mode 100644 lib/rules/active_ldap.rb create mode 100644 lib/rules/base.rb create mode 100644 lib/rules/email.rb create mode 100644 lib/rules/github_mfa.rb create mode 100644 lib/rules/github_oauth.rb create mode 100644 lib/rules/last_github_sync.rb create mode 100644 lib/rules/last_ldap_sync.rb create mode 100644 lib/settings.rb create mode 100644 lib/settings/base.rb create mode 100644 lib/settings/definition.rb create mode 100644 lib/tasks/.keep create mode 100644 lib/tasks/github.rake create mode 100644 lib/tasks/sync.rake create mode 100644 lib/transition_github_users.rb create mode 100644 log/.keep create mode 100644 public/404.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/robots.txt create mode 100644 spec/controllers/connect_controller_spec.rb create mode 100644 spec/controllers/dashboard_controller_spec.rb create mode 100644 spec/controllers/github_users_controller_spec.rb create mode 100644 spec/controllers/settings_controller_spec.rb create mode 100644 spec/controllers/setup/admin_user_controller_spec.rb create mode 100644 spec/controllers/setup/company_controller_spec.rb create mode 100644 spec/controllers/setup/email_controller_spec.rb create mode 100644 spec/controllers/setup/github_controller_spec.rb create mode 100644 spec/controllers/setup/ldap_controller_spec.rb create mode 100644 spec/controllers/setup/rules_controller_spec.rb create mode 100644 spec/controllers/users_controller_spec.rb create mode 100644 spec/factories/github_email.rb create mode 100644 spec/factories/github_team.rb create mode 100644 spec/factories/github_user.rb create mode 100644 spec/factories/user.rb create mode 100644 spec/helpers/application_helper_spec.rb create mode 100644 spec/helpers/github_users_helper_spec.rb create mode 100644 spec/jobs/connect_github_user_job_spec.rb create mode 100644 spec/lib/github_admin_spec.rb create mode 100644 spec/lib/github_synchronizer_spec.rb create mode 100644 spec/lib/ldap_synchronizer_spec.rb create mode 100644 spec/lib/rules/active_ldap_spec.rb create mode 100644 spec/lib/rules/base_spec.rb create mode 100644 spec/lib/rules/email_spec.rb create mode 100644 spec/lib/rules/github_mfa_spec.rb create mode 100644 spec/lib/rules/github_oauth_spec.rb create mode 100644 spec/lib/rules/last_github_sync_spec.rb create mode 100644 spec/lib/rules/last_ldap_sync_spec.rb create mode 100644 spec/lib/rules_spec.rb create mode 100644 spec/lib/settings/base_spec.rb create mode 100644 spec/lib/settings_spec.rb create mode 100644 spec/lib/transition_github_users_spec.rb create mode 100644 spec/mailers/user_mailer_spec.rb create mode 100644 spec/models/connect_github_user_status_spec.rb create mode 100644 spec/models/github_team_spec.rb create mode 100644 spec/models/github_user_spec.rb create mode 100644 spec/models/setting_spec.rb create mode 100644 spec/models/user_spec.rb create mode 100644 spec/rails_helper.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/controller_helpers.rb create mode 100644 spec/views/connect/index.html.erb_spec.rb create mode 100644 spec/views/layouts/application.html.erb_spec.rb create mode 100644 spec/views/settings/edit.html.erb_spec.rb create mode 100644 vendor/assets/javascripts/.keep create mode 100644 vendor/assets/stylesheets/.keep 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.

    + +

    To get started, follow these steps:

    + + +
    + +
      + + <%= render layout: 'connect_step', locals: {step: :create} do %> +

      Step 1: Create GitHub account

      +

      Create a free GitHub.com account if you don't already have one.

      + <% if settings.github_user_requirements %> +

      Your GitHub account must meet the following requirements:

      +
        + <% settings.github_user_requirements.each do |req| %> +
      • <%=raw Sanitize.fragment(req, Sanitize::Config::BASIC) %>
      • + <% end %> +
      + <% 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. +

      + + <% if @connect_status.step == :request %> +

      <%= link_to 'Request Permissions', {action: 'start'}, class: 'btn btn-primary' %>

      + <% end %> + <% end %> + + + <%= render layout: 'connect_step', locals: {step: :grant} do %> +

      Step 3: Grant GitHub permissions

      + + <% if @connect_status.step_error?(:grant) %> +
      +

      An error occurred while authenticating with GitHub. Please <%= link_to "try again", {action: 'start'} %>.

      + <% if @connect_status.error_message %> +

      <%= @connect_status.error_message %>

      + <% end %> +
      + <% elsif @connect_status.step == :grant && @connect_status.status == :queued %> +
      +

      Waiting for an available GitHub worker...

      +
      + <% elsif @connect_status.step == :grant && @connect_status.in_progress? %> +
      +

      Authenticating with GitHub...

      +
      + <% elsif @connect_status.step_complete?(:grant) && @connect_status.github_user %> +
      +

      Authenticated as <%= @connect_status.github_user.login %>.

      +
      + <% end %> + +

      On the GitHub page, click the Authorize application button. GitHub will return you to the GitHub Connector to complete the process.

      + <% end %> + + + <%= render layout: 'connect_step', locals: {step: :add} do %> +

      Step 4: Add to<%= " #{settings.company}" if settings.company.present? %> <%= "organization".pluralize((settings.github_orgs || []).count) %>

      + + <% if @connect_status.step_error?(:add) %> +
      + <% if @connect_status.github_user %> +

      Sorry, your GitHub account cannot be added due to the following <%= "error".pluralize(@connect_status.github_user.failing_rules.count) %>:

      +
        + <% @connect_status.github_user.failing_rules.each do |rule| %> +
      • <%= rule.error_msg %>
      • + <% end %> +
      +

      Please correct these errors and <%= link_to "try again", {action: 'start'} %>.

      + <% elsif @connect_status.error_message %> +

      <%= @connect_status.error_message %>

      + <% else %> +

      An unknown error occurred. Please <%= link_to "try again", {action: 'start'} %> again.

      + <% end %> +
      + <% elsif @connect_status.step == :add && @connect_status.status == :running %> +
      +

      Synchronizing<%= " #{settings.company}" if settings.company.present? %> <%= "organization".pluralize((settings.github_orgs || []).count) %>...

      +
      + <% elsif @connect_status.step_complete?(:add) %> +
      +

      You GitHub user, <%= @connect_status.github_user.login %>, is a member of the following organizations:

      +
        + <% @connect_status.github_user.organizations.each do |github_org| %> +
      • <%= github_org %>
      • + <% end %> +
      +
      + <% end %> + +

      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

      + + <% if @connect_status.step == :teams && @connect_status.github_user %> +
      +

      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| %> +
      • <%= link_to team.full_slug, "https://github.com/orgs/#{team.organization}/teams/#{team.slug}/repositories" %>
      • + <% end %> +
      +
      + +

      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| %> + +

    Sign in using your active directory credentials.

    + + <%= f.text_field :username, + autofocus: true, + placeholder: "Username or email address", + required: true, + class: 'form-control' %> + <%= f.password_field :password, + autocomplete: 'off', + placeholder: "Password", + class: 'form-control' %> + + <% if devise_mapping.rememberable? -%> +
    + <%= f.label :remember_me do %> + <%= f.check_box :remember_me %>Remember me? + <% end %> +
    + <% end -%> + + <%= f.submit "Sign in", class: 'btn btn-lg btn-primary btn-block' %> +<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/shared/_links.erb b/app/views/devise/shared/_links.erb new file mode 100644 index 0000000..d84bdde --- /dev/null +++ b/app/views/devise/shared/_links.erb @@ -0,0 +1,25 @@ +<%- if controller_name != 'sessions' %> + <%= link_to "Sign in", new_session_path(resource_name) %>
    +<% end -%> + +<%- if devise_mapping.registerable? && controller_name != 'registrations' %> + <%= link_to "Sign up", new_registration_path(resource_name) %>
    +<% end -%> + +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> + <%= link_to "Forgot your password?", new_password_path(resource_name) %>
    +<% end -%> + +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> + <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
    +<% end -%> + +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> + <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
    +<% end -%> + +<%- if devise_mapping.omniauthable? %> + <%- resource_class.omniauth_providers.each do |provider| %> + <%= link_to "Sign in with #{provider.to_s.titleize}", omniauth_authorize_path(resource_name, provider) %>
    + <% end -%> +<% end -%> diff --git a/app/views/github_users/index.html.erb b/app/views/github_users/index.html.erb new file mode 100644 index 0000000..4d8e968 --- /dev/null +++ b/app/views/github_users/index.html.erb @@ -0,0 +1,29 @@ +<% title "GitHub Users" %> +<% nav_section :github_users %> +<% jumbotron do %> +

    GitHub Users

    +

    GitHub users associated with <%= settings.company.present? ? settings.company : 'our' %> GitHub organizations

    +<% end %> + + + + + + + + + + + <% @github_users.each do |ghuser| %> + + + + + <% end %> + +
    GitHub AccountActive Directory UserState
    <%= link_to(ghuser.login, ghuser) %> + <% if ghuser.user %> + <%= link_to(ghuser.user.name, ghuser.user) %> + <% end %> + <%= github_user_state_label(ghuser) %> +
    diff --git a/app/views/github_users/show.html.erb b/app/views/github_users/show.html.erb new file mode 100644 index 0000000..bb22cdd --- /dev/null +++ b/app/views/github_users/show.html.erb @@ -0,0 +1,7 @@ +<% title @github_user.login %> +<% nav_section :github_users %> +<% jumbotron do %> +

    <%= @github_user.login %>

    +<% end %> + +<%= render partial: 'users/github_user', locals: {github_user: @github_user} %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..09adbb4 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,101 @@ + + + + + + + + <% if content_for?(:title) -%> + <%= yield :title %> - GitHub Connector + <% else -%> + GitHub Connector + <% end -%> + + <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> + <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> + <%= csrf_meta_tags %> + + + + +<% nav_section = yield(:nav_section).to_sym %> + + +<% if content_for?(:jumbotron) %> +
    +
    + <%= yield :jumbotron %> +
    +
    +<% end %> + +
    +<% if notice && !notice.empty? %>

    <%= notice %>

    <% end %> +<% if alert && !alert.empty? %>

    <%= alert %>

    <% end %> +<%= yield %> +
    + + + diff --git a/app/views/settings/_active_directory.html.erb b/app/views/settings/_active_directory.html.erb new file mode 100644 index 0000000..f2ff39e --- /dev/null +++ b/app/views/settings/_active_directory.html.erb @@ -0,0 +1,54 @@ +
    + <%= label_tag(:settings_ldap_host, 'Host', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :ldap_host, class: 'form-control') %> + Hostname of the server running LDAP. +
    +
    + +
    + <%= label_tag(:settings_ldap_port, 'Port', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :ldap_port, class: 'form-control') %> +
    + <%= label_tag(:settings_ldap_ssl) do %> + <%= check_box(:settings, :ldap_ssl) %> Use SSL? + <% end %> +
    +
    +
    + +
    + <%= label_tag(:settings_ldap_admin_user, 'User', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :ldap_admin_user, class: 'form-control') %> + User to log in to LDAP. Example: cn=foouser,dc=example,dc=com. +
    +
    + +
    + <%= label_tag(:settings_ldap_admin_password, 'Password', class: 'control-label col-sm-2') %> +
    + <%= password_field(:settings, + :ldap_admin_password, + value: controller.scrub_password(:ldap_admin_password), + class: 'form-control') %> +
    +
    + +
    + <%= label_tag(:settings_ldap_base, 'Base', class: 'control-label col-sm-2') %> +
    + <%= 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. +
    +
    + +
    + <%= label_tag(:settings_ldap_attribute, 'Attribute', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :ldap_attribute, class: 'form-control') %> + LDAP username attribute. +
    +
    + diff --git a/app/views/settings/_company.html.erb b/app/views/settings/_company.html.erb new file mode 100644 index 0000000..e0757a0 --- /dev/null +++ b/app/views/settings/_company.html.erb @@ -0,0 +1,7 @@ +
    + <%= label_tag(:settings_company, 'Company', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :company, class: 'form-control') %> + The company name to display. +
    +
    diff --git a/app/views/settings/_email.html.erb b/app/views/settings/_email.html.erb new file mode 100644 index 0000000..bd4da74 --- /dev/null +++ b/app/views/settings/_email.html.erb @@ -0,0 +1,86 @@ +
    + <%= label_tag(:settings_email_base_url, 'Base URL', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :email_base_url, class: 'form-control') %> + The base URL used for links in emails. +
    +
    + +
    + <%= label_tag(:settings_email_from, 'From', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :email_from, class: 'form-control') %> + The from address for emails. +
    +
    + +
    + <%= label_tag(:settings_email_reply_to, 'Reply-To', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :email_reply_to, class: 'form-control') %> + The Reply-To address for emails. +
    +
    + +
    + <%= label_tag(:settings_smtp_address, 'Hostname', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :smtp_address, class: 'form-control') %> + SMTP server hostname +
    +
    + +
    + <%= label_tag(:settings_smtp_port, 'Port', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :smtp_port, class: 'form-control') %> + SMTP server port +
    +
    + +
    + <%= label_tag(:settings_smtp_enable_starttls_auto, 'StartTLS?', class: 'control-label col-sm-2') %> +
    +
    + <%= label_tag(:settings_smtp_enable_starttls_auto) do %> + <%= check_box(:settings, :smtp_enable_starttls_auto) %> Use StartTLS? + <% end %> +
    + If checked, use StartTLS. Uncheck this if there is a problem with your server certificate that you cannot resolve. +
    +
    + +
    + <%= label_tag(:settings_smtp_user_name, 'Username', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :smtp_user_name, class: 'form-control') %> + SMTP username +
    +
    + +
    + <%= label_tag(:settings_smtp_password, 'Password', class: 'control-label col-sm-2') %> +
    + <%= password_field(:settings, + :smtp_password, + value: (settings.smtp_password.blank? ? nil : controller.scrub_password(:smtp_password)), + class: 'form-control') %> + SMTP password +
    +
    + +
    + <%= label_tag(:settings_smtp_authentication, 'Authentication', class: 'control-label col-sm-2') %> +
    + <%= select(:settings, :smtp_authentication, %w(plain login cram_md5), {include_blank: true}, class: 'form-control') %> + SMTP authentication type +
    +
    + +
    + <%= label_tag(:settings_smtp_domain, 'Domain', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :smtp_domain, class: 'form-control') %> + SMTP HELO domain. +
    +
    diff --git a/app/views/settings/_github.html.erb b/app/views/settings/_github.html.erb new file mode 100644 index 0000000..90d752b --- /dev/null +++ b/app/views/settings/_github.html.erb @@ -0,0 +1,61 @@ +
    + <%= label_tag(:settings_github_client_id, 'Client ID', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :github_client_id, class: 'form-control') %> + GitHub OAuth application client ID. +
    +
    + +
    + <%= label_tag(:settings_github_client_secret, 'Client secret', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :github_client_secret, class: 'form-control') %> + GitHub OAuth application client secret. +
    +
    + +
    + <%= label_tag(:settings_github_admin_token, 'Admin token', class: 'control-label col-sm-2') %> +
    +
    + <%= password_field(:settings, + :github_admin_token, + value: @settings.github_admin_token ? controller.scrub_password(:github_admin_token) : nil, + class: 'form-control') %> + + <%= button_tag "Connect to my GitHub", class: 'btn btn-default', name: 'connect_github', value: 'connect' %> + +
    + A GitHub user OAuth token used for synchronizing organizations. The connect button will authorize your GitHub account. +
    +
    + +
    + <%= label_tag(:settings_github_orgs, 'Organizations', class: 'control-label col-sm-2') %> +
    + <%= text_area(:settings, + :github_orgs, + value: @settings.github_orgs ? @settings.github_orgs.join("\r\n") : nil, + class: 'form-control') %> + GitHub organizations, one slug per line, to manage with this connector. +
    +
    + +
    + <%= label_tag(:settings_github_check_mfa_team, '2FA team', class: 'control-label col-sm-2') %> +
    + <%= 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_github_default_teams, 'Default teams', class: 'control-label col-sm-2') %> +
    + <%= text_area(:settings, + :github_default_teams, + value: @settings.github_default_teams ? @settings.github_default_teams.join("\r\n") : nil, + class: 'form-control') %> + GitHub teams, one slug per line, all users should belong to. +
    +
    diff --git a/app/views/settings/_rules.html.erb b/app/views/settings/_rules.html.erb new file mode 100644 index 0000000..5165187 --- /dev/null +++ b/app/views/settings/_rules.html.erb @@ -0,0 +1,63 @@ +
    + <%= label_tag(:settings_enforce_rules, 'Enforce', class: 'control-label col-sm-2') %> +
    +
    + <%= 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. +
    +
    + +
    + <%= label_tag(:settings_github_user_requirements, 'User requirements', class: 'control-label col-sm-2') %> +
    + <%= 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. +
    +
    + +
    + <%= label_tag(:settings_rule_email_regex, 'Email regex', class: 'control-label col-sm-2') %> +
    + <%= text_field(:settings, :rule_email_regex, class: 'form-control') %> + Regular expression used to validate GitHub email addresses. +
    +
    + +
    + <%= label_tag(:settings_rule_max_sync_age, 'Max sync age', class: 'control-label col-sm-2') %> +
    +
    + <%= text_field(:settings, :rule_max_sync_age, class: 'form-control') %> + seconds +
    + Max time since last successful GitHub and Active Directory synchronization before disabling users. +
    +
    + +
    + <%= label_tag(:settings_github_exclude_users, 'Exclude Users', class: 'control-label col-sm-2') %> +
    + <%= text_area(:settings, + :github_exclude_users, + value: @settings.github_exclude_users ? @settings.github_exclude_users.join("\r\n") : nil, + class: 'form-control') %> + GitHub users, one username per line, to exclude when removing users. +
    +
    + +
    + <%= label_tag(:settings_github_external_teams, 'External Teams', class: 'control-label col-sm-2') %> +
    + <%= text_area(:settings, + :github_external_teams, + value: @settings.github_external_teams ? @settings.github_external_teams.join("\r\n") : nil, + class: 'form-control') %> + GitHub teams, one slug per line, that allow external users. +
    +
    diff --git a/app/views/settings/edit.html.erb b/app/views/settings/edit.html.erb new file mode 100644 index 0000000..5b888d6 --- /dev/null +++ b/app/views/settings/edit.html.erb @@ -0,0 +1,32 @@ +<% title "Settings" %> +<% nav_section :settings %> +<% jumbotron do %> +

    Settings

    +

    Active Directory, GitHub and application settings

    +<% end %> + +<%= form_tag({action: :update}, method: :put, class: 'form-horizontal max-col-sm') do %> + <% if @error %> +
    <%= @error %>
    + <% end %> + + <%= render 'company' %> + +

    Active Directory

    + <%= render 'active_directory' %> + +

    GitHub

    + <%= render 'github' %> + +

    Rules

    + <%= render 'rules' %> + +

    Email

    + <%= render 'email' %> + +
    +
    + <%= submit_tag('Save', class: 'btn btn-default btn-primary') %> +
    +
    +<% end %> diff --git a/app/views/setup/admin_user/new.html.erb b/app/views/setup/admin_user/new.html.erb new file mode 100644 index 0000000..5d02eb7 --- /dev/null +++ b/app/views/setup/admin_user/new.html.erb @@ -0,0 +1,24 @@ +<% title "Create admin user - Setup Wizard" %> +<% nav_section :settings %> +<% jumbotron do %> +

    Setup Wizard

    +

    Step 3: Create initial admin user

    +<% end %> + + +

    Create the initial admin user by signing in with your Active Directory credentials. This user will become an administrator for the GitHub Connector.

    + +<%= form_for(resource, as: resource_name, url: setup_admin_path, html: {class: 'form-signin left'}) do |f| %> + + <%= f.text_field :username, + autofocus: true, + placeholder: "Username or email address", + required: true, + class: 'form-control' %> + <%= f.password_field :password, + autocomplete: 'off', + placeholder: "Password", + class: 'form-control' %> + + <%= f.submit "Create admin user", class: 'btn btn-lg btn-primary btn-block' %> +<% end %> diff --git a/app/views/setup/company/edit.html.erb b/app/views/setup/company/edit.html.erb new file mode 100644 index 0000000..5562f11 --- /dev/null +++ b/app/views/setup/company/edit.html.erb @@ -0,0 +1,27 @@ +<% title "Setup Wizard" %> +<% nav_section :settings %> +<% jumbotron do %> +

    Setup Wizard

    +

    Step 1: Company settings

    +<% end %> + +

    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 %> +
    <%= @error %>
    + <% end %> + +

    Company

    + <%= render 'settings/company' %> + +
    +
    + <%= submit_tag('Next', class: 'btn btn-default btn-primary') %> +
    +
    +<% end %> diff --git a/app/views/setup/email/edit.html.erb b/app/views/setup/email/edit.html.erb new file mode 100644 index 0000000..e527508 --- /dev/null +++ b/app/views/setup/email/edit.html.erb @@ -0,0 +1,23 @@ +<% title "Setup Wizard" %> +<% nav_section :settings %> +<% jumbotron do %> +

    Setup Wizard

    +

    Step 5: Email settings

    +<% end %> + +

    The Github Connector sends emails when disabling Github access. Configure your SMTP settings below.

    + +<%= form_tag({action: :update}, method: :put, class: 'form-horizontal max-col-sm') do %> + <% if @error %> +
    <%= @error %>
    + <% end %> + +

    Email

    + <%= render 'settings/email' %> + +
    +
    + <%= submit_tag('Next', class: 'btn btn-default btn-primary') %> +
    +
    +<% end %> diff --git a/app/views/setup/github/edit.html.erb b/app/views/setup/github/edit.html.erb new file mode 100644 index 0000000..068ccbb --- /dev/null +++ b/app/views/setup/github/edit.html.erb @@ -0,0 +1,23 @@ +<% title "Setup Wizard" %> +<% nav_section :settings %> +<% jumbotron do %> +

    Setup Wizard

    +

    Step 4: Github settings

    +<% end %> + +

    Enter information about your Github.com organization(s) below.

    + +<%= form_tag({action: :update}, method: :put, class: 'form-horizontal max-col-sm') do %> + <% if @error %> +
    <%= @error %>
    + <% end %> + +

    Github

    + <%= render 'settings/github' %> + +
    +
    + <%= submit_tag('Next', class: 'btn btn-default btn-primary') %> +
    +
    +<% 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 %> +
    <%= @error %>
    + <% end %> + +

    Active Directory

    + <%= render 'settings/active_directory' %> + +
    +
    + <%= submit_tag('Test and Next', class: 'btn btn-default btn-primary') %> +
    +
    +<% end %> diff --git a/app/views/setup/rules/edit.html.erb b/app/views/setup/rules/edit.html.erb new file mode 100644 index 0000000..86458ca --- /dev/null +++ b/app/views/setup/rules/edit.html.erb @@ -0,0 +1,23 @@ +<% title "Setup Wizard" %> +<% nav_section :settings %> +<% jumbotron do %> +

    Setup Wizard

    +

    Step 6: Rules

    +<% end %> + +

    The Github Connector decides who can access Github.com based on a list of rules. Configure the rules below.

    + +<%= form_tag({action: :update}, method: :put, class: 'form-horizontal max-col-sm') do %> + <% if @error %> +
    <%= @error %>
    + <% end %> + +

    Rules

    + <%= render 'settings/rules' %> + +
    +
    + <%= submit_tag('Next', class: 'btn btn-default btn-primary') %> +
    +
    +<% 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 @@ + + diff --git a/app/views/users/_github_users.html.erb b/app/views/users/_github_users.html.erb new file mode 100644 index 0000000..d123ac8 --- /dev/null +++ b/app/views/users/_github_users.html.erb @@ -0,0 +1,8 @@ +
    +

    <%= 'GitHub Account'.pluralize(user.github_users.size) %>

    + <% if user.github_users.empty? %> +

    No linked GitHub accounts.

    + <% else %> + <%= render partial: 'users/github_user', collection: user.github_users.order(updated_at: :desc) %> + <% end %> +
    diff --git a/app/views/users/_ldap_user.html.erb b/app/views/users/_ldap_user.html.erb new file mode 100644 index 0000000..6caedc4 --- /dev/null +++ b/app/views/users/_ldap_user.html.erb @@ -0,0 +1,32 @@ + diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb new file mode 100644 index 0000000..d0cc77c --- /dev/null +++ b/app/views/users/edit.html.erb @@ -0,0 +1,28 @@ +<% title @user.name %> +<% jumbotron do %> +

    <%= @user.name %>

    +<% end %> + +<%= form_for :user, + url: user_path(@user), + method: :patch, + html: { + class: 'form-horizontal max-col-sm' + } do |f| %> + +
    + <%= f.label :admin, class: 'control-label col-sm-2' %> +
    + <%= f.check_box :admin %> + Allows the user to administer the GitHub Connector application. +
    +
    + +
    +
    + <%= f.submit class: 'btn btn-default btn-primary' %> + <%= link_to "Cancel", @user, class: 'btn btn-default' %> +
    +
    + +<% 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 | | +%> + + + + + + + + + <% @users.each do |user| %> + <% github_users = user.github_users.empty? ? [nil] : user.github_users %> + <% github_users.each_with_index do |ghuser, i| %> + + <% if i == 0 %> + + <% end %> + <% if ghuser %> + + + <% else %> + + <% end %> + + <% end %> + <% end %> +
    UserGitHub AccountState
    + <%= link_to(user.name, user) %> + <%= link_to ghuser.login, user_path(user, anchor: ghuser.login) %><%= github_user_state_label(ghuser) %>None
    diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb new file mode 100644 index 0000000..2ccfac3 --- /dev/null +++ b/app/views/users/show.html.erb @@ -0,0 +1,21 @@ +<% title @user.name %> +<% nav_section :user if @user == current_user %> +<% jumbotron do %> +

    <%= @user.name %>

    +<% end %> + +<%= render partial: 'users/ldap_user', locals: {user: @user} %> +<%= render partial: 'users/github_users', locals: {user: @user} %> + +

    Internal

    + + + + + + +
    Admin<%= @user.admin? ? 'Yes' : 'No' %>
    + +<% 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] an array of objects + # @yieldparam obj [Object] a single object from the array of objects + # @return [void] + def thread_for_each(objs) + ary = objs.to_a + ary = ary.dup if ary === objs + + threads = [] + [ary.size, thread_count].min.times do + threads << Thread.new do |thread| + ActiveRecord::Base.connection_pool.with_connection do + while (obj = synchronize { ary.shift }) + yield obj + end + end + end + end + threads.each { |t| t.join } + end +end diff --git a/lib/github_admin.rb b/lib/github_admin.rb new file mode 100644 index 0000000..d5e32f7 --- /dev/null +++ b/lib/github_admin.rb @@ -0,0 +1,161 @@ +class GithubAdmin + + def initialize + @semaphore = Mutex.new + end + + # Returns a hash of GitHub user hashes belonging to one or more of + # our organizations. The returned hash is keyed by the GitHub login. + # Two extra keys are added to each user hash: + # * mfa_enabled - true if 2FA is enabled, false otherwise + # * orgs - array of our organizations the user belongs to + # + # @return [Hash] list of users keyed by user login + # @see GithubConnector::Settings#github_orgs + def org_users + synchronize { return @org_users if @org_users } + + users = {} + orgs = settings.github_orgs || [] + orgs.each do |org| + octokit.organization_members(org).each do |user| + if users.has_key?(user.login) + users[user.login][:orgs] << org + else + users[user.login] = user.to_h + users[user.login][:mfa_enabled] = true + users[user.login][:orgs] = [org] + end + end + end + orgs.each do |org| + octokit.organization_members(org, filter: '2fa_disabled').each do |user| + users[user.login][:mfa_enabled] = false + end + end + + synchronize { @org_users = users } + end + + # The GitHub API client + # + # @return [Octokit::Client] + def octokit + @octokit ||= Octokit::Client.new(access_token: settings.github_admin_token, auto_paginate: true) + end + + # Application settings + # + # @return [GithubConnector::Settings] + def settings + Rails.application.settings + end + + # Returns an array of members of the given GitHub team + # + # @param [Hash, Integer, String] a team hash (from {#teams}), a team ID, or + # a team org/slug + # @return [Hash] list of members keyed by user login + def team_members(team_id) + team_id = team_id_for(team_id) + + synchronize do + @team_members ||= {} + return @team_members[team_id] if @team_members.has_key?(team_id) + end + + members = {} + octokit.team_members(team_id).each do |member| + members[member.login] = member.to_h + end + + synchronize { @team_members[team_id] = members } + end + + def team(team_id) + team_id = team_id_for(team_id) + + synchronize do + if @teams && @teams.has_key?(team_id) + return @teams[team_id] + end + end + + team = octokit.team(team_id) + team = team.to_h + team[:organization] = team[:organization][:login] unless team[:organization].is_a?(String) + team + end + + # Returns an array of GitHub teams + # + # @return [Hash] list of teams keyed by ID + # @see GithubConnector::Settings#github_orgs + def teams + synchronize { return @teams if @teams } + + teams = {} + orgs = settings.github_orgs || [] + orgs.each do |org| + octokit.organization_teams(org).each do |team| + team = team.to_h + team[:organization] = org + teams[team[:id]] = team + end + end + + synchronize { @teams = teams } + end + + # Converts the given parameter into a team ID + # + # @param [Hash, String, Fixnum] a team ID, hash, or slug + # @return [Fixnum] + def team_id_for(team_param) + team_id = team_param + if team_param.is_a?(Hash) + team_id = team_param[:id] + elsif team_param.is_a?(String) && team_param.to_i.to_s != team_param + team_data = teams.values.find do |t| + team_param == t[:slug] || team_param == "#{t[:organization]}/#{t[:slug]}" + end + team_id = team_data[:id] if team_data + end + team_id + end + + # Checks if the user has MFA enabled. + # NOTE: The user must already be a member of the organization. This does not + # check this. + # + # @param login [String] a GitHub user login + # @param org [String] a GitHub organization to check + # @return [Boolean] true if the user has MFA enabled, false otherwise + def user_mfa?(login, org=nil) + synchronize { + if @org_users && @org_users[login] + return @org_users[login][:mfa_enabled] + end + } + + unless org + org = settings.github_orgs.find do |org| + octokit.organization_member?(org, login) + end + end + return nil unless org + + !octokit.organization_members(org, filter: '2fa_disabled').any? do |user| + user.login == login + end + 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 +end diff --git a/lib/github_synchronizer.rb b/lib/github_synchronizer.rb new file mode 100644 index 0000000..5a86d51 --- /dev/null +++ b/lib/github_synchronizer.rb @@ -0,0 +1,179 @@ +class GithubSynchronizer < BaseExecutor + + + # A Github admin client. + # @return [GithubAdmin] + attr_accessor :github_admin + + # A hash of statistics from the most recent run. Includes: + # * users_time - User sync execution time + # * teams_time - Team sync execution time + # * teams_added + # * teams_deleted + # * teams_synced + # * teams_errors + # * users_synced + # * users_errors + # + # @return [Hash] + attr_reader :stats + + def initialize + super() + @stats = {} + end + + # A Github admin client. + # + # @return [GithubAdmin] + def github_admin + @github_admin ||= GithubAdmin.new + end + + # Synchronizes all GitHub users from our organizations. + # + # @return [Bolean] + def sync_users + start = Time.now + stats[:users_added] = 0 + stats[:users_deleted] = 0 + stats[:users_synced] = 0 + stats[:user_errors] = 0 + threads = [] + + org_users = github_admin.org_users.values + + destroyed = GithubUser.joins( + GithubUser.arel_table.join(User.arel_table, Arel::Nodes::OuterJoin). + on(GithubUser.arel_table[:user_id].eq(User.arel_table[:id])). + join_sources + ).where.not(id: org_users.map { |u| u[:id] }). + where(User.arel_table[:id].eq(nil)). + destroy_all + stats[:users_deleted] = destroyed.count + + processed = [] + thread_for_each(org_users) do |org_user| + begin + synchronize do + next if processed.include?(org_user[:login]) + processed << org_user[:login] + end + github_user = GithubUser.find_or_initialize_by(id: org_user[:id]) + is_new = github_user.new_record? + github_user.login = org_user[:login] + github_user.avatar_url = org_user[:avatar_url] + github_user.html_url = org_user[:html_url] + github_user.mfa = org_user[:mfa_enabled] + github_user.last_sync_at = Time.now unless github_user.token + github_user.save! + # Sync with the user's token, if available + github_user.sync! if github_user.token + synchronize do + if github_user.sync_error + stats[:user_errors] += 1 + @errors << "Error synchronizing #{github_user.login}: #{github_user.sync_error}" + elsif is_new + stats[:users_added] += 1 + else + stats[:users_synced] += 1 + end + end + rescue => e + synchronize do + stats[:user_errors] += 1 + @errors << e + Rails.logger.error "Error processing user #{org_user[:login]}: #{e.message}" + end + end + end + + stats[:user_errors] == 0 + rescue => e + @errors << e + Rails.logger.error "Error processing user: #{e.message}" + false + ensure + stats[:users_time] = Time.now.to_f - start.to_f + end + + # Synchronizes teams. + # + # @return [Boolean] + def sync_teams + start = Time.now + stats[:teams_added] = 0 + stats[:teams_deleted] = 0 + stats[:teams_synced] = 0 + stats[:team_errors] = 0 + threads = [] + + teams = github_admin.teams.values + + destroyed = GithubTeam.where.not(id: teams.map { |team_data| team_data[:id] }).destroy_all + stats[:teams_deleted] = destroyed.count + + thread_for_each(teams) do |team_data| + begin + team = GithubTeam.find_or_initialize_by(id: team_data[:id]) + is_new = team.new_record? + team.github_admin = github_admin + team.sync! + synchronize do + if is_new + stats[:teams_added] += 1 + else + stats[:teams_synced] += 1 + end + end + rescue => e + synchronize do + stats[:team_errors] += 1 + @errors << e + Rails.logger.error "Error processing team #{team_data[:organization]}/#{team_data[:slug]}: #{e.message}" + end + end + end + + stats[:team_errors] == 0 + rescue => e + stats[:team_errors] += 1 + @errors << e + Rails.logger.error "Error synchronizing teams: #{e.message}" + false + ensure + stats[:teams_time] = Time.now.to_f - start.to_f + end + + # Synchronizes Github organizations with our local database. + # Synchronization is run in threads according to {#thread_count}. + # + # @return [Boolean] `true` if synchronizer executed successfully, `false` otherwise + def run! + start = Time.now + @errors = [] + @stats = {} + + Rails.application.settings.with_disconnected do |settings| + settings.reload + + # Check rate limit + rate_limit = github_admin.octokit.rate_limit + unless rate_limit.remaining > 100 + @errors << StandardError.new("Not running because Github rate limit is too low: #{rate_limit.remaining} remaining. Please try again after #{Time.now + rate_limit.resets_in}.") + return false + end + + # Synchronize Github user information + sync_users + + # Synchronize team information + sync_teams + + stats[:api_requests] = rate_limit.remaining - github_admin.octokit.rate_limit.remaining + stats[:total_time] = Time.now.to_f - start.to_f + end + + @errors.empty? + end +end diff --git a/lib/ldap_synchronizer.rb b/lib/ldap_synchronizer.rb new file mode 100644 index 0000000..7bb0e2d --- /dev/null +++ b/lib/ldap_synchronizer.rb @@ -0,0 +1,76 @@ +class LdapSynchronizer < BaseExecutor + + # A hash of statistics from the most recent run. Includes: + # * users_time - User sync execution time + # * users_synced + # * users_errors + # + # @return [Hash] + attr_reader :stats + + # @return [Enumerable] a list of users to synchronize + attr_reader :users + + # @param users [Enumerable] a list of users to synchronize + def initialize(users=User.all) + super() + @stats = {} + @users = users + end + + # Synchronizes all Active Directory users. + # + # @return [Bolean] + def sync_users + start = Time.now + stats[:users_synced] = 0 + stats[:user_errors] = 0 + threads = [] + + thread_for_each(users) do |user| + begin + user.sync_from_ldap! + synchronize do + if user.ldap_sync_error + stats[:user_errors] += 1 + @errors << "Error synchronizing #{user.username}: #{user.ldap_sync_error}" + else + stats[:users_synced] += 1 + end + end + rescue => e + synchronize do + stats[:user_errors] += 1 + @errors << e + Rails.logger.error "Error processing user #{user.username}: #{e.message}" + end + end + end + + stats[:user_errors] == 0 + rescue => e + stats[:user_errors] += 1 + @errors << e + Rails.logger.error "Error synchronizing users: #{e.message}" + false + ensure + stats[:users_time] = Time.now.to_f - start.to_f + end + + # Synchronizes Active Directory users with our local database. + # Synchronization is run in threads according to {#thread_count}. + # + # @return [Boolean] `true` if synchronizer executed successfully, `false` otherwise. + def run! + @errors = [] + @stats = {} + + Rails.application.settings.with_disconnected do |settings| + settings.reload + + sync_users + end + + @errors.empty? + end +end diff --git a/lib/rules.rb b/lib/rules.rb new file mode 100644 index 0000000..cb27cfc --- /dev/null +++ b/lib/rules.rb @@ -0,0 +1,99 @@ +module Rules + # @return [Array] an array of enabled {Rules::Base} classes + def self.enabled_rules + @all_rules ||= begin + Dir[File.join(File.dirname(__FILE__), 'rules', '*.rb')].map do |file| + filename = File.basename(file, '.rb') + next if filename == 'base' + Rules.const_get(filename.classify, false) rescue nil + end.compact + end + @all_rules.select { |rule| rule.enabled? } + end + + # @param user [GithubUser] + # @return [Rules::Iterator] an array of {Rules::Base}s for the given user + def self.for_github_user(user) + rules = enabled_rules.map { |klass| klass.new(user) } + Iterator.new(rules) + end + + ## + # An `Enumerable` wrapper around rules. It allows filtering + # and provides summary methods. Assign a `Proc` to + # {Iterator#selectors} to filter rules. + class Iterator + include ::Enumerable + + # @return [Array] an array of rules + attr_reader :rules + + # @return [Proc] callbacks to filter rules + attr_accessor :selectors + + def initialize(rules) + @rules = rules + @selectors = [] + end + + def initialize_copy(other) + super + @selectors = other.selectors.dup + end + + # Calls the given block once for each element in `self`, passing that + # element as a parameter. Elements are filtered by {#selectors} if + # set. + # + # @yieldparam element [Rules:Base] a rule + # @return [void] + def each(&block) + rules.each do |rule| + next unless selectors.all? { |selector| selector.call(rule) } + block.call(rule) + end + end + + # Returns `true` if `self` contains no elements + # @return [Boolean] + def empty? + !any? { true } + end + + # Includes only failing rules + # + # @return [Iterator] self + def failing + self.selectors << lambda { |rule| !rule.valid? } + self + end + + # Includes only rules required for external access + # + # @return [Iterator] self + def external + self.selectors << lambda { |rule| rule.required_for_external? } + self + end + + # Includes only passing rules + # + # @return [Iterator] self + def passing + self.selectors << lambda { |rule| rule.valid? } + self + end + + # Returns the result of all rules in `self` + # @return [Boolean] `true` if all rules are valid, `false` otherwise + def result + all?(&:result) + end + + # Returns `true` if all rules in `self` are valid + # @return [Boolean] + def valid? + !!result + end + end +end diff --git a/lib/rules/active_ldap.rb b/lib/rules/active_ldap.rb new file mode 100644 index 0000000..f09a2b7 --- /dev/null +++ b/lib/rules/active_ldap.rb @@ -0,0 +1,51 @@ +module Rules + ## + # Tests that the Active Directory account is active. The userAccountControl + # LDAP attribute is used to check for disabled users (disabled flag: 0x0002). + class ActiveLdap < Base + + # A descriptive error message to display when this rule + # fails. + # + # @return [String] + def error_msg + return nil if result + + if user && has_flag?(User::AccountControl::ACCOUNT_DISABLED) + "Active Directory account is disabled" + #elsif user && has_flag?(User::AccountControl::PASSWORD_EXPIRED) + # "Active Directory password is expired" + else + "Active Directory account does not meet criteria" + end + end + + # Should this rule notify the user when it is not valid? + # @return [Boolean] + def notify? + false + end + + # This rule is required for external users. + # + # @return [Boolean] false + def required_for_external? + false + end + + # The result of applying this rule to the {User}. + # @return [Boolean] `true` if the rule passes, false otherwise + def result + return false unless user + return false if has_flag?(User::AccountControl::ACCOUNT_DISABLED) + #return false if has_flag?(User::AccountControl::PASSWORD_EXPIRED) + true + end + + private + + def has_flag?(flag) + user.ldap_account_control & flag == flag + end + end +end diff --git a/lib/rules/base.rb b/lib/rules/base.rb new file mode 100644 index 0000000..ab726cc --- /dev/null +++ b/lib/rules/base.rb @@ -0,0 +1,75 @@ +module Rules + class Base + # @return [GithubUser] + attr_reader :github_user + + # Returns true if this rule is enabled. + # + # @return [Boolean] + def self.enabled? + true + end + + def initialize(github_user) + @github_user = github_user + end + + # A descriptive error message to display when this rule + # fails. + # + # @return [String] + def error_msg + name + end + + # A name for this rule. + # + # @return [String] + def name + self.class.name.demodulize.underscore + end + + # Should this rule notify the user when it is not valid? + # @return [Boolean] + def notify? + true + end + + # This rule is required for external users. + # + # @return [Boolean] + def required_for_external? + true + end + + # The result of applying this rule to the {GithubUser}. + # @return [Boolean] `true` if the rule passes, false otherwise + def result + raise NotImplementedError, "You must implement #{self.class.name}#result" + end + + # Application settings + # @return [GithubConnector::Settings] + def settings + self.class.settings + end + + # Application settings + # @return [GithubConnector::Settings] + def self.settings + Rails.application.settings + end + + # The {User} associated with the {GithubUser} + # @return [User] + def user + github_user.user + end + + # Returns `true` if the result of the rule is `true` + # @return [Boolean] + def valid? + !!result + end + end +end diff --git a/lib/rules/email.rb b/lib/rules/email.rb new file mode 100644 index 0000000..abed821 --- /dev/null +++ b/lib/rules/email.rb @@ -0,0 +1,51 @@ +module Rules + ## + # Tests that all GitHub email addresses match the + # {GithubConnector::Settings#rule_email_regex} setting. If + # no `rule_email_regex` setting exists, this rule always + # returns `true`. + class Email < Base + + # Returns true if this rule is enabled. + # + # @return [Boolean] + def self.enabled? + !!settings.rule_email_regex + end + + # A descriptive error message to display when this rule + # fails. + # + # @return [String] + def error_msg + return nil if result + + bad_emails = email_addresses.reject { |email| regex.match(email) } + + "#{bad_emails.count == 1 ? 'Email does' : 'Emails do'} not meet criteria: #{bad_emails.join(', ')}" + end + + # This rule is required for external users. + # + # @return [Boolean] false + def required_for_external? + false + end + + # The result of applying this rule to the {GithubUser}. + # @return [Boolean] `true` if the rule passes, false otherwise + def result + email_addresses.all? { |email| regex.match(email) } + end + + private + + def email_addresses + github_user.emails.map { |email| email.address.downcase } + end + + def regex + @regex ||= Regexp.new(settings.rule_email_regex) + end + end +end diff --git a/lib/rules/github_mfa.rb b/lib/rules/github_mfa.rb new file mode 100644 index 0000000..adc7802 --- /dev/null +++ b/lib/rules/github_mfa.rb @@ -0,0 +1,23 @@ +module Rules + ## + # Tests that the {GithubUser} has GitHub multi-factor authentication + # enabled. + class GithubMfa < Base + + # A descriptive error message to display when this rule + # fails. + # + # @return [String] + def error_msg + return nil if result + + "Two factor authentication is disabled" + end + + # The result of applying this rule to the {GithubUser}. + # @return [Boolean] `true` if the rule passes, false otherwise + def result + !!github_user.mfa + end + end +end diff --git a/lib/rules/github_oauth.rb b/lib/rules/github_oauth.rb new file mode 100644 index 0000000..d89f632 --- /dev/null +++ b/lib/rules/github_oauth.rb @@ -0,0 +1,37 @@ +module Rules + ## + # Tests that a {GithubUser} has valid GitHub OAuth access. This is + # evaluated by looking at the {GithubUser#sync_error} field for + # `notoken` or `unauthorized` errors. + class GithubOauth < Base + + # A descriptive error message to display when this rule + # fails. + # + # @return [String] + def error_msg + return nil if result + + if github_user.token + "Invalid OAuth token" + else + "Missing OAuth token" + end + end + + # This rule is required for external users. + # + # @return [Boolean] false + def required_for_external? + false + end + + # The result of applying this rule to the {GithubUser}. + # @return [Boolean] `true` if the rule passes, false otherwise + def result + return false unless github_user.token + return false if %w(notoken unauthorized).include?(github_user.sync_error) + true + end + end +end diff --git a/lib/rules/last_github_sync.rb b/lib/rules/last_github_sync.rb new file mode 100644 index 0000000..d2a7724 --- /dev/null +++ b/lib/rules/last_github_sync.rb @@ -0,0 +1,41 @@ +module Rules + ## + # Tests that a {GithubUser} has synced with GitHub + # within a certain amount of time specified by the + # {GithubConnector::Settings#rule_max_sync_age} setting. If no + # `rule_max_sync_age` setting exists, this rule always returns `true`. + class LastGithubSync < Base + + # Returns true if this rule is enabled. + # + # @return [Boolean] + def self.enabled? + !!settings.rule_max_sync_age + end + + # A descriptive error message to display when this rule + # fails. + # + # @return [String] + def error_msg + return nil if result + + if !github_user.last_sync_at + "GitHub has never been synchronized" + else + "Last GitHub synchronization is too old" + end + end + + # The result of applying this rule to the {GithubUser}. + # @return [Boolean] `true` if the rule passes, false otherwise + def result + return false unless github_user.last_sync_at + + min_sync_time = Time.now - settings.rule_max_sync_age + return false unless github_user.last_sync_at > min_sync_time + + true + end + end +end diff --git a/lib/rules/last_ldap_sync.rb b/lib/rules/last_ldap_sync.rb new file mode 100644 index 0000000..3b0bc1c --- /dev/null +++ b/lib/rules/last_ldap_sync.rb @@ -0,0 +1,51 @@ +module Rules + ## + # Tests that a {GithubUser} has synced with Active Directory + # within a certain amount of time specified by the + # {GithubConnector::Settings#rule_max_sync_age} setting. If no + # `rule_max_sync_age` setting exists, this rule always returns `true`. + class LastLdapSync < Base + + # Returns true if this rule is enabled. + # + # @return [Boolean] + def self.enabled? + !!settings.rule_max_sync_age + end + + # A descriptive error message to display when this rule + # fails. + # + # @return [String] + def error_msg + return nil if result + + if !user + "No active directory user" + elsif !user.last_ldap_sync + "Active Directory has never been synchronized" + else + "Last Active Directory synchornization is too old" + end + end + + # This rule is required for external users. + # + # @return [Boolean] false + def required_for_external? + false + end + + # The result of applying this rule to the {GithubUser}. + # @return [Boolean] `true` if the rule passes, false otherwise + def result + return false unless user + return false unless user.last_ldap_sync + + min_sync_time = Time.now - settings.rule_max_sync_age + return false unless user.last_ldap_sync > min_sync_time + + true + end + end +end diff --git a/lib/settings.rb b/lib/settings.rb new file mode 100644 index 0000000..1236cb7 --- /dev/null +++ b/lib/settings.rb @@ -0,0 +1,210 @@ +require 'settings/base' + +module GithubConnector + class Settings < ::Settings::Base + # @!attribute configured + # @return [Boolean] has the application been fully configured? + setting :configured, type: :boolean + + # @!attribute company + # @return [String] Company name + setting :company, type: :string + + # @!attribute ldap_host + # @return [String] Active Directory hostname + setting :ldap_host + + # @!attribute ldap_port + # @return [Integer] Active Directory port + setting :ldap_port, type: :integer + + # @!attribute ldap_ssl + # @return [Boolean] use SSL for Active Directory connection? + setting :ldap_ssl, type: :boolean + + # @!attribute ldap_admin_user + # @return [String] the Active Directory user to use for the LDAP + # connection in LDAP format (e.g. cn=admin,dc=example,dc=com) + setting :ldap_admin_user + + # @!attribute ldap_admin_password + # @return [String] the password to use for the LDAP connection + setting :ldap_admin_password, encrypt: true + + # @!attribute ldap_attribute + # @return [String] the LDAP attribute used for the username, + # e.g. sAMAccountName + setting :ldap_attribute + + # @!attribute ldap_base + # @return [String] the LDAP base, e.g. dc=example,dc=com + setting :ldap_base + + # @!attribute github_client_id + # @return [String] the GitHub application client ID + setting :github_client_id + + # @!attribute github_client_secret + # @return [String] the GitHub application client secret + setting :github_client_secret + + # @!attribute github_admin_user + # @return [String] the GitHub Oauth token to use for admin/organization + # access + setting :github_admin_token, encrypt: true + + # @!attribute github_orgs + # @return [Array] list of GitHub organizations to manage + setting :github_orgs, type: :array + + # @!attribute github_default_teams + # @return [Array] list of GitHub teams all users should belong to + setting :github_default_teams, type: :array + + # @!attribute github_check_mfa_team + # @return [String] the GitHub team used to check 2FA status for new users + setting :github_check_mfa_team + + # @!attribute github_external_teams + # @return [Array] list of teams that allow external users. + setting :github_external_teams, type: :array + + # @!attribute github_requirements + # @return [String] a list of requirements users must meet + setting :github_user_requirements, type: :array + + # @!attribute github_exclude_users + # @return [Array] list of GitHub users to exclude from rules processing + setting :github_exclude_users, type: :array + + # @!attribute email_base_url + # @return [String] the base url to use for links in emails + setting :email_base_url + + # @!attribute email_from + # @return [String] the from address to send emails with + setting :email_from + + # @!attribute email_reply_to + # @return [String] the reply-to address to send emails with + setting :email_reply_to + + # @!attribute smtp_address + # @return [String] remote mail server + setting :smtp_address + + # @!attribute smtp_port + # @return [Fixnum] remote mail server port + setting :smtp_port + + # @!attribute smtp_enable_starttls_auto + # @return [Boolean] allow StartTLS + setting :smtp_enable_starttls_auto, type: :boolean + + # @!attribute smtp_user_name + # @return [String] smtp user name + setting :smtp_user_name + + # @!attribute smtp_password + # @return [String] smtp password + setting :smtp_password, encrypt: true + + # @!attribute smtp_authentication + # @return [String] mail server authenticaton type, one of :plain, :login, or :cram_md5 + setting :smtp_authentication + + # @!attribute smtp_domain + # @return [String] smtp HELO domain + setting :smtp_domain + + # @!attribute enforce_rules + # @return [Boolean] true if rules should be enforced, false to for "dry-run" + # mode. + setting :enforce_rules, type: :boolean + + # @!attribute rule_email_regex + # @return [String] a regular expression used to validate + # GitHub email addresses + setting :rule_email_regex + + # @!attribute rule_max_sync_age + # @return [Fixnum] maximum number of seconds since last sucessful + # GitHub synchronization + setting :rule_max_sync_age, type: :integer + + # Apply Action Mailer related settings to the ActionMailer. + # + # @param klass [Class] the ActionMailer class to apply settings to. Default + # ActionMailer::Base. + # @return [void] + def apply_to_action_mailer(klass=ActionMailer::Base) + klass.smtp_settings = smtp_config + + email_opts = email_config + klass.default_options = email_config.select { |k,v| %i(from reply_to).include?(k) } + + uri = URI.parse(email_opts[:base_url]) rescue nil + if uri + host_with_port = uri.host + host_with_port += ":#{uri.port}" unless uri.port == uri.default_port + klass.default_url_options = { + host: host_with_port, + protocol: uri.scheme, + } + end + end + + # A list of Email configuration keys + # + # @return [Array] + def email_keys + keys.select { |key| key.to_s.start_with?('email_') } + end + + # An Email configuration hash. + # + # @rturn [Hash] + def email_config + hash_for(email_keys).inject({}) do |memo, (key, val)| + memo[key.to_s.gsub(/^email_/, '').to_sym] = val unless val.is_a?(String) && val.blank? + memo + end + end + + # A list of LDAP configuration keys + # + # @return [Array] + def ldap_keys + keys.select { |key| key.to_s.start_with?('ldap_') } + end + + # A LDAP configuration hash that can be consumed by the + # devise_ldap_authenticatable gem + # + # @return [Hash] + def ldap_config + hash_for(ldap_keys).inject({}) do |memo, (key, val)| + memo[key.to_s.gsub(/^ldap_/, '')] = val if key.to_s.start_with?('ldap_') + memo + end + end + + # A list of SMTP configuration keys + # + # @return [Array] + def smtp_keys + keys.select { |key| key.to_s.start_with?('smtp_') } + end + + # An SMTP configuration hash that can be consumed by + # ActionMailer's smtp_settings method. + # + # @rturn [Hash] + def smtp_config + hash_for(smtp_keys).inject({}) do |memo, (key, val)| + memo[key.to_s.gsub(/^smtp_/, '').to_sym] = val unless val.is_a?(String) && val.blank? + memo + end + end + end +end diff --git a/lib/settings/base.rb b/lib/settings/base.rb new file mode 100644 index 0000000..f16a85c --- /dev/null +++ b/lib/settings/base.rb @@ -0,0 +1,219 @@ +require 'settings/definition' + +# The +Settings+ classes and methods wrap the {Setting} database object +# allowing easy access to application settings. +module Settings + ## + # The settings class allows defining and interacting with settings via + # the {Setting} model. Settings are defined via the {setting} method. + # + # By default the settings are loaded from and saved to the database + # immediately when getting and setting. The {#disconnect} returns a + # disconnected instance that does not automaticallying update the + # database. A disconnected object can be loaded and saved via {#load} + # and {#save}. + # + # @example + # class MySettings + # setting :api_username + # setting :api_password, encrypt: true + # setting :api_ssl, type: boolean + # setting :api_port, type: integer + # end + class Base + include Encryptable + + # A list of {Settings::Definition}s. + # + # @return [Array] + def self.definitions + @definitions ||= {} + end + + # Creates a setting. + # + # @param key [Symbol] + # @param opts [Hash] setting options + # @option opts [Symbol] :type one of `:string`, `:integer`, `:float`, + # `:boolean`, `:datetime` + # @option opts [Boolean] :encrypt should the value be encrypted in the database? + # + # @example Define the setting type + # setting :my_setting, type: integer + # @example Encrypt the setting in the database + # setting :secure_setting, encrypt: true + def self.setting(key, opts={}) + definition = Definition.new(key, opts) + definitions[key.to_sym] = definition + define_method("#{key}=") { |val| set(key, val) } + define_method("#{key}") { get(key) } + if definition.type == :boolean + define_method("#{key}?") { !!send("#{key}") } + end + end + + def initialize + @settings = {} + @disconnected = false + @dirty = Set.new + end + + # Returns the {Settings::Definition} for the given setting + # + # @param key [Symbol] the setting name + # @return [Settings::Definition] + def definition(key) + self.class.definitions[key.to_sym] + end + + # Has the given key been modified? + # + # @param key [Symbol] the setting + # @return [Boolean] true if the setting has been modified + # without saving, false otherwise + def dirty?(key) + @dirty.include?(key.to_sym) + end + + # Return a new disconnected settings object. Accessing settings will not + # automatically query the database. Setting values will not automatically + # save to the database. You can still manually call the {#load} and {#save} + # methods. + # + # @return [Settings::Base] disconnected settings object + def disconnect + clone.tap { |c| c.disconnected = true } + end + + # Is this instance disconnected? + # + # @return [Boolean] + def disconnected? + @disconnected + end + + # Returns a hash with the given settings and values + # + # @param keys [Array] array of settings to include in the hash + # @return [Hash] + # @see {#to_h} + def hash_for(keys) + reload(keys) unless disconnected? + @settings.inject({}) do |memo, (key, val)| + if keys.include?(key.to_s) || keys.include?(key.to_sym) + memo[key.to_sym] = val + end + memo + end + end + + # An array of setting names defined in this instance. + # + # @return [Array] + def keys + self.class.definitions.map do |key, definition| + key + end + end + + # Load the given settings from the database. If no keys are specified, + # all defined settings will be loaded. + # + # @param keys [Array] array of setting names + # @return [Settings::Base] self + def load(keys=nil) + load_keys(keys || self.keys) + self + end + alias :reload :load + + # Saves settings that have been modified. + # + # @return [void] + def save + @dirty.each { |key| save_key(key) } + end + + # Returns a hash of all setting name/value pairs. + # + # @return [Hash] + def to_h + hash_for(keys) + end + alias :all :to_h + + # Runs the given block with disconnected settings. After the block + # returns, the disconnected state will be returned to its original value. + # Calls to `with_disconnected` may be nested. + # + # @yieldparam settings [Settings::Base] disconnected settings + # @return [Object] the return value of the block + def with_disconnected(&block) + prev = self.disconnected? + begin + self.disconnected = true + block.call(self) + ensure + self.disconnected = prev + end + end + + protected + # Sets the disconnected state + # + # @param bool [Boolean] + # @return [void] + # @see {#disconnect} + def disconnected=(bool) + @disconnected = !!bool + end + + private + def get(key) + key = key.to_sym + load_key(key) unless disconnected? + @settings[key] + end + + def load_keys(keys) + return unless keys + keys = [keys] unless keys.is_a?(Enumerable) + keys.each do |key| + @settings.delete(key) + @dirty.delete(key) + end + Setting.where(key: keys).each do |setting| + key = setting.key.to_sym + val = setting ? setting.value : nil + val = decrypt(val) if definition(key).encrypt? + val = definition(key).type_cast(val) + @settings[key] = val + end + end + alias :load_key :load_keys + + def set(key, val) + key = key.to_sym + val = definition(key).type_cast(val) + + unless val == @settings[key] + @settings[key] = val + @dirty << key + end + save_key(key) unless disconnected? + end + + def save_key(key) + key = key.to_sym + setting = Setting.find_by_key(key) + setting ||= Setting.new(key: key) + + val = @settings[key] + val = definition(key).db_cast(val) + val = encrypt(val) if definition(key).encrypt? + setting.value = val + setting.save! + @dirty.delete(key) + end + end +end diff --git a/lib/settings/definition.rb b/lib/settings/definition.rb new file mode 100644 index 0000000..4e3dbdf --- /dev/null +++ b/lib/settings/definition.rb @@ -0,0 +1,66 @@ +module Settings + ## + # Defines a setting used in {Settings::Base}. + class Definition + # The setting name + # @return [Symbol] + attr_accessor :key + + # The setting type + # @return [Symbol] one of `:string`, `:integer`, `:float`, `:boolean`, `:datetime`, `:array`, `:hash` + attr_accessor :type + + # Whether the value should be encrypted in the database + # @return [Boolean] true if the value should be encrypted, false otherwise + attr_accessor :encrypt + + def initialize(key, opts) + self.key = key.to_sym + self.type = :string + self.encrypt = false + opts.each do |opt, val| + send("#{opt}=", val) if respond_to?("#{opt}=") + end + end + + # Casts the given value for persistence in the database. + # + # @param [Object] val + # @return [String] a string for persisting in the database + def db_cast(val) + return nil if val.nil? + + val = case type + when :boolean then val ? 'true' : 'false' + when :array, :hash then val ? val.to_json : nil + else val.to_s + end + + val + end + + # Should the setting be encrypted when persisting? + # @return [Boolean] true if the value should be encrypted, false otherwise + def encrypt? + !!@encrypt + end + + # Casts the given value according to the `type` setting option. + # + # @param [Object] val + # @return [Object] the value, cast according to the `type` option + def type_cast(val) + return nil if val.nil? + + case type + when :integer then val.to_i rescue val ? 1 : 0 + when :float then val.to_f + when :boolean then val.to_s =~ /^(t|1|y)/i ? true : false + when :datetime then DateTime.parse(val.to_s) + when :array then val.is_a?(Array) ? val : JSON.parse(val) + when :hash then val.is_a?(Hash) ? val : JSON.parse(val) + else val + end + end + end +end diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/tasks/github.rake b/lib/tasks/github.rake new file mode 100644 index 0000000..6e67743 --- /dev/null +++ b/lib/tasks/github.rake @@ -0,0 +1,53 @@ +namespace :github do + + task sync: 'sync:github' + + desc "Transitions Github users based on rules and current attributes" + task transition_users: %w(environment sync:ldap sync:github) do + + # Make sure the majority of users have recently synced ldap and github + min_sync_time = Time.now - [Rails.application.settings.rule_max_sync_age, 120].max + 120.seconds + + total_ldap_count = User.count + synced_ldap_count = User.where('last_ldap_sync > ?', min_sync_time).count + if synced_ldap_count < [total_ldap_count / 4, 1].max + puts "Fewer than 25% of LDAP users (#{synced_ldap_count} of #{total_ldap_count}) meet minimum sync time. Skipping transition." + exit 1 + end + + total_github_count = GithubUser.active.count + synced_github_count = GithubUser.active.where('last_sync_at > ?', min_sync_time).count + if synced_github_count < [total_github_count / 4, 1].max + puts "Fewer than 25% of GitHub users (#{synced_github_count} of #{total_github_count}) meet minimum sync time. Skipping transition." + exit 2 + end + + puts "Checking for users to disable..." + executor = TransitionGithubUsers.new + executor.run! + + disabled_users = executor.transitions.select { |u| u.disabled? } + external_users = executor.transitions.select { |u| u.external? } + + executor.stats.each do |key, val| + puts " #{key}: #{val}" + end + + if disabled_users.empty? && external_users.empty? + puts " No users to disable." + end + unless disabled_users.empty? + puts " Disabled Github users: #{disabled_users.map { |u| u.login }.join(', ')}" + end + unless external_users.empty? + puts " External Github users: #{external_users.map { |u| u.login }.join(', ')}" + end + + unless executor.errors.empty? + puts " Errors:" + executor.errors.each do |error| + puts " #{error}" + end + end + end +end diff --git a/lib/tasks/sync.rake b/lib/tasks/sync.rake new file mode 100644 index 0000000..70fbb01 --- /dev/null +++ b/lib/tasks/sync.rake @@ -0,0 +1,36 @@ +desc "Synchronize LDAP and GitHub users" +task sync: ['sync:ldap', 'sync:github'] + +namespace :sync do + desc "Synchronize Github users and teams" + task github: :environment do + puts "Synchronizing Github..." + sync = GithubSynchronizer.new + sync.run! + sync.stats.each do |key, val| + puts " #{key}: #{val}" + end + unless sync.errors.empty? + puts " Errors:" + sync.errors.each do |error| + puts " #{error}" + end + end + end + + desc "Synchronize Active Directory users" + task ldap: :environment do + puts "Synchronizing Active Directory..." + sync = LdapSynchronizer.new + sync.run! + sync.stats.each do |key, val| + puts " #{key}: #{val}" + end + unless sync.errors.empty? + puts " Errors:" + sync.errors.each do |error| + puts " #{error}" + end + end + end +end diff --git a/lib/transition_github_users.rb b/lib/transition_github_users.rb new file mode 100644 index 0000000..33738f0 --- /dev/null +++ b/lib/transition_github_users.rb @@ -0,0 +1,207 @@ +# Updates every {GithubUser}'s state based on existing attributes. +# You should normally run the {GithubSynchronizer} and {LdapSynchronizer} +# before running this executor. +# +# Users that meet the current ruleset will be enabled. Those that do not +# meet the current rule set will be disabled. +class TransitionGithubUsers < BaseExecutor + + # A list of users to check and transition + # @return [Enumerable] + attr_reader :github_users + + # A hash of statistics from the most recent run. Includes: + # * users_transitioned + # * users_removed + # * users_restricted + # * transition_errors + # * transition_time + # * enforce_errors + # * remove_time + # + # @return [Hash] + attr_reader :stats + + # The users that were transitioned as a result of this executor. + # @return [Array] + attr_reader :transitions + + # The users that were removed from teams as a result of this executor. + # This may include previously disabled users that have been added to + # teams (via the Github UI) after the user was disabled. + # @return [Array] + attr_reader :removed_github_users + + # The users that were restricted to external teams as a result of this + # executor. This may include previous restricted users that have been + # added to internal teams (via the Github UI) after the user was + # restricted. + # @return [Array] + attr_reader :restricted_github_users + + # @param users [Enumerable] a list of users to check + def initialize(github_users=GithubUser.all) + super() + @github_users = github_users + @transitions = [] + @removed_github_users = [] + @restricted_github_users = [] + @stats = {} + end + + # Checks GithubUsers and disables those with failing rules. + # + # @return [Boolean] `true` if completed successfully + def transition_users + start = Time.now + stats[:users_transitioned] = 0 + stats[:users_removed] ||= 0 + stats[:users_restricted] ||= 0 + stats[:transition_errors] = 0 + + if github_users.is_a?(ActiveRecord::Relation) + github_users.reload + end + + thread_for_each(github_users) do |github_user| + begin + github_user.github_admin = github_admin + if github_user.transition + synchronize do + stats[:users_transitioned] += 1 + stats[:users_removed] += 1 if github_user.disabled? + stats[:users_restricted] += 1 if github_user.external? + @transitions << github_user + @removed_github_users << github_user if github_user.disabled? + @restricted_github_users << github_user if github_user.external? + end + end + rescue => e + synchronize do + stats[:transition_errors] += 1 + @errors << e + Rails.logger.error "Error processing user #{github_user.login}: #{e}" + end + end + end + + stats[:transition_errors] == 0 + rescue => e + @errors << e + stats[:transition_errors] += 1 + Rails.logger.error "Error checking and transitioning users: #{e.message}" + false + ensure + stats[:transition_time] = Time.now.to_f - start.to_f + end + + # A Github admin client. + # + # @return [GithubAdmin] + def github_admin + @github_admin ||= GithubAdmin.new + end + + # Removes GitHub users in disabled state and ensures external users + # only belong to external teams. + # + # @return [Boolean] `true` if completed successfully + def enforce_state + start = Time.now + + unless settings.enforce_rules + Rails.logger.info "Skipping state enforcement because settings.enforce_rules is false" + return false + end + + stats[:users_removed] ||= 0 + stats[:users_restricted] ||= 0 + stats[:enforce_errors] = 0 + + disabled_users = GithubUser.disabled + thread_for_each(disabled_users) do |github_user| + begin + github_user.github_admin = github_admin + teams = github_user.remove_from_organizations + if teams && !teams.empty? + github_user.disabled_teams = teams + end + synchronize do + if teams && !teams.empty? + stats[:users_removed] += 1 + @removed_github_users << github_user + end + end + rescue => e + synchronize do + stats[:enforce_errors] += 1 + @errors << e + Rails.logger.error "Error processing disabled user #{github_user.login}: #{e}" + end + end + end + + external_users = GithubUser.external + thread_for_each(external_users) do |github_user| + begin + github_user.github_admin = github_admin + teams = github_user.remove_from_internal_teams + if teams && !teams.empty? + github_user.disabled_teams = teams + end + synchronize do + if teams && !teams.empty? + stats[:users_restricted] += 1 + @restricted_github_users << github_user + end + end + rescue => e + synchronize do + stats[:enforce_errors] += 1 + @errors << e + Rails.logger.error "Error processing external user #{github_user.login}: #{e}" + end + end + end + + stats[:enforce_errors] == 0 + rescue => e + stats[:enforce_errors] += 1 + @errors << e + Rails.logger.error "Error removing disabled users: #{e.message}" + false + ensure + stats[:remove_time] = Time.now.to_f - start.to_f + end + + # Checks users and disables those that do not meet the acceptance + # criteria. Checks are run in threads according to {#thread_count}. + # + # @return [Boolean] `true` if executor executed successfully, `false` otherwise + def run! + start = Time.now + @errors = [] + @transitions = [] + @removed_github_users = [] + @restricted_github_users = [] + + # Attempt to workaround an auto-loading threading issue that causes + # "Circular dependency detected while autoloading constant Rules". + Rules.enabled_rules + + settings.with_disconnected do |settings| + settings.reload + + transition_users + enforce_state + + stats[:total_time] = Time.now.to_f - start.to_f + end + + @errors.empty? + end + + def settings + Rails.application.settings + end +end diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..b612547 --- /dev/null +++ b/public/404.html @@ -0,0 +1,67 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
    +
    +

    The page you were looking for doesn't exist.

    +

    You may have mistyped the address or the page may have moved.

    +
    +

    If you are the application owner check the logs for more information.

    +
    + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..a21f82b --- /dev/null +++ b/public/422.html @@ -0,0 +1,67 @@ + + + + The change you wanted was rejected (422) + + + + + + +
    +
    +

    The change you wanted was rejected.

    +

    Maybe you tried to change something you didn't have access to.

    +
    +

    If you are the application owner check the logs for more information.

    +
    + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..061abc5 --- /dev/null +++ b/public/500.html @@ -0,0 +1,66 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
    +
    +

    We're sorry, but something went wrong.

    +
    +

    If you are the application owner check the logs for more information.

    +
    + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..3c9c7c0 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,5 @@ +# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/spec/controllers/connect_controller_spec.rb b/spec/controllers/connect_controller_spec.rb new file mode 100644 index 0000000..a85a239 --- /dev/null +++ b/spec/controllers/connect_controller_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe ConnectController, :type => :controller do + before do + sign_in(user) + configured + end + + let(:user) { create(:user) } + let(:settings) { double.as_null_object } + + before do + allow(Rails.application).to receive(:settings).and_return(settings) + allow(controller).to receive(:current_user).and_return(user) + end + + describe "GET 'index'" do + it "returns http success" do + get 'index' + expect(response).to be_success + end + + it 'sets a new ConnectGithubUserStatus' do + get 'index' + connect_status = assigns(:connect_status) + expect(connect_status).to be_a(ConnectGithubUserStatus) + expect(connect_status).to be_new_record + end + end + + describe "GET 'status'" do + let(:connect_status) { ConnectGithubUserStatus.create(user: user) } + + it 'returns http success' do + get 'status', id: connect_status.id + expect(response).to be_success + end + + context 'with another user' do + let(:connect_status) { ConnectGithubUserStatus.create(user: create(:user)) } + + it 'returns http forbidden' do + get 'status', id: connect_status.id + expect(response).to be_forbidden + end + end + + end + + describe "GET 'start'" do + subject { get :start } + + before do + allow(settings).to receive(:github_client_id).and_return('fooclient') + allow(settings).to receive(:github_client_secret).and_return('foosecret') + end + + def redirect_params + uri = URI.parse(response['Location']) + CGI.parse(uri.query).inject({}) do |memo, (key, val)| + memo[key] = val.length == 1 ? val.first : val + memo + end + end + + it "redirects to GitHub's OAuth authorization page" do + expect(subject).to redirect_to(%r(^https://github.com/login/oauth/authorize)) + expect(response['Location']).to include('client_id=fooclient') + end + + it 'requests user:email scope' do + subject + expect(redirect_params).to include('scope' => 'user:email,read:public_key,write:org') + end + + it 'sets the callback url to the auth_code action' do + subject + expect(redirect_params).to include('redirect_uri' => 'http://test.host/connect/auth_code') + end + + it 'includes a CSRF state parameter' do + subject + expect(redirect_params).to include('state') + end + end + + describe "GET 'auth_code'" do + subject { get :auth_code, code: code, state: state } + let(:state) { 'foostate' } + let(:code) { 'foocode' } + #let(:oauth) { double('oauth', auth_code: double(get_token: oauth_token)) } + #let(:oauth_token) { double('oauth_token', token: 'footoken') } + #let(:octokit) { double('octokit', user: ghuser) } + #let(:ghuser) { double(id: 1337, login: 'githubuser') } + + before do + allow(controller).to receive(:oauth_authenticity_token).and_return(state) + #allow(controller).to receive(:oauth_client).and_return(oauth) + #allow_any_instance_of(GithubUser).to receive(:sync!) { |ghu| ghu.save! } + #allow_any_instance_of(GithubUser).to receive(:add_to_organizations).and_return(true) + #allow(Octokit::Client).to receive(:new).and_return(octokit) + end + + it 'redirects to status' do + expect(subject).to redirect_to(/\/connect\/\d+/) + end + + it 'starts a ConnectGithubUserJob' do + expect(ConnectGithubUserJob).to receive(:perform_later) do |status| + expect(status.oauth_code).to eq(code) + end + subject + end + + it 'rejects invalid CSRF token' do + expect(controller).to receive(:oauth_authenticity_token).and_return('wrongtoken') + expect { subject }.to raise_error(ActionController::InvalidAuthenticityToken) + end + end +end diff --git a/spec/controllers/dashboard_controller_spec.rb b/spec/controllers/dashboard_controller_spec.rb new file mode 100644 index 0000000..b398e70 --- /dev/null +++ b/spec/controllers/dashboard_controller_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe DashboardController, :type => :controller do + before do + sign_in + configured + end + + describe "GET 'index'" do + it "returns http success" do + get 'index' + expect(response).to be_success + end + + it 'redirects to setup wizard if application is not configured' do + Rails.application.settings.configured = false + get 'index' + expect(response).to redirect_to(setup_url) + end + + it 'returns a http error if an LDAP authentication error occurs' do + allow(controller).to receive(:index).and_raise(DeviseLdapAuthenticatable::LdapException) + get 'index' + expect(response).to be_error + end + end + +end diff --git a/spec/controllers/github_users_controller_spec.rb b/spec/controllers/github_users_controller_spec.rb new file mode 100644 index 0000000..179468e --- /dev/null +++ b/spec/controllers/github_users_controller_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe GithubUsersController, :type => :controller do + before do + sign_in(user) + configured + end + + let(:user) { create(:admin_user) } + let(:github_user) { create(:github_user) } + + describe "GET index" do + it "returns http success" do + get :index + expect(response).to be_success + end + end + + describe "GET show" do + it "returns http success" do + get :show, id: github_user.login + expect(response).to be_success + end + end + +end diff --git a/spec/controllers/settings_controller_spec.rb b/spec/controllers/settings_controller_spec.rb new file mode 100644 index 0000000..a9a5b98 --- /dev/null +++ b/spec/controllers/settings_controller_spec.rb @@ -0,0 +1,138 @@ +require 'rails_helper' + +RSpec.describe SettingsController, :type => :controller do + before do + sign_in(user) + configured + end + + let(:user) { create(:admin_user) } + + before do + allow(controller).to receive(:current_user).and_return(user) + end + + describe "GET 'edit'" do + it "returns http success" do + get 'edit' + expect(response).to be_success + end + end + + describe "PUT 'update'" do + subject { put 'update', settings: settings } + let(:ldap) { double('ldap', bind: true).as_null_object } + let(:settings) {{ + ldap_host: 'foohost', + ldap_port: 3389, + github_orgs: "org1\r\norg2", + }} + + before do + allow(Net::LDAP).to receive(:new).and_return(ldap) + end + + it 'redirects to the edit page' do + expect(subject).to redirect_to(controller: :settings, action: :edit) + end + + it 'saves settings' do + subject + expect(Rails.application.settings.ldap_host).to eq('foohost') + end + + it 'converts organizations list to an array' do + subject + expect(Rails.application.settings.github_orgs).to eq(['org1', 'org2']) + end + + it 'tests ldap connection before saving' do + expect(ldap).to receive(:bind).and_return(false) + expect(subject).to_not be_redirect + expect(assigns(:error)).to_not be_nil + end + + it 'handles ldap errors' do + expect(ldap).to receive(:bind).and_raise(Net::LDAP::LdapError) + expect(subject).to_not be_redirect + expect(assigns(:error)).to_not be_nil + end + + context 'with connect_github parameter' do + it "calls github_admin action" do + expect(controller).to receive(:github_admin) { controller.redirect_to('foobar') } + put 'update', settings: settings, connect_github: 'connect' + end + end + end + + describe "GET 'github_admin'" do + subject { get :github_admin } + let(:settings) { double.as_null_object } + + before do + allow(Rails.application).to receive(:settings).and_return(settings) + allow(settings).to receive(:github_client_id).and_return('fooclient') + allow(settings).to receive(:github_client_secret).and_return('foosecret') + allow(controller).to receive(:load_settings) + end + + def redirect_params + uri = URI.parse(response['Location']) + CGI.parse(uri.query).inject({}) do |memo, (key, val)| + memo[key] = val.length == 1 ? val.first : val + memo + end + end + + it "redirects to GitHub's OAuth authorization page" do + expect(subject).to redirect_to(%r(^https://github.com/login/oauth/authorize)) + expect(response['Location']).to include('client_id=fooclient') + end + + it 'requests org:admin scope' do + subject + expect(redirect_params).to include('scope' => 'user:email,read:public_key,write:org,admin:org') + end + + it 'sets the callback url to the github_auth_code action' do + subject + expect(redirect_params).to include('redirect_uri' => 'http://test.host/settings/github_auth_code') + end + + it 'includes a CSRF state parameter' do + subject + expect(redirect_params).to include('state') + end + end + + describe "GET 'github_auth_code'" do + subject { get :github_auth_code, state: state } + let(:state) { 'foostate' } + let(:oauth) { double('oauth', auth_code: double(get_token: oauth_token)) } + let(:oauth_token) { double('oauth_token', token: 'footoken') } + let(:octokit) { double('octokit', user: ghuser) } + let(:ghuser) { double(id: 7337, login: 'githubuser') } + + before do + allow(controller).to receive(:oauth_authenticity_token).and_return(state) + allow(controller).to receive(:oauth_client).and_return(oauth) + allow_any_instance_of(GithubUser).to receive(:sync!) + allow(Octokit::Client).to receive(:new).and_return(octokit) + end + + it 'redirects to edit' do + expect(subject).to redirect_to(controller: 'settings', action: 'edit') + end + + it 'stores the GitHub token' do + subject + expect(Rails.application.settings.github_admin_token).to eq(oauth_token.token) + end + + it 'rejects invalid CSRF token' do + expect(controller).to receive(:oauth_authenticity_token).and_return('wrongtoken') + expect { subject }.to raise_error(ActionController::InvalidAuthenticityToken) + end + end +end diff --git a/spec/controllers/setup/admin_user_controller_spec.rb b/spec/controllers/setup/admin_user_controller_spec.rb new file mode 100644 index 0000000..445e539 --- /dev/null +++ b/spec/controllers/setup/admin_user_controller_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Setup::AdminUserController, :type => :controller do + + let(:user) { create(:user) } + + before do + @request.env["devise.mapping"] = Devise.mappings[:user] + end + + describe "GET 'new'" do + it "returns http success" do + get 'new' + expect(response).to be_success + end + + it 'signs out existing users' do + sign_in user + get 'new' + expect(controller).to_not be_signed_in + end + end + + describe "POST 'create'" do + subject { post 'create', user: {username: user.username, password: 'foopass'} } + + it 'sets the admin user' do + allow(controller.warden).to receive(:authenticate!).and_return(user) + expect(subject).to be_redirect + expect(user).to be_an_admin + end + end + +end diff --git a/spec/controllers/setup/company_controller_spec.rb b/spec/controllers/setup/company_controller_spec.rb new file mode 100644 index 0000000..ad34b98 --- /dev/null +++ b/spec/controllers/setup/company_controller_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Setup::CompanyController, :type => :controller do + + describe "GET 'edit'" do + it "returns http success" do + get 'edit' + expect(response).to be_success + end + end + + describe "PUT 'update'" do + subject { put 'update', settings: {company: 'foocompany'} } + + it 'saves settings' do + subject + expect(Rails.application.settings.company).to eq('foocompany') + end + end + +end diff --git a/spec/controllers/setup/email_controller_spec.rb b/spec/controllers/setup/email_controller_spec.rb new file mode 100644 index 0000000..8f353d1 --- /dev/null +++ b/spec/controllers/setup/email_controller_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Setup::EmailController, :type => :controller do + + describe "GET 'edit'" do + it "returns http success" do + get 'edit' + expect(response).to be_success + end + + it 'sets default email from company name' do + allow(request).to receive(:host).and_return('localhost') + Rails.application.settings.company = 'Example Corp' + get 'edit' + expect(assigns(:settings).email_from).to eq('github@example_corp.com') + end + + it 'sets default email from url domain' do + allow(request).to receive(:host).and_return('foocorp.com') + get 'edit' + expect(assigns(:settings).email_from).to eq('github@foocorp.com') + end + end + + describe "PUT 'update'" do + subject { put 'update', settings: {smtp_address: 'localhost'} } + + it 'saves settings' do + subject + expect(Rails.application.settings.smtp_address).to eq('localhost') + end + end + +end diff --git a/spec/controllers/setup/github_controller_spec.rb b/spec/controllers/setup/github_controller_spec.rb new file mode 100644 index 0000000..3263c58 --- /dev/null +++ b/spec/controllers/setup/github_controller_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Setup::GithubController, :type => :controller do + + describe "GET 'edit'" do + it "returns http success" do + get 'edit' + expect(response).to be_success + end + + it 'sets default orgs' do + Rails.application.settings.company = 'Example' + get 'edit' + expect(assigns(:settings).github_orgs).to eq(['example']) + end + + it 'sets default teams' do + Rails.application.settings.company = 'Example' + get 'edit' + expect(assigns(:settings).github_default_teams).to eq(['example-employees']) + end + end + + describe "PUT 'update'" do + let(:settings) { {github_orgs: 'foocompany'} } + subject { put 'update', settings: settings } + + it 'saves settings' do + subject + expect(Rails.application.settings.github_orgs).to eq(['foocompany']) + end + + context 'with connect_github parameter' do + it 'calls github_admin action' do + expect(controller).to receive(:github_admin) { controller.redirect_to('foobar') } + put 'update', settings: settings, connect_github: 'connect' + end + end + end + +end diff --git a/spec/controllers/setup/ldap_controller_spec.rb b/spec/controllers/setup/ldap_controller_spec.rb new file mode 100644 index 0000000..db3c569 --- /dev/null +++ b/spec/controllers/setup/ldap_controller_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe Setup::LdapController, :type => :controller do + + describe "GET 'edit'" do + it "returns http success" do + get 'edit' + expect(response).to be_success + end + + it 'redirects to settings if application is already configured' do + Rails.application.settings.configured = true + get 'edit' + expect(response).to redirect_to(controller: '/settings', action: :edit) + end + + it 'sets development defaults for localhost' do + allow(request).to receive(:host).and_return('localhost') + get 'edit' + expect(assigns(:settings).ldap_base).to eq('dc=example,dc=com') + end + end + + describe "PUT 'update'" do + subject { put 'update', settings: {ldap_host: 'foohost', ldap_port: 3389} } + let(:ldap) { double('ldap', bind: true).as_null_object } + + before do + allow(Net::LDAP).to receive(:new).and_return(ldap) + end + + it 'saves settings' do + subject + expect(Rails.application.settings.ldap_host).to eq('foohost') + end + + it 'tests ldap connection before saving' do + expect(ldap).to receive(:bind).and_return(false) + expect(subject).to_not be_redirect + expect(assigns(:error)).to_not be_nil + end + end +end diff --git a/spec/controllers/setup/rules_controller_spec.rb b/spec/controllers/setup/rules_controller_spec.rb new file mode 100644 index 0000000..be1d704 --- /dev/null +++ b/spec/controllers/setup/rules_controller_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Setup::RulesController, :type => :controller do + + describe "GET 'edit'" do + it "returns http success" do + get 'edit' + expect(response).to be_success + end + end + + describe "PUT 'update'" do + subject { put 'update', settings: {rule_max_sync_age: 60} } + + it 'saves settings' do + subject + expect(Rails.application.settings.rule_max_sync_age).to eq(60) + end + end + +end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb new file mode 100644 index 0000000..62181e3 --- /dev/null +++ b/spec/controllers/users_controller_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe UsersController, :type => :controller do + before do + sign_in(user) + configured + end + + let(:user) { create(:admin_user, name: 'Admin User') } + + describe "GET 'index'" do + it 'returns http success' do + get 'index' + expect(response).to be_success + end + + it 'loads users in order' do + get 'index' + create(:user, name: 'Aaron Sorts First') + names = assigns(:users).map { |user| user.name } + expect(names).to eq(['Aaron Sorts First', 'Admin User']) + end + end + + describe "GET 'show'" do + it "returns http success" do + get 'show', id: user.username + expect(response).to be_success + end + + context 'with admin user' do + it 'shows other users' do + create(:user, username: 'otheruser', name: 'Other User') + get 'show', id: 'otheruser' + expect(response).to be_success + expect(assigns(:user).username).to eq('otheruser') + end + end + + context 'with non-admin user' do + let(:user) { create(:user, name: 'Regular User') } + + it 'shows own user' do + get 'show', id: user.username + expect(response).to be_success + end + + it 'does not show other users' do + create(:user, username: 'otheruser', name: 'Other User') + get 'show', id: 'otheruser' + expect(response).to be_forbidden + end + end + end + + describe "GET 'edit'" do + it "returns http success" do + get 'edit', id: user.username + expect(response).to be_success + end + end + + describe "PATCH 'edit'" do + it "redirects after save" do + patch 'update', id: user.username, user: {admin: 0} + expect(response).to be_redirect + end + end +end diff --git a/spec/factories/github_email.rb b/spec/factories/github_email.rb new file mode 100644 index 0000000..069c08c --- /dev/null +++ b/spec/factories/github_email.rb @@ -0,0 +1,6 @@ +FactoryGirl.define do + factory :github_email do + sequence(:address) { |n| "githubemail#{n}@example.com" } + github_user + end +end diff --git a/spec/factories/github_team.rb b/spec/factories/github_team.rb new file mode 100644 index 0000000..5613108 --- /dev/null +++ b/spec/factories/github_team.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :github_team do + sequence(:slug) { |n| "githubteam#{n}" } + end +end diff --git a/spec/factories/github_user.rb b/spec/factories/github_user.rb new file mode 100644 index 0000000..a989b05 --- /dev/null +++ b/spec/factories/github_user.rb @@ -0,0 +1,19 @@ +FactoryGirl.define do + factory :github_user do + sequence(:login) { |n| "githubber#{n}" } + + factory :github_user_with_emails do + transient do + emails_count 2 + end + + after(:create) do |github_user, evaluator| + create_list(:github_email, evaluator.emails_count, github_user: github_user) + end + end + + factory :github_user_with_user do + user + end + end +end diff --git a/spec/factories/user.rb b/spec/factories/user.rb new file mode 100644 index 0000000..88848a5 --- /dev/null +++ b/spec/factories/user.rb @@ -0,0 +1,15 @@ +FactoryGirl.define do + factory :user do + sequence(:username) { |n| "fakeuser#{n}" } + + factory :user_with_github_users do + transient do + github_users_count 2 + end + end + + factory :admin_user do + admin true + end + end +end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 0000000..4ed01af --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +describe ApplicationHelper do + describe '#format_time' do + it 'adds data-time attribute' do + html = format_time(Time.now) + expect(html).to include('data-time') + end + end +end diff --git a/spec/helpers/github_users_helper_spec.rb b/spec/helpers/github_users_helper_spec.rb new file mode 100644 index 0000000..c15e3e7 --- /dev/null +++ b/spec/helpers/github_users_helper_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +describe GithubUsersHelper do + describe '#github_user_state_label' do + let(:github_user) { build(:github_user) } + + it 'adds danger label for disabled users' do + github_user.state = 'disabled' + html = github_user_state_label(github_user) + expect(html).to include('label-danger') + end + + it 'adds info label for external users' do + github_user.state = 'external' + html = github_user_state_label(github_user) + expect(html).to include('label-info') + end + + it 'adds info label for excluded users' do + github_user.state = 'excluded' + html = github_user_state_label(github_user) + expect(html).to include('label-info') + end + + it 'adds warning label for unknown users' do + github_user.state = 'unknown' + html = github_user_state_label(github_user) + expect(html).to include('label-warning') + end + + it 'adds success label for enabled users' do + github_user.state = 'enabled' + html = github_user_state_label(github_user) + expect(html).to include('label-success') + end + end +end diff --git a/spec/jobs/connect_github_user_job_spec.rb b/spec/jobs/connect_github_user_job_spec.rb new file mode 100644 index 0000000..f0d6e83 --- /dev/null +++ b/spec/jobs/connect_github_user_job_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +describe ConnectGithubUserJob do + subject(:job) { ConnectGithubUserJob.new } + subject(:status) { ConnectGithubUserStatus.new(step: :grant) } + + let(:oauth) { double('oauth', auth_code: double(get_token: oauth_token)) } + let(:oauth_token) { double('oauth_token', token: 'footoken') } + let(:octokit) { double('octokit', user: ghuser) } + let(:ghuser) { double(id: 1337, login: 'githubuser') } + let(:github_user) { build(:github_user, id: 1337) } + + before do + allow(job).to receive(:oauth_client).and_return(oauth) + allow(GithubUser).to receive(:find_or_initialize_by).and_return(github_user) + allow(github_user).to receive(:sync!) { github_user.save! } + allow(github_user).to receive(:add_to_organizations).and_return(true) + allow(github_user).to receive(:do_enable) + allow(github_user).to receive(:do_disable) + allow(github_user).to receive(:do_notify_disabled) + allow(Octokit::Client).to receive(:new).and_return(octokit) + end + + it 'validates OAuth code' do + expect(job).to receive(:oauth_process_auth_code).and_return(github_user) + job.perform(status) + expect(status.status).to eq(:complete) + end + + it 'adds user to organzations' do + expect(github_user).to receive(:add_to_organizations).and_return(true) + job.perform(status) + expect(status.status).to eq(:complete) + end + + it 'enables the user' do + expect(github_user).to receive(:enable) + job.perform(status) + expect(status.status).to eq(:complete) + end + + it 'stores error if OAuth fails' do + oauth_response = double.as_null_object + expect(job).to receive(:oauth_process_auth_code).and_raise(OAuth2::Error.new(oauth_response)) + job.perform(status) + expect(status.status).to eq(:error) + end + + it 'stores error if add_to_organizations fails' do + expect(github_user).to receive(:add_to_organizations).and_return(false) + job.perform(status) + expect(status.status).to eq(:error) + end + + it 'stores error if unexpected error occurs' do + allow(github_user).to receive(:add_to_organizations).and_raise('fooerror') + job.perform(status) + expect(status.status).to eq(:error) + end +end diff --git a/spec/lib/github_admin_spec.rb b/spec/lib/github_admin_spec.rb new file mode 100644 index 0000000..b7e2f98 --- /dev/null +++ b/spec/lib/github_admin_spec.rb @@ -0,0 +1,137 @@ +require 'rails_helper' + +describe GithubAdmin do + subject(:github) { GithubAdmin.new } + + context 'with GitHub' do + + def double_team(id, name, attrs={}) + double(id: id, name: name, to_h: attrs.merge( + id: id, + name: name, + slug: name.parameterize.underscore, + organization: {login: 'org1'} + )) + end + + def double_user(username, attrs={}) + double(login: username, to_h: attrs.merge(login: username)) + end + + let(:octokit) { double } + let(:settings) { double(github_admin_token: 'footoken', github_orgs: orgs) } + let(:orgs) { ['org1', 'org2'] } + let(:users) {[ + double_user('hsimpson'), + double_user('msimpson'), + double_user('bsimpson'), + ]} + let(:mfa_disabled_users) {[ + double_user('bsimpson'), + ]} + let(:teams) {[ + double_team(1, 'My Team 1'), + double_team(2, 'My Team 2'), + ]} + let(:team_members) { users[0..1] } + + before do + allow(github).to receive(:octokit).and_return(octokit) + allow(github).to receive(:settings).and_return(settings) + allow(octokit).to receive(:organization_members).with(anything).and_return(users) + allow(octokit).to receive(:organization_members).with(anything, {filter: '2fa_disabled'}).and_return(mfa_disabled_users) + allow(octokit).to receive(:organization_teams).and_return(teams) + allow(octokit).to receive(:team).and_return(teams.first) + allow(octokit).to receive(:team_members).and_return(team_members) + end + + it 'searches all configured organizations for users' do + expect(octokit).to receive(:organization_members).with('org1').and_return(users) + expect(octokit).to receive(:organization_members).with('org2').and_return(users) + github.org_users + end + + it 'adds :mfa_enabled attribute to user hashes' do + users = github.org_users + expect(users['hsimpson']).to have_key(:mfa_enabled) + expect(users['hsimpson'][:mfa_enabled]).to eq(true) + expect(users['bsimpson'][:mfa_enabled]).to eq(false) + end + + it 'adds :orgs attribute to user hashes' do + users = github.org_users + expect(users['hsimpson']).to have_key(:orgs) + expect(users['hsimpson'][:orgs]).to eq(['org1', 'org2']) + end + + it 'adds organization to teams' do + teams = github.teams + expect(teams.values.first).to have_key(:organization) + expect(teams.values.first[:organization]).to be_a(String) + end + + it 'adds organization to individual teams' do + team = github.team(1) + expect(team).to have_key(:organization) + expect(team[:organization]).to be_a(String) + end + + it 'searches for team by id' do + expect(octokit).to receive(:team).with(1).and_return(teams.first) + github.team(1) + end + + it 'searches for team with hash' do + expect(octokit).to receive(:team).with(1).and_return(teams.first) + github.team({id: 1}) + end + + it 'searches for team by slug' do + team = github.team("my_team_1") + expect(team).to be_a(Hash) + expect(team[:name]).to eq('My Team 1') + end + + it 'fetches team members' do + team_members = github.team_members(1) + expect(team_members).to_not be_empty + end + + it 'checks MFA for a single user' do + allow(octokit).to receive(:organization_member?).and_return(true) + expect(github.user_mfa?('hsimpson')).to eq(true) + expect(github.user_mfa?('bsimpson')).to eq(false) + end + + it 'only checks MFA if user is a member of an organization' do + expect(octokit).to receive(:organization_member?).and_return(false).at_least(1) + expect(octokit).to_not receive(:organization_members) + expect(github.user_mfa?('foouser')).to_not eq(true) + end + + it 'uses cached users to query MFA if available' do + github.org_users + expect(octokit).to_not receive(:organization_members) + expect(github.user_mfa?('hsimpson')).to eq(true) + expect(github.user_mfa?('bsimpson')).to eq(false) + end + end + + it 'returns an Octokit client' do + allow(github).to receive(:settings).and_return(double(github_admin_token: 'footoken')) + octokit = github.octokit + expect(octokit).to be_a(Octokit::Client) + expect(octokit.access_token).to eq('footoken') + end + + it 'auto paginates GitHub API responses' do + allow(github).to receive(:settings).and_return(double(github_admin_token: 'footoken')) + octokit = github.octokit + expect(octokit.auto_paginate).to eq(true) + end + + it 'references the application settings singleton' do + expect(Rails.application).to receive(:settings).and_call_original + expect(github.settings).to be_a(GithubConnector::Settings) + end +end diff --git a/spec/lib/github_synchronizer_spec.rb b/spec/lib/github_synchronizer_spec.rb new file mode 100644 index 0000000..b09c802 --- /dev/null +++ b/spec/lib/github_synchronizer_spec.rb @@ -0,0 +1,183 @@ +require 'rails_helper' +require 'github_synchronizer' + +describe GithubSynchronizer do + + subject(:synchronizer) { GithubSynchronizer.new } + let(:github_admin) { double } + let(:octokit) { double } + let(:org_users) { {} } + let(:teams) { {} } + let(:team_members) { {} } + + before do + allow(github_admin).to receive(:octokit).and_return(octokit) + allow(github_admin).to receive(:org_users).and_return(org_users) + allow(github_admin).to receive(:teams).and_return(teams) + allow(github_admin).to receive(:team).and_return(teams.values.first) + allow(github_admin).to receive(:team_members).and_return(team_members) + + # Mock methods that make real calls / send emails, etc. + allow(GithubAdmin).to receive(:new).and_return(github_admin) + allow_any_instance_of(GithubUser).to receive(:sync) + allow_any_instance_of(GithubUser).to receive(:transition) + allow_any_instance_of(GithubTeam).to receive(:sync) + allow_any_instance_of(User).to receive(:sync) + end + + describe '#sync_users' do + let(:users) { create_list(:github_user, 1, id: 1337, login: 'hsimpson') } + let(:org_users) {{ + 'hsimpson' => {id: 1337, login: 'hsimpson', mfa_enabled: true}, + 'msimpson' => {id: 7331, login: 'msimpson', mfa_enabled: true}, + }} + + it 'adds users' do + expect(synchronizer.sync_users).to eq(true) + expect(GithubUser.all).to_not be_empty + expect(GithubUser.all.map(&:login)).to include('msimpson') + end + + it 'removes Github users without corresponding app users' do + create(:github_user, login: 'foouser') + expect(synchronizer.sync_users).to eq(true) + expect(GithubUser.where(login: 'foouser')).to be_empty + end + + it 'does not remove Github users with corresponding app users' do + user = create(:user) + create(:github_user, login: 'foouser', user: user) + expect(synchronizer.sync_users).to eq(true) + expect(GithubUser.where(login: 'foouser')).to_not be_empty + end + + it 'synchronizes the Github mfa the attribute' do + user = users.first + expect(synchronizer.sync_users).to eq(true) + expect(user.reload.mfa).to eq(true) + end + + it 'synchronizes users with tokens' do + user = users.first + user.token = 'footoken' + user.save + allow_any_instance_of(GithubUser).to receive(:sync) do |user| + expect(user.login).to eq('hsimpson') + end + expect(synchronizer.sync_users).to eq(true) + end + + it 'continues if errors occur' do + allow(github_admin).to receive(:org_users).and_raise('foo error') + expect(synchronizer.sync_users).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors).to_not be_empty + end + + it 'continues if errors occur in threads' do + allow_any_instance_of(GithubUser).to receive(:save!).and_raise(ActiveRecord::RecordNotSaved) + expect(synchronizer.sync_users).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors.first).to be_a(ActiveRecord::RecordNotSaved) + end + + it 'counts sync errors as errors' do + allow_any_instance_of(GithubUser).to receive(:sync_error).and_return('foo error') + expect(synchronizer.sync_users).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors.first).to include('foo error') + end + + it 'runs in threads according to thread_count' do + synchronizer.thread_count = 2 + expect(Thread).to receive(:new).exactly(2).times.and_call_original + synchronizer.sync_users + end + end + + describe '#sync_teams' do + let(:teams) {{ + 1 => {id: 1, slug: 'myteam', organization: 'myorg'}, + 5 => {id: 5, slug: 'footeam', organization: 'myorg'}, + }} + + before do + GithubTeam.create!(id: 5, slug: 'myoldslug') + end + + it 'synchronizes each team' do + sync_count = 0 + allow_any_instance_of(GithubTeam).to receive(:sync) { sync_count += 1 } + expect(synchronizer.sync_teams).to eq(true) + expect(sync_count).to eq(2) + end + + it 'continues if errors occur' do + allow(github_admin).to receive(:teams).and_raise('foo error') + expect(synchronizer.sync_teams).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors).to_not be_empty + end + + it 'continues if errors occur in threads' do + allow_any_instance_of(GithubTeam).to receive(:sync).and_raise(ActiveRecord::RecordNotSaved) + expect(synchronizer.sync_teams).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors.first).to be_a(ActiveRecord::RecordNotSaved) + end + + it 'runs in threads according to thread_count' do + synchronizer.thread_count = 2 + expect(Thread).to receive(:new).exactly(2).times.and_call_original + synchronizer.sync_teams + end + end + + describe '#run!' do + let (:rate_limit) { double(remaining: 5000, resets_in: 3600) } + + before do + allow(octokit).to receive(:rate_limit).and_return(rate_limit) + end + + it 'synchronizes teams' do + expect(synchronizer).to receive(:sync_teams) + synchronizer.run! + end + + it 'synchronizes users' do + expect(synchronizer).to receive(:sync_users) + synchronizer.run! + end + + it 'returns true if successful' do + expect(synchronizer.run!).to eq(true) + end + + it 'returns false if errors occurred' do + allow(github_admin).to receive(:teams).and_raise("foo error") + expect(synchronizer.run!).to eq(false) + end + + it 'checks rate limit' do + expect(rate_limit).to receive(:remaining).and_return(10) + expect(synchronizer.run!).to eq(false) + end + end + + describe '.run!' do + before do + allow_any_instance_of(GithubSynchronizer).to receive(:run!) + end + + it 'runs the synchronizer' do + expect_any_instance_of(GithubSynchronizer).to receive(:run!) + instance = GithubSynchronizer.run! + end + + it 'returns the synchronizer object' do + expect(GithubSynchronizer.run!).to be_a(GithubSynchronizer) + end + end + +end diff --git a/spec/lib/ldap_synchronizer_spec.rb b/spec/lib/ldap_synchronizer_spec.rb new file mode 100644 index 0000000..f9a2635 --- /dev/null +++ b/spec/lib/ldap_synchronizer_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +describe LdapSynchronizer do + + subject(:synchronizer) { LdapSynchronizer.new([user]) } + let(:user) { build(:user) } + + before do + allow(user).to receive(:sync_from_ldap).and_return(true) + end + + describe '#sync_users' do + it 'calls sync_from_ldap' do + expect(user).to receive(:sync_from_ldap).and_return(true) + expect(synchronizer.sync_users).to eq(true) + end + + it 'continues if errors occur' do + allow(synchronizer).to receive(:users).and_raise('foo error') + expect(synchronizer.sync_users).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors).to_not be_empty + end + + it 'continues if errors occur in threads' do + allow(user).to receive(:sync_from_ldap).and_raise('foo error') + expect(synchronizer.sync_users).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors).to_not be_empty + end + + it 'counts sync errors as errors' do + allow(user).to receive(:ldap_sync_error).and_return('foo error') + expect(synchronizer.sync_users).to eq(false) + expect(synchronizer.errors).to be_a(Array) + expect(synchronizer.errors.first).to include('foo error') + end + end + + describe '#run!' do + it 'synchronizes users' do + expect(synchronizer).to receive(:sync_users) + synchronizer.run! + end + + it 'returns true if successful' do + expect(synchronizer.run!).to eq(true) + end + + it 'returns false if errors occurred' do + allow(user).to receive(:sync_from_ldap).and_raise("foo error") + expect(synchronizer.run!).to eq(false) + end + end +end diff --git a/spec/lib/rules/active_ldap_spec.rb b/spec/lib/rules/active_ldap_spec.rb new file mode 100644 index 0000000..92b32da --- /dev/null +++ b/spec/lib/rules/active_ldap_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Rules::ActiveLdap do + subject(:rule) { Rules::ActiveLdap.new(github_user) } + let(:user) { github_user.user } + let(:github_user) { build(:github_user_with_user) } + let(:settings) { double } + + before do + allow(described_class).to receive(:settings).and_return(settings) + end + + it 'is valid for a normal account' do + user.ldap_account_control = 512 + expect(rule).to be_valid + end + + it 'is not valid when account is disabled' do + user.ldap_account_control = 514 + expect(rule).to_not be_valid + end + + it 'is not valid without a User' do + github_user.user = nil + expect(rule).to_not be_valid + end + + it 'does not notify' do + expect(rule).to_not be_notify + end + + it 'is not required for external users' do + expect(rule).to_not be_required_for_external + end + + describe '#error_msg' do + it 'returns a generic error message' do + github_user.user = nil + expect(rule.error_msg).to be_a(String) + expect(rule.error_msg).to include('criteria') + end + + it 'returns an account disabled error message' do + user.ldap_account_control = User::AccountControl::ACCOUNT_DISABLED + expect(rule.error_msg).to include('disabled') + end + + #it 'returns a password expired error message' do + # user.ldap_account_control = User::AccountControl::PASSWORD_EXPIRED + # expect(rule.error_msg).to include('password') + #end + end +end diff --git a/spec/lib/rules/base_spec.rb b/spec/lib/rules/base_spec.rb new file mode 100644 index 0000000..2fa44ae --- /dev/null +++ b/spec/lib/rules/base_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +describe Rules::Base do + class TestRule < Rules::Base + end + + subject(:rule) { TestRule.new(user) } + let(:user) { double } + + it 'does not implement #result' do + expect { rule.result }.to raise_error(NotImplementedError) + end + + it 'notifies by default' do + expect(rule.notify?).to eq(true) + end + + it 'is required for external users by default' do + expect(rule).to be_required_for_external + end + + it 'converts class name to a rule name' do + expect(rule.name).to eq('test_rule') + end + + it 'references the application settings singleton' do + expect(Rails.application).to receive(:settings).and_call_original + expect(rule.settings).to be_a(GithubConnector::Settings) + end + + it 'returns an error message' do + expect(rule.error_msg).to be_a(String) + expect(rule.error_msg).to_not be_empty + end + + it 'is enabled by default' do + expect(TestRule).to be_enabled + end +end diff --git a/spec/lib/rules/email_spec.rb b/spec/lib/rules/email_spec.rb new file mode 100644 index 0000000..e5d019b --- /dev/null +++ b/spec/lib/rules/email_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +describe Rules::Email do + subject(:rule) { Rules::Email.new(github_user) } + let(:github_user) { create(:github_user_with_emails, user: user) } + let(:user) { create(:user) } + let(:settings) { double(rule_email_regex: regex) } + + before do + allow(described_class).to receive(:settings).and_return(settings) + end + + context 'with email regex' do + let(:regex) { '@example\.com$' } + + it 'is enabled' do + expect(described_class).to be_enabled + end + + it 'is valid when regex matches' do + expect(rule).to be_valid + end + + it "is not valid when regex doesn't match" do + github_email = github_user.emails.last + github_email.address = 'bsimpson@example.org' + github_email.save + expect(rule).to_not be_valid + end + + it 'does not check ldap address' do + user.email = 'bsimpson@example.org' + expect(rule).to be_valid + end + + it 'is not required for external users' do + expect(rule).to_not be_required_for_external + end + + it 'returns an error message' do + github_email = github_user.emails.last + github_email.address = 'bsimpson@example.org' + github_email.save + expect(rule.error_msg).to be_a(String) + expect(rule.error_msg).to include('bsimpson@example.org') + end + end + + context 'without email regex' do + let(:regex) { nil } + + it 'is not enabled' do + expect(described_class).to_not be_enabled + end + end +end diff --git a/spec/lib/rules/github_mfa_spec.rb b/spec/lib/rules/github_mfa_spec.rb new file mode 100644 index 0000000..b9a5617 --- /dev/null +++ b/spec/lib/rules/github_mfa_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +describe Rules::GithubMfa do + subject(:rule) { Rules::GithubMfa.new(github_user) } + let(:github_user) { build(:github_user_with_user) } + let(:user) { github_user.user } + let(:settings) { double } + + before do + allow(described_class).to receive(:settings).and_return(settings) + end + + it 'is valid when MFA is enabled' do + github_user.mfa = true + expect(rule).to be_valid + end + + it 'is invaid when MFA is disabled' do + github_user.mfa = false + expect(rule).to_not be_valid + end + + it 'is invalid when MFA is unknown' do + github_user.mfa = nil + expect(rule).to_not be_valid + end + + it 'is required for external users' do + expect(rule).to be_required_for_external + end + + it 'returns an error message' do + expect(rule.error_msg).to be_a(String) + expect(rule.error_msg).to include('factor') + end +end diff --git a/spec/lib/rules/github_oauth_spec.rb b/spec/lib/rules/github_oauth_spec.rb new file mode 100644 index 0000000..80b9d0d --- /dev/null +++ b/spec/lib/rules/github_oauth_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Rules::GithubOauth do + subject(:rule) { Rules::GithubOauth.new(github_user) } + let(:github_user) { build(:github_user_with_user) } + let(:user) { github_user.user } + let(:settings) { double } + + before do + allow(described_class).to receive(:settings).and_return(settings) + github_user.token = 'footoken' + end + + it 'is invalid when GitHub token is missing' do + github_user.token = nil + expect(rule).to_not be_valid + end + + it 'is invalid with notoken GitHub error' do + github_user.sync_error = 'notoken' + expect(rule).to_not be_valid + end + + it 'is invalid with unauthorized GitHub error' do + github_user.sync_error = 'unauthorized' + expect(rule).to_not be_valid + end + + it 'is valid with no errors' do + expect(rule).to be_valid + end + + it 'is valid with GitHub server error' do + github_user.sync_error = 'internal_server_error' + expect(rule).to be_valid + end + + it 'is not required for external users' do + expect(rule).to_not be_required_for_external + end + + it 'returns an error message when a token is missing' do + github_user.token = nil + expect(rule.error_msg).to be_a(String) + expect(rule.error_msg.downcase).to include('missing') + end + + it 'returns an error message when a token is missing' do + github_user.sync_error = 'unauthorized' + expect(rule.error_msg).to be_a(String) + expect(rule.error_msg.downcase).to include('invalid') + end +end diff --git a/spec/lib/rules/last_github_sync_spec.rb b/spec/lib/rules/last_github_sync_spec.rb new file mode 100644 index 0000000..28373dd --- /dev/null +++ b/spec/lib/rules/last_github_sync_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +describe Rules::LastGithubSync do + subject(:rule) { described_class.new(github_user) } + let(:user) { github_user.user } + let(:github_user) { build(:github_user_with_user) } + let(:settings) { double(rule_max_sync_age: max_sync_age) } + + before do + allow(described_class).to receive(:settings).and_return(settings) + end + + context 'with max sync setting' do + let(:max_sync_age) { 86400 } + + it 'is enabled' do + expect(described_class).to be_enabled + end + + it 'is valid when Github was recently synced' do + github_user.last_sync_at = Time.now + expect(rule).to be_valid + end + + it 'is not valid when Github sync is out of date' do + github_user.last_sync_at = Time.now - 2.days + expect(rule).to_not be_valid + end + + it 'is not valid when GitHub sync date is missing' do + github_user.last_sync_at = nil + expect(rule).to_not be_valid + end + + it 'is required for external users' do + expect(rule).to be_required_for_external + end + + describe '#error_msg' do + it 'returns an error message if GitHub user has never synced' do + github_user.last_sync_at = nil + expect(rule.error_msg).to include('never') + end + + it 'returns an error message if GitHub user is too old' do + github_user.last_sync_at = Time.now - 2.days + expect(rule.error_msg).to include('old') + end + end + + end + + context 'without max sync setting' do + let(:max_sync_age) { nil } + + it 'is not enabled' do + expect(described_class).to_not be_enabled + end + end +end diff --git a/spec/lib/rules/last_ldap_sync_spec.rb b/spec/lib/rules/last_ldap_sync_spec.rb new file mode 100644 index 0000000..0bda44c --- /dev/null +++ b/spec/lib/rules/last_ldap_sync_spec.rb @@ -0,0 +1,65 @@ +require 'rails_helper' + +describe Rules::LastLdapSync do + subject(:rule) { described_class.new(github_user) } + let(:user) { github_user.user } + let(:github_user) { build(:github_user_with_user) } + let(:settings) { double(rule_max_sync_age: max_sync_age) } + + before do + allow(described_class).to receive(:settings).and_return(settings) + end + + context 'with max sync setting' do + let(:max_sync_age) { 86400 } + + it 'is enabled' do + expect(described_class).to be_enabled + end + + it 'is valid when Active Directory was recently synced' do + user.last_ldap_sync = Time.now + expect(rule).to be_valid + end + + it 'is not valid when Active Directory sync is out of date' do + user.last_ldap_sync = Time.now - 2.days + expect(rule).to_not be_valid + end + + it 'is not valid when Active Directory sync date is missing' do + user.last_ldap_sync = nil + expect(rule).to_not be_valid + end + + it 'is not required for external users' do + expect(rule).to_not be_required_for_external + end + + describe '#error_msg' do + it 'returns an error message if LDAP user doesn\'t exist' do + github_user.user = nil + expect(rule.error_msg).to include('user') + end + + it 'returns an error message if Active Directory has never synced' do + user.last_ldap_sync = nil + expect(rule.error_msg).to include('never') + end + + it 'returns an error message if Active Directory is too old' do + user.last_ldap_sync = Time.now - 2.days + expect(rule.error_msg).to include('old') + end + end + + end + + context 'without max sync setting' do + let(:max_sync_age) { nil } + + it 'is not enabled' do + expect(described_class).to_not be_enabled + end + end +end diff --git a/spec/lib/rules_spec.rb b/spec/lib/rules_spec.rb new file mode 100644 index 0000000..426bbce --- /dev/null +++ b/spec/lib/rules_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +describe Rules do + it 'returns default rules' do + rules = [ + Rules::Email, + Rules::LastGithubSync, + Rules::LastLdapSync, + Rules::ActiveLdap, + Rules::GithubMfa + ] + + rules.each do |rule_klass| + allow(rule_klass).to receive(:enabled?).and_return(true) + end + + rules.each do |rule_klass| + expect(Rules.enabled_rules).to include(rule_klass) + end + end + + it 'returns instantiated objects for a specific user' do + user = build(:github_user) + rule = Rules.for_github_user(user).first + expect(rule).to be_a(Rules::Base) + expect(rule.github_user).to eq(user) + end + + describe Rules::Iterator do + let(:user) { double(:user) } + let(:rules) {[ + double(:rule1, name: 'rule1', valid?: false, required_for_external?: true), + double(:rule2, name: 'rule2', valid?: false, required_for_external?: false), + double(:rule3, name: 'rule3', valid?: true, required_for_external?: true), + ]} + let(:iterator) { described_class.new(rules) } + + it 'filters for failing rules' do + expect(iterator.failing.map(&:name)).to eq(%w(rule1 rule2)) + end + + it 'filters for passing rules' do + expect(iterator.passing.map(&:name)).to eq(%w(rule3)) + end + + it 'filters for external rules' do + expect(iterator.external.map(&:name)).to eq(%w(rule1 rule3)) + end + + it 'allows chaining filters' do + expect(iterator.failing.external.map(&:name)).to eq(%w(rule1)) + end + + it 'adds filters to clones without filtering original' do + iterator2 = iterator.dup + expect(iterator2.external.map(&:name)).to eq(%w(rule1 rule3)) + expect(iterator.map(&:name)).to eq(%w(rule1 rule2 rule3)) + end + end +end diff --git a/spec/lib/settings/base_spec.rb b/spec/lib/settings/base_spec.rb new file mode 100644 index 0000000..257a241 --- /dev/null +++ b/spec/lib/settings/base_spec.rb @@ -0,0 +1,206 @@ +require 'rails_helper' +require 'settings/base' + +describe Settings::Base do + + class TestSettings < ::Settings::Base + setting :myname + setting :mynumber, type: :integer + setting :myfloat, type: :float + setting :mybool, type: :boolean + setting :mydate, type: :datetime + setting :myarray, type: :array + setting :myhash, type: :hash + end + + subject(:settings) { TestSettings.new } + + describe '#dirty?' do + subject(:settings) { TestSettings.new.disconnect } + + it 'tracks dirty attributes' do + settings.myname = 'foobar' + expect(settings).to be_dirty(:myname) + end + + it 'tracks clean attributes' do + settings.myname = 'foobar' + expect(settings).to_not be_dirty(:mynumber) + end + end + + describe '#disconnect' do + it 'returns a cloned object' do + disconnected = settings.disconnect + expect(disconnected).to be_disconnected + expect(disconnected).to_not eq(settings) + end + + it 'does not disconnect the current object' do + settings.disconnect + expect(settings).to_not be_disconnected + end + end + + describe '#hash_for' do + before do + settings.myname = 'foobar' + settings.mynumber = 100 + end + + it 'returns a hash for the given keys' do + expect(settings.hash_for([:myname])).to eq({myname: 'foobar'}) + end + + it 'returns an empty hash if no keys are given' do + expect(settings.hash_for([])).to eq({}) + end + end + + describe '#keys' do + it 'returns all defined settings' do + expect(settings.keys).to eq(%i(myname mynumber myfloat mybool mydate myarray myhash)) + end + end + + describe '#load' do + subject(:settings) { TestSettings.new.disconnect } + + before do + Setting.create!(key: 'myname', value: 'foobar') + Setting.create!(key: 'mynumber', value: '100') + Setting.create!(key: 'myfloat', value: '100.0') + Setting.create!(key: 'mybool', value: 'true') + Setting.create!(key: 'mydate', value: '2014-06-30') + Setting.create!(key: 'myarray', value: ['foo', 'bar'].to_json) + Setting.create!(key: 'myhash', value: {'foo' => 'bar'}.to_json) + end + + it 'loads all settings' do + settings.load + expect(settings.myname).to eq('foobar') + expect(settings.mynumber).to eq(100) + end + + it 'loads a subset of settings' do + settings.load(:myname) + expect(settings.myname).to eq('foobar') + expect(settings.mynumber).to be_nil + end + + it 'loads array settings' do + settings.load(:myarray) + expect(settings.myarray).to eq(['foo', 'bar']) + end + + it 'loads hash settings' do + settings.load(:myhash) + expect(settings.myhash).to eq('foo' => 'bar') + end + end + + describe '#save' do + subject(:settings) { TestSettings.new.disconnect } + + it 'saves settings' do + settings.myname = 'foobar' + settings.mynumber = 100 + settings.myfloat = 100.1 + settings.mybool = false + settings.mydate = Time.now + settings.save + expect(Setting.count).to eq(5) + end + + it 'only saves dirty settings' do + Setting.create!(key: 'myname', value: 'foobar') + Setting.create!(key: 'mynumber', value: '100') + settings.load + settings.myname = 'foo' + expect_any_instance_of(Setting).to receive(:save!).exactly(1).times.and_call_original + settings.save + end + + it 'saves array settings with JSON' do + settings.myarray = ['foo', 'bar'] + settings.save + setting = Setting.find_by_key('myarray') + expect(setting.value).to eq(['foo', 'bar'].to_json) + end + + it 'saves hash settings with JSON' do + settings.myhash = {'foo' => 'bar'} + settings.save + setting = Setting.find_by_key('myhash') + expect(setting.value).to eq({'foo' => 'bar'}.to_json) + end + end + + describe '#to_h' do + before do + settings.myname = 'foobar' + settings.mynumber = 100 + end + + it 'returns a hash with all settings' do + expect(settings.to_h).to eq(myname: 'foobar', mynumber: 100) + end + end + + describe '#with_disconnected' do + it 'disconnects settings' do + expect(Rails.application.settings).to_not be_disconnected + Rails.application.settings.with_disconnected do + expect(Rails.application.settings).to be_disconnected + end + end + + it 'restores disconnected state' do + expect(Rails.application.settings).to_not be_disconnected + Rails.application.settings.with_disconnected {} + expect(Rails.application.settings).to_not be_disconnected + end + + it 'can be nested' do + expect(Rails.application.settings).to_not be_disconnected + Rails.application.settings.with_disconnected do |settings| + settings.with_disconnected {} + expect(Rails.application.settings).to be_disconnected + end + expect(Rails.application.settings).to_not be_disconnected + end + end + + context 'when connected' do + before do + Setting.create!(key: 'myname', value: 'foobar') + end + + it 'loads automatically' do + expect(settings.myname).to eq('foobar') + end + + it 'saves automatically' do + settings.mynumber = 100 + expect(Setting.where(key: :mynumber).first.value).to eq('100') + end + end + + context 'when disconnected' do + subject(:settings) { TestSettings.new.disconnect } + + before do + Setting.create!(key: 'myname', value: 'foobar') + end + + it 'does not load automatically' do + expect(settings.myname).to be_nil + end + + it 'does not save automatically' do + settings.mynumber = 100 + expect(Setting.where(key: :mynumber)).to be_empty + end + end + +end diff --git a/spec/lib/settings_spec.rb b/spec/lib/settings_spec.rb new file mode 100644 index 0000000..24da238 --- /dev/null +++ b/spec/lib/settings_spec.rb @@ -0,0 +1,75 @@ +require 'rails_helper' +require 'settings' + +describe GithubConnector::Settings do + + subject(:settings) { GithubConnector::Settings.new } + + describe '#apply_to_action_mailer' do + it 'applies config to ActionMailer::Base' do + settings.smtp_address = 'foohost' + settings.email_base_url = 'https://localhost:443/' + settings.email_from = 'github@fooemail' + settings.email_reply_to = '' + settings.apply_to_action_mailer + expect(ActionMailer::Base.smtp_settings[:address]).to eq('foohost') + expect(ActionMailer::Base.default_url_options).to eq({host: 'localhost', protocol: 'https'}) + expect(ActionMailer::Base.default[:from]).to eq('github@fooemail') + expect(ActionMailer::Base.default.keys).to_not include(:reply_to) + end + end + + describe '#email_keys' do + it 'returns a list of email keys' do + expect(settings.email_keys).to eq(%i(email_base_url email_from email_reply_to)) + end + end + + describe '#email_config' do + before do + Setting.create(key: :email_from, value: 'fooemail@example.com') + end + + it 'returns hash with email_ key prefixes removed' do + config = settings.email_config + expect(config).to have_key(:from) + expect(config).to_not have_key(:email_from) + end + end + + describe '#ldap_keys' do + it 'returns a list of ldap keys' do + expect(settings.ldap_keys).to eq(%i(ldap_host ldap_port ldap_ssl ldap_admin_user ldap_admin_password ldap_attribute ldap_base)) + end + end + + describe '#ldap_config' do + before do + Setting.create(key: :ldap_host, value: 'localhost') + end + + it 'returns hash with ldap_ key prefixes removed' do + config = settings.ldap_config + expect(config).to have_key('host') + expect(config).to_not have_key('ldap_host') + end + end + + describe '#smtp_keys' do + it 'returns a list of smtp keys' do + expect(settings.smtp_keys).to eq(%i(smtp_address smtp_port smtp_enable_starttls_auto smtp_user_name smtp_password smtp_authentication smtp_domain)) + end + end + + describe '#smtp_config' do + before do + Setting.create(key: :smtp_address, value: 'localhost') + end + + it 'returns hash with smtp_ key prefixes removed' do + config = settings.smtp_config + expect(config).to have_key(:address) + expect(config).to_not have_key(:smtp_address) + end + end +end diff --git a/spec/lib/transition_github_users_spec.rb b/spec/lib/transition_github_users_spec.rb new file mode 100644 index 0000000..54a4f27 --- /dev/null +++ b/spec/lib/transition_github_users_spec.rb @@ -0,0 +1,183 @@ +require 'rails_helper' +require 'transition_github_users' + +describe TransitionGithubUsers do + + subject(:executor) { TransitionGithubUsers.new(github_users) } + let(:github_admin) { double } + let(:github_users) { nil } + let(:github_teams) { [] } + + before do + Rails.application.settings.enforce_rules = true + + # Mock methods that make real calls / send emails, etc. + allow(GithubAdmin).to receive(:new).and_return(github_admin) + allow_any_instance_of(GithubUser).to receive(:sync) + allow_any_instance_of(GithubUser).to receive(:transition) + allow_any_instance_of(GithubUser).to receive(:remove_from_organizations).and_return(github_teams) + allow_any_instance_of(GithubUser).to receive(:remove_from_internal_teams).and_return(github_teams) + allow_any_instance_of(User).to receive(:sync) + end + + context 'with_users' do + let(:github_users) { create_list(:github_user, 10) } + + describe '#transition_users' do + it 'calls transition for each user' do + github_users.each do |user| + expect(user).to receive(:transition) + end + expect(executor.transition_users).to eq(true) + end + + it 'continues if errors occur' do + expect(executor).to receive(:github_users).and_raise('foo error') + expect(executor.transition_users).to eq(false) + expect(executor.errors).to be_a(Array) + expect(executor.errors).to_not be_empty + end + + it 'continues if errors occur in threads' do + expect(github_users[2]).to receive(:transition).and_raise('foo error') + expect(executor.transition_users).to eq(false) + expect(executor.errors).to be_a(Array) + expect(executor.errors).to_not be_empty + end + + it 'tracks transitioned users' do + expect(github_users[2]).to receive(:transition).and_return(:disable) + executor.transition_users + expect(executor.transitions).to be_a(Array) + expect(executor.transitions.count).to eq(1) + expect(executor.transitions.first).to eq(github_users[2]) + end + + it 'runs in threads according to thread_count' do + executor.thread_count = 2 + expect(Thread).to receive(:new).exactly(2).times.and_call_original + executor.transition_users + end + end + + describe '#enforce_state' do + let(:disabled_github_user) { github_users[1] } + let(:external_github_user) { github_users[2] } + let(:github_teams) { create_list(:github_team, 4) } + + before do + disabled_github_user.state = :disabled + disabled_github_user.teams << github_teams + disabled_github_user.save! + allow(GithubUser).to receive(:disabled).and_return([disabled_github_user]) + + external_github_user.state = :external + external_github_user.teams << github_teams + external_github_user.save! + allow(GithubUser).to receive(:external).and_return([external_github_user]) + end + + it 'calls remove_from_organizations' do + expect(disabled_github_user).to receive(:remove_from_organizations).and_return(github_teams) + github_users.each do |user| + next if user == disabled_github_user + expect(user).to_not receive(:remove_from_organizations) + end + executor.enforce_state + end + + it 'calls remove_from_internal_teams for external users' do + expect(external_github_user).to receive(:remove_from_internal_teams).and_return(github_teams) + github_users.each do |user| + next if user == external_github_user + expect(user).to_not receive(:remove_from_internal_teams) + end + executor.enforce_state + end + + it 'stores removed teams in disabled_teams' do + executor.enforce_state + expect(disabled_github_user.disabled_teams).to eq(github_teams) + expect(external_github_user.disabled_teams).to eq(github_teams) + end + + it 'does not run if enforce_rules is false' do + Rails.application.settings.enforce_rules = false + expect(disabled_github_user).to_not receive(:remove_from_organizations) + expect(external_github_user).to_not receive(:remove_from_internal_teams) + executor.enforce_state + end + + it 'continues if errors occur' do + expect(GithubUser).to receive(:disabled).and_raise('foo error') + expect(executor.enforce_state).to eq(false) + expect(executor.errors).to be_a(Array) + expect(executor.errors).to_not be_empty + end + + it 'continues if errors occur in threads' do + expect(disabled_github_user).to receive(:remove_from_organizations).and_raise('foo error') + expect(external_github_user).to receive(:remove_from_internal_teams).and_raise('foo error') + expect(executor.enforce_state).to eq(false) + expect(executor.errors).to be_a(Array) + expect(executor.errors).to_not be_empty + end + end + + describe '#run!' do + it 'disables users' do + expect(executor).to receive(:transition_users) + executor.run! + end + + it 'enforces user state' do + expect(executor).to receive(:enforce_state) + executor.run! + end + + it 'returns true if successful' do + expect(executor.run!).to eq(true) + end + + it 'returns false if errors occurred' do + allow(github_users[1]).to receive(:transition).and_raise('foo error') + expect(executor.run!).to eq(false) + end + end + end + + it 'runs with all users by default' do + create(:github_user_with_user, state: :enabled) + create(:github_user, state: :enabled) + create(:github_user_with_user, state: :unknown) + create(:github_user_with_user, state: :disabled) + executor = TransitionGithubUsers.new + expect(executor.github_users.count).to eq(4) + end + + it 'reloads ActiveRecord scopes' do + user = create(:github_user, state: :enabled) + executor = TransitionGithubUsers.new(GithubUser.where(state: 'enabled')) + expect(executor.github_users.size).to eq(1) + user.state = :disabled + user.save! + expect_any_instance_of(GithubUser).to_not receive(:sync) + executor.run! + end + + describe '.run!' do + before do + allow_any_instance_of(TransitionGithubUsers).to receive(:run!) + end + + it 'runs the executor' do + expect_any_instance_of(TransitionGithubUsers).to receive(:run!) + instance = TransitionGithubUsers.run! + end + + it 'returns the executor object' do + expect(TransitionGithubUsers.run!).to be_a(TransitionGithubUsers) + end + end + +end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb new file mode 100644 index 0000000..cbb4c30 --- /dev/null +++ b/spec/mailers/user_mailer_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe UserMailer, :type => :mailer do + + before do + Rails.application.settings.email_base_url = 'http://localhost:3000' + end + + describe '#access_revoked' do + subject(:mail) { UserMailer.access_revoked(user, github_user) } + + let(:user) { build(:user) } + let(:github_user) { build(:github_user, user: user) } + + it 'renders subject' do + expect(mail.subject).to eq('GitHub Access Revoked') + end + + it 'renders html' do + expect(mail).to be_multipart + expect(mail.html_part.body).to include('GitHub access revoked!') + end + + it 'renders plaintext' do + expect(mail).to be_multipart + expect(mail.text_part.body).to include('GitHub access revoked!') + end + end +end diff --git a/spec/models/connect_github_user_status_spec.rb b/spec/models/connect_github_user_status_spec.rb new file mode 100644 index 0000000..b793a7d --- /dev/null +++ b/spec/models/connect_github_user_status_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +describe ConnectGithubUserStatus do + subject(:status) { described_class.new(step: :request, status: :running) } + let(:step) { :request } + + it 'computes completed steps' do + expect(status.step_complete?(:add)).to eq(false) + expect(status.steps_completed).to be_empty + status.step = :add + expect(status.step_complete?(:request)).to eq(true) + status.status = :complete + expect(status.step_complete?(:teams)).to eq(true) + end + + it 'computes disabled steps' do + expect(status.step_disabled?(:grant)).to eq(true) + expect(status.step_disabled?(:create)).to eq(false) + end + + it 'computes in progress status' do + expect(status.in_progress?).to eq(true) + status.status = :complete + expect(status.in_progress?).to eq(false) + end + + it 'computes complete status' do + expect(status.complete?).to eq(false) + status.status = :complete + expect(status.complete?).to eq(true) + end + + it 'computes error steps' do + expect(status.step_error?(:request)).to eq(false) + status.status = :error + expect(status.step_error?(:request)).to eq(true) + expect(status.step_error?(:create)).to eq(false) + end +end diff --git a/spec/models/github_team_spec.rb b/spec/models/github_team_spec.rb new file mode 100644 index 0000000..bddbdd8 --- /dev/null +++ b/spec/models/github_team_spec.rb @@ -0,0 +1,83 @@ +require 'rails_helper' + +describe GithubTeam do + subject(:team) { described_class.new(id: 1) } + + context 'with GitHub' do + let(:github_admin) { double('github_admin') } + let(:gh_teams) {{ + 1 => {id: 1, name: 'My Team 1', slug: 'my_team_1', organization: 'org1'}, + 5 => {id: 5, name: 'My Team 5', slug: 'my_team_5', organization: 'org1'}, + }} + let(:team_members) {{ + 'hsimpson' => {login: 'hsimpson', name: 'Homer Simpson'}, + 'msimpson' => {login: 'msimpson', name: 'Marge Simpson'}, + }} + + before do + allow(team).to receive(:github_admin).and_return(github_admin) + allow(github_admin).to receive(:teams).and_return(gh_teams) + allow(github_admin).to receive(:team) do |team_id| + gh_teams.values.find { |t| t[:id] == team_id || t[:slug] == team_id } + end + allow(github_admin).to receive(:team_members).and_return(team_members) + end + + it 'synchronizes team information' do + team.sync + expect(team.name).to eq('My Team 1') + expect(team.organization).to eq('org1') + expect(team.slug).to eq('my_team_1') + end + + it 'synchronizes added members' do + create(:github_user, login: 'hsimpson') + create(:github_user, login: 'msimpson') + team.sync! + expect(team.github_users.size).to eq(2) + members = team.github_users.map { |t| t.login } + expect(members).to include('hsimpson', 'msimpson') + end + + it 'synchronizes removed members' do + team.github_users << create(:github_user, login: 'foouser') + team.sync + members = team.github_users.map { |t| t.login } + expect(members).to_not include('foouser') + end + + it 'only saves if information changed' do + team.sync + expect(team).to_not receive(:save) + expect(team).to_not receive(:save!) + expect(team.sync).to eq(true) + end + + it 'only saves if information changed' do + team.sync + expect(team).to_not receive(:save) + expect(team).to_not receive(:save!) + expect(team.sync).to eq(true) + end + end + + it 'returns a GithubAdmin client' do + expect(team.github_admin).to be_a(GithubAdmin) + end + + it 'returns a "full" slug' do + team.organization = "org1" + team.slug = "my_team_1" + expect(team.full_slug).to eq("org1/my_team_1") + end + + it 'finds by "full" slug' do + team.organization = "org1" + team.slug = "my_team_1" + team.save + + found_team = described_class.find_by_full_slug('org1/my_team_1') + expect(found_team).to be_a(GithubTeam) + expect(found_team.id).to eq(team.id) + end +end diff --git a/spec/models/github_user_spec.rb b/spec/models/github_user_spec.rb new file mode 100644 index 0000000..8625c0d --- /dev/null +++ b/spec/models/github_user_spec.rb @@ -0,0 +1,562 @@ +require 'rails_helper' + +describe GithubUser do + subject(:user) { create(:github_user) } + + context 'with GitHub' do + let(:github_admin) { double('github_admin', octokit: admin_octokit) } + let(:admin_octokit) { double('admin-octokit') } + let(:octokit) { double('octokit', user: gh_user, emails: gh_emails, rate_limit: double) } + let(:gh_login) { 'foouser-gh' } + let(:gh_user) { double('gh_user', login: gh_login) } + let(:gh_emails) {[ + {email: 'foouser@example.com', primary: true, verified: true}, + {email: 'foouser@example.org', primary: false, verified: true}, + ]} + + before do + user.token = 'footoken' + allow(user).to receive(:github_admin).and_return(github_admin) + allow(user).to receive(:octokit).and_return(octokit) + end + + it 'synchronizes GitHub username' do + user.sync! + expect(user.login).to eq(gh_login) + end + + it 'synchronizes GitHub emails' do + user.sync! + emails = user.emails.map(&:address) + expect(emails).to include('foouser@example.com') + expect(emails).to include('foouser@example.org') + end + + it 'removes old emails during synchronization' do + user.save! + user.emails.create!(address: 'foouser2@example.org') + user.sync! + expect(user.emails).to_not include('foouser2@example.org') + end + + it 'updates the synchronization date' do + expect(user.last_sync_at).to be_nil + user.sync! + expect(user.last_sync_at).to_not be_nil + end + + it 'checks for a valid token' do + expect(user.valid_token?).to eq(true) + end + + it 'detects revoked token' do + expect(octokit).to receive(:rate_limit).and_raise(Octokit::Unauthorized) + expect(user.valid_token?).to eq(false) + end + + it 'saves GitHub API errors during sync' do + expect(octokit).to receive(:user).and_raise(Octokit::Unauthorized) + user.sync! + expect(user.last_sync_at).to be_nil + expect(user.sync_error).to eq('unauthorized') + expect(user.sync_error_at).to_not be_nil + end + + it 'does not sync when GitHub token is missing' do + user.token = nil + user.sync! + expect(user.last_sync_at).to be_nil + expect(user.sync_error).to eq('notoken') + end + + it 'removes previous GitHub API errors after successful sync' do + user.sync_error = 'fooerror' + user.sync_error_at = Time.now + user.save! + user.sync! + expect(user.sync_error).to be_nil + expect(user.sync_error_at).to be_nil + end + + describe '#organizations' do + let(:teams) { [ + create(:github_team, organization: 'fooorg1'), + create(:github_team, organization: 'fooorg2'), + ] } + + before do + teams.each do |team| + user.teams << team + end + end + + it 'returns organizations' do + expect(user.organizations).to eq(['fooorg1', 'fooorg2']) + end + end + + describe '#remove_from_organizations' do + before do + allow(admin_octokit).to receive(:remove_organization_member).and_return(true) + end + + it 'removes user from all Github organizations' do + teams = create_list(:github_team, 3) + user.teams << teams + Rails.application.settings.github_orgs = ['org1', 'org2'] + expect(admin_octokit).to receive(:remove_organization_member).with('org1', user.login).and_return(true) + expect(admin_octokit).to receive(:remove_organization_member).with('org2', user.login).and_return(true) + removed_teams = user.remove_from_organizations + expect(user.teams).to be_empty + expect(removed_teams).to be_an(Array) + expect(removed_teams.count).to eq(3) + end + end + + describe '#remove_from_internal_teams' do + before do + allow(admin_octokit).to receive(:remove_team_member).and_return(true) + end + + it 'removes user from Github teams' do + teams = create_list(:github_team, 2) + user.teams << teams + expect(admin_octokit).to receive(:remove_team_member).exactly(2).and_return(true) + removed_teams = user.remove_from_internal_teams + expect(removed_teams).to be_a(Array) + expect(removed_teams.count).to eq(2) + end + + it 'ignores external teams' do + teams = create_list(:github_team, 2) + user.teams << teams + Rails.application.settings.github_external_teams = [teams[1].slug] + removed_teams = user.remove_from_internal_teams + expect(removed_teams).to be_a(Array) + expect(removed_teams.count).to eq(1) + expect(removed_teams.first.slug).to_not eq(teams[1].slug) + end + end + + describe '#add_to_organizations' do + let(:orgs) { %w{org1 org2} } + let(:settings) { Rails.application.settings } + let(:check_mfa_team) { build(:github_team, id: 100, slug: 'check-mfa') } + let(:default_team) { build(:github_team, id: 101, slug: 'employees') } + + before do + settings.github_orgs = orgs + settings.github_check_mfa_team = check_mfa_team.slug + settings.github_default_teams = [default_team.slug] + allow(GithubTeam).to receive(:find_by_full_slug).with(/check-mfa/).and_return(check_mfa_team) + allow(GithubTeam).to receive(:find_by_full_slug).with(/employees/).and_return(default_team) + + allow(github_admin).to receive(:user_mfa?).and_return(true) + allow(admin_octokit).to receive(:add_team_membership) + allow(octokit).to receive(:update_organization_membership) + allow(admin_octokit).to receive(:remove_team_member) + allow(user).to receive(:failing_rules).and_return([]) + end + + context 'with new users' do + before do + allow(admin_octokit).to receive(:organization_member?).and_return(false) + end + + it 'adds users to organizations' do + expect(admin_octokit).to receive(:add_team_membership).with(check_mfa_team.id, user.login).exactly(orgs.count).times + expect(octokit).to receive(:update_organization_membership).exactly(orgs.count).times + user.add_to_organizations + end + + it 'checks MFA status' do + expect(github_admin).to receive(:user_mfa?) + user.add_to_organizations + end + + it 'adds to default teams' do + expect(user).to receive(:add_to_teams).with([default_team.slug]) + user.add_to_organizations + end + end + + context 'with existing users' do + before do + allow(admin_octokit).to receive(:organization_member?).and_return(true) + end + + it 'does not try to add to organization' do + expect(admin_octokit).to_not receive(:add_team_membership).with(check_mfa_team.id, user.login) + expect(octokit).to_not receive(:update_organization_membership) + user.add_to_organizations + end + + it 'adds to default teams' do + expect(user).to receive(:add_to_teams).with([default_team.slug]) + user.add_to_organizations + end + + it 'checks MFA' do + user.mfa = false + expect(github_admin).to receive(:user_mfa?).and_return(true) + user.add_to_organizations + expect(user.mfa).to eq(true) + end + end + end + + describe '#add_to_teams' do + let(:github_team1) { create(:github_team, id: 101, organization: 'org1', slug: 'footeam') } + let(:github_team2) { create(:github_team, id: 102, organization: 'org2', slug: 'footeam') } + let(:github_team3) { create(:github_team, id: 103, organization: 'org1', slug: 'barteam') } + + it 'adds GithubTeam objects' do + expect(admin_octokit).to receive(:add_team_membership).with(github_team1.id, user.login) + user.add_to_team(github_team1) + end + + it 'adds teams using full slugs' do + expect(admin_octokit).to receive(:add_team_membership).with(github_team1.id, user.login) + user.add_to_team('org1/footeam') + end + + it 'adds teams using unqualified slugs' do + expect(admin_octokit).to receive(:add_team_membership).with(github_team1.id, user.login) + expect(admin_octokit).to receive(:add_team_membership).with(github_team2.id, user.login) + user.add_to_team('footeam') + end + + it 'adds mixed GithubTeam, slug and full slugs' do + expect(admin_octokit).to receive(:add_team_membership).with(github_team1.id, user.login) + expect(admin_octokit).to receive(:add_team_membership).with(github_team2.id, user.login) + expect(admin_octokit).to receive(:add_team_membership).with(github_team3.id, user.login) + user.add_to_teams(github_team1, 'org2/footeam', 'barteam') + end + + it 'accepts an array' do + expect(admin_octokit).to receive(:add_team_membership).with(github_team1.id, user.login) + expect(admin_octokit).to receive(:add_team_membership).with(github_team2.id, user.login) + user.add_to_teams([github_team1, github_team2]) + end + end + + describe '#add_back_disabled_teams' do + let(:github_team1) { build(:github_team, id: 101, organization: 'org1', slug: 'footeam') } + let(:github_team2) { build(:github_team, id: 102, organization: 'org2', slug: 'footeam') } + + before do + user.disabled_teams = [github_team1, github_team2] + allow(admin_octokit).to receive(:add_team_membership) + end + + it 'adds the user to previously disabled teams' do + expect(user).to receive(:add_to_teams).with(user.disabled_teams) + user.add_back_disabled_teams + end + + it 'returns the added teams' do + expect(user.add_back_disabled_teams).to eq([github_team1, github_team2]) + end + + it 'clears the disabled teams' do + user.add_back_disabled_teams + expect(user.disabled_teams).to be_empty + end + + it 'does nothing if there are no disabled teams' do + user.disabled_teams.clear + expect(user.add_back_disabled_teams).to eq([]) + end + end + end + + it 'returns an Octokit client' do + user.token = 'footoken' + octokit = user.octokit + expect(octokit).to be_a(Octokit::Client) + expect(octokit.access_token).to eq('footoken') + end + + it 'returns a GithubAdmin client' do + expect(user.github_admin).to be_a(GithubAdmin) + end + + describe '#do_enable' do + let(:transition) { double.as_null_object } + + it 'calls add_back_disabled_teams' do + expect(user).to receive(:add_back_disabled_teams) + user.send(:do_enable, transition) + end + end + + describe '#do_disable' do + let(:transition) { double.as_null_object } + + it 'calls remove_from_organizations' do + Rails.application.settings.enforce_rules = true + expect(user).to receive(:remove_from_organizations).and_return([]) + user.send(:do_disable, transition) + end + + it 'remembers disabled teams' do + Rails.application.settings.enforce_rules = true + teams = build_list(:github_team, 2) + expect(user).to receive(:remove_from_organizations).and_return(teams) + user.send(:do_disable, transition) + expect(user.disabled_teams).to eq(teams) + end + + it 'does not remove users when enforce_rules is false' do + Rails.application.settings.enforce_rules = false + expect(user).to_not receive(:remove_from_organizations) + user.send(:do_disable, transition) + end + end + + describe '#do_notify_disabled' do + let(:transition) { double.as_null_object } + + let(:mail) { double('Mail') } + + before do + user.user = build(:user) + allow(mail).to receive(:deliver_now) + allow(mail).to receive(:deliver_later) + end + + it 'sends an access revoked mail' do + Rails.application.settings.enforce_rules = true + expect(UserMailer).to receive(:access_revoked).and_return(mail) + user.send(:do_notify_disabled, transition) + end + + it 'does not send email when not enforcing rules' do + Rails.application.settings.enforce_rules = false + expect(UserMailer).to_not receive(:access_revoked) + user.send(:do_notify_disabled, transition) + end + end + + describe '#do_restrict' do + let(:transition) { double.as_null_object } + + it 'calls remove_from_internal_teams' do + Rails.application.settings.enforce_rules = true + expect(user).to receive(:remove_from_internal_teams).and_return([]) + user.send(:do_restrict, transition) + end + + it 'remembers removed teams' do + Rails.application.settings.enforce_rules = true + teams = build_list(:github_team, 2) + expect(user).to receive(:remove_from_internal_teams).and_return(teams) + user.send(:do_restrict, transition) + expect(user.disabled_teams).to eq(teams) + end + + it 'does not remove users when enforce_rules is false' do + Rails.application.settings.enforce_rules = false + expect(user).to_not receive(:remove_from_internal_teams) + user.send(:do_restrict, transition) + end + end + + describe '#do_notify_restricted' do + let(:transition) { double.as_null_object } + + let(:mail) { double('Mail') } + + before do + user.user = build(:user) + allow(mail).to receive(:deliver_now) + allow(mail).to receive(:deliver_later) + end + + it 'sends an access revoked mail' do + Rails.application.settings.enforce_rules = true + expect(UserMailer).to receive(:access_revoked).and_return(mail) + user.send(:do_notify_restricted, transition) + end + + it 'does not send email when not enforcing rules' do + Rails.application.settings.enforce_rules = false + expect(UserMailer).to_not receive(:access_revoked) + user.send(:do_notify_restricted, transition) + end + end + + context 'state' do + class MockRule < Rules::Base + end + + let(:rules) { [MockRule, MockRule] } + let(:state) { :unknown } + + before do + allow(Rules).to receive(:enabled_rules).and_return(rules) + allow_any_instance_of(MockRule).to receive(:result).and_return(result) + + # Prevent do* methods from doing anything + %i(do_disable do_notify_disabled do_enable do_restrict do_notify_restricted).each do |meth| + allow(user).to receive(meth) + end + + user.state = state + user.save! + end + + context 'with failing rules' do + let(:result) { false } + + it 'has failing rules' do + expect(user.failing_rules.count).to eq(rules.count) + end + + it 'has no passing rules' do + expect(user.passing_rules).to be_empty + end + + context 'when enabled' do + let(:state) { :enabled } + + it 'executes the disable event' do + expect(user).to receive(:disable) + user.transition + end + + it 'executes the restrict event' do + team = build(:github_team) + allow(team).to receive(:external?).and_return(true) + allow(user).to receive(:teams).and_return([team]) + allow_any_instance_of(MockRule).to receive(:required_for_external?).and_return(false) + expect(user).to receive(:restrict) + user.transition + end + + describe '#disable' do + it 'calls do_disable' do + expect(user).to receive(:do_disable).with(kind_of(StateMachine::Transition)) + user.disable + end + + it 'does not allow disabling excluded users' do + Rails.application.settings.github_exclude_users = [user.login] + expect(user).to_not receive(:do_disable) + expect(user.disable).to eq(false) + end + + it 'calls do_notify_disabled with failing notify rules' do + allow_any_instance_of(MockRule).to receive(:notify?).and_return(true) + expect(user).to receive(:do_notify_disabled).with(kind_of(StateMachine::Transition)) + user.disable + end + + it 'does not call do_notify_disabled without failing notify rules' do + allow_any_instance_of(MockRule).to receive(:notify?).and_return(false) + expect(user).to_not receive(:do_notify_disabled).with(kind_of(StateMachine::Transition)) + user.disable + end + end + + describe '#restrict' do + it 'calls do_restrict' do + expect(user).to receive(:do_restrict).with(kind_of(StateMachine::Transition)) + user.restrict + end + + it 'does not allow restricting excluded users' do + Rails.application.settings.github_exclude_users = [user.login] + expect(user).to_not receive(:do_restrict) + expect(user.restrict).to eq(false) + end + + it 'calls do_notify_restricted with failing notify rules' do + allow_any_instance_of(MockRule).to receive(:notify?).and_return(true) + expect(user).to receive(:do_notify_restricted).with(kind_of(StateMachine::Transition)) + user.restrict + end + + it 'does not call do_notify_restricted without failing notify rules' do + allow_any_instance_of(MockRule).to receive(:notify?).and_return(false) + expect(user).to_not receive(:do_notify_restricted).with(kind_of(StateMachine::Transition)) + user.restrict + end + end + end + + context 'when disabled' do + let(:state) { :disabled } + + it 'does not execute an event' do + expect(user).to_not receive(:disable) + expect(user).to_not receive(:enable) + user.transition + end + end + + context 'when unknown' do + let(:state) { :unknown } + + it 'executes the disable event' do + expect(user).to receive(:disable) + user.transition + end + end + end + + context 'without failing rules' do + let(:result) { true } + + it 'has passing rules' do + expect(user.passing_rules.count).to eq(rules.count) + end + + it 'has no failing rules' do + expect(user.failing_rules).to be_empty + end + + context 'when enabled' do + let(:state) { :enabled } + + it 'does not execute an event' do + expect(user).to_not receive(:enable) + expect(user).to_not receive(:disable) + user.transition + end + end + + context 'when disabled' do + let(:state) { :disabled } + + it 'executes enable event' do + expect(user).to receive(:enable) + user.transition + end + + describe '#enable' do + it 'calls do_enable' do + expect(user).to receive(:do_enable).with(kind_of(StateMachine::Transition)) + user.transition + end + end + end + + context 'when unknown' do + let(:state) { :unknown } + + it 'executes the enable event' do + expect(user).to receive(:enable) + user.transition + end + + it 'executes the exclude event' do + allow(user).to receive(:global_excluded_user?).and_return(true) + expect(user).to receive(:exclude) + user.transition + end + end + end + end + +end diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb new file mode 100644 index 0000000..546b91e --- /dev/null +++ b/spec/models/setting_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe Setting, :type => :model do +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..718e6c8 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,135 @@ +require 'rails_helper' + +describe User do + + subject(:user) { create(:user, username: username) } + + let(:username) { 'foouser' } + let(:name) { 'Foo User' } + let(:email) { 'foouser@example.com' } + let(:user_account_control) { 512 } + + context 'with ldap' do + let(:ldap) { double } + let(:ldap_connection) { double(ldap: ldap) } + let(:ldap_entry) { + entry = Net::LDAP::Entry.new("cn=#{username},dc=example,dc=com") + entry['sAMAccountName'] = username + entry['mail'] = email + entry['name'] = name + entry['userAccountControl'] = user_account_control + entry + } + + before do + user.save! + allow(Devise::LDAP::Adapter).to receive(:ldap_connect).and_return(ldap_connection) + allow(ldap).to receive(:search).and_return([]) + allow(user).to receive(:ldap_entry).and_return(ldap_entry) + end + + { + sAMAccountName: 'username', + userPrincipalName: 'principal name', + mail: 'email address', + }.each do |attr, desc| + it "finds by Active Directory #{desc}" do + filter = Net::LDAP::Filter.eq(attr.to_s, 'fakesearch') + expect(ldap).to receive(:search).with(filter: filter).and_return([ldap_entry]) + resource = described_class.find_for_ldap_authentication(username: 'fakesearch') + expect(resource).to be_a(described_class) + expect(resource.username).to eq(username) + end + end + + it 'finds by domain\username syntax' do + resource = described_class.find_for_ldap_authentication(username: "DOMAIN\\#{username}") + expect(resource).to be_a(described_class) + expect(resource.username).to eq(username) + end + + it 'synchronizes ldap data during authentication' do + expect(user).to receive(:sync_from_ldap).and_return(true) + user.after_ldap_authentication + end + + it 'synchronizes name' do + expect(user.name).to be_nil + user.sync_from_ldap! + expect(user.name).to eq(name) + end + + it 'synchronizes email' do + expect(user.email).to be_nil + user.sync_from_ldap! + expect(user.email).to eq(email) + end + + it 'synchronizes userAccountControl' do + expect(user.ldap_account_control).to be_nil + user.sync_from_ldap! + expect(user.ldap_account_control).to eq(user_account_control) + end + + it 'updates the synchronization date' do + expect(user.last_ldap_sync).to be_nil + user.sync_from_ldap! + expect(user.last_ldap_sync).to_not be_nil + end + + it 'saves ldap errors during sync' do + expect(user).to receive(:ldap_get_param).and_raise(Net::LDAP::LdapError) + user.sync_from_ldap! + expect(user.last_ldap_sync).to be_nil + expect(user.ldap_sync_error).to_not be_nil + expect(user.ldap_sync_error_at).to_not be_nil + end + + it 'removes previous ldap errors after successful sync' do + user.ldap_sync_error = 'fooerror' + user.ldap_sync_error_at = Time.now + user.save! + user.sync_from_ldap! + expect(user.ldap_sync_error).to be_nil + expect(user.ldap_sync_error_at).to be_nil + end + + it 'returns account control flags' do + user.ldap_account_control = 512 + expect(user.ldap_account_control_flags).to eq([:normal_account]) + user.ldap_account_control = 514 + expect(user.ldap_account_control_flags).to eq([:account_disabled, :normal_account]) + end + end + + context 'with Github users' do + let(:github_users) { create_list(:github_user_with_emails, 2, user: user) } + + before do + github_users + end + + it 'calls sync on each Github user' do + allow(user).to receive(:github_users).and_return(github_users) + github_users.each do |github_user| + expect(github_user).to receive(:sync).and_return(true) + end + user.sync_from_github! + end + + it 'returns Github emails' do + emails = user.github_emails + expect(emails).to be_an(Array) + expect(emails).to_not be_empty + expect(emails).to include(/githubemail\d+@example.com/) + end + end + + describe '#sync!' do + it 'synchronizes ldap and GitHub' do + expect(user).to receive(:sync_from_ldap).and_return(true) + expect(user).to receive(:sync_from_github).and_return(true) + user.sync! + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..456464b --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,61 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +ENV["RAILS_ENV"] ||= 'test' +require 'spec_helper' +require File.expand_path("../../config/environment", __FILE__) +require 'rspec/rails' + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } + +# Checks for pending migrations before tests are run. +# If you are not using ActiveRecord, you can remove this line. +ActiveRecord::Migration.maintain_test_schema! + +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_path = "#{::Rails.root}/spec/fixtures" + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = false + + # RSpec Rails can automatically mix in different behaviours to your tests + # based on their file location, for example enabling you to call `get` and + # `post` in specs under `spec/controllers`. + # + # You can disable this behaviour by removing the line below, and instead + # explicitly tag your specs with their type, e.g.: + # + # RSpec.describe UsersController, :type => :controller do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://relishapp.com/rspec/rspec-rails/docs + config.infer_spec_type_from_file_location! + + config.include FactoryGirl::Syntax::Methods + config.include Devise::TestHelpers, type: :controller + config.include Devise::TestHelpers, type: :view + config.include ControllerHelpers, type: :controller + + config.before(:suite) do + FactoryGirl.lint + DatabaseCleaner.clean_with(:deletion) + end + + DatabaseCleaner.strategy = :deletion + config.around(:each) do |example| + DatabaseCleaner.cleaning do + example.run + end + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..06f273a --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,77 @@ +require 'simplecov' +SimpleCov.start + +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause this +# file to always be loaded, without a need to explicitly require it in any files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, make a +# separate helper file that requires this one and then use it only in the specs +# that actually need it. +# +# The `.rspec` file also contains a few flags that are not defaults but that +# users commonly want. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # These two settings work together to allow you to limit a spec run + # to individual examples or groups you care about by tagging them with + # `:focus` metadata. When nothing is tagged with `:focus`, all examples + # get run. + #config.filter_run :focus + #config.run_all_when_everything_filtered = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = 'doc' + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + #config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax + expectations.syntax = :expect + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + mocks.syntax = :expect + + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended. + mocks.verify_partial_doubles = true + end +end diff --git a/spec/support/controller_helpers.rb b/spec/support/controller_helpers.rb new file mode 100644 index 0000000..998cd1e --- /dev/null +++ b/spec/support/controller_helpers.rb @@ -0,0 +1,10 @@ +module ControllerHelpers + def sign_in(user=nil) + user = create(:user) unless user + super(user) + end + + def configured + Rails.application.settings.configured = true + end +end diff --git a/spec/views/connect/index.html.erb_spec.rb b/spec/views/connect/index.html.erb_spec.rb new file mode 100644 index 0000000..de468c8 --- /dev/null +++ b/spec/views/connect/index.html.erb_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe "connect/index.html.erb", type: :view do + let(:connect_status) { ConnectGithubUserStatus.new(step: :request) } + let(:user) { build(:user) } + + before do + assign(:connect_status, connect_status) + allow(view).to receive(:current_user).and_return(user) + end + + it 'renders' do + render + end +end diff --git a/spec/views/layouts/application.html.erb_spec.rb b/spec/views/layouts/application.html.erb_spec.rb new file mode 100644 index 0000000..6d617d9 --- /dev/null +++ b/spec/views/layouts/application.html.erb_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe "layouts/application", type: :view do + before do + Rails.application.settings.configured = true + end + + context 'without user' do + it 'does not display login items' do + render + expect(rendered).to_not include('Logout') + end + end + + context 'with user' do + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'displays login items' do + render + expect(rendered).to include('Logout') + end + end +end diff --git a/spec/views/settings/edit.html.erb_spec.rb b/spec/views/settings/edit.html.erb_spec.rb new file mode 100644 index 0000000..3b654cb --- /dev/null +++ b/spec/views/settings/edit.html.erb_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' +require 'settings' + +RSpec.describe "settings/edit.html.erb", type: :view do + # We can't call this "settings" because ViewExampleGroup adds view helpers + # to the spec -- the settings helper would take precedence over a let + let(:test_settings) { GithubConnector::Settings.new.disconnect; } + let(:user) { build(:user) } + + before do + controller.extend(SettingsMixin) + controller.instance_variable_set('@settings', test_settings) + assign(:settings, test_settings) + allow(view).to receive(:current_user).and_return(user) + end + + it 'replaces existing password with placeholder' do + test_settings.ldap_admin_password = 'foopass' + test_settings.save + render + expect(rendered).to_not include('foopass') + end + + it 'does not replace new password' do + test_settings.ldap_admin_password = 'foopass' + render + expect(rendered).to include('foopass') + end + + it 'replaces existing GitHub token with placeholder' do + test_settings.github_admin_token = 'footoken' + test_settings.save + render + expect(rendered).to_not include('footoken') + end + + it 'does not replace new GitHub token' do + test_settings.github_admin_token = 'footoken' + render + expect(rendered).to include('footoken') + end +end diff --git a/vendor/assets/javascripts/.keep b/vendor/assets/javascripts/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/assets/stylesheets/.keep b/vendor/assets/stylesheets/.keep new file mode 100644 index 0000000..e69de29