diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..e38c875 --- /dev/null +++ b/README.rst @@ -0,0 +1,16 @@ +Primus2AzureAD +============== + +Työkalu Primus-järjestelmän käyttäjätietojen siirtämiseksi Azure AD -palveluun. + +Käyttö +------ + +1. Ota käyttöön PrimusQuery-työkalu ja konfiguroi se tuottamaan tarvittavat + tiedot CSV-tiedostoiksi. Tarvitset käyttäjälistauksen ja oppiryhmät + (pkhenk-rekisteri). +2. Konfiguroi Primus2Azure -työkalu. Ohjeet ovat doc/configuration.rst + -tiedostossa. +3. Suorita Primus2Azure-työkalu ja tarkista Azure AD:sta käyttäjätilien + siirron tulokset. + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py new file mode 100644 index 0000000..9df969d --- /dev/null +++ b/app.py @@ -0,0 +1,54 @@ + +# -*- coding: utf-8 -*- +# +# Copyright Haltu Oy, info@haltu.fi + +#This program is free software: you can redistribute it and/or modify +#it under the terms of the GNU General Public License as published by +#the Free Software Foundation, either version 3 of the License, or +#(at your option) any later version. + +#This program is distributed in the hope that it will be useful, +#but WITHOUT ANY WARRANTY; without even the implied warranty of +#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +#GNU General Public License for more details. + +#You should have received a copy of the GNU General Public License +#along with this program. If not, see . + +import sys +import os +import logging +from dataparser import Parser +from azure import AzureAPI +from config import Config + +CONFIG = Config() +logging.basicConfig(level=getattr(logging, CONFIG.get('app', 'loglevel').upper())) +LOG = logging.getLogger(__name__) + +parser = Parser() +azure = AzureAPI(CONFIG.get('azure', 'domain'), CONFIG.get('azure', 'client_id'), CONFIG.get('azure', 'client_secret')) + +# if the app is frozen using cx_freeze, we need to tell requests where to find +# the certificate bundle +if getattr(sys, 'frozen', False): + # The application is frozen + os.environ["REQUESTS_CA_BUNDLE"] = os.path.join(os.path.dirname(sys.executable), "cacert.pem") + +for u in parser.parse(CONFIG.get('input', 'input_file')): + try: + azure.update_user(u, create=True) + except: + LOG.error('update_user failed', exc_info=True) + +# Parse course file if it exists +if os.path.isfile(CONFIG.get('input', 'course_file')): + for course in parser.parse_course(CONFIG.get('input', 'course_file')): + try: + azure.create_course(course) + except: + LOG.error('create_course failed', exc_info=True) + +# After all data has been parsed, delete users +azure.delete_users() diff --git a/azure.py b/azure.py new file mode 100644 index 0000000..26343a5 --- /dev/null +++ b/azure.py @@ -0,0 +1,701 @@ + +# -*- coding: utf-8 -*- +# +# Copyright Haltu Oy, info@haltu.fi + +#This program is free software: you can redistribute it and/or modify +#it under the terms of the GNU General Public License as published by +#the Free Software Foundation, either version 3 of the License, or +#(at your option) any later version. + +#This program is distributed in the hope that it will be useful, +#but WITHOUT ANY WARRANTY; without even the implied warranty of +#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +#GNU General Public License for more details. + +#You should have received a copy of the GNU General Public License +#along with this program. If not, see . + +import logging +import json +import requests +import time +import unicodedata +import re +import datetime +import random +import string +from models import User, session_scope +from requests.packages.urllib3.util.retry import Retry +from requests.adapters import HTTPAdapter +from config import Config +from sqlalchemy.sql import exists +from dataparser import save_password_file, file_created + +LOG = logging.getLogger(__name__) +CONFIG = Config() +LOG.setLevel(getattr(logging, CONFIG.get('app', 'loglevel').upper())) + +RESPONSE_CODES_OK = (200, 201, 202, 204) +ADD_MEMBER_BAD_REQUEST = 400 +REQUEST_THROTTLED_TEMPORARILY = 503 + +# PasswordPolicies +DISABLE_PASSWORD_EXPIRATION = "DisablePasswordExpiration" +DISABLE_STRONG_PASSWORD = "DisableStrongPassword" + +# Throttle exception retry limit +THROTTLE_RETRY_LIMIT = 7 + +# Required values in group creation +MAIL_ENABLED = False +SECURITY_ENABLED = True + +class AzureError(Exception): + pass + + +class ExpiredTokenError(AzureError): + pass + + +class AzureAPI(object): + + def __init__(self, domain, client_id, client_secret): + """ + :auth: an auth dict with client_id, client_secret and domain + """ + self.token = None + self.auth = {'client_id': client_id, 'client_secret': client_secret, 'domain': domain} + + # Azure API requires 'a few minutes' to be waited in case of throttle exception. + # Value in seconds + self.request_throttled_interval = 300 + + # Amount of retries completed + self.throttle_retries = 0 + + self.graph_url = CONFIG.get('azure', 'GRAPH_ENDPOINT')%self.auth['domain'] + + self.headers = {} + + # List of all groups fetched from Azure + self.azure_group_list = {} + + # List of all licenses fetched from Azure + self.azure_license_list = {} + + # List of all users which need to be disabled in Azure once the CSV file has been parsed through + self.azure_user_list = [] + with session_scope() as session: + for user in session.query(User).filter(User.is_active==True): + self.azure_user_list.append(user.immutable_id) + + def generate_headers(self, token): + auth_token_type, auth_token = token + headers = { + 'Authorization': "%s %s"%(auth_token_type, auth_token), + 'Accept': 'application/json;odata=minimalmetadata', + 'Content-Type': 'application/json;odata=minimalmetadata', + 'Prefer': 'return-content', + } + return headers + + def remove_immutable_attributes(self, data): + """ + remove any immutable attributes that can not be changed in azure ad from data + """ + + immutables = CONFIG.get('azure', 'immutable_attributes').split(';') + result = {} + for key, val in data.iteritems(): + if key not in immutables: + result[key] = val + return result + + def translate_attributes(self, data): + """ + translate a dictionary of attribute data for sending to azure ad + adds necessary attributes like accountEnabled if they are missing and mangle + the password into a PasswordProfile. + """ + attributes = data.copy() + if 'accountEnabled' not in attributes: + # by default an enabled account is assumed + attributes['accountEnabled'] = True + if 'password' in attributes: + password_profile = {'password': attributes.get('password', ''), 'forceChangePasswordNextLogin': False} + password_policies = '%s, %s' % (DISABLE_PASSWORD_EXPIRATION, DISABLE_STRONG_PASSWORD) + + del attributes['password'] + attributes['passwordProfile'] = password_profile + attributes['passwordPolicies'] = password_policies + + # usageLocation needs to be set in order to add licenses for a user + attributes['usageLocation'] = CONFIG.get('azure', 'USAGE_LOCATION') + + return attributes + + def make_request(self, method, url, empty_response, **kwargs): + """ + HTTP Request to Azure Graph API. + + :empty_response: If True responses with status codes 200 (without response data) and 400 will be + ignored, because they are expected + """ + + retries = 0 + exc = None + LOG.debug('make_request request method: %s, url: %s kwargs: %s' % (method, url, repr(kwargs))) + while retries <= int(CONFIG.get('azure', 'CONNECTION_RETRY_LIMIT')): + try: + response = getattr(requests, method)(url, **kwargs) + if response.status_code in RESPONSE_CODES_OK: + if not empty_response: + try: + LOG.debug('make_request response: %s' % (response.text)) + return response.json() + except ValueError: + raise AzureError('No JSON data in Azure response') + else: + LOG.info("Status 2xx with empty response") + return None + elif response.status_code == ADD_MEMBER_BAD_REQUEST and empty_response: + LOG.info("User already added to the group") + return None + elif response.status_code == REQUEST_THROTTLED_TEMPORARILY: + return AzureError('request_throttled') + else: + try: + payload = response.json() + if 'odata.error' in payload.keys(): + error_code = payload['odata.error'].get('code', 'none') + error_msg = payload['odata.error'].get('message', {}).get('value', 'none') + error_values = repr(payload['odata.error'].get('values', 'none')) + if error_code.lower() == 'authentication_expiredtoken': + # no retries, token is expired + return ExpiredTokenError('authentication_expiredtoken') + else: + exc = AzureError('AzureAD connection failed with status: %s, error code: %s, message: %s, values %s' % (response.status_code, error_code, error_msg, error_values)) + else: + exc = AzureError('AzureAD connection failed with status %s. No error code received, payload: %s' % (response.status_code, payload)) + except ValueError: + exc = AzureError('AzureAD connection failed with status %s' % response.status_code) + except requests.exceptions.RequestException as e: + LOG.warning('AzureAD connection failed', exc_info=True) + exc = e + retries += 1 + time.sleep(retries * 2) + if exc: + raise exc + raise AzureError('AzureAD connection failed') + + def prepare_request(self, method, url, empty_response=False, **kwargs): + """ + If request fails either wait few minutes before trying again or generate new oauth token. + If request is throttled add a minute to the throttle interval. Azure API requires the throttle + interval to increase in order for the request to succeed with higher probability. + """ + + request_response = self.make_request(method, url, empty_response, **kwargs) + + if request_response and 'message' in request_response: + if request_response.message == "authentication_expiredtoken": + self.token = self.get_oauth2_token(self.auth) + kwargs['headers'] = self.generate_headers(self.token) + self.throttle_retries = 0 + self.prepare_request(method, url, empty_response, **kwargs) + if request_response.message == "request_throttled": + self.throttle_retries += 1 + while self.throttle_retries <= THROTTLE_RETRY_LIMIT: + time.sleep(self.request_throttled_interval) + self.request_throttled_interval += 60 + self.prepare_request(method, url, empty_response, **kwargs) + if self.throttle_retries > THROTTLE_RETRY_LIMIT: + raise AzureError('Request Throttled') + else: + self.throttle_retries = 0 + return request_response + + def get_oauth2_token(self, auth): + """ + Request oauth2 API token from AzureAD + + :auth: an auth dict with client_id, client_secret and domain + :returns: a tuple (token_type, access_token) + + """ + data = {} + data['grant_type'] = 'client_credentials' + data['client_id'] = auth['client_id'] + data['client_secret'] = auth['client_secret'] + data['resource'] = CONFIG.get('azure', 'GRAPH_URI') + auth_url = CONFIG.get('azure', 'AUTH_ENDPOINT') % auth['domain'] + + try: + response = requests.post(auth_url, data=data) + except requests.exceptions.RequestException: + LOG.error('Connection error while requesting access token from AzureAD.', exc_info=True) + raise AzureError('Oauth2 token fetch failed') + try: + response_payload = response.json() + except ValueError: + LOG.error('Azure AD token api did not return json content. response.text: %(response_text)s, status_code: %(response_status_code)s' % {'response_text': response.text, 'response_status_code': response.status_code}) + raise AzureError('Oauth2 token fetch failed') + if response.status_code != 200: + LOG.error('Error while requesting access token from AzureAD. error: %(error)s, error_codes: %(codes)s, error_description: %(description)s' % {'error': response_payload.get('error', ''), 'codes': response_payload.get('error_codes', ''), 'description': response_payload.get('error_description', '')}) + raise AzureError('Oauth2 token fetch failed') + + LOG.debug('Got data from %(auth_url)s: %(response)s' % {'auth_url': auth_url, 'response': response_payload}) + return (response_payload['token_type'], response_payload['access_token']) + + def fetch_feed(self, feed, disable_paging=False): + """ + Fetch a list of directory items from Azure AD graph API + + A generator function, yields lists of items of length GRAPH_API_PAGE_SIZE. + :feed: the requested feed, for example "users" or "groups" or "groups/1747ad35-dd4c-4115-8604-09b54f89277d/members" + + """ + + if self.token is None: + self.token = self.get_oauth2_token(self.auth) + + headers = self.generate_headers(self.token) + + def get_payload(response): + try: + response_payload = response.json() + except ValueError: + LOG.error('Azure AD graph api did not return json content', + extra={'data': { + 'response.text': response.text, + 'status_code': response.status_code, + }}) + raise AzureError('Azure AD graph api did not return json content') + return response_payload + + next_link = "%(feed)s?api-version=%(api_version)s"%{'feed': feed, 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + if not disable_paging: + next_link = next_link + "&$top=%(page_size)s"%{'page_size': CONFIG.get('azure', 'GRAPH_API_PAGE_SIZE')} + + while next_link: + try: + response_payload = self.prepare_request('get', '%s/%s'%(self.graph_url.rstrip('/'), next_link), headers=headers) + except ExpiredTokenError: + self.token = self.get_oauth2_token(self.auth) + headers = self.generate_headers(self.token) + response_payload = self.prepare_request('get', '%s/%s'%(self.graph_url.rstrip('/'), next_link), headers=headers) + + LOG.debug('Data from Graph API', extra={'data': {'response_payload': response_payload}}) + + # next page + next_link = response_payload.get('odata.nextLink', None) + if next_link: + next_link = "%(next_link)s&$top=%(page_size)d&api-version=%(api_version)s"%{'next_link': next_link, 'page_size': CONFIG.get('azure', 'GRAPH_API_PAGE_SIZE'), 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + + if not 'value' in response_payload.keys(): + LOG.warning('Azure AD response did not have a value', extra={'data': {'response': response_payload}}) + yield (response_payload,) + else: + LOG.debug('feed page', extra={'data': {'page': response_payload['value']}}) + yield response_payload['value'] + return + + def get_membership(self, feed): + if self.token is None: + self.token = self.get_oauth2_token(self.auth) + headers = self.generate_headers(self.token) + url = "%(feed)s?api-version=%(api_version)s"%{'feed': feed + '/getMemberGroups', 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + data = json.dumps({'securityEnabledOnly': False}) + response_payload = self.prepare_request('post', '%s/%s'%(self.graph_url.rstrip('/'), url), headers=headers, data=data) + + LOG.debug('Data from Graph API', extra={'data': {'response_payload': response_payload}}) + + if not 'value' in response_payload.keys(): + LOG.warning('Azure AD response did not have a value', extra={'data': {'response': response_payload}}) + return (response_payload,) + else: + LOG.debug('feed page', extra={'data': {'page': response_payload['value']}}) + return response_payload['value'] + return + + def create_user(self, data): + + password_generated = False + + # If password was not configured, generate a password + if 'password' not in data['attributes']: + data['attributes']['password'] = self.generate_password() + password_generated = True + + attributes = self.translate_attributes(data['attributes']) + + if self.token is None: + self.token = self.get_oauth2_token(self.auth) + headers = self.generate_headers(self.token) + url = "users?api-version=%(api_version)s" % {'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + response_payload = self.prepare_request('post', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers, data=json.dumps(attributes)) + LOG.debug('Data from Graph API', extra={'data': {'response_payload': response_payload}}) + + if password_generated: + save_password_file(attributes['givenname'], attributes['surname'], attributes['userprincipalname'], data['attributes']['password'], file_created) + + return response_payload + + def update_user(self, data, create=False): + """ + Update user account with supplied data + Create missing account if create=True + return updated/created user or raise AzureError + """ + + attributes = self.translate_attributes(data['attributes']) + groups = data['groups'] + licenses = data['licenses'] + + if 'immutableid' in attributes: + immutable_id = attributes['immutableid'] + del attributes['immutableid'] + else: + user = attributes.get('userPrincipalName', repr(data)) + raise AzureError('Invalid data for user. Missing immutableId. User: %s'%user) + + if self.token is None: + self.token = self.get_oauth2_token(self.auth) + headers = self.generate_headers(self.token) + + # Splits userprincipalname using @ as the separator to check if the email address + # includes the allowed domain string. This prevents situations where user name might + # include part of the whitelisted domain + if CONFIG.get('azure', 'ALLOWED_DOMAIN'): + user_principal_name_split = attributes.get('userprincipalname').split('@') + if CONFIG.get('azure', 'ALLOWED_DOMAIN') not in user_principal_name_split[1]: + return None + + # get listing of users with this immutableId + url = "%(feed)s?api-version=%(api_version)s&$filter=immutableId eq '%(immutableId)s'"%{'feed': 'users', 'immutableId': immutable_id, 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + response_payload = self.prepare_request('get', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers) + if not 'value' in response_payload: + raise AzureError('Invalid response from Azure: %s' % repr(response_payload)) + users = response_payload['value'] + if len(users) == 0: + # no users found with this immutableId. create new or bail out. + if create: + response_payload = self.create_user(data) + self.add_user_to_db(immutable_id, response_payload['objectId'], True) + self.get_member_groups(response_payload['objectId'], groups, headers) + self.get_or_create_groups(response_payload['objectId'], groups, response_payload, headers) + self.get_or_create_licenses(licenses, response_payload['objectId'], headers) + return response_payload + else: + raise AzureError('User does not exist: %s' % repr(data)) + if len(users) > 1: + raise AzureError('Multiple users with immutableId %s in Azure! Data must be fixed.') + + # found exactly one user with this immutableId. update it. + azure_user = users[0] + LOG.debug('update_user: Found Azure user %s' % repr(azure_user)) + url = "%(feed)s?api-version=%(api_version)s" % {'feed': 'users/%s' % azure_user['objectId'], 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + update_data = self.remove_immutable_attributes(attributes) + + if 'passwordProfile' in update_data: + del update_data['passwordProfile'] + + response_payload = self.prepare_request('patch', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers, data=json.dumps(update_data)) + LOG.debug('update_user: Update result: %s', repr(response_payload)) + user_id = response_payload['objectId'] + + # If database has been cleared, users will be added back into the database + if (self.user_exists(immutable_id)): + self.update_user_in_db(immutable_id) + self.azure_user_list.remove(immutable_id) + else: + self.add_user_to_db(immutable_id, response_payload['objectId'], True) + + self.get_member_groups(user_id, groups, headers) + self.get_or_create_groups(user_id, groups, response_payload, headers) + self.get_or_create_licenses(licenses, user_id, headers) + + return response_payload + + def create_course(self, input_data): + """ + Add students to a course. If a student's immutableid is not found on the local database, + a warning is displayed prompting the user to add the student manually. + """ + + for student in input_data['students']: + azure_id = self.get_azure_id(student) + if azure_id: + self.get_or_create_groups(azure_id[0], [ input_data['course_name'] ], None, self.generate_headers(self.token)) + else: + LOG.warning("User does not exist and needs to be created manually. id: %s" % student) + + def get_member_groups(self, user_id, groups, headers): + """ + Fetch groups the user is a member of in order to get IDs of the groups. + """ + + url = "%(feed)s/%(user_id)s/%(path)s?api-version=%(api_version)s" % {'feed': 'users', 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION'), 'user_id': user_id, 'path': 'memberOf'} + response_payload = self.prepare_request('get', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers) + LOG.debug('get_member_groups: Get groups: %s', repr(response_payload)) + self.remove_user_from_group(user_id, groups, response_payload, headers) + + def remove_user_from_group(self, user_id, groups, response, headers): + """ + Compare groups the user is currently a member of and groups the user is about to be added to. + If a group cannot be found from the new groups. User will be removed from the group they are + still member of. + + :response: Group DirectoryObjects + """ + + old_groups = {} + response_arr = response['value'] + for response in response_arr: + old_groups[response['displayName']] = response['objectId'] + + new_groups = list(groups) + for temp in new_groups: + if 'child' in temp: + new_groups.append(temp['child']) + + for group in old_groups.keys(): + if group not in new_groups: + url = "%(feed)s/%(group_id)s/%(path)s/%(user_id)s?api-version=%(api_version)s" % {'feed': 'groups', 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION'), 'group_id': old_groups[group], 'path': '$links/members', 'user_id': user_id} + response_payload = self.prepare_request('delete', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers, empty_response=True) + LOG.info('remove_user_from_group: Remove user from group: %s', repr(response_payload)) + + def get_or_create_groups(self, user_id, groups, response_payload, headers): + """ + Check if a group already exists in Azure by fetching all the groups during the first user + iteration. After that check whether a group exists from the cached list of azure groups. If the + group does not exist create it. Add user to the group that was created or fetched. + """ + + # Fetch groups if they haven't been fetched yet + if len(self.azure_group_list) == 0: + paging = self.fetch_feed('groups') + # Add user to groups which were found + for group_response in paging: + for group in group_response: + self.azure_group_list[group['displayName']] = group['objectId'] + if group['displayName'] in groups: + self.add_user_to_group(group['objectId'], user_id, headers) + groups.remove(group['displayName']) + # Save parent and child group ids in order to only create the groups that have no ids + for g in groups: + if 'parent' in g: + if group['displayName'] == g['child']: + g['child_id'] = group['objectId'] + elif group['displayName'] == g['parent']: + g['parent_id'] = group['objectId'] + else: + remove_groups = [] + for group in groups: + if group in self.azure_group_list.keys(): + self.add_user_to_group(self.azure_group_list[group], user_id, headers) + remove_groups.append(group) + + if 'parent' in group: + if group['parent'] in self.azure_group_list.keys(): + group['parent_id'] = self.azure_group_list[group['parent']] + if group['child'] in self.azure_group_list.keys(): + group['child_id'] = self.azure_group_list[group['child']] + + # Remove groups which user has already been added to + for group in remove_groups: + if 'parent' in group: + if group['parent'] in groups: + groups.remove(group['parent']) + elif group['child'] in groups: + groups.remove(group['child']) + else: + if group in groups: + groups.remove(group) + + # Create groups which were not found and add user to the group + for group in groups: + # Check for dict in list + if 'parent' in group: + # Check if the groups need to be created + if 'child_id' not in group: + if group['child'] in self.azure_group_list.keys(): + group['child_id'] = self.azure_group_list[group['child']] + else: + group_response = self.create_group(group['child'], headers) + group['child_id'] = group_response['objectId'] + if 'parent_id' not in group: + if group['parent'] in self.azure_group_list.keys(): + group['parent_id'] = self.azure_group_list[group['parent']] + else: + group_response = self.create_group(group['parent'], headers) + group['parent_id'] = group_response['objectId'] + # Add user to child group and add child group to parent group + self.add_user_to_group(group['parent_id'], group['child_id'], headers) + self.add_user_to_group(group['child_id'], user_id, headers) + else: + if group not in self.azure_group_list.keys(): + group_response = self.create_group(group, headers) + self.add_user_to_group(group_response['objectId'], user_id, headers) + else: + self.add_user_to_group(self.azure_group_list[group], user_id, headers) + + def get_or_create_licenses(self, licenses, user_id, headers): + """ + Fetch all licenses on the first iteration, save the name and ID to a list and use the list + to lookup the IDs with the following iterations. + """ + + if len(self.azure_license_list) == 0: + # Fetch and add licenses + license_response = self.get_licenses(headers) + for license in license_response['value']: + self.azure_license_list[license['skuPartNumber']] = license['skuId'] + + if license['skuPartNumber'] in licenses: + self.add_license(license['skuId'], user_id, headers) + else: + for license in licenses: + if license in self.azure_license_list.keys(): + self.add_license(self.azure_license_list[license], user_id, headers) + + def generate_user_url(self, user_id): + """ + Return a user specific url + """ + + return "%(url)s/%(feed)s/%(user_id)s" % {'url': self.graph_url.rstrip('/'), 'feed': 'directoryObjects', 'user_id': user_id} + + def create_group(self, group_name, headers): + url = "%(feed)s?api-version=%(api_version)s" % {'feed': 'groups', 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + post_data = { 'displayName': group_name, 'mailEnabled': MAIL_ENABLED, 'mailNickname': self.slugify(group_name), 'securityEnabled': SECURITY_ENABLED } + response_payload = self.prepare_request('post', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers, data=json.dumps(post_data)) + LOG.debug('create_group: Create group: %s', repr(response_payload)) + self.azure_group_list[response_payload['displayName']] = response_payload['objectId'] + return response_payload + + def add_user_to_group(self, group_id, user_id, headers): + """ + :group_id: ID of the group which the user will be added to + :user_id: ID of the user or group which will be added to the group specified with the group_id + """ + + url = "%(feed)s/%(group_id)s/%(path)s?api-version=%(api_version)s" % {'feed': 'groups', 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION'), 'group_id': group_id, 'path': '$links/members'} + post_data = { 'url': self.generate_user_url(user_id) } + response_payload = self.prepare_request('post', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers, data=json.dumps(post_data), empty_response=True) + LOG.debug('add_user_to_group: Add user: %s', repr(response_payload)) + + def slugify(self, value): + """ + Return a camelcased value without whitespaces + e.g. Tampereen koulu -> TampereenKoulu + """ + + value = unicode(value, 'utf-8') + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii') + value = re.sub('[^\w\s-]', '', value.title()).strip() + return re.sub('[\s]+', '', value) + + def add_license(self, license_id, user_id, headers): + url = "%(feed)s/%(user_id)s/%(path)s?api-version=%(api_version)s" % {'feed': 'users', 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION'), 'user_id': user_id, 'path': 'assignLicense'} + post_data = { 'addLicenses': [{ 'disabledPlans': [], 'skuId': license_id }], 'removeLicenses': [] } + response_payload = self.prepare_request('post', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers, data=json.dumps(post_data)) + LOG.debug('add_license: Add License: %s', repr(response_payload)) + return response_payload + + def get_licenses(self, headers): + url = "%(feed)s?api-version=%(api_version)s" % {'feed': 'subscribedSkus', 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + response_payload = self.prepare_request('get', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=headers) + LOG.debug('get_licenses: Get Licenses: %s', repr(response_payload)) + return response_payload + + def user_exists(self, immutable_id): + """ + Returns True if user exists in the database + """ + with session_scope() as session: + user_filter = exists().where(User.immutable_id==immutable_id) + for user in session.query(User.immutable_id).filter(user_filter): + if user: + return True + else: + return False + + def update_user_in_db(self, immutable_id): + """ + Updates the synced time with the current time + """ + + with session_scope() as session: + user = session.query(User).filter_by(immutable_id=immutable_id).first() + if user: + user.synced = datetime.datetime.now() + session.add(user) + + def user_exists(self, immutable_id): + with session_scope() as session: + user = session.query(User).filter_by(immutable_id=immutable_id).first() + + if user: + return True + else: + return False + + def add_user_to_db(self, immutable_id, user_id, is_active): + user = User() + user.immutable_id = immutable_id + user.azure_user_id = user_id + user.is_active = is_active + user.created = datetime.datetime.now() + user.synced = datetime.datetime.now() + with session_scope() as session: + session.add(user) + + def is_active(self, immutable_id): + with session_scope() as session: + for active in session.query(User.is_active).filter(User.immutable_id==immutable_id): + return active + + def remove_user_from_db(self, immutable_id): + with session_scope() as session: + user = session.query(User).filter_by(immutable_id=immutable_id).first() + session.delete(user) + + def get_azure_id(self, immutable_id): + """ + Return Azure User ID, None is returned if a user with given ID is not found. + """ + with session_scope() as session: + azure_id = session.query(User.azure_user_id).filter_by(immutable_id=immutable_id).first() + return azure_id + + def delete_users(self): + """ + Delete all the users that were not found on the CSV file but were still present in the database. + """ + + for user in self.azure_user_list: + with session_scope() as session: + user = session.query(User).filter_by(immutable_id=user).first() + self.remove_user_from_db(user.immutable_id) + self.delete_user(user.azure_user_id) + + def delete_user(self, user_id): + """ + Delete user from Azure. + """ + + url = "%(feed)s?api-version=%(api_version)s" % {'feed': 'users/%s' % user_id, 'api_version': CONFIG.get('azure', 'GRAPH_API_VERSION')} + response_payload = self.prepare_request('delete', '%s/%s' % (self.graph_url.rstrip('/'), url), headers=self.generate_headers(self.token), empty_response=True) + LOG.debug('delete_user, DELETE: %s', repr(response_payload)) + + def generate_password(self, length=8): + """ + Generate a randomized password containing uppercase and lowercase letters as well as numbers. + """ + return ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(length)) + +# vim: tabstop=2 expandtab shiftwidth=2 softtabstop=2 + diff --git a/config.py b/config.py new file mode 100644 index 0000000..f16063a --- /dev/null +++ b/config.py @@ -0,0 +1,76 @@ + +# -*- coding: utf-8 -*- +# +# Copyright Haltu Oy, info@haltu.fi + +#This program is free software: you can redistribute it and/or modify +#it under the terms of the GNU General Public License as published by +#the Free Software Foundation, either version 3 of the License, or +#(at your option) any later version. + +#This program is distributed in the hope that it will be useful, +#but WITHOUT ANY WARRANTY; without even the implied warranty of +#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +#GNU General Public License for more details. + +#You should have received a copy of the GNU General Public License +#along with this program. If not, see . + +import os +import sys +import logging +from ConfigParser import RawConfigParser + +LOG = logging.getLogger(__name__) +logging.basicConfig() +DEFAULT_CONFIG_FILE_NAME = 'defaults.cfg' +CONFIG_FILE_NAME = 'primus2azure.cfg' + +if getattr(sys, 'frozen', False): + # The application is frozen + BASEDIR = os.path.dirname(sys.executable) +else: + BASEDIR = os.path.dirname(os.path.abspath(__file__)) + +CONFIG_SECTION_INPUT = 'input' +CONFIG_SECTION_RULES = 'rules' +CONFIG_SECTION_GROUPS = 'groups' +CONFIG_SECTION_LICENSES = 'licenses' + + +class ConfigurationError(Exception): + pass + + +class Config: + """ + Config stores the configuration and reads it from a file. + It's class with shared state across all instances (the Borg pattern) + """ + __shared_state = {} + + def __init__(self): + self.__dict__ = self.__shared_state + self.config = None + + def read_config_file(self, config_file=os.path.join(BASEDIR, CONFIG_FILE_NAME)): + self.config = RawConfigParser() + try: + self.config.readfp(open(os.path.join(BASEDIR, DEFAULT_CONFIG_FILE_NAME), 'r')) + self.config.readfp(open(config_file, 'r')) + except (OSError, IOError): + LOG.exception('Could not open configuration file') + raise ConfigurationError('Could not open configuration file') + + def get(self, section, option): + if self.config is None: + self.read_config_file() + return self.config.get(section, option) + + def items(self, section): + if self.config is None: + self.read_config_file() + return self.config.items(section) + +# vim: tabstop=2 expandtab shiftwidth=2 softtabstop=2 + diff --git a/dataparser.py b/dataparser.py new file mode 100644 index 0000000..2e5c84c --- /dev/null +++ b/dataparser.py @@ -0,0 +1,326 @@ + +# -*- coding: utf-8 -*- +# +# Copyright Haltu Oy, info@haltu.fi + +#This program is free software: you can redistribute it and/or modify +#it under the terms of the GNU General Public License as published by +#the Free Software Foundation, either version 3 of the License, or +#(at your option) any later version. + +#This program is distributed in the hope that it will be useful, +#but WITHOUT ANY WARRANTY; without even the implied warranty of +#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +#GNU General Public License for more details. + +#You should have received a copy of the GNU General Public License +#along with this program. If not, see . + +import csv +import shlex +import logging +import operator +import random +import string +import datetime +import time +import os +from os import path +from config import Config, CONFIG_SECTION_INPUT, CONFIG_SECTION_RULES, CONFIG_SECTION_GROUPS, CONFIG_SECTION_LICENSES + +LOG = logging.getLogger(__name__) +logging.basicConfig() +CONFIG = Config() + +CSV_DELIMITER = ';' +COURSE_FILE_STUDENT_DELIMITER = ',' +PASSWORD_OUTPUT_FILENAME = "Primus2Azure-Password-File" + +file_created = False + +def save_password_file(givenname, surname, email, password, file_created): + """ + Create a file containing the name and email of the user and a randomly generated password. + + If the filename is not found, generate it by adding date to a predetermined filename. If the + filename is set, file already exists and data should be appended to the file. + """ + + # Generate file name appending date to the filename + if not file_created: + today = datetime.datetime.today() + output_file = "%s-%s_%s_%s" % (PASSWORD_OUTPUT_FILENAME, str(today.day), str(today.month), str(today.year)) + file_created = True + + file_path = CONFIG.get('azure', 'PASSWORD_FILE_LOCATION').replace('base_dir/', '') + + # If backslash is not the last character append it to the path string in order for the path + # to be created + if file_path and not file_path[:-1] == '/': + file_path += "/" + + # Create path if necessary + if file_path and not os.path.exists(file_path): + os.makedirs(file_path) + + file_dir = os.path.dirname(os.path.abspath(__file__)) + filename = os.path.join(file_dir, file_path + output_file) + with open(filename, 'a') as output: + output.write("%s %s (%s) - Password: %s\n" % (givenname, surname, email, password)) + +class ParserError(Exception): + pass + + +class AttributeRule(object): + """ + Represents a mapping of one attribute in input data to output data + + Parser creates an AttributeRule for each attribute rule in the configuration. + When parsing data, the input row is fed to each AttributeRule, forming the + data which is passed to AzureAD. + + An AttributeRule can be conditional, generating output only if the condition + is true. + """ + def __init__(self, attribute_name, attribute_template, condition_operator=None, condition_field=None, condition_value=None, *args, **kwargs): + self.attribute_name = attribute_name + self.attribute_template = attribute_template + self.condition_operator = condition_operator + self.condition_field = condition_field + self.condition_value = condition_value + + def transform(self, data): + if self.condition_operator and self.condition_field and self.condition_value: + if not self.condition_operator(data.get(self.condition_field), self.condition_value): + return {} + try: + output = {self.attribute_name: self.attribute_template.format(**data)} + except KeyError: + LOG.warning('No data for rule %s: %s' % (self.attribute_name, self.attribute_template)) + output = {} + return output + +class GroupRule(object): + """ + Represents a mapping of one attribute in input data to output data + + Supports IF statements and hierarchical groups with MEMBEROF statement. + """ + # opettaja = [groupinnimi] MEMBEROF [toinengrouppi] IF role = opettaja + + def __init__(self, group_name, group_template, group_template_parent=None, condition_operator=None, condition_field=None, condition_value=None, *args, **kwargs): + self.group_name = group_name + self.group_template = group_template + self.group_template_parent = group_template_parent + self.condition_operator = condition_operator + self.condition_field = condition_field + self.condition_value = condition_value + + def transform(self, data): + + if self.condition_operator and self.condition_field and self.condition_value: + if not self.condition_operator(data.get(self.condition_field), self.condition_value): + return None + try: + output = [] + # If group is a memberof another, return them both as a dict + if self.group_template_parent: + output.append({'parent': self.group_template_parent.format(**data), 'child': self.group_template.format(**data)}) + else: + output.append(self.group_template.format(**data)) + except KeyError: + LOG.warning('error') + + return output + +class LicenseRule(object): + """ + Represents a mapping of one attribute in input data to output data + + Supports IF statements + """ + + def __init__(self, license_name, license_template, condition_operator=None, condition_field=None, condition_value=None, *args, **kwargs): + self.license_name = license_name + self.license_template = license_template + self.condition_operator = condition_operator + self.condition_field = condition_field + self.condition_value = condition_value + + def transform(self, data): + if self.condition_operator and self.condition_field and self.condition_value: + if not self.condition_operator(data.get(self.condition_field), self.condition_value): + return None + try: + output = self.license_template.format(**data) + except KeyError: + LOG.warning('LicenseRule error') + + return output + +class Parser(object): + def __init__(self, *args, **kwargs): + self.attribute_rules = self.create_attribute_rules(CONFIG.items(CONFIG_SECTION_RULES)) + self.group_rules = self.create_group_rules(CONFIG.items(CONFIG_SECTION_GROUPS)) + self.license_rules = self.create_lincense_rules(CONFIG.items(CONFIG_SECTION_LICENSES)) + + # Caches the file name in order to prevent new file from being created if day changes while the + # script is still running + self.password_output_file = "" + + def parse_attribute_rule(self, rule): + # rule: {class} IF role = "oppilas" + # tokens: ['{class}', 'IF', 'role', '=', 'oppilas'] + tokens = shlex.split(rule) + if "IF" in tokens: + # conditional rule + return { + 'attribute_template': tokens[0], + 'condition_operator': self.select_operator(tokens[3]), + 'condition_field': tokens[2], + 'condition_value': tokens[4], + } + else: + return { + 'attribute_template': tokens[0] + } + + def parse_group_rule(self, rule): + + tokens = shlex.split(rule) + # example: opettaja = [group1] MEMBEROF [group2] IF role = opettaja + # tokens: ['[group1]', 'MEMBEROF', '[group2]', 'IF', 'role', '=', 'opettaja'] + if "MEMBEROF" in tokens: + if "IF" in tokens: + return { + 'group_template': tokens[0], + 'group_template_parent': tokens[2], + 'condition_operator': self.select_operator(tokens[5]), + 'condition_field': tokens[4], + 'condition_value': tokens[6] + } + else: + return { + 'group_template': tokens[0], + 'group_template_parent': tokens[2], + } + elif "IF" in tokens: + return { + 'group_template': tokens[0], + 'condition_operator': self.select_operator(tokens[3]), + 'condition_field': tokens[2], + 'condition_value': tokens[4] + } + else: + return { + # PARSI KOULU INFO + 'group_template': tokens[0] + } + + return None + + def parse_license_rule(self, rule): + tokens = shlex.split(rule) + if "IF" in tokens: + return { + 'license_template': tokens[0], + 'condition_operator': self.select_operator(tokens[3]), + 'condition_field': tokens[2], + 'condition_value': tokens[4] + } + else: + return { + 'license_template': tokens[0] + } + + def select_operator(self, op_str): + if op_str == '=': + return operator.eq + elif op_str == '!=': + return operator.ne + elif op_str == 'contains': + return operator.contains + else: + raise ParserError('Invalid operator %s' % op_str) + + def create_attribute_rules(self, rules): + rule_objs = [] + for name, value in rules: + rule_objs.append(AttributeRule(name, **self.parse_attribute_rule(value))) + return rule_objs + + def create_group_rules(self, rules): + rule_objs = [] + for name, value in rules: + rule_objs.append(GroupRule(name, **self.parse_group_rule(value))) + return rule_objs + + def create_lincense_rules(self, rules): + rule_objs = [] + for name, value in rules: + rule_objs.append(LicenseRule(name, **self.parse_license_rule(value))) + return rule_objs + + def parse(self, file_name): + """ + Parses given input file for data. Headers for the data must be presented + in the config. Yields dictionaries for each line in the input + {'attributes': {'attr1': 'val1', 'attr2', 'val2'}, 'groups': []} + """ + + headers = CONFIG.get(CONFIG_SECTION_INPUT, 'headers').split(';') + with open(file_name, 'rb') as csvfile: + reader = csv.reader(csvfile, delimiter=CSV_DELIMITER) + for row in reader: + if len(row) != len(headers): + raise ParserError('Number of columns does not match number of headers: %s' % ';'.join(row)) + input_data = dict(zip(headers, row)) + attribute_data = self.transform_attribute_data(input_data) + group_data = self.transform_group_data(input_data) + license_data = self.transform_license_data(input_data) + + LOG.debug('attributes: %s' % repr(attribute_data)) + LOG.debug('groups: %s' % repr(group_data)) + LOG.debug('licenses: %s' % repr(license_data)) + + yield {'attributes': attribute_data, 'groups': group_data, 'licenses': license_data} + + def parse_course(self, file_name): + """ + Parses given input file and returns the data as a dictionary containing the course name and list + of students to be added to the course. + """ + with open(file_name, 'rb') as csvfile: + reader = csv.reader(csvfile, delimiter=CSV_DELIMITER) + for row in reader: + if len(row) != 2: + raise ParserError('Course file must include course name and the students') + yield {'course_name': row[0], 'students': row[1].split(COURSE_FILE_STUDENT_DELIMITER)} + + + def transform_attribute_data(self, input_data): + attributes = {} + for rule in self.attribute_rules: + attributes.update(rule.transform(input_data)) + return attributes + + def transform_group_data(self, input_data): + groups = [] + for rule in self.group_rules: + # Append group if group was returned + group = rule.transform(input_data) + if group: + groups.extend(group) + return groups + + def transform_license_data(self, input_data): + licenses = [] + for rule in self.license_rules: + license = rule.transform(input_data) + if license: + licenses.append(license) + return licenses + +# vim: tabstop=2 expandtab shiftwidth=2 softtabstop=2 + diff --git a/defaults.cfg b/defaults.cfg new file mode 100644 index 0000000..5ed9ee9 --- /dev/null +++ b/defaults.cfg @@ -0,0 +1,15 @@ +[app] +loglevel = warning + +[input] +input_file = input.csv + +[azure] +connection_retry_limit = 3 +graph_uri = https://graph.windows.net +auth_endpoint = https://login.windows.net/%s/oauth2/token?api-version=1.0 +graph_endpoint = https://graph.windows.net/%s/ +graph_api_version = 1.6 +graph_api_page_size = 600 +immutable_attributes = assignedPlans;creationType;deletionTimestamp;dirSyncEnabled;lastDirSyncTime;objectId;objectType;onPremisesSecurityIdentifier;provisionedPlans;provisioningErrors;proxyAddresses;signInNames;sipProxyAddress;immutableId +usage_location = FI diff --git a/doc/configuration.rst b/doc/configuration.rst new file mode 100644 index 0000000..8d05628 --- /dev/null +++ b/doc/configuration.rst @@ -0,0 +1,165 @@ + +Konfiguraatiotiedosto +===================== + +Työkalun käyttämä konfiguraatiotiedosto on primus2azure.cfg. + +Azuren AD:n kredentiaalit +========================= + +Primus2Azure tulee rekisteröidä Azure AD:ssa sallituksi applikaatioksi. Azuresta +saadaan tällöin Client Id ja Client Secret, joiden avulla työkalu voi +keskustella palvelun kanssa. + +1. Valitse Azuren hallintakonsolissa (https://manage.windowsazure.com/) Active + Directory ja valitse listalta oikea AD. + +2. Valitse yläpalkista **Applications** ja sen jälkeen alapalkista **Add**. + Valitse **Add an application my organization is developing**. + +3. Syötä applikaation nimeksi *Primus2Azure* ja valitse **Web application and/or web api** + +4. App properties -valintaikkunassa syötä jokin kelvollinen URL-osoite + kenttiin. Esimerkiksi *http://contoso.com* molempiin + kenttiin. Näitä arvoja ei käytetä, koska Primus2Azure ei käytä + kertakirjautumisominaisuuksia, ainoastaan Graph -rajapintaa. + +5. Siirry **Configure** -välilehdelle ja kopioi siellä näkyvä *Client Id* talteen, tarvitset sitä myöhemmin. Luo **keys** -osiossa uusi client key ja kopioi se talteen. + +6. Kohdassa **permissions to other applications** valitse Application + Permissioneiksi *Read and write directory data*. + +7. Paina **Save** alapalkissa. + + + +Lohko [input] +============= + + :input_file: + CSV tiedoston nimi, joka sisältää käyttäjätiedot. Tiedostossa olevien kolumnien määrä on täsmättävä headers kohdassa määriteltäviin kolumnien nimiin. + + :course_file: + CSV tiedosto, joka sisältää kurssit ja niille lisättävät oppilaat. Tiedosto tulee sisältää tiedon kurssin nimestä ja sinne lisättävien oppilaiden henkilötunnus (katso lisää kohdasta: immutableId). Oppilaat tulee olla pilkuilla (,) eroteltuna. Oppilat tulee olla lisättnyä Azureen, jotta heidät voidaan lisätä tästä tiedostosta kurssiryhmiin. Jos oppilas luodaan input_file tiedostossa hänet saadaan lisättyä samalla kertaa myös kurssiryhmään. + + Tiedosto tulee olla muodossa: + *Kurssin nimi;oppilas1,oppilas2,oppilas3,oppilas4* + + Tämä tiedosto ei ole pakollinen. Jos tiedostoa ei löydy hakemistosta, sovellus + ohittaa kurssiryhmien lisäämisen. + + :headers: + Kolumnien nimet, jotka CSV tiedosto sisältää eroteltuna puolipistein (;). Kolumnien nimet tulevat olla oikeassa järjestyksessä, jotta tietojen lukeminen onnistuu oikein. Headers kohdassa olevia kolumnin nimiä voi käyttää konfiguraatiotiedostossa tietojen hakemiseen käyttämällä ( esimerkiksi: {hetu} ). + + Esimerkki: + *first_name;last_name;email;hetu* + + +Lohko [azure] +============= + + :domain: + Käyttämäsi Azure domain. Esimerkiksi *organisaatio.onmicrosoft.com* tai + *edu.organisaatio.fi*. + + :client_id: + Kohdassa Azure AD:n kredentiaalit luotu *client id*. + + :client_secret: + Kohdassa Azure AD:n kredentiaalit luotu *client key*. + + :allowed_domain: + Sallittu osoite, joka tulee olla käyttäjän userPrincipalName:ssa, jotta käyttäjän lisäys onnistuu. Jos kohdan jättää tyhjäksi kaikki osoitteet hyväksytään. + + Esimerkkejä: + *allowed_domain = contoso* + *erkki.esimerkki@contoso.onmicrosoft.com - lisätään* + *pertti.esimerkki@kunta.onmicrosoft.com - ei lisätä* + --------------------------------------------------- + *allowed_domain = contoso.onmicrosoft.com* + *erkki.esimerkki@contoso.onmicrosoft.com - lisätään* + *pertti.esimerkki@kunta.com - ei lisätä* + + :password_file_location: + Mihin salasana tiedosto luodaan. base_dir/ tarkoittaa sovelluksen juurihakemistoa. Jos hakemistoa ei löydy se luodaan. Katso lisää sääntojen "password" kohdasta. + + Esimerkki: + **base_dir/password_files/** + +Lohko [rules] +============= + +Säännöt käyttäjän tietoja varten. Pakollisissa kohdissa tulee olla arvot asetettu. + + PAKOLLISET KOHDAT + ----------------- + :mailNickname: + Käyttäjän nimi sähköpostilistaa varten. + + :displayName: + Nimi, joka näytetään käyttäjästä näytetään Azuressa + + :userPrincipalName: + Käyttäjän sähköpostiosoite. Osoite tulee sisältää hyväksytyn domainin. + + :immutableId: + Tämän kohdan tulee olla henkilötunnuksen hash, jotta käyttäjän tunnistautuminen toimii oikein Azuressa. ID:n tulee olla uniikki, muutoin käyttäjän luonti Azureen ei onnistu. + + VAPAAEHTOISET KOHDAT + -------------------- + Mahdolliset käyttäjän attribuutit on listattu Azure AD:n dokumentaatiossa: https://msdn.microsoft.com/en-us/library/azure/ad/graph/api/entity-and-complex-type-reference#UserEntity + + Esimerkkejä: + + :password: + Käyttäjän salasanan. Jos tämän kohdan poistaa kokonaan (säännön nimi "password" tulee poistaa myös) käyttäjälle luodaan automaattisesti salasana, joka sisältää isoja ja pieniä kirjaimia ja numeroita. Salasanat lisätään tiedostoon, jossa löytyvät käyttäjät ja heidän salasanansa. + + :givenName: + Käyttäjän kutsumanimi. + + :surname: + Käyttäjän sukunimi. + + :jobTitle: + Käyttäjän rooli. + + :facsimileTelephoneNumber: + Käyttäjän henkilötunnus. + +Lohko [groups] +============== + +Säännöt ryhmien luomista varten, sekä käyttäjien ja ryhmien liittyminen tietyn ryhmän alaisuuteen. + + Ryhmä voidaan luoda kaikille käyttäjille tai tiettyjen ehtojen mukaan. Säännöt voivat myös + sisältää CSV tiedostosta saatavaa dataa. Säännöissä voi käyttää IF lausetta, jolloin + henkilö lisätään kyseiseen ryhmään vain, jos ehto täyttyy. Jos IF lausetta ei käytetä, lisätään + kaikki käyttäjät kyseisen ryhmään. Käyttämällä MEMBEROF lausetta voidaan ryhmä lisätä toisen + ryhmän alaisuuteen. Säännön nimi on vapaasti valittavissa, mutta sen tulee olla uniikki groups + lohkon sisällä. IF ja MEMBEROF osiot ovat vapaaehtoisia. + + Sääntö koostuu seuraavasti: + **säännön_nimi = ryhmän_nimi (MEMBEROF ylä_ryhmä) (IF jokin_kolumni = "jokin_arvo")** + + Esimerkkejä: + **koulu = School_{school}** + **oppilaat = Role_Students IF role = "oppilas"** + **luokka = Class_{class} MEMBEROF School_{school}** + +Lohko [licenses] +================ + +Säännöt lisensseille. + + Säännöissä voi käyttää IF lausetta, jolloin vain tietyille henkilöille lisätään kyseinen + lisenssi, jos ehto täyttyy. Jos IF lausetta ei käytetä, lisätään lisenssi kaikille käyttäjille. + Säännön nimi on vapaasti valittavissa, mutta sen tulee olla uniikki licenses lohkon sisällä. + IF osio on vapaaehtoinen. + + Sääntö koostuu seuraavasti: + **säännön_nimi = lisenssin_nimi (IF jokin_kolumni = "jokin_arvo")** + + Esimerkkejä: + **opettajat = STANDARDWOFFPACK_FACULTY IF role = "opettaja"** + **oppilaat = STANDARDWOFFPACK_STUDENT IF role = "oppilas"** + diff --git a/doc/deployment/index.rst b/doc/deployment/index.rst new file mode 100755 index 0000000..f331537 --- /dev/null +++ b/doc/deployment/index.rst @@ -0,0 +1,36 @@ +Building the deployment package on Windows +========================================== + +Prerequisites: + +* Python 2.7 +* Requests must be installed (for example from http://www.lfd.uci.edu/~gohlke/pythonlibs/) +* cx_freeze must be installed (for example http://www.lfd.uci.edu/~gohlke/pythonlibs/#cx_freeze) +* To install packages in Windows use pip bundled with Python + `c:\python27\python -m pip install [package]` + +Execute `python setup.py build` + + +Deploying the tool +================== + +* In Azure create application credentials. The following permissions are + needed: + + * Application Permissions: Read and write directory data + * Delegated Permissions: Read and write directory data, Read and write all + groups + +* To authorize the application to change user passwords use Powershell to add + it to the User Account Administrator role:: + + Connect-MsolService + $displayName = "Application Name" + $objectId = (Get-MsolServicePrincipal -SearchString $displayName).ObjectId + $roleName = "User Account Administator" + Add-MsolRoleMember -RoleName $roleName -RoleMemberType ServicePrincipal -RoleMemberObjectId $objectId + +* Configure the client id, client secret and domain in primus2azure.cfg +* Configure attribute rules and group rules in primus2azure.cfg + diff --git a/example_config.cfg b/example_config.cfg new file mode 100644 index 0000000..5f803c4 --- /dev/null +++ b/example_config.cfg @@ -0,0 +1,36 @@ +[app] +loglevel = warning + +[input] +input_file = input.csv +course_file = course_input.csv +headers = firstname;lastname;email;role;school;class;primus_id;hetu_hash;password + +[azure] +domain = contoso.onmicrosoft.com +client_id = +client_secret = +allowed_domain = +password_file_location = base_dir/ + +[rules] +mailNickname = {firstname}.{lastname} +displayName = {firstname} {lastname} +immutableId = {hetu_hash} +givenName = {firstname} +password = {firstname}{primus_id} +surname = {lastname} +userPrincipalName = {firstname}.{lastname}@contoso.onmicrosoft.com +jobTitle = {class} IF role = "oppilas" +facsimileTelephoneNumber = {hetu_hash} + +[groups] +opettajat = Role_Teachers IF role = "opettaja" +oppilaat = Role_Students IF role = "oppilas" +henkilokunta = Role_Students IF role = "henkilokunta" +koulu = School_{school} +testi = School_{class} MEMBEROF School_{school} + +[licenses] +opettajat = STANDARDWOFFPACK_FACULTY IF role = "opettaja" +oppilaat = STANDARDWOFFPACK_STUDENT IF role = "oppilas" diff --git a/models.py b/models.py new file mode 100644 index 0000000..ef39832 --- /dev/null +++ b/models.py @@ -0,0 +1,73 @@ + +# -*- coding: utf-8 -*- +# +# Copyright Haltu Oy, info@haltu.fi + +#This program is free software: you can redistribute it and/or modify +#it under the terms of the GNU General Public License as published by +#the Free Software Foundation, either version 3 of the License, or +#(at your option) any later version. + +#This program is distributed in the hope that it will be useful, +#but WITHOUT ANY WARRANTY; without even the implied warranty of +#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +#GNU General Public License for more details. + +#You should have received a copy of the GNU General Public License +#along with this program. If not, see . + +import os +import sys +import datetime +import logging +from contextlib import contextmanager +from sqlalchemy import * +from sqlalchemy.orm import relationship, backref, sessionmaker +from sqlalchemy.ext.declarative import declarative_base +from config import Config + +CONFIG = Config() +logging.basicConfig(level=getattr(logging, CONFIG.get('app', 'loglevel').upper())) +LOG = logging.getLogger(__name__) + +if getattr(sys, 'frozen', False): + # The application is frozen + BASEDIR = os.path.dirname(sys.executable) +else: + BASEDIR = os.path.dirname(os.path.abspath(__file__)) + +# Initialize local database +Base = declarative_base() +engine = create_engine('sqlite:///' + os.path.join(BASEDIR, 'primus2azure.db'), echo=False) +Session = sessionmaker(bind=engine) + +@contextmanager +def session_scope(): + session = Session() + try: + yield session + session.commit() + except: + session.rollback() + raise + finally: + session.close() + +class User(Base): + """ + Used mainly for keeping track of users which should be enabled/disabled. + + :immutable_id: Unique ID used in Azure + :azure_user_id: Unique ID to access user information in Azure + :created: First time the user was created/added to Azure + :synced: Last time the user was updated in Azure + :is_active: If False user account must be set as disabled in Azure + """ + __tablename__ = 'users' + immutable_id = Column(String(50), primary_key=True) + azure_user_id = Column(String(50)) + created = Column(DateTime()) + synced = Column(DateTime()) + is_active = Column(Boolean()) + +Base.metadata.create_all(engine) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f99c8f4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pysqlite==2.8.2 +requests==2.10.0 +SQLAlchemy==1.0.13 diff --git a/sample_input.csv b/sample_input.csv new file mode 100644 index 0000000..b6d0e91 --- /dev/null +++ b/sample_input.csv @@ -0,0 +1 @@ +Erik;Esimerkki;erik.esimerkki@edu.fi;opettaja;Testikoulu;1B;testi_124;hexhexhex;jAkjD4kjhads diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8fc8685 --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +import sys +from cx_Freeze import setup, Executable + +import requests.certs +build_exe_options = {"include_files": [(requests.certs.where(), 'cacert.pem'), ('defaults.cfg', 'defaults.cfg'), ('example_config.cfg', 'primus2azure.cfg'), ('sample_input.csv', 'sample_input.csv')]} + +setup( + name="primus2azure", + version="1.0", + description="Primus to Azure sync", + options={"build_exe": build_exe_options}, + executables=[Executable("app.py")] +) diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..d51681d --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,2 @@ +-r requirements.txt +mock==2.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_azure.py b/tests/test_azure.py new file mode 100644 index 0000000..548776b --- /dev/null +++ b/tests/test_azure.py @@ -0,0 +1,67 @@ +import sys +import unittest +import requests +import os +import mock +from mock import Mock, patch + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from azure import AzureAPI, save_password_file +from config import Config + +class AzureTestCase(unittest.TestCase): + + def setUp(self): + self.azure = AzureAPI("domain", "client", "secret") + self.data = {'licenses': ['STANDARDWOFFPACK_FACULTY'],'attributes': { 'immutableid': 'testi_0022', 'mailnickname': '2erkki.Esimerkki', 'surname': 'Esimerkki', 'facsimiletelephonenumber': 'hexhexhex', 'displayname': '2erkki', 'givenname': '2erkki', 'userprincipalname': '2erkki.Esimerkki@tieraedu.onmicrosoft.com' }, 'groups': ['Edison_Role_Teachers', 'Edison_School_Testikoulu', { 'parent': 'Edison_School_Testikoulu', 'child': 'Edison_School_1B' }]} + self.response_data = {'user ok'} + self.response_mock = Mock() + self.response_mock.status_code = requests.codes.ok + self.response_mock.json.return_value = self.response_data + self.azure.token = {'foo': 'foo', 'bar': 'bar'} + + def test_user_creation_attributes(self): + attributes = self.azure.translate_attributes(self.data['attributes']) + del attributes['immutableid'] + self.data['attributes']['password'] = self.azure.generate_password() + attributes = self.azure.translate_attributes(self.data['attributes']) + + self.assertTrue(attributes['givenname']) + self.assertTrue(attributes['surname']) + self.assertTrue(attributes['userprincipalname']) + self.assertTrue(attributes['passwordProfile']['password']) + + @patch.object(AzureAPI, 'prepare_request') + def test_user_creation(self, mock): + self.azure.create_user(self.data) + + @patch.object(AzureAPI, 'add_user_to_group') + def test_groups_without_group_fetch(self, mock): + groups_data = ['group'] + self.azure.azure_group_list['group'] = 'abc123' + self.azure.get_or_create_groups('123456-123K', groups_data, None, self.azure.generate_headers(self.azure.token)) + + @patch.object(AzureAPI, 'add_user_to_group') + @patch.object(AzureAPI, 'prepare_request') + def test_groups_with_group_fetch(self, mock_add, mock_prepare): + groups_data = ['group'] + self.azure.get_or_create_groups('123456-123K', groups_data, None, self.azure.generate_headers(self.azure.token)) + + @patch.object(AzureAPI, 'prepare_request') + def test_remove_user_from_group(self, mock): + groups_data = ['group'] + response_data = {'value': [{'displayName': 'Test Group', 'objectId': '123abc'}] } + self.azure.remove_user_from_group('123456-123K', groups_data, response_data, self.azure.generate_headers(self.azure.token)) + + def test_make_request_success_with_empty_response(self): + mock_request = mock.Mock() + mock_request.POST = { 'displayName': 'Test group', 'mailEnabled': False, 'mailNickname': 'TestGroup', 'securityEnabled': True} + expected_data = None + with mock.patch.object(self.azure, 'make_request', return_value=None): + query_result = self.azure.make_request(request=mock_request) + + self.assertEqual(query_result, expected_data) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..b49829a --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,22 @@ +import sys +import unittest +import requests +import os +import datetime +from mock import Mock, patch + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from azure import AzureAPI +from config import Config +import models + +class ModelsTestCase(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..3122f33 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,47 @@ +import sys +import unittest +import requests +import os +from mock import Mock, patch + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from dataparser import Parser +from config import Config + +class ParserTestCase(unittest.TestCase): + + def setUp(self): + self.parser = Parser() + + def test_required_attribute_keys(self): + actual_data = {} + for i in self.parser.parse(Config().get('input', 'input_file')): + actual_data = i + break + + self.assertTrue(actual_data['attributes']['immutableid']) + self.assertTrue(actual_data['attributes']['mailnickname']) + self.assertTrue(actual_data['attributes']['displayname']) + self.assertTrue(actual_data['attributes']['userprincipalname']) + + def test_parser_output_for_users(self): + excepted_data = {'licenses': ['STANDARDWOFFPACK_FACULTY'],'attributes': { 'immutableid': 'testi_0022', 'mailnickname': '2erkki.Esimerkki', 'surname': 'Esimerkki', 'facsimiletelephonenumber': 'hexhexhex', 'displayname': '2erkki', 'givenname': '2erkki', 'userprincipalname': '2erkki.Esimerkki@tieraedu.onmicrosoft.com' }, 'groups': ['Edison_Role_Teachers', 'Edison_School_Testikoulu', { 'parent': 'Edison_School_Testikoulu', 'child': 'Edison_School_1B' }]} + actual_data = {} + for i in self.parser.parse(Config().get('input', 'input_file')): + actual_data = i + break + + self.assertDictEqual(actual_data, excepted_data) + + def test_parser_output_for_coures(self): + excepted_data = {'course_name': 'Edison Kurssi Testi 1.0', 'students': ['testi_0021']} + actual_data = {} + for i in self.parser.parse_course(Config().get('input', 'course_file')): + actual_data = i + break + + self.assertDictEqual(actual_data, excepted_data) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file