From 95c8403369f71036f6c1087e2674cb61668de9c2 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Wed, 29 Apr 2020 16:41:19 +0200 Subject: [PATCH] user CRUD with keycloak Allow an admin to create, update and delete users in keycloak and gerrit via a single command in the sfmanager CLI. closes: TG-3545 Change-Id: I457b03f0316770021f65b62fe9bc2855875b1f42 --- sfmanager/sfauth.py | 36 ++++-- sfmanager/sfmanager.py | 195 ++++++++++++++++++++++++++++++--- sfmanager/tests/test_sfauth.py | 2 +- 3 files changed, 203 insertions(+), 30 deletions(-) diff --git a/sfmanager/sfauth.py b/sfmanager/sfauth.py index eeb6fde..5d8b020 100644 --- a/sfmanager/sfauth.py +++ b/sfmanager/sfauth.py @@ -34,15 +34,23 @@ class IntrospectionNotAvailableError(Exception): pass -def get_jwt(remote_gateway, username, password): - if 'keycloak' not in remote_gateway: - # assumption, might backfire - if not remote_gateway.startswith('https://'): - wk_root = 'https://' + remote_gateway +def get_jwt(remote_gateway, username, password, verify=True): + if (not remote_gateway.startswith('https://') and + not remote_gateway.startswith('http://')): + wk_root = 'https://' + remote_gateway else: wk_root = remote_gateway - wk_url = "%s/auth/realms/sf/.well-known/openid-configuration" - wk = requests.get(wk_url % wk_root, verify=True).json() + # TODO should be selectable + if username == "admin": + realm = "master" + client_id = "admin-cli" + else: + realm = "sf" + client_id = "managesf" + wk_url = ("%s/auth/realms/" + + realm + + "/.well-known/openid-configuration") + wk = requests.get(wk_url % wk_root, verify=verify).json() token_endpoint = wk.get('token_endpoint') if token_endpoint is None: raise Exception('No Token Endpoint defined at %s' % (wk_url % wk_root)) @@ -50,9 +58,9 @@ def get_jwt(remote_gateway, username, password): 'username': username, 'password': password, 'grant_type': 'password', - 'client_id': 'managesf', + 'client_id': client_id, } - token_request = requests.post(token_endpoint, data, verify=True) + token_request = requests.post(token_endpoint, data=data, verify=verify) if (int(token_request.status_code) >= 400 and int(token_request.status_code) < 500): raise Exception('Incorrect username/password combination') @@ -128,7 +136,11 @@ def get_cauth_info(auth_server, verify=True): def get_managesf_info(server, verify=True): - url = "%s/about/" % server + if not server.endswith('manage'): + _server = "%s/manage" % server + else: + _server = server + url = "%s/about/" % _server return _get_service_info(url, verify) @@ -139,7 +151,7 @@ def get_auth_params(server, api_key=None, use_ssl=True, verify=True): - services = get_managesf_info(server)['service']['services'] + services = get_managesf_info(server, verify)['service']['services'] params = {'cookies': None, 'headers': None} if 'keycloak' in services: @@ -150,7 +162,7 @@ def get_auth_params(server, } } else: - extras = get_jwt(server, username, password) + extras = get_jwt(server, username, password, verify) else: cookie = get_cookie(server, username, password, github_access_token, api_key, use_ssl, diff --git a/sfmanager/sfmanager.py b/sfmanager/sfmanager.py index a1d3961..7b600b2 100644 --- a/sfmanager/sfmanager.py +++ b/sfmanager/sfmanager.py @@ -24,6 +24,8 @@ import git import requests import sqlite3 +import shlex +import subprocess import sys import time try: @@ -191,15 +193,11 @@ def load_rc_file(args): "no rc file found" % args.env) -def fail_if_keycloak(func): +def fail_if_keycloak(args): """Actions that cannot be run without cauth.""" - def wrapper_func(args, base_url): - services = sfauth.get_managesf_info(args.url)['service']['services'] - if 'keycloak' in services: - die("This action is only available through the cauth service") - else: - return func(args, base_url) - return wrapper_func + services = sfauth.get_managesf_info(args.url)['service']['services'] + if 'keycloak' in services: + die("This action is only available through the cauth service") def default_arguments(parser): @@ -232,6 +230,9 @@ def default_arguments(parser): parser.add_argument('--debug', default=False, action='store_true', help='enable debug messages in console, ' 'disabled by default') + parser.add_argument('--gerrit-admin-key', + default="/root/.ssh/gerrit_admin", + help='path to gerrit admin ssh private key') def user_management_command(parser): @@ -243,7 +244,7 @@ def user_management_command(parser): cump.add_argument('--username', '-u', nargs='?', metavar='username', required=True, help='A unique username/login') cump.add_argument('--password', '-p', nargs='?', metavar='password', - required=True, + required=False, help='The user password, can be provided interactively' ' if this option is empty') cump.add_argument('--email', '-e', nargs='?', metavar='email', @@ -285,6 +286,11 @@ def sf_user_management_command(parser): required=True, help="The user's full name") create.add_argument('--email', '-e', nargs='?', metavar='email', required=True, help="The user's email") + create.add_argument('--ssh-key', '-s', nargs='?', + metavar='/path/to/pubkey', + required=False, help="The user's ssh public key file") + create.add_argument('--password', '-p', nargs='?', metavar='password', + required=False, help="the user's password") sfu_sub.add_parser('list', help='list all registered users') delete = sfu_sub.add_parser('delete', help='de-register a user from SF') delete.add_argument('--username', '-u', nargs='?', metavar='username', @@ -440,7 +446,6 @@ def build_url(*args): return '/'.join(s.strip('/') for s in args) + '/' -@fail_if_keycloak def apikey_action(args, base_url): url = base_url + '/apikey' if args.command != 'apikey': @@ -449,6 +454,8 @@ def apikey_action(args, base_url): if args.subcommand not in ['create', 'delete', 'get']: return False + fail_if_keycloak(args) + if args.subcommand == 'get': resp = request('get', url) return response(resp) @@ -559,12 +566,119 @@ def github_action(args, base_url): return False -@fail_if_keycloak def user_management_action(args, base_url): if args.command != 'user': return False if args.subcommand not in ['create', 'update', 'delete']: return False + services = sfauth.get_managesf_info( + args.url, + not args.insecure)['service']['services'] + if 'keycloak' in services: + return keycloak_user_management_action(args, base_url) + else: + return cauth_user_management_action(args, base_url) + + +def keycloak_user_management_action(args, base_url): + base = args.url.rstrip('/') + url = base + '/auth/admin/realms/sf/users' + if args.subcommand == 'create': + password = None + if args.password is None: + if not getattr(args, 'email'): + die("email required if no password is provided.") + print("An email will be sent to %s " + "to set the user's password." % args.email) + elif args.password: + password = args.password + userInfo = {"username": args.username, + "enabled": True} + if getattr(args, 'email'): + userInfo['email'] = args.email + if getattr(args, 'fullname'): + userInfo['firstName'] = args.fullname[0] + if len(args.fullname) > 1: + userInfo['lastName'] = args.fullname[1] + if password is not None: + userInfo['credentials'] = [{"type": "password", + "value": password}] + resp = request('post', url, json=userInfo) + if resp.ok: + if getattr(args, 'ssh_key') or (password is None): + kc_user = request('get', + url + ('?username=%s' % args.username)) + userInfo = kc_user.json()[0] + if getattr(args, 'ssh_key'): + with open(args.ssh_key, 'r') as f: + userInfo['attributes'] = {"publicKey": [f.read()]} + resp = request('put', url + "/" + userInfo["id"], + json=userInfo) + # send an email to the user to complete registration + if password is None: + action_url = url + "/%s/execute-actions-email" % userInfo['id'] + action_resp = request('put', action_url, + json=["UPDATE_PASSWORD", + "UPDATE_PROFILE"]) + if not action_resp.ok: + print(action_resp.text) + print("Provisioning services ...") + else: + die('Could not create user "%s": "%s"' % (args.username, + resp.text)) + elif args.subcommand == 'delete': + resp = request('get', + url + ('?username=%s' % args.username)) + if resp.ok: + kc_users = resp.json() + if len(kc_users) != 1: + die('%i user(s) found as username "%s"' % (len(kc_users), + args.username)) + user_id = kc_users[0]['id'] + del_resp = request('delete', url + "/" + user_id) + if del_resp.ok: + print("Deleting in services ...") + else: + die('Error deleting user "%s": %s' % (args.username, + del_resp.text)) + else: + die("Error during user lookup: %s" % resp.text) + else: + resp = request('get', + url + ('?username=%s' % args.username)) + if resp.ok: + kc_users = resp.json() + if len(kc_users) != 1: + die('%i user(s) found as username "%s"' % (len(kc_users), + args.username)) + userInfo = kc_users[0] + user_id = userInfo['id'] + if getattr(args, 'email'): + userInfo['email'] = args.email + if getattr(args, 'fullname'): + userInfo['firstName'] = args.fullname[0] + if len(args.fullname) > 1: + userInfo['lastName'] = args.fullname[1] + password = getattr(args, 'password') + if password is not None: + userInfo['credentials'] = [{"type": "password", + "value": password}] + if getattr(args, 'ssh_key'): + with open(args.ssh_key, 'r') as f: + userInfo['attributes'] = {"publicKey": [f.read()]} + update_resp = request('put', url + "/" + user_id, json=userInfo) + if update_resp.ok: + print("Updating in services ...") + else: + die('Updating user "%s" failed: %s' % (args.username, + update_resp.text)) + else: + die("Error during user lookup: %s" % resp.text) + keycloak_services_users_management_action(args, base_url) + return response(resp) + + +def cauth_user_management_action(args, base_url): url = build_url(base_url, 'user', args.username) if args.subcommand in ['create', 'update']: password = None @@ -641,12 +755,7 @@ def project_action(args, base_url): return True -@fail_if_keycloak -def services_users_management_action(args, base_url): - if args.command != 'sf_user': - return False - if args.subcommand not in ['create', 'list', 'delete']: - return False +def cauth_services_users_management_action(args, base_url): url = build_url(base_url, 'services_users') if args.subcommand in ['create', 'delete']: info = {} @@ -676,6 +785,58 @@ def services_users_management_action(args, base_url): return response(resp) +def keycloak_services_users_management_action(args, base_url): + print("Handling user in gerrit...") + cmd = "ssh admin@gerrit -p 29418 -i %s gerrit " % args.gerrit_admin_key + if args.subcommand in ['delete', 'update']: + cmd += "set-account " + if args.subcommand == 'delete': + # you can't really delete a user in gerrit + cmd += "--inactive " + else: + cmd += "create-account " + if args.subcommand in ['create', 'update']: + if getattr(args, 'fullname'): + cmd += " --full-name \"'%s'\"" % ' '.join(args.fullname) + if getattr(args, 'email'): + if args.subcommand == 'update': + cmd += " --add-email %s" % args.email + else: + cmd += " --email %s" % args.email + if getattr(args, 'password'): + cmd += " --http-password %s" % args.password + if getattr(args, 'username'): + cmd += " %s" % args.username + try: + env = os.environ.copy() + env['LC_ALL'] = 'en_US.UTF-8' + output = subprocess.check_output( + shlex.split(cmd), stderr=subprocess.STDOUT, + env=env).decode('utf-8') + except subprocess.CalledProcessError as err: + if err.output: + die('Command "%s" failed with the following ' + 'error message: "%s"' % (cmd, err.output)) + else: + die("Error with user management in gerrit: %s" % err) + print(output) + return True + + +def services_users_management_action(args, base_url): + if args.command != 'sf_user': + return False + if args.subcommand not in ['create', 'list', 'delete']: + return False + services = sfauth.get_managesf_info( + args.url, + not args.insecure)['service']['services'] + if 'keycloak' in services: + return keycloak_services_users_management_action(args, base_url) + else: + return cauth_services_users_management_action(args, base_url) + + def main(): parser = argparse.ArgumentParser( description="Software Factory CLI") diff --git a/sfmanager/tests/test_sfauth.py b/sfmanager/tests/test_sfauth.py index 7d8ccb9..4ecb6af 100644 --- a/sfmanager/tests/test_sfauth.py +++ b/sfmanager/tests/test_sfauth.py @@ -107,7 +107,7 @@ def test_get_managesf_info(self): 'version': 'x.y.z', 'services': ['gerrit', ]}}) i = sfauth.get_managesf_info('https://auth.tests.dom') - g.assert_called_with('https://auth.tests.dom/about/', + g.assert_called_with('https://auth.tests.dom/manage/about/', allow_redirects=False, verify=True) self.assertEqual('managesf',