X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=blobdiff_plain;f=osm_nbi%2Fauth.py;h=9098de7835a1c93c521a2d87e888532311b2ead5;hp=3d74d8959464d7bad960aed3e5ab0b86307ccb5d;hb=990ac461246c2449534955f8e5c73ecbc295f4dc;hpb=701018c9f19c0d18b7392ab63686bb5f982e5ea5 diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index 3d74d89..9098de7 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -39,15 +39,14 @@ from http import HTTPStatus from time import time from os import path -from authconn import AuthException, AuthExceptionUnauthorized -from authconn_keystone import AuthconnKeystone -from authconn_internal import AuthconnInternal # Comment out for testing&debugging, uncomment when ready -from osm_common import dbmongo -from osm_common import dbmemory +from osm_nbi.authconn import AuthException, AuthExceptionUnauthorized +from osm_nbi.authconn_keystone import AuthconnKeystone +from osm_nbi.authconn_internal import AuthconnInternal +from osm_common import dbmemory, dbmongo, msglocal, msgkafka from osm_common.dbbase import DbException +from osm_nbi.validation import is_valid_uuid from itertools import chain - -from uuid import uuid4 # For Role _id with internal authentication backend +from uuid import uuid4 class Authenticator: @@ -60,6 +59,7 @@ class Authenticator: """ periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned + token_limit = 500 # when reached, the token cache will be cleared def __init__(self, valid_methods, valid_query_string): """ @@ -69,10 +69,11 @@ class Authenticator: self.backend = None self.config = None self.db = None + self.msg = None self.tokens_cache = dict() self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done self.roles_to_operations_file = None - self.roles_to_operations_table = None + # self.roles_to_operations_table = None self.resources_to_operations_mapping = {} self.operation_to_allowed_roles = {} self.logger = logging.getLogger("nbi.authenticator") @@ -101,11 +102,21 @@ class Authenticator: else: raise AuthException("Invalid configuration param '{}' at '[database]':'driver'" .format(config["database"]["driver"])) + if not self.msg: + if config["message"]["driver"] == "local": + self.msg = msglocal.MsgLocal() + self.msg.connect(config["message"]) + elif config["message"]["driver"] == "kafka": + self.msg = msgkafka.MsgKafka() + self.msg.connect(config["message"]) + else: + raise AuthException("Invalid configuration param '{}' at '[message]':'driver'" + .format(config["message"]["driver"])) if not self.backend: if config["authentication"]["backend"] == "keystone": - self.backend = AuthconnKeystone(self.config["authentication"]) + self.backend = AuthconnKeystone(self.config["authentication"], self.db, self.role_permissions) elif config["authentication"]["backend"] == "internal": - self.backend = AuthconnInternal(self.config["authentication"], self.db, self.tokens_cache) + self.backend = AuthconnInternal(self.config["authentication"], self.db, self.role_permissions) self._internal_tokens_prune() else: raise AuthException("Unknown authentication backend: {}" @@ -126,11 +137,6 @@ class Authenticator: if not self.roles_to_operations_file: raise AuthException("Invalid permission configuration: roles_to_operations file missing") - if not self.roles_to_operations_table: # PROVISIONAL ? - self.roles_to_operations_table = "roles_operations" \ - if config["authentication"]["backend"] == "keystone" \ - else "roles" - # load role_permissions def load_role_permissions(method_dict): for k in method_dict: @@ -161,6 +167,50 @@ class Authenticator: except DbException as e: raise AuthException(str(e), http_code=e.http_code) + def create_admin_project(self): + """ + Creates a new project 'admin' into database if it doesn't exist. Useful for initialization. + :return: _id identity of the 'admin' project + """ + + # projects = self.db.get_one("projects", fail_on_empty=False, fail_on_more=False) + project_desc = {"name": "admin"} + projects = self.backend.get_project_list(project_desc) + if projects: + return projects[0]["_id"] + now = time() + project_desc["_id"] = str(uuid4()) + project_desc["_admin"] = {"created": now, "modified": now} + pid = self.backend.create_project(project_desc) + self.logger.info("Project '{}' created at database".format(project_desc["name"])) + return pid + + def create_admin_user(self, project_id): + """ + Creates a new user admin/admin into database if database is empty. Useful for initialization + :return: _id identity of the inserted data, or None + """ + # users = self.db.get_one("users", fail_on_empty=False, fail_on_more=False) + users = self.backend.get_user_list() + if users: + return None + # user_desc = {"username": "admin", "password": "admin", "projects": [project_id]} + now = time() + user_desc = {"username": "admin", "password": "admin", "_admin": {"created": now, "modified": now}} + if project_id: + pid = project_id + else: + # proj = self.db.get_one("projects", {"name": "admin"}, fail_on_empty=False, fail_on_more=False) + proj = self.backend.get_project_list({"name": "admin"}) + pid = proj[0]["_id"] if proj else None + # role = self.db.get_one("roles", {"name": "system_admin"}, fail_on_empty=False, fail_on_more=False) + roles = self.backend.get_role_list({"name": "system_admin"}) + if pid and roles: + user_desc["project_role_mappings"] = [{"project": pid, "role": roles[0]["_id"]}] + uid = self.backend.create_user(user_desc) + self.logger.info("User '{}' created at database".format(user_desc["username"])) + return uid + def init_db(self, target_version='1.0'): """ Check if the database has been initialized, with at least one user. If not, create the required tables @@ -170,16 +220,12 @@ class Authenticator: :return: None if OK, exception if error or version is different. """ - # PCR 28/05/2019 Commented out to allow initialization for internal backend - # if self.config["authentication"]["backend"] == "internal": - # return - - records = self.db.get_list(self.roles_to_operations_table) + records = self.backend.get_role_list() # Loading permissions to MongoDB if there is not any permission. - if not records: + if not records or (len(records) == 1 and records[0]["name"] == "admin"): with open(self.roles_to_operations_file, "r") as stream: - roles_to_operations_yaml = yaml.load(stream) + roles_to_operations_yaml = yaml.load(stream, Loader=yaml.Loader) role_names = [] for role_with_operations in roles_to_operations_yaml["roles"]: @@ -216,22 +262,39 @@ class Authenticator: "modified": now, } - if self.config["authentication"]["backend"] == "keystone": - if role_with_operations["name"] != "anonymous": - backend_roles = self.backend.get_role_list(filter_q={"name": role_with_operations["name"]}) - if backend_roles: - backend_id = backend_roles[0]["_id"] - else: - backend_id = self.backend.create_role(role_with_operations["name"]) - role_with_operations["_id"] = backend_id - else: - role_with_operations["_id"] = str(uuid4()) - - self.db.create(self.roles_to_operations_table, role_with_operations) + # self.db.create(self.roles_to_operations_table, role_with_operations) + self.backend.create_role(role_with_operations) self.logger.info("Role '{}' created at database".format(role_with_operations["name"])) - if self.config["authentication"]["backend"] != "internal": - self.backend.assign_role_to_user("admin", "admin", "system_admin") + # Create admin project&user if required + pid = self.create_admin_project() + user_id = self.create_admin_user(pid) + + # try to assign system_admin role to user admin if not any user has this role + if not user_id: + try: + users = self.backend.get_user_list() + roles = self.backend.get_role_list({"name": "system_admin"}) + role_id = roles[0]["_id"] + user_with_system_admin = False + user_admin_id = None + for user in users: + if not user_admin_id: + user_admin_id = user["_id"] + if user["username"] == "admin": + user_admin_id = user["_id"] + for prm in user.get("project_role_mappings", ()): + if prm["role"] == role_id: + user_with_system_admin = True + break + if user_with_system_admin: + break + if not user_with_system_admin: + self.backend.update_user({"_id": user_admin_id, + "add_project_role_mappings": [{"project": pid, "role": role_id}]}) + self.logger.info("Added role system admin to user='{}' project=admin".format(user_admin_id)) + except Exception as e: + self.logger.error("Error in Authorization DataBase initialization: {}: {}".format(type(e).__name__, e)) self.load_operation_to_allowed_roles() @@ -243,10 +306,13 @@ class Authenticator: """ permissions = {oper: [] for oper in self.role_permissions} - records = self.db.get_list(self.roles_to_operations_table) + # records = self.db.get_list(self.roles_to_operations_table) + records = self.backend.get_role_list() ignore_fields = ["_id", "_admin", "name", "default"] for record in records: + if not record.get("permissions"): + continue record_permissions = {oper: record["permissions"].get("default", False) for oper in self.role_permissions} operations_joined = [(oper, value) for oper, value in record["permissions"].items() if oper not in ignore_fields] @@ -265,7 +331,7 @@ class Authenticator: self.operation_to_allowed_roles = permissions - def authorize(self, role_permission=None, query_string_operations=None): + def authorize(self, role_permission=None, query_string_operations=None, item_id=None): token = None user_passwd64 = None try: @@ -299,26 +365,47 @@ class Authenticator: if not token: raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED) - token_info = self.backend.validate_token(token) + + # try to get from cache first + now = time() + token_info = self.tokens_cache.get(token) + if token_info and token_info["expires"] < now: + # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del + self.tokens_cache.pop(token, None) + token_info = None + + # get from database if not in cache + if not token_info: + token_info = self.backend.validate_token(token) + # Clear cache if token limit reached + if len(self.tokens_cache) > self.token_limit: + self.tokens_cache.clear() + self.tokens_cache[token] = token_info # TODO add to token info remote host, port if role_permission: - self.check_permissions(token_info, cherrypy.request.method, role_permission, - query_string_operations) + RBAC_auth = self.check_permissions(token_info, cherrypy.request.method, role_permission, + query_string_operations, item_id) + token_info["allow_show_user_project_role"] = RBAC_auth + return token_info except AuthException as e: if not isinstance(e, AuthExceptionUnauthorized): if cherrypy.session.get('Authorization'): del cherrypy.session['Authorization'] cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e) + elif self.config.get("user_not_authorized"): + # TODO provide user_id, roles id (not name), project_id + return {"id": "fake-token-id-for-test", + "project_id": self.config.get("project_not_authorized", "admin"), + "username": self.config["user_not_authorized"], + "roles": ["system_admin"]} raise def new_token(self, token_info, indata, remote): new_token_info = self.backend.authenticate( - user=indata.get("username"), - password=indata.get("password"), + credentials=indata, token_info=token_info, - project=indata.get("project_id") ) new_token_info["remote_port"] = remote.port @@ -333,8 +420,6 @@ class Authenticator: elif remote.ip: new_token_info["remote_host"] = remote.ip - self.tokens_cache[new_token_info["_id"]] = new_token_info - # TODO call self._internal_tokens_prune(now) ? return deepcopy(new_token_info) @@ -361,12 +446,13 @@ class Authenticator: def del_token(self, token): try: self.backend.revoke_token(token) - self.tokens_cache.pop(token, None) + # self.tokens_cache.pop(token, None) + self.remove_token_from_cache(token) return "token '{}' deleted".format(token) except KeyError: raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND) - def check_permissions(self, token_info, method, role_permission=None, query_string_operations=None): + def check_permissions(self, token_info, method, role_permission=None, query_string_operations=None, item_id=None): """ Checks that operation has permissions to be done, base on the assigned roles to this user project :param token_info: Dictionary that contains "roles" with a list of assigned roles. @@ -376,7 +462,9 @@ class Authenticator: :param role_permission: role permission name of the operation required :param query_string_operations: list of possible admin query strings provided by user. It is checked that the assigned role allows this query string for this method - :return: None if granted, exception if not allowed + :param item_id: item identifier if included in the URL, None otherwise + :return: True if access granted by permission rules, False if access granted by default rules (Bug 853) + :raises: AuthExceptionUnauthorized if access denied """ roles_required = self.operation_to_allowed_roles[role_permission] @@ -390,19 +478,29 @@ class Authenticator: break if "anonymous" in roles_required: - return + return True operation_allowed = False for role in roles_allowed: if role in roles_required: operation_allowed = True # if query_string operations, check if this role allows it if not query_string_operations: - return + return True for query_string_operation in query_string_operations: if role not in self.operation_to_allowed_roles[query_string_operation]: break else: - return + return True + + # Bug 853 - Final Solution + # User/Project/Role whole listings are filtered elsewhere + # uid, pid, rid = ("user_id", "project_id", "id") if is_valid_uuid(id) else ("username", "project_name", "name") + uid = "user_id" if is_valid_uuid(item_id) else "username" + if (role_permission in ["projects:get", "projects:id:get", "roles:get", "roles:id:get", "users:get"]) \ + or (role_permission == "users:id:get" and item_id == token_info[uid]): + # or (role_permission == "projects:id:get" and item_id == token_info[pid]) \ + # or (role_permission == "roles:id:get" and item_id in [role[rid] for role in token_info["roles"]]): + return False if not operation_allowed: raise AuthExceptionUnauthorized("Access denied: lack of permissions.") @@ -482,4 +580,11 @@ 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 - self.tokens_cache.clear() # force to reload tokens from database + # self.tokens_cache.clear() # not required any more + + def remove_token_from_cache(self, token=None): + if token: + self.tokens_cache.pop(token, None) + else: + self.tokens_cache.clear() + self.msg.write("admin", "revoke_token", {"_id": token} if token else None)