diff options
author | Petr Viktorin <pviktori@redhat.com> | 2013-01-31 06:19:02 -0500 |
---|---|---|
committer | Martin Kosek <mkosek@redhat.com> | 2013-03-13 12:36:33 +0100 |
commit | 4e6a2a916d99c4eb9f5e6f5d622517e1b3fe323e (patch) | |
tree | cda6529b34dd7450fa3e7f98085f7e39cd9848a9 /ipapython | |
parent | a38d93f65f87db1a0b9c34eb0ba1b6d9dca9e060 (diff) | |
download | freeipa-4e6a2a916d99c4eb9f5e6f5d622517e1b3fe323e.tar.gz freeipa-4e6a2a916d99c4eb9f5e6f5d622517e1b3fe323e.tar.xz freeipa-4e6a2a916d99c4eb9f5e6f5d622517e1b3fe323e.zip |
Move ipaldap to ipapython
Part of the work for: https://fedorahosted.org/freeipa/ticket/3446
Diffstat (limited to 'ipapython')
-rw-r--r-- | ipapython/ipaldap.py | 1815 |
1 files changed, 1815 insertions, 0 deletions
diff --git a/ipapython/ipaldap.py b/ipapython/ipaldap.py new file mode 100644 index 000000000..4f51d6a87 --- /dev/null +++ b/ipapython/ipaldap.py @@ -0,0 +1,1815 @@ +# Authors: Rich Megginson <richm@redhat.com> +# Rob Crittenden <rcritten@redhat.com> +# John Dennis <jdennis@redhat.com> +# +# Copyright (C) 2007 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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. +# +# This program 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 this program. If not, see <http://www.gnu.org/licenses/>. +# + +import string +import time +import shutil +from decimal import Decimal +from copy import deepcopy +import contextlib + +import ldap +import ldap.sasl +import ldap.filter +from ldap.ldapobject import SimpleLDAPObject +import ldapurl + +from ipalib import errors, _ +from ipapython import ipautil +from ipapython.ipautil import ( + format_netloc, wait_for_open_socket, wait_for_open_ports, CIDict) +from ipapython.ipa_log_manager import log_mgr +from ipapython.dn import DN, RDN + +# Global variable to define SASL auth +SASL_GSSAPI = ldap.sasl.sasl({}, 'GSSAPI') + +DEFAULT_TIMEOUT = 10 +DN_SYNTAX_OID = '1.3.6.1.4.1.1466.115.121.1.12' +_debug_log_ldap = False + +# Group Member types +MEMBERS_ALL = 0 +MEMBERS_DIRECT = 1 +MEMBERS_INDIRECT = 2 + +_missing = object() + + +def unicode_from_utf8(val): + ''' + val is a UTF-8 encoded string, return a unicode object. + ''' + return val.decode('utf-8') + + +def value_to_utf8(val): + ''' + Coerce the val parameter to a UTF-8 encoded string representation + of the val. + ''' + + # If val is not a string we need to convert it to a string + # (specifically a unicode string). Naively we might think we need to + # call str(val) to convert to a string. This is incorrect because if + # val is already a unicode object then str() will call + # encode(default_encoding) returning a str object encoded with + # default_encoding. But we don't want to apply the default_encoding! + # Rather we want to guarantee the val object has been converted to a + # unicode string because from a unicode string we want to explicitly + # encode to a str using our desired encoding (utf-8 in this case). + # + # Note: calling unicode on a unicode object simply returns the exact + # same object (with it's ref count incremented). This means calling + # unicode on a unicode object is effectively a no-op, thus it's not + # inefficient. + + return unicode(val).encode('utf-8') + + +class _ServerSchema(object): + ''' + Properties of a schema retrieved from an LDAP server. + ''' + + def __init__(self, server, schema): + self.server = server + self.schema = schema + self.retrieve_timestamp = time.time() + + +class SchemaCache(object): + ''' + Cache the schema's from individual LDAP servers. + ''' + + def __init__(self): + self.log = log_mgr.get_logger(self) + self.servers = {} + + def get_schema(self, url, conn, force_update=False): + ''' + Return schema belonging to a specific LDAP server. + + For performance reasons the schema is retrieved once and + cached unless force_update is True. force_update flushes the + existing schema for the server from the cache and reacquires + it. + ''' + + if force_update: + self.flush(url) + + server_schema = self.servers.get(url) + if server_schema is None: + schema = self._retrieve_schema_from_server(url, conn) + server_schema = _ServerSchema(url, schema) + self.servers[url] = server_schema + return server_schema.schema + + def flush(self, url): + self.log.debug('flushing %s from SchemaCache', url) + try: + del self.servers[url] + except KeyError: + pass + + def _retrieve_schema_from_server(self, url, conn): + """ + Retrieve the LDAP schema from the provided url and determine if + User-Private Groups (upg) are configured. + + Bind using kerberos credentials. If in the context of the + in-tree "lite" server then use the current ccache. If in the context of + Apache then create a new ccache and bind using the Apache HTTP service + principal. + + If a connection is provided then it the credentials bound to it are + used. The connection is not closed when the request is done. + """ + tmpdir = None + assert conn is not None + + self.log.debug( + 'retrieving schema for SchemaCache url=%s conn=%s', url, conn) + + try: + try: + schema_entry = conn.search_s('cn=schema', ldap.SCOPE_BASE, + attrlist=['attributetypes', 'objectclasses'])[0] + except ldap.NO_SUCH_OBJECT: + # try different location for schema + # openldap has schema located in cn=subschema + self.log.debug('cn=schema not found, fallback to cn=subschema') + schema_entry = conn.search_s('cn=subschema', ldap.SCOPE_BASE, + attrlist=['attributetypes', 'objectclasses'])[0] + except ldap.SERVER_DOWN: + raise errors.NetworkError(uri=url, + error=u'LDAP Server Down, unable to retrieve LDAP schema') + except ldap.LDAPError, e: + desc = e.args[0]['desc'].strip() + info = e.args[0].get('info', '').strip() + raise errors.DatabaseError(desc = u'uri=%s' % url, + info = u'Unable to retrieve LDAP schema: %s: %s' % (desc, info)) + except IndexError: + # no 'cn=schema' entry in LDAP? some servers use 'cn=subschema' + # TODO: DS uses 'cn=schema', support for other server? + # raise a more appropriate exception + raise + finally: + if tmpdir: + shutil.rmtree(tmpdir) + + return ldap.schema.SubSchema(schema_entry[1]) + +schema_cache = SchemaCache() + + +class IPASimpleLDAPObject(object): + ''' + The purpose of this class is to provide a boundary between IPA and + python-ldap. In IPA we use IPA defined types because they are + richer and are designed to meet our needs. We also require that we + consistently use those types without exception. On the other hand + python-ldap uses different types. The goal is to be able to have + IPA code call python-ldap methods using the types native to + IPA. This class accomplishes that goal by exposing python-ldap + methods which take IPA types, convert them to python-ldap types, + call python-ldap, and then convert the results returned by + python-ldap into IPA types. + + IPA code should never call python-ldap directly, it should only + call python-ldap methods in this class. + ''' + + # Note: the oid for dn syntax is: 1.3.6.1.4.1.1466.115.121.1.12 + + _SYNTAX_MAPPING = { + '1.3.6.1.4.1.1466.115.121.1.1' : str, # ACI item + '1.3.6.1.4.1.1466.115.121.1.4' : str, # Audio + '1.3.6.1.4.1.1466.115.121.1.5' : str, # Binary + '1.3.6.1.4.1.1466.115.121.1.8' : str, # Certificate + '1.3.6.1.4.1.1466.115.121.1.9' : str, # Certificate List + '1.3.6.1.4.1.1466.115.121.1.10' : str, # Certificate Pair + '1.3.6.1.4.1.1466.115.121.1.23' : str, # Fax + '1.3.6.1.4.1.1466.115.121.1.28' : str, # JPEG + '1.3.6.1.4.1.1466.115.121.1.40' : str, # OctetString (same as Binary) + '1.3.6.1.4.1.1466.115.121.1.49' : str, # Supported Algorithm + '1.3.6.1.4.1.1466.115.121.1.51' : str, # Teletext Terminal Identifier + + DN_SYNTAX_OID : DN, # DN, member, memberof + '2.16.840.1.113730.3.8.3.3' : DN, # enrolledBy + '2.16.840.1.113730.3.8.3.18' : DN, # managedBy + '2.16.840.1.113730.3.8.3.5' : DN, # memberUser + '2.16.840.1.113730.3.8.3.7' : DN, # memberHost + '2.16.840.1.113730.3.8.3.20' : DN, # memberService + '2.16.840.1.113730.3.8.11.4' : DN, # ipaNTFallbackPrimaryGroup + '2.16.840.1.113730.3.8.11.21' : DN, # ipaAllowToImpersonate + '2.16.840.1.113730.3.8.11.22' : DN, # ipaAllowedTarget + '2.16.840.1.113730.3.8.7.1' : DN, # memberAllowCmd + '2.16.840.1.113730.3.8.7.2' : DN, # memberDenyCmd + + '2.16.840.1.113719.1.301.4.14.1' : DN, # krbRealmReferences + '2.16.840.1.113719.1.301.4.17.1' : DN, # krbKdcServers + '2.16.840.1.113719.1.301.4.18.1' : DN, # krbPwdServers + '2.16.840.1.113719.1.301.4.26.1' : DN, # krbPrincipalReferences + '2.16.840.1.113719.1.301.4.29.1' : DN, # krbAdmServers + '2.16.840.1.113719.1.301.4.36.1' : DN, # krbPwdPolicyReference + '2.16.840.1.113719.1.301.4.40.1' : DN, # krbTicketPolicyReference + '2.16.840.1.113719.1.301.4.41.1' : DN, # krbSubTrees + '2.16.840.1.113719.1.301.4.52.1' : DN, # krbObjectReferences + '2.16.840.1.113719.1.301.4.53.1' : DN, # krbPrincContainerRef + } + + # In most cases we lookup the syntax from the schema returned by + # the server. However, sometimes attributes may not be defined in + # the schema (e.g. extensibleObject which permits undefined + # attributes), or the schema was incorrectly defined (i.e. giving + # an attribute the syntax DirectoryString when in fact it's really + # a DN). This (hopefully sparse) table allows us to trap these + # anomalies and force them to be the syntax we know to be in use. + # + # FWIW, many entries under cn=config are undefined :-( + + _SCHEMA_OVERRIDE = CIDict({ + 'managedtemplate': DN_SYNTAX_OID, # DN + 'managedbase': DN_SYNTAX_OID, # DN + 'originscope': DN_SYNTAX_OID, # DN + }) + + def __init__(self, uri, force_schema_updates, no_schema=False, + decode_attrs=True): + """An internal LDAP connection object + + :param uri: The LDAP URI to connect to + :param force_schema_updates: + If true, this object will always request a new schema from the + server. If false, a cached schema will be reused if it exists. + + Generally, it should be true if the API context is 'installer' or + 'updates', but it must be given explicitly since the API object + is not always available + :param no_schema: If true, schema is never requested from the server. + :param decode_attrs: + If true, attributes are decoded to Python types according to their + syntax. + """ + self.log = log_mgr.get_logger(self) + self.uri = uri + self.conn = SimpleLDAPObject(uri) + self._no_schema = no_schema + self._has_schema = False + self._schema = None + self._force_schema_updates = force_schema_updates + self._decode_attrs = decode_attrs + + def _get_schema(self): + if self._no_schema: + return None + if not self._has_schema: + try: + self._schema = schema_cache.get_schema( + self.uri, self.conn, + force_update=self._force_schema_updates) + except (errors.ExecutionError, IndexError): + pass + self._has_schema = True + return self._schema + + schema = property(_get_schema, None, None, 'schema associated with this LDAP server') + + + def flush_cached_schema(self): + ''' + Force this instance to forget it's cached schema and reacquire + it from the schema cache. + ''' + + # Currently this is called during bind operations to assure + # we're working with valid schema for a specific + # connection. This causes self._get_schema() to query the + # schema cache for the server's schema passing along a flag + # indicating if we're in a context that requires freshly + # loading the schema vs. returning the last cached version of + # the schema. If we're in a mode that permits use of + # previously cached schema the flush and reacquire is a very + # low cost operation. + # + # The schema is reacquired whenever this object is + # instantiated or when binding occurs. The schema is not + # reacquired for operations during a bound connection, it is + # presumed schema cannot change during this interval. This + # provides for maximum efficiency in contexts which do need + # schema refreshing by only peforming the refresh inbetween + # logical operations that have the potential to cause a schema + # change. + + self._has_schema = False + self._schema = None + + def get_syntax(self, attr): + # Is this a special case attribute? + syntax = self._SCHEMA_OVERRIDE.get(attr) + if syntax is not None: + return syntax + + if self.schema is None: + return None + + # Try to lookup the syntax in the schema returned by the server + obj = self.schema.get_obj(ldap.schema.AttributeType, attr) + if obj is not None: + return obj.syntax + else: + return None + + def has_dn_syntax(self, attr): + """ + Check the schema to see if the attribute uses DN syntax. + + Returns True/False + """ + syntax = self.get_syntax(attr) + return syntax == DN_SYNTAX_OID + + + def encode(self, val): + ''' + ''' + # Booleans are both an instance of bool and int, therefore + # test for bool before int otherwise the int clause will be + # entered for a boolean value instead of the boolean clause. + if isinstance(val, bool): + if val: + return 'TRUE' + else: + return 'FALSE' + elif isinstance(val, (unicode, float, int, long, Decimal, DN)): + return value_to_utf8(val) + elif isinstance(val, str): + return val + elif isinstance(val, list): + return [self.encode(m) for m in val] + elif isinstance(val, tuple): + return tuple(self.encode(m) for m in val) + elif isinstance(val, dict): + dct = dict((self.encode(k), self.encode(v)) for k, v in val.iteritems()) + return dct + elif val is None: + return None + else: + raise TypeError("attempt to pass unsupported type to ldap, value=%s type=%s" %(val, type(val))) + + def convert_value_list(self, attr, target_type, values): + ''' + ''' + + if not self._decode_attrs: + return values + + ipa_values = [] + + for original_value in values: + if isinstance(target_type, type) and isinstance(original_value, target_type): + ipa_value = original_value + else: + try: + ipa_value = target_type(original_value) + except Exception, e: + msg = 'unable to convert the attribute "%s" value "%s" to type %s' % (attr, original_value, target_type) + self.log.error(msg) + raise ValueError(msg) + + ipa_values.append(ipa_value) + + return ipa_values + + def convert_result(self, result): + ''' + result is a python-ldap result tuple of the form (dn, attrs), + where dn is a string containing the dn (distinguished name) of + the entry, and attrs is a dictionary containing the attributes + associated with the entry. The keys of attrs are strings, and + the associated values are lists of strings. + + We convert the dn to a DN object. + + We convert every value associated with an attribute according + to it's syntax into the desired Python type. + + returns a IPA result tuple of the same form as a python-ldap + result tuple except everything inside of the result tuple has + been converted to it's preferred IPA python type. + ''' + + ipa_result = [] + for dn_tuple in result: + original_dn = dn_tuple[0] + original_attrs = dn_tuple[1] + + ipa_entry = LDAPEntry(self, DN(original_dn)) + + for attr, original_values in original_attrs.items(): + target_type = self._SYNTAX_MAPPING.get(self.get_syntax(attr), unicode_from_utf8) + ipa_entry[attr] = self.convert_value_list(attr, target_type, original_values) + + ipa_result.append(ipa_entry) + + if _debug_log_ldap: + self.log.debug('ldap.result: %s', ipa_result) + return ipa_result + + #---------- python-ldap emulations ---------- + + def add(self, dn, modlist): + assert isinstance(dn, DN) + dn = str(dn) + modlist = self.encode(modlist) + return self.conn.add(dn, modlist) + + def add_ext(self, dn, modlist, serverctrls=None, clientctrls=None): + assert isinstance(dn, DN) + dn = str(dn) + modlist = self.encode(modlist) + return self.conn.add_ext(dn, modlist, serverctrls, clientctrls) + + def add_ext_s(self, dn, modlist, serverctrls=None, clientctrls=None): + assert isinstance(dn, DN) + dn = str(dn) + modlist = self.encode(modlist) + return self.conn.add_ext_s(dn, modlist, serverctrls, clientctrls) + + def add_s(self, dn, modlist): + assert isinstance(dn, DN) + dn = str(dn) + modlist = self.encode(modlist) + return self.conn.add_s(dn, modlist) + + def bind(self, who, cred, method=ldap.AUTH_SIMPLE): + self.flush_cached_schema() + if who is None: + who = DN() + assert isinstance(who, DN) + who = str(who) + cred = self.encode(cred) + return self.conn.bind(who, cred, method) + + def delete(self, dn): + assert isinstance(dn, DN) + dn = str(dn) + return self.conn.delete(dn) + + def delete_s(self, dn): + assert isinstance(dn, DN) + dn = str(dn) + return self.conn.delete_s(dn) + + def get_option(self, option): + return self.conn.get_option(option) + + def modify_s(self, dn, modlist): + assert isinstance(dn, DN) + dn = str(dn) + modlist = [(x[0], self.encode(x[1]), self.encode(x[2])) for x in modlist] + return self.conn.modify_s(dn, modlist) + + def modrdn_s(self, dn, newrdn, delold=1): + assert isinstance(dn, DN) + dn = str(dn) + assert isinstance(newrdn, (DN, RDN)) + newrdn = str(newrdn) + return self.conn.modrdn_s(dn, newrdn, delold) + + def passwd_s(self, dn, oldpw, newpw, serverctrls=None, clientctrls=None): + assert isinstance(dn, DN) + dn = str(dn) + oldpw = self.encode(oldpw) + newpw = self.encode(newpw) + return self.conn.passwd_s(dn, oldpw, newpw, serverctrls, clientctrls) + + def rename_s(self, dn, newrdn, newsuperior=None, delold=1): + # NOTICE: python-ldap of version 2.3.10 and lower does not support + # serverctrls and clientctrls for rename_s operation. Thus, these + # options are ommited from this command until really needed + assert isinstance(dn, DN) + dn = str(dn) + assert isinstance(newrdn, (DN, RDN)) + newrdn = str(newrdn) + return self.conn.rename_s(dn, newrdn, newsuperior, delold) + + def result(self, msgid=ldap.RES_ANY, all=1, timeout=None): + resp_type, resp_data = self.conn.result(msgid, all, timeout) + resp_data = self.convert_result(resp_data) + return resp_type, resp_data + + def sasl_interactive_bind_s(self, who, auth, serverctrls=None, + clientctrls=None, sasl_flags=ldap.SASL_QUIET): + self.flush_cached_schema() + if who is None: + who = DN() + assert isinstance(who, DN) + who = str(who) + return self.conn.sasl_interactive_bind_s(who, auth, serverctrls, clientctrls, sasl_flags) + + def search(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0): + assert isinstance(base, DN) + base = str(base) + filterstr = self.encode(filterstr) + attrlist = self.encode(attrlist) + return self.conn.search(base, scope, filterstr, attrlist, attrsonly) + + def search_ext(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0, serverctrls=None, clientctrls=None, timeout=-1, sizelimit=0): + assert isinstance(base, DN) + base = str(base) + filterstr = self.encode(filterstr) + attrlist = self.encode(attrlist) + + if _debug_log_ldap: + self.log.debug( + "ldap.search_ext: dn: %s\nfilter: %s\nattrs_list: %s", + base, filterstr, attrlist) + + + return self.conn.search_ext(base, scope, filterstr, attrlist, attrsonly, serverctrls, clientctrls, timeout, sizelimit) + + def search_ext_s(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0, serverctrls=None, clientctrls=None, timeout=-1, sizelimit=0): + assert isinstance(base, DN) + base = str(base) + filterstr = self.encode(filterstr) + attrlist = self.encode(attrlist) + ldap_result = self.conn.search_ext_s(base, scope, filterstr, attrlist, attrsonly, serverctrls, clientctrls, timeout, sizelimit) + ipa_result = self.convert_result(ldap_result) + return ipa_result + + def search_s(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0): + assert isinstance(base, DN) + base = str(base) + filterstr = self.encode(filterstr) + attrlist = self.encode(attrlist) + ldap_result = self.conn.search_s(base, scope, filterstr, attrlist, attrsonly) + ipa_result = self.convert_result(ldap_result) + return ipa_result + + def search_st(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0, timeout=-1): + assert isinstance(base, DN) + base = str(base) + filterstr = self.encode(filterstr) + attrlist = self.encode(attrlist) + ldap_result = self.conn.search_st(base, scope, filterstr, attrlist, attrsonly, timeout) + ipa_result = self.convert_result(ldap_result) + return ipa_result + + def set_option(self, option, invalue): + return self.conn.set_option(option, invalue) + + def simple_bind_s(self, who=None, cred='', serverctrls=None, clientctrls=None): + self.flush_cached_schema() + if who is None: + who = DN() + assert isinstance(who, DN) + who = str(who) + cred = self.encode(cred) + return self.conn.simple_bind_s(who, cred, serverctrls, clientctrls) + + def start_tls_s(self): + return self.conn.start_tls_s() + + def unbind_s(self): + self.flush_cached_schema() + return self.conn.unbind_s() + + +# Make python-ldap tuple style result compatible with Entry and Entity +# objects by allowing access to the dn (tuple index 0) via the 'dn' +# attribute name and the attr dict (tuple index 1) via the 'data' +# attribute name. Thus: +# r = result[0] +# r[0] == r.dn +# r[1] == r.data +class LDAPEntry(dict): + __slots__ = ('_conn', '_dn', '_names', '_orig') + + def __init__(self, _conn, _dn=None, _obj=None, **kwargs): + """ + LDAPEntry constructor. + + Takes 1 to 3 positional arguments and an arbitrary number of keyword + arguments. The 3 forms of positional arguments are: + + * LDAPEntry(entry) - create a shallow copy of an existing LDAPEntry. + * LDAPEntry(dn, entry) - create a shallow copy of an existing + LDAPEntry with a different DN. + * LDAPEntry(conn, dn, mapping) - create a new LDAPEntry using the + specified IPASimpleLDAPObject and DN and optionally initialize + attributes from the specified mapping object. + + Keyword arguments can be used to override values of specific attributes. + """ + super(LDAPEntry, self).__init__() + + if isinstance(_conn, LDAPEntry): + assert _dn is None + _dn = _conn + _conn = _conn._conn + + if isinstance(_dn, LDAPEntry): + assert _obj is None + _obj = _dn + _dn = DN(_dn._dn) + + if isinstance(_obj, LDAPEntry): + orig = _obj._orig + else: + if _obj is None: + _obj = {} + orig = self + + assert isinstance(_conn, IPASimpleLDAPObject) + assert isinstance(_dn, DN) + + self._conn = _conn + self._dn = _dn + self._orig = orig + self._names = CIDict() + + self.update(_obj, **kwargs) + + @property + def conn(self): + return self._conn + + # properties for Entry and Entity compatibility + @property + def dn(self): + return self._dn + + @dn.setter + def dn(self, value): + assert isinstance(value, DN) + self._dn = value + + @property + def data(self): + # FIXME: for backwards compatibility only + return self + + @property + def orig_data(self): + # FIXME: for backwards compatibility only + return self._orig + + def __repr__(self): + return '%s(%r, %s)' % (type(self).__name__, self._dn, + super(LDAPEntry, self).__repr__()) + + def copy(self): + return LDAPEntry(self) + + def clone(self): + result = LDAPEntry(self._conn, self._dn) + + for name in self.iterkeys(): + super(LDAPEntry, result).__setitem__( + name, deepcopy(super(LDAPEntry, self).__getitem__(name))) + + result._names = deepcopy(self._names) + if self._orig is not self: + result._orig = self._orig.clone() + + return result + + def commit(self): + """ + Make the current state of the entry a new reference point for change + tracking. + """ + self._orig = self + self._orig = self.clone() + + def _attr_name(self, name): + if not isinstance(name, basestring): + raise TypeError( + "attribute name must be unicode or str, got %s object %r" % ( + name.__class__.__name__, name)) + + if isinstance(name, str): + name = name.decode('utf-8') + + return name + + def __setitem__(self, name, value): + name = self._attr_name(name) + + if self._names.has_key(name): + oldname = self._names[name] + + if oldname != name: + for (altname, keyname) in self._names.iteritems(): + if keyname == oldname: + self._names[altname] = name + + super(LDAPEntry, self).__delitem__(oldname) + else: + self._names[name] = name + + if self._conn.schema is not None: + attrtype = self._conn.schema.get_obj(ldap.schema.AttributeType, + name.encode('utf-8')) + if attrtype is not None: + for altname in attrtype.names: + altname = altname.decode('utf-8') + self._names[altname] = name + + super(LDAPEntry, self).__setitem__(name, value) + + def setdefault(self, name, default): + if name not in self: + self[name] = default + return self[name] + + def update(self, _obj={}, **kwargs): + _obj = dict(_obj, **kwargs) + for (name, value) in _obj.iteritems(): + self[name] = value + + def _get_attr_name(self, name): + name = self._attr_name(name) + name = self._names[name] + return name + + def __getitem__(self, name): + # for python-ldap tuple compatibility + if name == 0: + return self._dn + elif name == 1: + return self + + return super(LDAPEntry, self).__getitem__(self._get_attr_name(name)) + + def get(self, name, default=None): + try: + name = self._get_attr_name(name) + except KeyError: + return default + + return super(LDAPEntry, self).get(name, default) + + def single_value(self, name, default=_missing): + """Return a single attribute value + + Checks that the attribute really has one and only one value + + If the entry is missing and default is given, return the default. + If the entry is missing and default is not given, raise KeyError. + """ + try: + attr_name = self._get_attr_name(name) + values = super(LDAPEntry, self).__getitem__(attr_name) + except KeyError: + if default is _missing: + raise + return default + if not isinstance(values, list): # TODO: remove when we enforce lists + return values + if len(values) != 1: + raise ValueError( + '%s has %s values, one expected' % (name, len(values))) + return values[0] + + def _del_attr_name(self, name): + name = self._get_attr_name(name) + + for (altname, keyname) in self._names.items(): + if keyname == name: + del self._names[altname] + + return name + + def __delitem__(self, name): + super(LDAPEntry, self).__delitem__(self._del_attr_name(name)) + + def pop(self, name, *default): + try: + name = self._del_attr_name(name) + except KeyError: + if not default: + raise + + return super(LDAPEntry, self).pop(name, *default) + + def popitem(self): + name, value = super(LDAPEntry, self).popitem() + self._del_attr_name(name) + return (name, value) + + def clear(self): + super(LDAPEntry, self).clear() + self._names.clear() + + def __contains__(self, name): + return self._names.has_key(self._attr_name(name)) + + def has_key(self, name): + return name in self + + # for python-ldap tuple compatibility + def __iter__(self): + yield self._dn + yield self + + def toDict(self): + # FIXME: for backwards compatibility only + """Convert the attrs and values to a dict. The dict is keyed on the + attribute name. The value is either single value or a list of values.""" + assert isinstance(self.dn, DN) + result = ipautil.CIDict(self.data) + for i in result.keys(): + result[i] = ipautil.utf8_encode_values(result[i]) + result['dn'] = self.dn + return result + + def origDataDict(self): + """Returns a dict of the original values of the user. + + Used for updates. + """ + result = ipautil.CIDict(self.orig_data) + result['dn'] = self.dn + return result + + +class LDAPClient(object): + """LDAP backend class + + This class abstracts a LDAP connection, providing methods that work with + LADPEntries. + + This class is not intended to be used directly; instead, use one of its + subclasses, IPAdmin or the ldap2 plugin. + """ + + # rules for generating filters from entries + MATCH_ANY = '|' # (|(filter1)(filter2)) + MATCH_ALL = '&' # (&(filter1)(filter2)) + MATCH_NONE = '!' # (!(filter1)(filter2)) + + # search scope for find_entries() + SCOPE_BASE = ldap.SCOPE_BASE + SCOPE_ONELEVEL = ldap.SCOPE_ONELEVEL + SCOPE_SUBTREE = ldap.SCOPE_SUBTREE + + def __init__(self, ldap_uri): + self.ldap_uri = ldap_uri + self.log = log_mgr.get_logger(self) + self._init_connection() + + def _init_connection(self): + self.conn = None + + def get_api(self): + """Return the API if available, otherwise None + + May be overridden in a subclass. + """ + return None + + @contextlib.contextmanager + def error_handler(self, arg_desc=None): + """Context manager that handles LDAPErrors + """ + try: + try: + yield + except ldap.TIMEOUT: + desc = '' + info = '' + raise + except ldap.LDAPError, e: + desc = e.args[0]['desc'].strip() + info = e.args[0].get('info', '').strip() + if arg_desc is not None: + info = "%s arguments: %s" % (info, arg_desc) + raise + except ldap.NO_SUCH_OBJECT: + raise errors.NotFound(reason=arg_desc or 'no such entry') + except ldap.ALREADY_EXISTS: + raise errors.DuplicateEntry() + except ldap.CONSTRAINT_VIOLATION: + # This error gets thrown by the uniqueness plugin + _msg = 'Another entry with the same attribute value already exists' + if info.startswith(_msg): + raise errors.DuplicateEntry() + else: + raise errors.DatabaseError(desc=desc, info=info) + except ldap.INSUFFICIENT_ACCESS: + raise errors.ACIError(info=info) + except ldap.INVALID_CREDENTIALS: + raise errors.ACIError(info="%s %s" % (info, desc)) + except ldap.NO_SUCH_ATTRIBUTE: + # this is raised when a 'delete' attribute isn't found. + # it indicates the previous attribute was removed by another + # update, making the oldentry stale. + raise errors.MidairCollision() + except ldap.INVALID_SYNTAX: + raise errors.InvalidSyntax(attr=info) + except ldap.OBJECT_CLASS_VIOLATION: + raise errors.ObjectclassViolation(info=info) + except ldap.ADMINLIMIT_EXCEEDED: + raise errors.LimitsExceeded() + except ldap.SIZELIMIT_EXCEEDED: + raise errors.LimitsExceeded() + except ldap.TIMELIMIT_EXCEEDED: + raise errors.LimitsExceeded() + except ldap.NOT_ALLOWED_ON_RDN: + raise errors.NotAllowedOnRDN(attr=info) + except ldap.FILTER_ERROR: + raise errors.BadSearchFilter(info=info) + except ldap.NOT_ALLOWED_ON_NONLEAF: + raise errors.NotAllowedOnNonLeaf() + except ldap.SERVER_DOWN: + raise errors.NetworkError(uri=self.ldap_uri, + error=u'LDAP Server Down') + except ldap.LOCAL_ERROR: + raise errors.ACIError(info=info) + except ldap.SUCCESS: + pass + except ldap.LDAPError, e: + if 'NOT_ALLOWED_TO_DELEGATE' in info: + raise errors.ACIError( + info="KDC returned NOT_ALLOWED_TO_DELEGATE") + self.log.info('Unhandled LDAPError: %s' % str(e)) + raise errors.DatabaseError(desc=desc, info=info) + + @property + def schema(self): + """schema associated with this LDAP server""" + return self.conn.schema + + def get_syntax(self, attr, value): + if self.schema is None: + return None + obj = self.schema.get_obj(ldap.schema.AttributeType, attr) + if obj is not None: + return obj.syntax + else: + return None + + def has_dn_syntax(self, attr): + return self.conn.has_dn_syntax(attr) + + def get_allowed_attributes(self, objectclasses, raise_on_unknown=False): + if self.schema is None: + return None + allowed_attributes = [] + for oc in objectclasses: + obj = self.schema.get_obj(ldap.schema.ObjectClass, oc) + if obj is not None: + allowed_attributes += obj.must + obj.may + elif raise_on_unknown: + raise errors.NotFound( + reason=_('objectclass %s not found') % oc) + return [unicode(a).lower() for a in list(set(allowed_attributes))] + + def get_single_value(self, attr): + """ + Check the schema to see if the attribute is single-valued. + + If the attribute is in the schema then returns True/False + + If there is a problem loading the schema or the attribute is + not in the schema return None + """ + if self.schema is None: + return None + obj = self.schema.get_obj(ldap.schema.AttributeType, attr) + return obj and obj.single_value + + def make_dn_from_attr(self, attr, value, parent_dn=None): + """ + Make distinguished name from attribute. + + Keyword arguments: + parent_dn -- DN of the parent entry (default '') + """ + if parent_dn is None: + parent_dn = DN() + + if isinstance(value, (list, tuple)): + value = value[0] + + return DN((attr, value), parent_dn) + + def make_dn(self, entry_attrs, primary_key='cn', parent_dn=None): + """ + Make distinguished name from entry attributes. + + Keyword arguments: + primary_key -- attribute from which to make RDN (default 'cn') + parent_dn -- DN of the parent entry (default '') + """ + + assert primary_key in entry_attrs + assert isinstance(parent_dn, DN) + + return DN((primary_key, entry_attrs[primary_key]), parent_dn) + + def make_entry(self, _dn=None, _obj=None, **kwargs): + return LDAPEntry(self.conn, _dn, _obj, **kwargs) + + # generating filters for find_entry + # some examples: + # f1 = ldap2.make_filter_from_attr(u'firstName', u'Pavel') + # f2 = ldap2.make_filter_from_attr(u'lastName', u'Zuna') + # f = ldap2.combine_filters([f1, f2], ldap2.MATCH_ALL) + # # f should be (&(firstName=Pavel)(lastName=Zuna)) + # # it should be equivalent to: + # entry_attrs = {u'firstName': u'Pavel', u'lastName': u'Zuna'} + # f = ldap2.make_filter(entry_attrs, rules=ldap2.MATCH_ALL) + + def combine_filters(self, filters, rules='|'): + """ + Combine filters into one for ldap2.find_entries. + + Keyword arguments: + rules -- see ldap2.make_filter + """ + + assert isinstance(filters, (list, tuple)) + + filters = [f for f in filters if f] + if filters and rules == self.MATCH_NONE: # unary operator + return '(%s%s)' % (self.MATCH_NONE, + self.combine_filters(filters, self.MATCH_ANY)) + + if len(filters) > 1: + flt = '(%s' % rules + else: + flt = '' + for f in filters: + if not f.startswith('('): + f = '(%s)' % f + flt = '%s%s' % (flt, f) + if len(filters) > 1: + flt = '%s)' % flt + return flt + + def make_filter_from_attr( + self, attr, value, rules='|', exact=True, + leading_wildcard=True, trailing_wildcard=True): + """ + Make filter for ldap2.find_entries from attribute. + + Keyword arguments: + rules -- see ldap2.make_filter + exact -- boolean, True - make filter as (attr=value) + False - make filter as (attr=*value*) + leading_wildcard -- boolean: + True - allow heading filter wildcard when exact=False + False - forbid heading filter wildcard when exact=False + trailing_wildcard -- boolean: + True - allow trailing filter wildcard when exact=False + False - forbid trailing filter wildcard when exact=False + """ + if isinstance(value, (list, tuple)): + if rules == self.MATCH_NONE: + make_filter_rules = self.MATCH_ANY + else: + make_filter_rules = rules + flts = [ + self.make_filter_from_attr( + attr, v, exact=exact, + leading_wildcard=leading_wildcard, + trailing_wildcard=trailing_wildcard) + for v in value + ] + return self.combine_filters(flts, rules) + elif value is not None: + value = ldap.filter.escape_filter_chars(value_to_utf8(value)) + if not exact: + template = '%s' + if leading_wildcard: + template = '*' + template + if trailing_wildcard: + template = template + '*' + value = template % value + if rules == self.MATCH_NONE: + return '(!(%s=%s))' % (attr, value) + return '(%s=%s)' % (attr, value) + return '' + + def make_filter( + self, entry_attrs, attrs_list=None, rules='|', exact=True, + leading_wildcard=True, trailing_wildcard=True): + """ + Make filter for ldap2.find_entries from entry attributes. + + Keyword arguments: + attrs_list -- list of attributes to use, all if None (default None) + rules -- specifies how to determine a match (default ldap2.MATCH_ANY) + exact -- boolean, True - make filter as (attr=value) + False - make filter as (attr=*value*) + leading_wildcard -- boolean: + True - allow heading filter wildcard when exact=False + False - forbid heading filter wildcard when exact=False + trailing_wildcard -- boolean: + True - allow trailing filter wildcard when exact=False + False - forbid trailing filter wildcard when exact=False + + rules can be one of the following: + ldap2.MATCH_NONE - match entries that do not match any attribute + ldap2.MATCH_ALL - match entries that match all attributes + ldap2.MATCH_ANY - match entries that match any of attribute + """ + if rules == self.MATCH_NONE: + make_filter_rules = self.MATCH_ANY + else: + make_filter_rules = rules + flts = [] + if attrs_list is None: + for (k, v) in entry_attrs.iteritems(): + flts.append( + self.make_filter_from_attr( + k, v, make_filter_rules, exact, + leading_wildcard, trailing_wildcard) + ) + else: + for a in attrs_list: + value = entry_attrs.get(a, None) + if value is not None: + flts.append( + self.make_filter_from_attr( + a, value, make_filter_rules, exact, + leading_wildcard, trailing_wildcard) + ) + return self.combine_filters(flts, rules) + + def get_entries(self, base_dn, scope=None, filter=None, attrs_list=None): + """Return a list of matching entries. + + Raises an error if the list is truncated by the server + + :param base_dn: dn of the entry at which to start the search + :param scope: search scope, see LDAP docs (default ldap2.SCOPE_SUBTREE) + :param filter: LDAP filter to apply + :param attrs_list: ist of attributes to return, all if None (default) + + Use the find_entries method for more options. + """ + entries, truncated = self.find_entries( + base_dn=base_dn, scope=scope, filter=filter, attrs_list=attrs_list) + if truncated: + raise errors.LimitsExceeded() + return entries + + def find_entries(self, filter=None, attrs_list=None, base_dn=None, + scope=ldap.SCOPE_SUBTREE, time_limit=None, + size_limit=None, search_refs=False): + """ + Return a list of entries and indication of whether the results were + truncated ([(dn, entry_attrs)], truncated) matching specified search + parameters followed by truncated flag. If the truncated flag is True, + search hit a server limit and its results are incomplete. + + Keyword arguments: + attrs_list -- list of attributes to return, all if None (default None) + base_dn -- dn of the entry at which to start the search (default '') + scope -- search scope, see LDAP docs (default ldap2.SCOPE_SUBTREE) + time_limit -- time limit in seconds (default use IPA config values) + size_limit -- size (number of entries returned) limit + (default use IPA config values) + search_refs -- allow search references to be returned + (default skips these entries) + """ + if base_dn is None: + base_dn = DN() + assert isinstance(base_dn, DN) + if not filter: + filter = '(objectClass=*)' + res = [] + truncated = False + + if time_limit is None or size_limit is None: + config = self.get_ipa_config() + if time_limit is None: + time_limit = config.get('ipasearchtimelimit', [-1])[0] + if size_limit is None: + size_limit = config.get('ipasearchrecordslimit', [0])[0] + if time_limit == 0: + time_limit = -1 + if not isinstance(size_limit, int): + size_limit = int(size_limit) + if not isinstance(time_limit, float): + time_limit = float(time_limit) + + if attrs_list: + attrs_list = list(set(attrs_list)) + + # pass arguments to python-ldap + with self.error_handler(): + try: + id = self.conn.search_ext( + base_dn, scope, filter, attrs_list, timeout=time_limit, + sizelimit=size_limit + ) + while True: + (objtype, res_list) = self.conn.result(id, 0) + if not res_list: + break + if (objtype == ldap.RES_SEARCH_ENTRY or + (search_refs and + objtype == ldap.RES_SEARCH_REFERENCE)): + res.append(res_list[0]) + except (ldap.ADMINLIMIT_EXCEEDED, ldap.TIMELIMIT_EXCEEDED, + ldap.SIZELIMIT_EXCEEDED), e: + truncated = True + + if not res and not truncated: + raise errors.NotFound(reason='no such entry') + + if attrs_list and ( + 'memberindirect' in attrs_list or '*' in attrs_list): + for r in res: + if not 'member' in r[1]: + continue + else: + members = r[1]['member'] + indirect = self.get_members( + r[0], members, membertype=MEMBERS_INDIRECT, + time_limit=time_limit, size_limit=size_limit) + if len(indirect) > 0: + r[1]['memberindirect'] = indirect + if attrs_list and ( + 'memberofindirect' in attrs_list or '*' in attrs_list): + for r in res: + if 'memberof' in r[1]: + memberof = r[1]['memberof'] + del r[1]['memberof'] + elif 'memberOf' in r[1]: + memberof = r[1]['memberOf'] + del r[1]['memberOf'] + else: + continue + direct, indirect = self.get_memberof( + r[0], memberof, time_limit=time_limit, + size_limit=size_limit) + if len(direct) > 0: + r[1]['memberof'] = direct + if len(indirect) > 0: + r[1]['memberofindirect'] = indirect + + return (res, truncated) + + def find_entry_by_attr(self, attr, value, object_class, attrs_list=None, + base_dn=None): + """ + Find entry (dn, entry_attrs) by attribute and object class. + + Keyword arguments: + attrs_list - list of attributes to return, all if None (default None) + base_dn - dn of the entry at which to start the search (default '') + """ + + if base_dn is None: + base_dn = DN() + assert isinstance(base_dn, DN) + + search_kw = {attr: value, 'objectClass': object_class} + filter = self.make_filter(search_kw, rules=self.MATCH_ALL) + (entries, truncated) = self.find_entries(filter, attrs_list, base_dn) + + if len(entries) > 1: + raise errors.SingleMatchExpected(found=len(entries)) + else: + if truncated: + raise errors.LimitsExceeded() + else: + return entries[0] + + def get_entry(self, dn, attrs_list=None, time_limit=None, + size_limit=None): + """ + Get entry (dn, entry_attrs) by dn. + + Keyword arguments: + attrs_list - list of attributes to return, all if None (default None) + """ + + assert isinstance(dn, DN) + + (entry, truncated) = self.find_entries( + None, attrs_list, dn, self.SCOPE_BASE, time_limit=time_limit, + size_limit=size_limit + ) + + if truncated: + raise errors.LimitsExceeded() + return entry[0] + + def get_ipa_config(self, attrs_list=None): + """Returns the IPA configuration entry. + + Overriden in the subclasses that have access to IPA configuration. + """ + return {} + + def get_memberof(self, entry_dn, memberof, time_limit=None, + size_limit=None): + """ + Examine the objects that an entry is a member of and determine if they + are a direct or indirect member of that group. + + entry_dn: dn of the entry we want the direct/indirect members of + memberof: the memberOf attribute for entry_dn + + Returns two memberof lists: (direct, indirect) + """ + + assert isinstance(entry_dn, DN) + + self.log.debug( + "get_memberof: entry_dn=%s memberof=%s", entry_dn, memberof) + if not type(memberof) in (list, tuple): + return ([], []) + if len(memberof) == 0: + return ([], []) + + search_entry_dn = ldap.filter.escape_filter_chars(str(entry_dn)) + attr_list = ["memberof"] + searchfilter = "(|(member=%s)(memberhost=%s)(memberuser=%s))" % ( + search_entry_dn, search_entry_dn, search_entry_dn) + + # Search only the groups for which the object is a member to + # determine if it is directly or indirectly associated. + + results = [] + for group in memberof: + assert isinstance(group, DN) + try: + result, truncated = self.find_entries( + searchfilter, attr_list, + group, time_limit=time_limit, size_limit=size_limit, + scope=ldap.SCOPE_BASE) + results.extend(list(result)) + except errors.NotFound: + pass + + direct = [] + # If there is an exception here, it is likely due to a failure in + # referential integrity. All members should have corresponding + # memberOf entries. + indirect = list(memberof) + for r in results: + direct.append(r[0]) + try: + indirect.remove(r[0]) + except ValueError, e: + self.log.info( + 'Failed to remove indirect entry %s from %s', + r[0], entry_dn) + raise e + + self.log.debug( + "get_memberof: result direct=%s indirect=%s", direct, indirect) + return (direct, indirect) + + def get_members(self, group_dn, members, attr_list=[], + membertype=MEMBERS_ALL, time_limit=None, size_limit=None): + """Do a memberOf search of groupdn and return the attributes in + attr_list (an empty list returns all attributes). + + membertype = MEMBERS_ALL all members returned + membertype = MEMBERS_DIRECT only direct members are returned + membertype = MEMBERS_INDIRECT only inherited members are returned + + Members may be included in a group as a result of being a member + of a group that is a member of the group being queried. + + Returns a list of DNs. + """ + + assert isinstance(group_dn, DN) + + if membertype not in [MEMBERS_ALL, MEMBERS_DIRECT, MEMBERS_INDIRECT]: + return None + + self.log.debug( + "get_members: group_dn=%s members=%s membertype=%s", + group_dn, members, membertype) + search_group_dn = ldap.filter.escape_filter_chars(str(group_dn)) + searchfilter = "(memberof=%s)" % search_group_dn + + attr_list.append("member") + + # Verify group membership + + results = [] + if membertype == MEMBERS_ALL or membertype == MEMBERS_INDIRECT: + api = self.get_api() + if api: + user_container_dn = DN(api.env.container_user, api.env.basedn) + host_container_dn = DN(api.env.container_host, api.env.basedn) + else: + user_container_dn = host_container_dn = None + checkmembers = set(DN(x) for x in members) + checked = set() + while checkmembers: + member_dn = checkmembers.pop() + checked.add(member_dn) + + # No need to check entry types that are not nested for + # additional members + if user_container_dn and ( + member_dn.endswith(user_container_dn) or + member_dn.endswith(host_container_dn)): + results.append([member_dn, {}]) + continue + try: + result, truncated = self.find_entries( + searchfilter, attr_list, member_dn, + time_limit=time_limit, size_limit=size_limit, + scope=ldap.SCOPE_BASE) + if truncated: + raise errors.LimitsExceeded() + results.append(list(result[0])) + for m in result[0][1].get('member', []): + # This member may contain other members, add it to our + # candidate list + if m not in checked: + checkmembers.add(m) + except errors.NotFound: + pass + + if membertype == MEMBERS_ALL: + entries = [] + for e in results: + entries.append(e[0]) + + return entries + + dn, group = self.get_entry( + group_dn, ['member'], + size_limit=size_limit, time_limit=time_limit) + real_members = group.get('member', []) + + entries = [] + for e in results: + if e[0] not in real_members and e[0] not in entries: + if membertype == MEMBERS_INDIRECT: + entries.append(e[0]) + else: + if membertype == MEMBERS_DIRECT: + entries.append(e[0]) + + self.log.debug("get_members: result=%s", entries) + return entries + + def _get_dn_and_attrs(self, entry_or_dn, entry_attrs): + """Helper for legacy calling style for {add,update}_entry + """ + if entry_attrs is None: + return entry_or_dn.dn, entry_or_dn + else: + assert isinstance(entry_or_dn, DN) + entry_attrs = self.make_entry(entry_or_dn, entry_attrs) + for key, value in entry_attrs.items(): + if value is None: + entry_attrs[key] = [] + return entry_or_dn, entry_attrs + + def add_entry(self, entry, entry_attrs=None): + """Create a new entry. + + This should be called as add_entry(entry). + + The legacy two-argument variant is: + add_entry(dn, entry_attrs) + """ + dn, attrs = self._get_dn_and_attrs(entry, entry_attrs) + + # remove all [] values (python-ldap hates 'em) + attrs = dict((k, v) for k, v in attrs.iteritems() + # FIXME: Once entry values are always lists, this condition can + # be just "if v": + if v is not None and v != []) + + with self.error_handler(): + self.conn.add_s(dn, attrs.items()) + + def update_entry_rdn(self, dn, new_rdn, del_old=True): + """ + Update entry's relative distinguished name. + + Keyword arguments: + del_old -- delete old RDN value (default True) + """ + + assert isinstance(dn, DN) + assert isinstance(new_rdn, RDN) + + if dn[0] == new_rdn: + raise errors.EmptyModlist() + with self.error_handler(): + self.conn.rename_s(dn, new_rdn, delold=int(del_old)) + time.sleep(.3) # Give memberOf plugin a chance to work + + def _generate_modlist(self, dn, entry_attrs): + assert isinstance(dn, DN) + + # get original entry + dn, entry_attrs_old = self.get_entry(dn, entry_attrs.keys()) + + # generate modlist + # for multi value attributes: no MOD_REPLACE to handle simultaneous + # updates better + # for single value attribute: always MOD_REPLACE + modlist = [] + for (k, v) in entry_attrs.iteritems(): + if v is None and k in entry_attrs_old: + modlist.append((ldap.MOD_DELETE, k, None)) + else: + if not isinstance(v, (list, tuple)): + v = [v] + v = set(filter(lambda value: value is not None, v)) + old_v = set(entry_attrs_old.get(k.lower(), [])) + + # FIXME: Convert all values to either unicode, DN or str + # before detecting value changes (see IPASimpleLDAPObject for + # supported types). + # This conversion will set a common ground for the comparison. + # + # This fix can be removed when ticket 2265 is fixed and our + # encoded entry_attrs' types will match get_entry result + try: + v = set( + unicode_from_utf8(self.conn.encode(value)) + if not isinstance(value, (DN, str, unicode)) + else value for value in v) + except Exception, e: + # Rather let the value slip in modlist than let ldap2 crash + self.log.error( + "Cannot convert attribute '%s' for modlist " + "for modlist comparison: %s", k, e) + + adds = list(v.difference(old_v)) + rems = list(old_v.difference(v)) + + is_single_value = self.get_single_value(k) + + value_count = len(old_v) + len(adds) - len(rems) + if is_single_value and value_count > 1: + raise errors.OnlyOneValueAllowed(attr=k) + + force_replace = False + if len(v) > 0 and len(v.intersection(old_v)) == 0: + force_replace = True + + if adds: + if force_replace: + modlist.append((ldap.MOD_REPLACE, k, adds)) + else: + modlist.append((ldap.MOD_ADD, k, adds)) + if rems: + if not force_replace: + modlist.append((ldap.MOD_DELETE, k, rems)) + + return modlist + + def update_entry(self, entry, entry_attrs=None): + """Update entry's attributes. + + This should be called as update_entry(entry). + + The legacy two-argument variant is: + update_entry(dn, entry_attrs) + """ + dn, attrs = self._get_dn_and_attrs(entry, entry_attrs) + + # generate modlist + modlist = self._generate_modlist(dn, attrs) + if not modlist: + raise errors.EmptyModlist() + + # pass arguments to python-ldap + with self.error_handler(): + self.conn.modify_s(dn, modlist) + + def delete_entry(self, entry_or_dn): + """Delete an entry given either the DN or the entry itself""" + if isinstance(entry_or_dn, DN): + dn = entry_or_dn + else: + dn = entry_or_dn.dn + + with self.error_handler(): + self.conn.delete_s(dn) + + +class IPAdmin(LDAPClient): + + def __get_ldap_uri(self, protocol): + if protocol == 'ldaps': + return 'ldaps://%s' % format_netloc(self.host, self.port) + elif protocol == 'ldapi': + return 'ldapi://%%2fvar%%2frun%%2fslapd-%s.socket' % ( + "-".join(self.realm.split("."))) + elif protocol == 'ldap': + return 'ldap://%s' % format_netloc(self.host, self.port) + else: + raise ValueError('Protocol %r not supported' % protocol) + + + def __guess_protocol(self): + """Return the protocol to use based on flags passed to the constructor + + Only used when "protocol" is not specified explicitly. + + If a CA certificate is provided then it is assumed that we are + doing SSL client authentication with proxy auth. + + If a CA certificate is not present then it is assumed that we are + using a forwarded kerberos ticket for SASL auth. SASL provides + its own encryption. + """ + if self.cacert is not None: + return 'ldaps' + elif self.ldapi: + return 'ldapi' + else: + return 'ldap' + + def __init__(self, host='', port=389, cacert=None, debug=None, ldapi=False, + realm=None, protocol=None, force_schema_updates=True, + start_tls=False, ldap_uri=None, no_schema=False, + decode_attrs=True): + self.conn = None + log_mgr.get_logger(self, True) + if debug and debug.lower() == "on": + ldap.set_option(ldap.OPT_DEBUG_LEVEL,255) + if cacert is not None: + ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, cacert) + + self.port = port + self.host = host + self.cacert = cacert + self.ldapi = ldapi + self.realm = realm + self.suffixes = {} + + if not ldap_uri: + ldap_uri = self.__get_ldap_uri(protocol or self.__guess_protocol()) + + LDAPClient.__init__(self, ldap_uri) + + self.conn = IPASimpleLDAPObject(ldap_uri, force_schema_updates=True, + no_schema=no_schema, + decode_attrs=decode_attrs) + + if start_tls: + self.conn.start_tls_s() + + def __str__(self): + return self.host + ":" + str(self.port) + + def __wait_for_connection(self, timeout): + lurl = ldapurl.LDAPUrl(self.ldap_uri) + if lurl.urlscheme == 'ldapi': + wait_for_open_socket(lurl.hostport, timeout) + else: + (host,port) = lurl.hostport.split(':') + wait_for_open_ports(host, int(port), timeout) + + def __bind_with_wait(self, bind_func, timeout, *args, **kwargs): + try: + bind_func(*args, **kwargs) + except (ldap.CONNECT_ERROR, ldap.SERVER_DOWN), e: + if not timeout or 'TLS' in e.args[0].get('info', ''): + # No connection to continue on if we have a TLS failure + # https://bugzilla.redhat.com/show_bug.cgi?id=784989 + raise e + try: + self.__wait_for_connection(timeout) + except: + raise e + bind_func(*args, **kwargs) + + def do_simple_bind(self, binddn=DN(('cn', 'directory manager')), bindpw="", + timeout=DEFAULT_TIMEOUT): + self.__bind_with_wait(self.conn.simple_bind_s, timeout, binddn, bindpw) + + def do_sasl_gssapi_bind(self, timeout=DEFAULT_TIMEOUT): + self.__bind_with_wait( + self.conn.sasl_interactive_bind_s, timeout, None, SASL_GSSAPI) + + def do_external_bind(self, user_name=None, timeout=DEFAULT_TIMEOUT): + auth_tokens = ldap.sasl.external(user_name) + self.__bind_with_wait( + self.conn.sasl_interactive_bind_s, timeout, None, auth_tokens) + + def updateEntry(self,dn,oldentry,newentry): + # FIXME: for backwards compatibility only + """This wraps the mod function. It assumes that the entry is already + populated with all of the desired objectclasses and attributes""" + + assert isinstance(dn, DN) + + modlist = self.generateModList(oldentry, newentry) + + if len(modlist) == 0: + raise errors.EmptyModlist + + with self.error_handler(): + self.modify_s(dn, modlist) + return True + + def generateModList(self, old_entry, new_entry): + # FIXME: for backwards compatibility only + """A mod list generator that computes more precise modification lists + than the python-ldap version. For single-value attributes always + use a REPLACE operation, otherwise use ADD/DEL. + """ + + # Some attributes, like those in cn=config, need to be replaced + # not deleted/added. + FORCE_REPLACE_ON_UPDATE_ATTRS = ('nsslapd-ssl-check-hostname', 'nsslapd-lookthroughlimit', 'nsslapd-idlistscanlimit', 'nsslapd-anonlimitsdn', 'nsslapd-minssf-exclude-rootdse') + modlist = [] + + old_entry = ipautil.CIDict(old_entry) + new_entry = ipautil.CIDict(new_entry) + + keys = set(map(string.lower, old_entry.keys())) + keys.update(map(string.lower, new_entry.keys())) + + for key in keys: + new_values = new_entry.get(key, []) + if not(isinstance(new_values,list) or isinstance(new_values,tuple)): + new_values = [new_values] + new_values = filter(lambda value:value!=None, new_values) + + old_values = old_entry.get(key, []) + if not(isinstance(old_values,list) or isinstance(old_values,tuple)): + old_values = [old_values] + old_values = filter(lambda value:value!=None, old_values) + + # We used to convert to sets and use difference to calculate + # the changes but this did not preserve order which is important + # particularly for schema + adds = [x for x in new_values if x not in old_values] + removes = [x for x in old_values if x not in new_values] + + if len(adds) == 0 and len(removes) == 0: + continue + + is_single_value = self.get_single_value(key) + force_replace = False + if key in FORCE_REPLACE_ON_UPDATE_ATTRS or is_single_value: + force_replace = True + + # You can't remove schema online. An add will automatically + # replace any existing schema. + if old_entry.get('dn', DN()) == DN(('cn', 'schema')): + if len(adds) > 0: + modlist.append((ldap.MOD_ADD, key, adds)) + else: + if adds: + if force_replace: + modlist.append((ldap.MOD_REPLACE, key, adds)) + else: + modlist.append((ldap.MOD_ADD, key, adds)) + if removes: + if not force_replace: + modlist.append((ldap.MOD_DELETE, key, removes)) + + return modlist + + def modify_s(self, *args, **kwargs): + # FIXME: for backwards compatibility only + return self.conn.modify_s(*args, **kwargs) + + def set_option(self, *args, **kwargs): + # FIXME: for backwards compatibility only + return self.conn.set_option(*args, **kwargs) + + def encode(self, *args, **kwargs): + # FIXME: for backwards compatibility only + return self.conn.encode(*args, **kwargs) + + def unbind(self, *args, **kwargs): + return self.conn.unbind_s(*args, **kwargs) |