Feature 8178 VNF Repositories
[osm/NBI.git] / osm_nbi / authconn_internal.py
index 02b5890..b8cfe5b 100644 (file)
@@ -24,16 +24,18 @@ AuthconnInternal implements implements the connector for
 OSM Internal Authentication Backend and leverages the RBAC model
 """
 
-__author__ = "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>"
+__author__ = "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, " \
+             "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
 __date__ = "$06-jun-2019 11:16:08$"
 
-from authconn import Authconn, AuthException   # , AuthconnOperationException
-from osm_common.dbbase import DbException
-from base_topic import BaseTopic
-
 import logging
 import re
-from time import time
+
+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
 from hashlib import sha256
@@ -42,19 +44,18 @@ from random import choice as random_choice
 
 
 class AuthconnInternal(Authconn):
-    def __init__(self, config, db, token_cache):
-        Authconn.__init__(self, config, db, token_cache)
+    token_time_window = 2   # seconds
+    token_delay = 1   # seconds to wait upon second request within time window
 
+    def __init__(self, config, db, role_permissions):
+        Authconn.__init__(self, config, db, role_permissions)
         self.logger = logging.getLogger("nbi.authenticator.internal")
 
-        # Get Configuration
-        # self.xxx = config.get("xxx", "default")
-
         self.db = db
-        self.token_cache = token_cache
+        # self.msg = msg
+        # self.token_cache = token_cache
 
         # To be Confirmed
-        self.auth = None
         self.sess = None
 
     def validate_token(self, token):
@@ -77,19 +78,13 @@ class AuthconnInternal(Authconn):
             if not token:
                 raise AuthException("Needed a token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
 
-            # try to get from cache first
             now = time()
-            token_info = self.token_cache.get(token)
-            if token_info and token_info["expires"] < now:
-                # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del
-                self.token_cache.pop(token, None)
-                token_info = None
 
             # get from database if not in cache
-            if not token_info:
-                token_info = self.db.get_one("tokens", {"_id": token})
-                if token_info["expires"] < now:
-                    raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
+            if not token_info:
+            token_info = self.db.get_one("tokens", {"_id": token})
+            if token_info["expires"] < now:
+                raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
 
             return token_info
 
@@ -99,12 +94,7 @@ class AuthconnInternal(Authconn):
             else:
                 raise
         except AuthException:
-            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"], "admin": True}
-            else:
-                raise
+            raise
         except Exception:
             self.logger.exception("Error during token validation using internal backend")
             raise AuthException("Error during token validation using internal backend",
@@ -117,7 +107,7 @@ class AuthconnInternal(Authconn):
         :param token: token to be revoked
         """
         try:
-            self.token_cache.pop(token, None)
+            self.token_cache.pop(token, None)
             self.db.del_one("tokens", {"_id": token})
             return True
         except DbException as e:
@@ -125,19 +115,20 @@ class AuthconnInternal(Authconn):
                 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
             else:
                 # raise
-                msg = "Error during token revocation using internal backend"
-                self.logger.exception(msg)
-                raise AuthException(msg, http_code=HTTPStatus.UNAUTHORIZED)
+                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 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,
@@ -148,6 +139,9 @@ 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:
@@ -170,6 +164,13 @@ class AuthconnInternal(Authconn):
             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:
+            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))
 
@@ -223,7 +224,6 @@ class AuthconnInternal(Authconn):
                      "roles": roles_list,
                      }
 
-        self.token_cache[token_id] = new_token
         self.db.create("tokens", new_token)
         return deepcopy(new_token)
 
@@ -256,7 +256,9 @@ class AuthconnInternal(Authconn):
         :param role_id: role identifier.
         :raises AuthconnOperationException: if role deletion failed.
         """
-        return self.db.del_one("roles", {"_id": role_id})
+        rc = self.db.del_one("roles", {"_id": role_id})
+        self.db.del_list("tokens", {"roles.id": role_id})
+        return rc
 
     def update_role(self, role_info):
         """
@@ -267,7 +269,7 @@ class AuthconnInternal(Authconn):
         :raises AuthconnOperationException: if user creation failed.
         """
         rid = role_info["_id"]
-        self.db.set_one("roles", {"_id": rid}, role_info)   # CONFIRM
+        self.db.set_one("roles", {"_id": rid}, role_info)
         return {"_id": rid, "name": role_info["name"]}
 
     def create_user(self, user_info):
@@ -324,7 +326,11 @@ class AuthconnInternal(Authconn):
                         pass
                     except ValueError:
                         pass
-        self.db.set_one("users", {BaseTopic.id_field("users", uid): uid}, user_data)   # CONFIRM
+        idf = BaseTopic.id_field("users", uid)
+        self.db.set_one("users", {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})
 
     def delete_user(self, user_id):
         """
@@ -334,45 +340,66 @@ class AuthconnInternal(Authconn):
         :raises AuthconnOperationException: if user deletion failed.
         """
         self.db.del_one("users", {"_id": user_id})
+        self.db.del_list("tokens", {"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"]
+        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("users", filt)
+        project_id_name = {}
+        role_id_name = {}
         for user in users:
-            projects = []
-            projs_with_roles = []
-            prms = user.get("project_role_mappings", [])
-            for prm in prms:
-                if prm["project"] not in projects:
-                    projects.append(prm["project"])
-            for project in projects:
-                roles = []
-                roles_for_proj = []
+            prms = user.get("project_role_mappings")
+            projects = user.get("projects")
+            if prms:
+                projects = []
+                # add project_name and role_name. Generate projects for backward compatibility
                 for prm in prms:
-                    if prm["project"] == project and prm["role"] not in roles:
-                        role = prm["role"]
-                        roles.append(role)
-                        rl = self.db.get_one("roles", {BaseTopic.id_field("roles", role): role})
-                        roles_for_proj.append({"name": rl["name"], "_id": rl["_id"], "id": rl["_id"]})
-                try:
-                    pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
-                    projs_with_roles.append({"name": pr["name"], "_id": pr["_id"], "id": pr["_id"],
-                                             "roles": roles_for_proj})
-                except Exception as e:
-                    self.logger.exception("Error during user listing using internal backend: {}".format(e))
-            user["projects"] = projs_with_roles
-            if "project_role_mappings" in user:
-                del user["project_role_mappings"]
+                    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)
+                        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:
+                        projects.append(prm["project_name"])
+
+                    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_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"})
+                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"]
+                           }
+                    user["project_role_mappings"].append(prm)
+            else:
+                user["projects"] = []
+                user["project_role_mappings"] = []
+
         return users
 
     def get_project_list(self, filter_q={}):
@@ -401,8 +428,10 @@ class AuthconnInternal(Authconn):
         :param project_id: project identifier.
         :raises AuthconnOperationException: if project deletion failed.
         """
-        filter_q = {BaseTopic.id_field("projects", project_id): project_id}
-        r = self.db.del_one("projects", filter_q)
+        idf = BaseTopic.id_field("projects", project_id)
+        r = self.db.del_one("projects", {idf: project_id})
+        idf = "project_id" if idf == "_id" else "project_name"
+        self.db.del_list("tokens", {idf: project_id})
         return r
 
     def update_project(self, project_id, project_info):