[Added cleaned-up version of LDAP REST interface Panu Kalliokoski **20151116113124 Ignore-this: 56f7c2d6c43f478bfc643e88c166704b ] adddir ./ldaprest addfile ./ldaprest/query.py hunk ./ldaprest/query.py 1 +#!/usr/bin/env python + +# TODO: make configurable with a conffile +# TODO: support for PUT, PATCH, (maybe POST) for typed objects. +# TODO: deny wildcards in names, separate URI for queries. +# TODO: support faceting in queries? + +from bottle import (Bottle, request, response, auth_basic, abort, redirect) +from urllib import quote as urlquote +from cgi import escape as htmlquote +from mimeparse import best_match +import ldap, json +from ldap.dn import str2dn + +server = 'ldap://ldap.example.com/' # XXX change +realm = 'LDAP directory on ' + server +userbase = 'ou=Users,o=Example' # XXX change +op_attrs = [ + 'createTimeStamp', + 'creatorsName', + 'modifiersName', + 'modifyTimeStamp', + 'revision', + 'GUID', + 'subordinateCount', + 'lastLoginTime'] + +def url_for_dn(dn): return '/prov/v1/object/' + urlquote(dn, '') + +def get_attrdict(result): + dn, attrs = result[0] + attrs['dn'] = [dn] + return attrs + +def looks_like_dn(s): + try: str2dn(s) + except ldap.DECODING_ERROR: return False + return True + +def looks_like_uri(s): return '://' in s + +def qualify_user(user): + # XXX change + return 'cn=%s,ou=%s_set,ou=Staff,%s' % (user, user, userbase) + +def modifyModlist(oldentry, newentry): + modlist = [] + for attribute in newentry: + oldvals = oldentry.get(attribute, []) + if not isinstance(oldvals, list): continue + oldvals = set(oldvals) + newvals = newentry.get(attribute, []) + if not isinstance(newvals, list): continue + newvals = set(val.encode('utf-8') for val in newvals) + addvals = list(newvals - oldvals) + delvals = list(oldvals - newvals) + if not (oldvals & newvals): + modlist.append((ldap.MOD_REPLACE, attribute, addvals)) + continue + if addvals: modlist.append((ldap.MOD_ADD, attribute, addvals)) + if delvals: modlist.append((ldap.MOD_DELETE, attribute, delvals)) + return modlist + + +def render_in_html(obj): + res = '%s%s' % \ + (htmlquote(obj.get('name', server)), object_to_html(obj)) + try: return res.decode('utf-8') + except UnicodeDecodeError: return res + +def object_to_html(obj): + if isinstance(obj, list): + return '' % '\n'.join( + '
  • %s
  • ' % object_to_html(o) for o in obj) + if isinstance(obj, dict) and 'href' in obj: + href = obj['href'] + del obj['href'] + return '%s' % (href, htmlquote(repr(obj))) + if isinstance(obj, dict): + return '
    \n%s\n
    ' % '\n'.join( + '
    %s
    \n
    %s
    ' % + (htmlquote(str(key)), object_to_html(value)) + for key, value in obj.iteritems()) + if isinstance(obj, str) and looks_like_dn(obj): + return '%s' % (url_for_dn(obj), htmlquote(obj)) + if isinstance(obj, str) and looks_like_uri(obj): + return '%s' % (obj, htmlquote(obj)) + return htmlquote(str(obj)) + +def may_convert_to_html(oldhandler): + def newhandler(*a, **ka): + res = oldhandler(*a, **ka) + if not isinstance(res, dict): return res + wanted_type = best_match(('text/html', 'application/json'), + request.headers['accept']) + if wanted_type == 'application/json': return res + return render_in_html(res) + return newhandler + +def setup_prov_service(): + + prov = Bottle() + ldapcon = ldap.initialize(server) + #ldapcon.start_tls_s() + objtypes = { # XXX change + 'user': (userbase, 'cn', 'inetOrgPerson'), + 'group': ('ou=Groups,o=Example', 'cn', 'groupOfNames') + } + + def ldapbind(user, password): + try: ldapcon.simple_bind_s(qualify_user(user), password) + except (ldap.INVALID_CREDENTIALS, ldap.INVALID_DN_SYNTAX): + return False + return True + + ldap_auth = auth_basic(ldapbind, realm=realm) + + #@prov.error(404) + #@prov.error(400) + #def json_error_document(error): + # return '{"status": "error", "code": %d, "message": "%s"}' % \ + # (error.status, error.body) + + @prov.get('/logout') + @auth_basic(lambda user, pw: False, realm=realm, + text = 'You are now logged out.') + def logout(): pass + + @prov.get('/') + @may_convert_to_html + def root(): + services = [('Log out', 0, '/logout')] # XXX add objtypes + return dict(name='Services for '+server, count=len(services), + type='links', + links=[dict(name=name, version=ver, href=link) + for name, ver, link in services]) + + def search(base, scope, filter='(objectClass=*)', attrs=None): + try: result = ldapcon.search_s(base, scope, filter, attrs) + except ldap.INVALID_DN_SYNTAX: + abort(400, 'Invalid DN syntax: ' + dn) + except ldap.NO_SUCH_OBJECT: abort(404, 'No such LDAP object') + if not result: abort(404, 'No such LDAP object') + return result + + def make_search(base, attr, filter, narrow_search, + scope=ldap.SCOPE_SUBTREE): + result = search(base, scope, filter, [attr]) + if len(result) > 1: return narrow_search(result) + dn, attrs = result[0] + attrs = get_attrdict(search(dn, ldap.SCOPE_BASE)) + attrs.update(get_attrdict(search(dn, ldap.SCOPE_BASE, + '(objectClass=*)', op_attrs))) + attrs.update(dict(count=1, type='object', + name='%s: %s' % (attr, attrs[attr][0]))) + return attrs + + @prov.get('/prov/v1/object/') + @ldap_auth + @may_convert_to_html + def get_object(dn): + return make_search(dn, 'dn', '(objectClass=*)', + lambda results: HTTPError(500, "Should not happen"), + ldap.SCOPE_BASE) + + @prov.get('/prov/v1//') + def list_objects(objtype): redirect('/prov/v1/%s/*' % objtype) + + @prov.get('/prov/v1//') + @ldap_auth + @may_convert_to_html + def get_object_by_type(objtype, name): + try: base, attr, clsname = objtypes[objtype] + except KeyError: abort(404, 'Unknown object type: ' + objtype) + def my_narrow_search(result): + response.status = 300 + offs = int(request.query.offset or 0) + if len(result) <= offs + 100: nextlnk = [] + else: nextlnk = [dict(href='/prov/v1/%s/%s?offset=%d' % + (objtype, name, offs+100), rel='next')] + links = [dict(id=value, + href='/prov/v1/%s/%s' % (objtype, value)) + for dn, attrs in result[offs:offs+100] + for value in attrs[attr]] + return dict(name='Multiple choices for ' + name, + offset=offs, type='choices', count=len(result), + links=nextlnk+links) + return make_search(base, attr, '(&(objectClass=%s)(%s=%s))' + % (clsname, attr, name), my_narrow_search) + + @prov.put('/prov/v1//') + @ldap_auth + @may_convert_to_html + def put(objtype, name): + try: base, attr, clsname = objtypes[objtype] + except KeyError: abort(404, 'Unknown object type: ' + objtype) + def multi(result): abort(300, 'ambiguous put: ' + name) + oldobj = make_search(base, attr, '(&(objectClass=%s)(%s=%s))' + % (clsname, attr, name), multi) + if oldobj[attr] != [name]: + abort(400, 'PUTting %s as %s but it should be %s' % + (objtype, name, oldobj[attr])) + newobj = request.json + if not newobj: + abort(400, 'Send your object as application/json') + modlist = modifyModlist(oldobj, newobj) + dn = oldobj['dn'][0] + print('Modifications for %s: %s' % (dn, repr(modlist))) + ldapcon.modify_s(dn, modlist) + return make_search(base, attr, '(&(objectClass=%s)(%s=%s))' + % (clsname, attr, name), multi) + + return prov + +if __name__ == '__main__': setup_prov_service().run(port=8888) +