RBAC permission storage in MongoDB 76/6876/43
authorEduardo Sousa <esousa@whitestack.com>
Wed, 14 Nov 2018 06:36:35 +0000 (06:36 +0000)
committerEduardo Sousa <esousa@whitestack.com>
Tue, 12 Feb 2019 18:10:30 +0000 (18:10 +0000)
Not yet fully tested. Partial implementation of topics
discussed in last TECH call.

Change-Id: If64936b898f2b0c6daaa9933a3216e4e1368578a
Signed-off-by: Eduardo Sousa <esousa@whitestack.com>
Dockerfile.local
MANIFEST.in
osm_nbi/auth.py
osm_nbi/authconn_keystone.py
osm_nbi/nbi.cfg
osm_nbi/resources_to_operations.yml [new file with mode: 0644]
osm_nbi/roles_to_operations.yml [new file with mode: 0644]
setup.py
stdeb.cfg

index 33fbf45..def89c4 100644 (file)
@@ -21,7 +21,7 @@ WORKDIR /app/NBI/osm_nbi
 RUN apt-get update && apt-get install -y git python3 python3-jsonschema \
     python3-pymongo python3-yaml python3-pip python3-keystoneclient \
     && pip3 install pip==9.0.3 \
 RUN apt-get update && apt-get install -y git python3 python3-jsonschema \
     python3-pymongo python3-yaml python3-pip python3-keystoneclient \
     && pip3 install pip==9.0.3 \
-    && pip3 install aiokafka cherrypy==18.0.0 keystoneauth1 \
+    && pip3 install aiokafka cherrypy==18.0.0 keystoneauth1 requests \
     && mkdir -p /app/storage/kafka && mkdir -p /app/log 
 
 # OSM_COMMON
     && mkdir -p /app/storage/kafka && mkdir -p /app/log 
 
 # OSM_COMMON
@@ -87,6 +87,9 @@ ENV OSMNBI_AUTHENTICATION_BACKEND               internal
 #ENV OSMNBI_AUTHENTICATION_SERVICE_USERNAME      nbi
 #ENV OSMNBI_AUTHENTICATION_SERVICE_PASSWORD      nbi
 #ENV OSMNBI_AUTHENTICATION_SERVICE_PROJECT       service
 #ENV OSMNBI_AUTHENTICATION_SERVICE_USERNAME      nbi
 #ENV OSMNBI_AUTHENTICATION_SERVICE_PASSWORD      nbi
 #ENV OSMNBI_AUTHENTICATION_SERVICE_PROJECT       service
+# RBAC
+ENV OSMNBI_RBAC_RESOURCES_TO_OPERATIONS         /app/NBI/osm_nbi/resources_to_operations.yml
+ENV OSMNBI_RBAC_ROLES_TO_OPERATIONS             /app/NBI/osm_nbi/roles_to_operations.yml
 
 # Copy the current directory contents into the container at /app
 ADD . /app/NBI
 
 # Copy the current directory contents into the container at /app
 ADD . /app/NBI
index 0553d06..2c2a19e 100644 (file)
@@ -17,7 +17,7 @@
 ##
 
 include README.rst
 ##
 
 include README.rst
-recursive-include osm_nbi *.py *.sh  *.cfg
+recursive-include osm_nbi *.py *.sh *.cfg *.yml
 recursive-include osm_nbi/html_public *
 recursive-include osm_nbi/http *
 recursive-include devops-stages *
 recursive-include osm_nbi/html_public *
 recursive-include osm_nbi/http *
 recursive-include devops-stages *
index 4bc4628..7d586ad 100644 (file)
@@ -19,12 +19,14 @@ 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$"
 
 import cherrypy
 import logging
 
 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
 __date__ = "$27-jul-2018 23:59:59$"
 
 import cherrypy
 import logging
+import yaml
 from base64 import standard_b64decode
 from copy import deepcopy
 from functools import reduce
 from base64 import standard_b64decode
 from copy import deepcopy
 from functools import reduce
@@ -32,6 +34,7 @@ from hashlib import sha256
 from http import HTTPStatus
 from random import choice as random_choice
 from time import time
 from http import HTTPStatus
 from random import choice as random_choice
 from time import time
+from uuid import uuid4
 
 from authconn import AuthException
 from authconn_keystone import AuthconnKeystone
 
 from authconn import AuthException
 from authconn_keystone import AuthconnKeystone
@@ -48,7 +51,7 @@ class Authenticator:
     RBAC model to manage permissions on operations.
     """
 
     RBAC model to manage permissions on operations.
     """
 
-    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):
         """
@@ -60,7 +63,10 @@ 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.resources_to_operations_mapping = {}
+        self.operation_to_allowed_roles = {}
         self.logger = logging.getLogger("nbi.authenticator")
 
     def start(self, config):
         self.logger = logging.getLogger("nbi.authenticator")
 
     def start(self, config):
@@ -92,6 +98,28 @@ class Authenticator:
                 else:
                     raise AuthException("Unknown authentication backend: {}"
                                         .format(config["authentication"]["backend"]))
                 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:
+                    for config_file in (__file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml",
+                                        "./resources_to_operations.yml"):
+                        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"]
+                else:
+                    for config_file in (__file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
+                                        "./roles_to_operations.yml"):
+                        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")
         except Exception as e:
             raise AuthException(str(e))
 
         except Exception as e:
             raise AuthException(str(e))
 
@@ -102,15 +130,125 @@ 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 init_db(self, target_version='1.1'):
+    def init_db(self, target_version='1.0'):
         """
         """
-        Check if the database has been initialized, with at least one user. If not, create an adthe required tables
+        Check if the database has been initialized, with at least one user. If not, create the required tables
         and insert the predefined mappings between roles and permissions.
 
         :param target_version: schema version that should be present in the database.
         :return: None if OK, exception if error or version is different.
         """
         and insert the predefined mappings between roles and permissions.
 
         :param target_version: schema version that should be present in the database.
         :return: None if OK, exception if error or version is different.
         """
-        pass
+        # 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 = []
+        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
+
+        records = self.db.get_list("roles_operations")
+
+        # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing.
+        if len(records) == 0:
+            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"])
+                else:
+                    self.logger.warning("Duplicated role with name: {0}. Role definition is ignored."
+                                        .format(role_with_operations["role"]))
+                    continue
+
+                operations = {}
+                root = None
+
+                if not role_with_operations["operations"]:
+                    continue
+
+                for operation, is_allowed in role_with_operations["operations"].items():
+                    if not isinstance(is_allowed, bool):
+                        continue
+
+                    if operation == ".":
+                        root = is_allowed
+                        continue
+
+                    if len(operation) != 1 and operation[-1] == ".":
+                        self.logger.warning("Invalid operation {0} terminated in '.'. "
+                                            "Operation will be discarded"
+                                            .format(operation))
+                        continue
+
+                    operation_key = operation.replace(".", ":")
+                    if operation_key not in operations.keys():
+                        operations[operation_key] = is_allowed
+                    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))
+
+                if not root:
+                    root = False
+                    self.logger.info("Root for role {0} not defined. Default value 'False' applied."
+                                     .format(role_with_operations["role"]))
+
+                now = time()
+                operation_to_roles_item = {
+                    "_id": str(uuid4()),
+                    "_admin": {
+                        "created": now,
+                        "modified": now,
+                    },
+                    "role": role_with_operations["role"],
+                    "root": root
+                }
+
+                for operation, value in operations.items():
+                    operation_to_roles_item[operation] = value
+
+                self.db.create("roles_operations", operation_to_roles_item)
+
+        permissions = {oper: [] for oper in operations}
+        records = self.db.get_list("roles_operations")
+
+        ignore_fields = ["_id", "_admin", "role", "root"]
+        roles = []
+        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]
+            operations_joined.sort(key=lambda x: x[0].count(":"))
+
+            for oper in operations_joined:
+                match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
+
+                for m in match:
+                    record_permissions[m] = oper[1]
+
+            allowed_operations = [k for k, v in record_permissions.items() if v is True]
+
+            for allowed_op in allowed_operations:
+                permissions[allowed_op].append(record["role"])
+
+        for oper, role_list in permissions.items():
+            self.operation_to_allowed_roles[oper] = role_list
+
+        if self.config["authentication"]["backend"] != "internal":
+            for role in roles:
+                if role == "anonymous":
+                    continue
+                self.backend.create_role(role)
+
+            self.backend.assign_role_to_user("admin", "admin", "system_admin")
 
     def authorize(self):
         token = None
 
     def authorize(self):
         token = None
@@ -145,10 +283,15 @@ class Authenticator:
             if self.config["authentication"]["backend"] == "internal":
                 return self._internal_authorize(token)
             else:
             if self.config["authentication"]["backend"] == "internal":
                 return self._internal_authorize(token)
             else:
+                if not token:
+                    raise AuthException("Needed a token or Authorization http header",
+                                        http_code=HTTPStatus.UNAUTHORIZED)
                 try:
                     self.backend.validate_token(token)
                 try:
                     self.backend.validate_token(token)
+                    self.check_permissions(self.tokens_cache[token], cherrypy.request.path_info,
+                                           cherrypy.request.method)
                     # TODO: check if this can be avoided. Backend may provide enough information
                     # TODO: check if this can be avoided. Backend may provide enough information
-                    return self.tokens_cache[token]
+                    return deepcopy(self.tokens_cache[token])
                 except AuthException:
                     self.del_token(token)
                     raise
                 except AuthException:
                     self.del_token(token)
                     raise
@@ -180,6 +323,9 @@ class Authenticator:
             else:
                 project_id = projects[0]
 
             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:
             if project_id == "admin":
                 session_admin = True
             else:
@@ -239,6 +385,77 @@ 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
+
+        operation = self.resources_to_operations_mapping[key]
+        roles_required = self.operation_to_allowed_roles[operation]
+        roles_allowed = self.backend.get_role_list(session["id"])
+
+        if "anonymous" in roles_required:
+            return
+
+        for role in roles_allowed:
+            if role in roles_required:
+                return
+
+        raise AuthException("Access denied: lack of permissions.")
+
+    def _normalize_url(self, url, method):
+        # Removing query strings
+        normalized_url = url if '?' not in url else url[:url.find("?")]
+        normalized_url_splitted = normalized_url.split("/")
+        parameters = {}
+
+        filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
+                         if method in key.split()[0]]
+
+        for idx, path_part in enumerate(normalized_url_splitted):
+            tmp_keys = []
+            for tmp_key in filtered_keys:
+                splitted = tmp_key.split()[1].split("/")
+                if "<" in splitted[idx] and ">" in splitted[idx]:
+                    if splitted[idx] == "<artifactPath>":
+                        tmp_keys.append(tmp_key)
+                        continue
+                    elif idx == len(normalized_url_splitted) - 1 and \
+                            len(normalized_url_splitted) != len(splitted):
+                        continue
+                    else:
+                        tmp_keys.append(tmp_key)
+                elif splitted[idx] == path_part:
+                    if idx == len(normalized_url_splitted) - 1 and \
+                            len(normalized_url_splitted) != len(splitted):
+                        continue
+                    else:
+                        tmp_keys.append(tmp_key)
+            filtered_keys = tmp_keys
+            if len(filtered_keys) == 1 and \
+                    filtered_keys[0].split("/")[-1] == "<artifactPath>":
+                break
+
+        if len(filtered_keys) == 0:
+            raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
+        elif len(filtered_keys) > 1:
+            raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
+
+        filtered_key = filtered_keys[0]
+
+        for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
+            if "<" in path_part and ">" in path_part:
+                if path_part == "<artifactPath>":
+                    parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
+                else:
+                    parameters[path_part[1:-1]] = normalized_url_splitted[idx]
+
+        return filtered_key, parameters
+
     def _internal_authorize(self, token_id):
         try:
             if not token_id:
     def _internal_authorize(self, token_id):
         try:
             if not token_id:
@@ -354,4 +571,4 @@ class Authenticator:
         if not self.next_db_prune_time or self.next_db_prune_time >= now:
             self.db.del_list("tokens", {"expires.lt": now})
             self.next_db_prune_time = self.periodin_db_pruning + now
         if not self.next_db_prune_time or self.next_db_prune_time >= now:
             self.db.del_list("tokens", {"expires.lt": now})
             self.next_db_prune_time = self.periodin_db_pruning + now
-            self.tokens_cache.clear()   # force to reload tokens from database
+            self.tokens_cache.clear()  # force to reload tokens from database
index 6e33ed6..059eae4 100644 (file)
@@ -5,6 +5,7 @@ AuthconnKeystone implements implements the connector for
 Openstack Keystone and leverages the RBAC model, to bring
 it for OSM.
 """
 Openstack Keystone and leverages the RBAC model, to bring
 it for OSM.
 """
+import time
 
 __author__ = "Eduardo Sousa <esousa@whitestack.com>"
 __date__ = "$27-jul-2018 23:59:59$"
 
 __author__ = "Eduardo Sousa <esousa@whitestack.com>"
 __date__ = "$27-jul-2018 23:59:59$"
@@ -12,9 +13,11 @@ __date__ = "$27-jul-2018 23:59:59$"
 from authconn import Authconn, AuthException, AuthconnOperationException
 
 import logging
 from authconn import Authconn, AuthException, AuthconnOperationException
 
 import logging
+import requests
 from keystoneauth1 import session
 from keystoneauth1.identity import v3
 from keystoneauth1.exceptions.base import ClientException
 from keystoneauth1 import session
 from keystoneauth1.identity import v3
 from keystoneauth1.exceptions.base import ClientException
+from keystoneauth1.exceptions.http import Conflict
 from keystoneclient.v3 import client
 from http import HTTPStatus
 
 from keystoneclient.v3 import client
 from http import HTTPStatus
 
@@ -32,6 +35,19 @@ class AuthconnKeystone(Authconn):
         self.admin_password = config.get("service_password", "nbi")
         self.project_domain_name = config.get("project_domain_name", "default")
 
         self.admin_password = config.get("service_password", "nbi")
         self.project_domain_name = config.get("project_domain_name", "default")
 
+        # Waiting for Keystone to be up
+        available = None
+        counter = 300
+        while available is None:
+            time.sleep(1)
+            try:
+                result = requests.get(self.auth_url)
+                available = True if result.status_code == 200 else None
+            except Exception:
+                counter -= 1
+                if counter == 0:
+                    raise AuthException("Keystone not available after 300s timeout")
+
         self.auth = v3.Password(user_domain_name=self.user_domain_name,
                                 username=self.admin_username,
                                 password=self.admin_password,
         self.auth = v3.Password(user_domain_name=self.user_domain_name,
                                 username=self.admin_username,
                                 password=self.admin_password,
@@ -80,14 +96,14 @@ class AuthconnKeystone(Authconn):
             projects = self.keystone.projects.list(user=token_info["user"]["id"])
             project_names = [project.name for project in projects]
 
             projects = self.keystone.projects.list(user=token_info["user"]["id"])
             project_names = [project.name for project in projects]
 
-            token = self.keystone.get_raw_token_from_identity_service(
+            new_token = self.keystone.get_raw_token_from_identity_service(
                 auth_url=self.auth_url,
                 token=token,
                 project_name=project,
                 user_domain_name=self.user_domain_name,
                 project_domain_name=self.project_domain_name)
 
                 auth_url=self.auth_url,
                 token=token,
                 project_name=project,
                 user_domain_name=self.user_domain_name,
                 project_domain_name=self.project_domain_name)
 
-            return token["auth_token"], project_names
+            return new_token["auth_token"], project_names
         except ClientException:
             self.logger.exception("Error during user authentication using keystone. Method: bearer")
             raise AuthException("Error during user authentication using Keystone", http_code=HTTPStatus.UNAUTHORIZED)
         except ClientException:
             self.logger.exception("Error during user authentication using keystone. Method: bearer")
             raise AuthException("Error during user authentication using Keystone", http_code=HTTPStatus.UNAUTHORIZED)
@@ -118,6 +134,7 @@ class AuthconnKeystone(Authconn):
         :param token: token to be revoked
         """
         try:
         :param token: token to be revoked
         """
         try:
+            self.logger.info("Revoking token: " + token)
             self.keystone.tokens.revoke_token(token=token)
 
             return True
             self.keystone.tokens.revoke_token(token=token)
 
             return True
@@ -152,7 +169,9 @@ class AuthconnKeystone(Authconn):
         """
         try:
             token_info = self.keystone.tokens.validate(token=token)
         """
         try:
             token_info = self.keystone.tokens.validate(token=token)
-            roles = self.keystone.roles.list(user=token_info["user"]["id"], project=token_info["project"]["id"])
+            roles_info = self.keystone.roles.list(user=token_info["user"]["id"], project=token_info["project"]["id"])
+
+            roles = [role.name for role in roles_info]
 
             return roles
         except ClientException:
 
             return roles
         except ClientException:
@@ -168,10 +187,7 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if user creation failed.
         """
         try:
         :raises AuthconnOperationException: if user creation failed.
         """
         try:
-            result = self.keystone.users.create(user, password=password, domain=self.user_domain_name)
-
-            if not result:
-                raise ClientException()
+            self.keystone.users.create(user, password=password, domain=self.user_domain_name)
         except ClientException:
             self.logger.exception("Error during user creation using keystone")
             raise AuthconnOperationException("Error during user creation using Keystone")
         except ClientException:
             self.logger.exception("Error during user creation using keystone")
             raise AuthconnOperationException("Error during user creation using Keystone")
@@ -185,10 +201,8 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if user password change failed.
         """
         try:
         :raises AuthconnOperationException: if user password change failed.
         """
         try:
-            result = self.keystone.users.update(user, password=new_password)
-
-            if not result:
-                raise ClientException()
+            user_obj = list(filter(lambda x: x.name == user, self.keystone.users.list()))[0]
+            self.keystone.users.update(user_obj, password=new_password)
         except ClientException:
             self.logger.exception("Error during user password update using keystone")
             raise AuthconnOperationException("Error during user password update using Keystone")
         except ClientException:
             self.logger.exception("Error during user password update using keystone")
             raise AuthconnOperationException("Error during user password update using Keystone")
@@ -201,10 +215,8 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if user deletion failed.
         """
         try:
         :raises AuthconnOperationException: if user deletion failed.
         """
         try:
-            result = self.keystone.users.delete(user)
-
-            if not result:
-                raise ClientException()
+            user_obj = list(filter(lambda x: x.name == user, self.keystone.users.list()))[0]
+            self.keystone.users.delete(user_obj)
         except ClientException:
             self.logger.exception("Error during user deletion using keystone")
             raise AuthconnOperationException("Error during user deletion using Keystone")
         except ClientException:
             self.logger.exception("Error during user deletion using keystone")
             raise AuthconnOperationException("Error during user deletion using Keystone")
@@ -217,10 +229,9 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if role creation failed.
         """
         try:
         :raises AuthconnOperationException: if role creation failed.
         """
         try:
-            result = self.keystone.roles.create(role, domain=self.user_domain_name)
-
-            if not result:
-                raise ClientException()
+            self.keystone.roles.create(role)
+        except Conflict as ex:
+            self.logger.info("Duplicate entry: %s", str(ex))
         except ClientException:
             self.logger.exception("Error during role creation using keystone")
             raise AuthconnOperationException("Error during role creation using Keystone")
         except ClientException:
             self.logger.exception("Error during role creation using keystone")
             raise AuthconnOperationException("Error during role creation using Keystone")
@@ -233,10 +244,8 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if role deletion failed.
         """
         try:
         :raises AuthconnOperationException: if role deletion failed.
         """
         try:
-            result = self.keystone.roles.delete(role)
-
-            if not result:
-                raise ClientException()
+            role_obj = list(filter(lambda x: x.name == role, self.keystone.roles.list()))[0]
+            self.keystone.roles.delete(role_obj)
         except ClientException:
             self.logger.exception("Error during role deletion using keystone")
             raise AuthconnOperationException("Error during role deletion using Keystone")
         except ClientException:
             self.logger.exception("Error during role deletion using keystone")
             raise AuthconnOperationException("Error during role deletion using Keystone")
@@ -249,10 +258,7 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if project creation failed.
         """
         try:
         :raises AuthconnOperationException: if project creation failed.
         """
         try:
-            result = self.keystone.project.create(project, self.project_domain_name)
-
-            if not result:
-                raise ClientException()
+            self.keystone.project.create(project, self.project_domain_name)
         except ClientException:
             self.logger.exception("Error during project creation using keystone")
             raise AuthconnOperationException("Error during project creation using Keystone")
         except ClientException:
             self.logger.exception("Error during project creation using keystone")
             raise AuthconnOperationException("Error during project creation using Keystone")
@@ -265,10 +271,8 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if project deletion failed.
         """
         try:
         :raises AuthconnOperationException: if project deletion failed.
         """
         try:
-            result = self.keystone.project.delete(project)
-
-            if not result:
-                raise ClientException()
+            project_obj = list(filter(lambda x: x.name == project, self.keystone.projects.list()))[0]
+            self.keystone.project.delete(project_obj)
         except ClientException:
             self.logger.exception("Error during project deletion using keystone")
             raise AuthconnOperationException("Error during project deletion using Keystone")
         except ClientException:
             self.logger.exception("Error during project deletion using keystone")
             raise AuthconnOperationException("Error during project deletion using Keystone")
@@ -283,10 +287,11 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if role assignment failed.
         """
         try:
         :raises AuthconnOperationException: if role assignment failed.
         """
         try:
-            result = self.keystone.roles.grant(role, user=user, project=project)
+            user_obj = list(filter(lambda x: x.name == user, self.keystone.users.list()))[0]
+            project_obj = list(filter(lambda x: x.name == project, self.keystone.projects.list()))[0]
+            role_obj = list(filter(lambda x: x.name == role, self.keystone.roles.list()))[0]
 
 
-            if not result:
-                raise ClientException()
+            self.keystone.roles.grant(role_obj, user=user_obj, project=project_obj)
         except ClientException:
             self.logger.exception("Error during user role assignment using keystone")
             raise AuthconnOperationException("Error during user role assignment using Keystone")
         except ClientException:
             self.logger.exception("Error during user role assignment using keystone")
             raise AuthconnOperationException("Error during user role assignment using Keystone")
@@ -301,10 +306,11 @@ class AuthconnKeystone(Authconn):
         :raises AuthconnOperationException: if role assignment revocation failed.
         """
         try:
         :raises AuthconnOperationException: if role assignment revocation failed.
         """
         try:
-            result = self.keystone.roles.revoke(role, user=user, project=project)
+            user_obj = list(filter(lambda x: x.name == user, self.keystone.users.list()))[0]
+            project_obj = list(filter(lambda x: x.name == project, self.keystone.projects.list()))[0]
+            role_obj = list(filter(lambda x: x.name == role, self.keystone.roles.list()))[0]
 
 
-            if not result:
-                raise ClientException()
+            self.keystone.roles.revoke(role_obj, user=user_obj, project=project_obj)
         except ClientException:
             self.logger.exception("Error during user role revocation using keystone")
             raise AuthconnOperationException("Error during user role revocation using Keystone")
         except ClientException:
             self.logger.exception("Error during user role revocation using keystone")
             raise AuthconnOperationException("Error during user role revocation using Keystone")
index d01aca9..81237cd 100644 (file)
@@ -90,3 +90,7 @@ group_id: "nbi-server"
 
 [authentication]
 backend: "internal"
 
 [authentication]
 backend: "internal"
+
+[rbac]
+#resources_to_operations: "resources_to_operations.yml"
+#roles_to_operations: "roles_to_operations.yml"
diff --git a/osm_nbi/resources_to_operations.yml b/osm_nbi/resources_to_operations.yml
new file mode 100644 (file)
index 0000000..ee8d386
--- /dev/null
@@ -0,0 +1,356 @@
+# Copyright 2018 Whitestack, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+# For those usages not covered by the Apache License, Version 2.0 please
+# contact: esousa@whitestack.com or glavado@whitestack.com
+##
+
+---
+resources_to_operations:
+
+##
+# The resources are defined using the following pattern:
+#
+#     "<METHOD> <PATH>": <OPERATION>
+#
+# Where <METHOD> refers to the HTTP Method being used, i.e. GET, POST, DELETE.
+# The <PATH> refers to the path after http(s)://<ip-or-domain>:<port>/osm
+# In the <PATH> variable parts should be replaced, using the <variable_name>
+# notation. Inside the RBAC module these variables can be extracted for further
+# analysis.
+#
+# NOTE: the <artifactPath> tag is reserved for artifact path (or file locations).
+#       meaning that it retains a special meaning.
+#
+# Operations are organized in a hierarchical tree, where <OPERATION> defines
+# the full path until the leaf (all the nodes in between need to be specified).
+#
+# NOTE: The end user should not modify this file.
+##
+
+################################################################################
+##################################### NSDs #####################################
+################################################################################
+
+  "GET /nsd/v1/ns_descriptors_content": nsds.get
+  "GET /nsd/v1/ns_descriptors": nsds.get
+
+  "POST /nsd/v1/ns_descriptors_content": nsds.content.post
+
+  "GET /nsd/v1/ns_descriptors_content/<nsdInfoId>": nsds.id.get
+  "GET /nsd/v1/ns_descriptors/<nsdInfoId>": nsds.id.get
+
+  "PUT /nsd/v1/ns_descriptors_content/<nsdInfoId>": nsds.id.put
+
+  "DELETE /nsd/v1/ns_descriptors_content/<nsdInfoId>": nsds.id.delete
+  "DELETE /nsd/v1/ns_descriptors/<nsdInfoId>": nsds.id.delete
+
+  "POST /nsd/v1/ns_descriptors": nsds.post
+
+  "PATCH /nsd/v1/ns_descriptors/<nsdInfoId>": nsds.id.patch
+
+  "GET /nsd/v1/ns_descriptors/<nsdInfoId>/nsd_content": nsds.id.content.get
+
+  "PUT /nsd/v1/ns_descriptors/<nsdInfoId>/nsd_content": nsds.id.content.put
+
+  "GET /nsd/v1/ns_descriptors/<nsdInfoId>/nsd": nsds.id.nsd.get
+
+  "GET /nsd/v1/ns_descriptors/<nsdInfoId>/artifacts": nsds.id.nsd_artifact.get
+  "GET /nsd/v1/ns_descriptors/<nsdInfoId>/artifacts/<artifactPath>": nsds.id.nsd_artifact.get
+
+################################################################################
+##################################### VNFDs ####################################
+################################################################################
+
+  "GET /vnfpkgm/v1/vnf_packages_content": vnfds.get
+  "GET /vnfpkgm/v1/vnf_packages": vnfds.get
+
+  "POST /vnfpkgm/v1/vnf_packages_content": vnfds.content.post
+
+  "GET /vnfpkgm/v1/vnf_packages_content/<vnfPkgId>": vnfds.id.get
+  "GET /vnfpkgm/v1/vnf_packages/<vnfPkgId>": vnfds.id.get
+
+  "PUT /vnfpkgm/v1/vnf_packages_content/<vnfPkgId>": vnfds.id.put
+
+  "DELETE /vnfpkgm/v1/vnf_packages_content/<vnfPkgId>": vnfds.id.delete
+  "DELETE /vnfpkgm/v1/vnf_packages/<vnfPkgId>": vnfds.id.delete
+
+  "POST /vnfpkgm/v1/vnf_packages": vnfds.post
+
+  "PATCH /vnfpkgm/v1/vnf_packages/<vnfPkgId>": vnfds.id.patch
+
+  "GET /vnfpkgm/v1/vnf_packages/<vnfPkgId>/package_content": vnfds.id.content.get
+
+  "PUT /vnfpkgm/v1/vnf_packages/<vnfPkgId>/package_content": vnfds.id.content.put
+
+  "POST /vnfpkgm/v1/vnf_packages/<vnfPkgId>/package_content/upload_from_uri": vnfds.id.upload.post
+
+  "GET /vnfpkgm/v1/vnf_packages/<vnfPkgId>/vnfd": vnfds.id.vnfd.get
+
+  "GET /vnfpkgm/v1/vnf_packages/<vnfPkgId>/artifacts": vnfds.id.vnfd_artifact.get
+  "GET /vnfpkgm/v1/vnf_packages/<vnfPkgId>/artifacts/<artifactPath>": vnfds.id.vnfd_artifact.get
+
+################################################################################
+################################## NS Instances ################################
+################################################################################
+
+  "GET /nslcm/v1/ns_instances_content": ns_instances.get
+  "GET /nslcm/v1/ns_instances": ns_instances.get
+
+  "POST /nslcm/v1/ns_instances_content": ns_instances.content.post
+
+  "GET /nslcm/v1/ns_instances_content/<nsInstanceId>": ns_instances.id.get
+  "GET /nslcm/v1/ns_instances/<nsInstanceId>": ns_instances.id.get
+
+  "DELETE /nslcm/v1/ns_instances_content/<nsInstanceId>": ns_instances.id.delete
+  "DELETE /nslcm/v1/ns_instances/<nsInstanceId>": ns_instances.id.delete
+
+  "POST /nslcm/v1/ns_instances": ns_instances.post
+
+  "POST /nslcm/v1/ns_instances/<nsInstanceId>/instantiate": ns_instances.id.instantiate.post
+
+  "POST /nslcm/v1/ns_instances/<nsInstanceId>/terminate": ns_instances.id.terminate.post
+
+  "POST /nslcm/v1/ns_instances/<nsInstanceId>/action": ns_instances.id.action.post
+
+  "POST /nslcm/v1/ns_instances/<nsInstanceId>/scale": ns_instances.id.scale.post
+
+  "GET /nslcm/v1/ns_instances/<nsInstanceId>/ns_lcm_op_occs": ns_instances.id.opps.get
+
+  "GET /nslcm/v1/ns_instances/<nsInstanceId>/ns_lcm_op_occs/<nsLcmOpOccId>": ns_instances.id.opps.id.get
+
+################################################################################
+################################# VNF Instances ################################
+################################################################################
+
+  "GET /nslcm/v1/vnfrs": vnf_instances.get
+  "GET /nslcm/v1/vnf_instances": vnf_instances.get
+
+  "GET /nslcm/v1/vnfrs/<vnfInstanceId>": vnf_instances.id.get
+  "GET /nslcm/v1/vnf_instances/<vnfInstanceId>": vnf_instances.id.get
+
+################################################################################
+#################################### Tokens ####################################
+################################################################################
+
+  "GET /admin/v1/tokens": tokens.get
+
+  "POST /admin/v1/tokens": tokens.post
+
+  "DELETE /admin/v1/tokens": tokens.delete
+
+  "GET /admin/v1/tokens/<id>": tokens.id.get
+
+  "DELETE /admin/v1/tokens/<id>": tokens.id.delete
+
+################################################################################
+##################################### Users ####################################
+################################################################################
+
+  "GET /admin/v1/users": users.get
+
+  "POST /admin/v1/users": users.post
+
+  "GET /admin/v1/users/<id>": users.id.get
+
+  "POST /admin/v1/users/<id>": users.id.post
+
+  "PUT /admin/v1/users/<id>": users.id.put
+
+  "DELETE /admin/v1/users/<id>": users.id.delete
+
+  "PATCH /admin/v1/users/<id>": users.id.patch
+
+################################################################################
+#################################### Projects ##################################
+################################################################################
+
+  "GET /admin/v1/projects": projects.get
+
+  "POST /admin/v1/projects": projects.post
+
+  "GET /admin/v1/projects/<id>": projects.id.get
+
+  "DELETE /admin/v1/projects/<id>": projects.id.delete
+
+################################################################################
+##################################### VIMs #####################################
+################################################################################
+
+  "GET /admin/v1/vims": vims.get
+
+  "POST /admin/v1/vims": vims.post
+
+  "GET /admin/v1/vims/<id>": vims.id.get
+
+  "PUT /admin/v1/vims/<id>": vims.id.put
+
+  "DELETE /admin/v1/vims/<id>": vims.id.delete
+
+  "PATCH /admin/v1/vims/<id>": vims.id.patch
+
+################################################################################
+################################## VIM Accounts ################################
+################################################################################
+
+  "GET /admin/v1/vim_accounts": vim_accounts.get
+
+  "POST /admin/v1/vim_accounts": vim_accounts.post
+
+  "GET /admin/v1/vim_accounts/<id>": vim_accounts.id.get
+
+  "PUT /admin/v1/vim_accounts/<id>": vim_accounts.id.put
+
+  "DELETE /admin/v1/vim_accounts/<id>": vim_accounts.id.delete
+
+  "PATCH /admin/v1/vim_accounts/<id>": vim_accounts.id.patch
+
+################################################################################
+################################# SDN Controllers ##############################
+################################################################################
+
+  "GET /admin/v1/sdns": sdn_controllers.get
+
+  "POST /admin/v1/sdns": sdn_controllers.post
+
+  "GET /admin/v1/sdns/<id>": sdn_controllers.id.get
+
+  "PUT /admin/v1/sdns/<id>": sdn_controllers.id.put
+
+  "DELETE /admin/v1/sdns/<id>": sdn_controllers.id.delete
+
+  "PATCH /admin/v1/sdns/<id>": sdn_controllers.id.patch
+
+################################################################################
+##################################### WIMs #####################################
+################################################################################
+
+  "GET /admin/v1/wims": wims.get
+
+  "POST /admin/v1/wims": wims.post
+
+  "GET /admin/v1/wims/<id>": wims.id.get
+
+  "PUT /admin/v1/wims/<id>": wims.id.put
+
+  "DELETE /admin/v1/wims/<id>": wims.id.delete
+
+  "PATCH /admin/v1/wims/<id>": wims.id.patch
+
+################################################################################
+################################## WIM Accounts ################################
+################################################################################
+
+  "GET /admin/v1/wim_accounts": wim_accounts.get
+
+  "POST /admin/v1/wim_accounts": wim_accounts.post
+
+  "GET /admin/v1/wim_accounts/<id>": wim_accounts.id.get
+
+  "PUT /admin/v1/wim_accounts/<id>": wim_accounts.id.put
+
+  "DELETE /admin/v1/wim_accounts/<id>": wim_accounts.id.delete
+
+  "PATCH /admin/v1/wim_accounts/<id>": wim_accounts.id.patch
+
+################################################################################
+##################################### Roles ####################################
+################################################################################
+
+  "GET /admin/v1/roles": roles.get
+
+  "POST /admin/v1/roles": roles.post
+
+  "GET /admin/v1/roles/<id>": roles.id.get
+
+  "DELETE /admin/v1/roles/<id>": roles.id.delete
+
+################################################################################
+##################################### PDUDs ####################################
+################################################################################
+
+  "GET /pdu/v1/pdu_descriptors": pduds.get
+
+  "POST /pdu/v1/pdu_descriptors": pduds.post
+
+  "PUT /pdu/v1/pdu_descriptors": pduds.put
+
+  "DELETE /pdu/v1/pdu_descriptors": pduds.delete
+
+  "PATCH /pdu/v1/pdu_descriptors": pduds.patch
+
+  "GET /pdu/v1/pdu_descriptors/<id>": pduds.id.get
+
+  "POST /pdu/v1/pdu_descriptors/<id>": pduds.id.post
+
+  "PUT /pdu/v1/pdu_descriptors/<id>": pduds.id.put
+
+  "DELETE /pdu/v1/pdu_descriptors/<id>": pduds.id.delete
+
+  "PATCH /pdu/v1/pdu_descriptors/<id>": pduds.id.patch
+
+################################################################################
+############################ Network Slice Templates ###########################
+################################################################################
+
+  "GET /nst/v1/netslice_templates_content": slice_templates.get
+  "GET /nst/v1/netslice_templates": slice_templates.get
+
+  "POST /nst/v1/netslice_templates_content": slice_templates.content.post
+
+  "GET /nst/v1/netslice_templates_content/<nstInfoId>": slice_templates.id.get
+  "GET /nst/v1/netslice_templates/<nstInfoId>": slice_templates.id.get
+
+  "PUT /nst/v1/netslice_templates_content/<nstInfoId>": slice_templates.id.put
+
+  "DELETE /nst/v1/netslice_templates_content/<nstInfoId>": slice_templates.id.delete
+  "DELETE /nst/v1/netslice_templates/<nstInfoId>": slice_templates.id.delete
+
+  "PATCH /nst/v1/netslice_templates/<nstInfoId>": slice_templates.id.patch
+
+  "GET /nst/v1/netslice_templates/<nstInfoId>/nst_content": slice_templates.content.get
+
+  "PUT /nst/v1/netslice_templates/<nstInfoId>/nst_content": slice_templates.content.put
+
+  "GET /nst/v1/netslice_templates/<nstInfoId>/nst": slice_templates.id.nst.get
+
+  "GET /nst/v1/netslice_templates/<nstInfoId>/artifacts": slice_templates.id.nst_artifact.get
+  "GET /nst/v1/netslice_templates/<nstInfoId>/artifacts/<artifactPath>": slice_templates.id.nst_artifact.get
+
+################################################################################
+############################ Network Slice Instances ###########################
+################################################################################
+
+  "GET /nsilcm/v1/netslice_instances_content": slice_instances.get
+  "GET /nsilcm/v1/netslice_instances": slice_instances.get
+
+  "POST /nsilcm/v1/netslice_instances_content": slice_instances.content.get
+
+  "GET /nsilcm/v1/netslice_instances_content/<SliceInstanceId>": slice_instances.id.get
+  "GET /nsilcm/v1/netslice_instances/<SliceInstanceId>": slice_instances.id.get
+
+  "DELETE /nsilcm/v1/netslice_instances_content/<SliceInstanceId>": slice_instances.id.delete
+  "DELETE /nsilcm/v1/netslice_instances/<SliceInstanceId>": slice_instances.id.delete
+
+  "POST /nsilcm/v1/netslice_instances": slice_instances.post
+
+  "POST /nsilcm/v1/netslice_instances/<SliceInstanceId>/instantiate": slice_instances.id.instantiate.post
+
+  "POST /nsilcm/v1/netslice_instances/<SliceInstanceId>/terminate": slice_instances.id.terminate.post
+
+  "POST /nsilcm/v1/netslice_instances/<SliceInstanceId>/action": slice_instances.id.action.post
+
+  "GET /nsilcm/v1/netslice_instances/<SliceInstanceId>/nsi_lcm_op_occs": slice_instances.id.opps.get
+
+  "GET /nsilcm/v1/netslice_instances/<SliceInstanceId>/nsi_lcm_op_occs/<nsiLcmOpOccId>": slice_instances.id.opps.id.get
diff --git a/osm_nbi/roles_to_operations.yml b/osm_nbi/roles_to_operations.yml
new file mode 100644 (file)
index 0000000..73d1a64
--- /dev/null
@@ -0,0 +1,137 @@
+# Copyright 2018 Whitestack, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+# For those usages not covered by the Apache License, Version 2.0 please
+# contact: esousa@whitestack.com or glavado@whitestack.com
+##
+
+---
+roles_to_operations:
+
+##
+# This file defines the mapping between user roles and operation permission.
+# It uses the following pattern:
+#
+#    - role: <ROLE_NAME>
+#      operations:
+#        "<OPERATION>": true | false
+#
+# <ROLE_NAME> defines the name of the role. This name will be matched with an
+# existing role in the RBAC system.
+#
+# NOTE: The role will only be used if there is an existing match. If there
+#       isn't a role in the system that can be matched, the operation permissions
+#       won't yield any result.
+#
+# operations: is a list of operation permissions for the role. An operation
+# permission is defined using the following pattern:
+#
+#    "<OPERATION>": true | false
+#
+# The operations are defined using an hierarchical tree. For this purpose, an
+# <OPERATION> tag can represents the path for the following:
+#    - Root
+#    - Node
+#    - Leaf
+#
+# The root <OPERATION> tag is defined using "." and the default value is false.
+# When you use this tag, all the operation permissions will be set to the value
+# assigned.
+# NOTE 1: The default value is false. So if a value isn't specified, it will
+#         default to false.
+# NOTE 2: The root <OPERATION> tag can be overridden by using more specific tags
+#         with a different value.
+#
+# The node <OPERATION> tag is defined by using an internal node of the tree, i.e.
+# "nsds", "users.id". A node <OPERATION> tag will affect all the nodes and leafs
+# beneath it. It can be used to override a root <OPERATION> tag.
+# NOTE 1: It can be overridden by using a more specific tag, such as a node which
+#         is beneath it or a leaf.
+#
+# The leaf <OPERATION> tag is defined by using a leaf of the tree, i.e. "users.post",
+# "ns_instances.get", "vim_accounts.id.get". A leaf <OPERATION> tag will override all
+# the values defined by the parent nodes, since it is the more specific tag that can
+# exist.
+#
+# General notes:
+#    - In order to find which tags are in use, check the resources_to_operations.yml.
+#    - In order to find which roles are in use, check the RBAC system.
+#    - Non existing tags will be ignored.
+#    - Tags finishing in a dot (excluding the root <OPERATION> tag) will be ignored.
+#    - The anonymous role allows to bypass the role definition for paths that
+#      shouldn't be verified.
+##
+
+  - role: "system_admin"
+    operations:
+      ".": true
+
+  - role: "account_manager"
+    operations:
+      ".": false
+      "tokens": true
+      "users": true
+      "projects": true
+      "roles": true
+
+  - role: "project_admin"
+    operations:
+      ".": true
+      # Users
+      "users.post": false
+      "users.id.post": false
+      "users.id.delete": false
+      # Projects
+      "projects": false
+      # Roles
+      "roles": false
+
+  - role: "project_user"
+    operations:
+      ".": true
+      # NS Instances
+      "ns_instances": false
+      "ns_instances.get": true
+      # VNF Instances
+      "vnf_instances": false
+      # Users
+      "users": false
+      "users.id.get": true
+      "users.id.put": true
+      "users.id.patch": true
+      # Projects
+      "projects": false
+      # VIMs
+      "vims": false
+      "vims.get": true
+      "vims.id.get": true
+      # VIM Accounts
+      "vim_accounts": false
+      "vim_accounts.get": true
+      "vim_accounts.id.get": true
+      # SDN Controllers
+      "sdn_controllers": false
+      "sdn_controllers.get": true
+      "sdn_controllers.id.get": true
+      # WIMs
+      "wims": false
+      "wims.get": true
+      "wims.id.get": true
+      # WIM Accounts
+      "wim_accounts": false
+      "wim_accounts.get": true
+      "wim_accounts.id.get": true
+
+  - role: "anonymous"
+    operations:
index cd7b5d4..8946575 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -57,7 +57,8 @@ setup(
         'jsonschema',
         'PyYAML',
         'osm-im',
         'jsonschema',
         'PyYAML',
         'osm-im',
-        'python-keystoneclient'
+        'python-keystoneclient',
+        'requests'
     ],
     setup_requires=['setuptools-version-command'],
 )
     ],
     setup_requires=['setuptools-version-command'],
 )
index d7e4022..c5d0fe9 100644 (file)
--- a/stdeb.cfg
+++ b/stdeb.cfg
@@ -1,3 +1,4 @@
 [DEFAULT]
 X-Python3-Version : >= 3.5
 [DEFAULT]
 X-Python3-Version : >= 3.5
-Depends3 : python3-osm-common, python3-osm-im, python3-cherrypy3, python3-yaml, python3-jsonschema, python3-keystoneclient, python3-pip
+Depends3 : python3-osm-common, python3-osm-im, python3-cherrypy3, python3-yaml, python3-jsonschema,
+    python3-keystoneclient, python3-pip, python3-requests