use ldap3 instead of python-ldap

This commit is contained in:
Max Erenberg 2021-08-15 05:04:49 +00:00
parent 6cdb41d47b
commit d82b5a763b
13 changed files with 301 additions and 325 deletions

View File

@ -1,5 +1,3 @@
from typing import Dict, List
from zope.interface import Interface, Attribute from zope.interface import Interface, Attribute
@ -9,7 +7,8 @@ class IGroup(Interface):
cn = Attribute('common name') cn = Attribute('common name')
gid_number = Attribute('gid number') gid_number = Attribute('gid number')
members = Attribute('usernames of group members') members = Attribute('usernames of group members')
dn = Attribute('distinguished name')
ldap3_entry = Attribute('cached ldap3.Entry instance for this group')
def add_to_ldap(): def add_to_ldap():
"""Add a new record to LDAP for this group.""" """Add a new record to LDAP for this group."""
@ -20,18 +19,5 @@ class IGroup(Interface):
def remove_member(username: str): def remove_member(username: str):
"""Remove the member from this group in LDAP.""" """Remove the member from this group in LDAP."""
def serialize_for_ldap() -> Dict[str, List[bytes]]:
"""
Serialize this group into a dict to be passed to
ldap.modlist.addModlist().
"""
# static method
def deserialize_from_ldap(data: Dict[str, List[bytes]]):
"""Deserialize this group from a dict returned by ldap.search_s().
:returns: IGroup
"""
def to_dict(): def to_dict():
"""Serialize this group as JSON.""" """Serialize this group as JSON."""

View File

@ -9,10 +9,16 @@ from .IGroup import IGroup
class ILDAPService(Interface): class ILDAPService(Interface):
"""An interface to the LDAP database.""" """An interface to the LDAP database."""
def uid_to_dn(self, uid: str) -> str:
"""Get the LDAP DN for the user with this UID."""
def group_cn_to_dn(self, cn: str) -> str:
"""Get the LDAP DN for the group with this CN."""
def get_user(username: str) -> IUser: def get_user(username: str) -> IUser:
"""Retrieve the user with the given username.""" """Retrieve the user with the given username."""
def add_user(user: IUser) -> IUser: def add_user(user: IUser):
""" """
Add the user to the database. Add the user to the database.
A new UID and GID will be generated and returned in the new user. A new UID and GID will be generated and returned in the new user.
@ -24,7 +30,7 @@ class ILDAPService(Interface):
def get_group(cn: str, is_club: bool = False) -> IGroup: def get_group(cn: str, is_club: bool = False) -> IGroup:
"""Retrieve the group with the given cn (Unix group name).""" """Retrieve the group with the given cn (Unix group name)."""
def add_group(group: IGroup) -> IGroup: def add_group(group: IGroup):
""" """
Add the group to the database. Add the group to the database.
The GID will not be changed and must be valid. The GID will not be changed and must be valid.
@ -33,11 +39,17 @@ class ILDAPService(Interface):
def remove_group(group: IGroup): def remove_group(group: IGroup):
"""Remove this group from the database.""" """Remove this group from the database."""
def modify_user(old_user: IUser, new_user: IUser): def entry_ctx_for_user(user: IUser):
"""Replace old_user with new_user.""" """
Get a context manager which yields an ldap3.WritableEntry
for this user.
"""
def modify_group(old_group: IGroup, new_group: IGroup): def entry_ctx_for_group(group: IGroup):
"""Replace old_group with new_group.""" """
Get a context manager which yields an ldap3.WritableEntry
for this group.
"""
def add_sudo_role(uid: str): def add_sudo_role(uid: str):
"""Create a sudo role for the club with this UID.""" """Create a sudo role for the club with this UID."""

View File

@ -19,10 +19,9 @@ class IUser(Interface):
non_member_terms = Attribute('list of terms for which this person was ' non_member_terms = Attribute('list of terms for which this person was '
'a club rep') 'a club rep')
mail_local_addresses = Attribute('email aliases') mail_local_addresses = Attribute('email aliases')
dn = Attribute('distinguished name')
# Non-LDAP attributes # Non-LDAP attributes
forwarding_addresses = Attribute('list of email forwarding addresses') ldap3_entry = Attribute('cached ldap3.Entry instance for this user')
def get_forwarding_addresses(self) -> List[str]: def get_forwarding_addresses(self) -> List[str]:
"""Get the forwarding addresses for this user.""" """Get the forwarding addresses for this user."""
@ -78,19 +77,6 @@ class IUser(Interface):
def unsubscribe_from_mailing_list(mailing_list: str): def unsubscribe_from_mailing_list(mailing_list: str):
"""Unsubscribe this user from the mailing list.""" """Unsubscribe this user from the mailing list."""
def serialize_for_ldap() -> Dict[str, List[bytes]]:
"""
Serialize this user into a dict to be passed to
ldap.modlist.addModlist().
"""
# static method
def deserialize_from_ldap(data: Dict[str, List[bytes]]):
"""Deserialize this user from a dict returned by ldap.search_s().
:returns: IUser
"""
def to_dict(get_forwarding_addresses: bool = False) -> Dict: def to_dict(get_forwarding_addresses: bool = False) -> Dict:
""" """
Serialize this user into a dict. Serialize this user into a dict.

View File

@ -1,28 +1,29 @@
import copy
import json import json
from typing import List, Dict, Union from typing import List, Union
import ldap3
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
from .utils import strings_to_bytes, bytes_to_strings, dn_to_uid from .utils import dn_to_uid
from ceo_common.interfaces import ILDAPService, IGroup, IConfig from ceo_common.interfaces import ILDAPService, IGroup
@implementer(IGroup) @implementer(IGroup)
class Group: class Group:
def __init__( def __init__(
self, cn: str, gid_number: int, self,
cn: str,
gid_number: int,
members: Union[List[str], None] = None, members: Union[List[str], None] = None,
ldap3_entry: Union[ldap3.Entry, None] = None,
): ):
self.cn = cn self.cn = cn
self.gid_number = gid_number self.gid_number = gid_number
# this is a list of the usernames of the members in this group # this is a list of the usernames of the members in this group
self.members = members or [] self.members = members or []
self.ldap3_entry = ldap3_entry
cfg = component.getUtility(IConfig)
self.dn = f'cn={cn},{cfg.get("ldap_groups_base")}'
self.ldap_users_base = cfg.get('ldap_users_base')
self.ldap_srv = component.getUtility(ILDAPService) self.ldap_srv = component.getUtility(ILDAPService)
def __repr__(self) -> str: def __repr__(self) -> str:
@ -30,7 +31,6 @@ class Group:
def to_dict(self): def to_dict(self):
return { return {
'dn': self.dn,
'cn': self.cn, 'cn': self.cn,
'gid_number': self.gid_number, 'gid_number': self.gid_number,
'members': self.members, 'members': self.members,
@ -42,44 +42,27 @@ class Group:
def remove_from_ldap(self): def remove_from_ldap(self):
self.ldap_srv.remove_group(self) self.ldap_srv.remove_group(self)
def serialize_for_ldap(self) -> Dict[str, List[bytes]]:
data = {
'cn': [self.cn],
'gidNumber': [str(self.gid_number)],
'objectClass': [
'top',
'group',
'posixGroup',
],
}
if self.members:
data['uniqueMember'] = [
f'uid={member},{self.ldap_users_base}'
for member in self.members
]
return strings_to_bytes(data)
@staticmethod @staticmethod
def deserialize_from_ldap(data: Dict[str, List[bytes]]) -> IGroup: def deserialize_from_ldap(entry: ldap3.Entry) -> IGroup:
data = bytes_to_strings(data) """Deserialize this group from an LDAP entry."""
attrs = entry.entry_attributes_as_dict
return Group( return Group(
cn=data['cn'][0], cn=attrs['cn'][0],
gid_number=int(data['gidNumber'][0]), gid_number=attrs['gidNumber'][0],
members=[ members=[
dn_to_uid(dn) for dn in data.get('uniqueMember', []) dn_to_uid(dn) for dn in attrs.get('uniqueMember', [])
], ],
ldap3_entry=entry,
) )
def add_member(self, username: str): def add_member(self, username: str):
new_group = copy.copy(self) dn = self.ldap_srv.uid_to_dn(username)
new_group.members = self.members.copy() with self.ldap_srv.entry_ctx_for_group(self) as entry:
new_group.members.append(username) entry.uniqueMember.add(dn)
self.ldap_srv.modify_group(self, new_group) self.members.append(username)
self.members = new_group.members
def remove_member(self, username: str): def remove_member(self, username: str):
new_group = copy.copy(self) dn = self.ldap_srv.uid_to_dn(username)
new_group.members = self.members.copy() with self.ldap_srv.entry_ctx_for_group(self) as entry:
new_group.members.remove(username) entry.uniqueMember.delete(dn)
self.ldap_srv.modify_group(self, new_group) self.members.remove(username)
self.members = new_group.members

View File

@ -1,73 +1,90 @@
import copy import contextlib
import grp import grp
import pwd import pwd
from typing import Union, List from typing import Union, List
import ldap import ldap3
import ldap.modlist
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \ from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \
UserAlreadyExistsError, GroupAlreadyExistsError UserAlreadyExistsError, GroupAlreadyExistsError
from ceo_common.interfaces import ILDAPService, IKerberosService, IConfig, \ from ceo_common.interfaces import ILDAPService, IConfig, \
IUser, IGroup, IUWLDAPService IUser, IGroup, IUWLDAPService
from .User import User from .User import User
from .Group import Group from .Group import Group
from .SudoRole import SudoRole
from .utils import dn_to_uid, bytes_to_strings
@implementer(ILDAPService) @implementer(ILDAPService)
class LDAPService: class LDAPService:
def __init__(self): def __init__(self):
cfg = component.getUtility(IConfig) cfg = component.getUtility(IConfig)
self.ldap_admin_principal = cfg.get('ldap_admin_principal')
self.ldap_server_url = cfg.get('ldap_server_url') self.ldap_server_url = cfg.get('ldap_server_url')
self.ldap_sasl_realm = cfg.get('ldap_sasl_realm')
self.ldap_users_base = cfg.get('ldap_users_base') self.ldap_users_base = cfg.get('ldap_users_base')
self.ldap_groups_base = cfg.get('ldap_groups_base') self.ldap_groups_base = cfg.get('ldap_groups_base')
self.ldap_sudo_base = cfg.get('ldap_sudo_base')
self.member_min_id = cfg.get('members_min_id') self.member_min_id = cfg.get('members_min_id')
self.member_max_id = cfg.get('members_max_id') self.member_max_id = cfg.get('members_max_id')
self.club_min_id = cfg.get('clubs_min_id') self.club_min_id = cfg.get('clubs_min_id')
self.club_max_id = cfg.get('clubs_max_id') self.club_max_id = cfg.get('clubs_max_id')
self.sasl_user = None
def _get_ldap_conn(self, gssapi_bind: bool = True) -> ldap.ldapobject.LDAPObject: def set_sasl_user(self, sasl_user: Union[str, None]):
# TODO: cache the connection # TODO: store SASL user in flask.g instead
conn = ldap.initialize(self.ldap_server_url) self.sasl_user = sasl_user
def _get_ldap_conn(self, gssapi_bind: bool = True) -> ldap3.Connection:
kwargs = {'auto_bind': True, 'raise_exceptions': True}
if gssapi_bind: if gssapi_bind:
self._gssapi_bind(conn) kwargs['authentication'] = ldap3.SASL
kwargs['sasl_mechanism'] = ldap3.KERBEROS
kwargs['user'] = self.sasl_user
# TODO: cache the connection for a single request
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
return conn return conn
def _gssapi_bind(self, conn: ldap.ldapobject.LDAPObject): def _get_readable_entry_for_user(self, conn: ldap3.Connection, username: str) -> ldap3.Entry:
krb_srv = component.getUtility(IKerberosService) base = self.uid_to_dn(username)
for i in range(2):
try: try:
conn.sasl_gssapi_bind_s() conn.search(
return base, '(objectClass=*)', search_scope=ldap3.BASE,
except ldap.LOCAL_ERROR as err: attributes=ldap3.ALL_ATTRIBUTES)
if 'Ticket expired' in err.args[0]['info']: except ldap3.core.exceptions.LDAPNoSuchObjectResult:
krb_srv.kinit() raise UserNotFoundError()
continue return conn.entries[0]
raise err
raise Exception('could not perform GSSAPI bind') def _get_readable_entry_for_group(self, conn: ldap3.Connection, cn: str) -> ldap3.Entry:
base = self.group_cn_to_dn(cn)
try:
conn.search(
base, '(objectClass=*)', search_scope=ldap3.BASE,
attributes=ldap3.ALL_ATTRIBUTES)
except ldap3.core.exceptions.LDAPNoSuchObjectResult:
raise GroupNotFoundError()
return conn.entries[0]
def _get_writable_entry_for_user(self, user: IUser) -> ldap3.WritableEntry:
if user.ldap3_entry is None:
conn = self._get_ldap_conn()
user.ldap3_entry = self._get_readable_entry_for_user(conn, user.uid)
return user.ldap3_entry.entry_writable()
def _get_writable_entry_for_group(self, group: IGroup) -> ldap3.WritableEntry:
if group.ldap3_entry is None:
conn = self._get_ldap_conn()
group.ldap3_entry = self._get_readable_entry_for_group(conn, group.cn)
return group.ldap3_entry.entry_writable()
def get_user(self, username: str) -> IUser: def get_user(self, username: str) -> IUser:
conn = self._get_ldap_conn(False) conn = self._get_ldap_conn(False)
base = self.uid_to_dn(username) entry = self._get_readable_entry_for_user(conn, username)
try: return User.deserialize_from_ldap(entry)
_, result = conn.search_s(base, ldap.SCOPE_BASE)[0]
return User.deserialize_from_ldap(result)
except ldap.NO_SUCH_OBJECT:
raise UserNotFoundError()
def get_group(self, cn: str) -> IGroup: def get_group(self, cn: str) -> IGroup:
conn = self._get_ldap_conn(False) conn = self._get_ldap_conn(False)
base = self.group_cn_to_dn(cn) entry = self._get_readable_entry_for_group(conn, cn)
try: return Group.deserialize_from_ldap(entry)
_, result = conn.search_s(base, ldap.SCOPE_BASE)[0]
return Group.deserialize_from_ldap(result)
except ldap.NO_SUCH_OBJECT:
raise GroupNotFoundError()
def uid_to_dn(self, uid: str): def uid_to_dn(self, uid: str):
return f'uid={uid},{self.ldap_users_base}' return f'uid={uid},{self.ldap_users_base}'
@ -75,7 +92,7 @@ class LDAPService:
def group_cn_to_dn(self, cn: str): def group_cn_to_dn(self, cn: str):
return f'cn={cn},{self.ldap_groups_base}' return f'cn={cn},{self.ldap_groups_base}'
def _get_next_uid(self, conn: ldap.ldapobject.LDAPObject, min_id: int, max_id: int) -> int: def _get_next_uid(self, conn: ldap3.Connection, min_id: int, max_id: int) -> int:
"""Gets the next available UID number between min_id and max_id, inclusive.""" """Gets the next available UID number between min_id and max_id, inclusive."""
def uid_exists(uid: int) -> bool: def uid_exists(uid: int) -> bool:
try: try:
@ -92,10 +109,10 @@ class LDAPService:
return False return False
def ldap_uid_or_gid_exists(uid: int) -> bool: def ldap_uid_or_gid_exists(uid: int) -> bool:
results = conn.search_s( return conn.search(
self.ldap_users_base, ldap.SCOPE_ONELEVEL, self.ldap_users_base,
f'(|(uidNumber={uid})(gidNumber={uid}))') f'(|(uidNumber={uid})(gidNumber={uid}))',
return len(results) > 0 size_limit=1)
# TODO: replace this with binary search # TODO: replace this with binary search
for uid in range(min_id, max_id + 1): for uid in range(min_id, max_id + 1):
@ -106,95 +123,125 @@ class LDAPService:
def add_sudo_role(self, uid: str): def add_sudo_role(self, uid: str):
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
sudo_role = SudoRole(uid) obj_def = ldap3.ObjectDef(['sudoRole'], conn)
modlist = ldap.modlist.addModlist(sudo_role.serialize_for_ldap()) writer = ldap3.Writer(conn, obj_def)
conn.add_s(sudo_role.dn, modlist) dn = f'cn=%{uid},{self.ldap_sudo_base}'
entry = writer.new(dn)
entry.cn = '%' + uid
entry.sudoUser = '%' + uid
entry.sudoHost = 'ALL'
entry.sudoCommand = 'ALL'
entry.sudoOption = ['!authenticate']
entry.sudoRunAsUser = uid
writer.commit()
def remove_sudo_role(self, uid: str): def remove_sudo_role(self, uid: str):
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
sudo_role = SudoRole(uid) dn = f'cn=%{uid},{self.ldap_sudo_base}'
conn.delete_s(sudo_role.dn) conn.delete(dn)
def add_user(self, user: IUser) -> IUser: def add_user(self, user: IUser):
object_classes = ['account', 'posixAccount', 'shadowAccount']
if user.is_club(): if user.is_club():
min_id, max_id = self.club_min_id, self.club_max_id min_id, max_id = self.club_min_id, self.club_max_id
object_classes.append('club')
else: else:
min_id, max_id = self.member_min_id, self.member_max_id min_id, max_id = self.member_min_id, self.member_max_id
object_classes.append('member')
if user.mail_local_addresses:
object_classes.append('inetLocalMailRecipient')
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
obj_def = ldap3.ObjectDef(object_classes, conn)
uid_number = self._get_next_uid(conn, min_id, max_id) uid_number = self._get_next_uid(conn, min_id, max_id)
new_user = copy.copy(user) user.uid_number = uid_number
new_user.uid_number = uid_number user.gid_number = uid_number
new_user.gid_number = uid_number
writer = ldap3.Writer(conn, obj_def)
entry = writer.new(self.uid_to_dn(user.uid))
entry.cn = user.cn
entry.uidNumber = user.uid_number
entry.gidNumber = user.gid_number
entry.homeDirectory = user.home_directory
if user.login_shell:
entry.loginShell = user.login_shell
if user.program:
entry.program = user.program
if user.terms:
entry.term = user.terms
if user.non_member_terms:
entry.nonMemberTerm = user.non_member_terms
if user.positions:
entry.position = user.positions
if user.mail_local_addresses:
entry.mailLocalAddress = user.mail_local_addresses
if not user.is_club():
entry.userPassword = '{SASL}%s@%s' % (user.uid, self.ldap_sasl_realm)
modlist = ldap.modlist.addModlist(new_user.serialize_for_ldap())
try: try:
conn.add_s(new_user.dn, modlist) writer.commit()
except ldap.ALREADY_EXISTS: except ldap3.core.exceptions.LDAPEntryAlreadyExistsResult:
raise UserAlreadyExistsError() raise UserAlreadyExistsError()
return new_user
def modify_user(self, old_user: IUser, new_user: IUser): @contextlib.contextmanager
conn = self._get_ldap_conn() def entry_ctx_for_user(self, user: IUser):
modlist = ldap.modlist.modifyModlist( entry = self._get_writable_entry_for_user(user)
old_user.serialize_for_ldap(), yield entry
new_user.serialize_for_ldap(), entry.entry_commit_changes()
)
conn.modify_s(old_user.dn, modlist)
def remove_user(self, user: IUser): def remove_user(self, user: IUser):
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
conn.delete_s(user.dn) conn.delete(self.uid_to_dn(user.uid))
def add_group(self, group: IGroup) -> IGroup: def add_group(self, group: IGroup) -> IGroup:
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
# make sure that the caller initialized the GID number # make sure that the caller initialized the GID number
assert group.gid_number assert group.gid_number
modlist = ldap.modlist.addModlist(group.serialize_for_ldap()) obj_def = ldap3.ObjectDef(['group', 'posixGroup'], conn)
try: writer = ldap3.Writer(conn, obj_def)
conn.add_s(group.dn, modlist) entry = writer.new(self.group_cn_to_dn(group.cn))
except ldap.ALREADY_EXISTS:
raise GroupAlreadyExistsError()
return group
def modify_group(self, old_group: IGroup, new_group: IGroup): entry.cn = group.cn
conn = self._get_ldap_conn() entry.gidNumber = group.gid_number
modlist = ldap.modlist.modifyModlist( if group.members:
old_group.serialize_for_ldap(), entry.uniqueMember = [self.uid_to_dn(uid) for uid in group.members]
new_group.serialize_for_ldap(),
) try:
conn.modify_s(old_group.dn, modlist) writer.commit()
except ldap3.core.exceptions.LDAPEntryAlreadyExistsResult:
raise GroupAlreadyExistsError()
@contextlib.contextmanager
def entry_ctx_for_group(self, group: IGroup):
entry = self._get_writable_entry_for_group(group)
yield entry
entry.entry_commit_changes()
def remove_group(self, group: IGroup): def remove_group(self, group: IGroup):
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
conn.delete_s(group.dn) conn.delete(self.group_cn_to_dn(group.cn))
def update_programs( def update_programs(
self, self,
dry_run: bool = False, dry_run: bool = False,
members: Union[List[str], None] = None, members: Union[List[str], None] = None,
uwldap_batch_size: int = 100, uwldap_batch_size: int = 10,
): ):
if members: if members:
filter_str = '(|' + ''.join([ filter_str = '(|' + ''.join([
f'(uid={uid})' for uid in members f'(uid={uid})' for uid in members
]) + ')' ]) + ')'
else: else:
filter_str = None filter_str = '(objectClass=*)'
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
raw_csc_records = conn.search_s( conn.search(
self.ldap_users_base, ldap.SCOPE_SUBTREE, filter_str, self.ldap_users_base, filter_str, attributes=['uid', 'program'])
attrlist=['program']) uids = [entry.uid.value for entry in conn.entries]
uids = [ csc_programs = [entry.program.value for entry in conn.entries]
dn_to_uid(dn) for dn, _ in raw_csc_records
]
csc_programs = [
bytes_to_strings(data).get('program', [None])[0]
for _, data in raw_csc_records
]
uwldap_srv = component.getUtility(IUWLDAPService) uwldap_srv = component.getUtility(IUWLDAPService)
uw_programs = [] uw_programs = []
# send queries in small batches # send queries in small batches so that we don't have an
# enormous filter in our query to UWLDAP
for i in range(0, len(csc_programs), uwldap_batch_size): for i in range(0, len(csc_programs), uwldap_batch_size):
batch_uids = uids[i:i + uwldap_batch_size] batch_uids = uids[i:i + uwldap_batch_size]
batch_uw_programs = uwldap_srv.get_programs_for_users(batch_uids) batch_uw_programs = uwldap_srv.get_programs_for_users(batch_uids)
@ -210,9 +257,7 @@ class LDAPService:
return users_to_change return users_to_change
for uid, old_program, new_program in users_to_change: for uid, old_program, new_program in users_to_change:
old_entry = {'program': [old_program.encode()]} changes = {'program': [(ldap3.MODIFY_REPLACE, [new_program])]}
new_entry = {'program': [new_program.encode()]} conn.modify(self.uid_to_dn(uid), changes)
modlist = ldap.modlist.modifyModlist(old_entry, new_entry)
conn.modify_s(self.uid_to_dn(uid), modlist)
return users_to_change return users_to_change

View File

@ -1,11 +1,20 @@
from typing import List, Dict, Union from typing import List, Union
from .utils import bytes_to_strings import ldap3
class UWLDAPRecord: class UWLDAPRecord:
"""Represents a record from the UW LDAP.""" """Represents a record from the UW LDAP."""
ldap_attributes = [
'uid',
'mailLocalAddress',
'ou', # program
'cn',
'sn',
'givenName',
]
def __init__( def __init__(
self, self,
uid: str, uid: str,
@ -23,19 +32,18 @@ class UWLDAPRecord:
self.given_name = given_name self.given_name = given_name
@staticmethod @staticmethod
def deserialize_from_ldap(data: Dict[str, List[bytes]]): def deserialize_from_ldap(entry: ldap3.Entry):
""" """
Deserializes a dict returned from ldap.search_s() into a Deserializes a dict returned from LDAP into a
UWLDAPRecord. UWLDAPRecord.
""" """
data = bytes_to_strings(data)
return UWLDAPRecord( return UWLDAPRecord(
uid=data['uid'][0], uid=entry.uid.value,
mail_local_addresses=data['mailLocalAddress'], mail_local_addresses=entry.mailLocalAddress.values,
program=data.get('ou', [None])[0], program=entry.ou.value,
cn=data.get('cn', [None])[0], cn=entry.cn.value,
sn=data.get('sn', [None])[0], sn=entry.sn.value,
given_name=data.get('givenName', [None])[0], given_name=entry.givenName.value,
) )
def to_dict(self): def to_dict(self):

View File

@ -1,11 +1,11 @@
from typing import Union, List from typing import Union, List
import ldap import ldap3
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
from .UWLDAPRecord import UWLDAPRecord from .UWLDAPRecord import UWLDAPRecord
from .utils import dn_to_uid, bytes_to_strings from .utils import dn_to_uid
from ceo_common.interfaces import IUWLDAPService, IConfig from ceo_common.interfaces import IUWLDAPService, IConfig
@ -16,27 +16,33 @@ class UWLDAPService:
self.uwldap_server_url = cfg.get('uwldap_server_url') self.uwldap_server_url = cfg.get('uwldap_server_url')
self.uwldap_base = cfg.get('uwldap_base') self.uwldap_base = cfg.get('uwldap_base')
def _get_conn(self) -> ldap3.Connection:
return ldap3.Connection(
self.uwldap_server_url, auto_bind=True, read_only=True,
raise_exceptions=True)
def get_user(self, username: str) -> Union[UWLDAPRecord, None]: def get_user(self, username: str) -> Union[UWLDAPRecord, None]:
conn = ldap.initialize(self.uwldap_server_url) conn = self._get_conn()
results = conn.search_s(self.uwldap_base, ldap.SCOPE_SUBTREE, f'uid={username}') conn.search(
if not results: self.uwldap_base, f'(uid={username})',
attributes=UWLDAPRecord.ldap_attributes, size_limit=1)
if not conn.entries:
return None return None
_, data = results[0] # discard the dn return UWLDAPRecord.deserialize_from_ldap(conn.entries[0])
return UWLDAPRecord.deserialize_from_ldap(data)
def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]: def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]:
filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')' filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
programs = [None] * len(usernames) programs = [None] * len(usernames)
user_indices = {uid: i for i, uid in enumerate(usernames)} user_indices = {uid: i for i, uid in enumerate(usernames)}
conn = ldap.initialize(self.uwldap_server_url) conn = self._get_conn()
records = conn.search_s( conn.search(
self.uwldap_base, ldap.SCOPE_SUBTREE, filter_str, attrlist=['ou']) self.uwldap_base, filter_str, attributes=['ou'],
for dn, data in records: size_limit=len(usernames))
uid = dn_to_uid(dn) for entry in conn.entries:
uid = dn_to_uid(entry.entry_dn)
idx = user_indices[uid] idx = user_indices[uid]
data = bytes_to_strings(data) program = entry.ou.value
program = data.get('ou', [None])[0]
if program: if program:
programs[idx] = program programs[idx] = program
return programs return programs

View File

@ -1,12 +1,11 @@
import copy
import json import json
import os import os
from typing import List, Dict, Union from typing import List, Dict, Union
import ldap3
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
from .utils import strings_to_bytes, bytes_to_strings
from .validators import is_valid_shell, is_valid_term from .validators import is_valid_shell, is_valid_term
from ceo_common.interfaces import ILDAPService, IKerberosService, IFileService, \ from ceo_common.interfaces import ILDAPService, IKerberosService, IFileService, \
IUser, IConfig, IMailmanService IUser, IConfig, IMailmanService
@ -15,7 +14,9 @@ from ceo_common.interfaces import ILDAPService, IKerberosService, IFileService,
@implementer(IUser) @implementer(IUser)
class User: class User:
def __init__( def __init__(
self, uid: str, cn: str, self,
uid: str,
cn: str,
program: Union[str, None] = None, program: Union[str, None] = None,
terms: Union[List[str], None] = None, terms: Union[List[str], None] = None,
non_member_terms: Union[List[str], None] = None, non_member_terms: Union[List[str], None] = None,
@ -26,6 +27,7 @@ class User:
positions: Union[List[str], None] = None, positions: Union[List[str], None] = None,
mail_local_addresses: Union[List[str], None] = None, mail_local_addresses: Union[List[str], None] = None,
is_club: bool = False, is_club: bool = False,
ldap3_entry: Union[ldap3.Entry, None] = None,
): ):
if not is_club and not terms and not non_member_terms: if not is_club and not terms and not non_member_terms:
raise Exception('terms and non_member_terms cannot both be empty') raise Exception('terms and non_member_terms cannot both be empty')
@ -50,16 +52,14 @@ class User:
self.positions = positions or [] self.positions = positions or []
self.mail_local_addresses = mail_local_addresses or [] self.mail_local_addresses = mail_local_addresses or []
self._is_club = is_club self._is_club = is_club
self.ldap3_entry = ldap3_entry
self.ldap_sasl_realm = cfg.get('ldap_sasl_realm')
self.dn = f'uid={uid},{cfg.get("ldap_users_base")}'
self.ldap_srv = component.getUtility(ILDAPService) self.ldap_srv = component.getUtility(ILDAPService)
self.krb_srv = component.getUtility(IKerberosService) self.krb_srv = component.getUtility(IKerberosService)
self.file_srv = component.getUtility(IFileService) self.file_srv = component.getUtility(IFileService)
def to_dict(self, get_forwarding_addresses: bool = False) -> Dict: def to_dict(self, get_forwarding_addresses: bool = False) -> Dict:
data = { data = {
'dn': self.dn,
'cn': self.cn, 'cn': self.cn,
'uid': self.uid, 'uid': self.uid,
'uid_number': self.uid_number, 'uid_number': self.uid_number,
@ -89,14 +89,10 @@ class User:
return self._is_club return self._is_club
def add_to_ldap(self): def add_to_ldap(self):
new_member = self.ldap_srv.add_user(self) self.ldap_srv.add_user(self)
self.uid_number = new_member.uid_number
self.gid_number = new_member.gid_number
def remove_from_ldap(self): def remove_from_ldap(self):
self.ldap_srv.remove_user(self) self.ldap_srv.remove_user(self)
self.uid_number = None
self.gid_number = None
def add_to_kerberos(self, password: str): def add_to_kerberos(self, password: str):
self.krb_srv.addprinc(self.uid, password) self.krb_srv.addprinc(self.uid, password)
@ -119,102 +115,61 @@ class User:
def unsubscribe_from_mailing_list(self, mailing_list: str): def unsubscribe_from_mailing_list(self, mailing_list: str):
component.getUtility(IMailmanService).unsubscribe(self.uid, mailing_list) component.getUtility(IMailmanService).unsubscribe(self.uid, mailing_list)
def serialize_for_ldap(self) -> Dict:
data = {
'cn': [self.cn],
'loginShell': [self.login_shell],
'homeDirectory': [self.home_directory],
'uid': [self.uid],
'uidNumber': [str(self.uid_number)],
'gidNumber': [str(self.gid_number)],
'objectClass': [
'top',
'account',
'posixAccount',
'shadowAccount',
],
}
if self.is_club():
data['objectClass'].append('club')
else:
data['objectClass'].append('member')
data['userPassword'] = ['{SASL}%s@%s' % (self.uid, self.ldap_sasl_realm)]
if self.program:
data['program'] = [self.program]
if self.terms:
data['term'] = self.terms
if self.non_member_terms:
data['nonMemberTerm'] = self.non_member_terms
if self.positions:
data['position'] = self.positions
if self.mail_local_addresses:
data['mailLocalAddress'] = self.mail_local_addresses
data['objectClass'].append('inetLocalMailRecipient')
return strings_to_bytes(data)
@staticmethod @staticmethod
def deserialize_from_ldap(data: Dict[str, List[bytes]]) -> IUser: def deserialize_from_ldap(entry: ldap3.Entry) -> IUser:
data = bytes_to_strings(data) """Deserialize this user from an LDAP entry."""
attrs = entry.entry_attributes_as_dict
return User( return User(
uid=data['uid'][0], uid=attrs['uid'][0],
cn=data['cn'][0], cn=attrs['cn'][0],
program=data.get('program', [None])[0], program=attrs.get('program', [None])[0],
terms=data.get('term'), terms=attrs.get('term'),
non_member_terms=data.get('nonMemberTerm'), non_member_terms=attrs.get('nonMemberTerm'),
login_shell=data['loginShell'][0], login_shell=attrs['loginShell'][0],
uid_number=int(data['uidNumber'][0]), uid_number=attrs['uidNumber'][0],
gid_number=int(data['gidNumber'][0]), gid_number=attrs['gidNumber'][0],
home_directory=data['homeDirectory'][0], home_directory=attrs['homeDirectory'][0],
positions=data.get('position'), positions=attrs.get('position'),
mail_local_addresses=data.get('mailLocalAddress', []), mail_local_addresses=attrs.get('mailLocalAddress'),
is_club=('club' in data['objectClass']), is_club=('club' in attrs['objectClass']),
ldap3_entry=entry,
) )
def replace_login_shell(self, login_shell: str): def replace_login_shell(self, login_shell: str):
new_user = copy.copy(self)
if not is_valid_shell(login_shell): if not is_valid_shell(login_shell):
raise Exception('%s is not a valid shell' % login_shell) raise Exception('%s is not a valid shell' % login_shell)
new_user.login_shell = login_shell with self.ldap_srv.entry_ctx_for_user(self) as entry:
self.ldap_srv.modify_user(self, new_user) entry.loginShell = login_shell
self.login_shell = login_shell self.login_shell = login_shell
def add_terms(self, terms: List[str]): def add_terms(self, terms: List[str]):
for term in terms: for term in terms:
if not is_valid_term(term): if not is_valid_term(term):
raise Exception('%s is not a valid term' % term) raise Exception('%s is not a valid term' % term)
new_user = copy.copy(self) with self.ldap_srv.entry_ctx_for_user(self) as entry:
new_user.terms = self.terms.copy() entry.term.add(terms)
new_user.terms.extend(terms) self.terms.extend(terms)
self.ldap_srv.modify_user(self, new_user)
self.terms = new_user.terms
def add_non_member_terms(self, terms: List[str]): def add_non_member_terms(self, terms: List[str]):
for term in terms: for term in terms:
if not is_valid_term(term): if not is_valid_term(term):
raise Exception('%s is not a valid term' % term) raise Exception('%s is not a valid term' % term)
new_user = copy.copy(self) with self.ldap_srv.entry_ctx_for_user(self) as entry:
new_user.non_member_terms = self.non_member_terms.copy() entry.nonMemberTerm.add(terms)
new_user.non_member_terms.extend(terms) self.non_member_terms.extend(terms)
self.ldap_srv.modify_user(self, new_user)
self.non_member_terms = new_user.non_member_terms
def add_position(self, position: str): def add_position(self, position: str):
new_user = copy.copy(self) with self.ldap_srv.entry_ctx_for_user(self) as entry:
new_user.positions = [*self.positions, position] entry.position.add(position)
self.ldap_srv.modify_user(self, new_user) self.positions.append(position)
self.positions = new_user.positions
def remove_position(self, position: str): def remove_position(self, position: str):
new_user = copy.copy(self) with self.ldap_srv.entry_ctx_for_user(self) as entry:
new_user.positions = self.positions.copy() entry.position.delete(position)
new_user.positions.remove(position) self.positions.remove(position)
self.ldap_srv.modify_user(self, new_user)
self.positions = new_user.positions
def get_forwarding_addresses(self) -> List[str]: def get_forwarding_addresses(self) -> List[str]:
return self.file_srv.get_forwarding_addresses(self) return self.file_srv.get_forwarding_addresses(self)
def set_forwarding_addresses(self, addresses: List[str]): def set_forwarding_addresses(self, addresses: List[str]):
self.file_srv.set_forwarding_addresses(self, addresses) self.file_srv.set_forwarding_addresses(self, addresses)
forwarding_addresses = property(get_forwarding_addresses, set_forwarding_addresses)

View File

@ -2,7 +2,7 @@ Flask==2.0.1
Flask-Kerberos==1.0.4 Flask-Kerberos==1.0.4
gssapi==1.6.14 gssapi==1.6.14
Jinja2==3.0.1 Jinja2==3.0.1
python-ldap==3.3.1 ldap3==2.9.1
requests==2.26.0 requests==2.26.0
requests-gssapi==1.2.3 requests-gssapi==1.2.3
zope.component==5.0.1 zope.component==5.0.1

View File

@ -35,7 +35,6 @@ def test_group_to_dict(simple_group):
group = simple_group group = simple_group
expected = { expected = {
'dn': group.dn,
'cn': group.cn, 'cn': group.cn,
'gid_number': group.gid_number, 'gid_number': group.gid_number,
'members': group.members, 'members': group.members,

View File

@ -136,7 +136,6 @@ def test_user_to_dict(cfg):
'positions': user.positions, 'positions': user.positions,
'login_shell': '/bin/bash', 'login_shell': '/bin/bash',
'home_directory': user.home_directory, 'home_directory': user.home_directory,
'dn': f'uid={user.uid},' + cfg.get('ldap_users_base'),
'is_club': False, 'is_club': False,
} }
assert user.to_dict() == expected assert user.to_dict() == expected

View File

@ -1,4 +1,6 @@
import ldap import ldap3
from tests.conftest import get_ldap_conn
def test_uwldap_get(uwldap_srv, uwldap_user): def test_uwldap_get(uwldap_srv, uwldap_user):
@ -13,14 +15,11 @@ def test_ldap_updateprograms(cfg, ldap_srv, uwldap_srv, ldap_user, uwldap_user):
# sanity check # sanity check
assert ldap_user.uid == uwldap_user.uid assert ldap_user.uid == uwldap_user.uid
# modify the user's program in UWLDAP # modify the user's program in UWLDAP
conn = ldap.initialize(cfg.get('uwldap_server_url')) conn = get_ldap_conn(cfg.get('uwldap_server_url'))
conn.sasl_gssapi_bind_s()
base_dn = cfg.get('uwldap_base') base_dn = cfg.get('uwldap_base')
dn = f'uid={uwldap_user.uid},{base_dn}' dn = f'uid={uwldap_user.uid},{base_dn}'
conn.modify_s(dn, ldap.modlist.modifyModlist( changes = {'ou': [(ldap3.MODIFY_REPLACE, ['New Program'])]}
{'ou': [uwldap_user.program.encode()]}, conn.modify(dn, changes)
{'ou': [b'New Program']},
))
assert ldap_srv.update_programs(dry_run=True) == [ assert ldap_srv.update_programs(dry_run=True) == [
(uwldap_user.uid, uwldap_user.program, 'New Program'), (uwldap_user.uid, uwldap_user.program, 'New Program'),

View File

@ -2,7 +2,7 @@ import importlib.resources
import os import os
import shutil import shutil
import ldap import ldap3
import pytest import pytest
import socket import socket
from zope import component from zope import component
@ -45,38 +45,40 @@ def krb_srv(cfg):
shutil.rmtree(cache_dir) shutil.rmtree(cache_dir)
def recursively_delete_subtree(conn: ldap.ldapobject.LDAPObject, base_dn: str): def delete_subtree(conn: ldap3.Connection, base_dn: str):
try: try:
records = conn.search_s(base_dn, ldap.SCOPE_ONELEVEL, attrlist=['']) conn.search(base_dn, '(objectClass=*)', search_scope=ldap3.LEVEL)
for dn, _ in records: for entry in conn.entries:
conn.delete_s(dn) conn.delete(entry.entry_dn)
conn.delete_s(base_dn) conn.delete(base_dn)
except ldap.NO_SUCH_OBJECT: except ldap3.core.exceptions.LDAPNoSuchObjectResult:
pass pass
def get_ldap_conn(server_url: str):
return ldap3.Connection(
server_url, auto_bind=True, raise_exceptions=True,
authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS)
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def ldap_srv(cfg, krb_srv): def ldap_srv(cfg, krb_srv):
conn = ldap.initialize(cfg.get('ldap_server_url')) conn = get_ldap_conn(cfg.get('ldap_server_url'))
conn.sasl_gssapi_bind_s()
users_base = cfg.get('ldap_users_base') users_base = cfg.get('ldap_users_base')
groups_base = cfg.get('ldap_groups_base') groups_base = cfg.get('ldap_groups_base')
recursively_delete_subtree(conn, users_base) delete_subtree(conn, users_base)
recursively_delete_subtree(conn, groups_base) delete_subtree(conn, groups_base)
for base_dn in [users_base, groups_base]: for base_dn in [users_base, groups_base]:
ou = base_dn.split(',', 1)[0].split('=')[1] ou = base_dn.split(',', 1)[0].split('=')[1]
conn.add_s(base_dn, ldap.modlist.addModlist({ conn.add(base_dn, 'organizationalUnit')
'objectClass': [b'organizationalUnit'],
'ou': [ou.encode()]
}))
_ldap_srv = LDAPService() _ldap_srv = LDAPService()
component.provideUtility(_ldap_srv, ILDAPService) component.provideUtility(_ldap_srv, ILDAPService)
yield _ldap_srv yield _ldap_srv
recursively_delete_subtree(conn, users_base) delete_subtree(conn, users_base)
recursively_delete_subtree(conn, groups_base) delete_subtree(conn, groups_base)
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
@ -118,22 +120,18 @@ def mailman_srv(mock_mailman_server, cfg, http_client):
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def uwldap_srv(cfg, ldap_srv): def uwldap_srv(cfg, ldap_srv):
conn = ldap.initialize(cfg.get('uwldap_server_url')) conn = get_ldap_conn(cfg.get('uwldap_server_url'))
conn.sasl_gssapi_bind_s()
base_dn = cfg.get('uwldap_base') base_dn = cfg.get('uwldap_base')
ou = base_dn.split(',', 1)[0].split('=')[1] ou = base_dn.split(',', 1)[0].split('=')[1]
recursively_delete_subtree(conn, base_dn) delete_subtree(conn, base_dn)
conn.add_s(base_dn, ldap.modlist.addModlist({ conn.add(base_dn, 'organizationalUnit')
'objectClass': [b'organizationalUnit'],
'ou': [ou.encode()]
}))
_uwldap_srv = UWLDAPService() _uwldap_srv = UWLDAPService()
component.provideUtility(_uwldap_srv, IUWLDAPService) component.provideUtility(_uwldap_srv, IUWLDAPService)
yield _uwldap_srv yield _uwldap_srv
recursively_delete_subtree(conn, base_dn) delete_subtree(conn, base_dn)
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
@ -219,8 +217,7 @@ def ldap_group(simple_group):
@pytest.fixture @pytest.fixture
def uwldap_user(cfg, uwldap_srv): def uwldap_user(cfg, uwldap_srv):
conn = ldap.initialize(cfg.get('uwldap_server_url')) conn = get_ldap_conn(cfg.get('uwldap_server_url'))
conn.sasl_gssapi_bind_s()
base_dn = cfg.get('uwldap_base') base_dn = cfg.get('uwldap_base')
user = UWLDAPRecord( user = UWLDAPRecord(
uid='test_jdoe', uid='test_jdoe',
@ -231,20 +228,21 @@ def uwldap_user(cfg, uwldap_srv):
given_name='John', given_name='John',
) )
dn = f'uid={user.uid},{base_dn}' dn = f'uid={user.uid},{base_dn}'
conn.add_s(dn, ldap.modlist.addModlist(strings_to_bytes({ conn.add(
'uid': [user.uid], dn,
'mailLocalAddress': user.mail_local_addresses, [
'ou': [user.program],
'cn': [user.cn],
'sn': [user.sn],
'givenName': [user.given_name],
'objectClass': [
'inetLocalMailRecipient', 'inetLocalMailRecipient',
'inetOrgPerson', 'inetOrgPerson',
'organizationalPerson', 'organizationalPerson',
'person', 'person',
'top',
], ],
}))) {
'mailLocalAddress': user.mail_local_addresses,
'ou': user.program,
'cn': user.cn,
'sn': user.sn,
'givenName': user.given_name,
},
)
yield user yield user
conn.delete_s(dn) conn.delete(dn)