# Copyright 2012-2014 Brian May
#
# This file is part of python-tldap.
#
# python-tldap is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# python-tldap is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with python-tldap If not, see <http://www.gnu.org/licenses/>.
""" This module provides the LDAP functions with transaction support faked,
with a subset of the functions from the real ldap module. """
import six
import tldap.dn
import ldap3
import tldap.exceptions
import tldap.modlist
import sys
import logging
logger = logging.getLogger(__name__)
from .base import LDAPbase
def _debug(*argv):
argv = [str(arg) for arg in argv]
logging.debug(" ".join(argv))
def raise_testfailure(place):
raise tldap.exceptions.TestFailure("fail %s called" % place)
# errors
class NO_SUCH_OBJECT(Exception):
pass
# wrapper class
[docs]class LDAPwrapper(LDAPbase):
""" The LDAP connection class. """
def __init__(self, settings_dict):
super(LDAPwrapper, self).__init__(settings_dict)
self._transact = False
self._onrollback = []
####################
# Cache Management #
####################
[docs] def reset(self):
"""
Reset transaction back to original state, discarding all
uncompleted transactions.
"""
super(LDAPwrapper, self).reset()
self._onrollback = []
def _cache_get_for_dn(self, dn):
"""
Object state is cached. When an update is required the update will be
simulated on this cache, so that rollback information can be correct.
This function retrieves the cached data.
"""
# no cached item, retrieve from ldap
self._do_with_retry(
lambda obj: obj.search(
dn,
'(objectclass=*)',
ldap3.SEARCH_SCOPE_WHOLE_SUBTREE,
attributes=['*', '+']))
results = self._obj.response
if len(results) < 1:
raise NO_SUCH_OBJECT("No results finding current value")
if len(results) > 1:
raise RuntimeError("Too many results finding current value")
return results[0]['raw_attributes']
##########################
# Transaction Management #
##########################
[docs] def is_dirty(self):
""" Are there uncommitted changes? """
if len(self._onrollback) > 0:
return True
return False
[docs] def is_managed(self):
""" Are we inside transaction management? """
return self._transact
[docs] def enter_transaction_management(self):
""" Start a transaction. """
if self._transact:
raise RuntimeError(
"enter_transaction_management called inside transaction")
self._transact = True
self._onrollback = []
[docs] def leave_transaction_management(self):
"""
End a transaction. Must not be dirty when doing so. ie. commit() or
rollback() must be called if changes made. If dirty, changes will be
discarded.
"""
if not self._transact:
self.reset()
self._transact = False
raise RuntimeError(
"leave_transaction_management called outside transaction")
if len(self._onrollback) > 0:
self.reset()
self._transact = False
raise RuntimeError(
"leave_transaction_management called "
"with uncommited rollbacks")
self.reset()
self._transact = False
[docs] def commit(self):
"""
Attempt to commit all changes to LDAP database. i.e. forget all
rollbacks. However stay inside transaction management.
"""
if not self._transact:
raise RuntimeError("commit called outside transaction")
_debug("commit")
self.reset()
[docs] def rollback(self):
"""
Roll back to previous database state. However stay inside transaction
management.
"""
if not self._transact:
raise RuntimeError("rollback called outside transaction")
_debug("rollback:", self._onrollback)
# if something goes wrong here, nothing we can do about it, leave
# database as is.
try:
# for every rollback action ...
for onrollback, onfailure in self._onrollback:
# execute it
_debug("--> rolling back", onrollback)
self._do_with_retry(onrollback)
if onfailure is not None:
onfailure()
except:
_debug("--> rollback failed")
exc_class, exc, tb = sys.exc_info()
raise tldap.exceptions.RollbackError(
"FATAL Unrecoverable rollback error: %r" % (exc))
finally:
# reset everything to clean state
_debug("--> rollback success")
self.reset()
def _process(self, oncommit, onrollback, onfailure):
"""
Process action. oncommit is a callback to execute action, onrollback is
a callback to execute if the oncommit() has been called and a rollback
is required
"""
_debug("---> commiting", oncommit)
result = self._do_with_retry(oncommit)
if not self._transact:
self.reset()
else:
# add statement to rollback log in case something goes wrong
self._onrollback.insert(0, (onrollback, onfailure))
return result
##################################
# Functions needing Transactions #
##################################
[docs] def add(self, dn, modlist, onfailure=None):
"""
Add a DN to the LDAP database; See ldap module. Doesn't return a result
if transactions enabled.
"""
_debug("add", self, dn, modlist)
# if rollback of add required, delete it
oncommit = lambda obj: obj.add(dn, None, modlist)
onrollback = lambda obj: obj.delete(dn)
# process this action
return self._process(oncommit, onrollback, onfailure)
[docs] def modify(self, dn, modlist, onfailure=None):
"""
Modify a DN in the LDAP database; See ldap module. Doesn't return a
result if transactions enabled.
"""
_debug("modify", self, dn, modlist)
# need to work out how to reverse changes in modlist; result in revlist
revlist = {}
# get the current cached attributes
result = self._cache_get_for_dn(dn)
# find the how to reverse modlist (for rollback) and put result in
# revlist. Also simulate actions on cache.
for mod_type, l in six.iteritems(modlist):
mod_op, mod_vals = l
reverse = None
_debug("attribute:", mod_type)
if mod_type in result:
_debug("attribute cache:", result[mod_type])
else:
_debug("attribute cache is empty")
_debug("attribute modify:", (mod_op, mod_vals))
if mod_vals is not None:
if not isinstance(mod_vals, list):
mod_vals = [mod_vals]
if mod_op == ldap3.MODIFY_ADD:
# reverse of MODIFY_ADD is MODIFY_DELETE
reverse = (ldap3.MODIFY_DELETE, mod_vals)
elif mod_op == ldap3.MODIFY_DELETE and len(mod_vals) > 0:
# Reverse of MODIFY_DELETE is MODIFY_ADD, but only if value
# is given if mod_vals is None, this means all values where
# deleted.
reverse = (ldap3.MODIFY_ADD, mod_vals)
elif mod_op == ldap3.MODIFY_DELETE \
or mod_op == ldap3.MODIFY_REPLACE:
if mod_type in result:
# If MODIFY_DELETE with no values or MODIFY_REPLACE
# then we have to replace all attributes with cached
# state
reverse = (
ldap3.MODIFY_REPLACE,
tldap.modlist.escape_list(result[mod_type])
)
else:
# except if we have no cached state for this DN, in
# which case we delete it.
reverse = (ldap3.MODIFY_DELETE, None)
else:
raise RuntimeError("mod_op of %d not supported" % mod_op)
_debug("attribute reverse:", reverse)
if mod_type in result:
_debug("attribute cache:", result[mod_type])
else:
_debug("attribute cache is empty")
revlist[mod_type] = reverse
_debug("--")
_debug("modlist:", modlist)
_debug("revlist:", revlist)
_debug("--")
# now the hard stuff is over, we get to the easy stuff
oncommit = lambda obj: obj.modify(dn, modlist)
onrollback = lambda obj: obj.modify(dn, revlist)
return self._process(oncommit, onrollback, onfailure)
[docs] def modify_no_rollback(self, dn, modlist):
"""
Modify a DN in the LDAP database; See ldap module. Doesn't return a
result if transactions enabled.
"""
_debug("modify_no_rollback", self, dn, modlist)
result = self._do_with_retry(lambda obj: obj.modify_s(dn, modlist))
_debug("--")
return result
[docs] def delete(self, dn, onfailure=None):
"""
delete a dn in the ldap database; see ldap module. doesn't return a
result if transactions enabled.
"""
_debug("delete", self)
# get copy of cache
result = self._cache_get_for_dn(dn)
# remove special values that can't be added
def delete_attribute(name):
if name in result:
del result[name]
delete_attribute('entryUUID')
delete_attribute('structuralObjectClass')
delete_attribute('modifiersName')
delete_attribute('subschemaSubentry')
delete_attribute('entryDN')
delete_attribute('modifyTimestamp')
delete_attribute('entryCSN')
delete_attribute('createTimestamp')
delete_attribute('creatorsName')
delete_attribute('hasSubordinates')
delete_attribute('pwdFailureTime')
delete_attribute('pwdChangedTime')
# turn into modlist list.
modlist = tldap.modlist.addModlist(result)
_debug("revlist:", modlist)
# on commit carry out action; on rollback restore cached state
oncommit = lambda obj: obj.delete(dn)
onrollback = lambda obj: obj.add(dn, None, modlist)
return self._process(oncommit, onrollback, onfailure)
[docs] def rename(self, dn, newrdn, new_base_dn=None, onfailure=None):
"""
rename a dn in the ldap database; see ldap module. doesn't return a
result if transactions enabled.
"""
_debug("rename", self, dn, newrdn, new_base_dn)
# split up the parameters
split_dn = tldap.dn.str2dn(dn)
split_newrdn = tldap.dn.str2dn(newrdn)
assert(len(split_newrdn) == 1)
# make dn unqualified
rdn = tldap.dn.dn2str(split_dn[0:1])
# make newrdn fully qualified dn
tmplist = []
tmplist.append(split_newrdn[0])
if new_base_dn is not None:
tmplist.extend(tldap.dn.str2dn(new_base_dn))
old_base_dn = tldap.dn.dn2str(split_dn[1:])
else:
tmplist.extend(split_dn[1:])
old_base_dn = None
newdn = tldap.dn.dn2str(tmplist)
_debug("--> commit ", self, dn, newrdn, new_base_dn)
_debug("--> rollback", self, newdn, rdn, old_base_dn)
# on commit carry out action; on rollback reverse rename
oncommit = lambda obj: obj.modify_dn(
dn, newrdn, new_superior=new_base_dn)
onrollback = lambda obj: obj.modify_dn(
newdn, rdn, new_superior=old_base_dn)
return self._process(oncommit, onrollback, onfailure)
[docs] def fail(self):
""" for testing purposes only. always fail in commit """
_debug("fail")
# on commit carry out action; on rollback reverse rename
oncommit = lambda obj: raise_testfailure("commit")
onrollback = lambda obj: raise_testfailure("rollback")
return self._process(oncommit, onrollback, None)