Skip to content

Commit 74dbdae

Browse files
Merge pull request #30 from williamnswanson/INF-3541.COmanage-LDAP-Upgrade
(INF-3541) Comanage LDAP Upgrade + Fixes
2 parents 0a91db9 + 1867a21 commit 74dbdae

6 files changed

Lines changed: 222 additions & 79 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM library/python:3.7-alpine
1+
FROM library/python:3.9-alpine
22

33
LABEL maintainer OSG Software <help@opensciencegrid.org>
44

comanage_utils.py

Lines changed: 170 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,61 @@
44
import re
55
import json
66
import time
7+
import configparser
78
import urllib.error
89
import urllib.request
10+
from enum import Enum
11+
from pathlib import Path
912
from ldap3 import Server, Connection, ALL, SAFE_SYNC, Tls
13+
from ldap3.core.exceptions import LDAPException
1014
from 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
3231
TIMEOUT_BASE = 5
3332
MAX_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

3663
GET = "GET"
3764
PUT = "PUT"
@@ -43,11 +70,18 @@ class Error(Exception):
4370
"""Base exception class for all exceptions defined"""
4471
pass
4572

46-
4773
class 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

5286
def 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+
82162
def 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+
281428
def provision_group_members(gid, prov_id, endpoint, authstr):
282429
data = {
283430
"RequestType" : "CoPersonProvisioning",

create_project.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,3 @@ def main(args):
152152
except (RuntimeError, urllib.error.HTTPError) as e:
153153
print(e, file=sys.stderr)
154154
sys.exit(1)
155-

osg-comanage-project-usermap.py

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
SCRIPT = os.path.basename(__file__)
1212
ENDPOINT = "https://registry-test.cilogon.org/registry/"
1313
TOPOLOGY_ENDPOINT = "https://topology.opensciencegrid.org/"
14-
LDAP_SERVER = "ldaps://ldap-test.cilogon.org"
15-
LDAP_USER = "uid=registry_user,ou=system,o=OSG,o=CO,dc=cilogon,dc=org"
1614
OSG_CO_ID = 8
1715
CACHE_FILENAME = "COmanage_Projects_cache.txt"
1816
CACHE_LIFETIME_HOURS = 0.5
@@ -22,26 +20,26 @@
2220
usage: {SCRIPT} [OPTIONS]
2321
2422
OPTIONS:
25-
-u USER[:PASS] specify USER and optionally PASS on command line
26-
-c OSG_CO_ID specify OSG CO ID (default = {OSG_CO_ID})
27-
-s LDAP_SERVER specify LDAP server to read data from
28-
-l LDAP_USER specify LDAP user for reading data from LDAP server
29-
-a ldap_authfile specify path to file to open and read LDAP authtok
30-
-d passfd specify open fd to read PASS
31-
-f passfile specify path to file to open and read PASS
32-
-e ENDPOINT specify REST endpoint
33-
(default = {ENDPOINT})
34-
-o outfile specify output file (default: write to stdout)
35-
-g filter_group filter users by group name (eg, 'ap1-login')
36-
-m localmaps specify a comma-delimited list of local HTCondor mapfiles to merge into outfile
37-
-n min_users Specify minimum number of users required to update the output file (default: 100)
38-
-h display this help text
23+
-u USER[:PASS] specify USER and optionally PASS on command line
24+
-c OSG_CO_ID specify OSG CO ID (default = {OSG_CO_ID})
25+
-l LDAP_CONFIG_PATH specify path to LDAP Config file for fallback-search servers
26+
-d passfd specify open fd to read PASS
27+
-f passfile specify path to file to open and read PASS
28+
-e ENDPOINT specify REST endpoint (default = {ENDPOINT})
29+
-o outfile specify output file (default: write to stdout)
30+
-g filter_group filter users by group name (eg, 'ap1-login')
31+
-m localmaps specify a comma-delimited list of local HTCondor mapfiles to merge into outfile
32+
-n min_users Specify minimum number of users required to update the output file (default: 100)
33+
-h display this help text
3934
4035
PASS for USER is taken from the first of:
4136
1. -u USER:PASS
4237
2. -d passfd (read from fd)
4338
3. -f passfile (read from file)
4439
4. read from $PASS env var
40+
41+
{utils.LDAP_CONFIG_USAGE_MESSAGE}
42+
4543
"""
4644

4745
def usage(msg=None):
@@ -58,10 +56,8 @@ class Options:
5856
osg_co_id = OSG_CO_ID
5957
outfile = None
6058
authstr = None
61-
ldap_server = LDAP_SERVER
62-
ldap_user = LDAP_USER
63-
ldap_authtok = None
6459
filtergrp = None
60+
ldap_config = None
6561
min_users = 100 # Bail out before updating the file if we have fewer than this many users
6662
localmaps = []
6763

@@ -80,7 +76,7 @@ def get_osg_co_groups__map():
8076

8177
def parse_options(args):
8278
try:
83-
ops, args = getopt.getopt(args, 'u:c:s:l:a:d:f:g:e:o:h:n:m:')
79+
ops, args = getopt.getopt(args, 'u:c:l:d:f:g:e:o:h:n:m:')
8480
except getopt.GetoptError:
8581
usage()
8682

@@ -89,15 +85,13 @@ def parse_options(args):
8985

9086
passfd = None
9187
passfile = None
92-
ldap_authfile = None
88+
ldap_auth_path = None
9389

9490
for op, arg in ops:
9591
if op == '-h': usage()
9692
if op == '-u': options.user = arg
9793
if op == '-c': options.osg_co_id = int(arg)
98-
if op == '-s': options.ldap_server= arg
99-
if op == '-l': options.ldap_user = arg
100-
if op == '-a': ldap_authfile = arg
94+
if op == '-l': ldap_config_path = arg
10195
if op == '-d': passfd = int(arg)
10296
if op == '-f': passfile = arg
10397
if op == '-e': options.endpoint = arg
@@ -109,9 +103,13 @@ def parse_options(args):
109103
try:
110104
user, passwd = utils.getpw(options.user, passfd, passfile)
111105
options.authstr = utils.mkauthstr(user, passwd)
112-
options.ldap_authtok = utils.get_ldap_authtok(ldap_authfile)
113106
except PermissionError:
114107
usage("PASS required")
108+
109+
try:
110+
options.ldap_config = utils.read_ldap_conffile(ldap_config_path)
111+
except utils.EmptyConfiguration:
112+
usage("LDAP Config File Required. Was empty or lacked a valid server configuration.")
115113

116114
def _deduplicate_list(items):
117115
""" Deduplicate a list while maintaining order by converting it to a dictionary and then back to a list.
@@ -120,7 +118,7 @@ def _deduplicate_list(items):
120118
return list(dict.fromkeys(items))
121119

122120
def get_osguser_groups(filter_group_name=None):
123-
ldap_users = utils.get_ldap_active_users_and_groups(options.ldap_server, options.ldap_user, options.ldap_authtok, filter_group_name)
121+
ldap_users = utils.get_ldap_active_users_and_groups(filter_group_name=filter_group_name, config=options.ldap_config)
124122
topology_projects = requests.get(f"{TOPOLOGY_ENDPOINT}/miscproject/json").json()
125123
project_names = topology_projects.keys()
126124

0 commit comments

Comments
 (0)