From c15c1eee729e912f4f55c90861d4dd0be0bdd601 Mon Sep 17 00:00:00 2001 From: Pavel Zuna Date: Tue, 12 Jan 2010 16:40:09 +0100 Subject: Add DS migration plugin and password migration page. --- ipalib/plugins/migration.py | 374 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 ipalib/plugins/migration.py (limited to 'ipalib') diff --git a/ipalib/plugins/migration.py b/ipalib/plugins/migration.py new file mode 100644 index 000000000..a0ff94b3e --- /dev/null +++ b/ipalib/plugins/migration.py @@ -0,0 +1,374 @@ +# Authors: +# Pavel Zuna +# +# Copyright (C) 2009 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; version 2 only +# +# 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, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" +Migration to IPA + +Example: Migrate users and groups from DS to IPA + + ipa migrate-ds ldap://example.com:389 +""" + +import logging +import re + +from ipalib import api, errors, output, uuid +from ipalib import Command, List, Password, Str +from ipalib.cli import to_cli +from ipaserver.plugins.ldap2 import ldap2 + + +# USER MIGRATION CALLBACKS AND VARS + +_krb_err_msg = 'Kerberos principal %s already exists. ' \ + 'Use \'ipa user-mod\' to set it manually.' +_grp_err_msg = 'Failed to add user to the default group. ' \ + 'Use \'ipa group-add-member\' to add manually.' + + +def _pre_migrate_user(ldap, pkey, dn, entry_attrs, failed, config, ctx): + # get default primary group for new users + if 'def_group_dn' not in ctx: + def_group = config.get('ipadefaultprimarygroup') + ctx['def_group_dn'] = api.Object.group.get_dn(def_group) + try: + (g_dn, g_attrs) = ldap.get_entry(ctx['def_group_dn'], ['gidnumber']) + except errors.NotFound: + error_msg = 'Default group for new users not found.' + raise errors.NotFound(reason=error_msg) + ctx['def_group_gid'] = g_attrs['gidnumber'][0] + + # fill in required attributes by IPA + entry_attrs['ipauniqueid'] = str(uuid.uuid1()) + if 'homedirectory' not in entry_attrs: + homes_root = config.get('ipahomesrootdir', ('/home', ))[0] + home_dir = '%s/%s' % (homes_root, pkey) + home_dir = home_dir.replace('//', '/').rstrip('/') + entry_attrs['homedirectory'] = home_dir + entry_attrs.setdefault('gidnumber', ctx['def_group_gid']) + + # generate a principal name and check if it isn't already taken + principal = '%s@%s' % (pkey, api.env.realm) + try: + ldap.find_entry_by_attr( + 'krbprincipalname', principal, 'krbprincipalaux', [''] + ) + except errors.NotFound: + entry_attrs['krbprincipalname'] = principal + else: + failed[pkey] = _krb_err_msg % principal + + return dn + + +def _post_migrate_user(ldap, pkey, dn, entry_attrs, failed, config, ctx): + # add user to the default group + try: + ldap.add_entry_to_group(dn, ctx['def_group_dn']) + except errors.ExecutionError, e: + failed[pkey] = _grp_err_msg + + +# GROUP MIGRATION CALLBACKS AND VARS + +def _pre_migrate_group(ldap, pkey, dn, entry_attrs, failed, config, ctx): + def convert_members(member_attr, overwrite=False): + """ + Convert DNs in member attributes to work in IPA. + """ + new_members = [] + entry_attrs.setdefault(member_attr, []) + for m in entry_attrs[member_attr]: + col = m.find(',') + if col == -1: + continue + if m.startswith('uid'): + m = '%s,%s' % (m[0:col], api.env.container_user) + elif m.startswith('cn'): + m = '%s,%s' % (m[0:col], api.env.container_group) + m = ldap.normalize_dn(m) + new_members.append(m) + del entry_attrs[member_attr] + if overwrite: + entry_attrs['member'] = [] + entry_attrs['member'] += new_members + + entry_attrs['ipauniqueid'] = str(uuid.uuid1()) + convert_members('member', overwrite=True) + convert_members('uniquemember') + + return dn + + +# DS MIGRATION PLUGIN + +def validate_ldapuri(ugettext, ldapuri): + m = re.match('^ldaps?://[-\w\.]+(:\d+)?$', ldapuri) + if not m: + err_msg = 'Invalid LDAP URI.' + raise errors.ValidationError(name='ldap_uri', error=err_msg) + + +class migrate_ds(Command): + """ + Migrate users and groups from DS to IPA. + """ + migrate_objects = { + # OBJECT_NAME: (search_filter, pre_callback, post_callback) + # + # OBJECT_NAME - is the name of an LDAPObject subclass + # search_filter - is the filter to retrieve objects from DS + # pre_callback - is called for each object just after it was + # retrieved from DS and before being added to IPA + # post_callback - is called for each object after it was added to IPA + # + # {pre, post}_callback parameters: + # ldap - ldap2 instance connected to IPA + # pkey - primary key value of the object (uid for users, etc.) + # dn - dn of the object as it (will be/is) stored in IPA + # entry_attrs - attributes of the object + # failed - a list of so-far failed objects + # config - IPA config entry attributes + # ctx - object context, used to pass data between callbacks + # + # If pre_callback return value evaluates to False, migration + # of the current object is aborted. + 'user': ( + '(&(objectClass=person)(uid=*))', + _pre_migrate_user, _post_migrate_user + ), + 'group': ( + '(&(objectClass=groupOfUniqueNames)(cn=*))', + _pre_migrate_group, None + ), + } + migrate_order = ('user', 'group') + + takes_args = ( + Str('ldapuri', validate_ldapuri, + cli_name='ldap_uri', + doc='LDAP URI of DS server to migrate from', + ), + Password('bindpw', + cli_name='password', + doc='bind password', + ), + ) + + takes_options = ( + Str('binddn?', + cli_name='bind_dn', + doc='bind DN', + default=u'cn=directory manager', + autofill=True, + ), + Str('usercontainer?', + cli_name='user_container', + doc='RDN of container for users in DS', + default=u'ou=people', + autofill=True, + ), + Str('groupcontainer?', + cli_name='group_container', + doc='RDN of container for groups in DS', + default=u'ou=groups', + autofill=True, + ), + ) + + has_output = ( + output.Output('result', + type=dict, + doc='Lists of objects migrated; categorized by type.', + ), + output.Output('failed', + type=dict, + doc='Lists of objects that could not be migrated; ' \ + 'categorized by type.', + ), + output.Output('enabled', + type=bool, + doc='False if migration mode was disabled.', + ), + ) + + exclude_doc = 'comma-separated list of %s to exclude from migration' + truncated_err_msg = 'search results for objects to be migrated ' \ + 'have been truncated by the server; migration ' \ + 'process might be uncomplete\n' + migration_disabled_msg = 'Migration mode is disabled. ' \ + 'Use \'ipa config-mod\' to enable it.' + pwd_migration_msg = 'Passwords have been migrated in pre-hashed format. ' \ + 'IPA is unable to generate Kerberos keys unless provided ' \ + 'with clear text passwords. All migrated users need to ' \ + 'login at https://your.domain/ipa/migration/ before they ' \ + 'can use their Kerberos accounts.' + + def get_options(self): + """ + Call get_options of the baseclass and add "exclude" options + for each type of object being migrated. + """ + for option in super(migrate_ds, self).get_options(): + yield option + for ldap_obj_name in self.migrate_objects: + ldap_obj = self.api.Object[ldap_obj_name] + name = 'exclude_%ss' % to_cli(ldap_obj_name) + doc = self.exclude_doc % ldap_obj.object_name_plural + yield List( + '%s?' % name, cli_name=name, doc=doc, default=tuple(), + autofill=True + ) + + def normalize_options(self, options): + """ + Convert all "exclude" option values to lower-case. + + Also, empty List parameters are converted to None, but the migration + plugin doesn't like that - convert back to empty lists. + """ + for p in self.params(): + if isinstance(p, List): + if options[p.name]: + options[p.name] = tuple( + v.lower() for v in options[p.name] + ) + else: + options[p.name] = tuple() + + def migrate(self, ldap, config, ds_ldap, ds_base_dn, options): + """ + Migrate objects from DS to LDAP. + """ + migrated = {} # {'OBJ': ['PKEY1', 'PKEY2', ...], ...} + failed = {} # {'OBJ': {'PKEY1': 'Failed 'cos blabla', ...}, ...} + for ldap_obj_name in self.migrate_order: + ldap_obj = self.api.Object[ldap_obj_name] + + search_filter = self.migrate_objects[ldap_obj_name][0] + search_base = '%s,%s' % ( + options['%scontainer' % to_cli(ldap_obj_name)], ds_base_dn + ) + exclude = options['exclude_%ss' % to_cli(ldap_obj_name)] + context = {} + + migrated[ldap_obj_name] = [] + failed[ldap_obj_name] = {} + + # FIXME: with limits set, we get a strange 'Success' exception + (entries, truncated) = ds_ldap.find_entries( + search_filter, ['*'], search_base, ds_ldap.SCOPE_ONELEVEL#, + #time_limit=0, size_limit=0 + ) + if truncated: + self.log.error( + '%s: %s' % ( + ldap_obj.object_name_plural, self.truncated_err_msg + ) + ) + + for (dn, entry_attrs) in entries: + pkey = entry_attrs[ldap_obj.primary_key.name][0].lower() + if pkey in exclude: + continue + + dn = ldap_obj.get_dn(pkey) + entry_attrs['objectclass'] = list( + set( + config.get( + ldap_obj.object_class_config, ldap_obj.object_class + ) + [o.lower() for o in entry_attrs['objectclass']] + ) + ) + + callback = self.migrate_objects[ldap_obj_name][1] + if callable(callback): + dn = callback( + ldap, pkey, dn, entry_attrs, failed[ldap_obj_name], + config, context + ) + if not dn: + continue + + try: + ldap.add_entry(dn, entry_attrs) + except errors.ExecutionError, e: + failed[ldap_obj_name][pkey] = str(e) + else: + migrated[ldap_obj_name].append(pkey) + + callback = self.migrate_objects[ldap_obj_name][2] + if callable(callback): + callback( + ldap, pkey, dn, entry_attrs, failed[ldap_obj_name], + config, context + ) + + return (migrated, failed) + + def execute(self, ldapuri, bindpw, **options): + ldap = self.api.Backend.ldap2 + self.normalize_options(options) + + config = ldap.get_ipa_config()[1] + + # check if migration mode is enabled + if config.get('ipamigrationenabled', ('FALSE', ))[0] == 'FALSE': + return dict(result={}, failed={}, enabled=False) + + # connect to DS + ds_ldap = ldap2(shared_instance=False, ldap_uri=ldapuri, base_dn='') + ds_ldap.connect(bind_dn=options['binddn'], bind_pw=bindpw) + + # retrieve DS base DN + (entries, truncated) = ds_ldap.find_entries( + '', ['namingcontexts'], '', ds_ldap.SCOPE_BASE + ) + try: + ds_base_dn = entries[0][1]['namingcontexts'][0] + except (IndexError, KeyError), e: + raise StandardError(str(e)) + + # migrate! + (migrated, failed) = self.migrate( + ldap, config, ds_ldap, ds_base_dn, options + ) + + return dict(result=migrated, failed=failed, enabled=True) + + def output_for_cli(self, textui, result, ldapuri, bindpw, **options): + textui.print_name(self.name) + if not result['enabled']: + textui.print_plain(self.migration_disabled_msg) + return 1 + textui.print_plain('Migrated:') + textui.print_entry1( + result['result'], attr_order=self.migrate_order, + one_value_per_line=False + ) + for ldap_obj_name in self.migrate_order: + textui.print_plain('Failed %s:' % ldap_obj_name) + textui.print_entry1( + result['failed'][ldap_obj_name], attr_order=self.migrate_order, + one_value_per_line=True, + ) + textui.print_plain('-' * len(self.name)) + textui.print_plain(self.pwd_migration_msg) + +api.register(migrate_ds) + -- cgit