X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=blobdiff_plain;f=osm_nbi%2Fauth.py;h=576ae4d376d6964c7ccb489ee6dd0c179e31439d;hp=44eaa9438df3048cb976db0b830da3b899f9c282;hb=ace34903be528e8e10e096d3d059b81df30ddaa9;hpb=7b7ffa61e6282094c1bd528e60b3b395c3ab1358;ds=sidebyside diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index 44eaa94..576ae4d 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -25,7 +25,6 @@ Authenticator is responsible for authenticating the users, create the tokens unscoped and scoped, retrieve the role list inside the projects that they are inserted """ -from os import path __author__ = "Eduardo Sousa ; Alfonso Tierno " __date__ = "$27-jul-2018 23:59:59$" @@ -35,14 +34,15 @@ import logging import yaml from base64 import standard_b64decode from copy import deepcopy -from functools import reduce +# from functools import reduce from hashlib import sha256 from http import HTTPStatus from random import choice as random_choice from time import time -from uuid import uuid4 +from os import path +from base_topic import BaseTopic # To allow project names in project_id -from authconn import AuthException +from authconn import AuthException, AuthExceptionUnauthorized from authconn_keystone import AuthconnKeystone from osm_common import dbmongo from osm_common import dbmemory @@ -55,6 +55,7 @@ class Authenticator: Authorization. Initially it should support Openstack Keystone as a backend through a plugin model where more backends can be added and a RBAC model to manage permissions on operations. + This class must be threading safe """ periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned @@ -74,6 +75,7 @@ class Authenticator: self.resources_to_operations_mapping = {} self.operation_to_allowed_roles = {} self.logger = logging.getLogger("nbi.authenticator") + self.operations = [] def start(self, config): """ @@ -108,8 +110,11 @@ class Authenticator: if "resources_to_operations" in config["rbac"]: self.resources_to_operations_file = config["rbac"]["resources_to_operations"] else: - for config_file in (__file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml", - "./resources_to_operations.yml"): + possible_paths = ( + __file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml", + "./resources_to_operations.yml" + ) + for config_file in possible_paths: if path.isfile(config_file): self.resources_to_operations_file = config_file break @@ -119,8 +124,11 @@ class Authenticator: if "roles_to_operations" in config["rbac"]: self.roles_to_operations_file = config["rbac"]["roles_to_operations"] else: - for config_file in (__file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml", - "./roles_to_operations.yml"): + possible_paths = ( + __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 @@ -147,91 +155,92 @@ class Authenticator: # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB) # Operations encoding: " " # Note: it is faster to rewrite the value than to check if it is already there or not - operations = [] + if self.config["authentication"]["backend"] == "internal": + return + with open(self.resources_to_operations_file, "r") as stream: resources_to_operations_yaml = yaml.load(stream) for resource, operation in resources_to_operations_yaml["resources_to_operations"].items(): - operation_key = operation.replace(".", ":") - if operation_key not in operations: - operations.append(operation_key) - self.resources_to_operations_mapping[resource] = operation_key + if operation not in self.operations: + self.operations.append(operation) + self.resources_to_operations_mapping[resource] = operation records = self.db.get_list("roles_operations") - # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing. - if len(records) == 0: + # Loading permissions to MongoDB if there is not any permission. + if not records: with open(self.roles_to_operations_file, "r") as stream: roles_to_operations_yaml = yaml.load(stream) - roles = [] - for role_with_operations in roles_to_operations_yaml["roles_to_operations"]: - # Verifying if role already exists. If it does, send warning to log and ignore it. - if role_with_operations["role"] not in roles: - roles.append(role_with_operations["role"]) + role_names = [] + for role_with_operations in roles_to_operations_yaml["roles"]: + # Verifying if role already exists. If it does, raise exception + if role_with_operations["name"] not in role_names: + role_names.append(role_with_operations["name"]) else: - self.logger.warning("Duplicated role with name: {0}. Role definition is ignored." - .format(role_with_operations["role"])) - continue + raise AuthException("Duplicated role name '{}' at file '{}''" + .format(role_with_operations["name"], self.roles_to_operations_file)) - operations = {} - root = None - - if not role_with_operations["operations"]: + if not role_with_operations["permissions"]: continue - for operation, is_allowed in role_with_operations["operations"].items(): + for permission, is_allowed in role_with_operations["permissions"].items(): if not isinstance(is_allowed, bool): - continue + raise AuthException("Invalid value for permission '{}' at role '{}'; at file '{}'" + .format(permission, role_with_operations["name"], + self.roles_to_operations_file)) - if operation == ".": - root = is_allowed - continue + # TODO chek 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)) - if len(operation) != 1 and operation[-1] == ".": - self.logger.warning("Invalid operation {0} terminated in '.'. " - "Operation will be discarded" - .format(operation)) - continue + if "default" not in role_with_operations["permissions"]: + role_with_operations["permissions"]["default"] = False + if "admin" not in role_with_operations["permissions"]: + role_with_operations["permissions"]["admin"] = False - operation_key = operation.replace(".", ":") - if operation_key not in operations.keys(): - operations[operation_key] = is_allowed + now = time() + role_with_operations["_admin"] = { + "created": now, + "modified": now, + } + + if self.config["authentication"]["backend"] != "internal" and \ + role_with_operations["name"] != "anonymous": + + backend_roles = self.backend.get_role_list(filter_q={"name": role_with_operations["name"]}) + + if backend_roles: + backend_id = backend_roles[0]["_id"] else: - self.logger.info("In role {0}, the operation {1} with the value {2} was discarded due to " - "repetition.".format(role_with_operations["role"], operation, is_allowed)) + backend_id = self.backend.create_role(role_with_operations["name"]) + role_with_operations["_id"] = backend_id - if not root: - root = False - self.logger.info("Root for role {0} not defined. Default value 'False' applied." - .format(role_with_operations["role"])) + self.db.create("roles_operations", role_with_operations) - now = time() - operation_to_roles_item = { - "_id": str(uuid4()), - "_admin": { - "created": now, - "modified": now, - }, - "role": role_with_operations["role"], - "root": root - } + if self.config["authentication"]["backend"] != "internal": + self.backend.assign_role_to_user("admin", "admin", "system_admin") - for operation, value in operations.items(): - operation_to_roles_item[operation] = value + self.load_operation_to_allowed_roles() - self.db.create("roles_operations", operation_to_roles_item) + def load_operation_to_allowed_roles(self): + """ + Fills the internal self.operation_to_allowed_roles based on database role content and self.operations + 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 operations} + permissions = {oper: [] for oper in self.operations} records = self.db.get_list("roles_operations") - ignore_fields = ["_id", "_admin", "role", "root"] - roles = [] + ignore_fields = ["_id", "_admin", "name", "default"] for record in records: - - roles.append(record["role"]) - record_permissions = {oper: record["root"] for oper in operations} - operations_joined = [(oper, value) for oper, value in record.items() if oper not in ignore_fields] + record_permissions = {oper: record["permissions"].get("default", False) for oper in self.operations} + 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: @@ -243,18 +252,9 @@ class Authenticator: allowed_operations = [k for k, v in record_permissions.items() if v is True] for allowed_op in allowed_operations: - permissions[allowed_op].append(record["role"]) - - for oper, role_list in permissions.items(): - self.operation_to_allowed_roles[oper] = role_list - - if self.config["authentication"]["backend"] != "internal": - for role in roles: - if role == "anonymous": - continue - self.backend.create_role(role) + permissions[allowed_op].append(record["name"]) - self.backend.assign_role_to_user("admin", "admin", "system_admin") + self.operation_to_allowed_roles = permissions def authorize(self): token = None @@ -292,62 +292,70 @@ class Authenticator: if not token: raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED) - try: - self.backend.validate_token(token) - self.check_permissions(self.tokens_cache[token], cherrypy.request.path_info, - cherrypy.request.method) - # TODO: check if this can be avoided. Backend may provide enough information - return deepcopy(self.tokens_cache[token]) - except AuthException: - self.del_token(token) - raise + token_info = self.backend.validate_token(token) + # TODO add to token info remote host, port + + self.check_permissions(token_info, cherrypy.request.path_info, + cherrypy.request.method) + return token_info except AuthException as e: - if cherrypy.session.get('Authorization'): - del cherrypy.session['Authorization'] - cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e) - raise AuthException(str(e)) + if not isinstance(e, AuthExceptionUnauthorized): + if cherrypy.session.get('Authorization'): + del cherrypy.session['Authorization'] + cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e) + raise def new_token(self, session, indata, remote): if self.config["authentication"]["backend"] == "internal": return self._internal_new_token(session, indata, remote) else: - if indata.get("username"): - token, projects = self.backend.authenticate_with_user_password( - indata.get("username"), indata.get("password")) - elif session: - token, projects = self.backend.authenticate_with_token( - session.get("id"), indata.get("project_id")) - else: - raise AuthException("Provide credentials: username/password or Authorization Bearer token", - http_code=HTTPStatus.UNAUTHORIZED) - - if indata.get("project_id"): - project_id = indata.get("project_id") - if project_id not in projects: - raise AuthException("Project {} not allowed for this user".format(project_id), - http_code=HTTPStatus.UNAUTHORIZED) - else: - project_id = projects[0] - - if not session: - token, projects = self.backend.authenticate_with_token(token, project_id) - - if project_id == "admin": - session_admin = True - else: - session_admin = reduce(lambda x, y: x or (True if y == "admin" else False), - projects, False) + current_token = None + if session: + current_token = session.get("token") + token_info = self.backend.authenticate( + user=indata.get("username"), + password=indata.get("password"), + token=current_token, + project=indata.get("project_id") + ) + + # if indata.get("username"): + # token, projects = self.backend.authenticate_with_user_password( + # indata.get("username"), indata.get("password")) + # elif session: + # token, projects = self.backend.authenticate_with_token( + # session.get("id"), indata.get("project_id")) + # else: + # raise AuthException("Provide credentials: username/password or Authorization Bearer token", + # http_code=HTTPStatus.UNAUTHORIZED) + # + # if indata.get("project_id"): + # project_id = indata.get("project_id") + # if project_id not in projects: + # raise AuthException("Project {} not allowed for this user".format(project_id), + # http_code=HTTPStatus.UNAUTHORIZED) + # else: + # project_id = projects[0] + # + # if not session: + # token, projects = self.backend.authenticate_with_token(token, project_id) + # + # if project_id == "admin": + # session_admin = True + # else: + # session_admin = reduce(lambda x, y: x or (True if y == "admin" else False), + # projects, False) now = time() new_session = { - "_id": token, - "id": token, + "_id": token_info["_id"], + "id": token_info["_id"], "issued_at": now, - "expires": now + 3600, - "project_id": project_id, - "username": indata.get("username") if not session else session.get("username"), + "expires": token_info.get("expires", now + 3600), + "project_id": token_info["project_id"], + "username": token_info.get("username") or session.get("username"), "remote_port": remote.port, - "admin": session_admin + "admin": True if token_info.get("project_name") == "admin" else False # TODO put admin in RBAC } if remote.name: @@ -356,7 +364,7 @@ class Authenticator: new_session["remote_host"] = remote.ip # TODO: check if this can be avoided. Backend may provide enough information - self.tokens_cache[token] = new_session + self.tokens_cache[token_info["_id"]] = new_session return deepcopy(new_session) @@ -402,7 +410,14 @@ class Authenticator: operation = self.resources_to_operations_mapping[key] roles_required = self.operation_to_allowed_roles[operation] - roles_allowed = self.backend.get_role_list(session["id"]) + roles_allowed = [role["name"] for role in session["roles"]] + + # fills session["admin"] if some roles allows it + session["admin"] = False + for role in roles_allowed: + if role in self.operation_to_allowed_roles["admin"]: + session["admin"] = True + break if "anonymous" in roles_required: return @@ -411,7 +426,10 @@ class Authenticator: if role in roles_required: return - raise AuthException("Access denied: lack of permissions.") + raise AuthExceptionUnauthorized("Access denied: lack of permissions.") + + def get_user_list(self): + return self.backend.get_user_list() def _normalize_url(self, url, method): # Removing query strings @@ -426,7 +444,9 @@ class Authenticator: tmp_keys = [] for tmp_key in filtered_keys: splitted = tmp_key.split()[1].split("/") - if "<" in splitted[idx] and ">" in splitted[idx]: + if idx >= len(splitted): + continue + elif "<" in splitted[idx] and ">" in splitted[idx]: if splitted[idx] == "": tmp_keys.append(tmp_key) continue @@ -470,7 +490,8 @@ class Authenticator: now = time() session = self.tokens_cache.get(token_id) if session and session["expires"] < now: - del self.tokens_cache[token_id] + # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del + self.tokens_cache.pop(token_id, None) session = None if session: return session @@ -491,7 +512,7 @@ class Authenticator: if self.config["global"].get("test.user_not_authorized"): return {"id": "fake-token-id-for-test", "project_id": self.config["global"].get("test.project_not_authorized", "admin"), - "username": self.config["global"]["test.user_not_authorized"]} + "username": self.config["global"]["test.user_not_authorized"], "admin": True} else: raise @@ -522,17 +543,21 @@ class Authenticator: token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(0, 32)) - if indata.get("project_id"): - project_id = indata.get("project_id") - if project_id not in user_content["projects"]: - raise AuthException("project {} not allowed for this user" - .format(project_id), http_code=HTTPStatus.UNAUTHORIZED) + project_id = indata.get("project_id") + if project_id: + if project_id != "admin": + # To allow project names in project_id + proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id}) + if proj["_id"] not in user_content["projects"] and proj["name"] not in user_content["projects"]: + raise AuthException("project {} not allowed for this user" + .format(project_id), http_code=HTTPStatus.UNAUTHORIZED) else: project_id = user_content["projects"][0] if project_id == "admin": session_admin = True else: - project = self.db.get_one("projects", {"_id": project_id}) + # To allow project names in project_id + project = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id}) session_admin = project.get("admin", False) new_session = {"issued_at": now, "expires": now + 3600, "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],