From: Eduardo Sousa Date: Wed, 8 May 2019 01:35:47 +0000 (+0100) Subject: Adding User, Projects and Roles (Keystone) to NBI API X-Git-Tag: v6.0.0~51 X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=commitdiff_plain;h=5c01e193a17fcf730406e39fe7d019e1dee5c64d;hp=09e6542ceae8ee08ee7cc372f7344a77bc1f5304 Adding User, Projects and Roles (Keystone) to NBI API Change-Id: Id8c65e5d076fefc329340ca195c268004ecb4a4e Signed-off-by: Eduardo Sousa --- diff --git a/.gitignore-common b/.gitignore-common index bfe5786..157e9ef 100644 --- a/.gitignore-common +++ b/.gitignore-common @@ -46,3 +46,6 @@ build dist *.egg-info .eggs + +#vscode +.vscode \ No newline at end of file diff --git a/osm_nbi/admin_topics.py b/osm_nbi/admin_topics.py index afa50d8..ea79cd4 100644 --- a/osm_nbi/admin_topics.py +++ b/osm_nbi/admin_topics.py @@ -17,9 +17,12 @@ from uuid import uuid4 from hashlib import sha256 from http import HTTPStatus +from time import time from validation import user_new_schema, user_edit_schema, project_new_schema, project_edit_schema from validation import vim_account_new_schema, vim_account_edit_schema, sdn_new_schema, sdn_edit_schema -from validation import wim_account_new_schema, wim_account_edit_schema +from validation import wim_account_new_schema, wim_account_edit_schema, roles_new_schema, roles_edit_schema +from validation import validate_input +from validation import ValidationError from base_topic import BaseTopic, EngineException __author__ = "Alfonso Tierno " @@ -313,3 +316,660 @@ class SdnTopic(BaseTopic): self.db.set_one("sdns", {"_id": _id}, {"_admin.to_delete": True}) # TODO change status self._send_msg("delete", {"_id": _id}) return v # TODO indicate an offline operation to return 202 ACCEPTED + + +class UserTopicAuth(UserTopic): + topic = "users" + topic_msg = "users" + schema_new = user_new_schema + schema_edit = user_edit_schema + + def __init__(self, db, fs, msg, auth): + UserTopic.__init__(self, db, fs, msg) + self.auth = auth + + def check_conflict_on_new(self, session, indata, force=False): + """ + Check that the data to be inserted is valid + + :param session: contains "username", if user is "admin" and the working "project_id" + :param indata: data to be inserted + :param force: boolean. With force it is more tolerant + :return: None or raises EngineException + """ + username = indata.get("username") + user_list = list(map(lambda x: x["username"], self.auth.get_user_list())) + + if username in user_list: + raise EngineException("username '{}' exists".format(username), HTTPStatus.CONFLICT) + + def check_conflict_on_edit(self, session, final_content, edit_content, _id, force=False): + """ + Check that the data to be edited/uploaded is valid + + :param session: contains "username", if user is "admin" and the working "project_id" + :param final_content: data once modified + :param edit_content: incremental data that contains the modifications to apply + :param _id: internal _id + :param force: boolean. With force it is more tolerant + :return: None or raises EngineException + """ + users = self.auth.get_user_list() + admin_user = [user for user in users if user["name"] == "admin"][0] + + if _id == admin_user["_id"] and edit_content["project_role_mappings"]: + elem = { + "project": "admin", + "role": "system_admin" + } + if elem not in edit_content: + raise EngineException("You cannot remove system_admin role from admin user", + http_code=HTTPStatus.FORBIDDEN) + + def check_conflict_on_del(self, session, _id, force=False): + """ + Check if deletion can be done because of dependencies if it is not force. To override + + :param session: contains "username", if user is "admin" and the working "project_id" + :param _id: internal _id + :param force: Avoid this checking + :return: None if ok or raises EngineException with the conflict + """ + if _id == session["username"]: + raise EngineException("You cannot delete your own 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"] + + def new(self, rollback, session, indata=None, kwargs=None, headers=None, force=False, make_public=False): + """ + Creates a new entry into the authentication backend. + + NOTE: Overrides BaseTopic functionality because it doesn't require access to database. + + :param rollback: list to append created items at database in case a rollback may to be done + :param session: contains the used login username and working project + :param indata: data to be inserted + :param kwargs: used to override the indata descriptor + :param headers: http request headers + :param force: If True avoid some dependence checks + :param make_public: Make the created item public to all projects + :return: _id: identity of the inserted data. + """ + try: + content = BaseTopic._remove_envelop(indata) + + # Override descriptor with query string kwargs + BaseTopic._update_input_with_kwargs(content, kwargs) + content = self._validate_input_new(content, force) + self.check_conflict_on_new(session, content, force=force) + self.format_on_new(content, project_id=session["project_id"], make_public=make_public) + _id = self.auth.create_user(content["username"], content["password"]) + rollback.append({"topic": self.topic, "_id": _id}) + del content["password"] + # self._send_msg("create", content) + return _id + except ValidationError as e: + raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) + + def show(self, session, _id): + """ + Get complete information on an topic + + :param session: contains the used login username and working project + :param _id: server internal id + :return: dictionary, raise exception if not found. + """ + users = [user for user in self.auth.get_user_list() if user["_id"] == _id] + + if len(users) == 1: + return users[0] + elif len(users) > 1: + raise EngineException("Too many users found", HTTPStatus.CONFLICT) + else: + raise EngineException("User not found", HTTPStatus.NOT_FOUND) + + def edit(self, session, _id, indata=None, kwargs=None, force=False, content=None): + """ + Updates an user entry. + + :param session: contains the used login username and working project + :param _id: + :param indata: data to be inserted + :param kwargs: used to override the indata descriptor + :param force: If True avoid some dependence checks + :param content: + :return: _id: identity of the inserted data. + """ + indata = self._remove_envelop(indata) + + # Override descriptor with query string kwargs + if kwargs: + BaseTopic._update_input_with_kwargs(indata, kwargs) + try: + indata = self._validate_input_edit(indata, force=force) + + if not content: + content = self.show(session, _id) + self.check_conflict_on_edit(session, content, indata, _id=_id, force=force) + self.format_on_edit(content, indata) + + if "password" in content: + self.auth.change_password(content["name"], content["password"]) + else: + users = self.auth.get_user_list() + user = [user for user in users if user["_id"] == content["_id"]][0] + original_mapping = [] + edit_mapping = content["project_role_mappings"] + + for project in user["projects"]: + for role in project["roles"]: + original_mapping += { + "project": project["name"], + "role": role["name"] + } + + mappings_to_remove = [mapping for mapping in original_mapping + if mapping not in edit_mapping] + + mappings_to_add = [mapping for mapping in edit_mapping + if mapping not in original_mapping] + + for mapping in mappings_to_remove: + self.auth.remove_role_from_user( + user["name"], + mapping["project"], + mapping["role"] + ) + + for mapping in mappings_to_add: + self.auth.assign_role_to_user( + user["name"], + mapping["project"], + mapping["role"] + ) + + return content["_id"] + except ValidationError as e: + raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) + + def list(self, session, filter_q=None): + """ + Get a list of the topic that matches a filter + :param session: contains the used login username and working 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_user_list() + + def delete(self, session, _id, force=False, dry_run=False): + """ + Delete item by its internal _id + + :param session: contains the used login username, working project, and admin rights + :param _id: server internal id + :param force: indicates if deletion must be forced in case of conflict + :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, force) + if not dry_run: + v = self.auth.delete_user(_id) + return v + return None + + +class ProjectTopicAuth(ProjectTopic): + topic = "projects" + topic_msg = "projects" + schema_new = project_new_schema + schema_edit = project_edit_schema + + def __init__(self, db, fs, msg, auth): + ProjectTopic.__init__(self, db, fs, msg) + self.auth = auth + + def check_conflict_on_new(self, session, indata, force=False): + """ + Check that the data to be inserted is valid + + :param session: contains "username", if user is "admin" and the working "project_id" + :param indata: data to be inserted + :param force: boolean. With force it is more tolerant + :return: None or raises EngineException + """ + project = indata.get("name") + project_list = list(map(lambda x: x["name"], self.auth.get_project_list())) + + if project in project_list: + raise EngineException("project '{}' exists".format(project), HTTPStatus.CONFLICT) + + def check_conflict_on_del(self, session, _id, force=False): + """ + Check if deletion can be done because of dependencies if it is not force. To override + + :param session: contains "username", if user is "admin" and the working "project_id" + :param _id: internal _id + :param force: Avoid this checking + :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"] == session["project_id"]][0] + + if _id == current_project["_id"]: + raise EngineException("You cannot delete your own project", http_code=HTTPStatus.CONFLICT) + + def new(self, rollback, session, indata=None, kwargs=None, headers=None, force=False, make_public=False): + """ + Creates a new entry into the authentication backend. + + NOTE: Overrides BaseTopic functionality because it doesn't require access to database. + + :param rollback: list to append created items at database in case a rollback may to be done + :param session: contains the used login username and working project + :param indata: data to be inserted + :param kwargs: used to override the indata descriptor + :param headers: http request headers + :param force: If True avoid some dependence checks + :param make_public: Make the created item public to all projects + :return: _id: identity of the inserted data. + """ + try: + content = BaseTopic._remove_envelop(indata) + + # Override descriptor with query string kwargs + BaseTopic._update_input_with_kwargs(content, kwargs) + content = self._validate_input_new(content, force) + self.check_conflict_on_new(session, content, force=force) + self.format_on_new(content, project_id=session["project_id"], make_public=make_public) + _id = self.auth.create_project(content["name"]) + rollback.append({"topic": self.topic, "_id": _id}) + # self._send_msg("create", content) + return _id + except ValidationError as e: + raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) + + def show(self, session, _id): + """ + Get complete information on an topic + + :param session: contains the used login username and working project + :param _id: server internal id + :return: dictionary, raise exception if not found. + """ + projects = [project for project in self.auth.get_project_list() if project["_id"] == _id] + + if len(projects) == 1: + return projects[0] + elif len(projects) > 1: + raise EngineException("Too many projects found", HTTPStatus.CONFLICT) + else: + raise EngineException("Project not found", HTTPStatus.NOT_FOUND) + + def list(self, session, filter_q=None): + """ + Get a list of the topic that matches a filter + + :param session: contains the used login username and working 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_project_list() + + def delete(self, session, _id, force=False, dry_run=False): + """ + Delete item by its internal _id + + :param session: contains the used login username, working project, and admin rights + :param _id: server internal id + :param force: indicates if deletion must be forced in case of conflict + :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, force) + if not dry_run: + v = self.auth.delete_project(_id) + return v + return None + + +class RoleTopicAuth(BaseTopic): + topic = "roles_operations" + topic_msg = "roles" + schema_new = roles_new_schema + schema_edit = roles_edit_schema + + def __init__(self, db, fs, msg, auth, ops): + BaseTopic.__init__(self, db, fs, msg) + self.auth = auth + self.operations = ops + + @staticmethod + def validate_role_definition(operations, role_definitions): + """ + Validates the role definition against the operations defined in + the resources to operations files. + + :param operations: operations list + :param role_definitions: role definition to test + :return: None if ok, raises ValidationError exception on error + """ + for role_def in role_definitions.keys(): + if role_def == ".": + continue + if role_def[-1] == ".": + raise ValidationError("Operation cannot end with \".\"") + + role_def_matches = [op for op in operations if op.starswith(role_def)] + + if len(role_def_matches) == 0: + raise ValidationError("No matching operation found.") + + def _validate_input_new(self, input, force=False): + """ + Validates input user content for a new entry. + + :param input: user input content for the new topic + :param force: may be used for being more tolerant + :return: The same input content, or a changed version of it. + """ + if self.schema_new: + validate_input(input, self.schema_new) + if "definition" in input and input["definition"]: + self.validate_role_definition(self.operations, input["definition"]) + return input + + def _validate_input_edit(self, input, force=False): + """ + Validates input user content for updating an entry. + + :param input: user input content for the new topic + :param force: may be used for being more tolerant + :return: The same input content, or a changed version of it. + """ + if self.schema_edit: + validate_input(input, self.schema_edit) + if "definition" in input and input["definition"]: + self.validate_role_definition(self.operations, input["definition"]) + return input + + def check_conflict_on_new(self, session, indata, force=False): + """ + Check that the data to be inserted is valid + + :param session: contains "username", if user is "admin" and the working "project_id" + :param indata: data to be inserted + :param force: boolean. With force it is more tolerant + :return: None or raises EngineException + """ + role = indata.get("name") + role_list = list(map(lambda x: x["name"], self.auth.get_role_list())) + + if role in role_list: + raise EngineException("role '{}' exists".format(role), HTTPStatus.CONFLICT) + + def check_conflict_on_edit(self, session, final_content, edit_content, _id, force=False): + """ + Check that the data to be edited/uploaded is valid + + :param session: contains "username", if user is "admin" and the working "project_id" + :param final_content: data once modified + :param edit_content: incremental data that contains the modifications to apply + :param _id: internal _id + :param force: boolean. With force it is more tolerant + :return: None or raises EngineException + """ + roles = self.auth.get_role_list() + system_admin_role = [role for role in roles + if roles["name"] == "system_admin"][0] + + if _id == system_admin_role["_id"]: + raise EngineException("You cannot edit system_admin role", http_code=HTTPStatus.FORBIDDEN) + + def check_conflict_on_del(self, session, _id, force=False): + """ + Check if deletion can be done because of dependencies if it is not force. To override + + :param session: contains "username", if user is "admin" and the working "project_id" + :param _id: internal _id + :param force: Avoid this checking + :return: None if ok or raises EngineException with the conflict + """ + roles = self.auth.get_role_list() + system_admin_role = [role for role in roles + if roles["name"] == "system_admin"][0] + + if _id == system_admin_role["_id"]: + raise EngineException("You cannot delete system_admin role", http_code=HTTPStatus.FORBIDDEN) + + @staticmethod + def format_on_new(content, project_id=None, make_public=False): + """ + Modifies content descriptor to include _admin + + :param content: descriptor to be modified + :param project_id: if included, it add project read/write permissions + :param make_public: if included it is generated as public for reading. + :return: None, but content is modified + """ + now = time() + if "_admin" not in content: + content["_admin"] = {} + if not content["_admin"].get("created"): + content["_admin"]["created"] = now + content["_admin"]["modified"] = now + content["root"] = False + + # Saving the role definition + if "definition" in content and content["definition"]: + for role_def, value in content["definition"].items(): + if role_def == ".": + content["root"] = value + else: + content[role_def.replace(".", ":")] = value + + # Cleaning undesired values + if "definition" in content: + del content["definition"] + + @staticmethod + def format_on_edit(final_content, edit_content): + """ + Modifies final_content descriptor to include the modified date. + + :param final_content: final descriptor generated + :param edit_content: alterations to be include + :return: None, but final_content is modified + """ + final_content["_admin"]["modified"] = time() + + ignore_fields = ["_id", "name", "_admin"] + delete_keys = [key for key in final_content.keys() if key not in ignore_fields] + + for key in delete_keys: + del final_content[key] + + # Saving the role definition + if "definition" in edit_content and edit_content["definition"]: + for role_def, value in edit_content["definition"].items(): + if role_def == ".": + final_content["root"] = value + else: + final_content[role_def.replace(".", ":")] = value + + if "root" not in final_content: + final_content["root"] = False + + @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 + """ + ignore_fields = ["_admin", "_id", "name", "root"] + content_keys = list(content.keys()) + definition = dict(content) + + for key in content_keys: + if key in ignore_fields: + del definition[key] + if ":" not in key: + del content[key] + continue + definition[key.replace(":", ".")] = definition[key] + del definition[key] + del content[key] + + content["definition"] = definition + + def show(self, session, _id): + """ + Get complete information on an topic + + :param session: contains the used login username and working project + :param _id: server internal id + :return: dictionary, raise exception if not found. + """ + filter_db = self._get_project_filter(session, write=False, show_all=True) + filter_db["_id"] = _id + + 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 the used login username and working 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 = {} + + 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 new(self, rollback, session, indata=None, kwargs=None, headers=None, force=False, make_public=False): + """ + Creates a new entry into database. + + :param rollback: list to append created items at database in case a rollback may to be done + :param session: contains the used login username and working project + :param indata: data to be inserted + :param kwargs: used to override the indata descriptor + :param headers: http request headers + :param force: If True avoid some dependence checks + :param make_public: Make the created item public to all projects + :return: _id: identity of the inserted data. + """ + try: + content = BaseTopic._remove_envelop(indata) + + # Override descriptor with query string kwargs + BaseTopic._update_input_with_kwargs(content, kwargs) + content = self._validate_input_new(content, force) + self.check_conflict_on_new(session, content, force=force) + self.format_on_new(content, project_id=session["project_id"], make_public=make_public) + role_name = content["name"] + role = self.auth.create_role(role_name) + content["_id"] = role["_id"] + _id = self.db.create(self.topic, content) + rollback.append({"topic": self.topic, "_id": _id}) + # self._send_msg("create", content) + return _id + except ValidationError as e: + raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) + + def delete(self, session, _id, force=False, dry_run=False): + """ + Delete item by its internal _id + + :param session: contains the used login username, working project, and admin rights + :param _id: server internal id + :param force: indicates if deletion must be forced in case of conflict + :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, force) + filter_q = self._get_project_filter(session, write=True, show_all=True) + filter_q["_id"] = _id + if not dry_run: + self.auth.delete_role(_id) + v = self.db.del_one(self.topic, filter_q) + return v + return None + + def edit(self, session, _id, indata=None, kwargs=None, force=False, content=None): + """ + Updates a role entry. + + :param session: contains the used login username and working project + :param _id: + :param indata: data to be inserted + :param kwargs: used to override the indata descriptor + :param force: If True avoid some dependence checks + :param content: + :return: _id: identity of the inserted data. + """ + indata = self._remove_envelop(indata) + + # Override descriptor with query string kwargs + if kwargs: + BaseTopic._update_input_with_kwargs(indata, kwargs) + try: + indata = self._validate_input_edit(indata, force=force) + + if not content: + content = self.show(session, _id) + self.check_conflict_on_edit(session, content, indata, _id=_id, force=force) + self.format_on_edit(content, indata) + self.db.replace(self.topic, _id, content) + return id + except ValidationError as e: + raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY) diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index 18986a3..751dd90 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -25,7 +25,6 @@ Authenticator is responsible for authenticating the users, 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$" @@ -40,7 +39,7 @@ from hashlib import sha256 from http import HTTPStatus from random import choice as random_choice from time import time -from uuid import uuid4 +from os import path from authconn import AuthException from authconn_keystone import AuthconnKeystone @@ -108,8 +107,11 @@ class Authenticator: 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"): + possible_paths = ( + __file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml", + "./resources_to_operations.yml" + ) + for config_file in possible_paths: if path.isfile(config_file): self.resources_to_operations_file = config_file break @@ -119,8 +121,11 @@ class Authenticator: 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"): + possible_paths = ( + __file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml", + "./roles_to_operations.yml" + ) + for config_file in possible_paths: if path.isfile(config_file): self.roles_to_operations_file = config_file break @@ -147,6 +152,9 @@ class Authenticator: # 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 + if self.config["authentication"]["backend"] == "internal": + return + operations = [] with open(self.resources_to_operations_file, "r") as stream: resources_to_operations_yaml = yaml.load(stream) @@ -208,28 +216,29 @@ class Authenticator: now = time() operation_to_roles_item = { - "_id": str(uuid4()), "_admin": { "created": now, "modified": now, }, - "role": role_with_operations["role"], + "name": role_with_operations["role"], "root": root } for operation, value in role_ops.items(): operation_to_roles_item[operation] = value + if self.config["authentication"]["backend"] != "internal" and \ + role_with_operations["role"] != "anonymous": + keystone_id = self.backend.create_role(role_with_operations["role"]) + operation_to_roles_item["_id"] = keystone_id["_id"] + 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 = [] + ignore_fields = ["_id", "_admin", "name", "root"] 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(":")) @@ -243,17 +252,12 @@ class Authenticator: 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"]) + permissions[allowed_op].append(record["name"]) 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): @@ -402,7 +406,7 @@ class Authenticator: operation = self.resources_to_operations_mapping[key] roles_required = self.operation_to_allowed_roles[operation] - roles_allowed = self.backend.get_role_list(session["id"]) + roles_allowed = self.backend.get_user_role_list(session["id"]) if "anonymous" in roles_required: return @@ -413,6 +417,9 @@ class Authenticator: raise AuthException("Access denied: lack of permissions.") + def get_user_list(self): + return self.backend.get_user_list() + def _normalize_url(self, url, method): # Removing query strings normalized_url = url if '?' not in url else url[:url.find("?")] diff --git a/osm_nbi/authconn.py b/osm_nbi/authconn.py index 90f0096..0ffaad8 100644 --- a/osm_nbi/authconn.py +++ b/osm_nbi/authconn.py @@ -34,8 +34,8 @@ class AuthException(Exception): Authentication error. """ def __init__(self, message, http_code=HTTPStatus.UNAUTHORIZED): + super(AuthException, self).__init__(message) self.http_code = http_code - Exception.__init__(self, message) class AuthconnException(Exception): @@ -43,7 +43,7 @@ class AuthconnException(Exception): Common and base class Exception for all authconn exceptions. """ def __init__(self, message, http_code=HTTPStatus.UNAUTHORIZED): - Exception.__init__(message) + super(AuthconnException, self).__init__(message) self.http_code = http_code @@ -52,7 +52,7 @@ class AuthconnConnectionException(AuthconnException): Connectivity error with Auth backend. """ def __init__(self, message, http_code=HTTPStatus.BAD_GATEWAY): - AuthconnException.__init__(self, message, http_code) + super(AuthconnConnectionException, self).__init__(message, http_code) class AuthconnNotSupportedException(AuthconnException): @@ -60,7 +60,7 @@ class AuthconnNotSupportedException(AuthconnException): The request is not supported by the Auth backend. """ def __init__(self, message, http_code=HTTPStatus.NOT_IMPLEMENTED): - AuthconnException.__init__(self, message, http_code) + super(AuthconnNotSupportedException, self).__init__(message, http_code) class AuthconnNotImplementedException(AuthconnException): @@ -68,7 +68,7 @@ class AuthconnNotImplementedException(AuthconnException): The method is not implemented by the Auth backend. """ def __init__(self, message, http_code=HTTPStatus.NOT_IMPLEMENTED): - AuthconnException.__init__(self, message, http_code) + super(AuthconnNotImplementedException, self).__init__(message, http_code) class AuthconnOperationException(AuthconnException): @@ -76,7 +76,7 @@ class AuthconnOperationException(AuthconnException): The operation executed failed. """ def __init__(self, message, http_code=HTTPStatus.INTERNAL_SERVER_ERROR): - AuthconnException.__init__(self, message, http_code) + super(AuthconnOperationException, self).__init__(message, http_code) class Authconn: @@ -136,7 +136,7 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def get_project_list(self, token): + def get_user_project_list(self, token): """ Get all the projects associated with a user. @@ -145,7 +145,7 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def get_role_list(self, token): + def get_user_role_list(self, token): """ Get role list for a scoped project. @@ -175,15 +175,22 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def delete_user(self, user): + def delete_user(self, user_id): """ Delete user. - :param user: username. + :param user_id: user identifier. :raises AuthconnOperationException: if user deletion failed. """ raise AuthconnNotImplementedException("Should have implemented this") + def get_user_list(self): + """ + Get user list. + + :return: returns a list of users. + """ + def create_role(self, role): """ Create a role. @@ -193,15 +200,23 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def delete_role(self, role): + def delete_role(self, role_id): """ Delete a role. - :param role: role name. + :param role_id: role identifier. :raises AuthconnOperationException: if user deletion failed. """ raise AuthconnNotImplementedException("Should have implemented this") + def get_role_list(self): + """ + Get all the roles. + + :return: list of roles + """ + raise AuthconnNotImplementedException("Should have implemented this") + def create_project(self, project): """ Create a project. @@ -211,15 +226,23 @@ class Authconn: """ raise AuthconnNotImplementedException("Should have implemented this") - def delete_project(self, project): + def delete_project(self, project_id): """ Delete a project. - :param project: project name. + :param project_id: project identifier. :raises AuthconnOperationException: if project deletion failed. """ raise AuthconnNotImplementedException("Should have implemented this") + def get_project_list(self): + """ + Get all the projects. + + :return: list of projects + """ + raise AuthconnNotImplementedException("Should have implemented this") + def assign_role_to_user(self, user, project, role): """ Assigning a role to a user in a project. diff --git a/osm_nbi/authconn_keystone.py b/osm_nbi/authconn_keystone.py index 518f47f..54442c8 100644 --- a/osm_nbi/authconn_keystone.py +++ b/osm_nbi/authconn_keystone.py @@ -160,7 +160,7 @@ class AuthconnKeystone(Authconn): self.logger.exception("Error during token revocation using keystone") raise AuthException("Error during token revocation using Keystone", http_code=HTTPStatus.UNAUTHORIZED) - def get_project_list(self, token): + def get_user_project_list(self, token): """ Get all the projects associated with a user. @@ -177,7 +177,7 @@ class AuthconnKeystone(Authconn): self.logger.exception("Error during user project listing using keystone") raise AuthException("Error during user project listing using Keystone", http_code=HTTPStatus.UNAUTHORIZED) - def get_role_list(self, token): + def get_user_role_list(self, token): """ Get role list for a scoped project. @@ -203,9 +203,11 @@ class AuthconnKeystone(Authconn): :param user: username. :param password: password. :raises AuthconnOperationException: if user creation failed. + :return: returns the id of the user in keystone. """ try: - self.keystone.users.create(user, password=password, domain=self.user_domain_name) + new_user = self.keystone.users.create(user, password=password, domain=self.user_domain_name) + return {"username": new_user.name, "_id": new_user.id} except ClientException: self.logger.exception("Error during user creation using keystone") raise AuthconnOperationException("Error during user creation using Keystone") @@ -225,20 +227,81 @@ class AuthconnKeystone(Authconn): self.logger.exception("Error during user password update using keystone") raise AuthconnOperationException("Error during user password update using Keystone") - def delete_user(self, user): + def delete_user(self, user_id): """ Delete user. - :param user: username. + :param user_id: user identifier. :raises AuthconnOperationException: if user deletion failed. """ try: - user_obj = list(filter(lambda x: x.name == user, self.keystone.users.list()))[0] - self.keystone.users.delete(user_obj) + 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) + + if result.status_code != 204: + raise ClientException("User was not deleted") + + return True except ClientException: self.logger.exception("Error during user deletion using keystone") raise AuthconnOperationException("Error during user deletion using Keystone") + def get_user_list(self): + """ + Get user list. + + :return: returns a list of users. + """ + try: + users = self.keystone.users.list() + users = [{ + "username": user.name, + "_id": user.id + } for user in users if user.name != self.admin_username] + + for user in users: + projects = self.keystone.projects.list(user=user["_id"]) + projects = [{ + "name": project.name, + "_id": project.id + } for project in projects] + + for project in projects: + roles = self.keystone.roles.list(user=user["_id"], project=project["_id"]) + roles = [{ + "name": role.name, + "_id": role.id + } for role in roles] + project["roles"] = roles + + user["projects"] = projects + + return users + except ClientException: + self.logger.exception("Error during user listing using keystone") + raise AuthconnOperationException("Error during user listing using Keystone") + + def get_role_list(self): + """ + Get role list. + + :return: returns the list of roles for the user in that project. If + the token is unscoped it returns None. + """ + try: + roles_list = self.keystone.roles.list() + + roles = [{ + "name": role.name, + "_id": role.id + } for role in roles_list if role.name != "service"] + + return roles + except ClientException: + self.logger.exception("Error during user role listing using keystone") + raise AuthException("Error during user role listing using Keystone", http_code=HTTPStatus.UNAUTHORIZED) + def create_role(self, role): """ Create a role. @@ -247,27 +310,52 @@ class AuthconnKeystone(Authconn): :raises AuthconnOperationException: if role creation failed. """ try: - self.keystone.roles.create(role) + result = self.keystone.roles.create(role) + return {"name": result.name, "_id": result.id} 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") - def delete_role(self, role): + def delete_role(self, role_id): """ Delete a role. - :param role: role name. + :param role_id: role identifier. :raises AuthconnOperationException: if role deletion failed. """ try: - role_obj = list(filter(lambda x: x.name == role, self.keystone.roles.list()))[0] - self.keystone.roles.delete(role_obj) + roles = self.keystone.roles.list() + role_obj = [role for role in roles if role.id == role_id][0] + result, _ = self.keystone.roles.delete(role_obj) + + if result.status_code != 204: + raise ClientException("Role was not deleted") + + return True except ClientException: self.logger.exception("Error during role deletion using keystone") raise AuthconnOperationException("Error during role deletion using Keystone") + def get_project_list(self): + """ + Get all the projects. + + :return: list of projects + """ + try: + projects = self.keystone.projects.list() + projects = [{ + "name": project.name, + "_id": project.id + } for project in projects if project.name != self.admin_project] + + return projects + except ClientException: + self.logger.exception("Error during user project listing using keystone") + raise AuthException("Error during user project listing using Keystone", http_code=HTTPStatus.UNAUTHORIZED) + def create_project(self, project): """ Create a project. @@ -276,21 +364,28 @@ class AuthconnKeystone(Authconn): :raises AuthconnOperationException: if project creation failed. """ try: - self.keystone.project.create(project, self.project_domain_name) + result = self.keystone.projects.create(project, self.project_domain_name) + return {"name": result.name, "_id": result.id} except ClientException: self.logger.exception("Error during project creation using keystone") raise AuthconnOperationException("Error during project creation using Keystone") - def delete_project(self, project): + def delete_project(self, project_id): """ Delete a project. - :param project: project name. + :param project_id: project identifier. :raises AuthconnOperationException: if project deletion failed. """ try: - project_obj = list(filter(lambda x: x.name == project, self.keystone.projects.list()))[0] - self.keystone.project.delete(project_obj) + projects = self.keystone.projects.list() + project_obj = [project for project in projects if project.id == project_id][0] + result, _ = self.keystone.projects.delete(project_obj) + + if result.status_code != 204: + raise ClientException("Project was not deleted") + + return True except ClientException: self.logger.exception("Error during project deletion using keystone") raise AuthconnOperationException("Error during project deletion using Keystone") diff --git a/osm_nbi/engine.py b/osm_nbi/engine.py index b90f713..30af852 100644 --- a/osm_nbi/engine.py +++ b/osm_nbi/engine.py @@ -14,17 +14,21 @@ # limitations under the License. import logging +import yaml from osm_common import dbmongo, dbmemory, fslocal, msglocal, msgkafka, version as common_version from osm_common.dbbase import DbException from osm_common.fsbase import FsException from osm_common.msgbase import MsgException from http import HTTPStatus + +from authconn_keystone import AuthconnKeystone from base_topic import EngineException, versiontuple from admin_topics import UserTopic, ProjectTopic, 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 from base64 import b64encode -from os import urandom +from os import urandom, path from threading import Lock __author__ = "Alfonso Tierno " @@ -54,7 +58,9 @@ class Engine(object): self.db = None self.fs = None self.msg = None + self.auth = None self.config = None + self.operations = None self.logger = logging.getLogger("nbi.engine") self.map_topic = {} self.write_lock = None @@ -99,11 +105,48 @@ class Engine(object): else: raise EngineException("Invalid configuration param '{}' at '[message]':'driver'".format( config["message"]["driver"])) + if not self.auth: + if config["authentication"]["backend"] == "keystone": + self.auth = AuthconnKeystone(config["authentication"]) + if not self.operations: + if "resources_to_operations" in config["rbac"]: + resources_to_operations_file = config["rbac"]["resources_to_operations"] + else: + possible_paths = ( + __file__[:__file__.rfind("engine.py")] + "resources_to_operations.yml", + "./resources_to_operations.yml" + ) + for config_file in possible_paths: + if path.isfile(config_file): + resources_to_operations_file = config_file + break + if not resources_to_operations_file: + raise EngineException("Invalid permission configuration: resources_to_operations file missing") + + with open(resources_to_operations_file, 'r') as f: + resources_to_operations = yaml.load(f) + + self.operations = [] + + for _, value in resources_to_operations["resources_to_operations"].items(): + 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(): - self.map_topic[topic] = topic_class(self.db, self.fs, self.msg) + if self.auth and topic_class in (UserTopicAuth, ProjectTopicAuth): + self.map_topic[topic] = topic_class(self.db, self.fs, self.msg, self.auth) + elif self.auth and topic_class == RoleTopicAuth: + self.map_topic[topic] = topic_class(self.db, self.fs, self.msg, self.auth, + self.operations) + else: + self.map_topic[topic] = topic_class(self.db, self.fs, self.msg) except (DbException, FsException, MsgException) as e: raise EngineException(str(e), http_code=e.http_code) diff --git a/osm_nbi/nbi.py b/osm_nbi/nbi.py index a9e2b24..63a6eca 100644 --- a/osm_nbi/nbi.py +++ b/osm_nbi/nbi.py @@ -213,6 +213,9 @@ class Server(object): "projects": {"METHODS": ("GET", "POST"), "": {"METHODS": ("GET", "DELETE")} }, + "roles": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "POST", "DELETE")} + }, "vims": {"METHODS": ("GET", "POST"), "": {"METHODS": ("GET", "DELETE", "PATCH", "PUT")} }, diff --git a/osm_nbi/validation.py b/osm_nbi/validation.py index c8c96cd..81b288d 100644 --- a/osm_nbi/validation.py +++ b/osm_nbi/validation.py @@ -565,6 +565,17 @@ pdu_edit_schema = { } # USERS +project_role_mapping = { + "title": "", + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "project": shortname_schema, + "role": shortname_schema + }, + "required": ["project", "role"], + "additionalProperties": False +} user_new_schema = { "$schema": "http://json-schema.org/draft-04/schema#", "title": "New user schema", @@ -573,8 +584,13 @@ user_new_schema = { "username": shortname_schema, "password": passwd_schema, "projects": nameshort_list_schema, + "project_role_mappings": { + "type": "array", + "items": project_role_mapping, + "minItems": 1 + }, }, - "required": ["username", "password", "projects"], + "required": ["username", "password"], "additionalProperties": False } user_edit_schema = { @@ -589,6 +605,11 @@ user_edit_schema = { array_edition_schema ] }, + "project_role_mappings": { + "type": "array", + "items": project_role_mapping, + "minItems": 1 + }, }, "minProperties": 1, "additionalProperties": False @@ -617,6 +638,32 @@ project_edit_schema = { "minProperties": 1 } +# ROLES +roles_new_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "New role schema for administrators", + "type": "object", + "properties": { + "name": shortname_schema, + "definition": object_schema, + }, + "required": ["name"], + "additionalProperties": False +} +roles_edit_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Roles edit schema for administrators", + "type": "object", + "properties": { + "_id": id_schema, + "name": shortname_schema, + "definition": object_schema, + }, + "required": ["_id", "name", "definition"], + "additionalProperties": False, + "minProperties": 1 +} + # GLOBAL SCHEMAS nbi_new_input_schemas = {