Repository: softlayer/softlayer-python
Pull request #978 (2018-06-06 19:57:03)

User Management
# `slcli user` 

## New Commands
*  **create:**            Creates a user Users.
*  **detail:**            User details.
*  **edit-details:**      Edit a Users details JSON strings should be...
*  **edit-permissions:**  Enable or Disable specific permissions.
*  **list:**              List Users.
*  **permissions:**       User Permissions.


Patch:
diff --git a/.gitignore b/.gitignore
index ebf9932d..e34d2250 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,5 @@ dist/*
 *.egg-info
 .cache
 .idea
+.pytest_cache/*
+slcli
diff --git a/SoftLayer/API.py b/SoftLayer/API.py
index b1557537..92ee27b1 100644
--- a/SoftLayer/API.py
+++ b/SoftLayer/API.py
@@ -333,6 +333,7 @@ class Service(object):
         :param name str: The service name
 
     """
+
     def __init__(self, client, name):
         self.client = client
         self.name = name
diff --git a/SoftLayer/CLI/columns.py b/SoftLayer/CLI/columns.py
index 18a9cea1..50bf8976 100644
--- a/SoftLayer/CLI/columns.py
+++ b/SoftLayer/CLI/columns.py
@@ -14,6 +14,7 @@
 
 class Column(object):
     """Column desctribes an attribute and how to fetch/display it."""
+
     def __init__(self, name, path, mask=None):
         self.name = name
         self.path = path
@@ -26,6 +27,7 @@ def __init__(self, name, path, mask=None):
 
 class ColumnFormatter(object):
     """Maps each column using a function"""
+
     def __init__(self):
         self.columns = []
         self.column_funcs = []
diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py
index e7586380..a02bf65a 100644
--- a/SoftLayer/CLI/core.py
+++ b/SoftLayer/CLI/core.py
@@ -115,17 +115,22 @@ def cli(env,
         **kwargs):
     """Main click CLI entry-point."""
 
-    logger = logging.getLogger()
-    logger.addHandler(logging.StreamHandler())
-    logger.setLevel(DEBUG_LOGGING_MAP.get(verbose, logging.DEBUG))
-
     # Populate environement with client and set it as the context object
     env.skip_confirmations = really
     env.config_file = config
     env.format = format
     env.ensure_client(config_file=config, is_demo=demo, proxy=proxy)
-
     env.vars['_start'] = time.time()
+    logger = logging.getLogger()
+
+    if demo is False:
+        logger.addHandler(logging.StreamHandler())
+    else:
+        # This section is for running CLI tests.
+        logging.getLogger("urllib3").setLevel(logging.WARNING)
+        logger.addHandler(logging.NullHandler())
+
+    logger.setLevel(DEBUG_LOGGING_MAP.get(verbose, logging.DEBUG))
     env.vars['_timings'] = SoftLayer.DebugTransport(env.client.transport)
     env.client.transport = env.vars['_timings']
 
diff --git a/SoftLayer/CLI/exceptions.py b/SoftLayer/CLI/exceptions.py
index 98b3f283..e3ae7ef4 100644
--- a/SoftLayer/CLI/exceptions.py
+++ b/SoftLayer/CLI/exceptions.py
@@ -10,6 +10,7 @@
 # pylint: disable=keyword-arg-before-vararg
 class CLIHalt(SystemExit):
     """Smoothly halt the execution of the command. No error."""
+
     def __init__(self, code=0, *args):
         super(CLIHalt, self).__init__(*args)
         self.code = code
@@ -23,6 +24,7 @@ def __str__(self):
 
 class CLIAbort(CLIHalt):
     """Halt the execution of the command. Gives an exit code of 2."""
+
     def __init__(self, msg, *args):
         super(CLIAbort, self).__init__(code=2, *args)
         self.message = msg
@@ -30,6 +32,7 @@ def __init__(self, msg, *args):
 
 class ArgumentError(CLIAbort):
     """Halt the execution of the command because of invalid arguments."""
+
     def __init__(self, msg, *args):
         super(ArgumentError, self).__init__(msg, *args)
         self.message = "Argument Error: %s" % msg
diff --git a/SoftLayer/CLI/file/snapshot/schedule_list.py b/SoftLayer/CLI/file/snapshot/schedule_list.py
index c83c50da..0d2a321d 100644
--- a/SoftLayer/CLI/file/snapshot/schedule_list.py
+++ b/SoftLayer/CLI/file/snapshot/schedule_list.py
@@ -63,7 +63,7 @@ def cli(env, volume_id):
             file_schedule_type,
             replication,
             schedule.get('createDate', '')
-            ]
+        ]
         table_row.extend(schedule_properties)
         table.add_row(table_row)
 
diff --git a/SoftLayer/CLI/formatting.py b/SoftLayer/CLI/formatting.py
index 403ba8a6..48e27133 100644
--- a/SoftLayer/CLI/formatting.py
+++ b/SoftLayer/CLI/formatting.py
@@ -1,10 +1,8 @@
 """
     SoftLayer.formatting
     ~~~~~~~~~~~~~~~~~~~~
-    Provider classes and helper functions to display output onto a
-    command-line.
+    Provider classes and helper functions to display output onto a command-line.
 
-    :license: MIT, see LICENSE for more details.
 """
 # pylint: disable=E0202, consider-merging-isinstance, arguments-differ, keyword-arg-before-vararg
 import collections
@@ -12,7 +10,12 @@
 import os
 
 import click
-import prettytable
+
+# If both PTable and prettytable are installed, its impossible to use the new version
+try:
+    from prettytable import prettytable
+except ImportError:
+    import prettytable
 
 from SoftLayer.CLI import exceptions
 from SoftLayer import utils
@@ -229,6 +232,7 @@ class SequentialOutput(list):
 
     :param separator str: string to use as a default separator
     """
+
     def __init__(self, separator=os.linesep, *args, **kwargs):
         self.separator = separator
         super(SequentialOutput, self).__init__(*args, **kwargs)
@@ -243,6 +247,7 @@ def __str__(self):
 
 class CLIJSONEncoder(json.JSONEncoder):
     """A JSON encoder which is able to use a .to_python() method on objects."""
+
     def default(self, obj):
         """Encode object if it implements to_python()."""
         if hasattr(obj, 'to_python'):
@@ -255,7 +260,8 @@ class Table(object):
 
     :param list columns: a list of column names
     """
-    def __init__(self, columns):
+
+    def __init__(self, columns, title=None):
         duplicated_cols = [col for col, count
                            in collections.Counter(columns).items()
                            if count > 1]
@@ -267,6 +273,7 @@ def __init__(self, columns):
         self.rows = []
         self.align = {}
         self.sortby = None
+        self.title = title
 
     def add_row(self, row):
         """Add a row to the table.
@@ -287,6 +294,7 @@ def to_python(self):
     def prettytable(self):
         """Returns a new prettytable instance."""
         table = prettytable.PrettyTable(self.columns)
+
         if self.sortby:
             if self.sortby in self.columns:
                 table.sortby = self.sortby
@@ -296,6 +304,8 @@ def prettytable(self):
         for a_col, alignment in self.align.items():
             table.align[a_col] = alignment
 
+        if self.title:
+            table.title = self.title
         # Adding rows
         for row in self.rows:
             table.add_row(row)
@@ -304,6 +314,7 @@ def prettytable(self):
 
 class KeyValueTable(Table):
     """A table that is oriented towards key-value pairs."""
+
     def to_python(self):
         """Decode this KeyValueTable object to standard Python types."""
         mapping = {}
@@ -318,6 +329,7 @@ class FormattedItem(object):
         :param original: raw (machine-readable) value
         :param string formatted: human-readable value
     """
+
     def __init__(self, original, formatted=None):
         self.original = original
         if formatted is not None:
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index 9a91c80e..cf861371 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -282,6 +282,14 @@
     ('ticket:attach', 'SoftLayer.CLI.ticket.attach:cli'),
     ('ticket:detach', 'SoftLayer.CLI.ticket.detach:cli'),
 
+    ('user', 'SoftLayer.CLI.user'),
+    ('user:list', 'SoftLayer.CLI.user.list:cli'),
+    ('user:detail', 'SoftLayer.CLI.user.detail:cli'),
+    ('user:permissions', 'SoftLayer.CLI.user.permissions:cli'),
+    ('user:edit-permissions', 'SoftLayer.CLI.user.edit_permissions:cli'),
+    ('user:edit-details', 'SoftLayer.CLI.user.edit_details:cli'),
+    ('user:create', 'SoftLayer.CLI.user.create:cli'),
+
     ('vlan', 'SoftLayer.CLI.vlan'),
     ('vlan:detail', 'SoftLayer.CLI.vlan.detail:cli'),
     ('vlan:list', 'SoftLayer.CLI.vlan.list:cli'),
diff --git a/SoftLayer/CLI/user/__init__.py b/SoftLayer/CLI/user/__init__.py
new file mode 100644
index 00000000..256c7e9c
--- /dev/null
+++ b/SoftLayer/CLI/user/__init__.py
@@ -0,0 +1,1 @@
+"""Manage Users."""
diff --git a/SoftLayer/CLI/user/create.py b/SoftLayer/CLI/user/create.py
new file mode 100644
index 00000000..ed6d7474
--- /dev/null
+++ b/SoftLayer/CLI/user/create.py
@@ -0,0 +1,100 @@
+"""Creates a user """
+# :license: MIT, see LICENSE for more details.
+
+import json
+import string
+import sys
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import exceptions
+from SoftLayer.CLI import formatting
+from SoftLayer.CLI import helpers
+
+
+@click.command()
+@click.argument('username')
+@click.option('--email', '-e', required=True,
+              help="Email address for this user. Required for creation.")
+@click.option('--password', '-p', default=None, show_default=True,
+              help="Password to set for this user. If no password is provided, user will be sent an email "
+                   "to generate one, which expires in 24 hours.  '-p generate' will create a password for you "
+                   "(Requires Python 3.6+). Passwords require 8+ characters, upper and lowercase, a number "
+                   "and a symbol.")
+@click.option('--from-user', '-u', default=None,
+              help="Base user to use as a template for creating this user. "
+                   "Will default to the user running this command. Information provided in --template "
+                   "supersedes this template.")
+@click.option('--template', '-t', default=None,
+              help="A json string describing https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/")
+@click.option('--api-key', '-a', default=False, is_flag=True, help="Create an API key for this user.")
+@environment.pass_env
+def cli(env, username, email, password, from_user, template, api_key):
+    """Creates a user Users.
+
+    :Example: slcli user create my@email.com -e my@email.com -p generate -a
+    -t '{"firstName": "Test", "lastName": "Testerson"}'
+
+    Remember to set the permissions and access for this new user.
+    """
+
+    mgr = SoftLayer.UserManager(env.client)
+    user_mask = ("mask[id, firstName, lastName, email, companyName, address1, city, country, postalCode, "
+                 "state, userStatusId, timezoneId]")
+    from_user_id = None
+    if from_user is None:
+        user_template = mgr.get_current_user(objectmask=user_mask)
+        from_user_id = user_template['id']
+    else:
+        from_user_id = helpers.resolve_id(mgr.resolve_ids, from_user, 'username')
+        user_template = mgr.get_user(from_user_id, objectmask=user_mask)
+    # If we send the ID back to the API, an exception will be thrown
+    del user_template['id']
+
+    if template is not None:
+        try:
+            template_object = json.loads(template)
+            for key in template_object:
+                user_template[key] = template_object[key]
+        except ValueError as ex:
+            raise exceptions.ArgumentError("Unable to parse --template. %s" % ex)
+
+    user_template['username'] = username
+    if password == 'generate':
+        password = generate_password()
+
+    user_template['email'] = email
+
+    if not env.skip_confirmations:
+        table = formatting.KeyValueTable(['name', 'value'])
+        for key in user_template:
+            table.add_row([key, user_template[key]])
+        table.add_row(['password', password])
+        click.secho("You are about to create the following user...", fg='green')
+        env.fout(table)
+        if not formatting.confirm("Do you wish to continue?"):
+            raise exceptions.CLIAbort("Canceling creation!")
+
+    result = mgr.create_user(user_template, password)
+    new_api_key = None
+    if api_key:
+        click.secho("Adding API key...", fg='green')
+        new_api_key = mgr.add_api_authentication_key(result['id'])
+
+    table = formatting.Table(['Username', 'Email', 'Password', 'API Key'])
+    table.add_row([result['username'], result['email'], password, new_api_key])
+    env.fout(table)
+
+
+def generate_password():
+    """Returns a 23 character random string, with 3 special characters at the end"""
+    if sys.version_info > (3, 6):
+        import secrets  # pylint: disable=import-error
+        alphabet = string.ascii_letters + string.digits
+        password = ''.join(secrets.choice(alphabet) for i in range(20))
+        special = ''.join(secrets.choice(string.punctuation) for i in range(3))
+        return password + special
+    else:
+        raise ImportError("Generating passwords require python 3.6 or higher")
diff --git a/SoftLayer/CLI/user/detail.py b/SoftLayer/CLI/user/detail.py
new file mode 100644
index 00000000..49887448
--- /dev/null
+++ b/SoftLayer/CLI/user/detail.py
@@ -0,0 +1,153 @@
+"""User details."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.CLI import helpers
+from SoftLayer import utils
+
+
+@click.command()
+@click.argument('identifier')
+@click.option('--keys', '-k', is_flag=True, default=False,
+              help="Show the users API key.")
+@click.option('--permissions', '-p', is_flag=True, default=False,
+              help="Display permissions assigned to this user. Master users will show no permissions")
+@click.option('--hardware', '-h', is_flag=True, default=False,
+              help="Display hardware this user has access to.")
+@click.option('--virtual', '-v', is_flag=True, default=False,
+              help="Display virtual guests this user has access to.")
+@click.option('--logins', '-l', is_flag=True, default=False,
+              help="Show login history of this user for the last 30 days")
+@click.option('--events', '-e', is_flag=True, default=False,
+              help="Show audit log for this user.")
+@environment.pass_env
+def cli(env, identifier, keys, permissions, hardware, virtual, logins, events):
+    """User details."""
+
+    mgr = SoftLayer.UserManager(env.client)
+    user_id = helpers.resolve_id(mgr.resolve_ids, identifier, 'username')
+    object_mask = "userStatus[name], parent[id, username], apiAuthenticationKeys[authenticationKey], "\
+                  "unsuccessfulLogins, successfulLogins"
+
+    user = mgr.get_user(user_id, object_mask)
+    env.fout(basic_info(user, keys))
+
+    if permissions:
+        perms = mgr.get_user_permissions(user_id)
+        env.fout(print_permissions(perms))
+    if hardware:
+        mask = "id, hardware, dedicatedHosts"
+        access = mgr.get_user(user_id, mask)
+        env.fout(print_dedicated_access(access.get('dedicatedHosts', [])))
+        env.fout(print_access(access.get('hardware', []), 'Hardware'))
+    if virtual:
+        mask = "id, virtualGuests"
+        access = mgr.get_user(user_id, mask)
+        env.fout(print_access(access.get('virtualGuests', []), 'Virtual Guests'))
+    if logins:
+        login_log = mgr.get_logins(user_id)
+        env.fout(print_logins(login_log))
+    if events:
+        event_log = mgr.get_events(user_id)
+        env.fout(print_events(event_log))
+
+
+def basic_info(user, keys):
+    """Prints a table of basic user information"""
+
+    table = formatting.KeyValueTable(['Title', 'Basic Information'])
+    table.align['Title'] = 'r'
+    table.align['Basic Information'] = 'l'
+
+    table.add_row(['Id', user.get('id', '-')])
+    table.add_row(['Username', user.get('username', '-')])
+    if keys:
+        for key in user.get('apiAuthenticationKeys'):
+            table.add_row(['APIKEY', key.get('authenticationKey')])
+    table.add_row(['Name', "%s %s" % (user.get('firstName', '-'), user.get('lastName', '-'))])
+    table.add_row(['Email', user.get('email')])
+    table.add_row(['OpenID', user.get('openIdConnectUserName')])
+    address = "%s %s %s %s %s %s" % (
+        user.get('address1'), user.get('address2'), user.get('city'), user.get('state'),
+        user.get('country'), user.get('postalCode'))
+    table.add_row(['Address', address])
+    table.add_row(['Company', user.get('companyName')])
+    table.add_row(['Created', user.get('createDate')])
+    table.add_row(['Phone Number', user.get('officePhone')])
+    if user.get('parentId', False):
+        table.add_row(['Parent User', utils.lookup(user, 'parent', 'username')])
+    table.add_row(['Status', utils.lookup(user, 'userStatus', 'name')])
+    table.add_row(['PPTP VPN', user.get('pptpVpnAllowedFlag', 'No')])
+    table.add_row(['SSL VPN', user.get('sslVpnAllowedFlag', 'No')])
+    for login in user.get('unsuccessfulLogins', {}):
+        login_string = "%s From: %s" % (login.get('createDate'), login.get('ipAddress'))
+        table.add_row(['Last Failed Login', login_string])
+        break
+    for login in user.get('successfulLogins', {}):
+        login_string = "%s From: %s" % (login.get('createDate'), login.get('ipAddress'))
+        table.add_row(['Last Login', login_string])
+        break
+
+    return table
+
+
+def print_permissions(permissions):
+    """Prints out a users permissions"""
+
+    table = formatting.Table(['keyName', 'Description'])
+    for perm in permissions:
+        table.add_row([perm['keyName'], perm['name']])
+    return table
+
+
+def print_access(access, title):
+    """Prints out the hardware or virtual guests a user can access"""
+
+    columns = ['id', 'hostname', 'Primary Public IP', 'Primary Private IP', 'Created']
+    table = formatting.Table(columns, title)
+
+    for host in access:
+        host_id = host.get('id')
+        host_fqdn = host.get('fullyQualifiedDomainName', '-')
+        host_primary = host.get('primaryIpAddress')
+        host_private = host.get('primaryBackendIpAddress')
+        host_created = host.get('provisionDate')
+        table.add_row([host_id, host_fqdn, host_primary, host_private, host_created])
+    return table
+
+
+def print_dedicated_access(access):
+    """Prints out the dedicated hosts a user can access"""
+
+    table = formatting.Table(['id', 'Name', 'Cpus', 'Memory', 'Disk', 'Created'], 'Dedicated Access')
+    for host in access:
+        host_id = host.get('id')
+        host_fqdn = host.get('name')
+        host_cpu = host.get('cpuCount')
+        host_mem = host.get('memoryCapacity')
+        host_disk = host.get('diskCapacity')
+        host_created = host.get('createDate')
+        table.add_row([host_id, host_fqdn, host_cpu, host_mem, host_disk, host_created])
+    return table
+
+
+def print_logins(logins):
+    """Prints out the login history for a user"""
+    table = formatting.Table(['Date', 'IP Address', 'Successufl Login?'])
+    for login in logins:
+        table.add_row([login.get('createDate'), login.get('ipAddress'), login.get('successFlag')])
+    return table
+
+
+def print_events(events):
+    """Prints out the event log for a user"""
+    columns = ['Date', 'Type', 'IP Address', 'label', 'username']
+    table = formatting.Table(columns)
+    for event in events:
+        table.add_row([event.get('eventCreateDate'), event.get('eventName'),
+                       event.get('ipAddress'), event.get('label'), event.get('username')])
+    return table
diff --git a/SoftLayer/CLI/user/edit_details.py b/SoftLayer/CLI/user/edit_details.py
new file mode 100644
index 00000000..024421ed
--- /dev/null
+++ b/SoftLayer/CLI/user/edit_details.py
@@ -0,0 +1,43 @@
+"""List Users."""
+# :license: MIT, see LICENSE for more details.
+
+
+import json
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import exceptions
+from SoftLayer.CLI import helpers
+
+
+@click.command()
+@click.argument('user')
+@click.option('--template', '-t', required=True,
+              help="A json string describing https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/")
+@environment.pass_env
+def cli(env, user, template):
+    """Edit a Users details
+
+    JSON strings should be enclosed in '' and each item should be enclosed in ""
+
+    :Example: slcli user edit-details testUser -t '{"firstName": "Test", "lastName": "Testerson"}'
+    """
+    mgr = SoftLayer.UserManager(env.client)
+    user_id = helpers.resolve_id(mgr.resolve_ids, user, 'username')
+
+    user_template = {}
+    if template is not None:
+        try:
+            template_object = json.loads(template)
+            for key in template_object:
+                user_template[key] = template_object[key]
+        except ValueError as ex:
+            raise exceptions.ArgumentError("Unable to parse --template. %s" % ex)
+
+    result = mgr.edit_user(user_id, user_template)
+    if result:
+        click.secho("%s updated successfully" % (user), fg='green')
+    else:
+        click.secho("Failed to update %s" % (user), fg='red')
diff --git a/SoftLayer/CLI/user/edit_permissions.py b/SoftLayer/CLI/user/edit_permissions.py
new file mode 100644
index 00000000..e86ee256
--- /dev/null
+++ b/SoftLayer/CLI/user/edit_permissions.py
@@ -0,0 +1,38 @@
+"""Enable or Disable specific permissions for a user"""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import helpers
+
+
+@click.command()
+@click.argument('identifier')
+@click.option('--enable/--disable', default=True,
+              help="Enable (DEFAULT) or Disable selected permissions")
+@click.option('--permission', '-p', multiple=True,
+              help="Permission keyName to set, multiple instances allowed. "
+                   "Use keyword ALL to select ALL permisssions")
+@click.option('--from-user', '-u', default=None,
+              help="Set permissions to match this user's permissions. "
+                   "Will add then remove the appropriate permissions")
+@environment.pass_env
+def cli(env, identifier, enable, permission, from_user):
+    """Enable or Disable specific permissions."""
+    mgr = SoftLayer.UserManager(env.client)
+    user_id = helpers.resolve_id(mgr.resolve_ids, identifier, 'username')
+    result = False
+    if from_user:
+        from_user_id = helpers.resolve_id(mgr.resolve_ids, from_user, 'username')
+        result = mgr.permissions_from_user(user_id, from_user_id)
+    elif enable:
+        result = mgr.add_permissions(user_id, permission)
+    else:
+        result = mgr.remove_permissions(user_id, permission)
+
+    if result:
+        click.secho("Permissions updated successfully: %s" % ", ".join(permission), fg='green')
+    else:
+        click.secho("Failed to update permissions: %s" % ", ".join(permission), fg='red')
diff --git a/SoftLayer/CLI/user/list.py b/SoftLayer/CLI/user/list.py
new file mode 100644
index 00000000..8e79fe6b
--- /dev/null
+++ b/SoftLayer/CLI/user/list.py
@@ -0,0 +1,48 @@
+"""List Users."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import columns as column_helper
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+COLUMNS = [
+    column_helper.Column('id', ('id',)),
+    column_helper.Column('username', ('username',)),
+    column_helper.Column('email', ('email',)),
+    column_helper.Column('displayName', ('displayName',)),
+    column_helper.Column('status', ('userStatus', 'name')),
+    column_helper.Column('hardwareCount', ('hardwareCount',)),
+    column_helper.Column('virtualGuestCount', ('virtualGuestCount',))
+]
+
+DEFAULT_COLUMNS = [
+    'id',
+    'username',
+    'email',
+    'displayName'
+]
+
+
+@click.command()
+@click.option('--columns',
+              callback=column_helper.get_formatter(COLUMNS),
+              help='Columns to display. [options: %s]' % ', '.join(column.name for column in COLUMNS),
+              default=','.join(DEFAULT_COLUMNS),
+              show_default=True)
+@environment.pass_env
+def cli(env, columns):
+    """List Users."""
+
+    mgr = SoftLayer.UserManager(env.client)
+    users = mgr.list_users()
+
+    table = formatting.Table(columns.columns)
+    for user in users:
+        table.add_row([value or formatting.blank()
+                       for value in columns.row(user)])
+
+    env.fout(table)
diff --git a/SoftLayer/CLI/user/permissions.py b/SoftLayer/CLI/user/permissions.py
new file mode 100644
index 00000000..b1851acb
--- /dev/null
+++ b/SoftLayer/CLI/user/permissions.py
@@ -0,0 +1,57 @@
+"""List A users permissions."""
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.CLI import helpers
+
+
+@click.command()
+@click.argument('identifier')
+@environment.pass_env
+def cli(env, identifier):
+    """User Permissions. TODO change to list all permissions, and which users have them"""
+
+    mgr = SoftLayer.UserManager(env.client)
+    user_id = helpers.resolve_id(mgr.resolve_ids, identifier, 'username')
+    object_mask = "mask[id, permissions, isMasterUserFlag, roles]"
+
+    user = mgr.get_user(user_id, object_mask)
+    all_permissions = mgr.get_all_permissions()
+    user_permissions = perms_to_dict(user['permissions'])
+
+    if user['isMasterUserFlag']:
+        click.secho('This account is the Master User and has all permissions enabled', fg='green')
+
+    env.fout(roles_table(user))
+    env.fout(permission_table(user_permissions, all_permissions))
+
+
+def perms_to_dict(perms):
+    """Takes a list of permissions and transforms it into a dictionary for better searching"""
+    permission_dict = {}
+    for perm in perms:
+        permission_dict[perm['keyName']] = True
+    return permission_dict
+
+
+def permission_table(user_permissions, all_permissions):
+    """Creates a table of available permissions"""
+
+    table = formatting.Table(['Description', 'KeyName', 'Assigned'])
+    table.align['KeyName'] = 'l'
+    table.align['Description'] = 'l'
+    table.align['Assigned'] = 'l'
+    for perm in all_permissions:
+        assigned = user_permissions.get(perm['keyName'], False)
+        table.add_row([perm['name'], perm['keyName'], assigned])
+    return table
+
+
+def roles_table(user):
+    """Creates a table for a users roles"""
+    table = formatting.Table(['id', 'Role Name', 'Description'])
+    for role in user['roles']:
+        table.add_row([role['id'], role['name'], role['description']])
+    return table
diff --git a/SoftLayer/CLI/virt/__init__.py b/SoftLayer/CLI/virt/__init__.py
index 90c2ad1f..cff6da44 100644
--- a/SoftLayer/CLI/virt/__init__.py
+++ b/SoftLayer/CLI/virt/__init__.py
@@ -30,4 +30,5 @@ def convert(self, value, param, ctx):  # pylint: disable=inconsistent-return-sta
         elif unit in ['g', 'gb']:
             return amount * 1024
 
+
 MEM_TYPE = MemoryType()
diff --git a/SoftLayer/auth.py b/SoftLayer/auth.py
index 66049232..57a911e7 100644
--- a/SoftLayer/auth.py
+++ b/SoftLayer/auth.py
@@ -42,6 +42,7 @@ class TokenAuthentication(AuthenticationBase):
         :param auth_token str: a user's auth token, attained through
                                User_Customer::getPortalLoginToken
     """
+
     def __init__(self, user_id, auth_token):
         self.user_id = user_id
         self.auth_token = auth_token
@@ -65,6 +66,7 @@ class BasicAuthentication(AuthenticationBase):
         :param username str: a user's username
         :param api_key str: a user's API key
     """
+
     def __init__(self, username, api_key):
         self.username = username
         self.api_key = api_key
@@ -87,6 +89,7 @@ class BasicHTTPAuthentication(AuthenticationBase):
         :param username str: a user's username
         :param api_key str: a user's API key
     """
+
     def __init__(self, username, api_key):
         self.username = username
         self.api_key = api_key
diff --git a/SoftLayer/exceptions.py b/SoftLayer/exceptions.py
index 450dc23e..5652730f 100644
--- a/SoftLayer/exceptions.py
+++ b/SoftLayer/exceptions.py
@@ -21,6 +21,7 @@ class SoftLayerAPIError(SoftLayerError):
 
     Provides faultCode and faultString properties.
     """
+
     def __init__(self, fault_code, fault_string, *args):
         SoftLayerError.__init__(self, fault_string, *args)
         self.faultCode = fault_code
diff --git a/SoftLayer/fixtures/SoftLayer_Account.py b/SoftLayer/fixtures/SoftLayer_Account.py
index 4a59000b..586e597a 100644
--- a/SoftLayer/fixtures/SoftLayer_Account.py
+++ b/SoftLayer/fixtures/SoftLayer_Account.py
@@ -559,3 +559,19 @@
     'cpuCount': 56,
     'id': 44701
 }]
+
+
+getUsers = [
+    {'displayName': 'ChristopherG',
+     'hardwareCount': 138,
+     'id': 11100,
+     'userStatus': {'name': 'Active'},
+     'username': 'SL1234',
+     'virtualGuestCount': 99},
+    {'displayName': 'PulseL',
+     'hardwareCount': 100,
+     'id': 11111,
+     'userStatus': {'name': 'Active'},
+     'username': 'sl1234-abob',
+     'virtualGuestCount': 99}
+]
diff --git a/SoftLayer/fixtures/SoftLayer_Event_Log.py b/SoftLayer/fixtures/SoftLayer_Event_Log.py
new file mode 100644
index 00000000..8d9ce1e4
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_Event_Log.py
@@ -0,0 +1,22 @@
+getAllObjects = [
+    {
+        "accountId": 1234,
+        "eventCreateDate": "2018-05-15T14:37:13.378291-06:00",
+        "eventName": "Login Successful",
+        "ipAddress": "1.2.3.4",
+        "label": "sl1234-aaa",
+        "metaData": "",
+        "objectId": 6657767,
+        "objectName": "User",
+        "openIdConnectUserName": "a@b.com",
+        "resource": {
+            "accountId": 307608,
+            "address1": "4849 Alpha Rd",
+            "city": "Dallas"
+        },
+        "traceId": "5afb44f95c61f",
+        "userId": 6657767,
+        "userType": "CUSTOMER",
+        "username": "sl1234-aaa"
+    }
+]
diff --git a/SoftLayer/fixtures/SoftLayer_User_Customer.py b/SoftLayer/fixtures/SoftLayer_User_Customer.py
new file mode 100644
index 00000000..32dc1770
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_User_Customer.py
@@ -0,0 +1,81 @@
+getObject = {
+    'accountId': 12345,
+    'address1': '315 Test Street',
+    'apiAuthenticationKeys': [{'authenticationKey': 'aaaaaaaaaaaaaaaaaaaaaaaaa'}],
+    'city': 'Houston',
+    'companyName': 'SoftLayer Development Community',
+    'country': 'US',
+    'createDate': '2014-08-18T12:58:02-06:00',
+    'displayName': 'Test',
+    'email': 'test@us.ibm.com',
+    'firstName': 'Test',
+    'id': 244956,
+    'isMasterUserFlag': False,
+    'lastName': 'Testerson',
+    'openIdConnectUserName': 'test@us.ibm.com',
+    'parent': {'id': 167758, 'username': 'SL12345'},
+    'parentId': 167758,
+    'postalCode': '77002',
+    'pptpVpnAllowedFlag': False,
+    'sslVpnAllowedFlag': True,
+    'state': 'TX',
+    'statusDate': None,
+    'successfulLogins': [
+        {'createDate': '2018-05-08T15:28:32-06:00',
+         'ipAddress': '175.125.126.118',
+         'successFlag': True,
+         'userId': 244956},
+    ],
+    'timezone': {
+        'id': 113,
+        'longName': '(GMT-06:00) America/Chicago - CST',
+        'name': 'America/Chicago',
+        'offset': '-0600',
+        'shortName': 'CST'},
+    'timezoneId': 113,
+    'unsuccessfulLogins': [
+        {'createDate': '2018-02-09T14:13:15-06:00',
+         'ipAddress': '73.136.219.36',
+         'successFlag': False,
+         'userId': 244956},
+    ],
+    'userStatus': {'name': 'Active'},
+    'userStatusId': 1001,
+    'username': 'SL12345-test',
+    'vpnManualConfig': False,
+    'permissions': [
+        {'key': 'ALL_1',
+         'keyName': 'ACCESS_ALL_HARDWARE',
+         'name': 'All Hardware Access'}
+    ],
+    'roles': []
+}
+
+getPermissions = [
+    {'key': 'ALL_1',
+     'keyName': 'ACCESS_ALL_HARDWARE',
+     'name': 'All Hardware Access'},
+    {'key': 'A_1',
+     'keyName': 'ACCOUNT_SUMMARY_VIEW',
+     'name': 'View Account Summary'},
+    {'key': 'A_10',
+     'keyName': 'ADD_SERVICE_STORAGE',
+     'name': 'Add/Upgrade Storage (StorageLayer)'}
+]
+
+
+getLoginAttempts = [
+    {
+        "createDate": "2017-10-03T09:28:33-06:00",
+        "ipAddress": "1.2.3.4",
+        "successFlag": False,
+        "userId": 1111,
+        "username": "sl1234"
+    }
+]
+
+addBulkPortalPermission = True
+removeBulkPortalPermission = True
+createObject = getObject
+editObject = True
+addApiAuthenticationKey = True
diff --git a/SoftLayer/fixtures/SoftLayer_User_Customer_CustomerPermission_Permission.py b/SoftLayer/fixtures/SoftLayer_User_Customer_CustomerPermission_Permission.py
new file mode 100644
index 00000000..5b747269
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_User_Customer_CustomerPermission_Permission.py
@@ -0,0 +1,42 @@
+getAllObjects = [
+    {
+        "key": "T_1",
+        "keyName": "TICKET_VIEW",
+        "name": "View Tickets"
+    },
+    {
+        "key": "T_2",
+        "keyName": "TEST",
+        "name": "A Testing Permission"
+    },
+    {
+        "key": "T_3",
+        "keyName": "TEST_3",
+        "name": "A Testing Permission 3"
+    },
+    {
+        "key": "T_4",
+        "keyName": "TEST_4",
+        "name": "A Testing Permission 4"
+    },
+    {
+        "key": "T_5",
+        "keyName": "ACCESS_ALL_HARDWARE",
+        "name": "A Testing Permission 5"
+    },
+    {
+        'key': 'ALL_1',
+        'keyName': 'ACCESS_ALL_HARDWARE',
+        'name': 'All Hardware Access'
+    },
+    {
+        'key': 'A_1',
+        'keyName': 'ACCOUNT_SUMMARY_VIEW',
+        'name': 'View Account Summary'
+    },
+    {
+        'key': 'A_10',
+        'keyName': 'ADD_SERVICE_STORAGE',
+        'name': 'Add/Upgrade Storage (StorageLayer)'
+    }
+]
diff --git a/SoftLayer/managers/__init__.py b/SoftLayer/managers/__init__.py
index 044da5a5..f0602579 100644
--- a/SoftLayer/managers/__init__.py
+++ b/SoftLayer/managers/__init__.py
@@ -25,8 +25,10 @@
 from SoftLayer.managers.sshkey import SshKeyManager
 from SoftLayer.managers.ssl import SSLManager
 from SoftLayer.managers.ticket import TicketManager
+from SoftLayer.managers.user import UserManager
 from SoftLayer.managers.vs import VSManager
 
+
 __all__ = [
     'BlockStorageManager',
     'CDNManager',
@@ -46,5 +48,6 @@
     'SshKeyManager',
     'SSLManager',
     'TicketManager',
+    'UserManager',
     'VSManager',
 ]
diff --git a/SoftLayer/managers/block.py b/SoftLayer/managers/block.py
index 74b5fb42..57f2044c 100644
--- a/SoftLayer/managers/block.py
+++ b/SoftLayer/managers/block.py
@@ -144,7 +144,7 @@ def get_block_volume_snapshot_list(self, volume_id, **kwargs):
                 'hourlySchedule',
                 'dailySchedule',
                 'weeklySchedule'
-                ]
+            ]
 
             kwargs['mask'] = ','.join(items)
 
diff --git a/SoftLayer/managers/file.py b/SoftLayer/managers/file.py
index 7b3894e3..e0a25032 100644
--- a/SoftLayer/managers/file.py
+++ b/SoftLayer/managers/file.py
@@ -142,7 +142,7 @@ def get_file_volume_snapshot_list(self, volume_id, **kwargs):
                 'hourlySchedule',
                 'dailySchedule',
                 'weeklySchedule'
-                ]
+            ]
             kwargs['mask'] = ','.join(items)
 
         return self.client.call('Network_Storage', 'getSnapshots',
diff --git a/SoftLayer/managers/hardware.py b/SoftLayer/managers/hardware.py
index 0b8d7a69..b23c9e1e 100644
--- a/SoftLayer/managers/hardware.py
+++ b/SoftLayer/managers/hardware.py
@@ -47,6 +47,7 @@ class HardwareManager(utils.IdentifierMixin, object):
                                               If none is provided, one will be
                                               auto initialized.
     """
+
     def __init__(self, client, ordering_manager=None):
         self.client = client
         self.hardware = self.client['Hardware_Server']
diff --git a/SoftLayer/managers/messaging.py b/SoftLayer/managers/messaging.py
index 8c0a0519..3ce1c17a 100644
--- a/SoftLayer/managers/messaging.py
+++ b/SoftLayer/managers/messaging.py
@@ -30,6 +30,7 @@ class QueueAuth(requests.auth.AuthBase):
     :param api_key: SoftLayer API Key
     :param auth_token: (optional) Starting auth token
     """
+
     def __init__(self, endpoint, username, api_key, auth_token=None):
         self.endpoint = endpoint
         self.username = username
@@ -79,6 +80,7 @@ class MessagingManager(object):
     :param SoftLayer.API.BaseClient client: the client instance
 
     """
+
     def __init__(self, client):
         self.client = client
 
@@ -152,6 +154,7 @@ class MessagingConnection(object):
     :param account_id: Message Queue Account id
     :param endpoint: Endpoint URL
     """
+
     def __init__(self, account_id, endpoint=None):
         self.account_id = account_id
         self.endpoint = endpoint
diff --git a/SoftLayer/managers/network.py b/SoftLayer/managers/network.py
index af7937de..265f325b 100644
--- a/SoftLayer/managers/network.py
+++ b/SoftLayer/managers/network.py
@@ -43,6 +43,7 @@ class NetworkManager(object):
     :param SoftLayer.API.BaseClient client: the client instance
 
     """
+
     def __init__(self, client):
         self.client = client
         self.account = client['Account']
diff --git a/SoftLayer/managers/user.py b/SoftLayer/managers/user.py
new file mode 100644
index 00000000..7031551c
--- /dev/null
+++ b/SoftLayer/managers/user.py
@@ -0,0 +1,268 @@
+"""
+    SoftLayer.user
+    ~~~~~~~~~~~~~
+    User Manager/helpers
+
+    :license: MIT, see LICENSE for more details.
+"""
+import datetime
+import logging
+from operator import itemgetter
+
+from SoftLayer import exceptions
+from SoftLayer import utils
+
+LOGGER = logging.getLogger(__name__)
+
+
+class UserManager(utils.IdentifierMixin, object):
+    """Manages Users.
+
+    See: https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/
+
+    Example::
+
+       # Initialize the Manager.
+       import SoftLayer
+       client = SoftLayer.create_client_from_env()
+       mgr = SoftLayer.UserManager(client)
+
+    :param SoftLayer.API.BaseClient client: the client instance
+
+    """
+
+    def __init__(self, client):
+        self.client = client
+        self.user_service = self.client['SoftLayer_User_Customer']
+        self.account_service = self.client['SoftLayer_Account']
+        self.resolvers = [self._get_id_from_username]
+        self.all_permissions = None
+
+    def list_users(self, objectmask=None, objectfilter=None):
+        """Lists all users on an account
+
+        :param string objectmask: Used to overwrite the default objectmask.
+        :param dictionary objectfilter: If you want to use an objectfilter.
+        :returns: A list of dictionaries that describe each user
+
+        Example::
+            result = mgr.list_users()
+        """
+
+        if objectmask is None:
+            objectmask = """mask[id, username, displayName, userStatus[name], hardwareCount, virtualGuestCount,
+                                 email, roles]"""
+
+        return self.account_service.getUsers(mask=objectmask, filter=objectfilter)
+
+    def get_user(self, user_id, objectmask=None):
+        """Calls SoftLayer_User_Customer::getObject
+
+        :param int user_id: Id of the user
+        :param string objectmask: default is 'mask[userStatus[name], parent[id, username]]'
+        :returns: A user object.
+        """
+        if objectmask is None:
+            objectmask = "mask[userStatus[name], parent[id, username]]"
+        return self.user_service.getObject(id=user_id, mask=objectmask)
+
+    def get_current_user(self, objectmask=None):
+        """Calls SoftLayer_Account::getCurrentUser"""
+
+        if objectmask is None:
+            objectmask = "mask[userStatus[name], parent[id, username]]"
+        return self.account_service.getCurrentUser(mask=objectmask)
+
+    def get_all_permissions(self):
+        """Calls SoftLayer_User_CustomerPermissions_Permission::getAllObjects
+
+        Stores the result in self.all_permissions
+        :returns: A list of dictionaries that contains all valid permissions
+        """
+        if self.all_permissions is None:
+            permissions = self.client.call('User_Customer_CustomerPermission_Permission', 'getAllObjects')
+            self.all_permissions = sorted(permissions, key=itemgetter('keyName'))
+        return self.all_permissions
+
+    def add_permissions(self, user_id, permissions):
+        """Enables a list of permissions for a user
+
+        :param int id: user id to set
+        :param list permissions: List of permissions keynames to enable
+        :returns: True on success, Exception otherwise
+
+        Example::
+            add_permissions(123, ['BANDWIDTH_MANAGE'])
+        """
+        pretty_permissions = self.format_permission_object(permissions)
+        LOGGER.warning("Adding the following permissions to %s: %s", user_id, pretty_permissions)
+        return self.user_service.addBulkPortalPermission(pretty_permissions, id=user_id)
+
+    def remove_permissions(self, user_id, permissions):
+        """Disables a list of permissions for a user
+
+        :param int id: user id to set
+        :param list permissions: List of permissions keynames to disable
+        :returns: True on success, Exception otherwise
+
+        Example::
+            remove_permissions(123, ['BANDWIDTH_MANAGE'])
+        """
+        pretty_permissions = self.format_permission_object(permissions)
+        LOGGER.warning("Removing the following permissions to %s: %s", user_id, pretty_permissions)
+        return self.user_service.removeBulkPortalPermission(pretty_permissions, id=user_id)
+
+    def permissions_from_user(self, user_id, from_user_id):
+        """Sets user_id's permission to be the same as from_user_id's
+
+        Any permissions from_user_id has will be added to user_id.
+        Any permissions from_user_id doesn't have will be removed from user_id.
+
+        :param int user_id: The user to change permissions.
+        :param int from_user_id: The use to base permissions from.
+        :returns: True on success, Exception otherwise.
+        """
+
+        from_permissions = self.get_user_permissions(from_user_id)
+        self.add_permissions(user_id, from_permissions)
+        all_permissions = self.get_all_permissions()
+        remove_permissions = []
+
+        for permission in all_permissions:
+            # If permission does not exist for from_user_id add it to the list to be removed
+            if _keyname_search(from_permissions, permission['keyName']):
+                continue
+            else:
+                remove_permissions.append({'keyName': permission['keyName']})
+
+        self.remove_permissions(user_id, remove_permissions)
+        return True
+
+    def get_user_permissions(self, user_id):
+        """Returns a sorted list of a users permissions"""
+        permissions = self.user_service.getPermissions(id=user_id)
+        return sorted(permissions, key=itemgetter('keyName'))
+
+    def get_logins(self, user_id, start_date=None):
+        """Gets the login history for a user, default start_date is 30 days ago
+
+        :param int id: User id to get
+        :param string start_date: "%m/%d/%Y %H:%M:%s" formatted string.
+        :returns: list https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer_Access_Authentication/
+        Example::
+            get_logins(123, '04/08/2018 0:0:0')
+        """
+
+        if start_date is None:
+            date_object = datetime.datetime.today() - datetime.timedelta(days=30)
+            start_date = date_object.strftime("%m/%d/%Y 0:0:0")
+
+        date_filter = {
+            'loginAttempts': {
+                'createDate': {
+                    'operation': 'greaterThanDate',
+                    'options': [{'name': 'date', 'value': [start_date]}]
+                }
+            }
+        }
+        login_log = self.user_service.getLoginAttempts(id=user_id, filter=date_filter)
+        return login_log
+
+    def get_events(self, user_id, start_date=None):
+        """Gets the event log for a specific user, default start_date is 30 days ago
+
+        :param int id: User id to view
+        :param string start_date: "%Y-%m-%dT%H:%M:%s.0000-06:00" is the full formatted string.
+                                  The Timezone part has to be HH:MM, notice the : there.
+        :returns: https://softlayer.github.io/reference/datatypes/SoftLayer_Event_Log/
+        """
+
+        if start_date is None:
+            date_object = datetime.datetime.today() - datetime.timedelta(days=30)
+            start_date = date_object.strftime("%Y-%m-%dT00:00:00")
+
+        object_filter = {
+            'userId': {
+                'operation': user_id
+            },
+            'eventCreateDate': {
+                'operation': 'greaterThanDate',
+                'options': [{'name': 'date', 'value': [start_date]}]
+            }
+        }
+
+        events = self.client.call('Event_Log', 'getAllObjects', filter=object_filter)
+        if events is None:
+            events = [{'eventName': 'No Events Found'}]
+        return events
+
+    def _get_id_from_username(self, username):
+        """Looks up a username's id
+
+        :param string username: Username to lookup
+        :returns: The id that matches username.
+        """
+        _mask = "mask[id, username]"
+        _filter = {'users': {'username': utils.query_filter(username)}}
+        user = self.list_users(_mask, _filter)
+        if len(user) == 1:
+            return [user[0]['id']]
+        elif len(user) > 1:
+            raise exceptions.SoftLayerError("Multiple users found with the name: %s" % username)
+        else:
+            raise exceptions.SoftLayerError("Unable to find user id for %s" % username)
+
+    def format_permission_object(self, permissions):
+        """Formats a list of permission key names into something the SLAPI will respect.
+
+        :param list permissions: A list of SLAPI permissions keyNames.
+                                 keyName of ALL will return all permissions.
+        :returns: list of dictionaries that can be sent to the api to add or remove permissions
+        :throws SoftLayerError: If any permission is invalid this exception will be thrown.
+        """
+        pretty_permissions = []
+        available_permissions = self.get_all_permissions()
+        # pp(available_permissions)
+        for permission in permissions:
+            # Handle data retrieved directly from the API
+            if isinstance(permission, dict):
+                permission = permission['keyName']
+            permission = permission.upper()
+            if permission == 'ALL':
+                return available_permissions
+            # Search through available_permissions to make sure what the user entered was valid
+            if _keyname_search(available_permissions, permission):
+                pretty_permissions.append({'keyName': permission})
+            else:
+                raise exceptions.SoftLayerError("'%s' is not a valid permission" % permission)
+        return pretty_permissions
+
+    def create_user(self, user_object, password):
+        """Blindly sends user_object to SoftLayer_User_Customer::createObject
+
+        :param dictionary user_object: https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/
+        """
+        LOGGER.warning("Creating User %s", user_object['username'])
+        return self.user_service.createObject(user_object, password, None)
+
+    def edit_user(self, user_id, user_object):
+        """Blindly sends user_object to SoftLayer_User_Customer::editObject
+
+        :param int user_id: User to edit
+        :param dictionary user_object: https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/
+        """
+        return self.user_service.editObject(user_object, id=user_id)
+
+    def add_api_authentication_key(self, user_id):
+        """Calls SoftLayer_User_Customer::addApiAuthenticationKey
+
+        :param int user_id: User to add API key to
+        """
+        return self.user_service.addApiAuthenticationKey(id=user_id)
+
+
+def _keyname_search(haystack, needle):
+    for item in haystack:
+        if item.get('keyName') == needle:
+            return True
+    return False
diff --git a/SoftLayer/shell/completer.py b/SoftLayer/shell/completer.py
index 62602e9e..1f59f3a5 100644
--- a/SoftLayer/shell/completer.py
+++ b/SoftLayer/shell/completer.py
@@ -14,6 +14,7 @@
 
 class ShellCompleter(completion.Completer):
     """Completer for the shell."""
+
     def __init__(self, click_root):
         self.root = click_root
 
diff --git a/SoftLayer/transports.py b/SoftLayer/transports.py
index 15abe362..df86d739 100644
--- a/SoftLayer/transports.py
+++ b/SoftLayer/transports.py
@@ -135,6 +135,7 @@ def __init__(self, items, total_count):
 
 class XmlRpcTransport(object):
     """XML-RPC transport."""
+
     def __init__(self, endpoint_url=None, timeout=None, proxy=None, user_agent=None, verify=True):
 
         self.endpoint_url = (endpoint_url or consts.API_PUBLIC_ENDPOINT).rstrip('/')
@@ -433,6 +434,7 @@ def __call__(self, call):
         self.requests.append(call)
 
         if call.exception is not None:
+            LOGGER.debug(self.print_reproduceable(call))
             raise call.exception
 
         return call.result
@@ -490,6 +492,7 @@ def print_reproduceable(self, call):
 
 class FixtureTransport(object):
     """Implements a transport which returns fixtures."""
+
     def __call__(self, call):
         """Load fixture from the default fixture path."""
         try:
diff --git a/docs/cli/users.rst b/docs/cli/users.rst
new file mode 100644
index 00000000..44cd7155
--- /dev/null
+++ b/docs/cli/users.rst
@@ -0,0 +1,93 @@
+.. _cli_user:
+
+Users
+=============
+Version 5.6.0 introduces the ability to interact with user accounts from the cli. 
+
+.. _cli_user_create:
+user create
+-----------
+This command will create a user on your account.
+
+Options
+^^^^^^^
+-e, --email TEXT      Email address for this user. Required for creation.  [required]
+-p, --password TEXT   Password to set for this user. If no password is provided, user will be sent an email to generate one, which expires in 24 hours.  '-p generate' will create a password for you (Requires Python 3.6+). Passwords require 8+ characters, upper and lowercase, a number and a symbol.
+-u, --from-user TEXT  Base user to use as a template for creating this user. Will default to the user running this command. Information provided in --template supersedes this template.
+-t, --template TEXT   A json string describing https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/
+-a, --api-key         Create an API key for this user.
+-h, --help            Show this message and exit.
+
+::
+    slcli user create my@email.com -e my@email.com -p generate -a -t '{"firstName": "Test", "lastName": "Testerson"}'
+
+.. _cli_user_list:
+
+user list
+----------
+This command will list all Active users on the account that your user has access to view. 
+There is the option to also filter by username
+
+
+.. _cli_user_detail:
+
+user detail <user>
+-------------------
+Gives a variety of details about a specific user. <user> can be a user id, or username. Will always print a basic set of information about the user, but there are a few extra flags to pull in more detailed information.
+
+user detail <user> -p, --permissions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Will list the permissions the user has. To see a list of all possible permissions, or to change a user's permissions, see :ref:`cli_user_permissions`
+
+user detail <user> -h, --hardware
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Will list the Hardware and Dedicated Hosts the user is able to access. 
+
+
+user detail <user> -v, --virtual
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Will list the Virtual Guests the user has access to.
+
+user detail <user> -l, --logins
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Show login history of this user for the last 30 days. IBMId Users will show logins properly, but may not show failed logins. 
+
+user detail <user> -e, --events
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Shows things that are logged in the Event_Log service. Logins, reboots, reloads, and other such actions will show up here.
+
+.. _cli_user_permissions:
+
+user permissions <user>
+^^^^^^^^^^^^^^^^^^^^^^^
+Will list off all permission keyNames, along with which are assigned to that specific user.
+
+.. _cli_user_permissions_edit:
+
+user edit-permissions
+---------------------
+Enable or Disable specific permissions. It is possible to set multiple permissions in one command as well.
+
+::
+
+    $ slcli user edit-permissions USERID --enable -p TICKET_EDIT -p TICKET_ADD -p TICKET_SEARCH
+
+Will enable TICKET_EDIT, TICKET_ADD, and TICKET_SEARCH permissions for the USERID
+
+.. _cli_user_edit_details:
+
+user edit-details
+-----------------
+Edit a User's details
+
+JSON strings should be enclosed in '' and each item should be enclosed in "\"
+
+::
+  slcli user edit-details testUser -t '{"firstName": "Test", "lastName": "Testerson"}'
+
+Options
+^^^^^^^
+-t, --template TEXT  A json string describing `SoftLayer_User_Customer
+https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/`_.  [required]
+-h, --help           Show this message and exit.
+
diff --git a/setup.py b/setup.py
index 3a3421b1..65bcadd0 100644
--- a/setup.py
+++ b/setup.py
@@ -31,7 +31,7 @@
     },
     install_requires=[
         'six >= 1.7.0',
-        'prettytable >= 0.7.0',
+        'ptable >= 0.9.2',
         'click >= 5',
         'requests >= 2.18.4',
         'prompt_toolkit >= 0.53',


Test patch:
diff --git a/SoftLayer/testing/__init__.py b/SoftLayer/testing/__init__.py
index b68d5a62..69c43903 100644
--- a/SoftLayer/testing/__init__.py
+++ b/SoftLayer/testing/__init__.py
@@ -160,11 +160,7 @@ def set_mock(self, service, method):
         """Set and return mock on the current client."""
         return self.mocks.set_mock(service, method)
 
-    def run_command(self,
-                    args=None,
-                    env=None,
-                    fixtures=True,
-                    fmt='json'):
+    def run_command(self, args=None, env=None, fixtures=True, fmt='json'):
         """A helper that runs a SoftLayer CLI command.
 
         This returns a click.testing.Result object.
@@ -185,7 +181,7 @@ def call_has_props(call, props):
     for prop, expected_value in props.items():
         actual_value = getattr(call, prop)
         if actual_value != expected_value:
-            logging.info(
+            logging.critical(
                 '%s::%s property mismatch, %s: expected=%r; actual=%r',
                 call.service,
                 call.method,
diff --git a/tests/CLI/helper_tests.py b/tests/CLI/helper_tests.py
index 40752afa..e6bbfe52 100644
--- a/tests/CLI/helper_tests.py
+++ b/tests/CLI/helper_tests.py
@@ -252,15 +252,18 @@ def test_init(self):
 class ResolveIdTests(testing.TestCase):
 
     def test_resolve_id_one(self):
-        self.assertEqual(helpers.resolve_id(lambda r: [12345], 'test'), 12345)
+        resolver = lambda r: [12345]
+        self.assertEqual(helpers.resolve_id(resolver, 'test'), 12345)
 
     def test_resolve_id_none(self):
+        resolver = lambda r: []
         self.assertRaises(
-            exceptions.CLIAbort, helpers.resolve_id, lambda r: [], 'test')
+            exceptions.CLIAbort, helpers.resolve_id, resolver, 'test')
 
     def test_resolve_id_multiple(self):
+        resolver = lambda r: [12345, 54321]
         self.assertRaises(
-            exceptions.CLIAbort, helpers.resolve_id, lambda r: [12345, 54321], 'test')
+            exceptions.CLIAbort, helpers.resolve_id, resolver, 'test')
 
 
 class TestTable(testing.TestCase):
diff --git a/tests/CLI/modules/block_tests.py b/tests/CLI/modules/block_tests.py
index 50d18ad8..2e6dbf8c 100644
--- a/tests/CLI/modules/block_tests.py
+++ b/tests/CLI/modules/block_tests.py
@@ -194,7 +194,7 @@ def test_volume_order_endurance(self, order_mock):
                     {'description': '0.25 IOPS per GB'},
                     {'description': '20 GB Storage Space'},
                     {'description': '10 GB Storage Space (Snapshot Space)'}]
-                }
+            }
         }
 
         result = self.run_command(['block', 'volume-order',
@@ -242,7 +242,7 @@ def test_volume_order_hourly_billing(self, order_mock):
                     {'description': 'Block Storage'},
                     {'description': '20 GB Storage Space'},
                     {'description': '200 IOPS'}]
-                }
+            }
         }
 
         result = self.run_command(['block', 'volume-order',
@@ -408,7 +408,7 @@ def test_snapshot_order(self, order_mock):
                 'items': [{'description':
                            '10 GB Storage Space (Snapshot Space)'}],
                 'status': 'PENDING_APPROVAL',
-                }
+            }
         }
 
         result = self.run_command(['block', 'snapshot-order', '1234',
diff --git a/tests/CLI/modules/dedicatedhost_tests.py b/tests/CLI/modules/dedicatedhost_tests.py
index ab62063c..ead83526 100644
--- a/tests/CLI/modules/dedicatedhost_tests.py
+++ b/tests/CLI/modules/dedicatedhost_tests.py
@@ -116,7 +116,7 @@ def test_create_options(self):
                     '56 Cores X 242 RAM X 1.2 TB',
                 'value': '56_CORES_X_242_RAM_X_1_4_TB'
             }
-            ]]
+        ]]
         )
 
     def test_create_options_with_only_datacenter(self):
@@ -148,7 +148,7 @@ def test_create_options_get_routers(self):
             {
                 "Available Backend Routers": "bcr04a.dal05"
             }
-            ]]
+        ]]
         )
 
     def test_create(self):
diff --git a/tests/CLI/modules/file_tests.py b/tests/CLI/modules/file_tests.py
index f7ec4859..9bc56320 100644
--- a/tests/CLI/modules/file_tests.py
+++ b/tests/CLI/modules/file_tests.py
@@ -197,7 +197,7 @@ def test_volume_order_endurance(self, order_mock):
                     {'description': '0.25 IOPS per GB'},
                     {'description': '20 GB Storage Space'},
                     {'description': '10 GB Storage Space (Snapshot Space)'}]
-                }
+            }
         }
 
         result = self.run_command(['file', 'volume-order',
@@ -394,7 +394,7 @@ def test_snapshot_order(self, order_mock):
                 'items': [{'description':
                            '10 GB Storage Space (Snapshot Space)'}],
                 'status': 'PENDING_APPROVAL',
-                }
+            }
         }
 
         result = self.run_command(['file', 'snapshot-order', '1234',
diff --git a/tests/CLI/modules/user_tests.py b/tests/CLI/modules/user_tests.py
new file mode 100644
index 00000000..2c0e62ac
--- /dev/null
+++ b/tests/CLI/modules/user_tests.py
@@ -0,0 +1,252 @@
+"""
+    SoftLayer.tests.CLI.modules.user_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    Tests for the user cli command
+"""
+import json
+import sys
+
+import mock
+import testtools
+
+from SoftLayer import testing
+
+
+class UserCLITests(testing.TestCase):
+
+    """User list tests"""
+
+    def test_user_list(self):
+        result = self.run_command(['user', 'list'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getUsers')
+
+    def test_user_list_only_id(self):
+        result = self.run_command(['user', 'list', '--columns=id'])
+        self.assert_no_fail(result)
+        self.assertEqual([{"id": 11100}, {"id": 11111}], json.loads(result.output))
+
+    """User detail tests"""
+
+    def test_detail(self):
+        result = self.run_command(['user', 'detail', '11100'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'getObject')
+
+    def test_detail_keys(self):
+        result = self.run_command(['user', 'detail', '11100', '-k'])
+        self.assert_no_fail(result)
+        self.assertIn('APIKEY', result.output)
+
+    def test_detail_permissions(self):
+        result = self.run_command(['user', 'detail', '11100', '-p'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'getPermissions')
+        self.assertIn('ACCESS_ALL_HARDWARE', result.output)
+
+    def test_detail_hardware(self):
+        result = self.run_command(['user', 'detail', '11100', '-h'])
+        self.assert_no_fail(result)
+        self.assert_called_with(
+            'SoftLayer_User_Customer', 'getObject', identifier=11100,
+            mask='mask[id, hardware, dedicatedHosts]'
+        )
+
+    def test_detail_virtual(self):
+        result = self.run_command(['user', 'detail', '11100', '-v'])
+        self.assert_no_fail(result)
+        self.assert_called_with(
+            'SoftLayer_User_Customer', 'getObject', identifier=11100,
+            mask='mask[id, virtualGuests]'
+        )
+
+    def test_detail_logins(self):
+        result = self.run_command(['user', 'detail', '11100', '-l'])
+        self.assert_no_fail(result)
+        self.assert_called_with(
+            'SoftLayer_User_Customer', 'getLoginAttempts', identifier=11100
+        )
+
+    def test_detail_events(self):
+        result = self.run_command(['user', 'detail', '11100', '-e'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Event_Log', 'getAllObjects')
+
+    def test_print_hardware_access(self):
+        mock = self.set_mock('SoftLayer_User_Customer', 'getObject')
+        mock.return_value = {
+            'accountId': 12345,
+            'address1': '315 Test Street',
+            'city': 'Houston',
+            'companyName': 'SoftLayer Development Community',
+            'country': 'US',
+            'displayName': 'Test',
+            'email': 'test@us.ibm.com',
+            'firstName': 'Test',
+            'id': 244956,
+            'lastName': 'Testerson',
+            'postalCode': '77002',
+            'state': 'TX',
+            'statusDate': None,
+            'hardware': [
+                {'id': 1234,
+                 'fullyQualifiedDomainName': 'test.test.test',
+                 'provisionDate': '2018-05-08T15:28:32-06:00',
+                 'primaryBackendIpAddress': '175.125.126.118',
+                 'primaryIpAddress':  '175.125.126.118'}
+            ],
+            'dedicatedHosts': [
+                {'id': 1234,
+                 'fullyQualifiedDomainName': 'test.test.test',
+                 'provisionDate': '2018-05-08T15:28:32-06:00',
+                 'primaryBackendIpAddress': '175.125.126.118',
+                 'primaryIpAddress': '175.125.126.118'}
+            ],
+        }
+        result = self.run_command(['user', 'detail', '11100', '-h'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'getObject', identifier=11100,
+                                mask="mask[id, hardware, dedicatedHosts]")
+
+    """User permissions tests"""
+
+    def test_permissions_list(self):
+        result = self.run_command(['user', 'permissions', '11100'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer_CustomerPermission_Permission', 'getAllObjects')
+        self.assert_called_with(
+            'SoftLayer_User_Customer', 'getObject', identifier=11100,
+            mask='mask[id, permissions, isMasterUserFlag, roles]'
+        )
+
+    """User edit-permissions tests"""
+
+    def test_edit_perms_on(self):
+        result = self.run_command(['user', 'edit-permissions', '11100', '--enable', '-p', 'TEST'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'addBulkPortalPermission', identifier=11100)
+
+    def test_edit_perms_on_bad(self):
+        result = self.run_command(['user', 'edit-permissions', '11100', '--enable', '-p', 'TEST_NOt_exist'])
+        self.assertEqual(result.exit_code, -1)
+
+    def test_edit_perms_off(self):
+        result = self.run_command(['user', 'edit-permissions', '11100', '--disable', '-p', 'TEST'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'removeBulkPortalPermission', identifier=11100)
+
+    @mock.patch('SoftLayer.CLI.user.edit_permissions.click')
+    def test_edit_perms_off_failure(self, click):
+        permission_mock = self.set_mock('SoftLayer_User_Customer', 'removeBulkPortalPermission')
+        permission_mock.return_value = False
+        result = self.run_command(['user', 'edit-permissions', '11100', '--disable', '-p', 'TEST'])
+        click.secho.assert_called_with('Failed to update permissions: TEST', fg='red')
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'removeBulkPortalPermission', identifier=11100)
+
+    def test_edit_perms_from_user(self):
+        result = self.run_command(['user', 'edit-permissions', '11100', '-u', '1234'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'getPermissions', identifier=1234)
+        self.assert_called_with('SoftLayer_User_Customer', 'removeBulkPortalPermission', identifier=11100)
+        self.assert_called_with('SoftLayer_User_Customer', 'addBulkPortalPermission', identifier=11100)
+
+    """User create tests"""
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com', '-p', 'testword'])
+        self.assert_no_fail(result)
+        self.assertIn('test@us.ibm.com', result.output)
+        self.assert_called_with('SoftLayer_Account', 'getCurrentUser')
+        self.assert_called_with('SoftLayer_User_Customer', 'createObject', args=mock.ANY)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_no_confirm(self, confirm_mock):
+        confirm_mock.return_value = False
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com', '-p', 'testword'])
+        self.assertEqual(result.exit_code, 2)
+
+    @testtools.skipIf(sys.version_info < (3, 6), "Secrets module only exists in version 3.6+")
+    @mock.patch('secrets.choice')
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_generate_password_36(self, confirm_mock, secrets):
+        secrets.return_value = 'Q'
+        confirm_mock.return_value = True
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com', '-p', 'generate'])
+
+        self.assert_no_fail(result)
+        self.assertIn('test@us.ibm.com', result.output)
+        self.assertIn('QQQQQQQQQQQQQQQQQQQQQQ', result.output)
+        self.assert_called_with('SoftLayer_Account', 'getCurrentUser')
+        self.assert_called_with('SoftLayer_User_Customer', 'createObject', args=mock.ANY)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_generate_password_2(self, confirm_mock):
+        if sys.version_info >= (3, 6):
+            self.skipTest("Python needs to be < 3.6 for this test.")
+
+        confirm_mock.return_value = True
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com', '-p', 'generate'])
+        self.assertIn(result.output, "ImportError")
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_and_apikey(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com', '-a'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'addApiAuthenticationKey')
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_with_template(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com',
+                                   '-t', '{"firstName": "Supermand"}'])
+        self.assertIn('Supermand', result.output)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_with_bad_template(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com',
+                                   '-t', '{firstName: "Supermand"}'])
+        self.assertIn("Argument Error", result.exception.message)
+        self.assertEqual(result.exit_code, 2)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_with_no_confirm(self, confirm_mock):
+        confirm_mock.return_value = False
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com'])
+        self.assertIn("Canceling creation!", result.exception.message)
+        self.assertEqual(result.exit_code, 2)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_user_from_user(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['user', 'create', 'test', '-e', 'test@us.ibm.com', '-u', '1234'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'getObject', identifier=1234)
+
+    """User edit-details tests"""
+    @mock.patch('SoftLayer.CLI.user.edit_details.click')
+    def test_edit_details(self, click):
+        result = self.run_command(['user', 'edit-details', '1234', '-t', '{"firstName":"Supermand"}'])
+        click.secho.assert_called_with('1234 updated successfully', fg='green')
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'editObject',
+                                args=({'firstName': 'Supermand'},), identifier=1234)
+
+    @mock.patch('SoftLayer.CLI.user.edit_details.click')
+    def test_edit_details_failure(self, click):
+        mock = self.set_mock('SoftLayer_User_Customer', 'editObject')
+        mock.return_value = False
+        result = self.run_command(['user', 'edit-details', '1234', '-t', '{"firstName":"Supermand"}'])
+        click.secho.assert_called_with('Failed to update 1234', fg='red')
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_User_Customer', 'editObject',
+                                args=({'firstName': 'Supermand'},), identifier=1234)
+
+    def test_edit_details_bad_json(self):
+        result = self.run_command(['user', 'edit-details', '1234', '-t', '{firstName:"Supermand"}'])
+        self.assertIn("Argument Error", result.exception.message)
+        self.assertEqual(result.exit_code, 2)
diff --git a/tests/CLI/modules/vs_tests.py b/tests/CLI/modules/vs_tests.py
index 0b488390..1117770d 100644
--- a/tests/CLI/modules/vs_tests.py
+++ b/tests/CLI/modules/vs_tests.py
@@ -15,127 +15,6 @@
 
 class VirtTests(testing.TestCase):
 
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_rescue_vs(self, confirm_mock):
-        confirm_mock.return_value = True
-        result = self.run_command(['vs', 'rescue', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_rescue_vs_no_confirm(self, confirm_mock):
-        confirm_mock.return_value = False
-        result = self.run_command(['vs', 'rescue', '100'])
-
-        self.assertEqual(result.exit_code, 2)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_reboot_vs_default(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'rebootDefault')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-        result = self.run_command(['vs', 'reboot', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_reboot_vs_no_confirm(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'rebootDefault')
-        mock.return_value = 'true'
-        confirm_mock.return_value = False
-        result = self.run_command(['vs', 'reboot', '100'])
-
-        self.assertEqual(result.exit_code, 2)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_reboot_vs_soft(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'rebootSoft')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-
-        result = self.run_command(['vs', 'reboot', '--soft', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_reboot_vs_hard(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'rebootHard')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-        result = self.run_command(['vs', 'reboot', '--hard', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_power_vs_off_soft(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'powerOffSoft')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-
-        result = self.run_command(['vs', 'power-off', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_power_off_vs_no_confirm(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'powerOffSoft')
-        mock.return_value = 'true'
-        confirm_mock.return_value = False
-
-        result = self.run_command(['vs', 'power-off', '100'])
-
-        self.assertEqual(result.exit_code, 2)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_power_off_vs_hard(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'powerOff')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-
-        result = self.run_command(['vs', 'power-off', '--hard', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_power_on_vs(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'powerOn')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-
-        result = self.run_command(['vs', 'power-on', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_pause_vs(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'pause')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-
-        result = self.run_command(['vs', 'pause', '100'])
-
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_pause_vs_no_confirm(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'pause')
-        mock.return_value = 'true'
-        confirm_mock.return_value = False
-
-        result = self.run_command(['vs', 'pause', '100'])
-
-        self.assertEqual(result.exit_code, 2)
-
-    @mock.patch('SoftLayer.CLI.formatting.confirm')
-    def test_resume_vs(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'resume')
-        mock.return_value = 'true'
-        confirm_mock.return_value = True
-
-        result = self.run_command(['vs', 'resume', '100'])
-
-        self.assert_no_fail(result)
-
     def test_list_vs(self):
         result = self.run_command(['vs', 'list', '--tag=tag'])
 
@@ -783,21 +662,3 @@ def test_going_ready(self, _sleep):
         result = self.run_command(['vs', 'ready', '100', '--wait=100'])
         self.assert_no_fail(result)
         self.assertEqual(result.output, '"READY"\n')
-
-    @mock.patch('SoftLayer.CLI.formatting.no_going_back')
-    def test_reload(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'reloadCurrentOperatingSystemConfguration')
-        confirm_mock.return_value = True
-        mock.return_value = 'true'
-
-        result = self.run_command(['vs', 'reload', '--postinstall', '100', '--key', '100', '--image', '100', '100'])
-        self.assert_no_fail(result)
-
-    @mock.patch('SoftLayer.CLI.formatting.no_going_back')
-    def test_reload_no_confirm(self, confirm_mock):
-        mock = self.set_mock('SoftLayer_Virtual_Guest', 'reloadCurrentOperatingSystemConfiguration')
-        confirm_mock.return_value = False
-        mock.return_value = 'false'
-
-        result = self.run_command(['vs', 'reload', '--postinstall', '100', '--key', '100', '--image', '100', '100'])
-        self.assertEqual(result.exit_code, 2)
diff --git a/tests/config_tests.py b/tests/config_tests.py
index ad913be5..4224bb7b 100644
--- a/tests/config_tests.py
+++ b/tests/config_tests.py
@@ -92,4 +92,4 @@ def test_config_file(config_parser):
     config.get_client_settings_config_file(config_file='path/to/config')
     config_parser().read.assert_called_with([mock.ANY,
                                              mock.ANY,
-                                            'path/to/config'])
+                                             'path/to/config'])
diff --git a/tests/managers/block_tests.py b/tests/managers/block_tests.py
index 6f2e5eb6..940557c7 100644
--- a/tests/managers/block_tests.py
+++ b/tests/managers/block_tests.py
@@ -75,7 +75,7 @@ def test_get_block_volume_details(self):
             'getObject',
             identifier=100,
             mask='mask[%s]' % expected_mask
-            )
+        )
 
     def test_list_block_volumes(self):
         result = self.block.list_block_volumes()
@@ -743,7 +743,7 @@ def test_order_block_duplicate_performance(self):
             duplicate_iops=2000,
             duplicate_tier_level=None,
             duplicate_snapshot_size=10
-            )
+        )
 
         self.assertEqual(fixtures.SoftLayer_Product_Order.placeOrder, result)
 
@@ -821,7 +821,7 @@ def test_order_block_duplicate_endurance(self):
             duplicate_iops=None,
             duplicate_tier_level=4,
             duplicate_snapshot_size=10
-            )
+        )
 
         self.assertEqual(fixtures.SoftLayer_Product_Order.placeOrder, result)
 
diff --git a/tests/managers/dedicated_host_tests.py b/tests/managers/dedicated_host_tests.py
index 7cc884df..d6ced130 100644
--- a/tests/managers/dedicated_host_tests.py
+++ b/tests/managers/dedicated_host_tests.py
@@ -397,7 +397,7 @@ def test_get_item(self):
                     'categories': [{
                         'categoryCode': 'dedicated_host_ram'
                     }]
-                }],
+            }],
             'capacity': '56',
             'description': '56 Cores X 242 RAM X 1.2 TB',
             'id': 10195,
diff --git a/tests/managers/file_tests.py b/tests/managers/file_tests.py
index 1cbcb47c..08658b6a 100644
--- a/tests/managers/file_tests.py
+++ b/tests/managers/file_tests.py
@@ -682,7 +682,7 @@ def test_order_file_duplicate_performance(self):
             duplicate_iops=2000,
             duplicate_tier_level=None,
             duplicate_snapshot_size=10
-            )
+        )
 
         self.assertEqual(fixtures.SoftLayer_Product_Order.placeOrder, result)
 
@@ -760,7 +760,7 @@ def test_order_file_duplicate_endurance(self):
             duplicate_iops=None,
             duplicate_tier_level=4,
             duplicate_snapshot_size=10
-            )
+        )
 
         self.assertEqual(fixtures.SoftLayer_Product_Order.placeOrder, result)
 
diff --git a/tests/managers/ipsec_tests.py b/tests/managers/ipsec_tests.py
index 05c6c976..aaebc9f7 100644
--- a/tests/managers/ipsec_tests.py
+++ b/tests/managers/ipsec_tests.py
@@ -125,7 +125,7 @@ def test_get_tunnel_context_raises_error(self):
     def test_get_translation(self):
         mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
         mock.return_value = [{'id': 445, 'addressTranslations':
-                             [{'id': 234123}, {'id': 872341}]}]
+                              [{'id': 234123}, {'id': 872341}]}]
         self.assertEqual(self.ipsec.get_translation(445, 872341),
                          {'id': 872341,
                           'customerIpAddress': '',
@@ -136,7 +136,7 @@ def test_get_translation(self):
     def test_get_translation_raises_error(self):
         mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
         mock.return_value = [{'id': 445, 'addressTranslations':
-                             [{'id': 234123}]}]
+                              [{'id': 234123}]}]
         self.assertRaises(SoftLayerAPIError,
                           self.ipsec.get_translation,
                           445,
diff --git a/tests/managers/user_tests.py b/tests/managers/user_tests.py
new file mode 100644
index 00000000..ddb8322f
--- /dev/null
+++ b/tests/managers/user_tests.py
@@ -0,0 +1,193 @@
+"""
+    SoftLayer.tests.managers.user_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+"""
+import datetime
+import mock
+import SoftLayer
+from SoftLayer import exceptions
+from SoftLayer import testing
+
+
+real_datetime_class = datetime.datetime
+
+
+def mock_datetime(target, datetime_module):
+    """A way to use specific datetimes in tests. Just mocking datetime doesn't work because of pypy
+
+    https://solidgeargroup.com/mocking-the-time
+    """
+    class DatetimeSubclassMeta(type):
+        @classmethod
+        def __instancecheck__(mcs, obj):
+            return isinstance(obj, real_datetime_class)
+
+    class BaseMockedDatetime(real_datetime_class):
+        @classmethod
+        def now(cls, tz=None):
+            return target.replace(tzinfo=tz)
+
+        @classmethod
+        def utcnow(cls):
+            return target
+
+        @classmethod
+        def today(cls):
+            return target
+
+    # Python2 & Python3-compatible metaclass
+    MockedDatetime = DatetimeSubclassMeta('datetime', (BaseMockedDatetime,), {})
+
+    return mock.patch.object(datetime_module, 'datetime', MockedDatetime)
+
+
+class UserManagerTests(testing.TestCase):
+
+    def set_up(self):
+        self.manager = SoftLayer.UserManager(self.client)
+
+    def test_list_user_defaults(self):
+        self.manager.list_users()
+        self.assert_called_with('SoftLayer_Account', 'getUsers', mask=mock.ANY)
+
+    def test_list_user_mask(self):
+        self.manager.list_users(objectmask="mask[id]")
+        self.assert_called_with('SoftLayer_Account', 'getUsers', mask="mask[id]")
+
+    def test_list_user_filter(self):
+        test_filter = {'id': {'operation': 1234}}
+        self.manager.list_users(objectfilter=test_filter)
+        self.assert_called_with('SoftLayer_Account', 'getUsers', filter=test_filter)
+
+    def test_get_user_default(self):
+        self.manager.get_user(1234)
+        self.assert_called_with('SoftLayer_User_Customer', 'getObject', identifier=1234,
+                                mask="mask[userStatus[name], parent[id, username]]")
+
+    def test_get_user_mask(self):
+        self.manager.get_user(1234, objectmask="mask[id]")
+        self.assert_called_with('SoftLayer_User_Customer', 'getObject', identifier=1234, mask="mask[id]")
+
+    def test_get_all_permissions(self):
+        self.manager.get_all_permissions()
+        self.assert_called_with('SoftLayer_User_Customer_CustomerPermission_Permission', 'getAllObjects')
+
+    def test_add_permissions(self):
+        self.manager.add_permissions(1234, ['TEST'])
+        expected_args = (
+            [{'keyName': 'TEST'}],
+        )
+        self.assert_called_with('SoftLayer_User_Customer', 'addBulkPortalPermission',
+                                args=expected_args, identifier=1234)
+
+    def test_remove_permissions(self):
+        self.manager.remove_permissions(1234, ['TEST'])
+        expected_args = (
+            [{'keyName': 'TEST'}],
+        )
+        self.assert_called_with('SoftLayer_User_Customer', 'removeBulkPortalPermission',
+                                args=expected_args, identifier=1234)
+
+    def test_get_logins_default(self):
+        target = datetime.datetime(2018, 5, 15)
+        with mock_datetime(target, datetime):
+            self.manager.get_logins(1234)
+            expected_filter = {
+                'loginAttempts': {
+                    'createDate': {
+                        'operation': 'greaterThanDate',
+                        'options': [{'name': 'date', 'value': ['04/15/2018 0:0:0']}]
+                    }
+                }
+            }
+            self.assert_called_with('SoftLayer_User_Customer', 'getLoginAttempts', filter=expected_filter)
+
+    def test_get_events_default(self):
+        target = datetime.datetime(2018, 5, 15)
+        with mock_datetime(target, datetime):
+
+            self.manager.get_events(1234)
+            expected_filter = {
+                'userId': {
+                    'operation': 1234
+                },
+                'eventCreateDate': {
+                    'operation': 'greaterThanDate',
+                    'options': [{'name': 'date', 'value': ['2018-04-15T00:00:00']}]
+                }
+            }
+            self.assert_called_with('SoftLayer_Event_Log', 'getAllObjects', filter=expected_filter)
+
+    def test_get_events_empty(self):
+        event_mock = self.set_mock('SoftLayer_Event_Log', 'getAllObjects')
+        event_mock.return_value = None
+        result = self.manager.get_events(1234)
+
+        self.assert_called_with('SoftLayer_Event_Log', 'getAllObjects', filter=mock.ANY)
+        self.assertEqual([{'eventName': 'No Events Found'}], result)
+
+    @mock.patch('SoftLayer.managers.user.UserManager.get_user_permissions')
+    def test_permissions_from_user(self, user_permissions):
+        user_permissions.return_value = [
+            {"keyName": "TICKET_VIEW"},
+            {"keyName": "TEST"}
+        ]
+        removed_permissions = [
+            {'keyName': 'ACCESS_ALL_HARDWARE'},
+            {'keyName': 'ACCESS_ALL_HARDWARE'},
+            {'keyName': 'ACCOUNT_SUMMARY_VIEW'},
+            {'keyName': 'ADD_SERVICE_STORAGE'},
+            {'keyName': 'TEST_3'},
+            {'keyName': 'TEST_4'}
+        ]
+        self.manager.permissions_from_user(1234, 5678)
+        self.assert_called_with('SoftLayer_User_Customer', 'addBulkPortalPermission',
+                                args=(user_permissions.return_value,))
+        self.assert_called_with('SoftLayer_User_Customer', 'removeBulkPortalPermission',
+                                args=(removed_permissions,))
+
+    def test_get_id_from_username_one_match(self):
+        account_mock = self.set_mock('SoftLayer_Account', 'getUsers')
+        account_mock.return_value = [{'id': 1234}]
+        user_id = self.manager._get_id_from_username('testUser')
+        expected_filter = {'users': {'username': {'operation': '_= testUser'}}}
+        self.assert_called_with('SoftLayer_Account', 'getUsers', filter=expected_filter, mask="mask[id, username]")
+        self.assertEqual([1234], user_id)
+
+    def test_get_id_from_username_multiple_match(self):
+        account_mock = self.set_mock('SoftLayer_Account', 'getUsers')
+        account_mock.return_value = [{'id': 1234}, {'id': 4567}]
+        self.assertRaises(exceptions.SoftLayerError, self.manager._get_id_from_username, 'testUser')
+
+    def test_get_id_from_username_zero_match(self):
+        account_mock = self.set_mock('SoftLayer_Account', 'getUsers')
+        account_mock.return_value = []
+        self.assertRaises(exceptions.SoftLayerError, self.manager._get_id_from_username, 'testUser')
+
+    def test_format_permission_object(self):
+        result = self.manager.format_permission_object(['TEST'])
+        self.assert_called_with('SoftLayer_User_Customer_CustomerPermission_Permission', 'getAllObjects')
+        self.assertEqual([{'keyName': 'TEST'}], result)
+
+    def test_format_permission_object_all(self):
+        expected = [
+            {'key': 'T_2', 'keyName': 'TEST', 'name': 'A Testing Permission'},
+            {'key': 'T_1', 'keyName': 'TICKET_VIEW', 'name': 'View Tickets'}
+        ]
+        service_name = 'SoftLayer_User_Customer_CustomerPermission_Permission'
+        permission_mock = self.set_mock(service_name, 'getAllObjects')
+        permission_mock.return_value = expected
+        result = self.manager.format_permission_object(['ALL'])
+        self.assert_called_with(service_name, 'getAllObjects')
+        self.assertEqual(expected, result)
+
+    def test_get_current_user(self):
+        result = self.manager.get_current_user()
+        self.assert_called_with('SoftLayer_Account', 'getCurrentUser', mask=mock.ANY)
+        self.assertEqual(result['id'], 12345)
+
+    def test_get_current_user_mask(self):
+        result = self.manager.get_current_user(objectmask="mask[id]")
+        self.assert_called_with('SoftLayer_Account', 'getCurrentUser', mask="mask[id]")
+        self.assertEqual(result['id'], 12345)