From 11e443efeb77b9198c6357611ed135838c33c16a Mon Sep 17 00:00:00 2001 From: hardik Date: Thu, 9 Jun 2016 15:18:58 +0530 Subject: [PATCH] Added role base authentication support Mistral api access can be restricted base on role by modifying /etc/mistral/policy.json file. Change-Id: I4c78ca4cc04d25f46aea55948bce339cfe460ff0 Implements: blueprint mistral-customize-authorization --- devstack/plugin.sh | 6 ++ etc/policy.json | 58 +++++++++++++++ mistral/api/access_control.py | 30 ++++++++ mistral/api/controllers/v2/action.py | 7 ++ .../api/controllers/v2/action_execution.py | 9 +++ mistral/api/controllers/v2/cron_trigger.py | 6 ++ mistral/api/controllers/v2/environment.py | 8 +++ mistral/api/controllers/v2/execution.py | 7 ++ mistral/api/controllers/v2/member.py | 7 ++ mistral/api/controllers/v2/service.py | 3 + mistral/api/controllers/v2/task.py | 6 ++ mistral/api/controllers/v2/workbook.py | 7 ++ mistral/api/controllers/v2/workflow.py | 7 ++ mistral/tests/unit/api/base.py | 3 + mistral/tests/unit/api/test_access_control.py | 66 +++++++++++++++++ mistral/tests/unit/fake_policy.py | 72 +++++++++++++++++++ .../unit/mstrlfixtures/policy_fixtures.py | 50 +++++++++++++ requirements.txt | 1 + tools/config/config-generator.mistral.conf | 3 +- 19 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 etc/policy.json create mode 100644 mistral/tests/unit/api/test_access_control.py create mode 100644 mistral/tests/unit/fake_policy.py create mode 100644 mistral/tests/unit/mstrlfixtures/policy_fixtures.py diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 173cf0a14..859a25b07 100755 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -62,6 +62,9 @@ function configure_mistral { oslo-config-generator --config-file $MISTRAL_DIR/tools/config/config-generator.mistral.conf --output-file $MISTRAL_CONF_FILE iniset $MISTRAL_CONF_FILE DEFAULT debug $MISTRAL_DEBUG + MISTRAL_POLICY_FILE=$MISTRAL_CONF_DIR/policy.json + cp $MISTRAL_DIR/etc/policy.json $MISTRAL_POLICY_FILE + # Run all Mistral processes as a single process iniset $MISTRAL_CONF_FILE DEFAULT server all @@ -89,6 +92,9 @@ function configure_mistral { # Configure action execution deletion policy iniset $MISTRAL_CONF_FILE api allow_action_execution_deletion True + # Path of policy.json file. + iniset $MISTRAL_CONF oslo_policy policy_file $MISTRAL_POLICY_FILE + if [ "$LOG_COLOR" == "True" ] && [ "$SYSLOG" == "False" ]; then setup_colorized_logging $MISTRAL_CONF_FILE DEFAULT tenant user fi diff --git a/etc/policy.json b/etc/policy.json new file mode 100644 index 000000000..c5df702af --- /dev/null +++ b/etc/policy.json @@ -0,0 +1,58 @@ +{ + "admin_only": "is_admin:True", + "admin_or_owner": "is_admin:True or project_id:%(project_id)s", + "default": "rule:admin_or_owner", + + "action_executions:delete": "rule:admin_or_owner", + "action_execution:create": "rule:admin_or_owner", + "action_executions:get": "rule:admin_or_owner", + "action_executions:list": "rule:admin_or_owner", + "action_executions:update": "rule:admin_or_owner", + + "actions:create": "rule:admin_or_owner", + "actions:delete": "rule:admin_or_owner", + "actions:get": "rule:admin_or_owner", + "actions:list": "rule:admin_or_owner", + "actions:update": "rule:admin_or_owner", + + "cron_triggers:create": "rule:admin_or_owner", + "cron_triggers:delete": "rule:admin_or_owner", + "cron_triggers:get": "rule:admin_or_owner", + "cron_triggers:list": "rule:admin_or_owner", + + "environments:create": "rule:admin_or_owner", + "environments:delete": "rule:admin_or_owner", + "environments:get": "rule:admin_or_owner", + "environments:list": "rule:admin_or_owner", + "environments:update": "rule:admin_or_owner", + + "executions:create": "rule:admin_or_owner", + "executions:delete": "rule:admin_or_owner", + "executions:get": "rule:admin_or_owner", + "executions:list": "rule:admin_or_owner", + "executions:update": "rule:admin_or_owner", + + "members:create": "rule:admin_or_owner", + "members:delete": "rule:admin_or_owner", + "members:get": "rule:admin_or_owner", + "members:list": "rule:admin_or_owner", + "members:update": "rule:admin_or_owner", + + "services:list": "rule:admin_or_owner", + + "tasks:get": "rule:admin_or_owner", + "tasks:list": "rule:admin_or_owner", + "tasks:update": "rule:admin_or_owner", + + "workbooks:create": "rule:admin_or_owner", + "workbooks:delete": "rule:admin_or_owner", + "workbooks:get": "rule:admin_or_owner", + "workbooks:list": "rule:admin_or_owner", + "workbooks:update": "rule:admin_or_owner", + + "workflows:create": "rule:admin_or_owner", + "workflows:delete": "rule:admin_or_owner", + "workflows:get": "rule:admin_or_owner", + "workflows:list": "rule:admin_or_owner", + "workflows:update": "rule:admin_or_owner" +} diff --git a/mistral/api/access_control.py b/mistral/api/access_control.py index a01085940..30a426638 100644 --- a/mistral/api/access_control.py +++ b/mistral/api/access_control.py @@ -18,6 +18,9 @@ from keystonemiddleware import auth_token from oslo_config import cfg +from oslo_policy import policy + +from mistral import exceptions as exc _ENFORCER = None @@ -29,12 +32,39 @@ def setup(app): # Change auth decisions of requests to the app itself. conf.update({'delay_auth_decision': True}) + _ensure_enforcer_initialization() return auth_token.AuthProtocol(app, conf) else: return app +def enforce(action, context, target=None, do_raise=True, + exc=exc.NotAllowedException): + target_obj = { + 'project_id': context.project_id, + 'user_id': context.user_id, + } + + target_obj.update(target or {}) + _ensure_enforcer_initialization() + + return _ENFORCER.enforce( + action, + target_obj, + context.to_dict(), + do_raise=do_raise, + exc=exc + ) + + +def _ensure_enforcer_initialization(): + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer(cfg.CONF) + _ENFORCER.load_rules() + + def get_limited_to(headers): """Return the user and project the request should be limited to. diff --git a/mistral/api/controllers/v2/action.py b/mistral/api/controllers/v2/action.py index c4ad56f79..4e4d87196 100644 --- a/mistral/api/controllers/v2/action.py +++ b/mistral/api/controllers/v2/action.py @@ -20,10 +20,12 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import types from mistral.api.controllers.v2 import validation from mistral.api.hooks import content_type as ct_hook +from mistral import context from mistral.db.v2 import api as db_api from mistral import exceptions as exc from mistral.services import actions @@ -102,6 +104,7 @@ class ActionsController(rest.RestController, hooks.HookController): @wsme_pecan.wsexpose(Action, wtypes.text) def get(self, name): """Return the named action.""" + acl.enforce('actions:get', context.ctx()) LOG.info("Fetch action [name=%s]" % name) db_model = db_api.get_action_definition(name) @@ -116,6 +119,7 @@ class ActionsController(rest.RestController, hooks.HookController): NOTE: This text is allowed to have definitions of multiple actions. In this case they all will be updated. """ + acl.enforce('actions:update', context.ctx()) definition = pecan.request.text LOG.info("Update action(s) [definition=%s]" % definition) scope = pecan.request.GET.get('scope', 'private') @@ -141,6 +145,7 @@ class ActionsController(rest.RestController, hooks.HookController): NOTE: This text is allowed to have definitions of multiple actions. In this case they all will be created. """ + acl.enforce('actions:create', context.ctx()) definition = pecan.request.text scope = pecan.request.GET.get('scope', 'private') pecan.response.status = 201 @@ -164,6 +169,7 @@ class ActionsController(rest.RestController, hooks.HookController): @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, name): """Delete the named action.""" + acl.enforce('actions:delete', context.ctx()) LOG.info("Delete action [name=%s]" % name) with db_api.transaction(): @@ -194,6 +200,7 @@ class ActionsController(rest.RestController, hooks.HookController): Where project_id is the same as the requester or project_id is different but the scope is public. """ + acl.enforce('actions:list', context.ctx()) LOG.info("Fetch actions. marker=%s, limit=%s, sort_keys=%s, " "sort_dirs=%s", marker, limit, sort_keys, sort_dirs) diff --git a/mistral/api/controllers/v2/action_execution.py b/mistral/api/controllers/v2/action_execution.py index fde5e77ab..03495eba6 100644 --- a/mistral/api/controllers/v2/action_execution.py +++ b/mistral/api/controllers/v2/action_execution.py @@ -20,8 +20,10 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import types +from mistral import context from mistral.db.v2 import api as db_api from mistral.engine import rpc from mistral import exceptions as exc @@ -132,6 +134,7 @@ class ActionExecutionsController(rest.RestController): @wsme_pecan.wsexpose(ActionExecution, wtypes.text) def get(self, id): """Return the specified action_execution.""" + acl.enforce('action_executions:get', context.ctx()) LOG.info("Fetch action_execution [id=%s]" % id) return _get_action_execution(id) @@ -141,6 +144,7 @@ class ActionExecutionsController(rest.RestController): body=ActionExecution, status_code=201) def post(self, action_ex): """Create new action_execution.""" + acl.enforce('action_executions:create', context.ctx()) LOG.info("Create action_execution [action_execution=%s]" % action_ex) name = action_ex.name @@ -166,6 +170,7 @@ class ActionExecutionsController(rest.RestController): @wsme_pecan.wsexpose(ActionExecution, wtypes.text, body=ActionExecution) def put(self, id, action_ex): """Update the specified action_execution.""" + acl.enforce('action_executions:update', context.ctx()) LOG.info( "Update action_execution [id=%s, action_execution=%s]" % (id, action_ex) @@ -192,6 +197,7 @@ class ActionExecutionsController(rest.RestController): @wsme_pecan.wsexpose(ActionExecutions) def get_all(self): """Return all action_executions within the execution.""" + acl.enforce('action_executions:list', context.ctx()) LOG.info("Fetch action_executions") return _get_action_executions() @@ -201,6 +207,7 @@ class ActionExecutionsController(rest.RestController): def delete(self, id): """Delete the specified action_execution.""" + acl.enforce('action_executions:delete', context.ctx()) LOG.info("Delete action_execution [id=%s]" % id) if not cfg.CONF.api.allow_action_execution_deletion: @@ -224,6 +231,7 @@ class TasksActionExecutionController(rest.RestController): @wsme_pecan.wsexpose(ActionExecutions, wtypes.text) def get_all(self, task_execution_id): """Return all action executions within the task execution.""" + acl.enforce('action_executions:list', context.ctx()) LOG.info("Fetch action executions") return _get_action_executions(task_execution_id=task_execution_id) @@ -232,6 +240,7 @@ class TasksActionExecutionController(rest.RestController): @wsme_pecan.wsexpose(ActionExecution, wtypes.text, wtypes.text) def get(self, task_execution_id, action_ex_id): """Return the specified action_execution.""" + acl.enforce('action_executions:get', context.ctx()) LOG.info("Fetch action_execution [id=%s]" % action_ex_id) return _get_action_execution(action_ex_id) diff --git a/mistral/api/controllers/v2/cron_trigger.py b/mistral/api/controllers/v2/cron_trigger.py index 5b8668916..5c60a8b20 100644 --- a/mistral/api/controllers/v2/cron_trigger.py +++ b/mistral/api/controllers/v2/cron_trigger.py @@ -17,8 +17,10 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import types +from mistral import context from mistral.db.v2 import api as db_api from mistral.services import triggers from mistral.utils import rest_utils @@ -78,6 +80,7 @@ class CronTriggersController(rest.RestController): def get(self, name): """Returns the named cron_trigger.""" + acl.enforce('cron_triggers:get', context.ctx()) LOG.info('Fetch cron trigger [name=%s]' % name) db_model = db_api.get_cron_trigger(name) @@ -89,6 +92,7 @@ class CronTriggersController(rest.RestController): def post(self, cron_trigger): """Creates a new cron trigger.""" + acl.enforce('cron_triggers:create', context.ctx()) LOG.info('Create cron trigger: %s' % cron_trigger) values = cron_trigger.to_dict() @@ -110,6 +114,7 @@ class CronTriggersController(rest.RestController): @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, name): """Delete cron trigger.""" + acl.enforce('cron_triggers:delete', context.ctx()) LOG.info("Delete cron trigger [name=%s]" % name) db_api.delete_cron_trigger(name) @@ -118,6 +123,7 @@ class CronTriggersController(rest.RestController): def get_all(self): """Return all cron triggers.""" + acl.enforce('cron_triggers:list', context.ctx()) LOG.info("Fetch cron triggers.") _list = [ diff --git a/mistral/api/controllers/v2/environment.py b/mistral/api/controllers/v2/environment.py index 18a121cee..13bf9c5a3 100644 --- a/mistral/api/controllers/v2/environment.py +++ b/mistral/api/controllers/v2/environment.py @@ -20,8 +20,10 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import types +from mistral import context from mistral.db.v2 import api as db_api from mistral import exceptions as exceptions from mistral.utils import rest_utils @@ -77,6 +79,7 @@ class EnvironmentController(rest.RestController): Where project_id is the same as the requestor or project_id is different but the scope is public. """ + acl.enforce('environments:list', context.ctx()) LOG.info("Fetch environments.") environments = [ @@ -90,6 +93,7 @@ class EnvironmentController(rest.RestController): @wsme_pecan.wsexpose(Environment, wtypes.text) def get(self, name): """Return the named environment.""" + acl.enforce('environments:get', context.ctx()) LOG.info("Fetch environment [name=%s]" % name) db_model = db_api.get_environment(name) @@ -100,6 +104,7 @@ class EnvironmentController(rest.RestController): @wsme_pecan.wsexpose(Environment, body=Environment, status_code=201) def post(self, env): """Create a new environment.""" + acl.enforce('environments:create', context.ctx()) LOG.info("Create environment [env=%s]" % env) self._validate_environment( @@ -115,6 +120,8 @@ class EnvironmentController(rest.RestController): @wsme_pecan.wsexpose(Environment, body=Environment) def put(self, env): """Update an environment.""" + acl.enforce('environments:update', context.ctx()) + if not env.name: raise exceptions.InputException( 'Name of the environment is not provided.' @@ -138,6 +145,7 @@ class EnvironmentController(rest.RestController): @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, name): """Delete the named environment.""" + acl.enforce('environments:delete', context.ctx()) LOG.info("Delete environment [name=%s]" % name) db_api.delete_environment(name) diff --git a/mistral/api/controllers/v2/execution.py b/mistral/api/controllers/v2/execution.py index 0ddcaefb4..982c2d51c 100644 --- a/mistral/api/controllers/v2/execution.py +++ b/mistral/api/controllers/v2/execution.py @@ -20,9 +20,11 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import task from mistral.api.controllers.v2 import types +from mistral import context from mistral.db.v2 import api as db_api from mistral.engine import rpc from mistral import exceptions as exc @@ -116,6 +118,7 @@ class ExecutionsController(rest.RestController): @wsme_pecan.wsexpose(Execution, wtypes.text) def get(self, id): """Return the specified Execution.""" + acl.enforce("executions:get", context.ctx()) LOG.info("Fetch execution [id=%s]" % id) return Execution.from_dict(db_api.get_workflow_execution(id).to_dict()) @@ -128,6 +131,7 @@ class ExecutionsController(rest.RestController): :param id: execution ID. :param wf_ex: Execution object. """ + acl.enforce('executions:update', context.ctx()) LOG.info('Update execution [id=%s, execution=%s]' % (id, wf_ex)) db_api.ensure_workflow_execution_exists(id) @@ -219,6 +223,7 @@ class ExecutionsController(rest.RestController): :param wf_ex: Execution object with input content. """ + acl.enforce('executions:create', context.ctx()) LOG.info('Create execution [execution=%s]' % wf_ex) engine = rpc.get_engine_client() @@ -244,6 +249,7 @@ class ExecutionsController(rest.RestController): @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, id): """Delete the specified Execution.""" + acl.enforce('executions:delete', context.ctx()) LOG.info('Delete execution [id=%s]' % id) return db_api.delete_workflow_execution(id) @@ -265,6 +271,7 @@ class ExecutionsController(rest.RestController): Default: desc. The length of sort_dirs can be equal or less than that of sort_keys. """ + acl.enforce('executions:list', context.ctx()) LOG.info( "Fetch executions. marker=%s, limit=%s, sort_keys=%s, " "sort_dirs=%s", marker, limit, sort_keys, sort_dirs diff --git a/mistral/api/controllers/v2/member.py b/mistral/api/controllers/v2/member.py index 17cd3491e..d392bccf9 100644 --- a/mistral/api/controllers/v2/member.py +++ b/mistral/api/controllers/v2/member.py @@ -20,8 +20,10 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import types +from mistral import context from mistral.db.v2 import api as db_api from mistral import exceptions as exc from mistral.utils import rest_utils @@ -87,6 +89,7 @@ class MembersController(rest.RestController): @wsme_pecan.wsexpose(Member, wtypes.text) def get(self, member_id): """Shows resource member details.""" + acl.enforce('members:get', context.ctx()) LOG.info( "Fetch resource member [resource_id=%s, resource_type=%s, " "member_id=%s].", @@ -108,6 +111,7 @@ class MembersController(rest.RestController): @wsme_pecan.wsexpose(Members) def get_all(self): """Return all members with whom the resource has been shared.""" + acl.enforce('members:list', context.ctx()) LOG.info( "Fetch resource members [resource_id=%s, resource_type=%s].", self.resource_id, @@ -127,6 +131,7 @@ class MembersController(rest.RestController): @wsme_pecan.wsexpose(Member, body=Member, status_code=201) def post(self, member_info): """Shares the resource to a new member.""" + acl.enforce('members:create', context.ctx()) LOG.info( "Share resource to a member. [resource_id=%s, " "resource_type=%s, member_info=%s].", @@ -161,6 +166,7 @@ class MembersController(rest.RestController): @wsme_pecan.wsexpose(Member, wtypes.text, body=Member) def put(self, member_id, member_info): """Sets the status for a resource member.""" + acl.enforce('members:update', context.ctx()) LOG.info( "Update resource member status. [resource_id=%s, " "member_id=%s, member_info=%s].", @@ -187,6 +193,7 @@ class MembersController(rest.RestController): @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, member_id): """Deletes a member from the member list of a resource.""" + acl.enforce('members:delete', context.ctx()) LOG.info( "Delete resource member. [resource_id=%s, " "resource_type=%s, member_id=%s].", diff --git a/mistral/api/controllers/v2/service.py b/mistral/api/controllers/v2/service.py index 07a98133d..cb09e26b0 100644 --- a/mistral/api/controllers/v2/service.py +++ b/mistral/api/controllers/v2/service.py @@ -20,8 +20,10 @@ import tooz.coordination from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.cmd import launch +from mistral import context from mistral import coordination from mistral import exceptions as exc from mistral.utils import rest_utils @@ -58,6 +60,7 @@ class ServicesController(rest.RestController): def get_all(self): """Return all services.""" + acl.enforce('services:list', context.ctx()) LOG.info("Fetch services.") if not cfg.CONF.coordination.backend_url: diff --git a/mistral/api/controllers/v2/task.py b/mistral/api/controllers/v2/task.py index 827013409..e04127dd2 100644 --- a/mistral/api/controllers/v2/task.py +++ b/mistral/api/controllers/v2/task.py @@ -21,9 +21,11 @@ import wsme from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import action_execution from mistral.api.controllers.v2 import types +from mistral import context from mistral.db.v2 import api as db_api from mistral.engine import rpc from mistral import exceptions as exc @@ -119,6 +121,7 @@ class TasksController(rest.RestController): @wsme_pecan.wsexpose(Task, wtypes.text) def get(self, id): """Return the specified task.""" + acl.enforce('tasks:get', context.ctx()) LOG.info("Fetch task [id=%s]" % id) task_ex = db_api.get_task_execution(id) @@ -128,6 +131,7 @@ class TasksController(rest.RestController): @wsme_pecan.wsexpose(Tasks) def get_all(self): """Return all tasks within the execution.""" + acl.enforce('tasks:list', context.ctx()) LOG.info("Fetch tasks") return _get_task_resources_with_results() @@ -140,6 +144,7 @@ class TasksController(rest.RestController): :param id: Task execution ID. :param task: Task execution object. """ + acl.enforce('tasks:update', context.ctx()) LOG.info("Update task execution [id=%s, task=%s]" % (id, task)) task_ex = db_api.get_task_execution(id) @@ -189,6 +194,7 @@ class ExecutionTasksController(rest.RestController): @wsme_pecan.wsexpose(Tasks, wtypes.text) def get_all(self, workflow_execution_id): """Return all tasks within the workflow execution.""" + acl.enforce('tasks:list', context.ctx()) LOG.info("Fetch tasks.") return _get_task_resources_with_results(workflow_execution_id) diff --git a/mistral/api/controllers/v2/workbook.py b/mistral/api/controllers/v2/workbook.py index ef299010b..961b2a899 100644 --- a/mistral/api/controllers/v2/workbook.py +++ b/mistral/api/controllers/v2/workbook.py @@ -20,9 +20,11 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import validation from mistral.api.hooks import content_type as ct_hook +from mistral import context from mistral.db.v2 import api as db_api from mistral.services import workbooks from mistral.utils import rest_utils @@ -80,6 +82,7 @@ class WorkbooksController(rest.RestController, hooks.HookController): @wsme_pecan.wsexpose(Workbook, wtypes.text) def get(self, name): """Return the named workbook.""" + acl.enforce('workbooks:get', context.ctx()) LOG.info("Fetch workbook [name=%s]" % name) db_model = db_api.get_workbook(name) @@ -90,6 +93,7 @@ class WorkbooksController(rest.RestController, hooks.HookController): @pecan.expose(content_type="text/plain") def put(self): """Update a workbook.""" + acl.enforce('workbooks:update', context.ctx()) definition = pecan.request.text LOG.info("Update workbook [definition=%s]" % definition) @@ -101,6 +105,7 @@ class WorkbooksController(rest.RestController, hooks.HookController): @pecan.expose(content_type="text/plain") def post(self): """Create a new workbook.""" + acl.enforce('workbooks:create', context.ctx()) definition = pecan.request.text LOG.info("Create workbook [definition=%s]" % definition) @@ -113,6 +118,7 @@ class WorkbooksController(rest.RestController, hooks.HookController): @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, name): """Delete the named workbook.""" + acl.enforce('workbooks:delete', context.ctx()) LOG.info("Delete workbook [name=%s]" % name) db_api.delete_workbook(name) @@ -124,6 +130,7 @@ class WorkbooksController(rest.RestController, hooks.HookController): Where project_id is the same as the requestor or project_id is different but the scope is public. """ + acl.enforce('workbooks:list', context.ctx()) LOG.info("Fetch workbooks.") workbooks_list = [Workbook.from_dict(db_model.to_dict()) diff --git a/mistral/api/controllers/v2/workflow.py b/mistral/api/controllers/v2/workflow.py index 1e6dcc545..c80d671c9 100644 --- a/mistral/api/controllers/v2/workflow.py +++ b/mistral/api/controllers/v2/workflow.py @@ -22,11 +22,13 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from mistral.api import access_control as acl from mistral.api.controllers import resource from mistral.api.controllers.v2 import member from mistral.api.controllers.v2 import types from mistral.api.controllers.v2 import validation from mistral.api.hooks import content_type as ct_hook +from mistral import context from mistral.db.v2 import api as db_api from mistral import exceptions as exc from mistral.services import workflows @@ -152,6 +154,7 @@ class WorkflowsController(rest.RestController, hooks.HookController): @wsme_pecan.wsexpose(Workflow, wtypes.text) def get(self, identifier): """Return the named workflow.""" + acl.enforce('workflows:get', context.ctx()) LOG.info("Fetch workflow [identifier=%s]" % identifier) db_model = db_api.get_workflow_definition(identifier) @@ -169,6 +172,7 @@ class WorkflowsController(rest.RestController, hooks.HookController): The text is allowed to have definitions of multiple workflows. In this case they all will be updated. """ + acl.enforce('workflows:update', context.ctx()) definition = pecan.request.text scope = pecan.request.GET.get('scope', 'private') @@ -200,6 +204,7 @@ class WorkflowsController(rest.RestController, hooks.HookController): NOTE: The text is allowed to have definitions of multiple workflows. In this case they all will be created. """ + acl.enforce('workflows:create', context.ctx()) definition = pecan.request.text scope = pecan.request.GET.get('scope', 'private') pecan.response.status = 201 @@ -223,6 +228,7 @@ class WorkflowsController(rest.RestController, hooks.HookController): @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, identifier): """Delete a workflow.""" + acl.enforce('workflows:delete', context.ctx()) LOG.info("Delete workflow [identifier=%s]" % identifier) with db_api.transaction(): @@ -252,6 +258,7 @@ class WorkflowsController(rest.RestController, hooks.HookController): Where project_id is the same as the requester or project_id is different but the scope is public. """ + acl.enforce('workflows:list', context.ctx()) LOG.info("Fetch workflows. marker=%s, limit=%s, sort_keys=%s, " "sort_dirs=%s, fields=%s", marker, limit, sort_keys, sort_dirs, fields) diff --git a/mistral/tests/unit/api/base.py b/mistral/tests/unit/api/base.py index 761d82a5b..4f61db3d1 100644 --- a/mistral/tests/unit/api/base.py +++ b/mistral/tests/unit/api/base.py @@ -22,6 +22,7 @@ from webtest import app as webtest_app from mistral.services import periodic from mistral.tests.unit import base +from mistral.tests.unit.mstrlfixtures import policy_fixtures # Disable authentication for functional tests. cfg.CONF.set_default('auth_enable', False, group='pecan') @@ -61,6 +62,8 @@ class APITest(base.DbTestCase): self.mock_ctx.return_value = self.ctx self.addCleanup(self.patch_ctx.stop) + self.policy = self.useFixture(policy_fixtures.PolicyFixture()) + def assertNotFound(self, url): try: self.app.get(url, headers={'Accept': 'application/json'}) diff --git a/mistral/tests/unit/api/test_access_control.py b/mistral/tests/unit/api/test_access_control.py new file mode 100644 index 000000000..13732ca5f --- /dev/null +++ b/mistral/tests/unit/api/test_access_control.py @@ -0,0 +1,66 @@ +# Copyright 2016 NEC Corporation. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from mistral.api import access_control as acl +from mistral import exceptions as exc +from mistral.tests.unit import base +from mistral.tests.unit.mstrlfixtures import policy_fixtures + + +class PolicyTestCase(base.BaseTest): + """Tests whether the configuration of the policy engine is corect.""" + def setUp(self): + super(PolicyTestCase, self).setUp() + self.policy = self.useFixture(policy_fixtures.PolicyFixture()) + rules = { + "admin_only": "is_admin:True", + "admin_or_owner": "is_admin:True or project_id:%(project_id)s", + + "example:admin": "rule:admin_only", + "example:admin_or_owner": "rule:admin_or_owner" + } + self.policy.set_rules(rules) + + def test_admin_api_allowed(self): + auth_ctx = base.get_context(default=True, admin=True) + self.assertTrue( + acl.enforce('example:admin', auth_ctx, auth_ctx.to_dict()) + ) + + def test_admin_api_disallowed(self): + auth_ctx = base.get_context(default=True) + self.assertRaises( + exc.NotAllowedException, + acl.enforce, + 'example:admin', + auth_ctx, + auth_ctx.to_dict() + ) + + def test_admin_or_owner_api_allowed(self): + auth_ctx = base.get_context(default=True) + self.assertTrue( + acl.enforce('example:admin_or_owner', auth_ctx, auth_ctx.to_dict()) + ) + + def test_admin_or_owner_api_disallowed(self): + auth_ctx = base.get_context(default=True) + target = {'project_id': 'another'} + self.assertRaises( + exc.NotAllowedException, + acl.enforce, + 'example:admin_or_owner', + auth_ctx, + target + ) diff --git a/mistral/tests/unit/fake_policy.py b/mistral/tests/unit/fake_policy.py new file mode 100644 index 000000000..ff6b3b8f4 --- /dev/null +++ b/mistral/tests/unit/fake_policy.py @@ -0,0 +1,72 @@ +# Copyright 2016 NEC Corporation. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +policy_data = """{ + "admin_only": "is_admin:True", + "admin_or_owner": "is_admin:True or project_id:%(project_id)s", + "default": "rule:admin_or_owner", + + "action_executions:delete": "rule:admin_or_owner", + "action_execution:create": "rule:admin_or_owner", + "action_executions:get": "rule:admin_or_owner", + "action_executions:list": "rule:admin_or_owner", + "action_executions:update": "rule:admin_or_owner", + + "actions:create": "rule:admin_or_owner", + "actions:delete": "rule:admin_or_owner", + "actions:get": "rule:admin_or_owner", + "actions:list": "rule:admin_or_owner", + "actions:update": "rule:admin_or_owner", + + "cron_triggers:create": "rule:admin_or_owner", + "cron_triggers:delete": "rule:admin_or_owner", + "cron_triggers:get": "rule:admin_or_owner", + "cron_triggers:list": "rule:admin_or_owner", + + "environments:create": "rule:admin_or_owner", + "environments:delete": "rule:admin_or_owner", + "environments:get": "rule:admin_or_owner", + "environments:list": "rule:admin_or_owner", + "environments:update": "rule:admin_or_owner", + + "executions:create": "rule:admin_or_owner", + "executions:delete": "rule:admin_or_owner", + "executions:get": "rule:admin_or_owner", + "executions:list": "rule:admin_or_owner", + "executions:update": "rule:admin_or_owner", + + "members:create": "rule:admin_or_owner", + "members:delete": "rule:admin_or_owner", + "members:get": "rule:admin_or_owner", + "members:list": "rule:admin_or_owner", + "members:update": "rule:admin_or_owner", + + "services:list": "rule:admin_or_owner", + + "tasks:get": "rule:admin_or_owner", + "tasks:list": "rule:admin_or_owner", + "tasks:update": "rule:admin_or_owner", + + "workbooks:create": "rule:admin_or_owner", + "workbooks:delete": "rule:admin_or_owner", + "workbooks:get": "rule:admin_or_owner", + "workbooks:list": "rule:admin_or_owner", + "workbooks:update": "rule:admin_or_owner", + + "workflows:create": "rule:admin_or_owner", + "workflows:delete": "rule:admin_or_owner", + "workflows:get": "rule:admin_or_owner", + "workflows:list": "rule:admin_or_owner", + "workflows:update": "rule:admin_or_owner", +}""" diff --git a/mistral/tests/unit/mstrlfixtures/policy_fixtures.py b/mistral/tests/unit/mstrlfixtures/policy_fixtures.py new file mode 100644 index 000000000..9ad8f2438 --- /dev/null +++ b/mistral/tests/unit/mstrlfixtures/policy_fixtures.py @@ -0,0 +1,50 @@ +# Copyright 2016 NEC Corporation. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os + +import fixtures +from oslo_config import cfg +from oslo_policy import opts as policy_opts +from oslo_policy import policy as oslo_policy + +from mistral.api import access_control as acl +from mistral.tests.unit import fake_policy + + +class PolicyFixture(fixtures.Fixture): + """Load a fake policy from nova.tests.unit.fake_policy""" + + def setUp(self): + super(PolicyFixture, self).setUp() + self.policy_dir = self.useFixture(fixtures.TempDir()) + self.policy_file_name = os.path.join( + self.policy_dir.path, + 'policy.json' + ) + with open(self.policy_file_name, 'w') as policy_file: + policy_file.write(fake_policy.policy_data) + policy_opts.set_defaults(cfg.CONF) + cfg.CONF.set_override( + 'policy_file', + self.policy_file_name, + 'oslo_policy' + ) + acl._ENFORCER = oslo_policy.Enforcer(cfg.CONF) + acl._ENFORCER.load_rules() + self.addCleanup(acl._ENFORCER.clear) + + def set_rules(self, rules): + policy = acl._ENFORCER + policy.set_rules(oslo_policy.Rules.from_dict(rules)) diff --git a/requirements.txt b/requirements.txt index f1a1f5132..bf588bb33 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ oslo.config>=3.10.0 # Apache-2.0 oslo.db>=4.1.0 # Apache-2.0 oslo.messaging>=4.5.0 # Apache-2.0 oslo.middleware>=3.0.0 # Apache-2.0 +oslo.policy>=1.9.0 # Apache-2.0 oslo.utils>=3.11.0 # Apache-2.0 oslo.log>=1.14.0 # Apache-2.0 oslo.serialization>=1.10.0 # Apache-2.0 diff --git a/tools/config/config-generator.mistral.conf b/tools/config/config-generator.mistral.conf index 40734c09f..aee9ace83 100644 --- a/tools/config/config-generator.mistral.conf +++ b/tools/config/config-generator.mistral.conf @@ -5,4 +5,5 @@ namespace = oslo.messaging namespace = oslo.middleware.cors namespace = keystonemiddleware.auth_token namespace = periodic.config -namespace = oslo.log \ No newline at end of file +namespace = oslo.log +namespace = oslo.policy