blob: 975ed33a2cef19cb566fd0b3fc2dec5703a13cb0 [file] [log] [blame]
Zack Williams7f87d3e2020-06-05 12:36:39 -07001#!/usr/bin/env python3
Joey Armstronge4b58002023-11-27 10:01:03 -05002# -----------------------------------------------------------------------
3# Copyright 2020-2023 Open Networking Foundation (ONF) and the ONF Contributors
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16# -----------------------------------------------------------------------
17# SPDX-FileCopyrightText: 2020-2023 Open Networking Foundation (ONF) and the ONF Contributors <support@opennetworking.org>
Zack Williams7f87d3e2020-06-05 12:36:39 -070018# SPDX-License-Identifier: Apache-2.0
19
20from __future__ import absolute_import
21
22import argparse
23import json
24import logging
25import urllib.request
26import urllib.parse
27import base64
28
29# create shared logger
30logging.basicConfig()
31logger = logging.getLogger("cg")
32
33# global dict of jsonpath expressions -> compiled jsonpath parsers, as
34# reparsing expressions in each loop results in 100x longer execution time
35gjpaths = {}
36
37# gerrit headers is cached
38gerrit_headers = []
39
40# gerrit_groups is a cache of groups, to prevent multiple lookups
41gerrit_groups = {}
42
43
44def parse_crowdgroups_args():
45 """
46 parse CLI arguments
47 """
48
49 parser = argparse.ArgumentParser(description="Crowd-Gerrit group sync")
50
51 # Positional args
52 parser.add_argument(
53 "settings", type=argparse.FileType("r"), help="JSON settings file",
54 )
55
56 parser.add_argument(
57 "--debug", action="store_true", help="Print additional debugging information"
58 )
59
60 return parser.parse_args()
61
62
63def basic_auth_header(username, password):
64 """
65 returns a tuple containing a http basic auth header
66 """
67 creds_str = "%s:%s" % (username, password)
68 creds_b64 = base64.standard_b64encode(creds_str.encode("utf-8"))
69
70 return ("Authorization", "Basic %s" % creds_b64.decode("utf-8"))
71
72
73def json_api_get(url, headers, data=None, trim_prefix=False, allow_failure=False):
74 """
75 Call JSON API endpoint, return data as a dict
76 """
77
78 # if data included, encode it as JSON
79 if data:
80 data_enc = str(json.dumps(data)).encode("utf-8")
81
82 request = urllib.request.Request(url, data=data_enc, method="POST")
83 request.add_header("Content-Type", "application/json; charset=UTF-8")
84 else:
85 request = urllib.request.Request(url)
86
87 # add headers tuples
88 for header in headers:
89 request.add_header(*header)
90
91 try:
92 response = urllib.request.urlopen(request)
93 except urllib.error.HTTPError:
94 # asking for data that doesn't exist results in a 404, just return nothing
95 if allow_failure:
96 return None
97 logger.exception("Server encountered an HTTPError at URL: '%s'", url)
98 except urllib.error.URLError:
99 logger.exception("An URLError occurred at URL: '%s'", url)
100 else:
101 # docs: https://docs.python.org/3/library/json.html
102 jsondata = response.read()
103 logger.debug("API response: %s", jsondata)
104
105 # optionally remove the gerrit "magic prefix" - docs:
106 # https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
107 if trim_prefix:
108 jsondata = jsondata.lstrip(b")]}'\n")
109
110 try:
111 data = json.loads(jsondata)
112 except json.decoder.JSONDecodeError:
113 # allow return of no data
114 if allow_failure:
115 return None
116 logger.exception("Unable to decode JSON")
117 else:
118 logger.debug("JSON decoded: %s", data)
119
120 return data
121
122
123def crowd_users_list(crowd_settings, groupname):
124 """
125 Returns dict of email addresses mapped to usernames from Crowd, given a
126 group name
127 """
128
129 url = "%s/rest/usermanagement/1/group/user/direct?groupname=%s&expand=user" % (
130 crowd_settings["url"],
131 groupname,
132 )
133
134 crowd_headers = [
135 basic_auth_header(crowd_settings["username"], crowd_settings["password"]),
136 ("Accept", "application/json"),
137 ]
138
139 cout = json_api_get(url, crowd_headers)
140
141 userlist = {}
142
143 for user in cout["users"]:
144 userlist[user["email"]] = user["name"]
145
146 return userlist
147
148
149def gerrit_group_id_by_name(gerrit_settings, groupname):
150 """
151 Returns a gerrit group ID given the name of a group
152 Note - this is the "id" not the "group_id" in the dict.
153 """
154
155 # read from cache if found
156 if groupname not in gerrit_groups:
157
158 gurl = "%s/groups/?m=%s" % (gerrit_settings["url"], groupname)
159 gout = json_api_get(gurl, gerrit_headers, None, True)
160
161 gerrit_groups[groupname] = gout[groupname]["id"]
162
163 logger.debug(
164 "Group: '%s' has group-id: '%s'", groupname, gerrit_groups[groupname]
165 )
166
167 return gerrit_groups[groupname]
168
169
170def gerrit_users_list(gerrit_settings, groupname):
171 """
172 Returns dict of lists of usernames and emails from Gerrit, given a group name
173 """
174
175 gid = gerrit_group_id_by_name(gerrit_settings, groupname)
176
177 gurl = "%s/groups/%s/members" % (gerrit_settings["url"], gid)
178 gout = json_api_get(gurl, gerrit_headers, None, True)
179
180 userlist = {}
181
182 for user in gout:
183 userlist[user["email"]] = {
184 "_account_id": user.get("_account_id"),
185 "username": user.get("username", None),
186 }
187
188 return userlist
189
190
191def gerrit_check_crowd_id(gerrit_settings, aid, crowdusername):
192 """
193 checks if a gerrit account has an external CrowdID identity string
194 """
195
196 gurl = "%s/accounts/%s/external_ids" % (gerrit_settings["url"], aid)
197 gout = json_api_get(gurl, gerrit_headers, None, True)
198
199 crowd_id_string = (
200 "https://crowd.opennetworking.org/openidserver/users/%s" % crowdusername
201 )
202
203 for identity in gout:
204 if identity["identity"] == crowd_id_string:
205 return aid
206
207 return None
208
209
210def gerrit_find_account_id(gerrit_settings, email, crowdusername):
211 """
212 Returns a gerrit account ID given the email address of a user and the
213 corresponding crowd username, or None if no account exists.
214 """
215
216 gurl = "%s/accounts/?q=email:%s" % (gerrit_settings["url"], email)
217 gout = json_api_get(gurl, gerrit_headers, None, True, True)
218
219 # check for users with multiple email addresses
220 if len(gout) > 1:
221 logger.warning("Email: '%s' register for more than one Gerrit account", email)
222 return None
223
224 # may not have an account, return None if no account
225 if len(gout) == 0:
226 logger.warning("Email: '%s' doesn't have a Gerrit account", email)
227 return None
228
229 aid_dict = gout[0]
230 logger.debug("Email: '%s' has account-id: '%s'", email, aid_dict["_account_id"])
231
232 return aid_dict["_account_id"]
233
234
235def gerrit_lookup_account_ids(gerrit_settings, users):
236 """
237 Returns a list of account-ids given a list of user tuples
238 """
239
240 account_ids = []
241
242 for user in users:
243 aid = gerrit_find_account_id(gerrit_settings, *user)
244
245 if aid:
246 account_ids.append(aid)
247
248 return account_ids
249
250
251def gerrit_add_accounts_to_group(gerrit_settings, groupname, account_ids):
252 """
253 Given a group name and a list of account-ids, add accounts from group
254 """
255
256 gid = gerrit_group_id_by_name(gerrit_settings, groupname)
257
258 # https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#_add_group_members
259 members = {"members": account_ids}
260
261 gurl = "%s/groups/%s/members.add" % (gerrit_settings["url"], gid)
262 gout = json_api_get(gurl, gerrit_headers, members, True)
263
264 logger.debug("output of adding accounts to group: %s", gout)
265
266
267def gerrit_remove_accounts_from_group(gerrit_settings, groupname, account_ids):
268 """
269 Given a group name and a list of account-ids, remove accounts from group
270 """
271
272 gid = gerrit_group_id_by_name(gerrit_settings, groupname)
273
274 # https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#_add_group_members
275 members = {"members": account_ids}
276
277 gurl = "%s/groups/%s/members.delete" % (gerrit_settings["url"], gid)
278 gout = json_api_get(gurl, gerrit_headers, members, True, True)
279
280 logger.debug("output of removing accounts to group: %s", gout)
281
282
283def sync_group(settings, groupname):
284 """
285 sync a group from Crowd to Gerrit
286 """
287
288 cusers = crowd_users_list(settings["crowd"], groupname)
289
290 gusers = gerrit_users_list(settings["gerrit"], groupname)
291
292 logger.info("Crowd users: %s" % cusers)
293 logger.info("Gerrit users: %s" % gusers)
294
295 # lists of users to add or remove from gerrit
296 add_g = []
297 rem_g = list(gusers.keys()) # default to removing all users
298
299 # go through list of crowd users by email
300 for cemail, cuserid in cusers.items():
301 # if a user is found, remove from rem_g list
302 if cemail in gusers:
303 rem_g.remove(cemail)
304 else:
305 add_g.append((cemail, cuserid))
306
307 logger.info("Users to add: %s" % add_g)
308 logger.info("Users to remove: %s" % rem_g)
309
310 add_aids = gerrit_lookup_account_ids(settings["gerrit"], add_g)
311
312 logger.info("Account-ids to add: %s" % add_aids)
313
314 gerrit_aids = [gusers[e]["_account_id"] for e in gusers.keys()]
315
316 logger.info("All Gerrit account-ids: %s" % gerrit_aids)
317
318 aa_nodupes = [a for a in add_aids if a not in gerrit_aids]
319
320 logger.info("Duplicates removed: %s" % aa_nodupes)
321
322 if aa_nodupes:
323 gerrit_add_accounts_to_group(settings["gerrit"], groupname, aa_nodupes)
324
325 remove_aids = [gusers[e]["_account_id"] for e in rem_g]
326
327 # user with a different primary email could be in both add and remove lists
328 rem_filtered = [a for a in remove_aids if a not in add_aids]
329
330 logger.info("Account-ids to remove: %s" % rem_filtered)
331
332 if rem_filtered:
333 gerrit_remove_accounts_from_group(settings["gerrit"], groupname, rem_filtered)
334
335
336# main function that calls other functions
337if __name__ == "__main__":
338
339 args = parse_crowdgroups_args()
340
341 # only print log messages if debugging
342 if args.debug:
343 logger.setLevel(logging.DEBUG)
344 else:
345 logger.setLevel(logging.CRITICAL)
346
347 # load settings from JSON file
348 settings = json.loads(args.settings.read())
349
350 # global, so this isn't run multiple times
351 gerrit_headers = [
352 basic_auth_header(
353 settings["gerrit"]["username"], settings["gerrit"]["password"]
354 ),
355 ]
356
357 # sync each group
358 for groupname in settings["groups"]:
359 sync_group(settings, groupname)
Joey Armstronge4b58002023-11-27 10:01:03 -0500360
361# [EOF]