From 01b15d3166ea28266fb3d994d0615e4091c43c08 Mon Sep 17 00:00:00 2001 From: delacruzramo Date: Tue, 2 Jul 2019 14:37:47 +0200 Subject: [PATCH 1/1] RBAC with internal authentication backend - Phase 2 Change-Id: Iaca4f3022c4184e03f9346d492e55e902e5ca720 Signed-off-by: delacruzramo --- osm_nbi/admin_topics.py | 373 +++++++++++++++++----------------- osm_nbi/auth.py | 93 ++++++--- osm_nbi/authconn.py | 120 +++++------ osm_nbi/authconn_internal.py | 376 +++++++++++++++++++++++------------ osm_nbi/authconn_keystone.py | 166 +++++----------- osm_nbi/base_topic.py | 3 +- osm_nbi/engine.py | 70 +------ osm_nbi/nbi.py | 11 +- 8 files changed, 619 insertions(+), 593 deletions(-) diff --git a/osm_nbi/admin_topics.py b/osm_nbi/admin_topics.py index 187ca82..efcb0f1 100644 --- a/osm_nbi/admin_topics.py +++ b/osm_nbi/admin_topics.py @@ -25,7 +25,9 @@ from validation import validate_input from validation import ValidationError from validation import is_valid_uuid # To check that User/Project Names don't look like UUIDs from base_topic import BaseTopic, EngineException -from authconn_keystone import AuthconnKeystone +from osm_common.dbbase import deep_update_rfc7396 +from authconn import AuthconnNotFoundException, AuthconnConflictException +# from authconn_keystone import AuthconnKeystone __author__ = "Alfonso Tierno " @@ -86,7 +88,7 @@ class UserTopic(BaseTopic): if content.get("password"): content["password"] = sha256(content["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest() if content.get("project_role_mappings"): - projects = [mapping[0] for mapping in content["project_role_mappings"]] + projects = [mapping["project"] for mapping in content["project_role_mappings"]] if content.get("projects"): content["projects"] += projects @@ -413,10 +415,19 @@ class UserTopicAuth(UserTopic): if "projects" in indata.keys(): # convert to new format project_role_mappings + role = self.auth.get_role_list({"name": "project_admin"}) + if not role: + role = self.auth.get_role_list() + if not role: + raise AuthconnNotFoundException("Can't find default role for user '{}'".format(username)) + rid = role[0]["_id"] if not indata.get("project_role_mappings"): indata["project_role_mappings"] = [] for project in indata["projects"]: - indata["project_role_mappings"].append({"project": project, "role": "project_user"}) + pid = self.auth.get_project(project)["_id"] + prm = {"project": pid, "role": rid} + if prm not in indata["project_role_mappings"]: + indata["project_role_mappings"].append(prm) # raise EngineException("Format invalid: the keyword 'projects' is not allowed for keystone authentication", # HTTPStatus.BAD_REQUEST) @@ -458,40 +469,7 @@ class UserTopicAuth(UserTopic): """ if db_content["username"] == session["username"]: raise EngineException("You cannot delete your own login user ", http_code=HTTPStatus.CONFLICT) - - # @staticmethod - # def format_on_new(content, project_id=None, make_public=False): - # """ - # Modifies content descriptor to include _id. - # - # NOTE: No password salt required because the authentication backend - # should handle these security concerns. - # - # :param content: descriptor to be modified - # :param make_public: if included it is generated as public for reading. - # :return: None, but content is modified - # """ - # BaseTopic.format_on_new(content, make_public=False) - # content["_id"] = content["username"] - # content["password"] = content["password"] - - # @staticmethod - # def format_on_edit(final_content, edit_content): - # """ - # Modifies final_content descriptor to include the modified date. - # - # NOTE: No password salt required because the authentication backend - # should handle these security concerns. - # - # :param final_content: final descriptor generated - # :param edit_content: alterations to be include - # :return: None, but final_content is modified - # """ - # BaseTopic.format_on_edit(final_content, edit_content) - # if "password" in edit_content: - # final_content["password"] = edit_content["password"] - # else: - # final_content["project_role_mappings"] = edit_content["project_role_mappings"] + # TODO: Check that user is not logged in ? How? (Would require listing current tokens) @staticmethod def format_on_show(content): @@ -501,14 +479,14 @@ class UserTopicAuth(UserTopic): """ project_role_mappings = [] - for project in content["projects"]: - for role in project["roles"]: - project_role_mappings.append({"project": project["_id"], - "project_name": project["name"], - "role": role["_id"], - "role_name": role["name"]}) - - del content["projects"] + if "projects" in content: + for project in content["projects"]: + for role in project["roles"]: + project_role_mappings.append({"project": project["_id"], + "project_name": project["name"], + "role": role["_id"], + "role_name": role["name"]}) + del content["projects"] content["project_role_mappings"] = project_role_mappings return content @@ -524,7 +502,7 @@ class UserTopicAuth(UserTopic): :param indata: data to be inserted :param kwargs: used to override the indata descriptor :param headers: http request headers - :return: _id: identity of the inserted data. + :return: _id: identity of the inserted data, operation _id (None) """ try: content = BaseTopic._remove_envelop(indata) @@ -534,16 +512,25 @@ class UserTopicAuth(UserTopic): content = self._validate_input_new(content, session["force"]) self.check_conflict_on_new(session, content) # self.format_on_new(content, session["project_id"], make_public=session["public"]) - _id = self.auth.create_user(content["username"], content["password"])["_id"] - - if "project_role_mappings" in content.keys(): - for mapping in content["project_role_mappings"]: - self.auth.assign_role_to_user(_id, mapping["project"], mapping["role"]) + now = time() + content["_admin"] = {"created": now, "modified": now} + prms = [] + for prm in content.get("project_role_mappings", []): + proj = self.auth.get_project(prm["project"], not session["force"]) + role = self.auth.get_role(prm["role"], not session["force"]) + pid = proj["_id"] if proj else None + rid = role["_id"] if role else None + prl = {"project": pid, "role": rid} + if prl not in prms: + prms.append(prl) + content["project_role_mappings"] = prms + # _id = self.auth.create_user(content["username"], content["password"])["_id"] + _id = self.auth.create_user(content)["_id"] rollback.append({"topic": self.topic, "_id": _id}) # del content["password"] # self._send_msg("create", content) - return _id + return _id, None except ValidationError as e: raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) @@ -590,18 +577,30 @@ class UserTopicAuth(UserTopic): self.check_conflict_on_edit(session, content, indata, _id=_id) # self.format_on_edit(content, indata) - if "password" in indata or "username" in indata: - self.auth.update_user(_id, new_name=indata.get("username"), new_password=indata.get("password")) - if not indata.get("remove_project_role_mappings") and not indata.get("add_project_role_mappings") and \ - not indata.get("project_role_mappings"): + if not ("password" in indata or "username" in indata or indata.get("remove_project_role_mappings") or + indata.get("add_project_role_mappings") or indata.get("project_role_mappings") or + indata.get("projects") or indata.get("add_projects")): return _id - if indata.get("project_role_mappings") and \ - (indata.get("remove_project_role_mappings") or indata.get("add_project_role_mappings")): + if indata.get("project_role_mappings") \ + and (indata.get("remove_project_role_mappings") or indata.get("add_project_role_mappings")): raise EngineException("Option 'project_role_mappings' is incompatible with 'add_project_role_mappings" "' or 'remove_project_role_mappings'", http_code=HTTPStatus.BAD_REQUEST) - user = self.show(session, _id) - original_mapping = user["project_role_mappings"] + if indata.get("projects") or indata.get("add_projects"): + role = self.auth.get_role_list({"name": "project_admin"}) + if not role: + role = self.auth.get_role_list() + if not role: + raise AuthconnNotFoundException("Can't find a default role for user '{}'" + .format(content["username"])) + rid = role[0]["_id"] + if "add_project_role_mappings" not in indata: + indata["add_project_role_mappings"] = [] + for proj in indata.get("projects", []) + indata.get("add_projects", []): + indata["add_project_role_mappings"].append({"project": proj, "role": rid}) + + # user = self.show(session, _id) # Already in 'content' + original_mapping = content["project_role_mappings"] mappings_to_add = [] mappings_to_remove = [] @@ -623,7 +622,9 @@ class UserTopicAuth(UserTopic): mappings_to_remove.remove(mapping) break # do not add, it is already at user else: - mappings_to_add.append(to_add) + pid = self.auth.get_project(to_add["project"])["_id"] + rid = self.auth.get_role(to_add["role"])["_id"] + mappings_to_add.append({"project": pid, "role": rid}) # set if indata.get("project_role_mappings"): @@ -631,12 +632,13 @@ class UserTopicAuth(UserTopic): for mapping in original_mapping: if to_set["project"] in (mapping["project"], mapping["project_name"]) and \ to_set["role"] in (mapping["role"], mapping["role_name"]): - if mapping in mappings_to_remove: # do not remove mappings_to_remove.remove(mapping) break # do not add, it is already at user else: - mappings_to_add.append(to_set) + pid = self.auth.get_project(to_set["project"])["_id"] + rid = self.auth.get_role(to_set["role"])["_id"] + mappings_to_add.append({"project": pid, "role": rid}) for mapping in original_mapping: for to_set in indata["project_role_mappings"]: if to_set["project"] in (mapping["project"], mapping["project_name"]) and \ @@ -647,21 +649,12 @@ class UserTopicAuth(UserTopic): if mapping not in mappings_to_remove: # do not remove mappings_to_remove.append(mapping) - for mapping in mappings_to_remove: - self.auth.remove_role_from_user( - _id, - mapping["project"], - mapping["role"] - ) - - for mapping in mappings_to_add: - self.auth.assign_role_to_user( - _id, - mapping["project"], - mapping["role"] - ) - - return "_id" + self.auth.update_user({"_id": _id, "username": indata.get("username"), "password": indata.get("password"), + "add_project_role_mappings": mappings_to_add, + "remove_project_role_mappings": mappings_to_remove + }) + + # return _id except ValidationError as e: raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) @@ -687,14 +680,11 @@ class UserTopicAuth(UserTopic): :return: dictionary with deleted item _id. It raises EngineException on error: not found, conflict, ... """ # Allow _id to be a name or uuid - filter_q = {self.id_field(self.topic, _id): _id} - user_list = self.auth.get_user_list(filter_q) - if not user_list: - raise EngineException("User '{}' not found".format(_id), http_code=HTTPStatus.NOT_FOUND) - _id = user_list[0]["_id"] - self.check_conflict_on_del(session, _id, user_list[0]) + user = self.auth.get_user(_id) + uid = user["_id"] + self.check_conflict_on_del(session, uid, user) if not dry_run: - v = self.auth.delete_user(_id) + v = self.auth.delete_user(uid) return v return None @@ -739,11 +729,14 @@ class ProjectTopicAuth(ProjectTopic): """ project_name = edit_content.get("name") - if project_name: + if project_name != final_content["name"]: # It is a true renaming if is_valid_uuid(project_name): - raise EngineException("project name '{}' cannot be an uuid format".format(project_name), + raise EngineException("project name '{}' cannot have an uuid format".format(project_name), HTTPStatus.UNPROCESSABLE_ENTITY) + if final_content["name"] == "admin": + raise EngineException("You cannot rename project 'admin'", http_code=HTTPStatus.CONFLICT) + # Check that project name is not used, regardless keystone already checks this if self.auth.get_project_list(filter_q={"name": project_name}): raise EngineException("project '{}' is already used".format(project_name), HTTPStatus.CONFLICT) @@ -757,13 +750,33 @@ class ProjectTopicAuth(ProjectTopic): :param db_content: The database content of this item _id :return: None if ok or raises EngineException with the conflict """ - # projects = self.auth.get_project_list() - # current_project = [project for project in projects - # if project["name"] in session["project_id"]][0] - # TODO check that any user is using this project, raise CONFLICT exception - if _id == session["project_id"]: + + def check_rw_projects(topic, title, id_field): + for desc in self.db.get_list(topic): + if _id in desc["_admin"]["projects_read"] + desc["_admin"]["projects_write"]: + raise EngineException("Project '{}' ({}) is being used by {} '{}'" + .format(db_content["name"], _id, title, desc[id_field]), HTTPStatus.CONFLICT) + + if _id in session["project_id"]: raise EngineException("You cannot delete your own project", http_code=HTTPStatus.CONFLICT) + if db_content["name"] == "admin": + raise EngineException("You cannot delete project 'admin'", http_code=HTTPStatus.CONFLICT) + + # If any user is using this project, raise CONFLICT exception + if not session["force"]: + for user in self.auth.get_user_list(): + if _id in [proj["_id"] for proj in user.get("projects", [])]: + raise EngineException("Project '{}' ({}) is being used by user '{}'" + .format(db_content["name"], _id, user["username"]), HTTPStatus.CONFLICT) + + # If any VNFD, NSD, NST, PDU, etc. is using this project, raise CONFLICT exception + if not session["force"]: + check_rw_projects("vnfds", "VNF Descriptor", "id") + check_rw_projects("nsds", "NS Descriptor", "id") + check_rw_projects("nsts", "NS Template", "id") + check_rw_projects("pdus", "PDU Descriptor", "name") + def new(self, rollback, session, indata=None, kwargs=None, headers=None): """ Creates a new entry into the authentication backend. @@ -775,7 +788,7 @@ class ProjectTopicAuth(ProjectTopic): :param indata: data to be inserted :param kwargs: used to override the indata descriptor :param headers: http request headers - :return: _id: identity of the inserted data. + :return: _id: identity of the inserted data, operation _id (None) """ try: content = BaseTopic._remove_envelop(indata) @@ -785,10 +798,10 @@ class ProjectTopicAuth(ProjectTopic): content = self._validate_input_new(content, session["force"]) self.check_conflict_on_new(session, content) self.format_on_new(content, project_id=session["project_id"], make_public=session["public"]) - _id = self.auth.create_project(content["name"]) + _id = self.auth.create_project(content) rollback.append({"topic": self.topic, "_id": _id}) # self._send_msg("create", content) - return _id + return _id, None except ValidationError as e: raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) @@ -831,14 +844,11 @@ class ProjectTopicAuth(ProjectTopic): :return: dictionary with deleted item _id. It raises EngineException on error: not found, conflict, ... """ # Allow _id to be a name or uuid - filter_q = {self.id_field(self.topic, _id): _id} - project_list = self.auth.get_project_list(filter_q) - if not project_list: - raise EngineException("Project '{}' not found".format(_id), http_code=HTTPStatus.NOT_FOUND) - _id = project_list[0]["_id"] - self.check_conflict_on_del(session, _id, project_list[0]) + proj = self.auth.get_project(_id) + pid = proj["_id"] + self.check_conflict_on_del(session, pid, proj) if not dry_run: - v = self.auth.delete_project(_id) + v = self.auth.delete_project(pid) return v return None @@ -864,10 +874,11 @@ class ProjectTopicAuth(ProjectTopic): if not content: content = self.show(session, _id) self.check_conflict_on_edit(session, content, indata, _id=_id) - # self.format_on_edit(content, indata) + self.format_on_edit(content, indata) if "name" in indata: - self.auth.update_project(content["_id"], indata["name"]) + content["name"] = indata["name"] + self.auth.update_project(content["_id"], content) except ValidationError as e: raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) @@ -883,7 +894,7 @@ class RoleTopicAuth(BaseTopic): BaseTopic.__init__(self, db, fs, msg) self.auth = auth self.operations = ops - self.topic = "roles_operations" if isinstance(auth, AuthconnKeystone) else "roles" + # self.topic = "roles_operations" if isinstance(auth, AuthconnKeystone) else "roles" @staticmethod def validate_role_definition(operations, role_definitions): @@ -946,8 +957,10 @@ class RoleTopicAuth(BaseTopic): :return: None or raises EngineException """ # check name not exists - if self.db.get_one(self.topic, {"name": indata.get("name")}, fail_on_empty=False, fail_on_more=False): - raise EngineException("role name '{}' exists".format(indata["name"]), HTTPStatus.CONFLICT) + name = indata["name"] + # if self.db.get_one(self.topic, {"name": indata.get("name")}, fail_on_empty=False, fail_on_more=False): + if self.auth.get_role_list({"name": name}): + raise EngineException("role name '{}' exists".format(name), HTTPStatus.CONFLICT) def check_conflict_on_edit(self, session, final_content, edit_content, _id): """ @@ -967,7 +980,9 @@ class RoleTopicAuth(BaseTopic): # check name not exists if "name" in edit_content: role_name = edit_content["name"] - if self.db.get_one(self.topic, {"name": role_name, "_id.ne": _id}, fail_on_empty=False, fail_on_more=False): + # if self.db.get_one(self.topic, {"name":role_name,"_id.ne":_id}, fail_on_empty=False, fail_on_more=False): + roles = self.auth.get_role_list({"name": role_name}) + if roles and roles[0][BaseTopic.id_field("roles", _id)] != _id: raise EngineException("role name '{}' exists".format(role_name), HTTPStatus.CONFLICT) def check_conflict_on_del(self, session, _id, db_content): @@ -979,14 +994,18 @@ class RoleTopicAuth(BaseTopic): :param db_content: The database content of this item _id :return: None if ok or raises EngineException with the conflict """ - roles = self.auth.get_role_list() - system_admin_roles = [role for role in roles if role["name"] == "system_admin"] + role = self.auth.get_role(_id) + if role["name"] in ["system_admin", "project_admin"]: + raise EngineException("You cannot delete role '{}'".format(role["name"]), http_code=HTTPStatus.FORBIDDEN) - if system_admin_roles and _id == system_admin_roles[0]["_id"]: - raise EngineException("You cannot delete system_admin role", http_code=HTTPStatus.FORBIDDEN) + # If any user is using this role, raise CONFLICT exception + for user in self.auth.get_user_list(): + if _id in [prl["_id"] for proj in user.get("projects", []) for prl in proj.get("roles", [])]: + raise EngineException("Role '{}' ({}) is being used by user '{}'" + .format(role["name"], _id, user["username"]), HTTPStatus.CONFLICT) @staticmethod - def format_on_new(content, project_id=None, make_public=False): + def format_on_new(content, project_id=None, make_public=False): # TO BE REMOVED ? """ Modifies content descriptor to include _admin @@ -1019,7 +1038,8 @@ class RoleTopicAuth(BaseTopic): :param edit_content: alterations to be include :return: None, but final_content is modified """ - final_content["_admin"]["modified"] = time() + if "_admin" in final_content: + final_content["_admin"]["modified"] = time() if "permissions" not in final_content: final_content["permissions"] = {} @@ -1030,62 +1050,31 @@ class RoleTopicAuth(BaseTopic): final_content["permissions"]["admin"] = False return None - # @staticmethod - # def format_on_show(content): - # """ - # Modifies the content of the role information to separate the role - # metadata from the role definition. Eases the reading process of the - # role definition. - # - # :param definition: role definition to be processed - # """ - # content["_id"] = str(content["_id"]) - # - # def show(self, session, _id): - # """ - # Get complete information on an topic - # - # :param session: contains "username", "admin", "force", "public", "project_id", "set_project" - # :param _id: server internal id - # :return: dictionary, raise exception if not found. - # """ - # filter_db = {"_id": _id} - # filter_db = { BaseTopic.id_field(self.topic, _id): _id } # To allow role addressing by name - # - # role = self.db.get_one(self.topic, filter_db) - # new_role = dict(role) - # self.format_on_show(new_role) - # - # return new_role - - # def list(self, session, filter_q=None): - # """ - # Get a list of the topic that matches a filter - # - # :param session: contains "username", "admin", "force", "public", "project_id", "set_project" - # :param filter_q: filter of data to be applied - # :return: The list, it can be empty if no one match the filter. - # """ - # if not filter_q: - # filter_q = {} - # - # if ":" in filter_q: - # filter_q["root"] = filter_q[":"] - # - # for key in filter_q.keys(): - # if key == "name": - # continue - # filter_q[key] = filter_q[key] in ["True", "true"] - # - # roles = self.db.get_list(self.topic, filter_q) - # new_roles = [] - # - # for role in roles: - # new_role = dict(role) - # self.format_on_show(new_role) - # new_roles.append(new_role) - # - # return new_roles + def show(self, session, _id): + """ + Get complete information on an topic + + :param session: contains "username", "admin", "force", "public", "project_id", "set_project" + :param _id: server internal id + :return: dictionary, raise exception if not found. + """ + filter_q = {BaseTopic.id_field(self.topic, _id): _id} + roles = self.auth.get_role_list(filter_q) + if not roles: + raise AuthconnNotFoundException("Not found any role with filter {}".format(filter_q)) + elif len(roles) > 1: + raise AuthconnConflictException("Found more than one role with filter {}".format(filter_q)) + return roles[0] + + def list(self, session, filter_q=None): + """ + Get a list of the topic that matches a filter + + :param session: contains "username", "admin", "force", "public", "project_id", "set_project" + :param filter_q: filter of data to be applied + :return: The list, it can be empty if no one match the filter. + """ + return self.auth.get_role_list(filter_q) def new(self, rollback, session, indata=None, kwargs=None, headers=None): """ @@ -1096,7 +1085,7 @@ class RoleTopicAuth(BaseTopic): :param indata: data to be inserted :param kwargs: used to override the indata descriptor :param headers: http request headers - :return: _id: identity of the inserted data. + :return: _id: identity of the inserted data, operation _id (None) """ try: content = self._remove_envelop(indata) @@ -1106,13 +1095,13 @@ class RoleTopicAuth(BaseTopic): content = self._validate_input_new(content, session["force"]) self.check_conflict_on_new(session, content) self.format_on_new(content, project_id=session["project_id"], make_public=session["public"]) - role_name = content["name"] - role_id = self.auth.create_role(role_name) - content["_id"] = role_id - _id = self.db.create(self.topic, content) - rollback.append({"topic": self.topic, "_id": _id}) + # role_name = content["name"] + rid = self.auth.create_role(content) + content["_id"] = rid + # _id = self.db.create(self.topic, content) + rollback.append({"topic": self.topic, "_id": rid}) # self._send_msg("create", content) - return _id + return rid, None except ValidationError as e: raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) @@ -1125,12 +1114,19 @@ class RoleTopicAuth(BaseTopic): :param dry_run: make checking but do not delete :return: dictionary with deleted item _id. It raises EngineException on error: not found, conflict, ... """ - self.check_conflict_on_del(session, _id, None) + filter_q = {BaseTopic.id_field(self.topic, _id): _id} + roles = self.auth.get_role_list(filter_q) + if not roles: + raise AuthconnNotFoundException("Not found any role with filter {}".format(filter_q)) + elif len(roles) > 1: + raise AuthconnConflictException("Found more than one role with filter {}".format(filter_q)) + rid = roles[0]["_id"] + self.check_conflict_on_del(session, rid, None) # filter_q = {"_id": _id} - filter_q = {BaseTopic.id_field(self.topic, _id): _id} # To allow role addressing by name + # filter_q = {BaseTopic.id_field(self.topic, _id): _id} # To allow role addressing by name if not dry_run: - self.auth.delete_role(_id) - v = self.db.del_one(self.topic, filter_q) + v = self.auth.delete_role(rid) + # v = self.db.del_one(self.topic, filter_q) return v return None @@ -1145,6 +1141,15 @@ class RoleTopicAuth(BaseTopic): :param content: :return: _id: identity of the inserted data. """ - _id = super().edit(session, _id, indata, kwargs, content) - if indata.get("name"): - self.auth.update_role(_id, name=indata.get("name")) + if kwargs: + self._update_input_with_kwargs(indata, kwargs) + try: + indata = self._validate_input_edit(indata, force=session["force"]) + if not content: + content = self.show(session, _id) + deep_update_rfc7396(content, indata) + self.check_conflict_on_edit(session, content, indata, _id=_id) + self.format_on_edit(content, indata) + self.auth.update_role(content) + except ValidationError as e: + raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index 3d74d89..94eb1e9 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -47,7 +47,7 @@ from osm_common import dbmemory from osm_common.dbbase import DbException from itertools import chain -from uuid import uuid4 # For Role _id with internal authentication backend +from uuid import uuid4 class Authenticator: @@ -72,7 +72,7 @@ class Authenticator: 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") @@ -103,7 +103,7 @@ class Authenticator: .format(config["database"]["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.tokens_cache) elif config["authentication"]["backend"] == "internal": self.backend = AuthconnInternal(self.config["authentication"], self.db, self.tokens_cache) self._internal_tokens_prune() @@ -126,11 +126,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 +156,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,14 +209,10 @@ 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) @@ -216,22 +251,19 @@ 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() + self.create_admin_user(pid) + + if self.config["authentication"]["backend"] == "keystone": + try: + self.backend.assign_role_to_user("admin", "admin", "system_admin") + except Exception: + pass self.load_operation_to_allowed_roles() @@ -243,10 +275,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] diff --git a/osm_nbi/authconn.py b/osm_nbi/authconn.py index 15d0d99..1727590 100644 --- a/osm_nbi/authconn.py +++ b/osm_nbi/authconn.py @@ -27,6 +27,7 @@ __author__ = "Eduardo Sousa " __date__ = "$27-jul-2018 23:59:59$" from http import HTTPStatus +from base_topic import BaseTopic class AuthException(Exception): @@ -108,7 +109,7 @@ class Authconn: Each Auth backend connector plugin must be a subclass of Authconn class. """ - def __init__(self, config): + def __init__(self, config, db, token_cache): """ Constructor of the Authconn class. @@ -136,18 +137,6 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - # def authenticate_with_token(self, token, project=None): - # """ - # Authenticate a user using a token. Can be used to revalidate the token - # or to get a scoped token. - # - # :param token: a valid token. - # :param project: (optional) project for a scoped token. - # :return: return a revalidated token, scoped if a project was passed or - # the previous token was already scoped. - # """ - # raise AuthconnNotImplementedException("Should have implemented this") - def validate_token(self, token): """ Check if the token is valid. @@ -166,43 +155,21 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def get_user_project_list(self, token): - """ - Get all the projects associated with a user. - - :param token: valid token - :return: list of projects - """ - raise AuthconnNotImplementedException("Should have implemented this") - - def get_user_role_list(self, token): - """ - Get role list for a scoped project. - - :param token: scoped token. - :return: returns the list of roles for the user in that project. If - the token is unscoped it returns None. - """ - raise AuthconnNotImplementedException("Should have implemented this") - - def create_user(self, user, password): + def create_user(self, user_info): """ Create a user. - :param user: username. - :param password: password. + :param user_info: full user info. :raises AuthconnOperationException: if user creation failed. """ raise AuthconnNotImplementedException("Should have implemented this") - def update_user(self, user, new_name=None, new_password=None): + def update_user(self, user_info): """ Change the user name and/or password. - :param user: username or user_id - :param new_name: new name - :param new_password: new password. - :raises AuthconnOperationException: if change failed. + :param user_info: user info modifications + :raises AuthconnNotImplementedException: if function not implemented """ raise AuthconnNotImplementedException("Should have implemented this") @@ -223,11 +190,21 @@ class Authconn: :return: returns a list of users. """ - def create_role(self, role): + def get_user(self, id, fail=True): + filt = {BaseTopic.id_field("users", id): id} + users = self.get_user_list(filt) + if not users: + if fail: + raise AuthconnNotFoundException("User with {} not found".format(filt), http_code=HTTPStatus.NOT_FOUND) + else: + return None + return users[0] + + def create_role(self, role_info): """ Create a role. - :param role: role name. + :param role_info: full role info. :raises AuthconnOperationException: if role creation failed. """ raise AuthconnNotImplementedException("Should have implemented this") @@ -250,20 +227,29 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def update_role(self, role, new_name): + def get_role(self, id, fail=True): + filt = {BaseTopic.id_field("roles", id): id} + roles = self.get_role_list(filt) + if not roles: + if fail: + raise AuthconnNotFoundException("Role with {} not found".format(filt)) + else: + return None + return roles[0] + + def update_role(self, role_info): """ - Change the name of a role - :param role: role name or id to be changed - :param new_name: new name + Change the information of a role + :param role_info: full role info :return: None """ raise AuthconnNotImplementedException("Should have implemented this") - def create_project(self, project): + def create_project(self, project_info): """ Create a project. - :param project: project name. + :param project_info: full project info. :return: the internal id of the created project :raises AuthconnOperationException: if project creation failed. """ @@ -287,33 +273,21 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def update_project(self, project_id, new_name): + def get_project(self, id, fail=True): + filt = {BaseTopic.id_field("projects", id): id} + projs = self.get_project_list(filt) + if not projs: + if fail: + raise AuthconnNotFoundException("project with {} not found".format(filt)) + else: + return None + return projs[0] + + def update_project(self, project_id, project_info): """ - Change the name of a project + Change the information of a project :param project_id: project to be changed - :param new_name: new name + :param project_info: full project info :return: None """ raise AuthconnNotImplementedException("Should have implemented this") - - def assign_role_to_user(self, user, project, role): - """ - Assigning a role to a user in a project. - - :param user: username. - :param project: project name. - :param role: role name. - :raises AuthconnOperationException: if role assignment failed. - """ - raise AuthconnNotImplementedException("Should have implemented this") - - def remove_role_from_user(self, user, project, role): - """ - Remove a role from a user in a project. - - :param user: username. - :param project: project name. - :param role: role name. - :raises AuthconnOperationException: if role assignment revocation failed. - """ - raise AuthconnNotImplementedException("Should have implemented this") diff --git a/osm_nbi/authconn_internal.py b/osm_nbi/authconn_internal.py index d3258fe..02b5890 100644 --- a/osm_nbi/authconn_internal.py +++ b/osm_nbi/authconn_internal.py @@ -27,11 +27,12 @@ OSM Internal Authentication Backend and leverages the RBAC model __author__ = "Pedro de la Cruz Ramos " __date__ = "$06-jun-2019 11:16:08$" -from authconn import Authconn, AuthException +from authconn import Authconn, AuthException # , AuthconnOperationException from osm_common.dbbase import DbException from base_topic import BaseTopic import logging +import re from time import time from http import HTTPStatus from uuid import uuid4 @@ -42,7 +43,7 @@ from random import choice as random_choice class AuthconnInternal(Authconn): def __init__(self, config, db, token_cache): - Authconn.__init__(self, config) + Authconn.__init__(self, config, db, token_cache) self.logger = logging.getLogger("nbi.authenticator.internal") @@ -56,48 +57,6 @@ class AuthconnInternal(Authconn): self.auth = None self.sess = None - # def create_token (self, user, password, projects=[], project=None, remote=None): - # Not Required - - # def authenticate_with_user_password(self, user, password, project=None, remote=None): - # Not Required - - # def authenticate_with_token(self, token, project=None, remote=None): - # Not Required - - # def get_user_project_list(self, token): - # Not Required - - # def get_user_role_list(self, token): - # Not Required - - # def create_user(self, user, password): - # Not Required - - # def change_password(self, user, new_password): - # Not Required - - # def delete_user(self, user_id): - # Not Required - - # def get_user_list(self, filter_q={}): - # Not Required - - # def get_project_list(self, filter_q={}): - # Not required - - # def create_project(self, project): - # Not required - - # def delete_project(self, project_id): - # Not required - - # def assign_role_to_user(self, user, project, role): - # Not required in Phase 1 - - # def remove_role_from_user(self, user, project, role): - # Not required in Phase 1 - def validate_token(self, token): """ Check if the token is valid. @@ -190,100 +149,105 @@ class AuthconnInternal(Authconn): now = time() user_content = None - try: - # Try using username/password - if user: - user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user}) - if user_rows: - user_content = user_rows[0] - salt = user_content["_admin"]["salt"] - shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest() - if shadow_password != user_content["password"]: - user_content = None - if not user_content: - raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED) - elif token_info: - user_rows = self.db.get_list("users", {"username": token_info["username"]}) - if user_rows: - user_content = user_rows[0] - else: - raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED) + # Try using username/password + if user: + user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user}) + if user_rows: + user_content = user_rows[0] + salt = user_content["_admin"]["salt"] + shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest() + if shadow_password != user_content["password"]: + user_content = None + if not user_content: + raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED) + elif token_info: + user_rows = self.db.get_list("users", {"username": token_info["username"]}) + if user_rows: + user_content = user_rows[0] else: - raise AuthException("Provide credentials: username/password or Authorization Bearer token", - http_code=HTTPStatus.UNAUTHORIZED) - - token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') - for _ in range(0, 32)) - - # TODO when user contained project_role_mappings with project_id,project_ name this checking to - # database will not be needed - if not project: - project = user_content["projects"][0] - - # To allow project names in project_id - proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project}) - if proj["_id"] not in user_content["projects"] and proj["name"] not in user_content["projects"]: - raise AuthException("project {} not allowed for this user".format(project), - http_code=HTTPStatus.UNAUTHORIZED) - - # TODO remove admin, this vill be used by roles RBAC - if proj["name"] == "admin": - token_admin = True - else: - token_admin = proj.get("admin", False) - - # TODO add token roles - PROVISIONAL. Get this list from user_content["project_role_mappings"] - role_id = self.db.get_one("roles", {"name": "system_admin"})["_id"] - roles_list = [{"name": "system_admin", "id": role_id}] - - new_token = {"issued_at": now, - "expires": now + 3600, - "_id": token_id, - "id": token_id, - "project_id": proj["_id"], - "project_name": proj["name"], - "username": user_content["username"], - "user_id": user_content["_id"], - "admin": token_admin, - "roles": roles_list, - } - - self.token_cache[token_id] = new_token - self.db.create("tokens", new_token) - return deepcopy(new_token) - - except Exception as e: - msg = "Error during user authentication using internal backend: {}".format(e) - self.logger.exception(msg) - raise AuthException(msg, http_code=HTTPStatus.UNAUTHORIZED) - - def get_role_list(self): + raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED) + else: + raise AuthException("Provide credentials: username/password or Authorization Bearer token", + http_code=HTTPStatus.UNAUTHORIZED) + + token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + for _ in range(0, 32)) + + # projects = user_content.get("projects", []) + prm_list = user_content.get("project_role_mappings", []) + + if not project: + project = prm_list[0]["project"] if prm_list else None + if not project: + raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED) + + projects = [prm["project"] for prm in prm_list] + + proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project}) + project_name = proj["name"] + project_id = proj["_id"] + if project_name not in projects and project_id not in projects: + raise AuthException("project {} not allowed for this user".format(project), + http_code=HTTPStatus.UNAUTHORIZED) + + # TODO remove admin, this vill be used by roles RBAC + if project_name == "admin": + token_admin = True + else: + token_admin = proj.get("admin", False) + + # add token roles + roles = [] + roles_list = [] + for prm in prm_list: + if prm["project"] in [project_id, project_name]: + role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]}) + rid = role["_id"] + if rid not in roles: + rnm = role["name"] + roles.append(rid) + roles_list.append({"name": rnm, "id": rid}) + if not roles_list: + rid = self.db.get_one("roles", {"name": "project_admin"})["_id"] + roles_list = [{"name": "project_admin", "id": rid}] + + new_token = {"issued_at": now, + "expires": now + 3600, + "_id": token_id, + "id": token_id, + "project_id": proj["_id"], + "project_name": proj["name"], + "username": user_content["username"], + "user_id": user_content["_id"], + "admin": token_admin, + "roles": roles_list, + } + + self.token_cache[token_id] = new_token + self.db.create("tokens", new_token) + return deepcopy(new_token) + + def get_role_list(self, filter_q={}): """ Get role list. :return: returns the list of roles. """ - try: - role_list = self.db.get_list("roles") - roles = [{"name": role["name"], "_id": role["_id"]} for role in role_list] # if role.name != "service" ? - return roles - except Exception: - raise AuthException("Error during role listing using internal backend", http_code=HTTPStatus.UNAUTHORIZED) + return self.db.get_list("roles", filter_q) - def create_role(self, role): + def create_role(self, role_info): """ Create a role. - :param role: role name. + :param role_info: full role info. + :return: returns the role id. :raises AuthconnOperationException: if role creation failed. """ - # try: # TODO: Check that role name does not exist ? - return str(uuid4()) - # except Exception: - # raise AuthconnOperationException("Error during role creation using internal backend") - # except Conflict as ex: - # self.logger.info("Duplicate entry: %s", str(ex)) + rid = str(uuid4()) + role_info["_id"] = rid + rid = self.db.create("roles", role_info) + return rid def delete_role(self, role_id): """ @@ -292,8 +256,162 @@ class AuthconnInternal(Authconn): :param role_id: role identifier. :raises AuthconnOperationException: if role deletion failed. """ - # try: - # TODO: Check that role exists ? + return self.db.del_one("roles", {"_id": role_id}) + + def update_role(self, role_info): + """ + Update a role. + + :param role_info: full role info. + :return: returns the role name and id. + :raises AuthconnOperationException: if user creation failed. + """ + rid = role_info["_id"] + self.db.set_one("roles", {"_id": rid}, role_info) # CONFIRM + return {"_id": rid, "name": role_info["name"]} + + def create_user(self, user_info): + """ + Create a user. + + :param user_info: full user info. + :return: returns the username and id of the user. + """ + BaseTopic.format_on_new(user_info, make_public=False) + salt = uuid4().hex + user_info["_admin"]["salt"] = salt + if "password" in user_info: + user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest() + # "projects" are not stored any more + if "projects" in user_info: + del user_info["projects"] + self.db.create("users", user_info) + return {"username": user_info["username"], "_id": user_info["_id"]} + + def update_user(self, user_info): + """ + Change the user name and/or password. + + :param user_info: user info modifications + """ + uid = user_info["_id"] + user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid}) + BaseTopic.format_on_edit(user_data, user_info) + # User Name + usnm = user_info.get("username") + if usnm: + user_data["username"] = usnm + # If password is given and is not already encripted + pswd = user_info.get("password") + if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)): # TODO: Improve check? + salt = uuid4().hex + if "_admin" not in user_data: + user_data["_admin"] = {} + user_data["_admin"]["salt"] = salt + user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest() + # Project-Role Mappings + # TODO: Check that user_info NEVER includes "project_role_mappings" + if "project_role_mappings" not in user_data: + user_data["project_role_mappings"] = [] + for prm in user_info.get("add_project_role_mappings", []): + user_data["project_role_mappings"].append(prm) + for prm in user_info.get("remove_project_role_mappings", []): + for pidf in ["project", "project_name"]: + for ridf in ["role", "role_name"]: + try: + user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]}) + except KeyError: + pass + except ValueError: + pass + self.db.set_one("users", {BaseTopic.id_field("users", uid): uid}, user_data) # CONFIRM + + def delete_user(self, user_id): + """ + Delete user. + + :param user_id: user identifier. + :raises AuthconnOperationException: if user deletion failed. + """ + self.db.del_one("users", {"_id": user_id}) return True - # except Exception: - # raise AuthconnOperationException("Error during role deletion using internal backend") + + def get_user_list(self, filter_q=None): + """ + Get user list. + + :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id + :return: returns a list of users. + """ + filt = filter_q or {} + if "name" in filt: + filt["username"] = filt["name"] + del filt["name"] + users = self.db.get_list("users", filt) + for user in users: + projects = [] + projs_with_roles = [] + prms = user.get("project_role_mappings", []) + for prm in prms: + if prm["project"] not in projects: + projects.append(prm["project"]) + for project in projects: + roles = [] + roles_for_proj = [] + for prm in prms: + if prm["project"] == project and prm["role"] not in roles: + role = prm["role"] + roles.append(role) + rl = self.db.get_one("roles", {BaseTopic.id_field("roles", role): role}) + roles_for_proj.append({"name": rl["name"], "_id": rl["_id"], "id": rl["_id"]}) + try: + pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project}) + projs_with_roles.append({"name": pr["name"], "_id": pr["_id"], "id": pr["_id"], + "roles": roles_for_proj}) + except Exception as e: + self.logger.exception("Error during user listing using internal backend: {}".format(e)) + user["projects"] = projs_with_roles + if "project_role_mappings" in user: + del user["project_role_mappings"] + return users + + def get_project_list(self, filter_q={}): + """ + Get role list. + + :return: returns the list of projects. + """ + return self.db.get_list("projects", filter_q) + + def create_project(self, project_info): + """ + Create a project. + + :param project: full project info. + :return: the internal id of the created project + :raises AuthconnOperationException: if project creation failed. + """ + pid = self.db.create("projects", project_info) + return pid + + def delete_project(self, project_id): + """ + Delete a project. + + :param project_id: project identifier. + :raises AuthconnOperationException: if project deletion failed. + """ + filter_q = {BaseTopic.id_field("projects", project_id): project_id} + r = self.db.del_one("projects", filter_q) + return r + + def update_project(self, project_id, project_info): + """ + Change the name of a project + + :param project_id: project to be changed + :param project_info: full project info + :return: None + :raises AuthconnOperationException: if project update failed. + """ + self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info) diff --git a/osm_nbi/authconn_keystone.py b/osm_nbi/authconn_keystone.py index 9f5e02c..3aedfab 100644 --- a/osm_nbi/authconn_keystone.py +++ b/osm_nbi/authconn_keystone.py @@ -44,8 +44,8 @@ from validation import is_valid_uuid class AuthconnKeystone(Authconn): - def __init__(self, config): - Authconn.__init__(self, config) + def __init__(self, config, db, token_cache): + Authconn.__init__(self, config, db, token_cache) self.logger = logging.getLogger("nbi.authenticator.keystone") @@ -157,35 +157,6 @@ class AuthconnKeystone(Authconn): raise AuthException("Error during user authentication using Keystone: {}".format(e), http_code=HTTPStatus.UNAUTHORIZED) - # def authenticate_with_token(self, token, project=None): - # """ - # Authenticate a user using a token. Can be used to revalidate the token - # or to get a scoped token. - # - # :param token: a valid token. - # :param project: (optional) project for a scoped token. - # :return: return a revalidated token, scoped if a project was passed or - # the previous token was already scoped. - # """ - # try: - # token_info = self.keystone.tokens.validate(token=token) - # projects = self.keystone.projects.list(user=token_info["user"]["id"]) - # project_names = [project.name for project in projects] - # - # new_token = self.keystone.get_raw_token_from_identity_service( - # auth_url=self.auth_url, - # token=token, - # project_name=project, - # project_id=None, - # user_domain_name=self.user_domain_name, - # project_domain_name=self.project_domain_name) - # - # return new_token["auth_token"], project_names - # except ClientException as e: - # # self.logger.exception("Error during user authentication using keystone. Method: bearer: {}".format(e)) - # raise AuthException("Error during user authentication using Keystone: {}".format(e), - # http_code=HTTPStatus.UNAUTHORIZED) - def validate_token(self, token): """ Check if the token is valid. @@ -238,55 +209,20 @@ class AuthconnKeystone(Authconn): raise AuthException("Error during token revocation using Keystone: {}".format(e), http_code=HTTPStatus.UNAUTHORIZED) - def get_user_project_list(self, token): - """ - Get all the projects associated with a user. - - :param token: valid token - :return: list of projects - """ - try: - token_info = self.keystone.tokens.validate(token=token) - projects = self.keystone.projects.list(user=token_info["user"]["id"]) - project_names = [project.name for project in projects] - - return project_names - except ClientException as e: - # self.logger.exception("Error during user project listing using keystone: {}".format(e)) - raise AuthException("Error during user project listing using Keystone: {}".format(e), - http_code=HTTPStatus.UNAUTHORIZED) - - def get_user_role_list(self, token): - """ - Get role list for a scoped project. - - :param token: scoped token. - :return: returns the list of roles for the user in that project. If - the token is unscoped it returns None. - """ - try: - token_info = self.keystone.tokens.validate(token=token) - 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 as e: - # self.logger.exception("Error during user role listing using keystone: {}".format(e)) - raise AuthException("Error during user role listing using Keystone: {}".format(e), - http_code=HTTPStatus.UNAUTHORIZED) - - def create_user(self, user, password): + def create_user(self, user_info): """ Create a user. - :param user: username. - :param password: password. + :param user_info: full user info. :raises AuthconnOperationException: if user creation failed. :return: returns the id of the user in keystone. """ try: - new_user = self.keystone.users.create(user, password=password, domain=self.user_domain_name) + new_user = self.keystone.users.create(user_info["username"], password=user_info["password"], + domain=self.user_domain_name, _admin=user_info["_admin"]) + if "project_role_mappings" in user_info.keys(): + for mapping in user_info["project_role_mappings"]: + self.assign_role_to_user(new_user.id, mapping["project"], mapping["role"]) return {"username": new_user.name, "_id": new_user.id} except Conflict as e: # self.logger.exception("Error during user creation using keystone: {}".format(e)) @@ -295,28 +231,34 @@ class AuthconnKeystone(Authconn): # self.logger.exception("Error during user creation using keystone: {}".format(e)) raise AuthconnOperationException("Error during user creation using Keystone: {}".format(e)) - def update_user(self, user, new_name=None, new_password=None): + def update_user(self, user_info): """ Change the user name and/or password. - :param user: username or user_id - :param new_name: new name - :param new_password: new password. + :param user_info: user info modifications :raises AuthconnOperationException: if change failed. """ try: + user = user_info.get("_id") or user_info.get("username") if is_valid_uuid(user): - user_id = user + user_obj_list = [self.keystone.users.get(user)] else: user_obj_list = self.keystone.users.list(name=user) - if not user_obj_list: - raise AuthconnNotFoundException("User '{}' not found".format(user)) - user_id = user_obj_list[0].id - - self.keystone.users.update(user_id, password=new_password, name=new_name) + if not user_obj_list: + raise AuthconnNotFoundException("User '{}' not found".format(user)) + user_obj = user_obj_list[0] + user_id = user_obj.id + if user_info.get("password") or user_info.get("username") \ + or user_info.get("add_project_role_mappings") or user_info.get("remove_project_role_mappings"): + self.keystone.users.update(user_id, password=user_info.get("password"), name=user_info.get("username"), + _admin={"created": user_obj._admin["created"], "modified": time.time()}) + for mapping in user_info.get("remove_project_role_mappings", []): + self.remove_role_from_user(user_id, mapping["project"], mapping["role"]) + for mapping in user_info.get("add_project_role_mappings", []): + self.assign_role_to_user(user_id, mapping["project"], mapping["role"]) except ClientException as e: # self.logger.exception("Error during user password/name update using keystone: {}".format(e)) - raise AuthconnOperationException("Error during user password/name update using Keystone: {}".format(e)) + raise AuthconnOperationException("Error during user update using Keystone: {}".format(e)) def delete_user(self, user_id): """ @@ -326,14 +268,9 @@ class AuthconnKeystone(Authconn): :raises AuthconnOperationException: if user deletion failed. """ try: - # users = self.keystone.users.list() - # user_obj = [user for user in users if user.id == user_id][0] - # result, _ = self.keystone.users.delete(user_obj) - result, detail = self.keystone.users.delete(user_id) if result.status_code != 204: raise ClientException("error {} {}".format(result.status_code, detail)) - return True except ClientException as e: # self.logger.exception("Error during user deletion using keystone: {}".format(e)) @@ -354,7 +291,8 @@ class AuthconnKeystone(Authconn): users = [{ "username": user.name, "_id": user.id, - "id": user.id + "id": user.id, + "_admin": user.to_dict().get("_admin", {}) # TODO: REVISE } for user in users if user.name != self.admin_username] if filter_q and filter_q.get("_id"): @@ -399,7 +337,9 @@ class AuthconnKeystone(Authconn): roles = [{ "name": role.name, - "_id": role.id + "_id": role.id, + "_admin": role.to_dict().get("_admin", {}), + "permissions": role.to_dict().get("permissions", {}) } for role in roles_list if role.name != "service"] if filter_q and filter_q.get("_id"): @@ -411,15 +351,16 @@ class AuthconnKeystone(Authconn): raise AuthException("Error during user role listing using Keystone: {}".format(e), http_code=HTTPStatus.UNAUTHORIZED) - def create_role(self, role): + def create_role(self, role_info): """ Create a role. - :param role: role name. + :param role_info: full role info. :raises AuthconnOperationException: if role creation failed. """ try: - result = self.keystone.roles.create(role) + result = self.keystone.roles.create(role_info["name"], permissions=role_info.get("permissions"), + _admin=role_info.get("_admin")) return result.id except Conflict as ex: raise AuthconnConflictException(str(ex)) @@ -445,22 +386,21 @@ class AuthconnKeystone(Authconn): # self.logger.exception("Error during role deletion using keystone: {}".format(e)) raise AuthconnOperationException("Error during role deletion using Keystone: {}".format(e)) - def update_role(self, role, new_name): + def update_role(self, role_info): """ Change the name of a role - :param role: role name or id to be changed - :param new_name: new name + :param role_info: full role info :return: None """ try: - if is_valid_uuid(role): - role_id = role - else: - role_obj_list = self.keystone.roles.list(name=role) + rid = role_info["_id"] + if not is_valid_uuid(rid): # Is this required? + role_obj_list = self.keystone.roles.list(name=rid) if not role_obj_list: - raise AuthconnNotFoundException("Role '{}' not found".format(role)) - role_id = role_obj_list[0].id - self.keystone.roles.update(role_id, name=new_name) + raise AuthconnNotFoundException("Role '{}' not found".format(rid)) + rid = role_obj_list[0].id + self.keystone.roles.update(rid, name=role_info["name"], permissions=role_info.get("permissions"), + _admin=role_info.get("_admin")) except ClientException as e: # self.logger.exception("Error during role update using keystone: {}".format(e)) raise AuthconnOperationException("Error during role updating using Keystone: {}".format(e)) @@ -480,7 +420,8 @@ class AuthconnKeystone(Authconn): projects = [{ "name": project.name, - "_id": project.id + "_id": project.id, + "_admin": project.to_dict().get("_admin", {}) # TODO: REVISE } for project in projects] if filter_q and filter_q.get("_id"): @@ -493,16 +434,17 @@ class AuthconnKeystone(Authconn): raise AuthException("Error during user project listing using Keystone: {}".format(e), http_code=HTTPStatus.UNAUTHORIZED) - def create_project(self, project): + def create_project(self, project_info): """ Create a project. - :param project: project name. + :param project_info: full project info. :return: the internal id of the created project :raises AuthconnOperationException: if project creation failed. """ try: - result = self.keystone.projects.create(project, self.project_domain_name) + result = self.keystone.projects.create(project_info["name"], self.project_domain_name, + _admin=project_info["_admin"]) return result.id except ClientException as e: # self.logger.exception("Error during project creation using keystone: {}".format(e)) @@ -529,18 +471,18 @@ class AuthconnKeystone(Authconn): # self.logger.exception("Error during project deletion using keystone: {}".format(e)) raise AuthconnOperationException("Error during project deletion using Keystone: {}".format(e)) - def update_project(self, project_id, new_name): + def update_project(self, project_id, project_info): """ Change the name of a project :param project_id: project to be changed - :param new_name: new name + :param project_info: full project info :return: None """ try: - self.keystone.projects.update(project_id, name=new_name) + self.keystone.projects.update(project_id, name=project_info["name"], _admin=project_info["_admin"]) except ClientException as e: # self.logger.exception("Error during project update using keystone: {}".format(e)) - raise AuthconnOperationException("Error during project deletion using Keystone: {}".format(e)) + raise AuthconnOperationException("Error during project update using Keystone: {}".format(e)) def assign_role_to_user(self, user, project, role): """ diff --git a/osm_nbi/base_topic.py b/osm_nbi/base_topic.py index 20d54bb..4fb84a5 100644 --- a/osm_nbi/base_topic.py +++ b/osm_nbi/base_topic.py @@ -61,8 +61,7 @@ class BaseTopic: alt_id_field = { "projects": "name", "users": "username", - "roles": "name", - "roles_operations": "name" + "roles": "name" } def __init__(self, db, fs, msg): diff --git a/osm_nbi/engine.py b/osm_nbi/engine.py index 1f7f0f0..ccfd7d3 100644 --- a/osm_nbi/engine.py +++ b/osm_nbi/engine.py @@ -24,7 +24,7 @@ from http import HTTPStatus from authconn_keystone import AuthconnKeystone from authconn_internal import AuthconnInternal from base_topic import EngineException, versiontuple -from admin_topics import UserTopic, ProjectTopic, VimAccountTopic, WimAccountTopic, SdnTopic +from admin_topics import VimAccountTopic, WimAccountTopic, SdnTopic from admin_topics import UserTopicAuth, ProjectTopicAuth, RoleTopicAuth from descriptor_topics import VnfdTopic, NsdTopic, PduTopic, NstTopic from instance_topics import NsrTopic, VnfrTopic, NsLcmOpTopic, NsiTopic, NsiLcmOpTopic @@ -49,8 +49,8 @@ class Engine(object): "vim_accounts": VimAccountTopic, "wim_accounts": WimAccountTopic, "sdns": SdnTopic, - "users": UserTopic, - "projects": ProjectTopic, + "users": UserTopicAuth, # Valid for both internal and keystone authentication backends + "projects": ProjectTopicAuth, # Valid for both internal and keystone authentication backends "roles": RoleTopicAuth, # Valid for both internal and keystone authentication backends "nsis": NsiTopic, "nsilcmops": NsiLcmOpTopic @@ -118,9 +118,9 @@ class Engine(object): config["message"]["driver"])) if not self.auth: if config["authentication"]["backend"] == "keystone": - self.auth = AuthconnKeystone(config["authentication"]) + self.auth = AuthconnKeystone(config["authentication"], self.db, None) else: - self.auth = AuthconnInternal(config["authentication"], self.db, dict()) # TO BE CONFIRMED + self.auth = AuthconnInternal(config["authentication"], self.db, dict()) if not self.operations: if "resources_to_operations" in config["rbac"]: resources_to_operations_file = config["rbac"]["resources_to_operations"] @@ -145,11 +145,6 @@ class Engine(object): if value not in self.operations: self.operations += [value] - if config["authentication"]["backend"] == "keystone": - self.map_from_topic_to_class["users"] = UserTopicAuth - self.map_from_topic_to_class["projects"] = ProjectTopicAuth - self.map_from_topic_to_class["roles"] = RoleTopicAuth - self.write_lock = Lock() # create one class per topic for topic, topic_class in self.map_from_topic_to_class.items(): @@ -267,7 +262,7 @@ class Engine(object): :param session: contains the used login username and working project :param topic: it can be: users, projects, vnfds, nsds, ... :param _id: server id of the item - :return: operation id (None if there is not operation), raise exception if error or not found. + :return: dictionary with deleted item _id. It raises exception if not found. """ if topic not in self.map_topic: raise EngineException("Unknown topic {}!!!".format(topic), HTTPStatus.INTERNAL_SERVER_ERROR) @@ -282,54 +277,13 @@ class Engine(object): :param _id: identifier to be updated :param indata: data to be inserted :param kwargs: used to override the indata descriptor - :return: operation id (None if there is not operation), raise exception if error or not found. + :return: dictionary with edited item _id, raise exception if not found. """ if topic not in self.map_topic: raise EngineException("Unknown topic {}!!!".format(topic), HTTPStatus.INTERNAL_SERVER_ERROR) with self.write_lock: return self.map_topic[topic].edit(session, _id, indata, kwargs) - def create_admin_project(self): - """ - Creates a new project 'admin' into database if database is empty. Useful for initialization. - :return: _id identity of the inserted data, or None - """ - - projects = self.db.get_one("projects", fail_on_empty=False, fail_on_more=False) - if projects: - return None - project_desc = {"name": "admin"} - fake_session = {"project_id": "admin", "username": "admin", "admin": True, "force": True, "public": None} - rollback_list = [] - _id = self.map_topic["projects"].new(rollback_list, fake_session, project_desc) - return _id - - def create_admin_user(self): - """ - 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) - if users: - return None - user_desc = {"username": "admin", "password": "admin", "projects": ["admin"]} - fake_session = {"project_id": "admin", "username": "admin", "admin": True, "force": True, "public": None} - rollback_list = [] - _id = self.map_topic["users"].new(rollback_list, fake_session, user_desc) - return _id - - def create_admin(self): - """ - Creates new 'admin' user and project into database if database is empty. Useful for initialization. - :return: _id identity of the inserted data, or None - """ - project_id = self.create_admin_project() - user_id = self.create_admin_user() - if project_id or user_id: - return {'project_id': project_id, 'user_id': user_id} - else: - return None - def upgrade_db(self, current_version, target_version): if target_version not in self.map_target_version_to_int.keys(): raise EngineException("Cannot upgrade to version '{}' with this version of code".format(target_version), @@ -357,9 +311,9 @@ class Engine(object): current_version = "1.0" if current_version in ("1.0", "1.1") and target_version_int >= self.map_target_version_to_int["1.2"]: - table = "roles_operations" if self.config['authentication']['backend'] == "keystone" else "roles" - self.db.del_list(table) - + if self.config['authentication']['backend'] == "internal": + self.db.del_list("roles") + version_data = { "_id": "version", "version_int": 1002, @@ -391,8 +345,4 @@ class Engine(object): if db_version != target_version: self.upgrade_db(db_version, target_version) - # create admin project&user if they don't exist - if self.config['authentication']['backend'] == 'internal' or not self.auth: - self.create_admin() - return diff --git a/osm_nbi/nbi.py b/osm_nbi/nbi.py index 1b3a6ce..33147b7 100644 --- a/osm_nbi/nbi.py +++ b/osm_nbi/nbi.py @@ -24,7 +24,7 @@ import logging.handlers import getopt import sys -from authconn import AuthException +from authconn import AuthException, AuthconnException from auth import Authenticator from engine import Engine, EngineException from subscriptions import SubscriptionThread @@ -598,7 +598,7 @@ class Server(object): try: if cherrypy.request.method == "GET": token_info = self.authenticator.authorize() - outdata = "Index page" + outdata = token_info # Home page else: raise cherrypy.HTTPError(HTTPStatus.METHOD_NOT_ALLOWED.value, "Method {} not allowed for tokens".format(cherrypy.request.method)) @@ -1110,7 +1110,7 @@ class Server(object): return self._format_out(outdata, token_info, _format) except Exception as e: if isinstance(e, (NbiException, EngineException, DbException, FsException, MsgException, AuthException, - ValidationError)): + ValidationError, AuthconnException)): http_code_value = cherrypy.response.status = e.http_code.value http_code_name = e.http_code.name cherrypy.log("Exception {}".format(e)) @@ -1255,7 +1255,10 @@ def _start_service(): try: with open("{}/version".format(engine_config["/static"]['tools.staticdir.dir'])) as version_file: version_data = version_file.read() - cherrypy.log.error("Starting OSM NBI Version: {}".format(version_data.replace("\n", " "))) + version = version_data.replace("\n", " ") + backend = engine_config["authentication"]["backend"] + cherrypy.log.error("Starting OSM NBI Version {} with {} authentication backend" + .format(version, backend)) except Exception: pass -- 2.17.1