From 21250cf20c0efbe6d57c4a712c51b80667e53b44 Mon Sep 17 00:00:00 2001 From: Petr Malik Date: Mon, 27 Jun 2016 16:01:42 -0400 Subject: [PATCH] Add support for Oslo Policies to Trove The Oslo Policy library provides support for RBAC policy enforcement across all OpenStack services. Update the devstack plugin to copy the default policy file over to /etc/trove in the gate environments. Note: Not adding a rule for 'reset-password' instance action as that API was discontinued years ago and is now just waiting for removal (Bug: 1645866). DocImpact Co-Authored-By: Ali Adil Change-Id: Ic443a4c663301840406cad537159eab7b0b5ed1c Implements: blueprint trove-policy --- devstack/plugin.sh | 3 + devstack/settings | 2 + etc/trove/policy.json | 96 +++++++ .../use-oslo-policy-bbd1b911e6487c36.yaml | 8 + requirements.txt | 1 + trove/backup/service.py | 8 + trove/cluster/service.py | 31 +++ trove/common/exception.py | 19 +- trove/common/policy.py | 260 ++++++++++++++++++ trove/common/wsgi.py | 5 +- trove/configuration/service.py | 40 ++- trove/datastore/service.py | 15 + trove/extensions/common/service.py | 38 ++- trove/extensions/mysql/service.py | 28 +- trove/flavor/service.py | 5 + trove/guestagent/guest_log.py | 11 +- trove/instance/service.py | 56 +++- trove/limits/service.py | 3 + trove/module/service.py | 21 ++ trove/tests/api/mgmt/instances_actions.py | 11 +- .../runners/instance_force_delete_runners.py | 5 +- .../tests/unittests/api/common/test_limits.py | 5 +- .../cluster/test_cluster_controller.py | 7 +- .../common/test_common_extensions.py | 58 ++-- trove/tests/unittests/common/test_policy.py | 53 ++++ trove/tests/unittests/trove_testtools.py | 6 + 26 files changed, 740 insertions(+), 55 deletions(-) create mode 100644 etc/trove/policy.json create mode 100644 releasenotes/notes/use-oslo-policy-bbd1b911e6487c36.yaml create mode 100644 trove/common/policy.py create mode 100644 trove/tests/unittests/common/test_policy.py diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 573a1ea3e3..504958ec64 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -110,6 +110,9 @@ function configure_trove { # Copy api-paste file over to the trove conf dir cp $TROVE_LOCAL_API_PASTE_INI $TROVE_API_PASTE_INI + # Copy the default policy file over to the trove conf dir + cp $TROVE_LOCAL_POLICY_JSON $TROVE_POLICY_JSON + # (Re)create trove conf files rm -f $TROVE_CONF rm -f $TROVE_TASKMANAGER_CONF diff --git a/devstack/settings b/devstack/settings index 2a4eb4b4ef..be3074f46e 100644 --- a/devstack/settings +++ b/devstack/settings @@ -21,9 +21,11 @@ TROVE_TASKMANAGER_CONF=${TROVE_TASKMANAGER_CONF:-${TROVE_CONF_DIR}/trove-taskman TROVE_CONDUCTOR_CONF=${TROVE_CONDUCTOR_CONF:-${TROVE_CONF_DIR}/trove-conductor.conf} TROVE_GUESTAGENT_CONF=${TROVE_GUESTAGENT_CONF:-${TROVE_CONF_DIR}/trove-guestagent.conf} TROVE_API_PASTE_INI=${TROVE_API_PASTE_INI:-${TROVE_CONF_DIR}/api-paste.ini} +TROVE_POLICY_JSON=${TROVE_POLICY_JSON:-${TROVE_CONF_DIR}/policy.json} TROVE_LOCAL_CONF_DIR=${TROVE_LOCAL_CONF_DIR:-${TROVE_DIR}/etc/trove} TROVE_LOCAL_API_PASTE_INI=${TROVE_LOCAL_API_PASTE_INI:-${TROVE_LOCAL_CONF_DIR}/api-paste.ini} +TROVE_LOCAL_POLICY_JSON=${TROVE_LOCAL_POLICY_JSON:-${TROVE_LOCAL_CONF_DIR}/policy.json} TROVE_AUTH_CACHE_DIR=${TROVE_AUTH_CACHE_DIR:-/var/cache/trove} TROVE_DATASTORE_TYPE=${TROVE_DATASTORE_TYPE:-"mysql"} TROVE_DATASTORE_VERSION=${TROVE_DATASTORE_VERSION:-"5.6"} diff --git a/etc/trove/policy.json b/etc/trove/policy.json new file mode 100644 index 0000000000..370a8f2a5d --- /dev/null +++ b/etc/trove/policy.json @@ -0,0 +1,96 @@ +{ + "admin": "role:admin or is_admin:True", + "admin_or_owner": "rule:admin or tenant:%(tenant)s", + "default": "rule:admin_or_owner", + + "instance:create": "rule:admin_or_owner", + "instance:delete": "rule:admin_or_owner", + "instance:force_delete": "rule:admin_or_owner", + "instance:index": "rule:admin_or_owner", + "instance:show": "rule:admin_or_owner", + "instance:update": "rule:admin_or_owner", + "instance:edit": "rule:admin_or_owner", + "instance:restart": "rule:admin_or_owner", + "instance:resize_volume": "rule:admin_or_owner", + "instance:resize_flavor": "rule:admin_or_owner", + "instance:reset_status": "rule:admin", + "instance:promote_to_replica_source": "rule:admin_or_owner", + "instance:eject_replica_source": "rule:admin_or_owner", + "instance:configuration": "rule:admin_or_owner", + "instance:guest_log_list": "rule:admin_or_owner", + "instance:backups": "rule:admin_or_owner", + "instance:module_list": "rule:admin_or_owner", + "instance:module_apply": "rule:admin_or_owner", + "instance:module_remove": "rule:admin_or_owner", + + "instance:extension:root:create": "rule:admin_or_owner", + "instance:extension:root:delete": "rule:admin_or_owner", + "instance:extension:root:index": "rule:admin_or_owner", + + "instance:extension:user:create": "rule:admin_or_owner", + "instance:extension:user:delete": "rule:admin_or_owner", + "instance:extension:user:index": "rule:admin_or_owner", + "instance:extension:user:show": "rule:admin_or_owner", + "instance:extension:user:update": "rule:admin_or_owner", + "instance:extension:user:update_all": "rule:admin_or_owner", + + "instance:extension:user_access:update": "rule:admin_or_owner", + "instance:extension:user_access:delete": "rule:admin_or_owner", + "instance:extension:user_access:index": "rule:admin_or_owner", + + "instance:extension:database:create": "rule:admin_or_owner", + "instance:extension:database:delete": "rule:admin_or_owner", + "instance:extension:database:index": "rule:admin_or_owner", + "instance:extension:database:show": "rule:admin_or_owner", + + "cluster:create": "rule:admin_or_owner", + "cluster:delete": "rule:admin_or_owner", + "cluster:force_delete": "rule:admin_or_owner", + "cluster:index": "rule:admin_or_owner", + "cluster:show": "rule:admin_or_owner", + "cluster:show_instance": "rule:admin_or_owner", + "cluster:action": "rule:admin_or_owner", + "cluster:reset-status": "rule:admin", + + "cluster:extension:root:create": "rule:admin_or_owner", + "cluster:extension:root:delete": "rule:admin_or_owner", + "cluster:extension:root:index": "rule:admin_or_owner", + + "backup:create": "rule:admin_or_owner", + "backup:delete": "rule:admin_or_owner", + "backup:index": "rule:admin_or_owner", + "backup:show": "rule:admin_or_owner", + + "configuration:create": "rule:admin_or_owner", + "configuration:delete": "rule:admin_or_owner", + "configuration:index": "rule:admin_or_owner", + "configuration:show": "rule:admin_or_owner", + "configuration:instances": "rule:admin_or_owner", + "configuration:update": "rule:admin_or_owner", + "configuration:edit": "rule:admin_or_owner", + + "configuration-parameter:index": "rule:admin_or_owner", + "configuration-parameter:show": "rule:admin_or_owner", + "configuration-parameter:index_by_version": "rule:admin_or_owner", + "configuration-parameter:show_by_version": "rule:admin_or_owner", + + "datastore:index": "", + "datastore:show": "", + "datastore:version_show": "", + "datastore:version_show_by_uuid": "", + "datastore:version_index": "", + "datastore:list_associated_flavors": "", + "datastore:list_associated_volume_types": "", + + "flavor:index": "", + "flavor:show": "", + + "limits:index": "rule:admin_or_owner", + + "module:create": "rule:admin_or_owner", + "module:delete": "rule:admin_or_owner", + "module:index": "rule:admin_or_owner", + "module:show": "rule:admin_or_owner", + "module:instances": "rule:admin_or_owner", + "module:update": "rule:admin_or_owner" +} diff --git a/releasenotes/notes/use-oslo-policy-bbd1b911e6487c36.yaml b/releasenotes/notes/use-oslo-policy-bbd1b911e6487c36.yaml new file mode 100644 index 0000000000..5e6138d731 --- /dev/null +++ b/releasenotes/notes/use-oslo-policy-bbd1b911e6487c36.yaml @@ -0,0 +1,8 @@ +--- +features: + - Add RBAC (role-based access control) + enforcement on all trove APIs. + Allows to define a role-based access rule + for every trove API call + (rule definitions are available in + /etc/trove/policy.json). diff --git a/requirements.txt b/requirements.txt index b1eb97922a..9621754256 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,3 +47,4 @@ oslo.db!=4.13.1,!=4.13.2,>=4.11.0 # Apache-2.0 enum34;python_version=='2.7' or python_version=='2.6' or python_version=='3.3' # BSD xmltodict>=0.10.1 # MIT pycrypto>=2.6 # Public Domain +oslo.policy>=1.17.0 # Apache-2.0 diff --git a/trove/backup/service.py b/trove/backup/service.py index 4d505f54ad..bb14b6bb8d 100644 --- a/trove/backup/service.py +++ b/trove/backup/service.py @@ -22,6 +22,7 @@ from trove.common.i18n import _ from trove.common import notification from trove.common.notification import StartNotification from trove.common import pagination +from trove.common import policy from trove.common import wsgi LOG = logging.getLogger(__name__) @@ -40,6 +41,7 @@ class BackupController(wsgi.Controller): LOG.debug("Listing backups for tenant %s" % tenant_id) datastore = req.GET.get('datastore') context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'backup:index') backups, marker = Backup.list(context, datastore) view = views.BackupViews(backups) paged = pagination.SimplePaginatedDataView(req.url, 'backups', view, @@ -52,11 +54,14 @@ class BackupController(wsgi.Controller): % (tenant_id, id)) context = req.environ[wsgi.CONTEXT_KEY] backup = Backup.get_by_id(context, id) + policy.authorize_on_target(context, 'backup:show', + {'tenant': backup.tenant_id}) return wsgi.Result(views.BackupView(backup).data(), 200) def create(self, req, body, tenant_id): LOG.info(_("Creating a backup for tenant %s"), tenant_id) context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'backup:create') data = body['backup'] instance = data['instance'] name = data['name'] @@ -76,6 +81,9 @@ class BackupController(wsgi.Controller): 'ID: %(backup_id)s') % {'tenant_id': tenant_id, 'backup_id': id}) context = req.environ[wsgi.CONTEXT_KEY] + backup = Backup.get_by_id(context, id) + policy.authorize_on_target(context, 'backup:delete', + {'tenant': backup.tenant_id}) context.notification = notification.DBaaSBackupDelete(context, request=req) with StartNotification(context, backup_id=id): diff --git a/trove/cluster/service.py b/trove/cluster/service.py index 97a91b9acd..67e7a24a25 100644 --- a/trove/cluster/service.py +++ b/trove/cluster/service.py @@ -25,6 +25,7 @@ from trove.common.i18n import _ from trove.common import notification from trove.common.notification import StartNotification from trove.common import pagination +from trove.common import policy from trove.common import utils from trove.common import wsgi from trove.datastore import models as datastore_models @@ -39,6 +40,11 @@ class ClusterController(wsgi.Controller): """Controller for cluster functionality.""" schemas = apischema.cluster.copy() + @classmethod + def authorize_cluster_action(cls, context, cluster_rule_name, cluster): + policy.authorize_on_target(context, 'cluster:%s' % cluster_rule_name, + {'tenant': cluster.tenant_id}) + @classmethod def get_action_schema(cls, body, action_schema): action_type = list(body.keys())[0] @@ -58,15 +64,25 @@ class ClusterController(wsgi.Controller): {"req": req, "id": id, "tenant_id": tenant_id}) if not body: raise exception.BadRequest(_("Invalid request body.")) + if len(body) != 1: raise exception.BadRequest(_("Action request should have exactly" " one action specified in body")) context = req.environ[wsgi.CONTEXT_KEY] cluster = models.Cluster.load(context, id) + if ('reset-status' in body and + 'force_delete' not in body['reset-status']): + self.authorize_cluster_action(context, 'reset-status', cluster) + elif ('reset-status' in body and + 'force_delete' in body['reset-status']): + self.authorize_cluster_action(context, 'force_delete', cluster) + else: + self.authorize_cluster_action(context, 'action', cluster) cluster.action(context, req, *next(iter(body.items()))) view = views.load_view(cluster, req=req, load_servers=False) wsgi_result = wsgi.Result(view.data(), 202) + return wsgi_result def show(self, req, tenant_id, id): @@ -77,6 +93,7 @@ class ClusterController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] cluster = models.Cluster.load(context, id) + self.authorize_cluster_action(context, 'show', cluster) return wsgi.Result(views.load_view(cluster, req=req).data(), 200) def show_instance(self, req, tenant_id, cluster_id, instance_id): @@ -92,6 +109,7 @@ class ClusterController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] cluster = models.Cluster.load(context, cluster_id) + self.authorize_cluster_action(context, 'show_instance', cluster) instance = models.Cluster.load_instance(context, cluster.id, instance_id) return wsgi.Result(views.ClusterInstanceDetailView( @@ -105,6 +123,7 @@ class ClusterController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] cluster = models.Cluster.load(context, id) + self.authorize_cluster_action(context, 'delete', cluster) context.notification = notification.DBaaSClusterDelete(context, request=req) with StartNotification(context, cluster_id=id): @@ -118,9 +137,19 @@ class ClusterController(wsgi.Controller): "tenant_id": tenant_id}) context = req.environ[wsgi.CONTEXT_KEY] + + # This theoretically allows the Admin tenant list clusters for + # only one particular tenant as opposed to listing all clusters for + # for all tenants. + # * As far as I can tell this is the only call which actually uses the + # passed-in 'tenant_id' for anything. if not context.is_admin and context.tenant != tenant_id: raise exception.TroveOperationAuthError(tenant_id=context.tenant) + # The rule checks that the currently authenticated tenant can perform + # the 'cluster-list' action. + policy.authorize_on_tenant(context, 'cluster:index') + # load all clusters and instances for the tenant clusters, marker = models.Cluster.load_all(context, tenant_id) view = views.ClustersView(clusters, req=req) @@ -134,6 +163,8 @@ class ClusterController(wsgi.Controller): {"tenant_id": tenant_id, "req": req, "body": body}) context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'cluster:create') + name = body['cluster']['name'] datastore_args = body['cluster'].get('datastore', {}) datastore, datastore_version = ( diff --git a/trove/common/exception.py b/trove/common/exception.py index dc57a58939..020a9b48b7 100644 --- a/trove/common/exception.py +++ b/trove/common/exception.py @@ -236,11 +236,6 @@ class UnprocessableEntity(TroveError): message = _("Unable to process the contained request.") -class UnauthorizedRequest(TroveError): - - message = _("Unauthorized request.") - - class CannotResizeToSameSize(TroveError): message = _("No change was requested in the size of the instance.") @@ -309,6 +304,11 @@ class Forbidden(TroveError): message = _("User does not have admin privileges.") +class PolicyNotAuthorized(Forbidden): + + message = _("Policy doesn't allow %(action)s to be performed.") + + class InvalidModelError(TroveError): message = _("The following values are invalid: %(errors)s.") @@ -538,6 +538,10 @@ class ModuleInvalid(Forbidden): message = _("The module is invalid: %(reason)s") +class InstanceNotFound(NotFound): + message = _("Instance '%(instance)s' cannot be found.") + + class ClusterNotFound(NotFound): message = _("Cluster '%(cluster)s' cannot be found.") @@ -622,3 +626,8 @@ class ImageNotFound(NotFound): class DatastoreVersionAlreadyExists(BadRequest): message = _("A datastore version with the name '%(name)s' already exists.") + + +class LogAccessForbidden(Forbidden): + + message = _("You must be admin to %(action)s log '%(log)s'.") diff --git a/trove/common/policy.py b/trove/common/policy.py new file mode 100644 index 0000000000..9304f309c4 --- /dev/null +++ b/trove/common/policy.py @@ -0,0 +1,260 @@ +# Copyright 2016 Tesora Inc. +# 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 oslo_config import cfg +from oslo_policy import policy + +from trove.common import exception as trove_exceptions + +CONF = cfg.CONF +_ENFORCER = None + + +base_rules = [ + policy.RuleDefault( + 'admin', + 'role:admin or is_admin:True', + description='Must be an administrator.'), + policy.RuleDefault( + 'admin_or_owner', + 'rule:admin or tenant:%(tenant)s', + description='Must be an administrator or owner of the object.'), + policy.RuleDefault( + 'default', + 'rule:admin_or_owner', + description='Must be an administrator or owner of the object.') +] + +instance_rules = [ + policy.RuleDefault( + 'instance:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:force_delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:show', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:update', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:edit', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:restart', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:resize_volume', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:resize_flavor', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:reset_status', 'rule:admin'), + policy.RuleDefault( + 'instance:promote_to_replica_source', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:eject_replica_source', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:configuration', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:guest_log_list', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:backups', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:module_list', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:module_apply', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:module_remove', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'instance:extension:root:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:root:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:root:index', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'instance:extension:user:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:user:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:user:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:user:show', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:user:update', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:user:update_all', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'instance:extension:user_access:update', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:user_access:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:user_access:index', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'instance:extension:database:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:database:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:database:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'instance:extension:database:show', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'cluster:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:force_delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:show', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:show_instance', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:action', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:reset-status', 'rule:admin'), + + policy.RuleDefault( + 'cluster:extension:root:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:extension:root:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'cluster:extension:root:index', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'backup:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'backup:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'backup:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'backup:show', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'configuration:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration:show', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration:instances', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration:update', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration:edit', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'configuration-parameter:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration-parameter:show', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration-parameter:index_by_version', 'rule:admin_or_owner'), + policy.RuleDefault( + 'configuration-parameter:show_by_version', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'datastore:index', ''), + policy.RuleDefault( + 'datastore:show', ''), + policy.RuleDefault( + 'datastore:version_show', ''), + policy.RuleDefault( + 'datastore:version_show_by_uuid', ''), + policy.RuleDefault( + 'datastore:version_index', ''), + policy.RuleDefault( + 'datastore:list_associated_flavors', ''), + policy.RuleDefault( + 'datastore:list_associated_volume_types', ''), + + policy.RuleDefault( + 'flavor:index', ''), + policy.RuleDefault( + 'flavor:show', ''), + + policy.RuleDefault( + 'limits:index', 'rule:admin_or_owner'), + + policy.RuleDefault( + 'module:create', 'rule:admin_or_owner'), + policy.RuleDefault( + 'module:delete', 'rule:admin_or_owner'), + policy.RuleDefault( + 'module:index', 'rule:admin_or_owner'), + policy.RuleDefault( + 'module:show', 'rule:admin_or_owner'), + policy.RuleDefault( + 'module:instances', 'rule:admin_or_owner'), + policy.RuleDefault( + 'module:update', 'rule:admin_or_owner'), +] + + +def get_enforcer(): + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer(CONF) + _ENFORCER.register_defaults(base_rules) + _ENFORCER.register_defaults(instance_rules) + return _ENFORCER + + +def authorize_on_tenant(context, rule): + return __authorize(context, rule, target=None) + + +def authorize_on_target(context, rule, target): + if target: + return __authorize(context, rule, target=target) + raise trove_exceptions.TroveError( + "BUG: Target must not evaluate to False.") + + +def __authorize(context, rule, target=None): + """Checks authorization of a rule against the target in this context. + + * This function is not to be called directly. + Calling the function with a target that evaluates to None may + result in policy bypass. + Use 'authorize_on_*' calls instead. + + :param context Trove context. + :type context Context. + + :param rule: The rule to evaluate. + e.g. ``instance:create_instance``, + ``instance:resize_volume`` + + :param target As much information about the object being operated on + as possible. + For object creation (target=None) this should be a + dictionary representing the location of the object + e.g. ``{'project_id': context.project_id}`` + :type target dict + + :raises: :class:`PolicyNotAuthorized` if verification fails. + + """ + target = target or {'tenant': context.tenant} + return get_enforcer().authorize( + rule, target, context.to_dict(), do_raise=True, + exc=trove_exceptions.PolicyNotAuthorized, action=rule) diff --git a/trove/common/wsgi.py b/trove/common/wsgi.py index bfec6c00ad..c087f6c8fc 100644 --- a/trove/common/wsgi.py +++ b/trove/common/wsgi.py @@ -322,6 +322,8 @@ class Controller(object): exception.BackupTooLarge, exception.ModuleAccessForbidden, exception.ModuleAppliedToInstance, + exception.PolicyNotAuthorized, + exception.LogAccessForbidden, ], webob.exc.HTTPBadRequest: [ exception.InvalidModelError, @@ -548,7 +550,8 @@ class ContextMiddleware(base_wsgi.Middleware): is_admin=is_admin, limit=limits.get('limit'), marker=limits.get('marker'), - service_catalog=service_catalog) + service_catalog=service_catalog, + roles=roles) request.environ[CONTEXT_KEY] = context @classmethod diff --git a/trove/configuration/service.py b/trove/configuration/service.py index 19dd51c3b6..485e6e17b2 100644 --- a/trove/configuration/service.py +++ b/trove/configuration/service.py @@ -25,6 +25,7 @@ from trove.common.i18n import _ from trove.common import notification from trove.common.notification import StartNotification, EndNotification from trove.common import pagination +from trove.common import policy from trove.common import wsgi from trove.configuration import models from trove.configuration.models import DBConfigurationParameter @@ -41,9 +42,16 @@ class ConfigurationsController(wsgi.Controller): schemas = apischema.configuration + @classmethod + def authorize_config_action(cls, context, config_rule_name, config): + policy.authorize_on_target( + context, 'configuration:%s' % config_rule_name, + {'tenant': config.tenant_id}) + def index(self, req, tenant_id): context = req.environ[wsgi.CONTEXT_KEY] configs, marker = models.Configurations.load(context) + policy.authorize_on_tenant(context, 'configuration:index') view = views.ConfigurationsView(configs) paged = pagination.SimplePaginatedDataView(req.url, 'configurations', view, marker) @@ -54,6 +62,7 @@ class ConfigurationsController(wsgi.Controller): % {"tenant": tenant_id, "id": id}) context = req.environ[wsgi.CONTEXT_KEY] configuration = models.Configuration.load(context, id) + self.authorize_config_action(context, 'show', configuration) configuration_items = models.Configuration.load_items(context, id) configuration.instance_count = instances_models.DBInstance.find_all( @@ -68,6 +77,7 @@ class ConfigurationsController(wsgi.Controller): def instances(self, req, tenant_id, id): context = req.environ[wsgi.CONTEXT_KEY] configuration = models.Configuration.load(context, id) + self.authorize_config_action(context, 'instances', configuration) instances = instances_models.DBInstance.find_all( tenant_id=context.tenant, configuration_id=configuration.id, @@ -89,6 +99,7 @@ class ConfigurationsController(wsgi.Controller): LOG.debug("body : '%s'\n\n" % req) context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'configuration:create') context.notification = notification.DBaaSConfigurationCreate( context, request=req) name = body['configuration']['name'] @@ -137,10 +148,11 @@ class ConfigurationsController(wsgi.Controller): LOG.info(msg % {"tenant_id": tenant_id, "cfg_id": id}) context = req.environ[wsgi.CONTEXT_KEY] + group = models.Configuration.load(context, id) + self.authorize_config_action(context, 'delete', group) context.notification = notification.DBaaSConfigurationDelete( context, request=req) with StartNotification(context, configuration_id=id): - group = models.Configuration.load(context, id) instances = instances_models.DBInstance.find_all( tenant_id=context.tenant, configuration_id=id, @@ -157,6 +169,15 @@ class ConfigurationsController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] group = models.Configuration.load(context, id) + # Note that changing the configuration group will also + # indirectly affect all the instances which attach it. + # + # The Trove instance itself won't be changed (the same group is still + # attached) but the configuration values will. + # + # The operator needs to keep this in mind when defining the related + # policies. + self.authorize_config_action(context, 'update', group) # if name/description are provided in the request body, update the # model with these values as well. @@ -181,10 +202,11 @@ class ConfigurationsController(wsgi.Controller): def edit(self, req, body, tenant_id, id): context = req.environ[wsgi.CONTEXT_KEY] + group = models.Configuration.load(context, id) + self.authorize_config_action(context, 'edit', group) context.notification = notification.DBaaSConfigurationEdit( context, request=req) with StartNotification(context, configuration_id=id): - group = models.Configuration.load(context, id) items = self._configuration_items_list(group, body['configuration']) models.Configuration.save(group, items) @@ -329,7 +351,18 @@ class ConfigurationsController(wsgi.Controller): class ParametersController(wsgi.Controller): + @classmethod + def authorize_request(cls, req, rule_name): + """Parameters (configuration templates) bind to a datastore. + Datastores are not owned by any particular tenant so we only check + the current tenant is allowed to perform the action. + """ + context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'configuration-parameter:%s' + % rule_name) + def index(self, req, tenant_id, datastore, id): + self.authorize_request(req, 'index') ds, ds_version = ds_models.get_datastore_version( type=datastore, version=id) rules = models.DatastoreConfigurationParameters.load_parameters( @@ -338,6 +371,7 @@ class ParametersController(wsgi.Controller): 200) def show(self, req, tenant_id, datastore, id, name): + self.authorize_request(req, 'show') ds, ds_version = ds_models.get_datastore_version( type=datastore, version=id) rule = models.DatastoreConfigurationParameters.load_parameter_by_name( @@ -345,6 +379,7 @@ class ParametersController(wsgi.Controller): return wsgi.Result(views.ConfigurationParameterView(rule).data(), 200) def index_by_version(self, req, tenant_id, version): + self.authorize_request(req, 'index_by_version') ds_version = ds_models.DatastoreVersion.load_by_uuid(version) rules = models.DatastoreConfigurationParameters.load_parameters( ds_version.id) @@ -352,6 +387,7 @@ class ParametersController(wsgi.Controller): 200) def show_by_version(self, req, tenant_id, version, name): + self.authorize_request(req, 'show_by_version') ds_models.DatastoreVersion.load_by_uuid(version) rule = models.DatastoreConfigurationParameters.load_parameter_by_name( version, name) diff --git a/trove/datastore/service.py b/trove/datastore/service.py index 6a04a1ce49..0f69c029f6 100644 --- a/trove/datastore/service.py +++ b/trove/datastore/service.py @@ -16,6 +16,7 @@ # under the License. # +from trove.common import policy from trove.common import wsgi from trove.datastore import models, views from trove.flavor import views as flavor_views @@ -23,7 +24,16 @@ from trove.flavor import views as flavor_views class DatastoreController(wsgi.Controller): + @classmethod + def authorize_request(cls, req, rule_name): + """Datastores are not owned by any particular tenant so we only check + the current tenant is allowed to perform the action. + """ + context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'datastore:%s' % rule_name) + def show(self, req, tenant_id, id): + self.authorize_request(req, 'show') datastore = models.Datastore.load(id) datastore_versions = (models.DatastoreVersions.load(datastore.id)) return wsgi.Result(views. @@ -31,6 +41,7 @@ class DatastoreController(wsgi.Controller): req).data(), 200) def index(self, req, tenant_id): + self.authorize_request(req, 'index') context = req.environ[wsgi.CONTEXT_KEY] only_active = True if context.is_admin: @@ -42,17 +53,20 @@ class DatastoreController(wsgi.Controller): req).data(), 200) def version_show(self, req, tenant_id, datastore, id): + self.authorize_request(req, 'version_show') datastore = models.Datastore.load(datastore) datastore_version = models.DatastoreVersion.load(datastore, id) return wsgi.Result(views.DatastoreVersionView(datastore_version, req).data(), 200) def version_show_by_uuid(self, req, tenant_id, uuid): + self.authorize_request(req, 'version_show_by_uuid') datastore_version = models.DatastoreVersion.load_by_uuid(uuid) return wsgi.Result(views.DatastoreVersionView(datastore_version, req).data(), 200) def version_index(self, req, tenant_id, datastore): + self.authorize_request(req, 'version_index') context = req.environ[wsgi.CONTEXT_KEY] only_active = True if context.is_admin: @@ -70,6 +84,7 @@ class DatastoreController(wsgi.Controller): one or more entries are found in datastore_version_metadata, in which case only those are returned. """ + self.authorize_request(req, 'list_associated_flavors') context = req.environ[wsgi.CONTEXT_KEY] flavors = (models.DatastoreVersionMetadata. list_datastore_version_flavor_associations( diff --git a/trove/extensions/common/service.py b/trove/extensions/common/service.py index 8120b48c5f..7866926610 100644 --- a/trove/extensions/common/service.py +++ b/trove/extensions/common/service.py @@ -21,14 +21,17 @@ from oslo_log import log as logging from oslo_utils import importutils import six +from trove.cluster import models as cluster_models from trove.cluster.models import DBCluster from trove.common import cfg from trove.common import exception from trove.common.i18n import _LI +from trove.common import policy from trove.common import wsgi from trove.datastore import models as datastore_models from trove.extensions.common import models from trove.extensions.common import views +from trove.instance import models as instance_models from trove.instance.models import DBInstance @@ -37,8 +40,30 @@ import_class = importutils.import_class CONF = cfg.CONF +class ExtensionController(wsgi.Controller): + + @classmethod + def authorize_target_action(cls, context, target_rule_name, + target_id, is_cluster=False): + target = None + if is_cluster: + target = cluster_models.Cluster.load(context, target_id) + else: + target = instance_models.Instance.load(context, target_id) + + if not target: + if is_cluster: + raise exception.ClusterNotFound(cluster=target_id) + raise exception.InstanceNotFound(instance=target_id) + + target_type = 'cluster' if is_cluster else 'instance' + policy.authorize_on_target( + context, '%s:extension:%s' % (target_type, target_rule_name), + {'tenant': target.tenant_id}) + + @six.add_metaclass(abc.ABCMeta) -class BaseDatastoreRootController(wsgi.Controller): +class BaseDatastoreRootController(ExtensionController): """Base class that defines the contract for root controllers.""" @abc.abstractmethod @@ -174,13 +199,16 @@ class ClusterRootController(DefaultRootController): return single_instance_id, instance_ids -class RootController(wsgi.Controller): +class RootController(ExtensionController): """Controller for instance functionality.""" def index(self, req, tenant_id, instance_id): """Returns True if root is enabled; False otherwise.""" datastore_manager, is_cluster = self._get_datastore(tenant_id, instance_id) + context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'root:index', instance_id, + is_cluster=is_cluster) root_controller = self.load_root_controller(datastore_manager) return root_controller.root_index(req, tenant_id, instance_id, is_cluster) @@ -189,6 +217,9 @@ class RootController(wsgi.Controller): """Enable the root user for the db instance.""" datastore_manager, is_cluster = self._get_datastore(tenant_id, instance_id) + context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'root:create', instance_id, + is_cluster=is_cluster) root_controller = self.load_root_controller(datastore_manager) if root_controller is not None: return root_controller.root_create(req, body, tenant_id, @@ -199,6 +230,9 @@ class RootController(wsgi.Controller): def delete(self, req, tenant_id, instance_id): datastore_manager, is_cluster = self._get_datastore(tenant_id, instance_id) + context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'root:delete', instance_id, + is_cluster=is_cluster) root_controller = self.load_root_controller(datastore_manager) if root_controller is not None: return root_controller.root_delete(req, tenant_id, diff --git a/trove/extensions/mysql/service.py b/trove/extensions/mysql/service.py index 8ea01954fb..fb444ededc 100644 --- a/trove/extensions/mysql/service.py +++ b/trove/extensions/mysql/service.py @@ -30,6 +30,7 @@ from trove.common import pagination from trove.common.utils import correct_id_with_req from trove.common import wsgi from trove.extensions.common.service import DefaultRootController +from trove.extensions.common.service import ExtensionController from trove.extensions.mysql.common import populate_users from trove.extensions.mysql.common import populate_validated_databases from trove.extensions.mysql.common import unquote_user_host @@ -42,7 +43,7 @@ import_class = importutils.import_class CONF = cfg.CONF -class UserController(wsgi.Controller): +class UserController(ExtensionController): """Controller for instance functionality.""" schemas = apischema.user @@ -60,6 +61,7 @@ class UserController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'user:index', instance_id) users, next_marker = models.Users.load(context, instance_id) view = views.UsersView(users) paged = pagination.SimplePaginatedDataView(req.url, 'users', view, @@ -75,6 +77,7 @@ class UserController(wsgi.Controller): "req": strutils.mask_password(req), "body": strutils.mask_password(body)}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'user:create', instance_id) context.notification = notification.DBaaSUserCreate(context, request=req) users = body['users'] @@ -94,6 +97,7 @@ class UserController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'user:delete', instance_id) id = correct_id_with_req(id, req) username, host = unquote_user_host(id) user = None @@ -122,6 +126,7 @@ class UserController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'user:show', instance_id) id = correct_id_with_req(id, req) username, host = unquote_user_host(id) user = None @@ -141,6 +146,7 @@ class UserController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": strutils.mask_password(req)}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'user:update', instance_id) id = correct_id_with_req(id, req) username, hostname = unquote_user_host(id) user = None @@ -171,6 +177,7 @@ class UserController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": strutils.mask_password(req)}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action(context, 'user:update_all', instance_id) context.notification = notification.DBaaSUserChangePassword( context, request=req) users = body['users'] @@ -203,7 +210,7 @@ class UserController(wsgi.Controller): return wsgi.Result(None, 202) -class UserAccessController(wsgi.Controller): +class UserAccessController(ExtensionController): """Controller for adding and removing database access for a user.""" schemas = apischema.user @@ -232,6 +239,8 @@ class UserAccessController(wsgi.Controller): {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action( + context, 'user_access:index', instance_id) # Make sure this user exists. user_id = correct_id_with_req(user_id, req) user = self._get_user(context, instance_id, user_id) @@ -249,6 +258,8 @@ class UserAccessController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action( + context, 'user_access:update', instance_id) context.notification = notification.DBaaSUserGrant( context, request=req) user_id = correct_id_with_req(user_id, req) @@ -270,6 +281,8 @@ class UserAccessController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action( + context, 'user_access:delete', instance_id) context.notification = notification.DBaaSUserRevoke( context, request=req) user_id = correct_id_with_req(user_id, req) @@ -288,7 +301,7 @@ class UserAccessController(wsgi.Controller): return wsgi.Result(None, 202) -class SchemaController(wsgi.Controller): +class SchemaController(ExtensionController): """Controller for instance functionality.""" schemas = apischema.dbschema @@ -299,6 +312,8 @@ class SchemaController(wsgi.Controller): {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action( + context, 'database:index', instance_id) schemas, next_marker = models.Schemas.load(context, instance_id) view = views.SchemasView(schemas) paged = pagination.SimplePaginatedDataView(req.url, 'databases', view, @@ -315,6 +330,8 @@ class SchemaController(wsgi.Controller): "body": body}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action( + context, 'database:create', instance_id) schemas = body['databases'] context.notification = notification.DBaaSDatabaseCreate(context, request=req) @@ -334,6 +351,8 @@ class SchemaController(wsgi.Controller): "req : '%(req)s'\n\n") % {"id": instance_id, "req": req}) context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action( + context, 'database:delete', instance_id) context.notification = notification.DBaaSDatabaseDelete( context, request=req) with StartNotification(context, instance_id=instance_id, dbname=id): @@ -349,6 +368,9 @@ class SchemaController(wsgi.Controller): return wsgi.Result(None, 202) def show(self, req, tenant_id, instance_id, id): + context = req.environ[wsgi.CONTEXT_KEY] + self.authorize_target_action( + context, 'database:show', instance_id) raise webob.exc.HTTPNotImplemented() diff --git a/trove/flavor/service.py b/trove/flavor/service.py index ea5b0d728a..60e879359c 100644 --- a/trove/flavor/service.py +++ b/trove/flavor/service.py @@ -17,6 +17,7 @@ import six from trove.common import exception +from trove.common import policy from trove.common import wsgi from trove.flavor import models from trove.flavor import views @@ -30,12 +31,16 @@ class FlavorController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] self._validate_flavor_id(id) flavor = models.Flavor(context=context, flavor_id=id) + # Flavors do not bind to a particular tenant. + # Only authorize the current tenant. + policy.authorize_on_tenant(context, 'flavor:show') # Pass in the request to build accurate links. return wsgi.Result(views.FlavorView(flavor, req).data(), 200) def index(self, req, tenant_id): """Return all flavors.""" context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'flavor:index') flavors = models.Flavors(context=context) return wsgi.Result(views.FlavorsView(flavors, req).data(), 200) diff --git a/trove/guestagent/guest_log.py b/trove/guestagent/guest_log.py index 23be944a7b..26170c4bc7 100644 --- a/trove/guestagent/guest_log.py +++ b/trove/guestagent/guest_log.py @@ -209,8 +209,7 @@ class GuestLog(object): 'metafile': self._metafile_name() } else: - raise exception.UnauthorizedRequest(_( - "Not authorized to show log '%s'.") % self._name) + raise exception.LogAccessForbidden(action='show', log=self._name) def _refresh_details(self): @@ -310,16 +309,16 @@ class GuestLog(object): self._file) return self.show() else: - raise exception.UnauthorizedRequest(_( - "Not authorized to publish log '%s'.") % self._name) + raise exception.LogAccessForbidden( + action='publish', log=self._name) def discard_log(self): if self.exposed: self._delete_log_components() return self.show() else: - raise exception.UnauthorizedRequest(_( - "Not authorized to discard log '%s'.") % self._name) + raise exception.LogAccessForbidden( + action='discard', log=self._name) def _delete_log_components(self): container_name = self.get_container_name(force=True) diff --git a/trove/instance/service.py b/trove/instance/service.py index 6f2e892757..686e3e53c2 100644 --- a/trove/instance/service.py +++ b/trove/instance/service.py @@ -27,6 +27,7 @@ from trove.common.i18n import _LI from trove.common import notification from trove.common.notification import StartNotification from trove.common import pagination +from trove.common import policy from trove.common.remote import create_guest_client from trove.common import utils from trove.common import wsgi @@ -47,6 +48,11 @@ class InstanceController(wsgi.Controller): """Controller for instance functionality.""" schemas = apischema.instance.copy() + @classmethod + def authorize_instance_action(cls, context, instance_rule_name, instance): + policy.authorize_on_target(context, 'instance:%s' % instance_rule_name, + {'tenant': instance.tenant_id}) + @classmethod def get_action_schema(cls, body, action_schema): action_type = list(body.keys())[0] @@ -106,6 +112,7 @@ class InstanceController(wsgi.Controller): def _action_restart(self, context, req, instance, body): context.notification = notification.DBaaSInstanceRestart(context, request=req) + self.authorize_instance_action(context, 'restart', instance) with StartNotification(context, instance_id=instance.id): instance.restart() return wsgi.Result(None, 202) @@ -136,6 +143,8 @@ class InstanceController(wsgi.Controller): def _action_resize_volume(self, context, req, instance, volume): context.notification = notification.DBaaSInstanceResizeVolume( context, request=req) + self.authorize_instance_action(context, 'resize_volume', instance) + with StartNotification(context, instance_id=instance.id, new_size=volume['size']): instance.resize_volume(volume['size']) @@ -144,6 +153,8 @@ class InstanceController(wsgi.Controller): def _action_resize_flavor(self, context, req, instance, flavorRef): context.notification = notification.DBaaSInstanceResizeInstance( context, request=req) + self.authorize_instance_action(context, 'resize_flavor', instance) + new_flavor_id = utils.get_id_from_href(flavorRef) with StartNotification(context, instance_id=instance.id, new_flavor_id=new_flavor_id): @@ -154,6 +165,8 @@ class InstanceController(wsgi.Controller): raise webob.exc.HTTPNotImplemented() def _action_promote_to_replica_source(self, context, req, instance, body): + self.authorize_instance_action( + context, 'promote_to_replica_source', instance) context.notification = notification.DBaaSInstanceEject(context, request=req) with StartNotification(context, instance_id=instance.id): @@ -161,6 +174,8 @@ class InstanceController(wsgi.Controller): return wsgi.Result(None, 202) def _action_eject_replica_source(self, context, req, instance, body): + self.authorize_instance_action( + context, 'eject_replica_source', instance) context.notification = notification.DBaaSInstancePromote(context, request=req) with StartNotification(context, instance_id=instance.id): @@ -168,6 +183,11 @@ class InstanceController(wsgi.Controller): return wsgi.Result(None, 202) def _action_reset_status(self, context, req, instance, body): + if 'force_delete' in body['reset_status']: + self.authorize_instance_action(context, 'force_delete', instance) + else: + self.authorize_instance_action( + context, 'reset_status', instance) context.notification = notification.DBaaSInstanceResetStatus( context, request=req) with StartNotification(context, instance_id=instance.id): @@ -183,6 +203,7 @@ class InstanceController(wsgi.Controller): LOG.info(_LI("Listing database instances for tenant '%s'"), tenant_id) LOG.debug("req : '%s'\n\n", req) context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'instance:index') clustered_q = req.GET.get('include_clustered', '').lower() include_clustered = clustered_q == 'true' servers, marker = models.Instances.load(context, include_clustered) @@ -197,6 +218,10 @@ class InstanceController(wsgi.Controller): id) LOG.debug("req : '%s'\n\n", req) context = req.environ[wsgi.CONTEXT_KEY] + + instance = models.Instance.load(context, id) + self.authorize_instance_action(context, 'backups', instance) + backups, marker = backup_model.list_for_instance(context, id) view = backup_views.BackupViews(backups) paged = pagination.SimplePaginatedDataView(req.url, 'backups', view, @@ -213,6 +238,7 @@ class InstanceController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] server = models.load_instance_with_info(models.DetailInstance, context, id) + self.authorize_instance_action(context, 'show', server) return wsgi.Result(views.InstanceDetailView(server, req=req).data(), 200) @@ -224,6 +250,7 @@ class InstanceController(wsgi.Controller): LOG.debug("req : '%s'\n\n", req) context = req.environ[wsgi.CONTEXT_KEY] instance = models.load_any_instance(context, id) + self.authorize_instance_action(context, 'delete', instance) context.notification = notification.DBaaSInstanceDelete( context, request=req) with StartNotification(context, instance_id=instance.id): @@ -247,6 +274,7 @@ class InstanceController(wsgi.Controller): LOG.debug("req : '%s'\n\n", strutils.mask_password(req)) LOG.debug("body : '%s'\n\n", strutils.mask_password(body)) context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'instance:create') context.notification = notification.DBaaSInstanceCreate(context, request=req) datastore_args = body['instance'].get('datastore', {}) @@ -268,6 +296,25 @@ class InstanceController(wsgi.Controller): except ValueError as ve: raise exception.BadRequest(msg=ve) + modules = body['instance'].get('modules') + + # The following operations have their own API calls. + # We need to make sure the same policies are enforced when + # creating an instance. + # i.e. if attaching configuration group to an existing instance is not + # allowed, it should not be possible to create a new instance with the + # group attached either + if configuration: + policy.authorize_on_tenant(context, 'instance:update') + if modules: + policy.authorize_on_tenant(context, 'instance:module_apply') + if users: + policy.authorize_on_tenant( + context, 'instance:extension:user:create') + if databases: + policy.authorize_on_tenant( + context, 'instance:extension:database:create') + if 'volume' in body['instance']: volume_info = body['instance']['volume'] volume_size = int(volume_info['size']) @@ -289,7 +336,6 @@ class InstanceController(wsgi.Controller): # also check for older name body['instance'].get('slave_of')) replica_count = body['instance'].get('replica_count') - modules = body['instance'].get('modules') locality = body['instance'].get('locality') if locality: locality_domain = ['affinity', 'anti-affinity'] @@ -371,6 +417,7 @@ class InstanceController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] instance = models.Instance.load(context, id) + self.authorize_instance_action(context, 'update', instance) # Make sure args contains a 'configuration_id' argument, args = {} @@ -388,6 +435,7 @@ class InstanceController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] instance = models.Instance.load(context, id) + self.authorize_instance_action(context, 'edit', instance) args = {} args['detach_replica'] = ('replica_of' in body['instance'] or @@ -411,6 +459,8 @@ class InstanceController(wsgi.Controller): LOG.info(_LI("Getting default configuration for instance %s"), id) context = req.environ[wsgi.CONTEXT_KEY] instance = models.Instance.load(context, id) + self.authorize_instance_action(context, 'configuration', instance) + LOG.debug("Server: %s", instance) config = instance.get_default_configuration_template() LOG.debug("Default config for instance %(instance_id)s is %(config)s", @@ -425,6 +475,7 @@ class InstanceController(wsgi.Controller): instance = models.Instance.load(context, id) if not instance: raise exception.NotFound(uuid=id) + self.authorize_instance_action(context, 'guest_log_list', instance) client = create_guest_client(context, id) guest_log_list = client.guest_log_list() return wsgi.Result({'logs': guest_log_list}, 200) @@ -454,6 +505,7 @@ class InstanceController(wsgi.Controller): instance = models.Instance.load(context, id) if not instance: raise exception.NotFound(uuid=id) + self.authorize_instance_action(context, 'module_list', instance) from_guest = bool(req.GET.get('from_guest', '').lower()) include_contents = bool(req.GET.get('include_contents', '').lower()) if from_guest: @@ -481,6 +533,7 @@ class InstanceController(wsgi.Controller): instance = models.Instance.load(context, id) if not instance: raise exception.NotFound(uuid=id) + self.authorize_instance_action(context, 'module_apply', instance) module_ids = [mod['id'] for mod in body.get('modules', [])] modules = module_models.Modules.load_by_ids(context, module_ids) module_list = [] @@ -501,6 +554,7 @@ class InstanceController(wsgi.Controller): instance = models.Instance.load(context, id) if not instance: raise exception.NotFound(uuid=id) + self.authorize_instance_action(context, 'module_remove', instance) module = module_models.Module.load(context, module_id) module_info = module_views.DetailedModuleView(module).data() client = create_guest_client(context, id) diff --git a/trove/limits/service.py b/trove/limits/service.py index 28d3ea663c..5200b79ff6 100644 --- a/trove/limits/service.py +++ b/trove/limits/service.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +from trove.common import policy from trove.common import wsgi from trove.limits import views from trove.quota.quota import QUOTAS @@ -27,6 +28,8 @@ class LimitsController(wsgi.Controller): """ Return all absolute and rate limit information. """ + context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'limits:index') quotas = QUOTAS.get_all_quotas_by_tenant(tenant_id) abs_limits = {k: v['hard_limit'] for k, v in quotas.items()} rate_limits = req.environ.get("trove.limits", []) diff --git a/trove/module/service.py b/trove/module/service.py index 555fee5de3..c6b08e1c3c 100644 --- a/trove/module/service.py +++ b/trove/module/service.py @@ -22,6 +22,7 @@ import trove.common.apischema as apischema from trove.common import exception from trove.common.i18n import _ from trove.common import pagination +from trove.common import policy from trove.common import wsgi from trove.datastore import models as datastore_models from trove.instance import models as instance_models @@ -37,8 +38,20 @@ class ModuleController(wsgi.Controller): schemas = apischema.module + @classmethod + def authorize_module_action(cls, context, module_rule_name, module): + """If a modules in not owned by any particular tenant just check + the current tenant is allowed to perform the action. + """ + if module.tenant_id is not None: + policy.authorize_on_target(context, 'module:%s' % module_rule_name, + {'tenant': module.tenant_id}) + else: + policy.authorize_on_tenant(context, 'module:%s' % module_rule_name) + def index(self, req, tenant_id): context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'module:index') datastore = req.GET.get('datastore', '') if datastore and datastore.lower() != models.Modules.MATCH_ALL_NAME: ds, ds_ver = datastore_models.get_datastore_version( @@ -53,6 +66,7 @@ class ModuleController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] module = models.Module.load(context, id) + self.authorize_module_action(context, 'show', module) module.instance_count = len(models.InstanceModules.load( context, module_id=module.id, md5=module.md5)) @@ -65,6 +79,7 @@ class ModuleController(wsgi.Controller): LOG.info(_("Creating module '%s'") % name) context = req.environ[wsgi.CONTEXT_KEY] + policy.authorize_on_tenant(context, 'module:create') module_type = body['module']['module_type'] contents = body['module']['contents'] @@ -89,6 +104,7 @@ class ModuleController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] module = models.Module.load(context, id) + self.authorize_module_action(context, 'delete', module) models.Module.delete(context, module) return wsgi.Result(None, 200) @@ -97,6 +113,7 @@ class ModuleController(wsgi.Controller): context = req.environ[wsgi.CONTEXT_KEY] module = models.Module.load(context, id) + self.authorize_module_action(context, 'update', module) original_module = copy.deepcopy(module) if 'name' in body['module']: module.name = body['module']['name'] @@ -146,6 +163,10 @@ class ModuleController(wsgi.Controller): LOG.info(_("Getting instances for module %s") % id) context = req.environ[wsgi.CONTEXT_KEY] + + module = models.Module.load(context, id) + self.authorize_module_action(context, 'instances', module) + instance_modules, marker = models.InstanceModules.load( context, module_id=id) if instance_modules: diff --git a/trove/tests/api/mgmt/instances_actions.py b/trove/tests/api/mgmt/instances_actions.py index 42c5230f70..beebaf7bd3 100644 --- a/trove/tests/api/mgmt/instances_actions.py +++ b/trove/tests/api/mgmt/instances_actions.py @@ -18,6 +18,7 @@ from proboscis import after_class from proboscis.asserts import assert_equal from proboscis.asserts import assert_raises from proboscis import before_class +from proboscis import SkipTest from proboscis import test from trove.backup import models as backup_models @@ -30,6 +31,7 @@ from trove.extensions.mgmt.instances.service import MgmtInstanceController from trove.instance import models as imodels from trove.instance.models import DBInstance from trove.instance.tasks import InstanceTasks +from trove.tests.config import CONFIG from trove.tests.util import create_dbaas_client from trove.tests.util import test_config from trove.tests.util.users import Requirements @@ -79,6 +81,7 @@ class MgmtInstanceBase(object): @test(groups=[GROUP]) class RestartTaskStatusTests(MgmtInstanceBase): + @before_class def setUp(self): super(RestartTaskStatusTests, self).setUp() @@ -137,6 +140,9 @@ class RestartTaskStatusTests(MgmtInstanceBase): @test def mgmt_reset_task_status_clears_backups(self): + if CONFIG.fake_mode: + raise SkipTest("Test requires an instance.") + self.reset_task_status() self._reload_db_info() assert_equal(self.db_info.task_status, InstanceTasks.NONE) @@ -201,5 +207,6 @@ class RestartTaskStatusTests(MgmtInstanceBase): found_backup.delete() admin = test_config.users.find_user(Requirements(is_admin=True)) admin_dbaas = create_dbaas_client(admin) - result = admin_dbaas.instances.backups(self.db_info.id) - assert_equal(0, len(result)) + if not CONFIG.fake_mode: + result = admin_dbaas.instances.backups(self.db_info.id) + assert_equal(0, len(result)) diff --git a/trove/tests/scenario/runners/instance_force_delete_runners.py b/trove/tests/scenario/runners/instance_force_delete_runners.py index 03045a29be..9a5ffd2b4e 100644 --- a/trove/tests/scenario/runners/instance_force_delete_runners.py +++ b/trove/tests/scenario/runners/instance_force_delete_runners.py @@ -46,8 +46,9 @@ class InstanceForceDeleteRunner(TestRunner): def run_delete_build_instance(self, expected_http_code=202): if self.build_inst_id: - self.auth_client.instances.force_delete(self.build_inst_id) - self.assert_client_code(expected_http_code) + self.admin_client.instances.force_delete(self.build_inst_id) + self.assert_client_code(expected_http_code, + client=self.admin_client) def run_wait_for_force_delete(self): if self.build_inst_id: diff --git a/trove/tests/unittests/api/common/test_limits.py b/trove/tests/unittests/api/common/test_limits.py index 5757f5a4c6..e90840cde9 100644 --- a/trove/tests/unittests/api/common/test_limits.py +++ b/trove/tests/unittests/api/common/test_limits.py @@ -45,6 +45,7 @@ class BaseLimitTestSuite(trove_testtools.TestCase): def setUp(self): super(BaseLimitTestSuite, self).setUp() + self.context = trove_testtools.TroveTestContext(self) self.absolute_limits = {"max_instances": 55, "max_volumes": 100, "max_backups": 40} @@ -60,7 +61,7 @@ class LimitsControllerTest(BaseLimitTestSuite): limit_controller = LimitsController() req = MagicMock() - req.environ = {} + req.environ = {'trove.context': self.context} view = limit_controller.index(req, "test_tenant_id") expected = {'limits': [{'verb': 'ABSOLUTE'}]} @@ -122,7 +123,7 @@ class LimitsControllerTest(BaseLimitTestSuite): hard_limit=55)} req = MagicMock() - req.environ = {"trove.limits": limits} + req.environ = {"trove.limits": limits, 'trove.context': self.context} with patch.object(QUOTAS, 'get_all_quotas_by_tenant', return_value=abs_limits): diff --git a/trove/tests/unittests/cluster/test_cluster_controller.py b/trove/tests/unittests/cluster/test_cluster_controller.py index e4554f6641..107c8cd58a 100644 --- a/trove/tests/unittests/cluster/test_cluster_controller.py +++ b/trove/tests/unittests/cluster/test_cluster_controller.py @@ -18,7 +18,6 @@ import jsonschema from mock import MagicMock from mock import Mock from mock import patch -from testtools import TestCase from testtools.matchers import Is, Equals from trove.cluster import models from trove.cluster.models import Cluster, DBCluster @@ -33,7 +32,8 @@ from trove.datastore import models as datastore_models from trove.tests.unittests import trove_testtools -class TestClusterController(TestCase): +class TestClusterController(trove_testtools.TestCase): + def setUp(self): super(TestClusterController, self).setUp() self.controller = ClusterController() @@ -248,7 +248,8 @@ class TestClusterController(TestCase): cluster.delete.assert_called_with() -class TestClusterControllerWithStrategy(TestCase): +class TestClusterControllerWithStrategy(trove_testtools.TestCase): + def setUp(self): super(TestClusterControllerWithStrategy, self).setUp() self.controller = ClusterController() diff --git a/trove/tests/unittests/common/test_common_extensions.py b/trove/tests/unittests/common/test_common_extensions.py index 07cc0d6775..1c2e35d4a1 100644 --- a/trove/tests/unittests/common/test_common_extensions.py +++ b/trove/tests/unittests/common/test_common_extensions.py @@ -24,6 +24,7 @@ from trove.extensions.common import models from trove.extensions.common.service import ClusterRootController from trove.extensions.common.service import DefaultRootController from trove.extensions.common.service import RootController +from trove.instance import models as instance_models from trove.instance.models import DBInstance from trove.tests.unittests import trove_testtools @@ -90,16 +91,20 @@ class TestRootController(trove_testtools.TestCase): def setUp(self): super(TestRootController, self).setUp() + self.context = trove_testtools.TroveTestContext(self) self.controller = RootController() + @patch.object(instance_models.Instance, "load") @patch.object(RootController, "load_root_controller") @patch.object(RootController, "_get_datastore") - def test_index(self, service_get_datastore, service_load_root_controller): + def test_index(self, service_get_datastore, service_load_root_controller, + service_load_instance): req = Mock() + req.environ = {'trove.context': self.context} tenant_id = Mock() uuid = utils.generate_uuid() ds_manager = Mock() - is_cluster = Mock() + is_cluster = False service_get_datastore.return_value = (ds_manager, is_cluster) root_controller = Mock() ret = Mock() @@ -112,15 +117,18 @@ class TestRootController(trove_testtools.TestCase): root_controller.root_index.assert_called_with( req, tenant_id, uuid, is_cluster) + @patch.object(instance_models.Instance, "load") @patch.object(RootController, "load_root_controller") @patch.object(RootController, "_get_datastore") - def test_create(self, service_get_datastore, service_load_root_controller): + def test_create(self, service_get_datastore, service_load_root_controller, + service_load_instance): req = Mock() + req.environ = {'trove.context': self.context} body = Mock() tenant_id = Mock() uuid = utils.generate_uuid() ds_manager = Mock() - is_cluster = Mock() + is_cluster = False service_get_datastore.return_value = (ds_manager, is_cluster) root_controller = Mock() ret = Mock() @@ -134,17 +142,20 @@ class TestRootController(trove_testtools.TestCase): root_controller.root_create.assert_called_with( req, body, tenant_id, uuid, is_cluster) + @patch.object(instance_models.Instance, "load") @patch.object(RootController, "load_root_controller") @patch.object(RootController, "_get_datastore") def test_create_with_no_root_controller(self, service_get_datastore, - service_load_root_controller): + service_load_root_controller, + service_load_instance): req = Mock() + req.environ = {'trove.context': self.context} body = Mock() tenant_id = Mock() uuid = utils.generate_uuid() ds_manager = Mock() - is_cluster = Mock() + is_cluster = False service_get_datastore.return_value = (ds_manager, is_cluster) service_load_root_controller.return_value = None @@ -160,6 +171,7 @@ class TestClusterRootController(trove_testtools.TestCase): def setUp(self): super(TestClusterRootController, self).setUp() + self.context = trove_testtools.TroveTestContext(self) self.controller = ClusterRootController() @patch.object(ClusterRootController, "cluster_root_index") @@ -204,22 +216,18 @@ class TestClusterRootController(trove_testtools.TestCase): @patch.object(models.ClusterRoot, "load") def test_instance_root_index(self, mock_cluster_root_load): - context = Mock() req = Mock() - req.environ = Mock() - req.environ.__getitem__ = Mock(return_value=context) + req.environ = {'trove.context': self.context} tenant_id = Mock() instance_id = utils.generate_uuid() self.controller.instance_root_index(req, tenant_id, instance_id) - mock_cluster_root_load.assert_called_with(context, instance_id) + mock_cluster_root_load.assert_called_with(self.context, instance_id) @patch.object(models.ClusterRoot, "load", side_effect=exception.UnprocessableEntity()) def test_instance_root_index_exception(self, mock_cluster_root_load): - context = Mock() req = Mock() - req.environ = Mock() - req.environ.__getitem__ = Mock(return_value=context) + req.environ = {'trove.context': self.context} tenant_id = Mock() instance_id = utils.generate_uuid() self.assertRaises( @@ -227,7 +235,7 @@ class TestClusterRootController(trove_testtools.TestCase): self.controller.instance_root_index, req, tenant_id, instance_id ) - mock_cluster_root_load.assert_called_with(context, instance_id) + mock_cluster_root_load.assert_called_with(self.context, instance_id) @patch.object(ClusterRootController, "instance_root_index") @patch.object(ClusterRootController, "_get_cluster_instance_id") @@ -278,12 +286,10 @@ class TestClusterRootController(trove_testtools.TestCase): @patch.object(models.ClusterRoot, "create") def test_instance_root_create(self, mock_cluster_root_create): user = Mock() - context = Mock() - context.user = Mock() - context.user.__getitem__ = Mock(return_value=user) + self.context.user = Mock() + self.context.user.__getitem__ = Mock(return_value=user) req = Mock() - req.environ = Mock() - req.environ.__getitem__ = Mock(return_value=context) + req.environ = {'trove.context': self.context} password = Mock() body = {'password': password} instance_id = utils.generate_uuid() @@ -291,17 +297,16 @@ class TestClusterRootController(trove_testtools.TestCase): self.controller.instance_root_create( req, body, instance_id, cluster_instances) mock_cluster_root_create.assert_called_with( - context, instance_id, context.user, password, cluster_instances) + self.context, instance_id, self.context.user, password, + cluster_instances) @patch.object(models.ClusterRoot, "create") def test_instance_root_create_no_body(self, mock_cluster_root_create): user = Mock() - context = Mock() - context.user = Mock() - context.user.__getitem__ = Mock(return_value=user) + self.context.user = Mock() + self.context.user.__getitem__ = Mock(return_value=user) req = Mock() - req.environ = Mock() - req.environ.__getitem__ = Mock(return_value=context) + req.environ = {'trove.context': self.context} password = None body = None instance_id = utils.generate_uuid() @@ -309,4 +314,5 @@ class TestClusterRootController(trove_testtools.TestCase): self.controller.instance_root_create( req, body, instance_id, cluster_instances) mock_cluster_root_create.assert_called_with( - context, instance_id, context.user, password, cluster_instances) + self.context, instance_id, self.context.user, password, + cluster_instances) diff --git a/trove/tests/unittests/common/test_policy.py b/trove/tests/unittests/common/test_policy.py new file mode 100644 index 0000000000..eebdeb132f --- /dev/null +++ b/trove/tests/unittests/common/test_policy.py @@ -0,0 +1,53 @@ +# Copyright 2016 Tesora Inc. +# 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 mock import MagicMock +from mock import NonCallableMock +from mock import patch + +from trove.common import exception as trove_exceptions +from trove.common import policy as trove_policy +from trove.tests.unittests import trove_testtools + + +class TestPolicy(trove_testtools.TestCase): + + def setUp(self): + super(TestPolicy, self).setUp() + self.context = trove_testtools.TroveTestContext(self) + self.mock_enforcer = MagicMock() + get_enforcer_patch = patch.object(trove_policy, 'get_enforcer', + return_value=self.mock_enforcer) + self.addCleanup(get_enforcer_patch.stop) + self.mock_get_enforcer = get_enforcer_patch.start() + + def test_authorize_on_tenant(self): + test_rule = NonCallableMock() + trove_policy.authorize_on_tenant(self.context, test_rule) + self.mock_get_enforcer.assert_called_once_with() + self.mock_enforcer.authorize.assert_called_once_with( + test_rule, {'tenant': self.context.tenant}, self.context.to_dict(), + do_raise=True, exc=trove_exceptions.PolicyNotAuthorized, + action=test_rule) + + def test_authorize_on_target(self): + test_rule = NonCallableMock() + test_target = NonCallableMock() + trove_policy.authorize_on_target(self.context, test_rule, test_target) + self.mock_get_enforcer.assert_called_once_with() + self.mock_enforcer.authorize.assert_called_once_with( + test_rule, test_target, self.context.to_dict(), + do_raise=True, exc=trove_exceptions.PolicyNotAuthorized, + action=test_rule) diff --git a/trove/tests/unittests/trove_testtools.py b/trove/tests/unittests/trove_testtools.py index 346c743f4d..36413b1e50 100644 --- a/trove/tests/unittests/trove_testtools.py +++ b/trove/tests/unittests/trove_testtools.py @@ -23,6 +23,7 @@ import testtools from trove.common import cfg from trove.common.context import TroveContext from trove.common.notification import DBaaSAPINotification +from trove.common import policy from trove.tests import root_logger @@ -101,6 +102,11 @@ class TestCase(testtools.TestCase): # Default manager used by all unittsest unless explicitly overridden. self.patch_datastore_manager('mysql') + policy_patcher = mock.patch.object(policy, 'get_enforcer', + return_value=mock.MagicMock()) + self.addCleanup(policy_patcher.stop) + policy_patcher.start() + def tearDown(self): # yes, this is gross and not thread aware. # but the only way to make it thread aware would require that