From: tierno Date: Thu, 14 Feb 2019 14:16:05 +0000 (+0100) Subject: Merge "RBAC permission storage in MongoDB" X-Git-Tag: v6.0.0~60 X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=commitdiff_plain;h=7b7ffa61e6282094c1bd528e60b3b395c3ab1358;hp=-c;ds=sidebyside Merge "RBAC permission storage in MongoDB" --- 7b7ffa61e6282094c1bd528e60b3b395c3ab1358 diff --combined osm_nbi/auth.py index f0e00b9,7d586ad..44eaa94 --- a/osm_nbi/auth.py +++ b/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 ; Alfonso Tierno " __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): """ @@@ -66,7 -63,10 +69,10 @@@ 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): @@@ -98,6 -98,28 +104,28 @@@ 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)) @@@ -108,15 -130,125 +136,125 @@@ 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: " " + # 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 @@@ -151,10 -283,15 +289,15 @@@ 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 @@@ -186,6 -323,9 +329,9 @@@ 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: @@@ -245,6 -385,77 +391,77 @@@ 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] == "": + 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] == "": + 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 == "": + 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: @@@ -360,4 -571,4 +577,4 @@@ 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 diff --combined osm_nbi/authconn_keystone.py index 068c213,059eae4..518f47f --- a/osm_nbi/authconn_keystone.py +++ b/osm_nbi/authconn_keystone.py @@@ -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 " __date__ = "$27-jul-2018 23:59:59$" @@@ -30,9 -13,11 +31,11 @@@ 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, @@@ -98,14 -96,14 +114,14 @@@ 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) @@@ -136,6 -134,7 +152,7 @@@ :param token: token to be revoked """ try: + self.logger.info("Revoking token: " + token) self.keystone.tokens.revoke_token(token=token) return True @@@ -170,7 -169,9 +187,9 @@@ """ 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: @@@ -186,10 -187,7 +205,7 @@@ :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") @@@ -203,10 -201,8 +219,8 @@@ :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") @@@ -219,10 -215,8 +233,8 @@@ :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") @@@ -235,10 -229,9 +247,9 @@@ :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") @@@ -251,10 -244,8 +262,8 @@@ :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") @@@ -267,10 -258,7 +276,7 @@@ :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") @@@ -283,10 -271,8 +289,8 @@@ :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") @@@ -301,10 -287,11 +305,11 @@@ :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") @@@ -319,10 -306,11 +324,11 @@@ :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")