44import re
55import json
66import time
7+ import configparser
78import urllib .error
89import urllib .request
9- from ldap3 import Server , Connection , ALL , ALL_ATTRIBUTES , SAFE_SYNC
10+ from enum import Enum
11+ from pathlib import Path
12+ from ldap3 import Server , Connection , ALL , SAFE_SYNC , Tls
13+ from ldap3 .core .exceptions import LDAPException
14+ from dataclasses import dataclass
1015
1116#PRODUCTION VALUES
1217
13- PRODUCTION_ENDPOINT = "https://registry.cilogon.org/registry/"
14- PRODUCTION_LDAP_SERVER = "ldaps://ldap.cilogon.org"
15- PRODUCTION_LDAP_USER = "uid=readonly_user,ou=system,o=OSG,o=CO,dc=cilogon,dc=org"
16- PRODUCTION_OSG_CO_ID = 7
17- PRODUCTION_UNIX_CLUSTER_ID = 1
18- PRODUCTION_LDAP_TARGET_ID = 6
18+ # PRODUCTION_ENDPOINT = "https://registry.cilogon.org/registry/"
19+ # PRODUCTION_OSG_CO_ID = 7
20+ # PRODUCTION_UNIX_CLUSTER_ID = 1
21+ # PRODUCTION_LDAP_TARGET_ID = 6
1922
2023#TEST VALUES
2124
22- TEST_ENDPOINT = "https://registry-test.cilogon.org/registry/"
23- TEST_LDAP_SERVER = "ldaps://ldap-test.cilogon.org"
24- TEST_LDAP_USER = "uid=registry_user,ou=system,o=OSG,o=CO,dc=cilogon,dc=org"
25- TEST_OSG_CO_ID = 8
26- TEST_UNIX_CLUSTER_ID = 10
27- TEST_LDAP_TARGET_ID = 9
25+ # TEST_ENDPOINT = "https://registry-test.cilogon.org/registry/"
26+ # TEST_OSG_CO_ID = 8
27+ # TEST_UNIX_CLUSTER_ID = 10
28+ # TEST_LDAP_TARGET_ID = 9
2829
2930# Value for the base of the exponential backoff
3031TIMEOUT_BASE = 5
3132MAX_ATTEMPTS = 5
3233
3334# HTTP return codes we shouldn't attempt to retry
3435HTTP_NO_RETRY_CODES = {401 , 403 , 404 , 405 , 500 }
36+ # LDAP Server Connection and Search Config, required keys
37+ class LDAP_CONFIG_KEYS (str , Enum ):
38+ LDAP_Server_URL = "LDAPServerURl"
39+ LDAP_Search_Base = "SearchBase"
40+ LDAP_User = "User"
41+ LDAP_AuthTok_File = "AuthTokenFile"
42+
43+ def __str__ (self ):
44+ return f'{ self .value } '
45+
46+ LDAP_CONFIG_USAGE_MESSAGE = f"""
47+ LDAP CONNECTION CONFIG:
48+ File at LDAP_CONFIG_PATH should be in ini format, servers will be attempted in descending order.
49+ An example section of this config file follows:
50+
51+ ---
52+
53+ [human_server_name] # arbitrary human label for this server's config
54+ { LDAP_CONFIG_KEYS .LDAP_Server_URL } = ldaps://ldap-replica-1.osg.chtc.io # URL to reach this LDAP server from
55+ { LDAP_CONFIG_KEYS .LDAP_Search_Base } = dc=osg-htc,dc=org # LDAP Search Base
56+ { LDAP_CONFIG_KEYS .LDAP_User } = cn=readonly,ou=system,dc=osg-htc,dc=org # full LDAP user DN
57+ { LDAP_CONFIG_KEYS .LDAP_AuthTok_File } = /etc/ldap-secrets/osg-ldap/authtoken # file containing authtoken for access
58+
59+ ---
60+
61+ Config file should contain one such section per LDAP server to communicate with.
62+ """
63+
3564
3665GET = "GET"
3766PUT = "PUT"
@@ -43,11 +72,18 @@ class Error(Exception):
4372 """Base exception class for all exceptions defined"""
4473 pass
4574
46-
4775class URLRequestError (Error ):
4876 """Class for exceptions due to not being able to fulfill a URLRequest"""
4977 pass
5078
79+ class EmptyConfiguration (Error ):
80+ """Class for exceptions due to loading an empty Config file, or one where every section lacked the required keys"""
81+ pass
82+
83+ class NoLDAPResponse (Error ):
84+ """Class for exceptions due to being unable to get any request from any configured LDAP servers."""
85+ pass
86+
5187
5288class HTTPRequestError (URLRequestError ):
5389 """Class for exceptions due to not being able to fulfill a HTTPRequest"""
@@ -81,6 +117,60 @@ def mkauthstr(user, passwd):
81117 return encodebytes (raw_authstr .encode ()).decode ().replace ("\n " , "" )
82118
83119
120+ def get_ldap_authtok (ldap_authfile ):
121+ if ldap_authfile is not None :
122+ ldap_authtok = open (ldap_authfile ).readline ().strip ()
123+ else :
124+ raise PermissionError
125+ return ldap_authtok
126+
127+
128+ def read_ldap_conffile (ldap_conffile_path ):
129+ config = configparser .ConfigParser (allow_no_value = True )
130+
131+ print (f"Attempting to read config from { ldap_conffile_path } " )
132+ config .read (ldap_conffile_path )
133+ misconfigured_sections = list ()
134+ for section in config .sections ():
135+ for key in LDAP_CONFIG_KEYS :
136+ # All servers must have all required keys for operation
137+ if not config .has_option (section , key ) or config .get (section , key ) == "" :
138+ print (f"Section \" { section } \" : required key \" { key } \" missing, ignoring section." )
139+ misconfigured_sections .append (section )
140+ break
141+ # For-Else to only check key values if we know the required ones exist (i.e. we didn't break)
142+ else :
143+ # All server AuthTok files must exist, be files, and not be empty
144+ token_path = Path (config .get (section , LDAP_CONFIG_KEYS .LDAP_AuthTok_File ))
145+ try :
146+ if not token_path .exists ():
147+ print (f"Section \" { section } \" : AuthToken File missing or non-file, ignoring section." )
148+ misconfigured_sections .append (section )
149+ continue
150+ if token_path .stat ().st_size == 0 :
151+ print (f"Section \" { section } \" : AuthToken File is empty, ignoring section." )
152+ misconfigured_sections .append (section )
153+ continue
154+ except OSError as e :
155+ print (f"Section \" { section } \" : Exception raised while checking AuthTok File: { e } , skipping section." )
156+ misconfigured_sections .append (section )
157+ continue
158+
159+ for section in misconfigured_sections :
160+ print (f"Dropping section { section } " )
161+ config .remove_section (section )
162+
163+ # if their are no servers in the file that have all required keys
164+ if len (config .sections ()) == 0 :
165+ #when script needs to say LDAP Config required, raise a EmptyConfiguration error
166+ #usage("LDAP Config File Required")
167+ raise EmptyConfiguration (
168+ f"Config file at { ldap_conffile_path } was empty or all sections lacked required keys."
169+ )
170+ print (f"Finished reading config from { ldap_conffile_path } " )
171+ return config
172+
173+
84174def mkrequest (method , target , data , endpoint , authstr , ** kw ):
85175 url = os .path .join (endpoint , target )
86176 if kw :
@@ -223,16 +313,109 @@ def get_datalist(data, listname):
223313 return data [listname ] if data else []
224314
225315
226- def get_ldap_groups (ldap_server , ldap_user , ldap_authtok ):
316+ class LDAPServer :
317+ """ Wrapper class for LDAP searches. """
318+ server : Server = None
319+ connection : Connection = None
320+
321+ def __init__ (self , ldap_server , ldap_user , ldap_authtok ):
322+ self .server = Server (ldap_server , get_info = ALL )
323+ self .connection = Connection (self .server , ldap_user , ldap_authtok , client_strategy = SAFE_SYNC , auto_bind = True )
324+
325+ def search (self , ou , search_base , filter_str , attrs ):
326+ # simple paged search
327+ # https://github.com/cannatag/ldap3/blob/7991e67d0a2fb2c1f9cbf832d110ad29fc378f9b/docs/manual/source/standard.rst#L4
328+ # https://ldap3.readthedocs.io/en/latest/tutorial_searches.html#simple-paged-search
329+ response = self .connection .extend .standard .paged_search (
330+ f"ou={ ou } ,{ search_base } " ,
331+ filter_str ,
332+ attributes = attrs ,
333+ paged_size = 500 ,
334+ generator = True
335+ )
336+
337+ return response
338+
339+
340+ # TODO:
341+ # do_ldap_fallback_search, get_ldap_groups, and get_ldap_active_users_and_groups should be a method of the LDAPSearch class
342+ # script calling this lib should init LDAPSearch, then call the method that asks for the info it wants.
343+ # Be able to feed in either one server's config to the LDAPSearch, or a conffile to parse with a list of >=1 LDAP servers to do fallback searches with.
344+
345+ def do_ldap_fallback_search (search_ou , search_filter , attrs , ldap_config : configparser .ConfigParser ):
346+ response = None
347+
348+ if ldap_config == None :
349+ raise EmptyConfiguration (
350+ "Search Attempted with \" None\" config object."
351+ )
352+
353+ for section in ldap_config .sections ():
354+ print (f"Attempting search with server { section } " )
355+ try :
356+ server_url = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_Server_URL )
357+ search_base = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_Search_Base )
358+ search_user = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_User )
359+ authtok_file = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_AuthTok_File )
360+ authtok = get_ldap_authtok (authtok_file )
361+
362+ searcher = LDAPServer (ldap_server = server_url , ldap_user = search_user , ldap_authtok = authtok )
363+ response = searcher .search (search_ou , search_base , search_filter , attrs )
364+
365+ #If we get a response from one of the servers, we don't need to check the rest
366+ if not response is None :
367+ print (f"Response found for server { section } ." )
368+ break
369+ # Perm issue reading token file
370+ except PermissionError as permError :
371+ print (f"Permission Error when attempting search for { section } : { permError } ." )
372+ # Problem getting LDAP Response
373+ except LDAPException as ldapError :
374+ print (f"Exception occurred when attempting search for { section } : { ldapError } ." )
375+ continue
376+
377+ if response is None :
378+ raise NoLDAPResponse (
379+ f"No response found via LDAP servers: { [section for section in ldap_config ]} ."
380+ )
381+
382+ return response
383+
384+
385+ def get_ldap_groups (config = None ):
227386 ldap_group_osggids = set ()
228- server = Server (ldap_server , get_info = ALL )
229- connection = Connection (server , ldap_user , ldap_authtok , client_strategy = SAFE_SYNC , auto_bind = True )
230- _ , _ , response , _ = connection .search ("ou=groups,o=OSG,o=CO,dc=cilogon,dc=org" , "(cn=*)" , attributes = ALL_ATTRIBUTES )
387+
388+ response = do_ldap_fallback_search (
389+ search_ou = "groups" ,
390+ search_filter = "(cn=*)" ,
391+ attrs = ["gidNumber" ],
392+ ldap_config = config
393+ )
394+
231395 for group in response :
232396 ldap_group_osggids .add (group ["attributes" ]["gidNumber" ])
233397 return ldap_group_osggids
234398
235399
400+ def get_ldap_active_users_and_groups (filter_group_name = None , config = None ):
401+ """ Retrieve a dictionary of active users from LDAP, with their group memberships. """
402+ ldap_active_users = dict ()
403+ filter_str = ("(isMemberOf=CO:members:active)" if filter_group_name is None
404+ else f"(&(isMemberOf={ filter_group_name } )(isMemberOf=CO:members:active))" )
405+
406+ response = do_ldap_fallback_search (
407+ search_ou = "people" ,
408+ search_filter = filter_str ,
409+ attrs = ["employeeNumber" , "isMemberOf" ],
410+ ldap_config = config
411+ )
412+
413+ for person in response :
414+ ldap_active_users [person ["attributes" ]["employeeNumber" ]] = person ["attributes" ].get ("isMemberOf" , [])
415+
416+ return ldap_active_users
417+
418+
236419def identifier_from_list (id_list , id_type ):
237420 id_type_list = [id ["Type" ] for id in id_list ]
238421 try :
@@ -324,6 +507,7 @@ def provision_group(gid, provision_target, endpoint, authstr):
324507 }
325508 return call_api3 (POST , path , data , endpoint , authstr )
326509
510+
327511def provision_group_members (gid , prov_id , endpoint , authstr ):
328512 data = {
329513 "RequestType" : "CoPersonProvisioning" ,
0 commit comments