Skip to content

Commit

Permalink
add checkusers management command (#1410)
Browse files Browse the repository at this point in the history
  • Loading branch information
mikkonie committed Apr 16, 2024
1 parent 588b0d0 commit ec7d5a6
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 6 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Added
- Update local user data in remote sync (#1407)
- ``USER`` scope settings in remote sync (#1322)
- ``AppLinkContent`` utility class (#1380, #1381)
- ``checkusers`` management command (#1410)

Changed
-------
Expand All @@ -34,6 +35,7 @@ Changed
- Upgrade general Python dependencies (#1374)
- Reformat with black v24.3.0 (#1374)
- Update download URL in ``get_chromedriver_url.py`` (#1385)
- Add ``AUTH_LDAP_USER_SEARCH_BASE`` as a Django setting (#1410)
- **Filesfolders**
- Add migration required by Django v4.2 (#1396)
- Add app specific media type and versioning (#1278)
Expand Down
8 changes: 6 additions & 2 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,9 @@
AUTH_LDAP_USER_FILTER = env.str(
'AUTH_LDAP_USER_FILTER', '(sAMAccountName=%(user)s)'
)
AUTH_LDAP_USER_SEARCH_BASE = env.str('AUTH_LDAP_USER_SEARCH_BASE', None)
AUTH_LDAP_USER_SEARCH = LDAPSearch(
env.str('AUTH_LDAP_USER_SEARCH_BASE', None),
AUTH_LDAP_USER_SEARCH_BASE,
ldap.SCOPE_SUBTREE,
AUTH_LDAP_USER_FILTER,
)
Expand Down Expand Up @@ -406,8 +407,11 @@
AUTH_LDAP2_USER_FILTER = env.str(
'AUTH_LDAP2_USER_FILTER', '(sAMAccountName=%(user)s)'
)
AUTH_LDAP2_USER_SEARCH_BASE = env.str(
'AUTH_LDAP2_USER_SEARCH_BASE', None
)
AUTH_LDAP2_USER_SEARCH = LDAPSearch(
env.str('AUTH_LDAP2_USER_SEARCH_BASE', None),
AUTH_LDAP2_USER_SEARCH_BASE,
ldap.SCOPE_SUBTREE,
AUTH_LDAP2_USER_FILTER,
)
Expand Down
11 changes: 11 additions & 0 deletions docs/source/app_projectroles_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,17 @@ project permissions, or by a site admin using the ``batchupdateroles``
management command. The latter supports multiple projects in one batch. It is
also able to send invites to users who have not yet signed up on the site.

User Status Checking
--------------------

An administrator can check status of external LDAP user accounts using the
``checkusers`` management command. This will list accounts disabled or locked
out of the LDAP server. Use the ``-h`` flag to see additional options.

.. code-block:: console
$ ./manage.py checkusers
Remote Projects
===============
Expand Down
15 changes: 11 additions & 4 deletions docs/source/major_changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Release Highlights
- Add target site user UUID updating in remote sync
- Add remote sync of existing target local users
- Add remote sync of USER scope app settings
- Add checkusers management command
- Rewrite sodarcache REST API views
- Rename AppSettingAPI "app_name" arguments to "plugin_name"
- Plugin API return data updates and deprecations
Expand Down Expand Up @@ -205,11 +206,17 @@ Local User Details Updated on Target Site
of local users must still be done manually: they will not be automatically
created by remote sync.

PROJECTROLES_HIDE_APP_LINKS Removed
-----------------------------------
Django Settings Changed
-----------------------

The ``PROJECTROLES_HIDE_APP_LINKS`` Django setting, which was deprecated in
v0.13, has been removed. Use ``PROJECTROLES_HIDE_PROJECT_APPS`` instead.
``AUTH_LDAP*_USER_SEARCH_BASE`` Added
The user search base values for primary and secondary LDAP servers have been
included as directly accessible Django settings. This is require for the
``checkuser`` management command to work. It is recommended to update your
site's LDAP settings accordingly.
``PROJECTROLES_HIDE_APP_LINKS`` Removed
The ``PROJECTROLES_HIDE_APP_LINKS`` Django setting, which was deprecated in
v0.13, has been removed. Use ``PROJECTROLES_HIDE_PROJECT_APPS`` instead.

Base Test Classes Renamed
-------------------------
Expand Down
178 changes: 178 additions & 0 deletions projectroles/management/commands/checkusers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""
Checkusers management command for checking user status and reporting disabled or
removed users.
"""

import ldap
import sys

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

from projectroles.management.logging import ManagementCommandLogger


logger = ManagementCommandLogger(__name__)
User = get_user_model()


# Local constants
# UserAccountControl flags for disabled accounts
# TODO: Can we get these from python-ldap and print out exact status?
UAC_DISABLED_VALUES = [
2,
514,
546,
66050,
66082,
262658,
262690,
328194,
328226,
]
UAC_LOCKED_VALUE = 16
# Messages
USER_DISABLED_MSG = 'Disabled'
USER_LOCKED_MSG = 'Locked'
USER_NOT_FOUND_MSG = 'Not found'
USER_OK_MSG = 'OK'


class Command(BaseCommand):
help = (
'Check user status and report disabled or removed users. Prints out '
'user name, real name, email and status as semicolon-separated values.'
)

@classmethod
def _print_result(cls, django_user, msg):
print(
'{};{};{};{}'.format(
django_user.username,
django_user.get_full_name(),
django_user.email,
msg,
)
)

@classmethod
def _get_setting_prefix(cls, primary):
return 'AUTH_LDAP{}_'.format('' if primary else '2')

def _check_search_base_setting(self, primary):
pf = self._get_setting_prefix(primary)
s_name = pf + 'USER_SEARCH_BASE'
if not hasattr(settings, s_name):
logger.error(s_name + ' not in Django settings')
sys.exit(1)

def _check_ldap_users(self, users, primary, all_users):
"""
Check and print out user status for a specific LDAP server.
:param users: QuerySet of SODARUser objects
:param primary: Whether to check for primary or secondary server (bool)
:param all_users: Display status for all users (bool)
"""

def _get_s(name):
pf = self._get_setting_prefix(primary)
return getattr(settings, pf + name)

domain = _get_s('USERNAME_DOMAIN')
domain_users = users.filter(username__endswith='@' + domain.upper())
server_uri = _get_s('SERVER_URI')
server_str = '{} LDAP server at "{}"'.format(
'primary' if primary else 'secondary', server_uri
)
if not domain_users:
logger.debug('No users found for {}, skipping'.format(server_str))
return

bind_dn = _get_s('BIND_DN')
bind_pw = _get_s('BIND_PASSWORD')
start_tls = _get_s('START_TLS')
options = _get_s('CONNECTION_OPTIONS')
user_filter = _get_s('USER_FILTER')
search_base = _get_s('USER_SEARCH_BASE')

# Enable debug if set in env
if settings.LDAP_DEBUG:
ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)

# Connect to LDAP
lc = ldap.initialize(server_uri)
for k, v in options.items():
lc.set_option(k, v)
if start_tls:
lc.protocol_version = 3
lc.start_tls_s()
try:
lc.simple_bind_s(bind_dn, bind_pw)
except Exception as ex:
logger.error(
'Exception connecting to {}: {}'.format(server_str, ex)
)
return

for d_user in domain_users:
r = lc.search(
search_base,
ldap.SCOPE_SUBTREE,
user_filter.replace('%(user)s', d_user.username.split('@')[0]),
)
_, l_user = lc.result(r, 60)
if len(l_user) > 0:
name, attrs = l_user[0]
user_ok = True
# logger.debug('Result: {}; {}'.format(name, attrs))
if (
'userAccountControl' in attrs
and len(attrs['userAccountControl']) > 0
):
val = int(attrs['userAccountControl'][0].decode('utf-8'))
if val in UAC_DISABLED_VALUES:
self._print_result(d_user, USER_DISABLED_MSG)
user_ok = False
elif val == UAC_LOCKED_VALUE:
self._print_result(d_user, USER_LOCKED_MSG)
user_ok = False
if all_users and user_ok:
self._print_result(d_user, USER_OK_MSG)
else: # Not found
self._print_result(d_user, USER_NOT_FOUND_MSG)

def add_arguments(self, parser):
parser.add_argument(
'-a',
'--all',
dest='all',
action='store_true',
required=False,
help='Display results for all users even if status is OK',
)
parser.add_argument(
'-l',
'--limit',
dest='limit',
required=False,
help='Limit search to "ldap1" or "ldap2".',
)

def handle(self, *args, **options):
if not settings.ENABLE_LDAP:
logger.info('LDAP not enabled, nothing to do')
return
self._check_search_base_setting(primary=True)
self._check_search_base_setting(primary=False)
users = User.objects.filter(username__contains='@').order_by('username')
limit = options.get('limit')
if not limit or limit == 'ldap1':
self._check_ldap_users(
users, primary=True, all_users=options.get('all', False)
)
if settings.ENABLE_LDAP_SECONDARY and (not limit or limit == 'ldap2'):
self._check_ldap_users(
users, primary=False, all_users=options.get('all', False)
)

0 comments on commit ec7d5a6

Please sign in to comment.