X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=blobdiff_plain;f=osm_nbi%2Fauth.py;h=eef2ae7af57acf9bb49de7d18e105675572f884f;hp=f756d9deaf96dfab52299e134ddb8e6e2e077152;hb=60bf895c75956f7a91cad46f920a1ad0b86a2000;hpb=6486f7485862dc51758864e6cf7d5d2523751873 diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index f756d9d..eef2ae7 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -34,14 +34,16 @@ import logging import yaml from base64 import standard_b64decode from copy import deepcopy + # from functools import reduce from http import HTTPStatus from time import time from os import path -from osm_nbi.authconn import AuthException, AuthExceptionUnauthorized +from osm_nbi.authconn import AuthException, AuthconnException, AuthExceptionUnauthorized from osm_nbi.authconn_keystone import AuthconnKeystone from osm_nbi.authconn_internal import AuthconnInternal +from osm_nbi.authconn_tacacs import AuthconnTacacs from osm_common import dbmemory, dbmongo, msglocal, msgkafka from osm_common.dbbase import DbException from osm_nbi.validation import is_valid_uuid @@ -58,8 +60,10 @@ class Authenticator: This class must be threading safe """ - periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned - token_limit = 500 # when reached, the token cache will be cleared + periodin_db_pruning = ( + 60 * 30 + ) # for the internal backend only. every 30 minutes expired tokens will be pruned + token_limit = 500 # when reached, the token cache will be cleared def __init__(self, valid_methods, valid_query_string): """ @@ -71,7 +75,9 @@ class Authenticator: self.db = None self.msg = None self.tokens_cache = dict() - self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done + self.next_db_prune_time = ( + 0 # time when next cleaning of expired tokens must be done + ) self.roles_to_operations_file = None # self.roles_to_operations_table = None self.resources_to_operations_mapping = {} @@ -80,6 +86,8 @@ class Authenticator: self.role_permissions = [] self.valid_methods = valid_methods self.valid_query_string = valid_query_string + self.system_admin_role_id = None # system_role id + self.test_project_id = None # test_project_id def start(self, config): """ @@ -100,8 +108,11 @@ class Authenticator: self.db = dbmemory.DbMemory() self.db.db_connect(config["database"]) else: - raise AuthException("Invalid configuration param '{}' at '[database]':'driver'" - .format(config["database"]["driver"])) + raise AuthException( + "Invalid configuration param '{}' at '[database]':'driver'".format( + config["database"]["driver"] + ) + ) if not self.msg: if config["message"]["driver"] == "local": self.msg = msglocal.MsgLocal() @@ -110,44 +121,66 @@ class Authenticator: self.msg = msgkafka.MsgKafka() self.msg.connect(config["message"]) else: - raise AuthException("Invalid configuration param '{}' at '[message]':'driver'" - .format(config["message"]["driver"])) + raise AuthException( + "Invalid configuration param '{}' at '[message]':'driver'".format( + config["message"]["driver"] + ) + ) if not self.backend: if config["authentication"]["backend"] == "keystone": - self.backend = AuthconnKeystone(self.config["authentication"], self.db) + self.backend = AuthconnKeystone( + self.config["authentication"], self.db, self.role_permissions + ) elif config["authentication"]["backend"] == "internal": - self.backend = AuthconnInternal(self.config["authentication"], self.db) - self._internal_tokens_prune() + self.backend = AuthconnInternal( + self.config["authentication"], self.db, self.role_permissions + ) + self._internal_tokens_prune("tokens") + elif config["authentication"]["backend"] == "tacacs": + self.backend = AuthconnTacacs( + self.config["authentication"], self.db, self.role_permissions + ) + self._internal_tokens_prune("tokens_tacacs") else: - raise AuthException("Unknown authentication backend: {}" - .format(config["authentication"]["backend"])) + raise AuthException( + "Unknown authentication backend: {}".format( + config["authentication"]["backend"] + ) + ) if not self.roles_to_operations_file: if "roles_to_operations" in config["rbac"]: - self.roles_to_operations_file = config["rbac"]["roles_to_operations"] + self.roles_to_operations_file = config["rbac"][ + "roles_to_operations" + ] else: possible_paths = ( - __file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml", - "./roles_to_operations.yml" + __file__[: __file__.rfind("auth.py")] + + "roles_to_operations.yml", + "./roles_to_operations.yml", ) for config_file in possible_paths: if path.isfile(config_file): self.roles_to_operations_file = config_file break if not self.roles_to_operations_file: - raise AuthException("Invalid permission configuration: roles_to_operations file missing") + raise AuthException( + "Invalid permission configuration: roles_to_operations file missing" + ) # load role_permissions def load_role_permissions(method_dict): for k in method_dict: if k == "ROLE_PERMISSION": - for method in chain(method_dict.get("METHODS", ()), method_dict.get("TODO", ())): + for method in chain( + method_dict.get("METHODS", ()), method_dict.get("TODO", ()) + ): permission = method_dict["ROLE_PERMISSION"] + method.lower() if permission not in self.role_permissions: self.role_permissions.append(permission) elif k in ("TODO", "METHODS"): continue - else: + elif method_dict[k]: load_role_permissions(method_dict[k]) load_role_permissions(self.valid_methods) @@ -157,6 +190,21 @@ class Authenticator: if permission not in self.role_permissions: self.role_permissions.append(permission) + # get ids of role system_admin and test project + role_system_admin = self.db.get_one( + "roles", {"name": "system_admin"}, fail_on_empty=False + ) + if role_system_admin: + self.system_admin_role_id = role_system_admin["_id"] + test_project_name = self.config["authentication"].get( + "project_not_authorized", "admin" + ) + test_project = self.db.get_one( + "projects", {"name": test_project_name}, fail_on_empty=False + ) + if test_project: + self.test_project_id = test_project["_id"] + except Exception as e: raise AuthException(str(e)) @@ -182,7 +230,9 @@ class Authenticator: project_desc["_id"] = str(uuid4()) project_desc["_admin"] = {"created": now, "modified": now} pid = self.backend.create_project(project_desc) - self.logger.info("Project '{}' created at database".format(project_desc["name"])) + self.logger.info( + "Project '{}' created at database".format(project_desc["name"]) + ) return pid def create_admin_user(self, project_id): @@ -196,7 +246,11 @@ class Authenticator: return None # user_desc = {"username": "admin", "password": "admin", "projects": [project_id]} now = time() - user_desc = {"username": "admin", "password": "admin", "_admin": {"created": now, "modified": now}} + user_desc = { + "username": "admin", + "password": "admin", + "_admin": {"created": now, "modified": now}, + } if project_id: pid = project_id else: @@ -206,12 +260,14 @@ class Authenticator: # role = self.db.get_one("roles", {"name": "system_admin"}, fail_on_empty=False, fail_on_more=False) roles = self.backend.get_role_list({"name": "system_admin"}) if pid and roles: - user_desc["project_role_mappings"] = [{"project": pid, "role": roles[0]["_id"]}] + user_desc["project_role_mappings"] = [ + {"project": pid, "role": roles[0]["_id"]} + ] uid = self.backend.create_user(user_desc) self.logger.info("User '{}' created at database".format(user_desc["username"])) return uid - def init_db(self, target_version='1.0'): + def init_db(self, target_version="1.0"): """ Check if the database has been initialized, with at least one user. If not, create the required tables and insert the predefined mappings between roles and permissions. @@ -222,8 +278,10 @@ class Authenticator: records = self.backend.get_role_list() - # Loading permissions to MongoDB if there is not any permission. - if not records or (len(records) == 1 and records[0]["name"] == "admin"): + # Loading permissions to AUTH. At lease system_admin must be present. + if not records or not next( + (r for r in records if r["name"] == "system_admin"), None + ): with open(self.roles_to_operations_file, "r") as stream: roles_to_operations_yaml = yaml.load(stream, Loader=yaml.Loader) @@ -233,23 +291,36 @@ class Authenticator: if role_with_operations["name"] not in role_names: role_names.append(role_with_operations["name"]) else: - raise AuthException("Duplicated role name '{}' at file '{}''" - .format(role_with_operations["name"], self.roles_to_operations_file)) + raise AuthException( + "Duplicated role name '{}' at file '{}''".format( + role_with_operations["name"], self.roles_to_operations_file + ) + ) if not role_with_operations["permissions"]: continue - for permission, is_allowed in role_with_operations["permissions"].items(): + for permission, is_allowed in role_with_operations[ + "permissions" + ].items(): if not isinstance(is_allowed, bool): - raise AuthException("Invalid value for permission '{}' at role '{}'; at file '{}'" - .format(permission, role_with_operations["name"], - self.roles_to_operations_file)) - - # TODO chek permission is ok + raise AuthException( + "Invalid value for permission '{}' at role '{}'; at file '{}'".format( + permission, + role_with_operations["name"], + self.roles_to_operations_file, + ) + ) + + # TODO check permission is ok if permission[-1] == ":": - raise AuthException("Invalid permission '{}' terminated in ':' for role '{}'; at file {}" - .format(permission, role_with_operations["name"], - self.roles_to_operations_file)) + raise AuthException( + "Invalid permission '{}' terminated in ':' for role '{}'; at file {}".format( + permission, + role_with_operations["name"], + self.roles_to_operations_file, + ) + ) if "default" not in role_with_operations["permissions"]: role_with_operations["permissions"]["default"] = False @@ -263,8 +334,19 @@ class Authenticator: } # self.db.create(self.roles_to_operations_table, role_with_operations) - self.backend.create_role(role_with_operations) - self.logger.info("Role '{}' created at database".format(role_with_operations["name"])) + try: + self.backend.create_role(role_with_operations) + self.logger.info( + "Role '{}' created".format(role_with_operations["name"]) + ) + except (AuthException, AuthconnException) as e: + if role_with_operations["name"] == "system_admin": + raise + self.logger.error( + "Role '{}' cannot be created: {}".format( + role_with_operations["name"], e + ) + ) # Create admin project&user if required pid = self.create_admin_project() @@ -290,11 +372,25 @@ class Authenticator: if user_with_system_admin: break if not user_with_system_admin: - self.backend.update_user({"_id": user_admin_id, - "add_project_role_mappings": [{"project": pid, "role": role_id}]}) - self.logger.info("Added role system admin to user='{}' project=admin".format(user_admin_id)) + self.backend.update_user( + { + "_id": user_admin_id, + "add_project_role_mappings": [ + {"project": pid, "role": role_id} + ], + } + ) + self.logger.info( + "Added role system admin to user='{}' project=admin".format( + user_admin_id + ) + ) except Exception as e: - self.logger.error("Error in Authorization DataBase initialization: {}: {}".format(type(e).__name__, e)) + self.logger.error( + "Error in Authorization DataBase initialization: {}: {}".format( + type(e).__name__, e + ) + ) self.load_operation_to_allowed_roles() @@ -304,7 +400,6 @@ class Authenticator: It works in a shadow copy and replace at the end to allow other threads working with the old copy :return: None """ - permissions = {oper: [] for oper in self.role_permissions} # records = self.db.get_list(self.roles_to_operations_table) records = self.backend.get_role_list() @@ -313,13 +408,21 @@ class Authenticator: for record in records: if not record.get("permissions"): continue - record_permissions = {oper: record["permissions"].get("default", False) for oper in self.role_permissions} - operations_joined = [(oper, value) for oper, value in record["permissions"].items() - if oper not in ignore_fields] + record_permissions = { + oper: record["permissions"].get("default", False) + for oper in self.role_permissions + } + operations_joined = [ + (oper, value) + for oper, value in record["permissions"].items() + if oper not in ignore_fields + ] operations_joined.sort(key=lambda x: x[0].count(":")) for oper in operations_joined: - match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys())) + match = list( + filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()) + ) for m in match: record_permissions[m] = oper[1] @@ -331,7 +434,9 @@ class Authenticator: self.operation_to_allowed_roles = permissions - def authorize(self, role_permission=None, query_string_operations=None, item_id=None): + def authorize( + self, role_permission=None, query_string_operations=None, item_id=None + ): token = None user_passwd64 = None try: @@ -349,7 +454,9 @@ class Authenticator: token = cherrypy.session.get("Authorization") if token == "logout": token = None # force Unauthorized response to insert user password again - elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"): + elif user_passwd64 and cherrypy.request.config.get( + "auth.allow_basic_authentication" + ): # 3. Get new token from user password user = None passwd = None @@ -358,13 +465,17 @@ class Authenticator: user, _, passwd = user_passwd.partition(":") except Exception: pass - outdata = self.new_token(None, {"username": user, "password": passwd}) + outdata = self.new_token( + None, {"username": user, "password": passwd} + ) token = outdata["_id"] - cherrypy.session['Authorization'] = token + cherrypy.session["Authorization"] = token if not token: - raise AuthException("Needed a token or Authorization http header", - http_code=HTTPStatus.UNAUTHORIZED) + raise AuthException( + "Needed a token or Authorization http header", + http_code=HTTPStatus.UNAUTHORIZED, + ) # try to get from cache first now = time() @@ -384,22 +495,34 @@ class Authenticator: # TODO add to token info remote host, port if role_permission: - RBAC_auth = self.check_permissions(token_info, cherrypy.request.method, role_permission, - query_string_operations, item_id) + RBAC_auth = self.check_permissions( + token_info, + cherrypy.request.method, + role_permission, + query_string_operations, + item_id, + ) + self.logger.info("RBAC_auth: {}".format(RBAC_auth)) token_info["allow_show_user_project_role"] = RBAC_auth return token_info except AuthException as e: if not isinstance(e, AuthExceptionUnauthorized): - if cherrypy.session.get('Authorization'): - del cherrypy.session['Authorization'] - cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e) - elif self.config.get("user_not_authorized"): - # TODO provide user_id, roles id (not name), project_id - return {"id": "fake-token-id-for-test", - "project_id": self.config.get("project_not_authorized", "admin"), - "username": self.config["user_not_authorized"], - "roles": ["system_admin"]} + if cherrypy.session.get("Authorization"): + del cherrypy.session["Authorization"] + cherrypy.response.headers[ + "WWW-Authenticate" + ] = 'Bearer realm="{}"'.format(e) + if self.config["authentication"].get("user_not_authorized"): + return { + "id": "testing-token", + "_id": "testing-token", + "project_id": self.test_project_id, + "username": self.config["authentication"]["user_not_authorized"], + "roles": [self.system_admin_role_id], + "admin": True, + "allow_show_user_project_role": True, + } raise def new_token(self, token_info, indata, remote): @@ -412,7 +535,9 @@ class Authenticator: if not new_token_info.get("expires"): new_token_info["expires"] = time() + 3600 if not new_token_info.get("admin"): - new_token_info["admin"] = True if new_token_info.get("project_name") == "admin" else False + new_token_info["admin"] = ( + True if new_token_info.get("project_name") == "admin" else False + ) # TODO put admin in RBAC if remote.name: @@ -428,8 +553,11 @@ class Authenticator: return self._internal_get_token_list(token_info) else: # TODO: check if this can be avoided. Backend may provide enough information - return [deepcopy(token) for token in self.tokens_cache.values() - if token["username"] == token_info["username"]] + return [ + deepcopy(token) + for token in self.tokens_cache.values() + if token["username"] == token_info["username"] + ] def get_token(self, token_info, token): if self.config["authentication"]["backend"] == "internal": @@ -439,8 +567,13 @@ class Authenticator: token_value = self.tokens_cache.get(token) if not token_value: raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND) - if token_value["username"] != token_info["username"] and not token_info["admin"]: - raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED) + if ( + token_value["username"] != token_info["username"] + and not token_info["admin"] + ): + raise AuthException( + "needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED + ) return token_value def del_token(self, token): @@ -450,9 +583,18 @@ class Authenticator: self.remove_token_from_cache(token) return "token '{}' deleted".format(token) except KeyError: - raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND) - - def check_permissions(self, token_info, method, role_permission=None, query_string_operations=None, item_id=None): + raise AuthException( + "Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND + ) + + def check_permissions( + self, + token_info, + method, + role_permission=None, + query_string_operations=None, + item_id=None, + ): """ Checks that operation has permissions to be done, base on the assigned roles to this user project :param token_info: Dictionary that contains "roles" with a list of assigned roles. @@ -466,6 +608,7 @@ class Authenticator: :return: True if access granted by permission rules, False if access granted by default rules (Bug 853) :raises: AuthExceptionUnauthorized if access denied """ + self.load_operation_to_allowed_roles() roles_required = self.operation_to_allowed_roles[role_permission] roles_allowed = [role["name"] for role in token_info["roles"]] @@ -487,7 +630,10 @@ class Authenticator: if not query_string_operations: return True for query_string_operation in query_string_operations: - if role not in self.operation_to_allowed_roles[query_string_operation]: + if ( + role + not in self.operation_to_allowed_roles[query_string_operation] + ): break else: return True @@ -496,8 +642,16 @@ class Authenticator: # User/Project/Role whole listings are filtered elsewhere # uid, pid, rid = ("user_id", "project_id", "id") if is_valid_uuid(id) else ("username", "project_name", "name") uid = "user_id" if is_valid_uuid(item_id) else "username" - if (role_permission in ["projects:get", "projects:id:get", "roles:get", "roles:id:get", "users:get"]) \ - or (role_permission == "users:id:get" and item_id == token_info[uid]): + if ( + role_permission + in [ + "projects:get", + "projects:id:get", + "roles:get", + "roles:id:get", + "users:get", + ] + ) or (role_permission == "users:id:get" and item_id == token_info[uid]): # or (role_permission == "projects:id:get" and item_id == token_info[pid]) \ # or (role_permission == "roles:id:get" and item_id in [role[rid] for role in token_info["roles"]]): return False @@ -505,7 +659,9 @@ class Authenticator: if not operation_allowed: raise AuthExceptionUnauthorized("Access denied: lack of permissions.") else: - raise AuthExceptionUnauthorized("Access denied: You have not permissions to use these admin query string") + raise AuthExceptionUnauthorized( + "Access denied: You have not permissions to use these admin query string" + ) def get_user_list(self): return self.backend.get_user_list() @@ -513,12 +669,15 @@ class Authenticator: def _normalize_url(self, url, method): # DEPRECATED !!! # Removing query strings - normalized_url = url if '?' not in url else url[:url.find("?")] + normalized_url = url if "?" not in url else url[: url.find("?")] normalized_url_splitted = normalized_url.split("/") parameters = {} - filtered_keys = [key for key in self.resources_to_operations_mapping.keys() - if method in key.split()[0]] + filtered_keys = [ + key + for key in self.resources_to_operations_mapping.keys() + if method in key.split()[0] + ] for idx, path_part in enumerate(normalized_url_splitted): tmp_keys = [] @@ -530,33 +689,47 @@ class Authenticator: if splitted[idx] == "": tmp_keys.append(tmp_key) continue - elif idx == len(normalized_url_splitted) - 1 and \ - len(normalized_url_splitted) != len(splitted): + elif idx == len(normalized_url_splitted) - 1 and len( + normalized_url_splitted + ) != len(splitted): continue else: tmp_keys.append(tmp_key) elif splitted[idx] == path_part: - if idx == len(normalized_url_splitted) - 1 and \ - len(normalized_url_splitted) != len(splitted): + if idx == len(normalized_url_splitted) - 1 and len( + normalized_url_splitted + ) != len(splitted): continue else: tmp_keys.append(tmp_key) filtered_keys = tmp_keys - if len(filtered_keys) == 1 and \ - filtered_keys[0].split("/")[-1] == "": + if ( + len(filtered_keys) == 1 + and filtered_keys[0].split("/")[-1] == "" + ): break if len(filtered_keys) == 0: - raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url)) + raise AuthException( + "Cannot make an authorization decision. URL not found. URL: {0}".format( + url + ) + ) elif len(filtered_keys) > 1: - raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url)) + raise AuthException( + "Cannot make an authorization decision. Multiple URLs found. URL: {0}".format( + url + ) + ) filtered_key = filtered_keys[0] for idx, path_part in enumerate(filtered_key.split()[1].split("/")): if "<" in path_part and ">" in path_part: if path_part == "": - parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:]) + parameters[path_part[1:-1]] = "/".join( + normalized_url_splitted[idx:] + ) else: parameters[path_part[1:-1]] = normalized_url_splitted[idx] @@ -564,21 +737,28 @@ class Authenticator: def _internal_get_token_list(self, token_info): now = time() - token_list = self.db.get_list("tokens", {"username": token_info["username"], "expires.gt": now}) + token_list = self.db.get_list( + "tokens", {"username": token_info["username"], "expires.gt": now} + ) return token_list def _internal_get_token(self, token_info, token_id): token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False) if not token_value: raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND) - if token_value["username"] != token_info["username"] and not token_info["admin"]: - raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED) + if ( + token_value["username"] != token_info["username"] + and not token_info["admin"] + ): + raise AuthException( + "needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED + ) return token_value - def _internal_tokens_prune(self, now=None): + def _internal_tokens_prune(self, token_collection, now=None): now = now or time() if not self.next_db_prune_time or self.next_db_prune_time >= now: - self.db.del_list("tokens", {"expires.lt": now}) + self.db.del_list(token_collection, {"expires.lt": now}) self.next_db_prune_time = self.periodin_db_pruning + now # self.tokens_cache.clear() # not required any more