You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
294 lines
11 KiB
294 lines
11 KiB
import contextlib
|
|
import grp
|
|
import pwd
|
|
from typing import Union, Dict, List
|
|
|
|
from flask import g
|
|
import gssapi
|
|
import ldap3
|
|
from zope import component
|
|
from zope.interface import implementer
|
|
|
|
from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \
|
|
UserAlreadyExistsError, GroupAlreadyExistsError
|
|
from ceo_common.interfaces import ILDAPService, IConfig, \
|
|
IUser, IGroup, IUWLDAPService
|
|
from .User import User
|
|
from .Group import Group
|
|
|
|
|
|
@implementer(ILDAPService)
|
|
class LDAPService:
|
|
def __init__(self):
|
|
cfg = component.getUtility(IConfig)
|
|
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_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_max_id = cfg.get('members_max_id')
|
|
self.club_min_id = cfg.get('clubs_min_id')
|
|
self.club_max_id = cfg.get('clubs_max_id')
|
|
|
|
def _get_ldap_conn(self) -> ldap3.Connection:
|
|
if 'ldap_conn' in g:
|
|
return g.ldap_conn
|
|
kwargs = {'auto_bind': True, 'raise_exceptions': True}
|
|
if 'client_token' in g:
|
|
kwargs['authentication'] = ldap3.SASL
|
|
kwargs['sasl_mechanism'] = ldap3.KERBEROS
|
|
creds = gssapi.Credentials(token=g.client_token)
|
|
# see https://github.com/cannatag/ldap3/blob/master/ldap3/protocol/sasl/kerberos.py
|
|
kwargs['sasl_credentials'] = (None, None, creds)
|
|
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
|
|
# cache the connection for a single request
|
|
g.ldap_conn = conn
|
|
return conn
|
|
|
|
def _get_readable_entry_for_user(self, conn: ldap3.Connection, username: str) -> ldap3.Entry:
|
|
base = self.uid_to_dn(username)
|
|
try:
|
|
conn.search(
|
|
base, '(objectClass=*)', search_scope=ldap3.BASE,
|
|
attributes=ldap3.ALL_ATTRIBUTES)
|
|
except ldap3.core.exceptions.LDAPNoSuchObjectResult:
|
|
raise UserNotFoundError(username)
|
|
return conn.entries[0]
|
|
|
|
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(cn)
|
|
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:
|
|
conn = self._get_ldap_conn()
|
|
entry = self._get_readable_entry_for_user(conn, username)
|
|
return User.deserialize_from_ldap(entry)
|
|
|
|
def get_group(self, cn: str) -> IGroup:
|
|
conn = self._get_ldap_conn()
|
|
entry = self._get_readable_entry_for_group(conn, cn)
|
|
return Group.deserialize_from_ldap(entry)
|
|
|
|
def get_display_info_for_users(self, usernames: List[str]) -> List[Dict[str, str]]:
|
|
if not usernames:
|
|
return []
|
|
conn = self._get_ldap_conn()
|
|
filter = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
|
|
attributes = ['uid', 'cn', 'program']
|
|
conn.search(self.ldap_users_base, filter, attributes=attributes)
|
|
return [
|
|
{
|
|
'uid': entry.uid.value,
|
|
'cn': entry.cn.value,
|
|
'program': entry.program.value or 'Unknown',
|
|
}
|
|
for entry in conn.entries
|
|
]
|
|
|
|
def get_users_with_positions(self) -> List[IUser]:
|
|
conn = self._get_ldap_conn()
|
|
conn.search(self.ldap_users_base, '(position=*)', attributes=ldap3.ALL_ATTRIBUTES)
|
|
return [User.deserialize_from_ldap(entry) for entry in conn.entries]
|
|
|
|
def uid_to_dn(self, uid: str):
|
|
return f'uid={uid},{self.ldap_users_base}'
|
|
|
|
def group_cn_to_dn(self, cn: str):
|
|
return f'cn={cn},{self.ldap_groups_base}'
|
|
|
|
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."""
|
|
def uid_exists(uid: int) -> bool:
|
|
try:
|
|
pwd.getpwuid(uid)
|
|
return True
|
|
except KeyError:
|
|
return False
|
|
|
|
def gid_exists(gid: int) -> bool:
|
|
try:
|
|
grp.getgrgid(gid)
|
|
return True
|
|
except KeyError:
|
|
return False
|
|
|
|
def ldap_uid_or_gid_exists(uid: int) -> bool:
|
|
return conn.search(
|
|
self.ldap_users_base,
|
|
f'(|(uidNumber={uid})(gidNumber={uid}))',
|
|
size_limit=1)
|
|
|
|
# TODO: replace this with binary search
|
|
for uid in range(min_id, max_id + 1):
|
|
if uid_exists(uid) or gid_exists(uid) or ldap_uid_or_gid_exists(uid):
|
|
continue
|
|
return uid
|
|
raise Exception('no UIDs remaining')
|
|
|
|
def add_sudo_role(self, uid: str):
|
|
conn = self._get_ldap_conn()
|
|
obj_def = ldap3.ObjectDef(['sudoRole'], conn)
|
|
writer = ldap3.Writer(conn, obj_def)
|
|
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):
|
|
conn = self._get_ldap_conn()
|
|
dn = f'cn=%{uid},{self.ldap_sudo_base}'
|
|
conn.delete(dn)
|
|
|
|
def add_user(self, user: IUser):
|
|
object_classes = ['top', 'account', 'posixAccount', 'shadowAccount']
|
|
if user.is_club():
|
|
min_id, max_id = self.club_min_id, self.club_max_id
|
|
object_classes.append('club')
|
|
else:
|
|
assert user.given_name and user.sn, \
|
|
'First name and last name must be specified for new members'
|
|
assert user.terms or user.non_member_terms, \
|
|
'terms and non_member_terms cannot both be empty'
|
|
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()
|
|
obj_def = ldap3.ObjectDef(object_classes, conn)
|
|
uid_number = self._get_next_uid(conn, min_id, max_id)
|
|
user.uid_number = uid_number
|
|
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 user.is_club_rep:
|
|
entry.isClubRep = True
|
|
if not user.is_club():
|
|
entry.givenName = user.given_name
|
|
entry.sn = user.sn
|
|
entry.userPassword = '{SASL}%s@%s' % (user.uid, self.ldap_sasl_realm)
|
|
|
|
try:
|
|
writer.commit()
|
|
except ldap3.core.exceptions.LDAPEntryAlreadyExistsResult:
|
|
raise UserAlreadyExistsError()
|
|
|
|
@contextlib.contextmanager
|
|
def entry_ctx_for_user(self, user: IUser):
|
|
entry = self._get_writable_entry_for_user(user)
|
|
yield entry
|
|
entry.entry_commit_changes()
|
|
|
|
def remove_user(self, user: IUser):
|
|
conn = self._get_ldap_conn()
|
|
conn.delete(self.uid_to_dn(user.uid))
|
|
|
|
def add_group(self, group: IGroup) -> IGroup:
|
|
conn = self._get_ldap_conn()
|
|
# make sure that the caller initialized the GID number
|
|
assert group.gid_number
|
|
obj_def = ldap3.ObjectDef(['group', 'posixGroup'], conn)
|
|
writer = ldap3.Writer(conn, obj_def)
|
|
entry = writer.new(self.group_cn_to_dn(group.cn))
|
|
|
|
entry.cn = group.cn
|
|
entry.gidNumber = group.gid_number
|
|
if group.members:
|
|
entry.uniqueMember = [self.uid_to_dn(uid) for uid in group.members]
|
|
if group.description:
|
|
entry.description = group.description
|
|
|
|
try:
|
|
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):
|
|
conn = self._get_ldap_conn()
|
|
conn.delete(self.group_cn_to_dn(group.cn))
|
|
|
|
def update_programs(
|
|
self,
|
|
dry_run: bool = False,
|
|
members: Union[List[str], None] = None,
|
|
uwldap_batch_size: int = 10,
|
|
):
|
|
if members:
|
|
filter = '(|' + ''.join([f'(uid={uid})' for uid in members]) + ')'
|
|
else:
|
|
filter = '(objectClass=*)'
|
|
conn = self._get_ldap_conn()
|
|
conn.search(
|
|
self.ldap_users_base, filter, attributes=['uid', 'program'])
|
|
uids = [entry.uid.value for entry in conn.entries]
|
|
csc_programs = [entry.program.value for entry in conn.entries]
|
|
|
|
uwldap_srv = component.getUtility(IUWLDAPService)
|
|
uw_programs = []
|
|
# 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):
|
|
batch_uids = uids[i:i + uwldap_batch_size]
|
|
batch_uw_programs = uwldap_srv.get_programs_for_users(batch_uids)
|
|
uw_programs.extend(batch_uw_programs)
|
|
users_to_change = [
|
|
(uids[i], csc_programs[i], uw_programs[i])
|
|
for i in range(len(uids))
|
|
if csc_programs[i] != uw_programs[i] and (
|
|
uw_programs[i] not in (None, 'expired', 'orphaned')
|
|
)
|
|
]
|
|
if dry_run:
|
|
return users_to_change
|
|
|
|
for uid, old_program, new_program in users_to_change:
|
|
changes = {'program': [(ldap3.MODIFY_REPLACE, [new_program])]}
|
|
conn.modify(self.uid_to_dn(uid), changes)
|
|
|
|
return users_to_change
|
|
|