Reformat NBI to standardized format
[osm/NBI.git] / osm_nbi / authconn_internal.py
index 672892d..e342150 100644 (file)
@@ -24,16 +24,19 @@ AuthconnInternal implements implements the connector for
 OSM Internal Authentication Backend and leverages the RBAC model
 """
 
-__author__ = "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, " \
-             "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
+__author__ = (
+    "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, "
+    "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
+)
 __date__ = "$06-jun-2019 11:16:08$"
 
-from osm_nbi.authconn import Authconn, AuthException   # , AuthconnOperationException
-from osm_common.dbbase import DbException
-from osm_nbi.base_topic import BaseTopic
-
 import logging
 import re
+
+from osm_nbi.authconn import Authconn, AuthException  # , AuthconnOperationException
+from osm_common.dbbase import DbException
+from osm_nbi.base_topic import BaseTopic
+from osm_nbi.validation import is_valid_uuid
 from time import time, sleep
 from http import HTTPStatus
 from uuid import uuid4
@@ -43,11 +46,16 @@ from random import choice as random_choice
 
 
 class AuthconnInternal(Authconn):
-    token_time_window = 2   # seconds
-    token_delay = 1   # seconds to wait upon second request within time window
+    token_time_window = 2  # seconds
+    token_delay = 1  # seconds to wait upon second request within time window
+
+    users_collection = "users"
+    roles_collection = "roles"
+    projects_collection = "projects"
+    tokens_collection = "tokens"
 
-    def __init__(self, config, db):
-        Authconn.__init__(self, config, db)
+    def __init__(self, config, db, role_permissions):
+        Authconn.__init__(self, config, db, role_permissions)
         self.logger = logging.getLogger("nbi.authenticator.internal")
 
         self.db = db
@@ -75,29 +83,42 @@ class AuthconnInternal(Authconn):
 
         try:
             if not token:
-                raise AuthException("Needed a token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
+                raise AuthException(
+                    "Needed a token or Authorization HTTP header",
+                    http_code=HTTPStatus.UNAUTHORIZED,
+                )
 
             now = time()
 
             # get from database if not in cache
             # if not token_info:
-            token_info = self.db.get_one("tokens", {"_id": token})
+            token_info = self.db.get_one(self.tokens_collection, {"_id": token})
             if token_info["expires"] < now:
-                raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
+                raise AuthException(
+                    "Expired Token or Authorization HTTP header",
+                    http_code=HTTPStatus.UNAUTHORIZED,
+                )
 
             return token_info
 
         except DbException as e:
             if e.http_code == HTTPStatus.NOT_FOUND:
-                raise AuthException("Invalid Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
+                raise AuthException(
+                    "Invalid Token or Authorization HTTP header",
+                    http_code=HTTPStatus.UNAUTHORIZED,
+                )
             else:
                 raise
         except AuthException:
             raise
         except Exception:
-            self.logger.exception("Error during token validation using internal backend")
-            raise AuthException("Error during token validation using internal backend",
-                                http_code=HTTPStatus.UNAUTHORIZED)
+            self.logger.exception(
+                "Error during token validation using internal backend"
+            )
+            raise AuthException(
+                "Error during token validation using internal backend",
+                http_code=HTTPStatus.UNAUTHORIZED,
+            )
 
     def revoke_token(self, token):
         """
@@ -107,26 +128,49 @@ class AuthconnInternal(Authconn):
         """
         try:
             # self.token_cache.pop(token, None)
-            self.db.del_one("tokens", {"_id": token})
+            self.db.del_one(self.tokens_collection, {"_id": token})
             return True
         except DbException as e:
             if e.http_code == HTTPStatus.NOT_FOUND:
-                raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
+                raise AuthException(
+                    "Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND
+                )
             else:
                 # raise
                 exmsg = "Error during token revocation using internal backend"
                 self.logger.exception(exmsg)
                 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
 
-    def authenticate(self, user, password, project=None, token_info=None):
+    def validate_user(self, user, password):
+        """
+        Validate username and password via appropriate backend.
+        :param user: username of the user.
+        :param password: password to be validated.
+        """
+        user_rows = self.db.get_list(
+            self.users_collection, {BaseTopic.id_field("users", user): user}
+        )
+        user_content = None
+        if user_rows:
+            user_content = user_rows[0]
+            salt = user_content["_admin"]["salt"]
+            shadow_password = sha256(
+                password.encode("utf-8") + salt.encode("utf-8")
+            ).hexdigest()
+            if shadow_password != user_content["password"]:
+                user_content = None
+        return user_content
+
+    def authenticate(self, credentials, token_info=None):
         """
         Authenticate a user using username/password or previous token_info plus project; its creates a new token
 
-        :param user: user: name, id or None
-        :param password: password or None
-        :param project: name, id, or None. If None first found project will be used to get an scope token
+        :param credentials: dictionary that contains:
+            username: name, id or None
+            password: password or None
+            project_id: name, id, or None. If None first found project will be used to get an scope token
+            other items are allowed and ignored
         :param token_info: previous token_info to obtain authorization
-        :param remote: remote host information
         :return: the scoped token info or raises an exception. The token is a dictionary with:
             _id:  token string id,
             username: username,
@@ -137,37 +181,55 @@ class AuthconnInternal(Authconn):
 
         now = time()
         user_content = None
+        user = credentials.get("username")
+        password = credentials.get("password")
+        project = credentials.get("project_id")
 
         # Try using username/password
         if user:
-            user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user})
-            if user_rows:
-                user_content = user_rows[0]
-                salt = user_content["_admin"]["salt"]
-                shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest()
-                if shadow_password != user_content["password"]:
-                    user_content = None
+            user_content = self.validate_user(user, password)
             if not user_content:
-                raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
+                raise AuthException(
+                    "Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED
+                )
+            if not user_content.get("_admin", None):
+                raise AuthException(
+                    "No default project for this user.",
+                    http_code=HTTPStatus.UNAUTHORIZED,
+                )
         elif token_info:
-            user_rows = self.db.get_list("users", {"username": token_info["username"]})
+            user_rows = self.db.get_list(
+                self.users_collection, {"username": token_info["username"]}
+            )
             if user_rows:
                 user_content = user_rows[0]
             else:
                 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
         else:
-            raise AuthException("Provide credentials: username/password or Authorization Bearer token",
-                                http_code=HTTPStatus.UNAUTHORIZED)
-
+            raise AuthException(
+                "Provide credentials: username/password or Authorization Bearer token",
+                http_code=HTTPStatus.UNAUTHORIZED,
+            )
         # Delay upon second request within time window
-        if now - user_content["_admin"].get("last_token_time", 0) < self.token_time_window:
+        if (
+            now - user_content["_admin"].get("last_token_time", 0)
+            < self.token_time_window
+        ):
             sleep(self.token_delay)
         # user_content["_admin"]["last_token_time"] = now
         # self.db.replace("users", user_content["_id"], user_content)   # might cause race conditions
-        self.db.set_one("users", {"_id": user_content["_id"]}, {"_admin.last_token_time": now})
-
-        token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
-                           for _ in range(0, 32))
+        self.db.set_one(
+            self.users_collection,
+            {"_id": user_content["_id"]},
+            {"_admin.last_token_time": now},
+        )
+
+        token_id = "".join(
+            random_choice(
+                "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+            )
+            for _ in range(0, 32)
+        )
 
         # projects = user_content.get("projects", [])
         prm_list = user_content.get("project_role_mappings", [])
@@ -175,16 +237,23 @@ class AuthconnInternal(Authconn):
         if not project:
             project = prm_list[0]["project"] if prm_list else None
         if not project:
-            raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED)
+            raise AuthException(
+                "can't find a default project for this user",
+                http_code=HTTPStatus.UNAUTHORIZED,
+            )
 
         projects = [prm["project"] for prm in prm_list]
 
-        proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
+        proj = self.db.get_one(
+            self.projects_collection, {BaseTopic.id_field("projects", project): project}
+        )
         project_name = proj["name"]
         project_id = proj["_id"]
         if project_name not in projects and project_id not in projects:
-            raise AuthException("project {} not allowed for this user".format(project),
-                                http_code=HTTPStatus.UNAUTHORIZED)
+            raise AuthException(
+                "project {} not allowed for this user".format(project),
+                http_code=HTTPStatus.UNAUTHORIZED,
+            )
 
         # TODO remove admin, this vill be used by roles RBAC
         if project_name == "admin":
@@ -197,29 +266,35 @@ class AuthconnInternal(Authconn):
         roles_list = []
         for prm in prm_list:
             if prm["project"] in [project_id, project_name]:
-                role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]})
+                role = self.db.get_one(
+                    self.roles_collection,
+                    {BaseTopic.id_field("roles", prm["role"]): prm["role"]},
+                )
                 rid = role["_id"]
                 if rid not in roles:
                     rnm = role["name"]
                     roles.append(rid)
                     roles_list.append({"name": rnm, "id": rid})
         if not roles_list:
-            rid = self.db.get_one("roles", {"name": "project_admin"})["_id"]
+            rid = self.db.get_one(self.roles_collection, {"name": "project_admin"})[
+                "_id"
+            ]
             roles_list = [{"name": "project_admin", "id": rid}]
 
-        new_token = {"issued_at": now,
-                     "expires": now + 3600,
-                     "_id": token_id,
-                     "id": token_id,
-                     "project_id": proj["_id"],
-                     "project_name": proj["name"],
-                     "username": user_content["username"],
-                     "user_id": user_content["_id"],
-                     "admin": token_admin,
-                     "roles": roles_list,
-                     }
-
-        self.db.create("tokens", new_token)
+        new_token = {
+            "issued_at": now,
+            "expires": now + 3600,
+            "_id": token_id,
+            "id": token_id,
+            "project_id": proj["_id"],
+            "project_name": proj["name"],
+            "username": user_content["username"],
+            "user_id": user_content["_id"],
+            "admin": token_admin,
+            "roles": roles_list,
+        }
+
+        self.db.create(self.tokens_collection, new_token)
         return deepcopy(new_token)
 
     def get_role_list(self, filter_q={}):
@@ -228,7 +303,7 @@ class AuthconnInternal(Authconn):
 
         :return: returns the list of roles.
         """
-        return self.db.get_list("roles", filter_q)
+        return self.db.get_list(self.roles_collection, filter_q)
 
     def create_role(self, role_info):
         """
@@ -241,7 +316,7 @@ class AuthconnInternal(Authconn):
         # TODO: Check that role name does not exist ?
         rid = str(uuid4())
         role_info["_id"] = rid
-        rid = self.db.create("roles", role_info)
+        rid = self.db.create(self.roles_collection, role_info)
         return rid
 
     def delete_role(self, role_id):
@@ -251,8 +326,8 @@ class AuthconnInternal(Authconn):
         :param role_id: role identifier.
         :raises AuthconnOperationException: if role deletion failed.
         """
-        rc = self.db.del_one("roles", {"_id": role_id})
-        self.db.del_list("tokens", {"roles.id": role_id})
+        rc = self.db.del_one(self.roles_collection, {"_id": role_id})
+        self.db.del_list(self.tokens_collection, {"roles.id": role_id})
         return rc
 
     def update_role(self, role_info):
@@ -264,7 +339,7 @@ class AuthconnInternal(Authconn):
         :raises AuthconnOperationException: if user creation failed.
         """
         rid = role_info["_id"]
-        self.db.set_one("roles", {"_id": rid}, role_info)
+        self.db.set_one(self.roles_collection, {"_id": rid}, role_info)
         return {"_id": rid, "name": role_info["name"]}
 
     def create_user(self, user_info):
@@ -278,11 +353,13 @@ class AuthconnInternal(Authconn):
         salt = uuid4().hex
         user_info["_admin"]["salt"] = salt
         if "password" in user_info:
-            user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest()
+            user_info["password"] = sha256(
+                user_info["password"].encode("utf-8") + salt.encode("utf-8")
+            ).hexdigest()
         # "projects" are not stored any more
         if "projects" in user_info:
             del user_info["projects"]
-        self.db.create("users", user_info)
+        self.db.create(self.users_collection, user_info)
         return {"username": user_info["username"], "_id": user_info["_id"]}
 
     def update_user(self, user_info):
@@ -292,7 +369,9 @@ class AuthconnInternal(Authconn):
         :param user_info: user info modifications
         """
         uid = user_info["_id"]
-        user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid})
+        user_data = self.db.get_one(
+            self.users_collection, {BaseTopic.id_field("users", uid): uid}
+        )
         BaseTopic.format_on_edit(user_data, user_info)
         # User Name
         usnm = user_info.get("username")
@@ -300,12 +379,16 @@ class AuthconnInternal(Authconn):
             user_data["username"] = usnm
         # If password is given and is not already encripted
         pswd = user_info.get("password")
-        if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)):   # TODO: Improve check?
+        if pswd and (
+            len(pswd) != 64 or not re.match("[a-fA-F0-9]*", pswd)
+        ):  # TODO: Improve check?
             salt = uuid4().hex
             if "_admin" not in user_data:
                 user_data["_admin"] = {}
             user_data["_admin"]["salt"] = salt
-            user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
+            user_data["password"] = sha256(
+                pswd.encode("utf-8") + salt.encode("utf-8")
+            ).hexdigest()
         # Project-Role Mappings
         # TODO: Check that user_info NEVER includes "project_role_mappings"
         if "project_role_mappings" not in user_data:
@@ -316,16 +399,18 @@ class AuthconnInternal(Authconn):
             for pidf in ["project", "project_name"]:
                 for ridf in ["role", "role_name"]:
                     try:
-                        user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]})
+                        user_data["project_role_mappings"].remove(
+                            {"role": prm[ridf], "project": prm[pidf]}
+                        )
                     except KeyError:
                         pass
                     except ValueError:
                         pass
         idf = BaseTopic.id_field("users", uid)
-        self.db.set_one("users", {idf: uid}, user_data)
+        self.db.set_one(self.users_collection, {idf: uid}, user_data)
         if user_info.get("remove_project_role_mappings"):
             idf = "user_id" if idf == "_id" else idf
-            self.db.del_list("tokens", {idf: uid})
+            self.db.del_list(self.tokens_collection, {idf: uid})
 
     def delete_user(self, user_id):
         """
@@ -334,22 +419,26 @@ class AuthconnInternal(Authconn):
         :param user_id: user identifier.
         :raises AuthconnOperationException: if user deletion failed.
         """
-        self.db.del_one("users", {"_id": user_id})
-        self.db.del_list("tokens", {"user_id": user_id})
+        self.db.del_one(self.users_collection, {"_id": user_id})
+        self.db.del_list(self.tokens_collection, {"user_id": user_id})
         return True
 
     def get_user_list(self, filter_q=None):
         """
         Get user list.
 
-        :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id
+        :param filter_q: dictionary to filter user list by:
+            name (username is also admitted).  If a user id is equal to the filter name, it is also provided
+            other
         :return: returns a list of users.
         """
         filt = filter_q or {}
-        if "name" in filt:
-            filt["username"] = filt["name"]
-            del filt["name"]
-        users = self.db.get_list("users", filt)
+        if "name" in filt:  # backward compatibility
+            filt["username"] = filt.pop("name")
+        if filt.get("username") and is_valid_uuid(filt["username"]):
+            # username cannot be a uuid. If this is the case, change from username to _id
+            filt["_id"] = filt.pop("username")
+        users = self.db.get_list(self.users_collection, filt)
         project_id_name = {}
         role_id_name = {}
         for user in users:
@@ -361,8 +450,11 @@ class AuthconnInternal(Authconn):
                 for prm in prms:
                     project_id = prm["project"]
                     if project_id not in project_id_name:
-                        pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id},
-                                             fail_on_empty=False)
+                        pr = self.db.get_one(
+                            self.projects_collection,
+                            {BaseTopic.id_field("projects", project_id): project_id},
+                            fail_on_empty=False,
+                        )
                         project_id_name[project_id] = pr["name"] if pr else None
                     prm["project_name"] = project_id_name[project_id]
                     if prm["project_name"] not in projects:
@@ -370,22 +462,32 @@ class AuthconnInternal(Authconn):
 
                     role_id = prm["role"]
                     if role_id not in role_id_name:
-                        role = self.db.get_one("roles", {BaseTopic.id_field("roles", role_id): role_id},
-                                               fail_on_empty=False)
+                        role = self.db.get_one(
+                            self.roles_collection,
+                            {BaseTopic.id_field("roles", role_id): role_id},
+                            fail_on_empty=False,
+                        )
                         role_id_name[role_id] = role["name"] if role else None
                     prm["role_name"] = role_id_name[role_id]
                 user["projects"] = projects  # for backward compatibility
             elif projects:
                 # user created with an old version. Create a project_role mapping with role project_admin
                 user["project_role_mappings"] = []
-                role = self.db.get_one("roles", {BaseTopic.id_field("roles", "project_admin"): "project_admin"})
+                role = self.db.get_one(
+                    self.roles_collection,
+                    {BaseTopic.id_field("roles", "project_admin"): "project_admin"},
+                )
                 for p_id_name in projects:
-                    pr = self.db.get_one("projects", {BaseTopic.id_field("projects", p_id_name): p_id_name})
-                    prm = {"project": pr["_id"],
-                           "project_name": pr["name"],
-                           "role_name": "project_admin",
-                           "role": role["_id"]
-                           }
+                    pr = self.db.get_one(
+                        self.projects_collection,
+                        {BaseTopic.id_field("projects", p_id_name): p_id_name},
+                    )
+                    prm = {
+                        "project": pr["_id"],
+                        "project_name": pr["name"],
+                        "role_name": "project_admin",
+                        "role": role["_id"],
+                    }
                     user["project_role_mappings"].append(prm)
             else:
                 user["projects"] = []
@@ -399,7 +501,7 @@ class AuthconnInternal(Authconn):
 
         :return: returns the list of projects.
         """
-        return self.db.get_list("projects", filter_q)
+        return self.db.get_list(self.projects_collection, filter_q)
 
     def create_project(self, project_info):
         """
@@ -409,7 +511,7 @@ class AuthconnInternal(Authconn):
         :return: the internal id of the created project
         :raises AuthconnOperationException: if project creation failed.
         """
-        pid = self.db.create("projects", project_info)
+        pid = self.db.create(self.projects_collection, project_info)
         return pid
 
     def delete_project(self, project_id):
@@ -420,9 +522,9 @@ class AuthconnInternal(Authconn):
         :raises AuthconnOperationException: if project deletion failed.
         """
         idf = BaseTopic.id_field("projects", project_id)
-        r = self.db.del_one("projects", {idf: project_id})
+        r = self.db.del_one(self.projects_collection, {idf: project_id})
         idf = "project_id" if idf == "_id" else "project_name"
-        self.db.del_list("tokens", {idf: project_id})
+        self.db.del_list(self.tokens_collection, {idf: project_id})
         return r
 
     def update_project(self, project_id, project_info):
@@ -434,4 +536,8 @@ class AuthconnInternal(Authconn):
         :return: None
         :raises AuthconnOperationException: if project update failed.
         """
-        self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info)
+        self.db.set_one(
+            self.projects_collection,
+            {BaseTopic.id_field("projects", project_id): project_id},
+            project_info,
+        )