From 36db1d8279675be46ae1c2d8f952cf9742994de3 Mon Sep 17 00:00:00 2001 From: Francisco Trivino Date: Wed, 1 Feb 2023 23:11:39 +0100 Subject: [PATCH] Extend writable interface to support AD provider This commit adds missing code to extend writable interface to write back user modifications for attributes defined in AD integration domain. Signed-off-by: Francisco Trivino --- src/ipa-tuura/ipatuura/ipa.py | 321 +++++++++++++++++++++++----------- 1 file changed, 216 insertions(+), 105 deletions(-) diff --git a/src/ipa-tuura/ipatuura/ipa.py b/src/ipa-tuura/ipatuura/ipa.py index b682be16..977a7ca7 100644 --- a/src/ipa-tuura/ipatuura/ipa.py +++ b/src/ipa-tuura/ipatuura/ipa.py @@ -9,10 +9,9 @@ import six import ldap import ldap.modlist as modlist -import SSSDConfig import datetime +import domains -from django.conf import settings from ipalib.krb_utils import get_credentials_if_valid from ipalib import api from ipalib.errors import EmptyModlist @@ -113,7 +112,8 @@ def _valid_creds(self): try: logger.info('kinit keytab') cred = kinit_keytab( - settings.SCIM_SERVICE_PROVIDER['WRITABLE_USER'], + # settings.SCIM_SERVICE_PROVIDER['WRITABLE_USER'], + domains.models.Domain.objects.last().client_id, keytab, ccache_name ) @@ -127,7 +127,7 @@ def _valid_creds(self): creds = get_credentials_if_valid() if creds and \ creds.lifetime > 0 and \ - "%s@" % settings.SCIM_SERVICE_PROVIDER['WRITABLE_USER'] in \ + "%s@" % domains.models.Domain.objects.last().client_id in \ creds.name.display_as(creds.name.name_type): return True return False @@ -195,84 +195,60 @@ class LDAP: """ Initialization of the LDAP writable interface """ - def __init__(self): self._conn = None - self._backend = None - self._context = "client" - self._ccache_dir = None - self._ccache_name = None - - # replace by sssd.conf settings. - self._base_dn = 'dc=ipa,dc=test' - self._uri = 'ldaps://idm.ipa.test' - self._tls_cacert = '/etc/ipa/ca.crt' - - # read from keycloak + self._dn = None + self._users_dn = None + self._ldap_uri = None + self._ldap_search_base = None + self._ldap_user_extra_attrs = None + self._user_object_classes = None + # TLS + self._ldap_tls_cacert = None self._sasl_gssapi = ldap.sasl.sasl({}, 'GSSAPI') - self._group = 'cn=users,cn=accounts' - self._objectClass = None - - self._read_sssd_config() - self._ldap_connect() - - def _read_sssd_config(self): + self._client_id = None + self._client_secret = None + # init and connect + self._fetch_domain() + self._conn = self._bind() + + def _fetch_domain(self): + """ + Fetch relevant information from the integration domain + """ + domain = domains.models.Domain.objects.last() + suffix = domain.name.split('.') + + self._dn = domain.client_id + "@" + domain.name + self._ldap_uri = domain.integration_domain_url + self._ldap_user_extra_attrs = domain.user_extra_attrs + self._ldap_search_base = "dn=" + suffix[0] + ", dn=" + suffix[1] + self._ldap_tls_cacert = domain.ldap_tls_cacert + self._client_id = domain.client_id + self._client_secret = domain.client_secret + self._users_dn = domain.users_dn + self._user_object_classes = domain.user_object_classes + + logger.info(f'Domain info: {domain}') + + def _bind(self): + """ + Bind to ldap server + """ + self._fetch_domain() + # TODO enable TLS support + # self._conn = ldap.initialize(self._ldap_uri) + # self._conn.set_option(ldap.OPT_X_TLS_CACERTFILE, self._tls_cacert) + # self._conn.sasl_interactive_bind_s('', self._sasl_gssapi) + self._conn = ldap.initialize(self._ldap_uri) + self._conn.protocol_version = 3 + self._conn.set_option(ldap.OPT_REFERRALS, 0) try: - sssdconfig = SSSDConfig.SSSDConfig() - sssdconfig.import_config() - except Exception as e: - # SSSD configuration does not exist or cannot be parsed - print("Unable to parse SSSD configuration") - print("Please ensure the host is properly configured.") - raise e - # Read attributes from the domain section - self._read_ldap_domains(sssdconfig) - - def _read_ldap_domains(self, sssdconfig): - """ - Configure the domains with extra attribute mappings - - Loop on the configured domains and configure the domain with extra - attribute mappings if the id_provider is "ldap". - """ - # Configure each ipa/ad/ldap domain - domains = sssdconfig.list_active_domains() - for name in domains: - domain = sssdconfig.get_domain(name) - provider = domain.get_option('id_provider') - if provider in {"ldap"}: - self._read_ldap_domain(domain) - - def _read_ldap_domain(self, domain): - """ - Configure the domain with extra attribute mappings - - Add the following ldap_user_extra_attrs mappings to the [domain/] - section: - mail:mail, sn:sn, givenname:givenname - If the section already defines some mappings, they are kept. - """ - try: - self._uri = domain.get_option('ldap_uri') - self._base_dn = domain.get_option('ldap_search_base') - self._tls_cacert = domain.get_option('ldap_tls_cacert') - except Exception as e: - # SSSD configuration does not exist or cannot be parsed - print("Unable to parse SSSD configuration") - print("Please ensure the host is properly configured.") - raise e - - def _ldap_connect(self): - """ - Create a connection to LDAP and bind to it. - """ - try: - # PYTHON-LDAP - self._conn = ldap.initialize(self._uri) - self._conn.set_option(ldap.OPT_X_TLS_CACERTFILE, self._tls_cacert) - self._conn.sasl_interactive_bind_s('', self._sasl_gssapi) + self._conn.simple_bind_s(self._dn, self._client_secret) except Exception as e: logger.error(f'Unable to bind to LDAP server {e}') + else: + return self._conn def encode(self, val): """ @@ -315,25 +291,28 @@ def add(self, scim_user): Add a new user :param scim_user: user object conforming to the SCIM User Schema + For a RHDS deployment: + dc=ipa,dc=com + cn=accounts + cn=users + uid=oneuser """ + # TODO: implement dynamic list based on _ldap_user_extra_attrs attrs = {} - # TODO: objectclasses should be propagated from keycloak - attrs['objectclass'] = [b'inetOrgPerson', - b'organizationalPerson', - b'person', - b'top'] + attrs['objectclass'] = self._user_object_classes attrs['cn'] = self.encode(scim_user.obj.username) attrs['mail'] = self.encode(scim_user.obj.email) attrs['givenname'] = self.encode(scim_user.obj.first_name) attrs['sn'] = self.encode(scim_user.obj.last_name) ldif = modlist.addModlist(attrs) - self._ldap_connect() + self._bind() try: - self._conn.add_s("uid={uid},{group},{basedn}".format( + # AD: cn, LDAP: uid + self._conn.add_s("uid={uid},{usersdn},{basedn}".format( uid=scim_user.obj.username, - group=self._group, - basedn=self._base_dn), ldif) + usersdn=self._users_dn, + basedn=self._ldap_search_base), ldif) except ldap.LDAPError as e: desc = e.args[0]['desc'].strip() info = e.args[0].get('info', '').strip() @@ -346,23 +325,19 @@ def modify(self, scim_user): :param scim_user: user object conforming to the SCIM User Schema """ attrs = {} - # TODO: objectclasses should be propagated from keycloak - attrs['objectclass'] = [b'inetOrgPerson', - b'organizationalPerson', - b'person', - b'top'] + attrs['objectclass'] = self._user_object_classes attrs['cn'] = self.encode(scim_user.obj.username) attrs['mail'] = self.encode(scim_user.obj.email) attrs['givenname'] = self.encode(scim_user.obj.first_name) attrs['sn'] = self.encode(scim_user.obj.last_name) ldif = modlist.addModlist(attrs) - self._ldap_connect() + self._bind() try: - self._conn.modify_s("uid={uid},{group},{basedn}".format( + self._conn.modify_s("uid={uid},{usersdn},{basedn}".format( uid=scim_user.obj.username, - group=self._group, - basedn=self._base_dn), ldif) + usersdn=self._users_dn, + basedn=self._ldap_search_base), ldif) except ldap.LDAPError as e: desc = e.args[0]['desc'].strip() info = e.args[0].get('info', '').strip() @@ -374,12 +349,12 @@ def delete(self, scim_user): :param scim_user: user object conforming to the SCIM User Schema """ - self._ldap_connect() + self._bind() try: - self._conn.delete_s("uid={uid},{group},{basedn}".format( + self._conn.delete_s("uid={uid},{usersdn},{basedn}".format( uid=scim_user.obj.username, - group=self._group, - basedn=self._base_dn)) + usersdn=self._users_dn, + basedn=self._ldap_search_base)) except ldap.LDAPError as e: desc = e.args[0]['desc'].strip() info = e.args[0].get('info', '').strip() @@ -391,15 +366,127 @@ class AD: Initialization of the LDAP AD writable interface """ def __init__(self): - pass + self._conn = None + self._dn = None + self._users_dn = None + self._ldap_uri = None + self._ldap_search_base = None + self._ldap_user_extra_attrs = None + self._user_object_classes = None + # TLS + self._ldap_tls_cacert = None + self._sasl_gssapi = ldap.sasl.sasl({}, 'GSSAPI') + self._client_id = None + self._client_secret = None + # init and connect + self._fetch_domain() + self._conn = self._bind() + + def _fetch_domain(self): + """ + Fetch relevant information from the integration domain + """ + domain = domains.models.Domain.objects.last() + suffix = domain.name.split('.') + + self._dn = domain.client_id + "@" + domain.name + self._ldap_uri = domain.integration_domain_url + self._ldap_user_extra_attrs = domain.user_extra_attrs + self._ldap_search_base = "dc=" + suffix[0] + ", dc=" + suffix[1] + self._ldap_tls_cacert = domain.ldap_tls_cacert + self._client_id = domain.client_id + self._client_secret = domain.client_secret + self._users_dn = domain.users_dn + self._user_object_classes = domain.user_object_classes + + logger.info(f'Domain info: {domain}') + + def _bind(self): + """ + Bind to ldap server + """ + self._fetch_domain() + # TODO enable TLS support + # self._conn = ldap.initialize(self._ldap_uri) + # self._conn.set_option(ldap.OPT_X_TLS_CACERTFILE, self._tls_cacert) + # self._conn.sasl_interactive_bind_s('', self._sasl_gssapi) + self._conn = ldap.initialize(self._ldap_uri) + self._conn.protocol_version = 3 + self._conn.set_option(ldap.OPT_REFERRALS, 0) + try: + self._conn.simple_bind_s(self._dn, self._client_secret) + except Exception as e: + logger.error(f'Unable to bind to LDAP server {e}') + else: + return self._conn + + def encode(self, val): + """ + Encode attribute value to LDAP representation (str/bytes) + """ + # 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 b'TRUE' + else: + return b'FALSE' + elif isinstance(val, (unicode, int, Decimal, DN, Principal)): + return str(val).encode('utf-8') + elif isinstance(val, DNSName): + return val.to_text().encode('ascii') + elif isinstance(val, bytes): + 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): + # key in dict must be str not bytes + dct = dict((k, self.encode(v)) for k, v in val.items()) + return dct + elif isinstance(val, datetime.datetime): + return val.strftime(LDAP_GENERALIZED_TIME_FORMAT).encode('utf-8') + elif isinstance(val, crypto_x509.Certificate): + return val.public_bytes(x509.Encoding.DER) + elif val is None: + return None + else: + raise TypeError("attempt to pass unsupported type to ldap, " + "value=%s type=%s" % (val, type(val))) def add(self, scim_user): """ Add a new user :param scim_user: user object conforming to the SCIM User Schema + + For an AD deployment: + dc=ad,dc=com + cn=users + cn=oneuser """ - pass + # TODO: implement dynamic list based on _ldap_user_extra_attrs + attrs = {} + attrs['objectclass'] = self._user_object_classes + attrs['cn'] = self.encode(scim_user.obj.username) + attrs['mail'] = self.encode(scim_user.obj.email) + attrs['givenname'] = self.encode(scim_user.obj.first_name) + attrs['sn'] = self.encode(scim_user.obj.last_name) + ldif = modlist.addModlist(attrs) + + self._bind() + try: + # AD: cn, LDAP: uid + self._conn.add_s("cn={uid},{usersdn},{basedn}".format( + uid=scim_user.obj.username, + usersdn=self._users_dn, + basedn=self._ldap_search_base), ldif) + except ldap.LDAPError as e: + desc = e.args[0]['desc'].strip() + info = e.args[0].get('info', '').strip() + logger.error(f'LDAP Error: {desc}: {info}') def modify(self, scim_user): """ @@ -407,7 +494,24 @@ def modify(self, scim_user): :param scim_user: user object conforming to the SCIM User Schema """ - pass + attrs = {} + attrs['objectclass'] = self._user_object_classes + attrs['cn'] = self.encode(scim_user.obj.username) + attrs['mail'] = self.encode(scim_user.obj.email) + attrs['givenname'] = self.encode(scim_user.obj.first_name) + attrs['sn'] = self.encode(scim_user.obj.last_name) + ldif = modlist.addModlist(attrs) + + self._bind() + try: + self._conn.modify_s("cn={uid},{usersdn},{basedn}".format( + uid=scim_user.obj.username, + usersdn=self._users_dn, + basedn=self._ldap_search_base), ldif) + except ldap.LDAPError as e: + desc = e.args[0]['desc'].strip() + info = e.args[0].get('info', '').strip() + logger.error(f'LDAP Error: {desc}: {info}') def delete(self, scim_user): """ @@ -415,7 +519,16 @@ def delete(self, scim_user): :param scim_user: user object conforming to the SCIM User Schema """ - pass + self._bind() + try: + self._conn.delete_s("cn={uid},{usersdn},{basedn}".format( + uid=scim_user.obj.username, + usersdn=self._users_dn, + basedn=self._ldap_search_base)) + except ldap.LDAPError as e: + desc = e.args[0]['desc'].strip() + info = e.args[0].get('info', '').strip() + logger.error(f'LDAP Error: {desc}: {info}') class _IPA(): @@ -424,11 +537,9 @@ class _IPA(): def __init__(self): """ Initialize writable interface - Instantiate the writable interface depending on the current - configuration settings.py: SCIM_SERVICE_PROVIDER['WRITABLE_IFACE'] """ self._apiconn = self._write( - settings.SCIM_SERVICE_PROVIDER['WRITABLE_IFACE'] + domains.models.Domain.objects.last().id_provider ) def _write(self, iface="ipa"):