import json import logging from pathlib import Path from ldap3 import SIMPLE from ms_active_directory import ADDomain, ADGroup, ADUser, ADObject from .conf import ( AD_DOMAIN, AD_USER_NAME, AD_PASSWORD, AD_LDAP_SERVER, AD_PARENT_GROUP, AD_TIMEZONE, STDOUT_LOG_LEVEL, FILE_LOG_LEVEL, LOG_DIR, CIVICRM_BASE_URL, CIVICRM_API_KEY, CIVICRM_BATCH_SIZE, CIVICRM_RETRIES, CIVICRM_IGNORE_SSL, NTFY_URL, NTFY_TOPIC, NTFY_ACCESS_TOKEN, ) from .logger import setup_logging from .models import RecentRun, CiviCrm, Ntfy logger = logging.getLogger(__package__) civicrm_credentials = { 'base_url': CIVICRM_BASE_URL, 'api_key': CIVICRM_API_KEY, 'batch_size': CIVICRM_BATCH_SIZE, 'ignore_ssl': CIVICRM_IGNORE_SSL, } def is_user_disabled(user: ADUser): """ Check if the user account is disabled. :param user: ADUser object :return: True if the user is disabled, False otherwise """ user_account_control = user.get('userAccountControl') is_disabled = user_account_control is not None and ( user_account_control & 0b10) != 0 if is_disabled: logger.debug(f"User '{user.name}' is disabled", extra={'user': user.__dict__, 'status': 'disabled', 'userAccountControl': user_account_control}) return is_disabled def has_changed(object_: ADObject, recent_run): """ Check if the object has changed since the last run :param object_: Object to check :param recent_run: RecentRun object :return: True if the object has changed, False otherwise """ modify_timestamp = object_.get('modifyTimestamp') if modify_timestamp is None: message = (f"Object '{object_.get('sn')}' of type '{type(object_)}' " "does not have 'modifyTimestamp' attribute") raise ValueError(message) if recent_run.datetime is None: return True return modify_timestamp > recent_run def check_group_changes(group, level_dict, recent_run, visited): """ Check if the group or any of its child groups have changed. :param group: The group to check. :param level_dict: The dictionary containing the hierarchy. :param recent_run: The RecentRun object. :param visited: Set of visited groups to avoid infinite loops. :return: True if the group or any of its child groups have changed, False otherwise. """ if group in visited: return False visited.add(group) # First, check if the group itself has changed if has_changed(group, recent_run): return True # Then, check if any child group has changed for child in level_dict.get(group, []): if isinstance(child, ADGroup) and check_group_changes( child, level_dict, recent_run, visited): return True return False def collect_members(group, level_dict, visited): if group in visited: return set() visited.add(group) members = set() for child in level_dict.get(group, []): if isinstance(child, ADUser) and not is_user_disabled( child): # If it is an active user, add to members # If it is a user, add to members members.add(child) elif isinstance(child, ADGroup): # If it is a group, recursively collect members members.update(collect_members(child, level_dict, visited)) return members def build_group_membership(level_dict): all_groups = {} for group in level_dict.keys(): visited = set() members = collect_members(group, level_dict, visited) if members: all_groups[group] = members return all_groups def find_changed(all_groups, level_dict, recent_run): changed_groups = set() changed_users = set() for group in level_dict.keys(): if check_group_changes(group, level_dict, recent_run, set()): changed_groups.add(group) logger.debug(f"Group '{group.name}' has changed", extra={ 'group': group.__dict__, 'modifyTimestamp': group.get('modifyTimestamp'), }) for group, members in all_groups.items(): for user in members: if isinstance(user, ADUser) and not is_user_disabled( user) and has_changed(user, recent_run): changed_users.add(user) logger.debug(f"User '{user.name}' has changed", extra={ 'user': user.__dict__, 'modifyTimestamp': user.get('modifyTimestamp'), }) return changed_groups, changed_users def sync_groups(recent_run: RecentRun): # Setup ntfy if URL and topic are provided ntfy = False if NTFY_URL and NTFY_TOPIC: ntfy = Ntfy(NTFY_URL, NTFY_ACCESS_TOKEN) # Setup session domain = ADDomain(AD_DOMAIN, ldap_servers_or_uris=AD_LDAP_SERVER) logger.debug(f"Connecting to Domain '{domain.domain}'", extra={'domain': domain.__dict__}) try: session = domain.create_session_as_user( AD_USER_NAME, AD_PASSWORD, authentication_mechanism=SIMPLE, read_only=True) except Exception as e: logger.error(f"Error creating session: {e}") exit(1) logger.debug(f"Session opened: {session.is_open()}", extra={'session': session.__dict__}) # Get parent group parent_group = session.find_group_by_name( AD_PARENT_GROUP, ['modifyTimestamp']) # Get child groups group_attrs = [ 'modifyTimestamp', 'objectSid', 'givenName', 'sn', 'mail', 'sAMAccountName', 'description', 'userAccountControl', ] mailinglists_levels = session.find_members_of_group_recursive( parent_group, group_attrs) level_dict = {k: v for level in mailinglists_levels for k, v in level.items()} groups = build_group_membership(level_dict) mailinglists = {group: members for group, members in groups.items() if group in mailinglists_levels[1].keys()} changed_groups, changed_users = find_changed(mailinglists, level_dict, recent_run) groups_to_update = {group: members for group, members in mailinglists.items() if group in changed_groups} users_to_update = set(user for user in changed_users if not any((user in members) for members in groups_to_update.values())) # Break if there are no requests to send if not groups_to_update and not users_to_update: logger.info('No changes detected. Exiting...') return # Connect to CiviCRM with CiviCrm(**civicrm_credentials) as civicrm: # Prepare request for changed users civicrm.update_users(users_to_update) # Prepare requests for changed groups civicrm.update_groups(groups_to_update) # Send requests and retry 3 times retry_count = 0 while retry_count < CIVICRM_RETRIES \ and (error_count := civicrm.send_requests()) != 0: retry_count += 1 logger.warning(f"A total of {error_count} requests failed." f" Retrying {retry_count}/3") if retry_count >= CIVICRM_RETRIES: logger.error( f"Failed to send requests after {CIVICRM_RETRIES} retries.") # Send notification if ntfy is set if ntfy: logger.info('Sending notification via ntfy') ntfy_message = ( f"Failed to send requests after {CIVICRM_RETRIES} retries.\n" '## Errors\n```json' f"{json.dumps(civicrm.error_bag, indent=2)}\n```" ) ntfy.send( topic=NTFY_TOPIC, title='Failed to sync AD groups with CiviCRM', message=ntfy_message, priority=ntfy.PRIORITY.HIGH, markdown=True, ) else: logger.info('All requests were sent successfully!') def main(): setup_logging(file_log_level=FILE_LOG_LEVEL, stdout_log_level=STDOUT_LOG_LEVEL, logdir=LOG_DIR) try: logger.info('Running group sync') # Get the recent run timestamp file_path = Path().home() / '.recent_run' with RecentRun(file_path, tz=AD_TIMEZONE) as recent_run: if recent_run.datetime is None: logger.info('No recent run found') else: rr_time = recent_run.datetime.strftime('%Y-%m-%d %H:%M:%S %Z') logger.info( f"Recent run at: {rr_time}") # Synchronize groups sync_groups(recent_run) # Log the current run timestamp started_at = recent_run.started_at.strftime('%Y-%m-%d %H:%M:%S %Z') logger.info(f"Setting previous run to: {started_at}") except Exception as e: logger.error(f"An error occurred: {e}", exc_info=True) exit(1) if __name__ == '__main__': main()