You've already forked oncall
mirror of
https://github.com/linkedin/oncall.git
synced 2025-11-26 23:10:47 +02:00
Add ldap example user sync and auth
This commit is contained in:
@@ -16,7 +16,7 @@ Development setup
|
||||
|
||||
```bash
|
||||
python setup.py develop
|
||||
pip install -r dev_requirements.txt
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
Setup mysql schema:
|
||||
|
||||
@@ -22,6 +22,10 @@ session:
|
||||
auth:
|
||||
debug: True
|
||||
module: 'oncall.auth.modules.debug'
|
||||
# module: 'oncall.auth.modules.ldap_example'
|
||||
# ldap_url: 'ldaps://example.com'
|
||||
# ldap_user_suffix: '@example.biz'
|
||||
# ldap_cert_path: '/etc/ldap_cert.pem'
|
||||
notifier:
|
||||
skipsend: True
|
||||
healthcheck_path: /tmp/status
|
||||
@@ -87,4 +91,17 @@ iris_plan_integration:
|
||||
# oauth_access_token: 'foo'
|
||||
#
|
||||
# user_sync:
|
||||
# module: 'oncall.user_sync.slack'
|
||||
# module: 'oncall.user_sync.ldap_sync'
|
||||
# ldap_sync:
|
||||
# url: 'ldaps://example.com'
|
||||
# base: 'ou=Staff Users,dc=multiforest,dc=biz'
|
||||
# user: 'CN=example,DC=multiforest,DC=biz'
|
||||
# password: 'password'
|
||||
# cert_path: '/etc/ldap_cert.pem'
|
||||
# query: '(&(objectClass=userProxy)(employeeId=*))'
|
||||
# attrs:
|
||||
# username: 'sAMAccountName'
|
||||
# full_name: 'displayName'
|
||||
# mail: 'mail'
|
||||
# mobile: 'mobile'
|
||||
# image_url: 'https://image.example.com/api/%s/picture'
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
pytest
|
||||
pytest-mock
|
||||
requests
|
||||
gunicorn
|
||||
flake8
|
||||
Sphinx==1.5.6
|
||||
sphinxcontrib-httpdomain
|
||||
sphinx_rtd_theme
|
||||
sphinx-autobuild
|
||||
14
setup.py
14
setup.py
@@ -32,6 +32,20 @@ setuptools.setup(
|
||||
'irisclient',
|
||||
'slackclient',
|
||||
],
|
||||
extras_require={
|
||||
'ldap': ['python-ldap'],
|
||||
'dev': [
|
||||
'pytest',
|
||||
'pytest-mock',
|
||||
'requests',
|
||||
'gunicorn',
|
||||
'flake8',
|
||||
'Sphinx==1.5.6',
|
||||
'sphinxcontrib-httpdomain',
|
||||
'sphinx_rtd_theme',
|
||||
'sphinx-autobuild',
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'oncall-dev = oncall.bin.run_server:main',
|
||||
|
||||
33
src/oncall/auth/modules/ldap_example.py
Normal file
33
src/oncall/auth/modules/ldap_example.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license.
|
||||
# See LICENSE in the project root for license information.
|
||||
import ldap
|
||||
|
||||
|
||||
class Authenticator:
|
||||
def __init__(self, config):
|
||||
if config.get('debug'):
|
||||
self.authenticate = self.debug_auth
|
||||
return
|
||||
self.ldap_url = config['ldap_url']
|
||||
self.cert_path = config['ldap_cert_path']
|
||||
self.user_suffix = config['ldap_user_suffix']
|
||||
self.authenticate = self.ldap_auth
|
||||
|
||||
def ldap_auth(self, username, password):
|
||||
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.cert_path)
|
||||
connection = ldap.initialize(self.ldap_url)
|
||||
connection.set_option(ldap.OPT_REFERRALS, 0)
|
||||
|
||||
try:
|
||||
if password:
|
||||
connection.simple_bind_s(username + self.user_suffix, password)
|
||||
else:
|
||||
return False
|
||||
except ldap.INVALID_CREDENTIALS:
|
||||
return False
|
||||
except ldap.SERVER_DOWN:
|
||||
return None
|
||||
return True
|
||||
|
||||
def debug_auth(self, username, password):
|
||||
return True
|
||||
309
src/oncall/user_sync/ldap_sync.py
Normal file
309
src/oncall/user_sync/ldap_sync.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from gevent import monkey, sleep, spawn
|
||||
monkey.patch_all() # NOQA
|
||||
|
||||
import sys
|
||||
import time
|
||||
import yaml
|
||||
import logging
|
||||
import ldap
|
||||
|
||||
from oncall import metrics
|
||||
from ldap.controls import SimplePagedResultsControl
|
||||
from datetime import datetime
|
||||
from pytz import timezone
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||
from phonenumbers import format_number, parse, PhoneNumberFormat
|
||||
from phonenumbers.phonenumberutil import NumberParseException
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s')
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG)
|
||||
ch.setFormatter(formatter)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(ch)
|
||||
|
||||
stats = {
|
||||
'ldap_found': 0,
|
||||
'sql_errors': 0,
|
||||
'users_added': 0,
|
||||
'users_failed_to_add': 0,
|
||||
'users_failed_to_update': 0,
|
||||
'users_purged': 0,
|
||||
'user_contacts_updated': 0,
|
||||
'user_names_updated': 0,
|
||||
'user_photos_updated': 0,
|
||||
'users_reactivated': 0,
|
||||
'users_failed_to_reactivate': 0,
|
||||
}
|
||||
|
||||
LDAP_SETTINGS = {}
|
||||
|
||||
|
||||
def normalize_phone_number(num):
|
||||
return format_number(parse(num.decode('utf-8'), 'US'), PhoneNumberFormat.INTERNATIONAL)
|
||||
|
||||
|
||||
def get_predefined_users(config):
|
||||
users = {}
|
||||
|
||||
try:
|
||||
config_users = config['sync_script']['preset_users']
|
||||
except KeyError:
|
||||
return {}
|
||||
|
||||
for user in config_users:
|
||||
users[user['name']] = user
|
||||
for key in ['sms', 'call']:
|
||||
try:
|
||||
users[user['name']][key] = normalize_phone_number(users[user['name']][key])
|
||||
except (NumberParseException, KeyError, AttributeError):
|
||||
users[user['name']][key] = None
|
||||
|
||||
return users
|
||||
|
||||
|
||||
def timestamp_to_human_str(timestamp, tz):
|
||||
dt = datetime.fromtimestamp(timestamp, timezone(tz))
|
||||
return ' '.join([dt.strftime('%Y-%m-%d %H:%M:%S'), tz])
|
||||
|
||||
|
||||
def prune_user(engine, username):
|
||||
global stats
|
||||
stats['users_purged'] += 1
|
||||
|
||||
try:
|
||||
engine.execute('DELETE FROM `user` WHERE `name` = %s', username)
|
||||
logger.info('Deleted inactive user %s', username)
|
||||
|
||||
# The user has messages or some other user data which should be preserved. Just mark as inactive.
|
||||
except IntegrityError:
|
||||
logger.info('Marking user %s inactive', username)
|
||||
engine.execute('UPDATE `user` SET `active` = FALSE WHERE `name` = %s', username)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error('Deleting user %s failed: %s', username, e)
|
||||
stats['sql_errors'] += 1
|
||||
|
||||
|
||||
def fetch_ldap():
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW)
|
||||
l = ldap.initialize(LDAP_SETTINGS['url'])
|
||||
l.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_SETTINGS['cert_path'])
|
||||
l.simple_bind_s(LDAP_SETTINGS['user'], LDAP_SETTINGS['password'])
|
||||
|
||||
req_ctrl = SimplePagedResultsControl(True, size=1000, cookie='')
|
||||
|
||||
known_ldap_resp_ctrls = {
|
||||
SimplePagedResultsControl.controlType: SimplePagedResultsControl,
|
||||
}
|
||||
|
||||
base = LDAP_SETTINGS['base']
|
||||
attrs = ['distinguishedName'] + LDAP_SETTINGS['attrs'].values()
|
||||
query = LDAP_SETTINGS['query']
|
||||
|
||||
users = {}
|
||||
dn_map = {}
|
||||
|
||||
while True:
|
||||
msgid = l.search_ext(base, ldap.SCOPE_SUBTREE, query, attrs, serverctrls=[req_ctrl])
|
||||
rtype, rdata, rmsgid, serverctrls = l.result3(msgid, resp_ctrl_classes=known_ldap_resp_ctrls)
|
||||
logger.info('Loaded %d entries from ldap.' % len(rdata))
|
||||
for dn, ldap_dict in rdata:
|
||||
if LDAP_SETTINGS['attrs']['mail'] not in ldap_dict:
|
||||
logger.error('ERROR: invalid ldap entry for dn: %s' % dn)
|
||||
continue
|
||||
|
||||
username = ldap_dict['sAMAccountName'][0]
|
||||
|
||||
mobile = ldap_dict.get(LDAP_SETTINGS['attrs']['mobile'])
|
||||
mail = ldap_dict.get(LDAP_SETTINGS['attrs']['mail'])
|
||||
name = ldap_dict.get(LDAP_SETTINGS['attrs']['full_name'])[0]
|
||||
|
||||
if mobile:
|
||||
try:
|
||||
mobile = normalize_phone_number(mobile[0])
|
||||
except NumberParseException:
|
||||
mobile = None
|
||||
except UnicodeEncodeError:
|
||||
mobile = None
|
||||
|
||||
if mail:
|
||||
mail = mail[0]
|
||||
slack = mail.split('@')[0]
|
||||
else:
|
||||
slack = None
|
||||
|
||||
contacts = {'call': mobile, 'sms': mobile, 'email': mail, 'slack': slack, 'name': name}
|
||||
dn_map[dn] = username
|
||||
users[username] = contacts
|
||||
|
||||
pctrls = [
|
||||
c for c in serverctrls if c.controlType == SimplePagedResultsControl.controlType
|
||||
]
|
||||
|
||||
cookie = pctrls[0].cookie
|
||||
if not cookie:
|
||||
break
|
||||
req_ctrl.cookie = cookie
|
||||
|
||||
return users
|
||||
|
||||
|
||||
def sync(config, engine):
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
oncall_users = {}
|
||||
users_query = '''SELECT `user`.`name` as `name`, `contact_mode`.`name` as `mode`, `user_contact`.`destination`,
|
||||
`user`.`full_name`, `user`.`photo_url`
|
||||
FROM `user`
|
||||
LEFT OUTER JOIN `user_contact` ON `user`.`id` = `user_contact`.`user_id`
|
||||
LEFT OUTER JOIN `contact_mode` ON `user_contact`.`mode_id` = `contact_mode`.`id`
|
||||
ORDER BY `user`.`name`'''
|
||||
for row in engine.execute(users_query):
|
||||
contacts = oncall_users.setdefault(row.name, {})
|
||||
if row.mode is None or row.destination is None:
|
||||
continue
|
||||
contacts[row.mode] = row.destination
|
||||
contacts['full_name'] = row.full_name
|
||||
contacts['photo_url'] = row.photo_url
|
||||
|
||||
oncall_usernames = set(oncall_users)
|
||||
|
||||
# users from ldap and config file
|
||||
ldap_users = fetch_ldap()
|
||||
stats['ldap_found'] += len(ldap_users)
|
||||
ldap_users.update(get_predefined_users(config))
|
||||
ldap_usernames = set(ldap_users)
|
||||
|
||||
# set of ldap users not in oncall
|
||||
users_to_insert = ldap_usernames - oncall_usernames
|
||||
# set of existing oncall users that are in ldap
|
||||
users_to_update = oncall_usernames & ldap_usernames
|
||||
# set of users in oncall but not ldap, assumed to be inactive
|
||||
inactive_users = oncall_usernames - ldap_usernames
|
||||
# users who need to be deactivated
|
||||
rows = engine.execute('SELECT name FROM user WHERE active = TRUE AND name IN %s', inactive_users)
|
||||
users_to_purge = (user.name for user in rows)
|
||||
|
||||
# set of inactive oncall users who appear in ldap
|
||||
rows = engine.execute('SELECT name FROM user WHERE active = FALSE AND name IN %s', ldap_usernames)
|
||||
users_to_reactivate = (user.name for user in rows)
|
||||
|
||||
# get objects needed for insertion
|
||||
modes = dict(list(session.execute('SELECT `name`, `id` FROM `contact_mode`')))
|
||||
|
||||
user_add_sql = 'INSERT INTO `user` (`name`, `full_name`, `photo_url`) VALUES (%s, %s, %s)'
|
||||
|
||||
# insert users that need to be
|
||||
logger.debug('Users to insert:')
|
||||
for username in users_to_insert:
|
||||
logger.debug('Inserting %s' % username)
|
||||
full_name = ldap_users[username].pop('name')
|
||||
try:
|
||||
user_id = engine.execute(user_add_sql, (username, full_name, LDAP_SETTINGS['image_url'] % username)).lastrowid
|
||||
except SQLAlchemyError:
|
||||
stats['users_failed_to_add'] += 1
|
||||
stats['sql_errors'] += 1
|
||||
logger.exception('Failed to add user %s' % username)
|
||||
continue
|
||||
stats['users_added'] += 1
|
||||
for key, value in ldap_users[username].iteritems():
|
||||
if value and key in modes:
|
||||
logger.debug('\t%s -> %s' % (key, value))
|
||||
user_contact_add_sql = 'INSERT INTO `user_contact` (`user_id`, `mode_id`, `destination`) VALUES (%s, %s, %s)'
|
||||
engine.execute(user_contact_add_sql, (user_id, modes[key], value))
|
||||
|
||||
# update users that need to be
|
||||
contact_update_sql = 'UPDATE user_contact SET destination = %s WHERE user_id = (SELECT id FROM user WHERE name = %s) AND mode_id = %s'
|
||||
contact_insert_sql = 'INSERT INTO user_contact (user_id, mode_id, destination) VALUES ((SELECT id FROM user WHERE name = %s), %s, %s)'
|
||||
contact_delete_sql = 'DELETE FROM user_contact WHERE user_id = (SELECT id FROM user WHERE name = %s) AND mode_id = %s'
|
||||
name_update_sql = 'UPDATE user SET full_name = %s WHERE name = %s'
|
||||
photo_update_sql = 'UPDATE user SET photo_url = %s WHERE name = %s'
|
||||
logger.debug('Users to update:')
|
||||
for username in users_to_update:
|
||||
logger.debug(username)
|
||||
try:
|
||||
db_contacts = oncall_users[username]
|
||||
ldap_contacts = ldap_users[username]
|
||||
full_name = ldap_contacts.pop('name')
|
||||
if full_name != db_contacts.get('full_name'):
|
||||
engine.execute(name_update_sql, (full_name, username))
|
||||
stats['user_names_updated'] += 1
|
||||
if not db_contacts.get('photo_url'):
|
||||
engine.execute(photo_update_sql, (LDAP_SETTINGS['image_url'] % username, username))
|
||||
stats['user_photos_updated'] += 1
|
||||
for mode in modes:
|
||||
if mode in ldap_contacts and ldap_contacts[mode]:
|
||||
if mode in db_contacts:
|
||||
if ldap_contacts[mode] != db_contacts[mode]:
|
||||
logger.debug('\tupdating %s', mode)
|
||||
engine.execute(contact_update_sql, (ldap_contacts[mode], username, modes[mode]))
|
||||
stats['user_contacts_updated'] += 1
|
||||
else:
|
||||
logger.debug('\tadding %s', mode)
|
||||
engine.execute(contact_insert_sql, (username, modes[mode], ldap_contacts[mode]))
|
||||
stats['user_contacts_updated'] += 1
|
||||
elif mode in db_contacts:
|
||||
logger.debug('\tdeleting %s', mode)
|
||||
engine.execute(contact_delete_sql, (username, modes[mode]))
|
||||
stats['user_contacts_updated'] += 1
|
||||
else:
|
||||
logger.debug('\tmissing %s', mode)
|
||||
except SQLAlchemyError:
|
||||
stats['users_failed_to_update'] += 1
|
||||
stats['sql_errors'] += 1
|
||||
logger.exception('Failed to update user %s' % username)
|
||||
continue
|
||||
|
||||
logger.debug('Users to mark as inactive:')
|
||||
for username in users_to_purge:
|
||||
prune_user(engine, username)
|
||||
|
||||
logger.debug('Users to reactivate:')
|
||||
for username in users_to_reactivate:
|
||||
logger.debug(username)
|
||||
try:
|
||||
engine.execute('UPDATE user SET active = TRUE WHERE name = %s', username)
|
||||
stats['users_reactivated'] += 1
|
||||
except SQLAlchemyError:
|
||||
stats['users_failed_to_reactivate'] += 1
|
||||
stats['sql_errors'] += 1
|
||||
logger.exception('Failed to reactivate user %s', username)
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
|
||||
def metrics_sender():
|
||||
while True:
|
||||
metrics.emit_metrics()
|
||||
sleep(60)
|
||||
|
||||
|
||||
def main(config):
|
||||
global LDAP_SETTINGS
|
||||
|
||||
LDAP_SETTINGS = config['ldap_sync']
|
||||
|
||||
metrics.init(config, 'oncall-ldap-user-sync', stats)
|
||||
spawn(metrics_sender)
|
||||
# Default sleep one hour
|
||||
sleep_time = config.get('user_sync_sleep_time', 3600)
|
||||
engine = create_engine(config['db']['conn']['str'] % config['db']['conn']['kwargs'],
|
||||
**config['db']['kwargs'])
|
||||
while 1:
|
||||
logger.info('Starting user sync loop at %s' % time.time())
|
||||
sync(config, engine)
|
||||
logger.info('Sleeping for %s seconds' % sleep_time)
|
||||
sleep(sleep_time)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
config_path = sys.argv[1]
|
||||
with open(config_path, 'r') as config_file:
|
||||
config = yaml.load(config_file)
|
||||
main(config)
|
||||
Reference in New Issue
Block a user