Fix Bug 774 - NBI+Keystone: Trying to obtain a token with user+password+project gives...
[osm/NBI.git] / osm_nbi / auth.py
index 18986a3..576ae4d 100644 (file)
@@ -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
 """
 create the tokens unscoped and scoped, retrieve the role
 list inside the projects that they are inserted
 """
-from os import path
 
 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
 __date__ = "$27-jul-2018 23:59:59$"
 
 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
 __date__ = "$27-jul-2018 23:59:59$"
@@ -35,14 +34,15 @@ import logging
 import yaml
 from base64 import standard_b64decode
 from copy import deepcopy
 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 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
 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.
     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
     """
 
     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.resources_to_operations_mapping = {}
         self.operation_to_allowed_roles = {}
         self.logger = logging.getLogger("nbi.authenticator")
+        self.operations = []
 
     def start(self, config):
         """
 
     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:
                 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
                         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:
                 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
                         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: "<METHOD> <URL>"
         # Note: it is faster to rewrite the value than to check if it is already there or not
         # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
         # Operations encoding: "<METHOD> <URL>"
         # 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():
         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")
 
 
         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)
 
             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:
                 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))
 
 
-                role_ops = {}
-                root = None
-
-                if not role_with_operations["operations"]:
+                if not role_with_operations["permissions"]:
                     continue
 
                     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):
                     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 role_ops.keys():
-                        role_ops[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:
                     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 role_ops.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")
 
         records = self.db.get_list("roles_operations")
 
-        ignore_fields = ["_id", "_admin", "role", "root"]
-        roles = []
+        ignore_fields = ["_id", "_admin", "name", "default"]
         for record in records:
         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:
             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:
             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
 
     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)
                 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:
         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:
 
     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 = {
 
             now = time()
             new_session = {
-                "_id": token,
-                "id": token,
+                "_id": token_info["_id"],
+                "id": token_info["_id"],
                 "issued_at": now,
                 "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,
                 "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:
             }
 
             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
                 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)
 
 
             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]
 
         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
 
         if "anonymous" in roles_required:
             return
@@ -411,7 +426,10 @@ class Authenticator:
             if role in roles_required:
                 return
 
             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
 
     def _normalize_url(self, url, method):
         # Removing query strings
@@ -472,7 +490,8 @@ class Authenticator:
             now = time()
             session = self.tokens_cache.get(token_id)
             if session and session["expires"] < now:
             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
                 session = None
             if session:
                 return session
@@ -493,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"),
             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
 
             else:
                 raise
 
@@ -524,17 +543,21 @@ class Authenticator:
 
         token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
                            for _ in range(0, 32))
 
         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:
         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"],
             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"],