From 4d5bfb13bf7a970dff4f0a0ad891846d8597f473 Mon Sep 17 00:00:00 2001 From: Jarrod Johnson Date: Wed, 1 May 2019 16:57:15 -0400 Subject: [PATCH] Add support for Operator role Support a reduced privilege user that can still perform most operations, but cannot modify, delete, or add users/groups to confluent or to BMCs. --- confluent_server/confluent/auth.py | 68 ++++++++++++++++--- .../confluent/config/configmanager.py | 23 ++++++- confluent_server/confluent/core.py | 18 +++-- confluent_server/confluent/httpapi.py | 24 +++---- confluent_server/confluent/sockapi.py | 6 +- 5 files changed, 106 insertions(+), 33 deletions(-) diff --git a/confluent_server/confluent/auth.py b/confluent_server/confluent/auth.py index 6e3985f3..136e36fe 100644 --- a/confluent_server/confluent/auth.py +++ b/confluent_server/confluent/auth.py @@ -23,6 +23,7 @@ import confluent.config.configmanager as configmanager import eventlet import eventlet.tpool import Cryptodome.Protocol.KDF as KDF +from fnmatch import fnmatch import hashlib import hmac import multiprocessing @@ -40,7 +41,43 @@ _passchecking = {} authworkers = None authcleaner = None +_allowedbyrole = { + 'Operator': { + 'retrieve': ['*'], + 'create': [ + '/node*/media/uploads/', + '/node*/inventory/firmware/updates/*', + '/node*/suppport/servicedata*', + ], + 'update': [ + '/discovery/*', + '/networking/macs/rescan', + '/node*/power/state', + '/node*/power/reseat', + '/node*/attributes/*', + '/node*/media/*tach', + '/node*/boot/nextdevice', + '/node*/identify', + '/node*/configuration/*', + ], + 'start': [ + '/nodes/*/console/session*', + '/nodes/*/shell/sessions*', + ], + 'delete': [ + '/node*/*/events/hardware/log', + ], + }, +} +_deniedbyrole = { + # This supersedes the above and is only consulted after the allowed has happened + 'Operator': { + 'update': [ + '/node*/configuration/management_controller/users/*', + ] + } +} class Credentials(object): def __init__(self, username, passphrase): self.username = username @@ -113,14 +150,15 @@ def authorize(name, element, tenant=False, operation='create', and the relevant ConfigManager object for the context of the request. """ - if operation not in ('create', 'start', 'update', 'retrieve', 'delete'): - return None + # skipuserobj is a leftover from the now abandoned plan to use pam session + # to do authorization and authentication. Now confluent always does authorization + # even if pam does authentication. + if operation not in ('create', 'start', 'update', 'retrieve', 'delete', None): + return False user, tenant = _get_usertenant(name, tenant) if tenant is not None and not configmanager.is_tenant(tenant): - return None + return False manager = configmanager.ConfigManager(tenant, username=user) - if skipuserobj: - return None, manager, user, tenant, skipuserobj userobj = manager.get_user(user) if not userobj: for group in userutil.grouplist(user): @@ -128,11 +166,21 @@ def authorize(name, element, tenant=False, operation='create', if userobj: break if userobj: # returning + role = userobj.get('role', 'Administrator') + if element and role != 'Administrator': + for rule in _allowedbyrole.get(role, {}).get(operation, []): + if fnmatch(element, rule): + break + else: + return False + for rule in _deniedbyrole.get(role, {}).get(operation, []): + if fnmatch(element, rule): + return False return userobj, manager, user, tenant, skipuserobj - return None + return False -def check_user_passphrase(name, passphrase, element=None, tenant=False): +def check_user_passphrase(name, passphrase, operation=None, element=None, tenant=False): """Check a a login name and passphrase for authenticity and authorization The function combines authentication and authorization into one function. @@ -179,7 +227,7 @@ def check_user_passphrase(name, passphrase, element=None, tenant=False): return None if (user, tenant) in _passcache: if hashlib.sha256(passphrase).digest() == _passcache[(user, tenant)]: - return authorize(user, element, tenant) + return authorize(user, element, tenant, operation=operation) else: # In case of someone trying to guess, # while someone is legitimately logged in @@ -214,7 +262,7 @@ def check_user_passphrase(name, passphrase, element=None, tenant=False): # delay as well if crypt == crypted: _passcache[(user, tenant)] = hashlib.sha256(passphrase).digest() - return authorize(user, element, tenant) + return authorize(user, element, tenant, operation) try: pammy = PAM.pam() pammy.start(_pamservice, user, credobj.pam_conv) @@ -222,7 +270,7 @@ def check_user_passphrase(name, passphrase, element=None, tenant=False): pammy.acct_mgmt() del pammy _passcache[(user, tenant)] = hashlib.sha256(passphrase).digest() - return authorize(user, element, tenant, skipuserobj=False) + return authorize(user, element, tenant, operation, skipuserobj=False) except NameError: pass except PAM.error: diff --git a/confluent_server/confluent/config/configmanager.py b/confluent_server/confluent/config/configmanager.py index 0cfd3322..8c2f588e 100644 --- a/confluent_server/confluent/config/configmanager.py +++ b/confluent_server/confluent/config/configmanager.py @@ -99,6 +99,7 @@ _attraliases = { 'bmcpass': 'secret.hardwaremanagementpassword', 'switchpass': 'secret.hardwaremanagementpassword', } +_validroles = ('Administrator', 'Operator') def _mkpath(pathname): try: @@ -1263,7 +1264,16 @@ class ConfigManager(object): def _true_set_usergroup(self, groupname, attributemap): for attribute in attributemap: - self._cfgstore['usergroups'][attribute] = attributemap[attribute] + if attribute == 'role': + therole = None + for candrole in _validroles: + if candrole.lower().startswith(attributemap[attribute].lower()): + therole = candrole + if therole not in _validroles: + raise ValueError( + 'Unrecognized role "{0}" (valid roles: {1})'.format(attributemap[attribute], ','.join(_validroles))) + attributemap[attribute] = therole + self._cfgstore['usergroups'][groupname][attribute] = attributemap[attribute] _mark_dirtykey('usergroups', groupname, self.tenant) self._bg_sync_to_file() @@ -1322,6 +1332,15 @@ class ConfigManager(object): def _true_set_user(self, name, attributemap): user = self._cfgstore['users'][name] for attribute in attributemap: + if attribute == 'role': + therole = None + for candrole in _validroles: + if candrole.lower().startswith(attributemap[attribute].lower()): + therole = candrole + if therole not in _validroles: + raise ValueError( + 'Unrecognized role "{0}" (valid roles: {1})'.format(attributemap[attribute], ','.join(_validroles))) + attributemap[attribute] = therole if attribute == 'password': salt = os.urandom(8) #TODO: WORKERPOOL, offload password set to a worker @@ -1598,7 +1617,7 @@ class ConfigManager(object): del attribmap[group][attr] if 'noderange' in attribmap[group]: if len(attribmap[group]) > 1: - raise ValueErorr('noderange attribute must be set by itself') + raise ValueError('noderange attribute must be set by itself') for attr in attribmap[group]: if attr in _attraliases: newattr = _attraliases[attr] diff --git a/confluent_server/confluent/core.py b/confluent_server/confluent/core.py index 0e802ce6..0846d947 100644 --- a/confluent_server/confluent/core.py +++ b/confluent_server/confluent/core.py @@ -407,19 +407,21 @@ def create_usergroup(inputdata, configmanager): def update_usergroup(groupname, attribmap, configmanager): try: - configmanager.set_usergroup(name, attribmap) - except ValueError: - raise exc.InvalidArgumentException() + configmanager.set_usergroup(groupname, attribmap) + except ValueError as e: + raise exc.InvalidArgumentException(str(e)) def update_user(name, attribmap, configmanager): try: configmanager.set_user(name, attribmap) - except ValueError: - raise exc.InvalidArgumentException() + except ValueError as e: + raise exc.InvalidArgumentException(str(e)) def show_usergroup(groupname, configmanager): - return [] + groupinfo = configmanager.get_usergroup(groupname) + for attr in groupinfo: + yield msg.Attributes(kv={attr: groupinfo[attr]}) def show_user(name, configmanager): userobj = configmanager.get_user(name) @@ -437,6 +439,10 @@ def show_user(name, configmanager): rv[attr] = userobj[attr] yield msg.Attributes(kv={attr: rv[attr]}, desc=attrscheme.user[attr]['description']) + if 'role' in userobj: + yield msg.Attributes(kv={'role': userobj['role']}) + + def stripnode(iterablersp, node): diff --git a/confluent_server/confluent/httpapi.py b/confluent_server/confluent/httpapi.py index a923164a..2feac493 100644 --- a/confluent_server/confluent/httpapi.py +++ b/confluent_server/confluent/httpapi.py @@ -269,6 +269,9 @@ def _authorize_request(env, operation): name = '' sessionid = None cookie = Cookie.SimpleCookie() + element = env['PATH_INFO'] + if element.startswith('/sessions/current/'): + element = None if 'HTTP_COOKIE' in env: #attempt to use the cookie. If it matches cc = RobustCookie() @@ -290,7 +293,7 @@ def _authorize_request(env, operation): httpsessions[sessionid]['expiry'] = time.time() + 90 name = httpsessions[sessionid]['name'] authdata = auth.authorize( - name, element=None, + name, element=element, operation=operation, skipuserobj=httpsessions[sessionid]['skipuserobject']) if (not authdata) and 'HTTP_AUTHORIZATION' in env: if env['PATH_INFO'] == '/sessions/current/logout': @@ -303,8 +306,10 @@ def _authorize_request(env, operation): return ('logout',) name, passphrase = base64.b64decode( env['HTTP_AUTHORIZATION'].replace('Basic ', '')).split(':', 1) - authdata = auth.check_user_passphrase(name, passphrase, element=None) - if not authdata: + authdata = auth.check_user_passphrase(name, passphrase, operation=operation, element=element) + if authdata is False: + return {'code': 403} + elif not authdata: return {'code': 401} sessid = util.randomstring(32) while sessid in httpsessions: @@ -341,15 +346,10 @@ def _authorize_request(env, operation): if 'csrftoken' in httpsessions[sessid]: authinfo['authtoken'] = httpsessions[sessid]['csrftoken'] return authinfo - else: + elif authdata is None: return {'code': 401} - # TODO(jbjohnso): actually evaluate the request for authorization - # In theory, the x509 or http auth stuff will get translated and then - # passed on to the core authorization function in an appropriate form - # expresses return in the form of http code - # 401 if there is no known identity - # 403 if valid identity, but no access - # going to run 200 just to get going for now + else: + return {'code': 403} def _pick_mimetype(env): @@ -429,7 +429,7 @@ def resourcehandler_backend(env, start_response): return if authorized['code'] == 403: start_response('403 Forbidden', badauth) - yield 'authorization failed' + yield 'Forbidden' return if authorized['code'] != 200: raise Exception("Unrecognized code from auth engine") diff --git a/confluent_server/confluent/sockapi.py b/confluent_server/confluent/sockapi.py index 061317f9..6c181a4e 100644 --- a/confluent_server/confluent/sockapi.py +++ b/confluent_server/confluent/sockapi.py @@ -120,7 +120,7 @@ def sessionhdl(connection, authname, skipauth=False, cert=None): cfm = configmanager.ConfigManager(tenant=None, username=authname) elif authname: authdata = auth.authorize(authname, element=None) - if authdata is not None: + if authdata: cfm = authdata[1] authenticated = True send_data(connection, "Confluent -- v0 --") @@ -145,7 +145,7 @@ def sessionhdl(connection, authname, skipauth=False, cert=None): # element path, that authorization will need to be called # per request the user makes authdata = auth.check_user_passphrase(authname, passphrase) - if authdata is None: + if not authdata: auditlog.log( {'operation': 'connect', 'user': authname, 'allowed': False}) else: @@ -212,7 +212,7 @@ def process_request(connection, request, cfm, authdata, authname, skipauth): } if not skipauth: authdata = auth.authorize(authdata[2], path, authdata[3], operation) - if authdata is None: + if not authdata: auditmsg['allowed'] = False auditlog.log(auditmsg) raise exc.ForbiddenRequest()