Merge "RBAC permission storage in MongoDB"
authortierno <alfonso.tiernosepulveda@telefonica.com>
Thu, 14 Feb 2019 14:16:05 +0000 (15:16 +0100)
committerGerrit Code Review <root@osm.etsi.org>
Thu, 14 Feb 2019 14:16:05 +0000 (15:16 +0100)
1  2 
osm_nbi/auth.py
osm_nbi/authconn_keystone.py

diff --combined osm_nbi/auth.py
@@@ -1,23 -1,17 +1,23 @@@
  # -*- coding: utf-8 -*-
  
 -# 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
 +# Copyright 2018 Whitestack, LLC
 +# Copyright 2018 Telefonica S.A.
  #
 -#    http://www.apache.org/licenses/LICENSE-2.0
 +# 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.
 +# 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 alfonso.tiernosepulveda@telefonica.com
 +##
  
  
  """
@@@ -25,12 -19,14 +25,14 @@@ Authenticator is responsible for authen
  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
+ import yaml
  from base64 import standard_b64decode
  from copy import deepcopy
  from functools import reduce
@@@ -38,6 -34,7 +40,7 @@@ from hashlib import sha25
  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
@@@ -54,7 -51,7 +57,7 @@@ class Authenticator
      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):
          """
          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):
                  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 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.
          """
-         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
              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)
+                     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
-                     return self.tokens_cache[token]
+                     return deepcopy(self.tokens_cache[token])
                  except AuthException:
                      self.del_token(token)
                      raise
              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:
              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:
          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
@@@ -1,28 -1,11 +1,29 @@@
  # -*- coding: utf-8 -*-
  
 +# 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
 +##
 +
  """
  AuthconnKeystone implements implements the connector for
  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$"
  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.exceptions.http import Conflict
  from keystoneclient.v3 import client
  from http import HTTPStatus
  
@@@ -50,6 -35,19 +53,19 @@@ class AuthconnKeystone(Authconn)
          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,
              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)
  
-             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)
          :param token: token to be revoked
          """
          try:
+             self.logger.info("Revoking token: " + token)
              self.keystone.tokens.revoke_token(token=token)
  
              return True
          """
          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:
          :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")
          :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")
          :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")
          :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")
          :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")
          :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")
          :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")
          :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")
          :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")