Adding User, Projects and Roles (Keystone) to NBI API
Change-Id: Id8c65e5d076fefc329340ca195c268004ecb4a4e
Signed-off-by: Eduardo Sousa <eduardo.sousa@canonical.com>
diff --git a/.gitignore-common b/.gitignore-common
index bfe5786..157e9ef 100644
--- a/.gitignore-common
+++ b/.gitignore-common
@@ -46,3 +46,6 @@
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 <alfonso.tiernosepulveda@telefonica.com>"
@@ -313,3 +316,660 @@
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 @@
create the tokens unscoped and scoped, retrieve the role
list inside the projects that they are inserted
"""
-from os import path
__author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
__date__ = "$27-jul-2018 23:59:59$"
@@ -40,7 +39,7 @@
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 @@
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 @@
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 @@
# Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
# Operations encoding: "<METHOD> <URL>"
# Note: it is faster to rewrite the value than to check if it is already there or not
+ 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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
"""
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 @@
"""
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 @@
"""
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 @@
"""
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 @@
"""
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 @@
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 @@
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 @@
: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 @@
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 @@
: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 @@
: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 <alfonso.tiernosepulveda@telefonica.com>"
@@ -54,7 +58,9 @@
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 @@
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 @@
"projects": {"METHODS": ("GET", "POST"),
"<ID>": {"METHODS": ("GET", "DELETE")}
},
+ "roles": {"METHODS": ("GET", "POST"),
+ "<ID>": {"METHODS": ("GET", "POST", "DELETE")}
+ },
"vims": {"METHODS": ("GET", "POST"),
"<ID>": {"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 @@
}
# 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 @@
"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 @@
array_edition_schema
]
},
+ "project_role_mappings": {
+ "type": "array",
+ "items": project_role_mapping,
+ "minItems": 1
+ },
},
"minProperties": 1,
"additionalProperties": False
@@ -617,6 +638,32 @@
"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 = {