Browse Source

Command-line utility for importing users and groups from LDAP (Fixed test)

Fixing a broken test.
Jon Natkins 13 năm trước cách đây
mục cha
commit
e8ed7e9ee2

+ 159 - 0
apps/useradmin/src/useradmin/ldap_access.py

@@ -0,0 +1,159 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+This module provides access to LDAP servers, along with some basic functionality required for Hue and
+User Admin to work seamlessly with LDAP.
+"""
+import desktop.conf
+import ldap
+import ldap.filter
+
+import logging
+LOG = logging.getLogger(__name__)
+
+CACHED_LDAP_CONN = None
+
+def get_connection():
+  global CACHED_LDAP_CONN
+  if CACHED_LDAP_CONN is not None:
+    return CACHED_LDAP_CONN
+
+  ldap_url = desktop.conf.LDAP.LDAP_URL.get()
+  nt_domain = desktop.conf.LDAP.NT_DOMAIN.get()
+  username = desktop.conf.LDAP.BIND_DN.get()
+  password = desktop.conf.LDAP.BIND_PASSWORD.get()
+  ldap_cert = desktop.conf.LDAP.LDAP_CERT.get()
+
+  return LdapConnection(ldap_url, get_ldap_username(username, nt_domain),
+                        password, ldap_cert)
+
+def get_ldap_username(username, nt_domain):
+  if nt_domain:
+    return '%s@%s' % (username, nt_domain)
+  else:
+    return username
+
+class LdapConnection(object):
+  """
+  Constructor creates LDAP connection. Contains methods
+  to easily query an LDAP server.
+  """
+
+  def __init__(self, ldap_url, bind_user=None, bind_password=None, cert_file=None):
+    """
+    Constructor initializes the LDAP connection
+    """
+    if cert_file is not None:
+      ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW)
+      ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, cert_file)
+
+    ldap.set_option(ldap.OPT_REFERRALS, 0)
+
+    self.ldap_handle = ldap.initialize(ldap_url)
+
+    if bind_user is not None:
+      try:
+        self.ldap_handle.simple_bind_s(bind_user, bind_password)
+      except:
+        raise RuntimeError("Failed to bind to LDAP server as user %s" %
+            (bind_user,))
+    else:
+      try:
+        # Do anonymous bind
+        self.ldap_handle.simple_bind_s('','')
+      except:
+        raise RuntimeError("Failed to bind to LDAP server anonymously")
+
+  def find_user(self, username, find_by_dn=False):
+    """
+    LDAP search helper method finding users. This supports searching for users
+    by distinguished name, or the configured username attribute.
+    """
+    base_dn = self._get_root_dn()
+    scope = ldap.SCOPE_SUBTREE
+
+    user_filter = desktop.conf.LDAP.USERS.USER_FILTER.get()
+    if not user_filter.startswith('('):
+      user_filter = '(' + user_filter + ')'
+    user_name_attr = desktop.conf.LDAP.USERS.USER_NAME_ATTR.get()
+
+    if find_by_dn:
+      sanitized_name = ldap.filter.escape_filter_chars(username)
+      user_name_filter = '(distinguishedName=' + sanitized_name + ')'
+    else:
+      sanitized_name = ldap.filter.escape_filter_chars(username)
+      user_name_filter = '(' + user_name_attr + '=' + sanitized_name + ')'
+    ldap_filter = '(&' + user_filter + user_name_filter + ')'
+
+    ldap_result_id = self.ldap_handle.search(base_dn, scope, ldap_filter)
+    result_type, result_data = self.ldap_handle.result(ldap_result_id)
+    if result_type == ldap.RES_SEARCH_RESULT and result_data[0][0] is not None:
+      data = result_data[0][1]
+      user_info = { 'username': data[user_name_attr][0] }
+
+      if 'givenName' in data:
+        user_info['first'] = data['givenName'][0]
+      if 'sn' in data:
+        user_info['last'] = data['sn'][0]
+      if 'email' in data:
+        user_info['email'] = data['email'][0]
+
+      return user_info
+
+    return None
+
+  def find_group(self, groupname, find_by_dn=False):
+    """
+    LDAP search helper method for finding groups
+    """
+    base_dn = self._get_root_dn()
+    scope = ldap.SCOPE_SUBTREE
+
+    group_filter = desktop.conf.LDAP.GROUPS.GROUP_FILTER.get()
+    if not group_filter.startswith('('):
+      group_filter = '(' + group_filter + ')'
+    group_name_attr = desktop.conf.LDAP.GROUPS.GROUP_NAME_ATTR.get()
+
+    if find_by_dn:
+      sanitized_name = ldap.filter.escape_filter_chars(groupname)
+      group_name_filter = '(distinguishedName=' + sanitized_name + ')'
+    else:
+      sanitized_name = ldap.filter.escape_filter_chars(groupname)
+      group_name_filter = '(' + group_name_attr + '=' + sanitized_name + ')'
+    ldap_filter = '(&' + group_filter + group_name_filter + ')'
+
+    ldap_result_id = self.ldap_handle.search(base_dn, scope, ldap_filter)
+    result_type, result_data = self.ldap_handle.result(ldap_result_id)
+    if result_type == ldap.RES_SEARCH_RESULT and result_data[0][0] is not None:
+      data = result_data[0][1]
+      group_info = { 'name': data[group_name_attr][0] }
+
+      member_attr = desktop.conf.LDAP.GROUPS.GROUP_MEMBER_ATTR.get()
+      if member_attr in data:
+        group_info['members'] = data[member_attr]
+      else:
+        group_info['members'] = []
+
+      return group_info
+
+    return None
+
+  def _get_root_dn(self):
+    """
+    Returns the configured base DN (DC=desktop,DC=local).
+    """
+    return desktop.conf.LDAP.BASE_DN.get()

+ 50 - 0
apps/useradmin/src/useradmin/management/commands/import_ldap_group.py

@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from optparse import make_option
+
+from useradmin.views import import_ldap_group
+
+from django.core.management.base import BaseCommand, CommandError
+
+class Command(BaseCommand):
+  """
+  Handler for importing LDAP groups into the Hue database.
+
+  If a group has been previously imported, this will sync membership within the
+  group with the LDAP server. If --import-members is specified, it will import
+  all unimported users.
+  """
+
+  option_list = BaseCommand.option_list + (
+      make_option("--dn", help="Whether or not the user should be imported by "
+                               "distinguished name",
+                          action="store_true",
+                          default=False),
+      make_option("--import-members", help="Import users from the group",
+                                      action="store_true",
+                                      default=False),
+   )
+
+  args = "group-name"
+
+  def handle(self, group=None, **options):
+    if group is None:
+      raise CommandError("A group name must be provided")
+
+    import_members = options['import_members']
+    import_by_dn = options['dn']
+    import_ldap_group(group, import_members, import_by_dn)

+ 44 - 0
apps/useradmin/src/useradmin/management/commands/import_ldap_user.py

@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from optparse import make_option
+
+from useradmin.views import import_ldap_user
+
+from django.core.management.base import BaseCommand, CommandError
+
+class Command(BaseCommand):
+  """
+  Handler for importing LDAP users into the Hue database.
+
+  If a user has been previously imported, this will sync their user information.
+  """
+
+  option_list = BaseCommand.option_list + (
+      make_option("--dn", help="Whether or not the user should be imported by "
+                               "distinguished name",
+                          action="store_true",
+                          default=False),
+  )
+
+  args = "username"
+
+  def handle(self, user=None, **options):
+    if user is None:
+      raise CommandError("A username must be provided")
+
+    import_by_dn = options['dn']
+    import_ldap_user(user, import_by_dn)

+ 31 - 0
apps/useradmin/src/useradmin/management/commands/sync_ldap_users_and_groups.py

@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from useradmin.views import sync_ldap_users_and_groups
+
+from django.core.management.base import NoArgsCommand
+
+class Command(NoArgsCommand):
+  """
+  Handler for syncing the Hue database with LDAP users and groups.
+
+  This will not import any users or groups that don't already exist in Hue. All
+  user information and group memberships will be updated based on the LDAP
+  server's current state.
+  """
+
+  def handle_noargs(self, **options):
+    sync_ldap_users_and_groups()

+ 85 - 1
apps/useradmin/src/useradmin/tests.py

@@ -29,9 +29,12 @@ from desktop.lib.django_test_util import make_logged_in_client
 from django.contrib.auth.models import User, Group
 from django.utils.encoding import smart_unicode
 
-from useradmin.models import HuePermission, GroupPermission
+from useradmin.models import HuePermission, GroupPermission, LdapGroup, UserProfile
 from useradmin.models import get_profile
 
+from views import sync_ldap_users_and_groups, import_ldap_user, import_ldap_group
+import ldap_access
+
 def reset_all_users():
   """Reset to a clean state by deleting all users"""
   for user in User.objects.all():
@@ -42,6 +45,43 @@ def reset_all_groups():
   for grp in Group.objects.all():
     grp.delete()
 
+class LdapTestConnection(object):
+  """
+  Test class which mimics the behaviour of LdapConnection (from ldap_access.py).
+  It also includes functionality to fake modifications to an LDAP server.  It is designed
+  as a singleton, to allow for changes to persist across discrete connections.
+  """
+
+  _instance = None
+  def __init__(self):
+    if LdapTestConnection._instance is None:
+      LdapTestConnection._instance = LdapTestConnection._Singleton()
+
+  def add_user_group_for_test(self, user, group):
+    LdapTestConnection._instance.groups[group]['members'].append(user)
+
+  def remove_user_group_for_test(self, user, group):
+    LdapTestConnection._instance.groups[group]['members'].remove(user)
+
+  def find_user(self, user, find_by_dn=False):
+    """ Returns info for a particular user """
+    return LdapTestConnection._instance.users[user]
+
+  def find_group(self, groupname, find_by_dn=False):
+    """ Return all groups in the system with parents and children """
+    return LdapTestConnection._instance.groups[groupname]
+
+  class _Singleton:
+    def __init__(self):
+      self.users = {'moe': {'username':'moe', 'first':'Moe', 'email':'moe@stooges.com'},
+                    'larry': {'username':'larry', 'first':'Larry', 'last':'Stooge', 'email':'larry@stooges.com'},
+                    'curly': {'username':'curly', 'first':'Curly', 'last':'Stooge', 'email':'curly@stooges.com'}}
+
+      self.groups = {'TestUsers': {'name':'TestUsers', 'members':['moe','larry','curly']},
+                     'Test Administrators': {'name':'Test Administrators', 'members':['curly','larry']}}
+
+
+
 def test_invalid_username():
   BAD_NAMES = ('-foo', 'foo:o', 'foo o', ' foo')
 
@@ -268,3 +308,47 @@ def test_user_admin():
   # You shouldn't be able to create a user without a password
   response = c_su.post('/useradmin/users/new', dict(username="test"))
   assert_true("You must specify a password when creating a new user." in response.content)
+
+def test_userman_ldap_integration():
+  reset_all_users()
+  reset_all_groups()
+
+  # Set up LDAP tests to use a LdapTestConnection instead of an actual LDAP connection
+  ldap_access.CACHED_LDAP_CONN = LdapTestConnection()
+
+  # Try importing a user
+  import_ldap_user('larry', import_by_dn=False)
+  larry = User.objects.get(username='larry')
+  assert_true(larry.first_name == 'Larry')
+  assert_true(larry.last_name == 'Stooge')
+  assert_true(larry.email == 'larry@stooges.com')
+  assert_true(get_profile(larry).creation_method == str(UserProfile.CreationMethod.EXTERNAL))
+
+  # Should be a noop
+  sync_ldap_users_and_groups()
+  assert_true(len(User.objects.all()) == 1)
+  assert_true(len(Group.objects.all()) == 0)
+
+  # Should import a group, but will only sync already-imported members
+  import_ldap_group('Test Administrators', import_members=False, import_by_dn=False)
+  assert_true(len(User.objects.all()) == 1)
+  assert_true(len(Group.objects.all()) == 1)
+  test_admins = Group.objects.get(name='Test Administrators')
+  assert_true(len(test_admins.user_set.all()) == 1)
+  assert_true(test_admins.user_set.all()[0].username == larry.username)
+
+  # Import all members of TestUsers
+  import_ldap_group('TestUsers', import_members=True, import_by_dn=False)
+  test_users = Group.objects.get(name='TestUsers')
+  assert_true(LdapGroup.objects.filter(group=test_users).exists())
+  assert_true(len(test_users.user_set.all()) == 3)
+
+  ldap_access.CACHED_LDAP_CONN.remove_user_group_for_test('moe', 'TestUsers')
+  import_ldap_group('TestUsers', import_members=False, import_by_dn=False)
+  assert_true(len(test_users.user_set.all()) == 2)
+  assert_true(len(User.objects.get(username='moe').groups.all()) == 0)
+
+  ldap_access.CACHED_LDAP_CONN.add_user_group_for_test('moe', 'TestUsers')
+  import_ldap_group('TestUsers', import_members=False, import_by_dn=False)
+  assert_true(len(test_users.user_set.all()) == 3)
+  assert_true(len(User.objects.get(username='moe').groups.all()) == 1)

+ 95 - 1
apps/useradmin/src/useradmin/views.py

@@ -31,7 +31,9 @@ from django.contrib.auth.models import User, Group
 from desktop.lib.django_util import get_username_re_rule, get_groupname_re_rule, render, PopupException, format_preserving_redirect
 from django.core import urlresolvers
 
-from useradmin.models import GroupPermission, HuePermission
+from useradmin.models import GroupPermission, HuePermission, UserProfile, LdapGroup
+from useradmin.models import get_profile
+import ldap_access
 
 LOG = logging.getLogger(__name__)
 
@@ -326,6 +328,98 @@ def sync_unix_users_and_groups(min_uid, max_uid, min_gid, max_gid, check_shell):
   __users_lock.release()
   __groups_lock.release()
 
+def _import_ldap_user(username, import_by_dn=False):
+  """
+  Import a user from LDAP. If import_by_dn is true, this will import the user by
+  the distinguished name, rather than the configured username attribute.
+  """
+  conn = ldap_access.get_connection()
+  user_info = conn.find_user(username, import_by_dn)
+  if user_info is None:
+    LOG.warn("Could not get LDAP details for user %s" % (username,))
+    return None
+
+  user, created = User.objects.get_or_create(username=user_info['username'])
+  profile = get_profile(user)
+  if not created and profile.creation_method == str(UserProfile.CreationMethod.HUE):
+    # This is a Hue user, and shouldn't be overwritten
+    LOG.warn('There was a naming conflict while importing user %s' % (username,))
+    return None
+
+  if 'first' in user_info:
+    user.first_name = user_info['first']
+  if 'last' in user_info:
+    user.last_name = user_info['last']
+  if 'email' in user_info:
+    user.email = user_info['email']
+
+  profile.creation_method = UserProfile.CreationMethod.EXTERNAL
+  profile.save()
+  user.save()
+
+  return user
+
+def _import_ldap_group(groupname, import_members=False, import_by_dn=False):
+  """
+  Import a group from LDAP. If import_members is true, this will also import any
+  LDAP users that exist within the group.
+  """
+  conn = ldap_access.get_connection()
+  group_info = conn.find_group(groupname, import_by_dn)
+  if group_info is None:
+    LOG.warn("Could not get LDAP details for group %s" % (groupname,))
+    return None
+
+  group, created = Group.objects.get_or_create(name=group_info['name'])
+  if not created and not LdapGroup.objects.filter(group=group).exists():
+    # This is a Hue group, and shouldn't be overwritten
+    LOG.warn('There was a naming conflict while importing group %s' % (groupname,))
+    return None
+
+  LdapGroup.objects.get_or_create(group=group)
+
+  group.user_set.clear()
+  for member in group_info['members']:
+    if import_members:
+      LOG.debug("Importing user %s" % (member,))
+      user = _import_ldap_user(member, import_by_dn=True)
+    else:
+      user_info = conn.find_user(member, find_by_dn=True)
+      try:
+        user = User.objects.get(username=user_info['username'])
+      except User.DoesNotExist:
+        continue
+
+    if get_profile(user).creation_method == str(UserProfile.CreationMethod.HUE):
+      continue
+    LOG.debug("Adding user %s to group %s" % (member, group.name))
+    group.user_set.add(user)
+
+  group.save()
+  return group
+
+def import_ldap_user(user, import_by_dn):
+  _import_ldap_user(user, import_by_dn)
+
+def import_ldap_group(group, import_members, import_by_dn):
+  _import_ldap_group(group, import_members, import_by_dn)
+
+def sync_ldap_users_and_groups():
+  """
+  Syncs LDAP user information and group memberships. This will not import new
+  users or groups from LDAP. It is also not possible to import both a user and a
+  group at the same time. Each must be a separate operation. If neither a user,
+  nor a group is provided, all users and groups will be synced.
+  """
+  # Sync everything
+  users = User.objects.filter(userprofile__creation_method=str(UserProfile.CreationMethod.EXTERNAL)).all()
+  for user in users:
+    _import_ldap_user(user.username)
+
+  groups = Group.objects.filter(group__in=LdapGroup.objects.all())
+  for group in groups:
+    _import_ldap_group(group.name)
+
 class GroupEditForm(forms.ModelForm):
   """
   Form to manipulate a group.  This manages the group name and its membership.