44import re
55import json
66import time
7+ import configparser
78import urllib .error
89import urllib .request
10+ from enum import Enum
11+ from pathlib import Path
912from ldap3 import Server , Connection , ALL , SAFE_SYNC , Tls
13+ from ldap3 .core .exceptions import LDAPException
1014from dataclasses import dataclass
1115
1216#PRODUCTION VALUES
1317
14- PRODUCTION_ENDPOINT = "https://registry.cilogon.org/registry/"
15- PRODUCTION_LDAP_SERVER = "ldaps://ldap.cilogon.org"
16- PRODUCTION_LDAP_USER = "uid=readonly_user,ou=system,o=OSG,o=CO,dc=cilogon,dc=org"
17- PRODUCTION_OSG_CO_ID = 7
18- PRODUCTION_UNIX_CLUSTER_ID = 1
19- PRODUCTION_LDAP_TARGET_ID = 6
20- LDAP_BASE_DN = "o=OSG,o=CO,dc=cilogon,dc=org"
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
2122
2223#TEST VALUES
2324
24- TEST_ENDPOINT = "https://registry-test.cilogon.org/registry/"
25- TEST_LDAP_SERVER = "ldaps://ldap-test.cilogon.org"
26- TEST_LDAP_USER = "uid=registry_user,ou=system,o=OSG,o=CO,dc=cilogon,dc=org"
27- TEST_OSG_CO_ID = 8
28- TEST_UNIX_CLUSTER_ID = 10
29- 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
3029
3130# Value for the base of the exponential backoff
3231TIMEOUT_BASE = 5
3332MAX_ATTEMPTS = 5
3433
34+ # LDAP Server Connection and Search Config, required keys
35+ class LDAP_CONFIG_KEYS (str , Enum ):
36+ LDAP_Server_URL = "LDAPServerURl"
37+ LDAP_Search_Base = "SearchBase"
38+ LDAP_User = "User"
39+ LDAP_AuthTok_File = "AuthTokenFile"
40+
41+ def __str__ (self ):
42+ return f'{ self .value } '
43+
44+ LDAP_CONFIG_USAGE_MESSAGE = f"""
45+ LDAP CONNECTION CONFIG:
46+ File at LDAP_CONFIG_PATH should be in ini format, servers will be attempted in descending order.
47+ An example section of this config file follows:
48+
49+ ---
50+
51+ [human_server_name] # arbitrary human label for this server's config
52+ { LDAP_CONFIG_KEYS .LDAP_Server_URL } = ldaps://ldap-replica-1.osg.chtc.io # URL to reach this LDAP server from
53+ { LDAP_CONFIG_KEYS .LDAP_Search_Base } = dc=osg-htc,dc=org # LDAP Search Base
54+ { LDAP_CONFIG_KEYS .LDAP_User } = cn=readonly,ou=system,dc=osg-htc,dc=org # full LDAP user DN
55+ { LDAP_CONFIG_KEYS .LDAP_AuthTok_File } = /etc/ldap-secrets/osg-ldap/authtoken # file containing authtoken for access
56+
57+ ---
58+
59+ Config file should contain one such section per LDAP server to communicate with.
60+ """
61+
3562
3663GET = "GET"
3764PUT = "PUT"
@@ -43,11 +70,18 @@ class Error(Exception):
4370 """Base exception class for all exceptions defined"""
4471 pass
4572
46-
4773class URLRequestError (Error ):
4874 """Class for exceptions due to not being able to fulfill a URLRequest"""
4975 pass
5076
77+ class EmptyConfiguration (Error ):
78+ """Class for exceptions due to loading an empty Config file, or one where every section lacked the required keys"""
79+ pass
80+
81+ class NoLDAPResponse (Error ):
82+ """Class for exceptions due to being unable to get any request from any configured LDAP servers."""
83+ pass
84+
5185
5286def getpw (user , passfd , passfile ):
5387 if ":" in user :
@@ -79,6 +113,52 @@ def get_ldap_authtok(ldap_authfile):
79113 return ldap_authtok
80114
81115
116+ def read_ldap_conffile (ldap_conffile_path ):
117+ config = configparser .ConfigParser (allow_no_value = True )
118+
119+ print (f"Attempting to read config from { ldap_conffile_path } " )
120+ config .read (ldap_conffile_path )
121+ misconfigured_sections = list ()
122+ for section in config .sections ():
123+ for key in LDAP_CONFIG_KEYS :
124+ # All servers must have all required keys for operation
125+ if not config .has_option (section , key ) or config .get (section , key ) == "" :
126+ print (f"Section \" { section } \" : required key \" { key } \" missing, ignoring section." )
127+ misconfigured_sections .append (section )
128+ break
129+ # For-Else to only check key values if we know the required ones exist (i.e. we didn't break)
130+ else :
131+ # All server AuthTok files must exist, be files, and not be empty
132+ token_path = Path (config .get (section , LDAP_CONFIG_KEYS .LDAP_AuthTok_File ))
133+ try :
134+ if not token_path .exists ():
135+ print (f"Section \" { section } \" : AuthToken File missing or non-file, ignoring section." )
136+ misconfigured_sections .append (section )
137+ continue
138+ if token_path .stat ().st_size == 0 :
139+ print (f"Section \" { section } \" : AuthToken File is empty, ignoring section." )
140+ misconfigured_sections .append (section )
141+ continue
142+ except OSError as e :
143+ print (f"Section \" { section } \" : Exception raised while checking AuthTok File: { e } , skipping section." )
144+ misconfigured_sections .append (section )
145+ continue
146+
147+ for section in misconfigured_sections :
148+ print (f"Dropping section { section } " )
149+ config .remove_section (section )
150+
151+ # if their are no servers in the file that have all required keys
152+ if len (config .sections ()) == 0 :
153+ #when script needs to say LDAP Config required, raise a EmptyConfiguration error
154+ #usage("LDAP Config File Required")
155+ raise EmptyConfiguration (
156+ f"Config file at { ldap_conffile_path } was empty or all sections lacked required keys."
157+ )
158+ print (f"Finished reading config from { ldap_conffile_path } " )
159+ return config
160+
161+
82162def mkrequest (method , target , data , endpoint , authstr , ** kw ):
83163 url = os .path .join (endpoint , target )
84164 if kw :
@@ -175,7 +255,7 @@ def get_datalist(data, listname):
175255 return data [listname ] if data else []
176256
177257
178- class LDAPSearch :
258+ class LDAPServer :
179259 """ Wrapper class for LDAP searches. """
180260 server : Server = None
181261 connection : Connection = None
@@ -184,27 +264,93 @@ def __init__(self, ldap_server, ldap_user, ldap_authtok):
184264 self .server = Server (ldap_server , get_info = ALL )
185265 self .connection = Connection (self .server , ldap_user , ldap_authtok , client_strategy = SAFE_SYNC , auto_bind = True )
186266
187- def search (self , ou , filter_str , attrs ):
188- _ , _ , response , _ = self .connection .search (f"ou={ ou } ,{ LDAP_BASE_DN } " , filter_str , attributes = attrs )
267+ def search (self , ou , search_base , filter_str , attrs ):
268+ # simple paged search
269+ # https://github.com/cannatag/ldap3/blob/7991e67d0a2fb2c1f9cbf832d110ad29fc378f9b/docs/manual/source/standard.rst#L4
270+ # https://ldap3.readthedocs.io/en/latest/tutorial_searches.html#simple-paged-search
271+ response = self .connection .extend .standard .paged_search (
272+ f"ou={ ou } ,{ search_base } " ,
273+ filter_str ,
274+ attributes = attrs ,
275+ paged_size = 500 ,
276+ generator = True
277+ )
278+
189279 return response
190280
191- def get_ldap_groups (ldap_server , ldap_user , ldap_authtok ):
281+
282+ # TODO:
283+ # do_ldap_fallback_search, get_ldap_groups, and get_ldap_active_users_and_groups should be a method of the LDAPSearch class
284+ # script calling this lib should init LDAPSearch, then call the method that asks for the info it wants.
285+ # 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.
286+
287+ def do_ldap_fallback_search (search_ou , search_filter , attrs , ldap_config : configparser .ConfigParser ):
288+ response = None
289+
290+ if ldap_config == None :
291+ raise EmptyConfiguration (
292+ "Search Attempted with \" None\" config object."
293+ )
294+
295+ for section in ldap_config .sections ():
296+ print (f"Attempting search with server { section } " )
297+ try :
298+ server_url = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_Server_URL )
299+ search_base = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_Search_Base )
300+ search_user = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_User )
301+ authtok_file = ldap_config .get (section , LDAP_CONFIG_KEYS .LDAP_AuthTok_File )
302+ authtok = get_ldap_authtok (authtok_file )
303+
304+ searcher = LDAPServer (ldap_server = server_url , ldap_user = search_user , ldap_authtok = authtok )
305+ response = searcher .search (search_ou , search_base , search_filter , attrs )
306+
307+ #If we get a response from one of the servers, we don't need to check the rest
308+ if not response is None :
309+ print (f"Response found for server { section } ." )
310+ break
311+ # Perm issue reading token file
312+ except PermissionError as permError :
313+ print (f"Permission Error when attempting search for { section } : { permError } ." )
314+ # Problem getting LDAP Response
315+ except LDAPException as ldapError :
316+ print (f"Exception occurred when attempting search for { section } : { ldapError } ." )
317+ continue
318+
319+ if response is None :
320+ raise NoLDAPResponse (
321+ f"No response found via LDAP servers: { [section for section in ldap_config ]} ."
322+ )
323+
324+ return response
325+
326+
327+ def get_ldap_groups (config = None ):
192328 ldap_group_osggids = set ()
193- searcher = LDAPSearch (ldap_server , ldap_user , ldap_authtok )
194- response = searcher .search ("groups" , "(cn=*)" , ["gidNumber" ])
329+
330+ response = do_ldap_fallback_search (
331+ search_ou = "groups" ,
332+ search_filter = "(cn=*)" ,
333+ attrs = ["gidNumber" ],
334+ ldap_config = config
335+ )
336+
195337 for group in response :
196338 ldap_group_osggids .add (group ["attributes" ]["gidNumber" ])
197339 return ldap_group_osggids
198340
199341
200- def get_ldap_active_users_and_groups (ldap_server , ldap_user , ldap_authtok , filter_group_name = None ):
342+ def get_ldap_active_users_and_groups (filter_group_name = None , config = None ):
201343 """ Retrieve a dictionary of active users from LDAP, with their group memberships. """
202344 ldap_active_users = dict ()
203345 filter_str = ("(isMemberOf=CO:members:active)" if filter_group_name is None
204346 else f"(&(isMemberOf={ filter_group_name } )(isMemberOf=CO:members:active))" )
205347
206- searcher = LDAPSearch (ldap_server , ldap_user , ldap_authtok )
207- response = searcher .search ("people" , filter_str , ["employeeNumber" , "isMemberOf" ])
348+ response = do_ldap_fallback_search (
349+ search_ou = "people" ,
350+ search_filter = filter_str ,
351+ attrs = ["employeeNumber" , "isMemberOf" ],
352+ ldap_config = config
353+ )
208354
209355 for person in response :
210356 ldap_active_users [person ["attributes" ]["employeeNumber" ]] = person ["attributes" ].get ("isMemberOf" , [])
@@ -278,6 +424,7 @@ def provision_group(gid, provision_target, endpoint, authstr):
278424 }
279425 return call_api3 (POST , path , data , endpoint , authstr )
280426
427+
281428def provision_group_members (gid , prov_id , endpoint , authstr ):
282429 data = {
283430 "RequestType" : "CoPersonProvisioning" ,
0 commit comments