Skip to content

Commit

Permalink
Get users from sso and run it as a rake task (#430)
Browse files Browse the repository at this point in the history
Co-authored-by: Letiste <leo.sale72@gmail.com>
  • Loading branch information
nymous and Letiste authored Mar 19, 2023
1 parent 8354478 commit 1f1ce79
Show file tree
Hide file tree
Showing 15 changed files with 195 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ db/schema.rb linguist-generated

# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

# Ignore master key for decrypting credentials and more.
/config/master.key
/config/credentials/production.key

coverage
.idea
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,5 @@ group :test do
gem 'simplecov', '~> 0.21.2'
gem 'simplecov-cobertura', '~> 2.1'
gem 'webdrivers'
gem 'webmock', '~> 3.18'
end
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ GEM
childprocess (4.1.0)
coderay (1.1.3)
concurrent-ruby (1.1.10)
crack (0.4.5)
rexml
crass (1.0.6)
debug (1.6.3)
irb (>= 1.3.6)
Expand Down Expand Up @@ -120,6 +122,7 @@ GEM
guard-minitest (2.4.6)
guard-compat (~> 1.2)
minitest (>= 3.0)
hashdiff (1.0.1)
hashie (5.0.0)
httpclient (2.8.3)
i18n (1.12.0)
Expand Down Expand Up @@ -347,6 +350,10 @@ GEM
webfinger (1.2.0)
activesupport
httpclient (>= 2.4)
webmock (3.18.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.9)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
Expand Down Expand Up @@ -393,6 +400,7 @@ DEPENDENCIES
tzinfo-data
web-console
webdrivers
webmock (~> 3.18)

RUBY VERSION
ruby 3.1.2p20
Expand Down
14 changes: 8 additions & 6 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,19 @@ def cancel_current_subscription!

def self.upsert_from_auth_hash(auth_hash)
user = find_or_initialize_by("#{auth_hash[:provider]}_id": auth_hash[:uid])
user.update(
firstname: auth_hash[:info][:first_name],
lastname: auth_hash[:info][:last_name],
email: auth_hash[:info][:email],
room: auth_hash[:extra][:raw_info][:room]
)
user.update_from_sso(firstname: auth_hash[:info][:first_name],
lastname: auth_hash[:info][:last_name],
email: auth_hash[:info][:email],
room: auth_hash[:extra][:raw_info][:room])
user.groups = auth_hash[:extra][:raw_info][:groups]
user.save!
user
end

def update_from_sso(firstname:, lastname:, email:, room:)
update(firstname:, lastname:, email:, room:)
end

def admin?
return false if groups.nil?

Expand Down
2 changes: 1 addition & 1 deletion config/credentials.yml.enc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3hBkelrJkRosY8dMQ7mCuMYvOmv0joqEot1iGbs/jSd+krvIwDkjd/7/lnOUBFxAhrkTFfO5e05L2W3zC9Z4G+mDq5wf8GJQbxah2dTLf9HkgrLqnobuFCF7HPeaIsi3I9dKWJMgSNAiUvxYz0pj9dMUaoHEOml0tUiHgFa2q44+rPs7OHo7atYW1B7QwnvVFiBuiEw5lLij/4fTH2AMXPK65YxPNL0ljap5d+chkdw47ukryOInPtqN/rBgRlfWt7EHctvKdLca7SoJ9f0s3HGayMdI3Z1p0m6xPzaVy/wJrw1x0dLZz0hUm76Vop1K19DvZggVczIQQOoHquNtu97w8g9ybUW5mciH696LjcktZlGVLo0vVjm6ZK4H9vxFj/NAH3Om7NIF0wKy0BfPsIOt71FJ6FpbyPV58kkLuISoaVusewIYY++QwOa+woG8jqGPlCOl1L3LkSaOpKpQ5YMnxmcHSXS7eVRuIFOVvrPv4isAjefQl8ZgwbF59dQO5IU=--+K1TdG/RdN6c2gha--9rlzCCSz/Aq0gJawQhEBFA==
VfC7hNP55EjMyi4vt8jK8lAaRmOkql+wsyAFxSVsQdVT8tbZk1l0dgyELsvoVfHwU9kqzI+wMIFyuH6MoXBhcghZfa6m5g683FM06BDkYPwwDflEVeox0DvWmgGji4qzk3oFe57T9qQT736mz3dWfeQTzvjHnaUpq27gYQTpQHOuELjYwKsMXbFRFiNNMmG5phiG2k0Asc7dqZ8CRPwmhJYPm5aJBc9Bzrz3ebBnhxmZ+JzZ8AsZnnvnAFnylm2jRwgN91kJE9l3fkWVTlF6rm0EoCJ9r3neibTeDu2PtNnkYISp3O7chrBJKo6FS9GFAcOxgkxB8QPiX2TV2s3jPiNyxffxLqUe+jziPP4dDiHdZvKrONIltblTJV2WTzur7/82fCEhQUf2oM7uwavq/yvQNOlay4nUD9TyBGZrs1fyFuulT4Ik2/w4T7usUC6am1xIzB2ITF8IuAgolkT/5EPsIs45gJEpJy9Fqlg1PAv01NN9hhViEl+A--tARvrsUmwyVJ9/XI--ytpULZnEcYeOSqfUYhHAnA==
1 change: 1 addition & 0 deletions config/credentials/production.yml.enc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
QF2+HIDkGNTPjx81hqlGjQAZyIzZVAp9c7J+dWPqlggPt9u/LWhxWWi6vY7KXGYe3opGafwsWlOgrO0BXtzbOg+RK+Bq9a8rUgsKchqHi44B487Y9/rTvS5bcCTJj5c78AJFdZCsEdOXqUv6khBGHB2i8fGQ5LZyh/SFJhmyt3QqGuL6Ig4MsuD/275o2M94CjrlXT2nNGe6BPl94GurtQv7s67bbgrSy3N2f0Kc+VR4VfrIhTwgkpXxEBv7vk/ok/1WvYpqRbFNuyPhiqkZ6Rj+0oK7JCxtFDL15tBtrIRbdpNuBeWO8BQwf/CfvcgBsJnOGg//LU/AJe+ndMCdUzdfVpuB1QKG4A62lmrLNOEwlw/K3JdPsWqPEh6rSVIr5nAzl4oaxMo0NZs7VK7ZhNpGxkvdNVQEZDxchZkaanQOIFU02240w3nMHYx5aed1MEj2ZpuR26Jg+W85m3K0BvsCDcXAouLTvMCB8kGZ8H6SOWExIdbIhmM3--x6H3t+K9w244qMLp--x+vXvS3cObYjuhqsAb4HYA==
8 changes: 4 additions & 4 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
Rails.application.config.middleware.use OmniAuth::Builder do
if Rails.env.production?
client_options = {
identifier: ENV.fetch('SSO_PROD_ID'),
secret: ENV.fetch('SSO_PROD_SECRET'),
identifier: Rails.application.credentials.sso_id!,
secret: Rails.application.credentials.sso_secret!,
redirect_uri: "https://lea5.rezoleo.fr/#{AUTH_CALLBACK_PATH}"
}
elsif Rails.env.development?
client_options = {
identifier: Rails.application.credentials.sso_dev_id!,
secret: Rails.application.credentials.sso_dev_secret!,
identifier: Rails.application.credentials.sso_id!,
secret: Rails.application.credentials.sso_secret!,
redirect_uri: "http://127.0.0.1:3000/#{AUTH_CALLBACK_PATH}"
}
end
Expand Down
8 changes: 8 additions & 0 deletions docs/features/sync_accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Sync Accounts

We use an SSO system to [authenticate our users](./authentication.md). That implies that our users' data are not stored only
on lea5 database. To ensure the consistency of the data, we use a task to synchronize the data from the SSO
to lea5.

The task is defined in [`sync_accounts.rake`](../../lib/tasks/sync_accounts.rake), and runs every 3 hours.
The timer is done with service/timer of systemd, the configuration can be found in [systemd folder](../../lib/support/systemd).
8 changes: 8 additions & 0 deletions lib/support/systemd/lea5-sync-accounts.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[Unit]
Description=Lea5 - Sync users between Keycloak and Lea5

[Service]
# Command runs once then exists, it is not a background service
Type=oneshot

ExecStart=/opt/lea5/bin/rails lea5:sync_accounts
9 changes: 9 additions & 0 deletions lib/support/systemd/lea5-sync-accounts.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# See https://leethax.org/2017/11/17/systemd-timers.html
# and https://wiki.archlinux.org/title/Systemd/Timers
[Timer]
# Run the service every 3 hours
OnActiveSec=3h
OnUnitActiveSec=3h

[Install]
WantedBy=timer.target
61 changes: 61 additions & 0 deletions lib/tasks/sync_accounts.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

require 'net/http'
require 'json'

namespace :lea5 do
desc 'sync accounts from SSO'
task sync_accounts: [:environment] do
sso_users = retrieve_users_from_sso

User.all.each do |user|
user_from_sso = sso_users[user.keycloak_id]
if user_from_sso
update_user(user, user_from_sso)
else
destroy_user(user)
end
end
end
end

# @return [Hash<String, Hash<String, Object>>]
def retrieve_users_from_sso
uri = URI('https://auth.rezoleo.fr/realms/rezoleo/protocol/openid-connect/token')
params = {
client_id: Rails.application.credentials.sso_id!,
client_secret: Rails.application.credentials.sso_secret!,
grant_type: 'client_credentials'
}
res = Net::HTTP.post_form(uri, params)
# Needs "view-users" service account role in Keycloak
access_token = JSON.parse(res.body)['access_token']

uri = URI('https://auth.rezoleo.fr/admin/realms/rezoleo/users?max=9999') # because pagination is a no no for keycloak
res = Net::HTTP.get_response(uri, { 'Authorization' => "Bearer #{access_token}" })
JSON.parse(res.body).index_by { |user| user['id'] }
end

def update_user(user, user_from_sso)
user.update_from_sso(
firstname: user_from_sso['firstName'],
lastname: user_from_sso['lastName'],
email: user_from_sso['email'],
room: user_from_sso['attributes']['room'].first
)
if user.save
puts "Updated #{user.email}"
else
puts "Error updating user #{user.email}"
end
end

def destroy_user(user)
if user.destroy
puts "Destroyed #{user.email}"
else
# :nocov:
puts "Error destroying user #{user.email}"
# :nocov:
end
end
7 changes: 7 additions & 0 deletions test/fixtures/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ pepper:
email: "pepper@potts.com"
room: "A201"
keycloak_id: "12345678-1234-1234-1234-123456789def"

spiderman:
firstname: "Peter"
lastname: "Parker"
email: "peterp@univ.edu"
room: "A202"
keycloak_id: "12345678-1234-1234-1234-123456789ghi"
69 changes: 69 additions & 0 deletions test/tasks/sync_accounts_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

require 'json'
require 'rake'
require 'webmock'

class SyncAccountsTest < ActiveSupport::TestCase
# https://blog.10pines.com/2019/01/14/testing-rake-tasks/
# https://thoughtbot.com/blog/test-rake-tasks-like-a-boss
def setup
Rake.application.rake_require 'tasks/sync_accounts'
Rake::Task.define_task(:environment)
Rails.application.credentials.sso_id = '123456'
Rails.application.credentials.sso_secret = 'super-secret'
end

test 'sync_accounts rake task' do
KeycloakStub.stub_access_token
KeycloakStub.stub_list_users

assert_not_equal 'A113', User.find_by(email: 'tony@avengers.com').room

assert_difference 'User.count', -1 do
Rake::Task['lea5:sync_accounts'].invoke
end
assert_equal 'A113', User.find_by(email: 'tony@avengers.com').room
end
end

class KeycloakStub
MOCK_KEYCLOAK_USER_OK = {
id: '12345678-1234-1234-1234-123456789abc',
username: 'user1',
firstName: 'Tony',
lastName: 'Stark',
email: 'tony@avengers.com',
attributes: { locale: ['en'], room: ['A113'] }
}.freeze
MOCK_KEYCLOAK_USER_BAD_ROOM = {
id: '12345678-1234-1234-1234-123456789ghi',
username: 'user2',
firstName: 'Peter',
lastName: 'Parker',
email: 'peterp@univ.edu',
attributes: { room: ['BAD-ROOM'] }
}.freeze

def self.stub_access_token
WebMock.stub_request(:post, 'https://auth.rezoleo.fr/realms/rezoleo/protocol/openid-connect/token')
.with(
body: WebMock.hash_including({
client_id: '123456',
client_secret: 'super-secret',
grant_type: 'client_credentials'
})
)
.to_return(status: 200,
body: JSON.dump({ access_token: 'my_access_token' }),
headers: { content_type: 'application/json' })
end

def self.stub_list_users
WebMock.stub_request(:get, 'https://auth.rezoleo.fr/admin/realms/rezoleo/users?max=9999')
.with(headers: { Authorization: 'Bearer my_access_token' })
.to_return(status: 200,
body: JSON.dump([MOCK_KEYCLOAK_USER_OK, MOCK_KEYCLOAK_USER_BAD_ROOM]),
headers: {})
end
end
7 changes: 7 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
require 'minitest/reporters'
Minitest::Reporters.use! unless ENV['RM_INFO']

require 'webmock/minitest'
# Allow system tests to get their webdriver release
WebMock.disable_net_connect!(
allow_localhost: true,
allow: 'chromedriver.storage.googleapis.com'
)

module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
Expand Down

0 comments on commit 1f1ce79

Please sign in to comment.