From 357ec9e5b2dca9654a30b3e400e9aff658b0411b Mon Sep 17 00:00:00 2001 From: Antoine Drochon Date: Wed, 2 Mar 2022 14:49:46 -0800 Subject: [PATCH] New feature: import users from a CSV file Bump to version 0.0.8 --- bin/akamai-mfa | 229 ++++++++++++++++++++++++++++++++++++++++++------- cli.json | 2 +- 2 files changed, 201 insertions(+), 30 deletions(-) diff --git a/bin/akamai-mfa b/bin/akamai-mfa index ff8d065..7ace7c5 100755 --- a/bin/akamai-mfa +++ b/bin/akamai-mfa @@ -37,21 +37,36 @@ import configparser import sys import os import json +import csv + #: cli-mfa version, see also cli.json -__VERSION__ = "0.0.7" +__VERSION__ = "0.0.8" #: Log formatting aligned with other CLIs LOG_FMT = '%(asctime)s [%(levelname)s] %(threadName)s %(message)s' #: Near real-time, 30s ago is the most recent by default MOST_RECENT_PADDING = 30 log_file = None +logger = logging.getLogger() mfa_api_url = "https://mfa.akamai.com" mfa_api_ver = "v1" tail_pull_interval = 60 # Default is 60 seconds epilog = '''Copyright (C) Akamai Technologies, Inc\n''' \ '''Visit http://github.com/akamai/cli-mfa for detailed documentation''' +class cli(): + """ + TODO: share this module with other cli-* + """ + + @staticmethod + def write(s): + print(s) + + @staticmethod + def current_command(): + return "akamai mfa " + " ".join(sys.argv[1:]) class MFAConfig(): """ @@ -60,7 +75,9 @@ class MFAConfig(): CONFIG_KEYS = [ 'mfa_integration_id', - 'mfa_signing_key' + 'mfa_signing_key', + 'mfa_api_integration_id', + 'mfa_api_signing_key' ] def __init__(self): @@ -85,12 +102,19 @@ class MFAConfig(): eventparser.add_argument("--noreceipt", default=False, action="store_true", help="Discard the receipt attribute to save log space") + loaduserparser = subparsers.add_parser('importusers', help="Import users from a CSV file") + loaduserparser.add_argument("--file", "-f", help="CSV file used as input") + loaduserparser.add_argument("--ignore-header", "-i", dest="ignore_header", default=False, action="store_true", + help="Ignore the first line (if header is present)") + loaduserparser.add_argument("--fullname-format", "-n", dest="fullname_format", default="{firstname} {lastname}", + help="Full name formatting (default is '{firstname} {lastname}')") + self.parser.add_argument("--edgerc", type=str, default="~/.edgerc", help='Location of the credentials file (default is "~/.edgerc")') self.parser.add_argument("--section", default="default", help="Section inside .edgerc, default is [default]") self.parser.add_argument("--debug", '-d', action="store_true", default=False, help="Debug mode") - self.parser.add_argument('--user-agent-prefix', dest='ua_prefix', default='Akamai-CLI', help=argparse.SUPPRESS) + self.parser.add_argument("--user-agent-prefix", dest='ua_prefix', default='Akamai-CLI', help=argparse.SUPPRESS) try: scanned_cli_args = self.parser.parse_args() @@ -113,11 +137,15 @@ class MFAConfig(): if key in MFAConfig.CONFIG_KEYS: setattr(self, key, value) - # And the environment variable + # And the environment variables if os.getenv('MFA_INTEGRATION_ID'): self.integration_id = os.getenv('MFA_INTEGRATION_ID') if os.getenv('MFA_SIGNING_KEY'): self.signing_key = os.getenv('MFA_SIGNING_KEY') + if os.getenv('MFA_API_INTEGRATION_ID'): + self.api_integration_id = os.getenv('MFA_API_INTEGRATION_ID') + if os.getenv('MFA_API_SIGNING_KEY'): + self.api_signing_key = os.getenv('MFA_API_SIGNING_KEY') self.validate() @@ -131,18 +159,50 @@ class MFAConfig(): self.parser.print_help() +class BaseAPI(object): + + api_version = None # Specific API can be overriden on derivated class + + def __init__(self, config, signing_key=None, integration_id=None): + self.config = config + self._session = requests.Session() + self._session.headers.update({'User-Agent': f'{config.ua_prefix} cli-mfa/{__VERSION__}'}) + if not signing_key or not integration_id: # use the default integration creds + self._session.auth = AkamaiMFAAuth(config.mfa_signing_key, config.mfa_integration_id, self.api_version) + else: + self._session.auth = AkamaiMFAAuth(signing_key, integration_id, self.api_version) + + def get(self, url, params=None): + url = f"{mfa_api_url}{url}" + api_response = self._session.get(url, params=params) + return api_response.json() + + def post(self, url, params=None, json=None): + url = f"{mfa_api_url}{url}" + api_response = self._session.post(url, params=params, json=json) + return api_response.json() + class AkamaiMFAAuth(requests.auth.AuthBase): """ Akamai MFA API authentication for Requests. """ - def __init__(self, config): - self._config = config + def __init__(self, signing_key, integration_id, api_version=None): + """ + Args: + config (MFAConfig): cli-mfa config + api_version (_type_): API version in the backend - optional + """ + self._signing_key = signing_key + self._integration_id = integration_id self._content_type_json = {'Content-Type': 'application/json'} + self._api_version = '2021-07-15' + if api_version: + self._api_version = api_version def get_signature(self, t): signature = hmac.new( - key=self._config.mfa_signing_key.encode("utf-8"), + key=self._signing_key.encode("utf-8"), msg=str(t).encode("utf-8"), digestmod=hashlib.sha256).hexdigest() return signature @@ -151,38 +211,22 @@ class AkamaiMFAAuth(requests.auth.AuthBase): now = str(int(time.time())) signature = self.get_signature(now) self._headers = { - 'X-Pushzero-Id': self._config.mfa_integration_id, + 'X-Pushzero-Id': self._integration_id, 'X-Pushzero-Signature': signature, 'X-Pushzero-Signature-Time': now, - 'X-Api-Version': '2021-07-15'} + 'X-Api-Version': self._api_version} r.headers.update(self._headers) r.headers.update(self._content_type_json) return r -if __name__ == "__main__": +class EventAPI(object): - config = MFAConfig() - - logging.basicConfig(filename=log_file, level=logging.INFO, format=LOG_FMT) - - if config.debug: - HTTPConnection.debuglevel = 1 - logging.getLogger().setLevel(logging.DEBUG) - requests_log = logging.getLogger("urllib3") - requests_log.setLevel(logging.DEBUG) - requests_log.propagate = True - - if config.command is None: - config.display_help() - sys.exit(1) - if config.command == "version": - print(__VERSION__) - sys.exit(0) - elif config.command == 'event': + @staticmethod + def pull_events(): session = requests.Session() session.headers.update({'User-Agent': f'{config.ua_prefix} cli-mfa/{__VERSION__}'}) - session.auth = AkamaiMFAAuth(config) + session.auth = AkamaiMFAAuth(config.mfa_signing_key, config.mfa_integration_id) api_url = f'{mfa_api_url}/api/{mfa_api_ver}/control/reports/auths' scan_end = datetime.datetime.utcnow() - datetime.timedelta(seconds=MOST_RECENT_PADDING) @@ -227,5 +271,132 @@ if __name__ == "__main__": scan_end = datetime.datetime.utcnow() - datetime.timedelta(seconds=MOST_RECENT_PADDING) else: break + + +class IdentityManagementAPI(BaseAPI): + + api_version = "2022-02-22" + + """ + Manage users/groups on Akamai MFA + + Args: + object (_type_): _description_ + """ + def list_groups(self): + """ + Fetch the list of groups visible in Akamai MFA. + + https://pushzero-staging.akamai.com/api/v1/open_api.html#get-/api/v1/control/groups + You will need to get all of the groups currently in the system in order to manage + the “group name” -> “group id” lookup since all apis related to groups operate on + their ids, not their names. + + """ + return self.get("/api/v1/control/groups") + + def create_group(self, group_name, group_summary=None): + """Create a new group in MFA backend.""" + payload = { + "name": group_name, + "summary": group_summary + } + return self.post("/api/v1/control/groups", json=payload) + + def create_users(self, users): + payload = {"ignore_conflicts": True, "users": users} + logger.debug("create_users payload %s" % payload) + return self.post("/api/v1/control/users/bulk/create", json=payload) + + def associate_users_to_group(self, usernames, group_id): + logger.debug(f"Adding users {usernames} to group {group_id}") + return self.post(f"/api/v1/control/groups/{group_id}/users/bulk/associate", json=usernames) + + def import_users_from_csv(self, csv_filename, ignore_header, fullname_format): + """ + Read a CSV file containing user identity, and one group + Create the users, groups and association in the Akamai MFA backend. + + Args: + csv_filename (_type_): path to the csv file + ignore_header (_type_): ignore the first line of the file + fullname_format (_type_): How the user Fullname should be formatted + """ + new_users = [] + scanned_groups = set() + user_groupname_map = {} + + # Parse the CSV file to prepare the API calls + with open(csv_filename) as csvfile: + reader = csv.reader(csvfile) + if ignore_header: + next(reader) + for row in reader: + newuser = { + 'full_name': fullname_format.format(firstname=row[1], lastname=row[2]), + 'last_name': row[2], + 'email': row[0], + 'username': row[3] + } + user_groupname_map[row[3]] = row[4] + scanned_groups.add(row[4]) + new_users.append(newuser) + + # First, let's figure out groups, existing vs. ones in the input file + count_group_added = 0 + count_group_existing = 0 + existing_groups = self.list_groups() + groups_map = {g.get('id'): g.get('name') for g in existing_groups.get('result').get('page')} + reverse_groups_map = {g.get('name'): g.get('id') for g in existing_groups.get('result').get('page')} + for g in scanned_groups: + if g not in groups_map.values(): + r = self.create_group(g, f"Created with command: {cli.current_command()}") + groups_map[r.get('result').get('id')] = r.get('result').get('name') + reverse_groups_map[r.get('result').get('name')] = r.get('result').get('id') + count_group_added += 1 + else: + count_group_existing += 1 + logger.debug(f"Group {g} was already present, not added.") + logger.debug("Final groups: %s" % groups_map) + cli.write(f"{count_group_added} group(s) added, {count_group_existing} group(s) were already existing") + + # Second, bulk user insertion + new_users_response = self.create_users(new_users) + logger.debug("new_users: %s" % new_users_response) + count_user_added = len(new_users_response.get('result', {}).get('created', [])) + count_user_exist = len(new_users_response.get('result', {}).get('existing', [])) + cli.write(f"{count_user_added} user(s) added, {count_user_exist} user(s) where already existing and left unchanged") + + # Third, associate user to group + for new_user in new_users_response.get('result', {}).get('created', []): + group_id = reverse_groups_map[user_groupname_map[new_user.get('username')]] + self.associate_users_to_group([new_user.get('id')], group_id) + + +if __name__ == "__main__": + + config = MFAConfig() + + logging.basicConfig(filename=log_file, level=logging.INFO, format=LOG_FMT) + + if config.debug: + HTTPConnection.debuglevel = 1 + logger.setLevel(logging.DEBUG) + requests_log = logging.getLogger("urllib3") + requests_log.setLevel(logging.DEBUG) + requests_log.propagate = True + + if config.command is None: + config.display_help() + sys.exit(1) + elif config.command == "version": + print(__VERSION__) + sys.exit(0) + elif config.command == 'event': + EventAPI.pull_events() + elif config.command == 'importusers': + logger.debug("starting import...") + identity = IdentityManagementAPI(config, config.mfa_api_signing_key, config.mfa_api_integration_id) + identity.import_users_from_csv(config.file, config.ignore_header, config.fullname_format) else: raise ValueError(f"Unsupported command: {config.command}") diff --git a/cli.json b/cli.json index c244e11..35b0ef8 100644 --- a/cli.json +++ b/cli.json @@ -5,7 +5,7 @@ "commands": [ { "name": "mfa", - "version": "0.0.7", + "version": "0.0.8", "description": "Akamai CLI for MFA" } ]