bug 767: uniform role format
[osm/NBI.git] / osm_nbi / auth.py
index 18986a3..e9a6b4e 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,12 +34,13 @@ 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_keystone import AuthconnKeystone
 
 from authconn import AuthException
 from authconn_keystone import AuthconnKeystone
@@ -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,91 @@ 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
-
-                role_ops = {}
-                root = None
+                    raise AuthException("Duplicated role name '{}' at file '{}''"
+                                        .format(role_with_operations["name"], self.roles_to_operations_file))
 
 
-                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
+
+                now = time()
+                role_with_operations["_admin"] = {
+                    "created": now,
+                    "modified": now,
+                }
+
+                if self.config["authentication"]["backend"] != "internal" and \
+                        role_with_operations["name"] != "anonymous":
 
 
-                    operation_key = operation.replace(".", ":")
-                    if operation_key not in role_ops.keys():
-                        role_ops[operation_key] = is_allowed
+                    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
+        :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", "admin"]
         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,19 +251,11 @@ 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"])
+                permissions[allowed_op].append(record["name"])
 
         for oper, role_list in permissions.items():
             self.operation_to_allowed_roles[oper] = role_list
 
 
         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)
-
-            self.backend.assign_role_to_user("admin", "admin", "system_admin")
-
     def authorize(self):
         token = None
         user_passwd64 = None
     def authorize(self):
         token = None
         user_passwd64 = None
@@ -311,43 +311,53 @@ class Authenticator:
         if self.config["authentication"]["backend"] == "internal":
             return self._internal_new_token(session, indata, remote)
         else:
         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("username"),
+                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 +366,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 +412,7 @@ 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 = self.backend.get_user_role_list(session["_id"])
 
         if "anonymous" in roles_required:
             return
 
         if "anonymous" in roles_required:
             return
@@ -413,6 +423,9 @@ class Authenticator:
 
         raise AuthException("Access denied: lack of permissions.")
 
 
         raise AuthException("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
         normalized_url = url if '?' not in url else url[:url.find("?")]
     def _normalize_url(self, url, method):
         # Removing query strings
         normalized_url = url if '?' not in url else url[:url.find("?")]
@@ -472,7 +485,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 +507,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 +538,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"],