Query Active Directory LDAP for alumni (#120)
continuous-integration/drone/push Build is passing Details

Closes #116.

UWLDAP has program information for current students, so we should continue using it by default.
If the sn attribute (last name) is missing from the entry, then we query ADLDAP instead.

Reviewed-on: #120
This commit is contained in:
Max Erenberg 2024-02-01 23:57:53 -05:00
parent bd1da799c6
commit a4a4ef089c
28 changed files with 353 additions and 43 deletions

17
.drone/adldap_data.ldif Normal file
View File

@ -0,0 +1,17 @@
dn: ou=ADLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: ADLDAP
dn: cn=alumni1,ou=ADLDAP,dc=csclub,dc=internal
description: One, Alumni
givenName: Alumni
sn: *One
cn: alumni1
sAMAccountName: alumni1
displayName: alumni1
mail: alumni1@alumni.uwaterloo.internal
objectClass: mockADUser
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top

View File

@ -31,13 +31,13 @@ IMAGE__setup_ldap() {
cp .drone/slapd.conf /etc/ldap/slapd.conf cp .drone/slapd.conf /etc/ldap/slapd.conf
cp .drone/ldap.conf /etc/ldap/ldap.conf cp .drone/ldap.conf /etc/ldap/ldap.conf
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
cp .drone/rfc2307bis.schema /etc/ldap/schema/ cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/
cp .drone/csc.schema /etc/ldap/schema/
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/ chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
sleep 0.5 && service slapd start sleep 0.5 && service slapd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:/// ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
if [ -z "$CI" ]; then if [ -z "$CI" ]; then
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
ldapadd -c -f .drone/adldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
# setup ldapvi for convenience # setup ldapvi for convenience
apt install -y --no-install-recommends vim ldapvi apt install -y --no-install-recommends vim ldapvi
grep -q 'export EDITOR' /root/.bashrc || \ grep -q 'export EDITOR' /root/.bashrc || \

View File

@ -186,3 +186,28 @@ objectClass: group
objectClass: posixGroup objectClass: posixGroup
cn: office1 cn: office1
gidNumber: 20004 gidNumber: 20004
dn: uid=alumni1,ou=People,dc=csclub,dc=internal
cn: Alumni One
givenName: Alumni
sn: One
userPassword: {SASL}alumni1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/alumni1
uid: alumni1
uidNumber: 20005
gidNumber: 20005
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: Alumni
term: w2024
dn: cn=alumni1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: alumni1
gidNumber: 20005

10
.drone/mock_ad.schema Normal file
View File

@ -0,0 +1,10 @@
# Mock Active Directory Schema
attributetype ( 1.3.6.1.4.1.70000.1.1.1 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
objectclass ( 1.3.6.1.4.1.70000.1.2.1 NAME 'mockADUser'
SUP top AUXILIARY
MUST ( sAMAccountName ) )

View File

@ -7,6 +7,7 @@ include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/sudo.schema include /etc/ldap/schema/sudo.schema
include /etc/ldap/schema/csc.schema include /etc/ldap/schema/csc.schema
include /etc/ldap/schema/mock_ad.schema
include /etc/ldap/schema/misc.schema include /etc/ldap/schema/misc.schema
pidfile /var/run/slapd/slapd.pid pidfile /var/run/slapd/slapd.pid
@ -40,6 +41,11 @@ access to *
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
by * break by * break
# hide most attributes for alumni in mock UWLDAP
access to attrs=cn,sn,givenName,displayName,ou,mail
dn.regex="^uid=alumni[^,]+,ou=(Test)?UWLDAP,dc=csclub,dc=internal$"
by * none
# systems committee get full access # systems committee get full access
access to * access to *
by dn="cn=ceod,dc=csclub,dc=internal" write by dn="cn=ceod,dc=csclub,dc=internal" write

View File

@ -106,3 +106,18 @@ objectClass: person
objectClass: top objectClass: top
uid: exec3 uid: exec3
mail: exec3@uwaterloo.internal mail: exec3@uwaterloo.internal
dn: uid=alumni1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Alumni One
givenName: Alumni
sn: One
cn: Alumni One
ou: MAT/Mathematics Computer Science
mailLocalAddress: alumni1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: alumni1
mail: alumni1@uwaterloo.internal

View File

@ -1,5 +1,4 @@
import os import os
import socket
import sys import sys
from zope import component from zope import component
@ -9,6 +8,7 @@ from .krb_check import krb_check
from .tui.start import main as tui_main from .tui.start import main as tui_main
from ceo_common.interfaces import IConfig, IHTTPClient from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient from ceo_common.model import Config, HTTPClient
from ceo_common.utils import is_in_development
def register_services(): def register_services():
@ -19,8 +19,7 @@ def register_services():
if 'CEO_CONFIG' in os.environ: if 'CEO_CONFIG' in os.environ:
config_file = os.environ['CEO_CONFIG'] config_file = os.environ['CEO_CONFIG']
else: else:
# This is a hack to determine if we're in the dev env or not if is_in_development():
if socket.getfqdn().endswith('.csclub.internal'):
config_file = './tests/ceo_dev.ini' config_file = './tests/ceo_dev.ini'
else: else:
config_file = '/etc/csc/ceo.ini' config_file = '/etc/csc/ceo.ini'

View File

@ -1,10 +1,9 @@
import socket
from typing import List, Tuple, Dict from typing import List, Tuple, Dict
import click import click
import requests import requests
from ceo_common.utils import is_in_development
from ..utils import space_colon_kv, generic_handle_stream_response from ..utils import space_colon_kv, generic_handle_stream_response
from .CLIStreamResponseHandler import CLIStreamResponseHandler from .CLIStreamResponseHandler import CLIStreamResponseHandler
@ -53,6 +52,6 @@ def handle_sync_response(resp: requests.Response):
def check_if_in_development() -> bool: def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment.""" """Aborts if we are not currently in the dev environment."""
if not socket.getfqdn().endswith('.csclub.internal'): if not is_in_development():
click.echo('This command may only be called during development.') click.echo('This command may only be called during development.')
raise Abort() raise Abort()

View File

@ -0,0 +1,18 @@
import typing
from typing import Optional
from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.ADLDAPRecord import ADLDAPRecord
class IADLDAPService(Interface):
"""Represents the AD LDAP database."""
def get_user(username: str) -> Optional['ADLDAPRecord']:
"""
Return the LDAP record for the given user, or
None if no such record exists.
"""

View File

@ -1,20 +1,23 @@
from typing import List, Union import typing
from typing import List, Optional
from zope.interface import Interface from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.UWLDAPRecord import UWLDAPRecord
class IUWLDAPService(Interface): class IUWLDAPService(Interface):
"""Represents the UW LDAP database.""" """Represents the UW LDAP database."""
def get_user(username: str): def get_user(username: str) -> Optional['UWLDAPRecord']:
""" """
Return the LDAP record for the given user, or Return the LDAP record for the given user, or
None if no such record exists. None if no such record exists.
:rtype: Union[UWLDAPRecord, None]
""" """
def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]: def get_programs_for_users(usernames: List[str]) -> List[Optional[str]]:
""" """
Return the programs for the given users from UWLDAP. Return the programs for the given users from UWLDAP.
If no record or program is found for a user, their entry in If no record or program is found for a user, their entry in

View File

@ -1,3 +1,4 @@
from .IADLDAPService import IADLDAPService
from .ICloudResourceManager import ICloudResourceManager from .ICloudResourceManager import ICloudResourceManager
from .ICloudStackService import ICloudStackService from .ICloudStackService import ICloudStackService
from .IKerberosService import IKerberosService from .IKerberosService import IKerberosService

View File

@ -0,0 +1,57 @@
import ldap3
from .UWLDAPRecord import UWLDAPRecord
class ADLDAPRecord:
"""Represents a record from the AD LDAP."""
# These are just the ones in which we're interested
ldap_attributes = [
'sAMAccountName',
'mail',
'description',
'sn',
'givenName',
]
def __init__(
self,
sam_account_name: str,
mail: str,
description: str,
sn: str,
given_name: str,
):
self.sam_account_name = sam_account_name
self.mail = mail
self.description = description
self.sn = sn
self.given_name = given_name
@staticmethod
def deserialize_from_ldap(entry: ldap3.Entry):
"""
Deserializes a dict returned from LDAP into an
ADLDAPRecord.
"""
return ADLDAPRecord(
sam_account_name=entry.sAMAccountName.value,
mail=entry.mail.value,
description=entry.description.value,
sn=entry.sn.value,
given_name=entry.givenName.value,
)
def to_uwldap_record(self) -> UWLDAPRecord:
"""Converts this AD LDAP record into a UW LDAP record."""
return UWLDAPRecord(
uid=self.sam_account_name,
mail_local_addresses=[self.mail],
program=None,
# The description attribute has the format "Lastname, Firstname"
cn=' '.join(self.description.split(', ')[::-1]),
# Alumni's last names are prepended with an asterisk
sn=(self.sn[1:] if self.sn[0] == '*' else self.sn),
given_name=self.given_name
)

View File

@ -1,4 +1,6 @@
from .ADLDAPRecord import ADLDAPRecord
from .Config import Config from .Config import Config
from .HTTPClient import HTTPClient from .HTTPClient import HTTPClient
from .RemoteMailmanService import RemoteMailmanService from .RemoteMailmanService import RemoteMailmanService
from .Term import Term from .Term import Term
from .UWLDAPRecord import UWLDAPRecord

View File

@ -1,6 +1,7 @@
import datetime import datetime
import re import re
from dataclasses import dataclass from dataclasses import dataclass
import socket
# TODO: disallow underscores. Will break many tests with usernames that include _ # TODO: disallow underscores. Will break many tests with usernames that include _
VALID_USERNAME_RE = re.compile(r"^[a-z][a-z0-9-_]+$") VALID_USERNAME_RE = re.compile(r"^[a-z][a-z0-9-_]+$")
@ -70,3 +71,8 @@ def validate_username(username: str) -> UsernameValidationResult:
if not VALID_USERNAME_RE.fullmatch(username): if not VALID_USERNAME_RE.fullmatch(username):
return UsernameValidationResult(False, 'Username is invalid') return UsernameValidationResult(False, 'Username is invalid')
return UsernameValidationResult(True) return UsernameValidationResult(True)
def is_in_development() -> bool:
"""This is a hack to determine if we're in the dev env or not"""
return socket.getfqdn().endswith('.csclub.internal')

View File

@ -9,13 +9,13 @@ from .error_handlers import register_error_handlers
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \ from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \ IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \
ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \ ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \
IContainerRegistryService, IClubWebHostingService IContainerRegistryService, IClubWebHostingService, IADLDAPService
from ceo_common.model import Config, HTTPClient, RemoteMailmanService from ceo_common.model import Config, HTTPClient, RemoteMailmanService
from ceod.api.spnego import init_spnego from ceod.api.spnego import init_spnego
from ceod.model import KerberosService, LDAPService, FileService, \ from ceod.model import KerberosService, LDAPService, FileService, \
MailmanService, MailService, UWLDAPService, CloudStackService, \ MailmanService, MailService, UWLDAPService, CloudStackService, \
CloudResourceManager, KubernetesService, VHostManager, \ CloudResourceManager, KubernetesService, VHostManager, \
ContainerRegistryService, ClubWebHostingService ContainerRegistryService, ClubWebHostingService, ADLDAPService
from ceod.db import MySQLService, PostgreSQLService from ceod.db import MySQLService, PostgreSQLService
@ -36,6 +36,15 @@ def create_app(flask_config={}):
from ceod.api import members from ceod.api import members
app.register_blueprint(members.bp, url_prefix='/api/members') app.register_blueprint(members.bp, url_prefix='/api/members')
from ceod.api import groups
app.register_blueprint(groups.bp, url_prefix='/api/groups')
from ceod.api import positions
app.register_blueprint(positions.bp, url_prefix='/api/positions')
from ceod.api import uwldap
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
# Only offer mailman API if this host is running Mailman # Only offer mailman API if this host is running Mailman
if hostname == cfg.get('ceod_mailman_host'): if hostname == cfg.get('ceod_mailman_host'):
from ceod.api import mailman from ceod.api import mailman
@ -53,15 +62,6 @@ def create_app(flask_config={}):
from ceod.api import webhosting from ceod.api import webhosting
app.register_blueprint(webhosting.bp, url_prefix='/api/webhosting') app.register_blueprint(webhosting.bp, url_prefix='/api/webhosting')
from ceod.api import groups
app.register_blueprint(groups.bp, url_prefix='/api/groups')
from ceod.api import positions
app.register_blueprint(positions.bp, url_prefix='/api/positions')
from ceod.api import uwldap
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
register_error_handlers(app) register_error_handlers(app)
@app.route('/ping') @app.route('/ping')
@ -112,9 +112,13 @@ def register_services(app):
mail_srv = MailService() mail_srv = MailService()
component.provideUtility(mail_srv, IMailService) component.provideUtility(mail_srv, IMailService)
# UWLDAPService # UWLDAPService, ADLDAPService
uwldap_srv = UWLDAPService() if hostname == cfg.get('ceod_admin_host'):
component.provideUtility(uwldap_srv, IUWLDAPService) uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
adldap_srv = ADLDAPService()
component.provideUtility(adldap_srv, IADLDAPService)
# ClubWebHostingService # ClubWebHostingService
if hostname == cfg.get('ceod_webhosting_host'): if hostname == cfg.get('ceod_webhosting_host'):

View File

@ -3,15 +3,25 @@ from flask.json import jsonify
from zope import component from zope import component
from .utils import authz_restrict_to_syscom, is_truthy from .utils import authz_restrict_to_syscom, is_truthy
from ceo_common.interfaces import IUWLDAPService, ILDAPService from ceo_common.interfaces import IUWLDAPService, IADLDAPService, ILDAPService
from ceo_common.logger_factory import logger_factory
bp = Blueprint('uwldap', __name__) bp = Blueprint('uwldap', __name__)
logger = logger_factory(__name__)
@bp.route('/<username>') @bp.route('/<username>')
def get_user(username: str): def get_user(username: str):
uwldap_srv = component.getUtility(IUWLDAPService) uwldap_srv = component.getUtility(IUWLDAPService)
record = uwldap_srv.get_user(username) record = uwldap_srv.get_user(username)
if record is not None and not record.sn:
# Alumni are missing a lot of information in UWLDAP.
# Try AD LDAP instead
logger.debug('Querying %s from AD LDAP', username)
adldap_srv = component.getUtility(IADLDAPService)
ad_record = adldap_srv.get_user(username)
if ad_record is not None:
record = ad_record.to_uwldap_record()
if record is None: if record is None:
return { return {
'error': 'user not found', 'error': 'user not found',

View File

@ -0,0 +1,57 @@
from typing import Optional
import dns.resolver
import ldap3
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IADLDAPService, IConfig
from ceo_common.logger_factory import logger_factory
from ceo_common.model import ADLDAPRecord
from ceo_common.utils import is_in_development
logger = logger_factory(__name__)
@implementer(IADLDAPService)
class ADLDAPService:
def __init__(self):
cfg = component.getUtility(IConfig)
if is_in_development():
self.adldap_dns_srv_name = None
self.adldap_server_url = cfg.get('adldap_server_url')
else:
self.adldap_dns_srv_name = cfg.get('adldap_dns_srv_name')
# Perform the actual DNS query later so that we don't delay startup
self.adldap_server_url = None
self.adldap_base = cfg.get('adldap_base')
def _get_server_url(self) -> str:
assert self.adldap_dns_srv_name is not None
answers = dns.resolver.resolve(self.adldap_dns_srv_name, 'SRV')
target = answers[0].target.to_text()
# Strip the trailing '.'
target = target[:-1]
logger.debug('Using AD LDAP server %s', target)
# ldaps doesn't seem to work
return 'ldap://' + target
def _get_conn(self) -> ldap3.Connection:
if self.adldap_server_url is None:
self.adldap_server_url = self._get_server_url()
# When get_info=ldap3.SCHEMA (the default), ldap3 tries to search
# for schema information in 'CN=Schema,CN=Configuration,DC=ds,DC=uwaterloo,DC=ca',
# which doesn't exist so a LDAPNoSuchObjectResult exception is raised.
# To avoid this, we tell ldap3 not to look up any server info.
server = ldap3.Server(self.adldap_server_url, get_info=ldap3.NONE)
return ldap3.Connection(
server, auto_bind=True, read_only=True, raise_exceptions=True)
def get_user(self, username: str) -> Optional[ADLDAPRecord]:
conn = self._get_conn()
conn.search(
self.adldap_base, f'(sAMAccountName={username})',
attributes=ADLDAPRecord.ldap_attributes, size_limit=1)
if not conn.entries:
return None
return ADLDAPRecord.deserialize_from_ldap(conn.entries[0])

View File

@ -1,11 +1,11 @@
from typing import Union, List from typing import List, Optional
import ldap3 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 ceo_common.interfaces import IUWLDAPService, IConfig from ceo_common.interfaces import IUWLDAPService, IConfig
from ceo_common.model import UWLDAPRecord
@implementer(IUWLDAPService) @implementer(IUWLDAPService)
@ -20,7 +20,7 @@ class UWLDAPService:
self.uwldap_server_url, auto_bind=True, read_only=True, self.uwldap_server_url, auto_bind=True, read_only=True,
raise_exceptions=True) raise_exceptions=True)
def get_user(self, username: str) -> Union[UWLDAPRecord, None]: def get_user(self, username: str) -> Optional[UWLDAPRecord]:
conn = self._get_conn() conn = self._get_conn()
conn.search( conn.search(
self.uwldap_base, f'(uid={username})', self.uwldap_base, f'(uid={username})',
@ -29,7 +29,7 @@ class UWLDAPService:
return None return None
return UWLDAPRecord.deserialize_from_ldap(conn.entries[0]) return UWLDAPRecord.deserialize_from_ldap(conn.entries[0])
def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]: def get_programs_for_users(self, usernames: List[str]) -> List[Optional[str]]:
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)}

View File

@ -1,3 +1,4 @@
from .ADLDAPService import ADLDAPService
from .CloudResourceManager import CloudResourceManager from .CloudResourceManager import CloudResourceManager
from .CloudStackService import CloudStackService from .CloudStackService import CloudStackService
from .KerberosService import KerberosService from .KerberosService import KerberosService
@ -5,7 +6,6 @@ from .LDAPService import LDAPService, UserNotFoundError, GroupNotFoundError
from .User import User from .User import User
from .Group import Group from .Group import Group
from .UWLDAPService import UWLDAPService from .UWLDAPService import UWLDAPService
from .UWLDAPRecord import UWLDAPRecord
from .FileService import FileService from .FileService import FileService
from .SudoRole import SudoRole from .SudoRole import SudoRole
from .MailService import MailService from .MailService import MailService

View File

@ -1,15 +1,16 @@
version: "3.6" version: "3.6"
x-common: &common x-common: &common
image: ceo-generic:bullseye
volumes: volumes:
- ceo-venv:/app/venv:ro
- ./.drone:/app/.drone:ro - ./.drone:/app/.drone:ro
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh:ro - ./docker-entrypoint.sh:/app/docker-entrypoint.sh:ro
- ceo-venv:/app/venv:ro
- ./ceo:/app/ceo:ro - ./ceo:/app/ceo:ro
- ./ceo_common:/app/ceo_common:ro - ./ceo_common:/app/ceo_common:ro
- ./ceod:/app/ceod:ro - ./ceod:/app/ceod:ro
- ./tests:/app/tests:ro - ./tests:/app/tests:ro
# for flake8
- ./setup.cfg:/app/setup.cfg:ro
security_opt: security_opt:
- label:disable - label:disable
working_dir: /app working_dir: /app
@ -18,6 +19,7 @@ x-common: &common
x-ceod-common: &ceod-common x-ceod-common: &ceod-common
<<: *common <<: *common
image: ceo-generic:bullseye
environment: environment:
FLASK_APP: ceod.api FLASK_APP: ceod.api
FLASK_DEBUG: "true" FLASK_DEBUG: "true"

View File

@ -30,6 +30,10 @@ sudo_base = ou=SUDOers,dc=csclub,dc=uwaterloo,dc=ca
server_url = ldaps://uwldap.uwaterloo.ca server_url = ldaps://uwldap.uwaterloo.ca
base = dc=uwaterloo,dc=ca base = dc=uwaterloo,dc=ca
[adldap]
dns_srv_name = _ldap._tcp.teaching.ds.uwaterloo.ca
base = dc=teaching,dc=ds,dc=uwaterloo,dc=ca
[members] [members]
min_id = 20001 min_id = 20001
max_id = 29999 max_id = 29999

View File

@ -1,5 +1,6 @@
click==8.1.6 click==8.1.6
cryptography==41.0.2 cryptography==41.0.2
dnspython==2.5.0
Flask==2.3.2 Flask==2.3.2
gssapi==1.8.2 gssapi==1.8.2
gunicorn==21.2.0 gunicorn==21.2.0

View File

@ -35,6 +35,7 @@ if ! $DOCKER volume exists ceo-venv; then
$DOCKER volume create ceo-venv $DOCKER volume create ceo-venv
$DOCKER run \ $DOCKER run \
--rm \ --rm \
--security-opt label=disable \
-w /app/venv \ -w /app/venv \
-v ceo-venv:/app/venv:z \ -v ceo-venv:/app/venv:z \
-v ./requirements.txt:/tmp/requirements.txt:ro \ -v ./requirements.txt:/tmp/requirements.txt:ro \

View File

@ -50,3 +50,19 @@ def test_updateprograms(
# make sure that the user was changed # make sure that the user was changed
status, data = client.get(f'/api/members/{uwldap_user.uid}') status, data = client.get(f'/api/members/{uwldap_user.uid}')
assert data['program'] == 'New Program' assert data['program'] == 'New Program'
def test_get_alumni(client):
uid = 'alumni1'
status, data = client.get(f'/api/uwldap/{uid}')
assert status == 200
expected = {
"cn": 'Alumni One',
"given_name": 'Alumni',
"sn": 'One',
# This should be the email address from AD LDAP
"mail_local_addresses": [f'{uid}@alumni.uwaterloo.internal'],
# Program attribute should be missing
"uid": uid,
}
assert data == expected

View File

@ -27,6 +27,10 @@ sudo_base = ou=SUDOers,dc=csclub,dc=internal
server_url = ldap://auth1.csclub.internal server_url = ldap://auth1.csclub.internal
base = ou=UWLDAP,dc=csclub,dc=internal base = ou=UWLDAP,dc=csclub,dc=internal
[adldap]
server_url = ldap://auth1.csclub.internal
base = ou=ADLDAP,dc=csclub,dc=internal
[members] [members]
min_id = 20001 min_id = 20001
max_id = 29999 max_id = 29999

View File

@ -26,6 +26,10 @@ sudo_base = ou=TestSUDOers,dc=csclub,dc=internal
server_url = ldap://auth1.csclub.internal server_url = ldap://auth1.csclub.internal
base = ou=TestUWLDAP,dc=csclub,dc=internal base = ou=TestUWLDAP,dc=csclub,dc=internal
[adldap]
server_url = ldap://auth1.csclub.internal
base = ou=TestADLDAP,dc=csclub,dc=internal
[members] [members]
# 20000 is ctdalek, 20001 is office1 # 20000 is ctdalek, 20001 is office1
min_id = 20002 min_id = 20002

View File

@ -30,15 +30,16 @@ from .utils import ( # noqa: F401
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \ from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService, \ IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService, \
IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \ IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \
ICloudResourceManager, IContainerRegistryService, IClubWebHostingService ICloudResourceManager, IContainerRegistryService, IClubWebHostingService, \
from ceo_common.model import Config, HTTPClient, Term IADLDAPService
from ceo_common.model import Config, HTTPClient, Term, UWLDAPRecord
import ceo_common.utils import ceo_common.utils
from ceod.api import create_app from ceod.api import create_app
from ceod.db import MySQLService, PostgreSQLService from ceod.db import MySQLService, PostgreSQLService
from ceod.model import KerberosService, LDAPService, FileService, User, \ from ceod.model import KerberosService, LDAPService, FileService, User, \
MailmanService, Group, UWLDAPService, UWLDAPRecord, MailService, \ MailmanService, Group, UWLDAPService, MailService, \
CloudStackService, KubernetesService, VHostManager, CloudResourceManager, \ CloudStackService, KubernetesService, VHostManager, CloudResourceManager, \
ContainerRegistryService, ClubWebHostingService ContainerRegistryService, ClubWebHostingService, ADLDAPService
from .MockSMTPServer import MockSMTPServer from .MockSMTPServer import MockSMTPServer
from .MockMailmanServer import MockMailmanServer from .MockMailmanServer import MockMailmanServer
from .MockCloudStackServer import MockCloudStackServer from .MockCloudStackServer import MockCloudStackServer
@ -309,6 +310,17 @@ def uwldap_srv(cfg, ldap_conn):
'givenName': 'Calum', 'givenName': 'Calum',
}, },
) )
conn.add(
f'uid=alumni1,{base_dn}',
['inetLocalMailRecipient', 'inetOrgPerson', 'organizationalPerson', 'person'],
{
'mailLocalAddress': 'alumni1@uwaterloo.internal',
'ou': 'Alumni',
'cn': 'Alumni',
'sn': 'One',
'givenName': 'Alumni',
},
)
_uwldap_srv = UWLDAPService() _uwldap_srv = UWLDAPService()
component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService) component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService)
yield _uwldap_srv yield _uwldap_srv
@ -316,6 +328,31 @@ def uwldap_srv(cfg, ldap_conn):
delete_subtree(conn, base_dn) delete_subtree(conn, base_dn)
@pytest.fixture(scope='session')
def adldap_srv(cfg, ldap_conn):
conn = ldap_conn
base_dn = cfg.get('adldap_base')
delete_subtree(conn, base_dn)
conn.add(base_dn, 'organizationalUnit')
conn.add(
f'cn=alumni1,{base_dn}',
['mockADUser', 'inetOrgPerson', 'organizationalPerson', 'person'],
{
'description': 'One, Alumni',
'givenName': 'Alumni',
'sn': '*One',
'cn': 'alumni1',
'sAMAccountName': 'alumni1',
'displayName': 'alumni1',
'mail': 'alumni1@alumni.uwaterloo.internal',
},
)
_adldap_srv = ADLDAPService()
component.getGlobalSiteManager().registerUtility(_adldap_srv, IADLDAPService)
yield _adldap_srv
delete_subtree(conn, base_dn)
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def webhosting_srv(): def webhosting_srv():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@ -376,14 +413,25 @@ def postgresql_srv(cfg):
return psql_srv return psql_srv
def delete_dir_contents(dir: str):
if os.path.isdir(dir):
for item in os.listdir(dir):
path = os.path.join(dir, item)
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.unlink(path)
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def vhost_dir_setup(cfg): def vhost_dir_setup(cfg):
state_dir = '/run/ceod' state_dir = '/run/ceod'
if os.path.isdir(state_dir): # Don't delete the directory itself because the non-test ceod process
shutil.rmtree(state_dir) # is using it
os.makedirs(state_dir) delete_dir_contents(state_dir)
os.makedirs(state_dir, exist_ok=True)
yield yield
shutil.rmtree(state_dir) delete_dir_contents(state_dir)
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
@ -422,6 +470,7 @@ def app(
file_srv, file_srv,
mailman_srv, mailman_srv,
uwldap_srv, uwldap_srv,
adldap_srv,
mail_srv, mail_srv,
mysql_srv, mysql_srv,
postgresql_srv, postgresql_srv,