diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5e9bbd5cd694559670f5820e7f7c0f915c07b753..0efc6c0c78300fc7b7aa811a4b83b49ab6a23ed5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,7 +19,7 @@ build: - shared-fi before_script: - echo exit 0 > /usr/sbin/policy-rc.d - - apt update && apt install -y redis-server + - apt update && apt install -y redis-server ldap-utils ldapscripts libldap2-dev libsasl2-dev - service redis-server start - python -V # Print out python version for debugging - pip install pipenv diff --git a/Pipfile b/Pipfile index d38782c31f01885d67f83d21b128592abfd19e82..123122bb5d3cdd5b0abf66ecec6906d0154a4e02 100644 --- a/Pipfile +++ b/Pipfile @@ -22,6 +22,7 @@ python-gitlab = "*" psycopg2-binary = "*" flask-restplus = "*" celery = {extras = ["auth", "yaml", "msgpack", "redis"]} +python-ldap = "*" [dev-packages] pytest-cov = "*" diff --git a/portal/__init__.py b/portal/__init__.py index ea08b93e76b2ff0fbc2ccd9ba085d477830205bf..a17d3ce2f6877efbda807ad7b34ff3ca834f731c 100644 --- a/portal/__init__.py +++ b/portal/__init__.py @@ -17,6 +17,7 @@ from storage import Storage from portal import rest from portal.config import CONFIGURATIONS from portal.tools.gitlab_client import GitlabFactory +from portal.tools.ldap_client import LDAPWrapper from portal.tools.paths import ROOT_DIR db = SQLAlchemy() @@ -26,6 +27,7 @@ cors = CORS(supports_credentials=True) storage = Storage() migrate = Migrate(db=db) gitlab_factory = GitlabFactory() +ldap_wrapper = LDAPWrapper() log = logging.getLogger(__name__) @@ -65,7 +67,7 @@ def configure_storage(app: Flask) -> Flask: test_files_dir=app.config.get('PORTAL_STORAGE_TEST_FILES_DIR'), submissions_dir=app.config.get('PORTAL_STORAGE_SUBMISSIONS_DIR'), workspace_dir=app.config.get('PORTAL_STORAGE_WORKSPACE_DIR') - ) + ) storage.init_storage(**storage_config) return app @@ -84,6 +86,7 @@ def configure_extensions(app: Flask): oauth.init_app(app) cors.init_app(app) gitlab_factory.init_app(app) + ldap_wrapper.init_app(app) return app diff --git a/portal/service/users.py b/portal/service/users.py index 941ca64d19b6541b1dac1d87d1b548ad586fc15a..528e03a619f9b4ab2817130d2c1d0fbd4d869aec 100644 --- a/portal/service/users.py +++ b/portal/service/users.py @@ -5,6 +5,7 @@ Users service import logging from typing import List +from portal import ldap_wrapper from portal.database.models import Course, Group, Project, Review, Role, Submission, User from portal.service import emails, general from portal.service.errors import IncorrectCredentialsError, PortalAPIError @@ -29,14 +30,36 @@ def create_user(**data) -> User: Returns(User): Created user instance """ data['is_admin'] = data.get('is_admin', False) - new_user = User(username=data['username']) + username = data['username'] + new_user = User(username=username) __set_user_data(new_user, data, True) + + if data['uco'] is None and ldap_wrapper.is_enabled: + new_user.uco = ldap_get_uco_for_user(username) + emails.notify_user(new_user, 'users', 'created', - context=dict(username=new_user.username)) + context=dict(username=username)) log.info(f"[CREATE] User: {new_user}") return new_user +def ldap_get_uco_for_user(username: str) -> int: + """Get uco from the ldap + Args: + username(str): Username + Returns(int): User's uco + + """ + user_dict = ldap_wrapper.search_dict(f"uid={username},ou=People") + if not user_dict: + return 0 + desc = user_dict[0]['description'] + uco = [u for u in desc if u.startswith('UCO=')] + if not uco: + return 0 + return int(uco[0][4:]) + + def update_user(user: User, data: dict, full: bool = False) -> User: """Updates a user @@ -117,8 +140,8 @@ def find_users_filtered(course_id: str = None, course = find_course(course_id) users = users.join(User.groups).filter( ((Group.id == group_id) | (Group.name == group_id)) & ( - course.id == Group.course_id) - ) + course.id == Group.course_id) + ) return users.all() @@ -187,7 +210,7 @@ def find_submissions_filtered(user: User, course_id: str = None, submissions = submissions.filter( ((Project.id.in_(project_ids)) | (Project.name.in_(project_ids))) & (Project.course == course) - ) + ) submissions = submissions.join(Course).filter( (Course.id == course_id) | (Course.codename == course_id)) return submissions diff --git a/portal/tools/ldap_client.py b/portal/tools/ldap_client.py new file mode 100644 index 0000000000000000000000000000000000000000..c69d5618428b62a8cd44789d05254f5ec6bf6088 --- /dev/null +++ b/portal/tools/ldap_client.py @@ -0,0 +1,66 @@ +""" +LDAP integration module +""" +from flask import Flask +import ldap +from ldap.ldapobject import LDAPObject +import logging + +log = logging.getLogger(__name__) + + +class LDAPWrapper(object): + def __init__(self, app: Flask = None): + self.flask_app = None + self._ldap_client = None + self._selector_base = ',dc=fi,dc=muni,dc=cz' + if app: + self.init_app(app) + + def init_app(self, app: Flask): + self.flask_app = app + self._ldap_client = None + return app + + @property + def ldap_url(self) -> str: + """Returns the LDAP server URL + Returns(str): URL to the LDAP server + """ + return self.flask_app.config.get('LDAP_URL', None) + + @property + def is_enabled(self) -> bool: + """Whether the LDAP extension is enabled + + LDAP is enabled when LDAP url is set + Returns(bool): true if is enabled, otherwise false + """ + return self.ldap_url is not None + + @property + def selector_base(self) -> str: + """Base selector, for FI muni it is: 'dc=fi,dc=muni,dc=cz' + + Returns(str): Selector base string + """ + return self._selector_base + + @property + def ldap(self) -> LDAPObject: + """LDAP object instance + Returns(LDAPObject): LDAP Object instance + """ + if not self._ldap_client and self.is_enabled: + self._ldap_client = ldap.initialize(self.ldap_url) + return self._ldap_client + + def search(self, selector: str): + selector = selector + self.selector_base + return self.ldap.search_s(selector, ldap.SCOPE_SUBTREE, "objectclass=*") + + def search_dict(self, *args, **kwargs): + l_res = self.search(*args, **kwargs) + if not l_res: + return None + return [rec[1] for rec in l_res]