Since leihs lacks the feature to dynamically create users based on an identity provider during login, the user must already exist in the DB before the user’s first login. Therefore it is up to you to sync the relevant user details from your source of choice to the leihs-API. This is my implementation for a Ruby user sync script from LDAP to leihs.
You probably never heard of leihs. Leihs is an open source inventory management, procurement and rental system, primarily for educational institutions. It’s free, it’s actively maintained, it works and has all the features we need from such a system. What it lacks, are integrations, in our case automatic user synchronisation.
Since I had a bit of extra time when setting up our leihs instance, I took the opportunity to develop the user synchronisation script in Ruby (which I was and still am learning), and deploy it on an OKD Kubernetes cluster to run as a Kubernetes CronJob every day. The script is based on the leihs developer’s reference leihs-sync implementation written in Clojure. It can run interactively, requiring user input for credentials and other parameters, or it can be run without user interaction using EVN VARs.
The script works as it is, it certainly could be improved, both in code quality, functionality and whatnot, but as with most internal projects, it’s enough that it does what it does reliably. Keep in mind, that I was scripting this as I was learning Ruby, so it might not the best and most idiomatic Ruby code in the world.
Requirements
- Ruby 2.X or 3.X (you need to (un)comment one line depending on version due to function changes)
- Somewhere to run it (PC, server, Kubernetes, Docker, etc.)
- Bind-credentials for LDAP, api-token for the leihs-API
- Set either to interactive mode, or specify all required ENV VARs
What the script does
- Creates class definitions and methods
- Retrieves the parameters either from the user or from ENV VARs
- Fetches and parses relevant users from LDAP
- Fetches and parses users from the leihs-API
- Posts new users to the leihs-API
- Updates existing users in the leihs-API, if the option is enabled
- Deletes the users from leihs-API who do not exist in LDAP anymore
- Disables the users in the leihs-API who could not be deleted due to dependencies (existing contracts, etc.)
Code
The repo (with everything needed) is available on GitLab, but the script itself and the BuildConfig is also included below:
The script
# LDAP --> Leihs (API) user sync script.
# Based on the leihs developer's reference leihs sync implementation written in clojure.
# https://github.com/functional-swiss/leihs-sync/blob/master/src/funswiss/leihs_sync/leihs/core.clj
#
# Imports
#
require 'rubygems'
require 'bundler/setup'
require 'net-ldap'
require 'httparty'
require 'json'
#require 'resolv-replace'
#
# Object definitions
#
##
# Leihs user definition, attribute names correspond with the attribute names used by the leihs API
#
class User
def initialize(login:, firstname:, lastname:, email:, badge_id:, id:, account_enabled: nil, password_sign_in_enabled: nil, is_admin: nil)
@login = login
@firstname = firstname
@lastname = lastname
@email = email
@badge_id = badge_id
@id = id
@account_enabled = account_enabled
@password_sign_in_enabled = password_sign_in_enabled
@is_admin = is_admin
end
attr_accessor :login
attr_accessor :firstname
attr_accessor :lastname
attr_accessor :email
attr_accessor :badge_id
# The leihs generated internal UUID
attr_accessor :id
attr_accessor :account_enabled
attr_accessor :password_sign_in_enabled
attr_accessor :is_admin
def self.from_json_hash(entry)
User.new(
id: entry["id"],
login: entry["login"],
firstname: entry["firstname"],
lastname: entry["lastname"],
email: entry["email"],
badge_id: entry["badge_id"],
account_enabled: entry["account_enabled"],
password_sign_in_enabled: entry["password_sign_in_enabled"],
is_admin: entry["is_admin"])
end
def self.from_ldap_struct(entry)
User.new(
id: nil,
login: entry.uid[0],
firstname: entry.givenName[0],
lastname: entry.sn[0],
email: entry.mail[0],
badge_id: entry.respond_to?(:employeeNumber) ? entry.employeeNumber[0] : nil,
account_enabled: true,
password_sign_in_enabled: true,
is_admin: false)
end
def as_json(options={})
{
login: @login,
firstname: @firstname,
lastname: @lastname,
email: @email,
badge_id: @badge_id,
id: @id,
account_enabled: @account_enabled,
password_sign_in_enabled: @password_sign_in_enabled,
is_admin: @is_admin
}
end
def to_json(*options)
as_json(*options).to_json(*options)
end
end
##
# LDAP client object
#
class LeihsLdap < Net::LDAP
#TODO: Get LDAP parameters from ENV vars.
def initialize(bind_username:, bind_password:,
filter: "(&(objectClass=posixAccount)(|(memberOf=cn=SomeCN,ou=SomeOu,dc=company,dc=tld)(memberOf=cn=SomeOtherCN,ou=SomeOu,dc=company,dc=tld)))",
treebase: "ou=people,dc=company,dc=tld",
attributes: ["uid", "givenName", "sn", "mail", "employeeNumber"])
@treebase = treebase
@attributes = attributes
@filter = Net::LDAP::Filter.construct(filter)
super(:host => 'ldapserver.company.tld',
:port => 636,
:encryption => :simple_tls,
:auth => {
:method => :simple,
:username => bind_username,
:password => bind_password,
})
end
attr_reader :filter
attr_reader :treebase
attr_reader :attributes
end
##
# HTTP/Rest API client object
#
class LeihsRestApi
include HTTParty
base_uri "https://leihs.company.tld"
headers "User-Agent" => "LeihsSyncer *Ruby/HTTParty*"
headers "Content-Type" => "application/json"
headers "Accept" => "application/json"
headers "Host" => "leihs.company.tld:443"
##
# Disables the Rails-style array encoding that's not compatible with the leihs API.
# https://www.rubydoc.info/github/jnunemaker/httparty/HTTParty%2FClassMethods:disable_rails_query_string_format
disable_rails_query_string_format
##
# Enable this to dump HTTP logs to stdout
#debug_output $stdout
def initialize(auth_token:, base_path: "/admin", fields: ["account_disabled_at", "account_enabled", "admin_protected", "badge_id", "email", "firstname", "id", "is_admin", "is_system_admin", "lastname", "login", "password_sign_in_enabled", "system_admin_protected"] )
@base_path = base_path
@fields = fields
@headers = self.class.headers.merge("Authorization" => "Token #{auth_token}")
end
def get_users(page: 1, fields: @fields, per_page: 1000, type: "any", term: "", is_admin: "", retries: 5, retry_wait: 2)
self.class.get("#{@base_path}/users/", headers: @headers, query: { page: page, "per-page" => per_page, type: type, term: term, is_admin: is_admin, fields: fields })
rescue Net::OpenTimeout => e
raise if retries <= 1
puts "!RETRIES #{retries}: #{e}"
puts "!SLEEP #{retry_wait}s ..."
sleep retry_wait
self.get_users(page: page, fields: fields, per_page: per_page, type: type, term: term, is_admin: is_admin, retries: retries - 1, retry_wait: retry_wait * 2)
end
def head_users(page: 1, fields: @fields, per_page: 1000, type: "any", term: "", is_admin: "", retries: 5, retry_wait: 2)
self.class.head("#{@base_path}/users/", headers: @headers, query: { page: page, "per-page" => per_page, type: type, term: term, is_admin: is_admin, fields: fields })
rescue Net::OpenTimeout => e
raise if retries <= 1
puts "!RETRIES #{retries}: #{e}"
puts "!SLEEP #{retry_wait}s ..."
sleep retry_wait
self.head_users(page: page, fields: fields, per_page: per_page, type: type, term: term, is_admin: is_admin, retries: retries - 1, retry_wait: retry_wait * 2)
end
def get_user(id:, retries: 5, retry_wait: 2)
self.class.get("#{@base_path}/users/#{id}", headers: @headers)
rescue Net::OpenTimeout => e
raise if retries <= 1
puts "!RETRIES #{retries}: #{e}"
puts "!SLEEP #{retry_wait}s ..."
sleep retry_wait
self.get_user(id: id, retries: retries - 1, retry_wait: retry_wait * 2)
end
def post_user(body:, retries: 5, retry_wait: 2)
self.class.post("#{@base_path}/users/", headers: @headers, body: body.to_json)
rescue Net::OpenTimeout => e
raise if retries <= 1
puts "!RETRIES #{retries}: #{e}"
puts "!SLEEP #{retry_wait}s ..."
sleep retry_wait
self.post_user(body: body, retries: retries - 1, retry_wait: retry_wait * 2)
end
def patch_user(id:, body:, retries: 5, retry_wait: 2)
self.class.patch("#{@base_path}/users/#{id}", headers: @headers, body: body.to_json)
rescue Net::OpenTimeout => e
raise if retries <= 1
puts "!RETRIES #{retries}: #{e}"
puts "!SLEEP #{retry_wait}s ..."
sleep retry_wait
self.patch_user(id: id, body: body, retries: retries - 1 , retry_wait: retry_wait * 2)
end
def delete_user(id:, retries: 5, retry_wait: 2)
self.class.delete("#{@base_path}/users/#{id}", headers: @headers)
rescue Net::OpenTimeout => e
raise if retries <= 1
puts "!RETRIES #{retries}: #{e}"
puts "!SLEEP #{retry_wait}s ..."
sleep retry_wait
self.delete_user(id: id, retries: retries - 1, retry_wait: retry_wait * 2)
end
end
#
# Methods
#
##
# Returns true if the user wants to exit the script.
def ask_exit
puts "Continue? [Y/n]"
exit_answ = STDIN.gets.chomp.strip.downcase
!exit_answ.empty? && exit_answ != 'y'
end
##
# Asks the user to enter the value for parameter
def get_param_user param
exit = false
answer = nil
until exit
puts "Enter #{param}:"
answer = STDIN.gets.chomp.strip
puts "Did you enter the #{param} parameter correctly? [Y/n]"
exit_answ = STDIN.gets.chomp.strip.downcase
exit = exit_answ.empty? || exit_answ == "y"
end
return answer
end
##
# Returns a list of User objects from the specified LDAP, retries 5 times with increasing sleep times.
def get_users_ldap(leihs_ldap:, retries: 5, retry_wait: 2)
# Technically you should use the method body callback to process and receive the entries but I'm simply to lazy to look that up.
ldap_results = leihs_ldap.search(:base => leihs_ldap.treebase, :filter => leihs_ldap.filter, :attributes => leihs_ldap.attributes, :return_result => true) { |entry| }
raise Net::LDAP::ResponseMissingOrInvalidError.new("Unknown query error, result code #{leihs_ldap.get_operation_result.code}: #{leihs_ldap.get_operation_result.message}") unless leihs_ldap.get_operation_result.code == 0
puts "Query successfully returned #{ldap_results.size} entries."
ldap_results.map{ |entry| [entry.uid[0], User.from_ldap_struct(entry)] }.to_h
rescue Errno::ECONNRESET, Net::LDAP::ConnectionError, Net::LDAP::SocketError, Net::LDAP::NoStartTLSResultError, Net::LDAP::NoBindResultError, Net::LDAP::ResponseMissingOrInvalidError, Net::LDAP::ResponseTypeInvalidError, Net::LDAP::SASLChallengeOverflowError => e
raise if retries <= 1
puts "!RETRIES #{retries}: #{e}"
puts "!SLEEP #{retry_wait}s ..."
sleep retry_wait
get_users_ldap(leihs_ldap: leihs_ldap, retries: retries - 1, retry_wait: retry_wait * 2)
end
##
# Returns a list of User objects from the Leihs API.
#
# TODO: add retries and error handling like in get_users_ldap!
def get_users_api api
page = 0
users_json_arr = []
exit = false
until exit
page += 1
#begin
#TODO: exception handling!
response = api.get_users(page: page)
#end
if response.code == 200 && !response.parsed_response.nil?
users_body_arr = response.parsed_response["users"]
if !users_body_arr.empty?
users_json_arr = users_json_arr | users_body_arr
else
exit = true
puts "Retrieved #{page} pages, #{users_json_arr.size} JSON objects."
end
else
puts "Invalid response code, aborting and throwing results away."
p response
users_json_arr = nil
exit = true
end
#rescue
# retry
end
results = users_json_arr.map{ |entry| [ entry["login"], User.from_json_hash(entry) ]}.to_h
puts "Parsed #{results.size} users."
return results
end
#
# Script
#
# Base global parameters
header = [ ' _ _ _ ____ ',
'| | ___ (_)| |__ ___ / ___| _ _ _ __ ___ ___ _ __ __/\__ ',
'| | / _ \| || `_ \ / __|\___ \ | | | || ,_ \ / __|/ _ \| `__| \ / ',
'| |___| __/| || | | |\__ \ ___) || |_| || | | || (__| __/| | /_ _\ ',
'|_____|\___||_||_| |_||___/|____/ \__, ||_| |_| \___|\___||_| \/ ',
' |___/ ',
' ____ _ __ _ _ _____ _____ ____ _ ',
'| _ \ _ _ | |__ _ _ / /| | | ||_ _||_ _|| _ \ __ _ _ __ | |_ _ _ __/\__ ',
'| |_) || | | || `_ \ | | | | / / | |_| | | | | | | |_) |/ _` || `__|| __|| | | |\ / ',
'| _ < | |_| || |_) || |_| | / / | _ | | | | | | __/| (_| || | | |_ | |_| |/_ _\ ',
'|_| \_\ \__,_||_.__/ \__, |/_/ |_| |_| |_| |_| |_| \__,_||_| \__| \__, | \/ ',
' |___/ |___/ ']
##
# You must specify these ENV VARS if you want to run without manual user input! Set SYNCER_AUTOPILOT=true and specify all ENV VARS.
#
# ENV VARs for the non-interactive mode (autopilot = true).
env_vars = {"SYNCER_AUTOPILOT" => nil, "SYNCER_BINDDN" => nil, "SYNCER_PASSWORD" => nil, "SYNCER_API_TOKEN" => nil, "SYNCER_SKIP_PATCH" => nil}.map{ |k,v| [k, ENV[k]]}.to_h
# If true, script runs non-interactively using the ENV VARs. If false it takes user inputs.
autopilot = env_vars["SYNCER_AUTOPILOT"].nil? ? false : env_vars["SYNCER_AUTOPILOT"].chomp.strip.downcase == "true"
# Bind credentials for the LDAP query.
bind_credentials = { :username => "", :password => ""}
# Token for the leihs API.
token = nil
# Users who should be admins.
admin_users = ["admin1", "sync-job-user", "admin2"]
# Users who should remain active in leihs, despite not existing in LDAP anymore.
keep_users = ["patrik.ploechl"]
log = {get_ldap: nil, get_api: nil, post_api: nil, patch_api: nil, delete_api: nil, patch_disable_api: nil}
# If true, the script does not update all users with their up-to-date information from LDAP (name changes, etc.). If false, users get updated but the scipt takes longer.
skip_patch = false
exit = false
# This is where the fun starts :)
# Write cool leihs header to console
header.each { |line| puts line }
puts ""
# Retrieve (from the user from the console or over ENV VARS) LDAP credentials, Leihs API token and whether to skip the patching of existing users.
# for ruby 3 the syntax and some functions changed, uncomment this if, comment the if for ruby 2!
#if autopilot && env_vars.except("SYNCER_AUTOPILOT").any? { |_, v| v.nil? }
# for ruby 2
if autopilot && env_vars.reject{ |key, _| key == "SYNCER_AUTOPILOT" }.any?{ |_, v| v.nil? }
# Aborting since some ENV VARS are missing.
puts "If SYNCER_AUTOPILOT is set all other env vars must be set #{env_vars.keys.to_s}"
Kernel.exit(1)
elsif autopilot
# Running on ENV VARS
puts "Running on autopilot..."
bind_credentials[:username] = env_vars["SYNCER_BINDDN"]
bind_credentials[:password] = env_vars["SYNCER_PASSWORD"]
token = env_vars["SYNCER_API_TOKEN"]
skip_patch = env_vars["SYNCER_SKIP_PATCH"].chomp.strip.downcase == "true"
else
# Running on user input
puts "Running in interactive mode..."
exit = false
until exit
bind_credentials.each { |key, value| value.replace get_param_user key.to_s }
exit = !ask_exit
end
exit = false
until exit
token = get_param_user "leihs token"
exit = !ask_exit
end
exit = false
until exit
skip_answ = get_param_user("?skip_patch[true|false]?").downcase
skip_patch = skip_answ == "true"
exit = !ask_exit && (skip_answ == "true" || skip_answ == "false")
end
end
# List users who won't be disabled or deleted, even if they are not in ldap. Admins get admin rights assigned.
puts "Admins: #{admin_users.to_s}"
puts "Users to keep: #{keep_users.to_s}"
Kernel.exit(0) if !autopilot && ask_exit
#
# 1. Fetch users from LDAP
#
puts ""
puts "Fetch users from LDAP:"
leihs_ldap = LeihsLdap.new(bind_username: bind_credentials[:username], bind_password: bind_credentials[:password])
puts "Attributes: #{leihs_ldap.attributes}"
puts "Filter: #{leihs_ldap.filter}"
users_ldap = get_users_ldap(leihs_ldap: leihs_ldap)
log[:get_ldap] = users_ldap.keys
puts "Results:"
puts log[:get_ldap].to_s
# Set all admins as admins
(users_ldap.keys & admin_users).each { |key| users_ldap[key].is_admin = true }
Kernel.exit(0) if !autopilot && ask_exit
#
# 2.1. Fetch users from API
#
puts ""
puts "Fetch users from API:"
api = LeihsRestApi.new(auth_token: token)
users_api = get_users_api(api)
log[:get_api] = users_api.keys
puts "Results:"
puts log[:get_api].to_s
Kernel.exit(0) if !autopilot && ask_exit
#
# 2.2. Post new users to API
#
puts ""
puts "Post new users to API:"
new_user_keys = users_ldap.keys - users_api.keys
log[:post_api] = {}
new_user_keys.each do |key|
res = api.post_user(body: users_ldap[key])
log[:post_api][key] = res.code
end
puts "Results:"
puts log[:post_api].to_s
Kernel.exit(0) if !autopilot && ask_exit
#
# 2.3. Patch existing users in API
#
puts ""
puts "Patch existing users in API:"
if skip_patch
puts "Skipped as per ENV VAR or user request!"
else
patch_user_keys = users_ldap.keys & users_api.keys
patch_user_keys.each do |key|
users_ldap[key].id = users_api[key].id
users_ldap[key].is_admin = users_api[key].is_admin
users_ldap[key].account_enabled = users_api[key].account_enabled
users_ldap[key].password_sign_in_enabled = users_api[key].password_sign_in_enabled
end
log[:patch_api] = {}
patch_user_keys.each do |key|
res = api.patch_user(id: users_ldap[key].id, body: users_ldap[key])
log[:patch_api][key] = res.code
end
puts "Results:"
puts log[:patch_api].to_s
Kernel.exit(0) if !autopilot && ask_exit
end
#
# 2.4. Delete users who are in Leihs (API) but are not in LDAP anymore:
#
puts ""
puts "Delete users who aren't in LDAP anymore:"
puts "Keeping #{admin_users} and #{keep_users}"
delete_user_keys = (users_api.keys - users_ldap.keys) - (admin_users | keep_users)
log[:delete_api] = {}
delete_user_keys.each do |key|
res = api.delete_user(id: users_api[key].id)
log[:delete_api][key] = res.code
end
puts "Results:"
puts log[:delete_api].to_s
Kernel.exit(0) if !autopilot && ask_exit
#
# 2.5. Disable users who aren't in LDAP and cannot be deleted due to dependencies:
#
puts ""
puts "Disable users who aren't in LDAP and cannot be deleted due to dependencies:"
disable_user_keys = log[:delete_api].select{ |key, response_code| response_code == 409 }.keys
log[:patch_disable_api] = {}
disable_user_keys.each do |key|
res = api.patch_user(id: users_api[key].id, body: { account_enabled: false, password_sign_in_enabled: false })
log[:patch_disable_api][key] = res.code
end
puts "Results:"
puts log[:patch_disable_api].to_s
Kernel.exit(0) if !autopilot && ask_exit
puts "Goodbye :)" unless autopilot
The YAML object definitions
#
# YAML definitions to run the script every day using a K8s CronJob to update the leihs API.
#
# manual test possible: oc create job --from=cronjob/ruby-leihssyncer-s2i syncer-manual-test -n leihssyncer-job
kind: Secret
apiVersion: v1
metadata:
name: "ruby-leihssyncer-ldap"
namespace: "leihssyncer-job"
annotations:
app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
labels:
app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
app.kubernetes.io/part-of: "ruby-leihssyncer-s2i"
type: Opaque
stringData:
SYNCER_API_TOKEN: ""
SYNCER_BINDDN: ""
SYNCER_PASSWORD: ""
---
apiVersion: v1
kind: ConfigMap
metadata:
name: "ruby-leihssyncer-conf"
namespace: "leihssyncer-job"
annotations:
app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
labels:
app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
app.kubernetes.io/part-of: "ruby-leihssyncer-s2i"
data:
SYNCER_AUTOPILOT: "true"
SYNCER_SKIP_PATCH: "false"
---
kind: CronJob
apiVersion: batch/v1
metadata:
name: "ruby-leihssyncer-s2i"
namespace: "leihssyncer-job"
annotations:
app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
labels:
app.kubernetes.io/part-of: "ruby-leihssyncer-s2i"
app.openshift.io/runtime: ruby
app.openshift.io/runtime-version: "2.7-ubi8"
app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
spec:
schedule: "15 5 * * 1"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 10
failedJobsHistoryLimit: 10
jobTemplate:
metadata:
annotations:
app.openshift.io/vcs-ref: ""
app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
labels:
app.openshift.io/runtime: ruby
app.openshift.io/runtime-version: 2.7-ubi8
app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
spec:
backoffLimit: 0
template:
metadata:
annotations:
app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
labels:
app.openshift.io/runtime: ruby
app.openshift.io/runtime-version: 2.7-ubi8
app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
spec:
containers:
- name: "ruby-leihssyncer-s2i"
image: "image-registry.openshift-image-registry.svc:5000/project-of-imagestream/ruby-leihssyncer-s2i:stable"
imagePullPolicy: Always
command: ["bundle", "exec", "ruby", "leihs_syncer.rb"]
envFrom:
- secretRef:
name: ruby-leihssyncer-ldap
- configMapRef:
name: ruby-leihssyncer-conf
restartPolicy: "Never"
terminationGracePeriodSeconds: 30
activeDeadlineSeconds: 500
dnsPolicy: "ClusterFirst"
The BuildConfig
kind: BuildConfig
apiVersion: build.openshift.io/v1
metadata:
name: ruby-leihssyncer-s2i
namespace: project-of-imagestream
annotations:
app.openshift.io/vcs-ref: ""
app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
labels:
app.openshift.io/runtime: ruby
spec:
failedBuildsHistoryLimit: 5
output:
to:
kind: ImageStreamTag
name: "ruby-leihssyncer-s2i:latest"
runPolicy: Serial
source:
contextDir: /docker/
git:
uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
sourceSecret:
name: "k8s-secret-containing-your-git-access-token"
type: Git
strategy:
sourceStrategy:
from:
kind: ImageStreamTag
name: ruby:2.7-ubi8
namespace: namespace-of-ruby-imagestream
type: Source
successfulBuildsHistoryLimit: 5
triggers:
- type: ImageChange
- type: ConfigChange