Skip to content

Commit

Permalink
New feature: import users from a CSV file
Browse files Browse the repository at this point in the history
Bump to version 0.0.8
  • Loading branch information
bitonio committed Mar 2, 2022
1 parent d7ff593 commit 357ec9e
Show file tree
Hide file tree
Showing 2 changed files with 201 additions and 30 deletions.
229 changes: 200 additions & 29 deletions bin/akamai-mfa
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
Expand All @@ -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):
Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
<internal>
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.
</internal>
"""
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}")
2 changes: 1 addition & 1 deletion cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"commands": [
{
"name": "mfa",
"version": "0.0.7",
"version": "0.0.8",
"description": "Akamai CLI for MFA"
}
]
Expand Down

0 comments on commit 357ec9e

Please sign in to comment.