change version to be aligned with OSM version
[osm/NBI.git] / osm_nbi / auth.py
index b1f73fe..3047c9f 100644 (file)
@@ -39,14 +39,15 @@ from http import HTTPStatus
 from time import time
 from os import path
 
 from time import time
 from os import path
 
-from authconn import AuthException, AuthExceptionUnauthorized
-from authconn_keystone import AuthconnKeystone
-from authconn_internal import AuthconnInternal   # Comment out for testing&debugging, uncomment when ready
+from osm_nbi.authconn import AuthException, AuthExceptionUnauthorized
+from osm_nbi.authconn_keystone import AuthconnKeystone
+from osm_nbi.authconn_internal import AuthconnInternal   # Comment out for testing&debugging, uncomment when ready
 from osm_common import dbmongo
 from osm_common import dbmemory
 from osm_common.dbbase import DbException
 from osm_common import dbmongo
 from osm_common import dbmemory
 from osm_common.dbbase import DbException
+from itertools import chain
 
 
-from uuid import uuid4   # For Role _id with internal authentication backend
+from uuid import uuid4
 
 
 class Authenticator:
 
 
 class Authenticator:
@@ -60,7 +61,7 @@ class Authenticator:
 
     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
 
-    def __init__(self):
+    def __init__(self, valid_methods, valid_query_string):
         """
         Authenticator initializer. Setup the initial state of the object,
         while it waits for the config dictionary and database initialization.
         """
         Authenticator initializer. Setup the initial state of the object,
         while it waits for the config dictionary and database initialization.
@@ -70,13 +71,14 @@ class Authenticator:
         self.db = None
         self.tokens_cache = dict()
         self.next_db_prune_time = 0  # time when next cleaning of expired tokens must be done
         self.db = None
         self.tokens_cache = dict()
         self.next_db_prune_time = 0  # time when next cleaning of expired tokens must be done
-        self.resources_to_operations_file = None
         self.roles_to_operations_file = None
         self.roles_to_operations_file = None
-        self.roles_to_operations_table = None
+        self.roles_to_operations_table = None
         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 = []
+        self.role_permissions = []
+        self.valid_methods = valid_methods
+        self.valid_query_string = valid_query_string
 
     def start(self, config):
         """
 
     def start(self, config):
         """
@@ -101,27 +103,14 @@ class Authenticator:
                                         .format(config["database"]["driver"]))
             if not self.backend:
                 if config["authentication"]["backend"] == "keystone":
                                         .format(config["database"]["driver"]))
             if not self.backend:
                 if config["authentication"]["backend"] == "keystone":
-                    self.backend = AuthconnKeystone(self.config["authentication"])
+                    self.backend = AuthconnKeystone(self.config["authentication"], self.db, self.tokens_cache)
                 elif config["authentication"]["backend"] == "internal":
                     self.backend = AuthconnInternal(self.config["authentication"], self.db, self.tokens_cache)
                     self._internal_tokens_prune()
                 else:
                     raise AuthException("Unknown authentication backend: {}"
                                         .format(config["authentication"]["backend"]))
                 elif config["authentication"]["backend"] == "internal":
                     self.backend = AuthconnInternal(self.config["authentication"], self.db, self.tokens_cache)
                     self._internal_tokens_prune()
                 else:
                     raise AuthException("Unknown authentication backend: {}"
                                         .format(config["authentication"]["backend"]))
-            if not self.resources_to_operations_file:
-                if "resources_to_operations" in config["rbac"]:
-                    self.resources_to_operations_file = config["rbac"]["resources_to_operations"]
-                else:
-                    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 not self.resources_to_operations_file:
-                        raise AuthException("Invalid permission configuration: resources_to_operations file missing")
+
             if not self.roles_to_operations_file:
                 if "roles_to_operations" in config["rbac"]:
                     self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
             if not self.roles_to_operations_file:
                 if "roles_to_operations" in config["rbac"]:
                     self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
@@ -134,12 +123,29 @@ class Authenticator:
                         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
-                    if not self.roles_to_operations_file:
-                        raise AuthException("Invalid permission configuration: roles_to_operations file missing")
-            if not self.roles_to_operations_table:  # PROVISIONAL ?
-                self.roles_to_operations_table = "roles_operations" \
-                    if config["authentication"]["backend"] == "keystone" \
-                    else "roles"
+                if not self.roles_to_operations_file:
+                    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", ())):
+                            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:
+                        load_role_permissions(method_dict[k])
+
+            load_role_permissions(self.valid_methods)
+            for query_string in self.valid_query_string:
+                for method in ("get", "put", "patch", "post", "delete"):
+                    permission = query_string.lower() + ":" + method
+                    if permission not in self.role_permissions:
+                        self.role_permissions.append(permission)
+
         except Exception as e:
             raise AuthException(str(e))
 
         except Exception as e:
             raise AuthException(str(e))
 
@@ -150,6 +156,50 @@ class Authenticator:
         except DbException as e:
             raise AuthException(str(e), http_code=e.http_code)
 
         except DbException as e:
             raise AuthException(str(e), http_code=e.http_code)
 
+    def create_admin_project(self):
+        """
+        Creates a new project 'admin' into database if it doesn't exist. Useful for initialization.
+        :return: _id identity of the 'admin' project
+        """
+
+        # projects = self.db.get_one("projects", fail_on_empty=False, fail_on_more=False)
+        project_desc = {"name": "admin"}
+        projects = self.backend.get_project_list(project_desc)
+        if projects:
+            return projects[0]["_id"]
+        now = time()
+        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"]))
+        return pid
+
+    def create_admin_user(self, project_id):
+        """
+        Creates a new user admin/admin into database if database is empty. Useful for initialization
+        :return: _id identity of the inserted data, or None
+        """
+        # users = self.db.get_one("users", fail_on_empty=False, fail_on_more=False)
+        users = self.backend.get_user_list()
+        if users:
+            return None
+        # user_desc = {"username": "admin", "password": "admin", "projects": [project_id]}
+        now = time()
+        user_desc = {"username": "admin", "password": "admin", "_admin": {"created": now, "modified": now}}
+        if project_id:
+            pid = project_id
+        else:
+            # proj = self.db.get_one("projects", {"name": "admin"}, fail_on_empty=False, fail_on_more=False)
+            proj = self.backend.get_project_list({"name": "admin"})
+            pid = proj[0]["_id"] if proj else None
+        # 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"]}]
+        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'):
         """
         Check if the database has been initialized, with at least one user. If not, create the required tables
     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
@@ -158,26 +208,11 @@ class Authenticator:
         :param target_version: schema version that should be present in the database.
         :return: None if OK, exception if error or version is different.
         """
         :param target_version: schema version that should be present in the database.
         :return: None if OK, exception if error or version is different.
         """
-        # 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
-
-        # PCR 28/05/2019 Commented out to allow initialization for internal backend
-        # 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():
-            if operation not in self.operations:
-                self.operations.append(operation)
-            self.resources_to_operations_mapping[resource] = operation
-
-        records = self.db.get_list(self.roles_to_operations_table)
+        records = self.backend.get_role_list()
 
         # Loading permissions to MongoDB if there is not any permission.
 
         # Loading permissions to MongoDB if there is not any permission.
-        if not records:
+        if not records or (len(records) == 1 and records[0]["name"] == "admin"):
             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)
 
@@ -216,37 +251,40 @@ class Authenticator:
                     "modified": now,
                 }
 
                     "modified": now,
                 }
 
-                if self.config["authentication"]["backend"] == "keystone":
-                    if 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:
-                            backend_id = self.backend.create_role(role_with_operations["name"])
-                        role_with_operations["_id"] = backend_id
-                else:
-                    role_with_operations["_id"] = str(uuid4())
+                # 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"]))
 
 
-                self.db.create(self.roles_to_operations_table, role_with_operations)
+        # Create admin project&user if required
+        pid = self.create_admin_project()
+        self.create_admin_user(pid)
 
 
-        if self.config["authentication"]["backend"] != "internal":
-            self.backend.assign_role_to_user("admin", "admin", "system_admin")
+        # self.backend.update_user({"_id": "admin",
+        #                           "add_project_role_mappings": {"project": "admin", "role": "system_admin"}})
+        if self.config["authentication"]["backend"] == "keystone":
+            try:
+                self.backend.assign_role_to_user("admin", "admin", "system_admin")
+            except Exception:
+                pass
 
         self.load_operation_to_allowed_roles()
 
     def load_operation_to_allowed_roles(self):
         """
 
         self.load_operation_to_allowed_roles()
 
     def load_operation_to_allowed_roles(self):
         """
-        Fills the internal self.operation_to_allowed_roles based on database role content and self.operations
+        Fills the internal self.operation_to_allowed_roles based on database role content and self.role_permissions
         It works in a shadow copy and replace at the end to allow other threads working with the old copy
         :return: None
         """
 
         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.operations}
-        records = self.db.get_list(self.roles_to_operations_table)
+        permissions = {oper: [] for oper in self.role_permissions}
+        # records = self.db.get_list(self.roles_to_operations_table)
+        records = self.backend.get_role_list()
 
         ignore_fields = ["_id", "_admin", "name", "default"]
         for record in records:
 
         ignore_fields = ["_id", "_admin", "name", "default"]
         for record in records:
-            record_permissions = {oper: record["permissions"].get("default", False) for oper in self.operations}
+            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]
             operations_joined.sort(key=lambda x: x[0].count(":"))
             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(":"))
@@ -264,7 +302,7 @@ class Authenticator:
 
         self.operation_to_allowed_roles = permissions
 
 
         self.operation_to_allowed_roles = permissions
 
-    def authorize(self):
+    def authorize(self, role_permission=None, query_string_operations=None):
         token = None
         user_passwd64 = None
         try:
         token = None
         user_passwd64 = None
         try:
@@ -292,7 +330,7 @@ class Authenticator:
                     except Exception:
                         pass
                     outdata = self.new_token(None, {"username": user, "password": passwd})
                     except Exception:
                         pass
                     outdata = self.new_token(None, {"username": user, "password": passwd})
-                    token = outdata["id"]
+                    token = outdata["_id"]
                     cherrypy.session['Authorization'] = token
 
             if not token:
                     cherrypy.session['Authorization'] = token
 
             if not token:
@@ -301,69 +339,65 @@ class Authenticator:
             token_info = self.backend.validate_token(token)
             # TODO add to token info remote host, port
 
             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)
+            if role_permission:
+                self.check_permissions(token_info, cherrypy.request.method, role_permission,
+                                       query_string_operations)
             return token_info
             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)
         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"]}
             raise
 
             raise
 
-    def new_token(self, session, indata, remote):
-        current_token = None
-        if session:
-            # current_token = session.get("token")
-            current_token = session.get("_id") if self.config["authentication"]["backend"] == "keystone" \
-                else session
-        token_info = self.backend.authenticate(
+    def new_token(self, token_info, indata, remote):
+        new_token_info = self.backend.authenticate(
             user=indata.get("username"),
             password=indata.get("password"),
             user=indata.get("username"),
             password=indata.get("password"),
-            token=current_token,
+            token_info=token_info,
             project=indata.get("project_id")
         )
 
             project=indata.get("project_id")
         )
 
-        now = time()
-        new_session = {
-            "_id": token_info["_id"],
-            "id": token_info["_id"],
-            "issued_at": now,
-            "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": True if token_info.get("project_name") == "admin" else False   # TODO put admin in RBAC
-        }
+        new_token_info["remote_port"] = remote.port
+        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
+            # TODO put admin in RBAC
 
         if remote.name:
 
         if remote.name:
-            new_session["remote_host"] = remote.name
+            new_token_info["remote_host"] = remote.name
         elif remote.ip:
         elif remote.ip:
-            new_session["remote_host"] = remote.ip
+            new_token_info["remote_host"] = remote.ip
 
 
-        # TODO: check if this can be avoided. Backend may provide enough information
-        self.tokens_cache[token_info["_id"]] = new_session
+        self.tokens_cache[new_token_info["_id"]] = new_token_info
 
 
-        return deepcopy(new_session)
+        # TODO call self._internal_tokens_prune(now) ?
+        return deepcopy(new_token_info)
 
 
-    def get_token_list(self, session):
+    def get_token_list(self, token_info):
         if self.config["authentication"]["backend"] == "internal":
         if self.config["authentication"]["backend"] == "internal":
-            return self._internal_get_token_list(session)
+            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()
         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"] == session["username"]]
+                    if token["username"] == token_info["username"]]
 
 
-    def get_token(self, session, token):
+    def get_token(self, token_info, token):
         if self.config["authentication"]["backend"] == "internal":
         if self.config["authentication"]["backend"] == "internal":
-            return self._internal_get_token(session, token)
+            return self._internal_get_token(token_info, token)
         else:
             # TODO: check if this can be avoided. Backend may provide enough information
             token_value = self.tokens_cache.get(token)
             if not token_value:
                 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
         else:
             # TODO: check if this can be avoided. Backend may provide enough information
             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"] != session["username"] and not session["admin"]:
+            if token_value["username"] != token_info["username"] and not token_info["admin"]:
                 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
             return token_value
 
                 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
             return token_value
 
@@ -375,39 +409,54 @@ class Authenticator:
         except KeyError:
             raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
 
         except KeyError:
             raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
 
-    def check_permissions(self, session, url, method):
-        self.logger.info("Session: {}".format(session))
-        self.logger.info("URL: {}".format(url))
-        self.logger.info("Method: {}".format(method))
-
-        key, parameters = self._normalize_url(url, method)
-
-        # TODO: Check if parameters might be useful for the decision
+    def check_permissions(self, token_info, method, role_permission=None, query_string_operations=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.
+            This method fills the token_info["admin"] with True or False based on assigned tokens, if any allows admin
+            This will be used among others to hide or not the _admin content of topics
+        :param method: GET,PUT, POST, ...
+        :param role_permission: role permission name of the operation required
+        :param query_string_operations: list of possible admin query strings provided by user. It is checked that the
+            assigned role allows this query string for this method
+        :return: None if granted, exception if not allowed
+        """
 
 
-        operation = self.resources_to_operations_mapping[key]
-        roles_required = self.operation_to_allowed_roles[operation]
-        roles_allowed = [role["name"] for role in session["roles"]]
+        roles_required = self.operation_to_allowed_roles[role_permission]
+        roles_allowed = [role["name"] for role in token_info["roles"]]
 
 
-        # fills session["admin"] if some roles allows it
-        session["admin"] = False
+        # fills token_info["admin"] if some roles allows it
+        token_info["admin"] = False
         for role in roles_allowed:
         for role in roles_allowed:
-            if role in self.operation_to_allowed_roles["admin"]:
-                session["admin"] = True
+            if role in self.operation_to_allowed_roles["admin:" + method.lower()]:
+                token_info["admin"] = True
                 break
 
         if "anonymous" in roles_required:
             return
                 break
 
         if "anonymous" in roles_required:
             return
-
+        operation_allowed = False
         for role in roles_allowed:
             if role in roles_required:
         for role in roles_allowed:
             if role in roles_required:
-                return
+                operation_allowed = True
+                # if query_string operations, check if this role allows it
+                if not query_string_operations:
+                    return
+                for query_string_operation in query_string_operations:
+                    if role not in self.operation_to_allowed_roles[query_string_operation]:
+                        break
+                else:
+                    return
 
 
-        raise AuthExceptionUnauthorized("Access denied: lack of permissions.")
+        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")
 
     def get_user_list(self):
         return self.backend.get_user_list()
 
     def _normalize_url(self, url, method):
 
     def get_user_list(self):
         return self.backend.get_user_list()
 
     def _normalize_url(self, url, method):
+        # DEPRECATED !!!
         # Removing query strings
         normalized_url = url if '?' not in url else url[:url.find("?")]
         normalized_url_splitted = normalized_url.split("/")
         # Removing query strings
         normalized_url = url if '?' not in url else url[:url.find("?")]
         normalized_url_splitted = normalized_url.split("/")
@@ -458,16 +507,16 @@ class Authenticator:
 
         return filtered_key, parameters
 
 
         return filtered_key, parameters
 
-    def _internal_get_token_list(self, session):
+    def _internal_get_token_list(self, token_info):
         now = time()
         now = time()
-        token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
+        token_list = self.db.get_list("tokens", {"username": token_info["username"], "expires.gt": now})
         return token_list
 
         return token_list
 
-    def _internal_get_token(self, session, token_id):
+    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)
         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"] != session["username"] and not session["admin"]:
+        if token_value["username"] != token_info["username"] and not token_info["admin"]:
             raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
         return token_value
 
             raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
         return token_value