]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Neutron RBAC API and network support
authorKevin Benton <blak111@gmail.com>
Wed, 17 Jun 2015 06:43:59 +0000 (23:43 -0700)
committerKevin Benton <blak111@gmail.com>
Fri, 21 Aug 2015 03:00:17 +0000 (20:00 -0700)
This adds the new API endpoint to create, update, and delete
role-based access control entries. These entries enable tenants
to grant access to other tenants to perform an action on an object
they do not own.

This was previously done using a single 'shared' flag; however, this
was too coarse because an object would either be private to a tenant
or it would be shared with every tenant.

In addition to introducing the API, this patch also adds support to
for the new entries in Neutron networks. This means tenants can now
share their networks with specific tenants as long as they know the
tenant ID.

This feature is backwards-compatible with the previous 'shared'
attribute in the API. So if a deployer doesn't want this new feature
enabled, all of the RBAC operations can be blocked in policy.json and
networks can still be globally shared in the legacy manner.

Even though this feature is referred to as role-based access control,
this first version only supports sharing networks with specific
tenant IDs because Neutron currently doesn't have integration with
Keystone to handle changes in a tenant's roles/groups/etc.

DocImpact
APIImpact

Change-Id: Ib90e2a931df068f417faf26e9c3780dc3c468867
Partially-Implements: blueprint rbac-networks

etc/policy.json
neutron/api/extensions.py
neutron/db/common_db_mixin.py
neutron/db/db_base_plugin_v2.py
neutron/db/rbac_db_mixin.py [new file with mode: 0644]
neutron/extensions/rbac.py [new file with mode: 0644]
neutron/services/rbac/__init__.py [new file with mode: 0644]
neutron/tests/api/admin/test_shared_network_extension.py
neutron/tests/etc/policy.json
neutron/tests/tempest/services/network/json/network_client.py
neutron/tests/unit/api/test_extensions.py

index a07a80c29ae084c6bae8770bfcd9d7c61d2564ea..ac5a27ee8102677d04c61e998016b2eb86eb5d9f 100644 (file)
@@ -1,8 +1,10 @@
 {
     "context_is_admin":  "role:admin",
-    "admin_or_owner": "rule:context_is_admin or tenant_id:%(tenant_id)s",
+    "owner": "tenant_id:%(tenant_id)s",
+    "admin_or_owner": "rule:context_is_admin or rule:owner",
     "context_is_advsvc":  "role:advsvc",
     "admin_or_network_owner": "rule:context_is_admin or tenant_id:%(network:tenant_id)s",
+    "admin_owner_or_network_owner": "rule:admin_or_network_owner or rule:owner",
     "admin_only": "rule:context_is_admin",
     "regular_user": "",
     "shared": "field:networks:shared=True",
@@ -62,7 +64,7 @@
     "create_port:binding:profile": "rule:admin_only",
     "create_port:mac_learning_enabled": "rule:admin_or_network_owner or rule:context_is_advsvc",
     "create_port:allowed_address_pairs": "rule:admin_or_network_owner",
-    "get_port": "rule:admin_or_owner or rule:context_is_advsvc",
+    "get_port": "rule:admin_owner_or_network_owner or rule:context_is_advsvc",
     "get_port:queue_id": "rule:admin_only",
     "get_port:binding:vif_type": "rule:admin_only",
     "get_port:binding:vif_details": "rule:admin_only",
@@ -76,7 +78,7 @@
     "update_port:binding:profile": "rule:admin_only",
     "update_port:mac_learning_enabled": "rule:admin_or_network_owner or rule:context_is_advsvc",
     "update_port:allowed_address_pairs": "rule:admin_or_network_owner",
-    "delete_port": "rule:admin_or_owner or rule:context_is_advsvc",
+    "delete_port": "rule:admin_owner_or_network_owner or rule:context_is_advsvc",
 
     "get_router:ha": "rule:admin_only",
     "create_router": "rule:regular_user",
     "get_policy_bandwidth_limit_rule": "rule:regular_user",
     "create_policy_bandwidth_limit_rule": "rule:admin_only",
     "delete_policy_bandwidth_limit_rule": "rule:admin_only",
-    "update_policy_bandwidth_limit_rule": "rule:admin_only"
-
+    "update_policy_bandwidth_limit_rule": "rule:admin_only",
+
+    "restrict_wildcard": "(not field:rbac_policy:target_tenant=*) or rule:admin_only",
+    "create_rbac_policy": "",
+    "create_rbac_policy:target_tenant": "rule:restrict_wildcard",
+    "update_rbac_policy": "rule:admin_or_owner",
+    "update_rbac_policy:target_tenant": "rule:restrict_wildcard and rule:admin_or_owner",
+    "get_rbac_policy": "rule:admin_or_owner",
+    "delete_rbac_policy": "rule:admin_or_owner"
 }
index 8eb0f9070c9937fba25a6f173950dfc313bd6f39..1246087f90d820f014ddba1c0e8e375dcfc373a4 100644 (file)
@@ -17,7 +17,6 @@
 import abc
 import collections
 import imp
-import itertools
 import os
 
 from oslo_config import cfg
@@ -559,10 +558,7 @@ class PluginAwareExtensionManager(ExtensionManager):
 
     def _plugins_support(self, extension):
         alias = extension.get_alias()
-        supports_extension = any((hasattr(plugin,
-                                          "supported_extension_aliases") and
-                                  alias in plugin.supported_extension_aliases)
-                                 for plugin in self.plugins.values())
+        supports_extension = alias in self.get_supported_extension_aliases()
         if not supports_extension:
             LOG.warn(_LW("Extension %s not supported by any of loaded "
                          "plugins"),
@@ -587,11 +583,25 @@ class PluginAwareExtensionManager(ExtensionManager):
                                 manager.NeutronManager.get_service_plugins())
         return cls._instance
 
+    def get_supported_extension_aliases(self):
+        """Gets extension aliases supported by all plugins."""
+        aliases = set()
+        for plugin in self.plugins.values():
+            # we also check all classes that the plugins inherit to see if they
+            # directly provide support for an extension
+            for item in [plugin] + plugin.__class__.mro():
+                try:
+                    aliases |= set(
+                        getattr(item, "supported_extension_aliases", []))
+                except TypeError:
+                    # we land here if a class has an @property decorator for
+                    # supported extension aliases. They only work on objects.
+                    pass
+        return aliases
+
     def check_if_plugin_extensions_loaded(self):
         """Check if an extension supported by a plugin has been loaded."""
-        plugin_extensions = set(itertools.chain.from_iterable([
-            getattr(plugin, "supported_extension_aliases", [])
-            for plugin in self.plugins.values()]))
+        plugin_extensions = self.get_supported_extension_aliases()
         missing_aliases = plugin_extensions - set(self.extensions)
         if missing_aliases:
             raise exceptions.ExtensionsNotFound(
index 3b31c61df1a7350b799876d2e1f0fdab20b4b725..d7eedd53d4b61989737610afd293fe3ad83f4444 100644 (file)
@@ -96,6 +96,34 @@ class CommonDbMixin(object):
         return model_query_scope(context, model)
 
     def _model_query(self, context, model):
+        if isinstance(model, UnionModel):
+            return self._union_model_query(context, model)
+        else:
+            return self._single_model_query(context, model)
+
+    def _union_model_query(self, context, model):
+        # A union query is a query that combines multiple sets of data
+        # together and represents them as one. So if a UnionModel was
+        # passed in, we generate the query for each model with the
+        # appropriate filters and then combine them together with the
+        # .union operator. This allows any subsequent users of the query
+        # to handle it like a normal query (e.g. add pagination/sorting/etc)
+        first_query = None
+        remaining_queries = []
+        for name, component_model in model.model_map.items():
+            query = self._single_model_query(context, component_model)
+            if model.column_type_name:
+                query.add_columns(
+                    sql.expression.column('"%s"' % name, is_literal=True).
+                    label(model.column_type_name)
+                )
+            if first_query is None:
+                first_query = query
+            else:
+                remaining_queries.append(query)
+        return first_query.union(*remaining_queries)
+
+    def _single_model_query(self, context, model):
         query = context.session.query(model)
         # define basic filter condition for model query
         query_filter = None
@@ -260,3 +288,14 @@ class CommonDbMixin(object):
         columns = [c.name for c in model.__table__.columns]
         return dict((k, v) for (k, v) in
                     six.iteritems(data) if k in columns)
+
+
+class UnionModel(object):
+    """Collection of models that _model_query can query as a single table."""
+
+    def __init__(self, model_map, column_type_name=None):
+        # model_map is a dictionary of models keyed by an arbitrary name.
+        # If column_type_name is specified, the resulting records will have a
+        # column with that name which identifies the source of each record
+        self.model_map = model_map
+        self.column_type_name = column_type_name
index cfd18a7f4d5b2b38c79c26f1a5d87cce49414e5f..578f5f08fd272f639137deb9f44831ec535622e2 100644 (file)
@@ -34,11 +34,13 @@ from neutron.common import constants
 from neutron.common import exceptions as n_exc
 from neutron.common import ipv6_utils
 from neutron.common import utils
+from neutron import context as ctx
 from neutron.db import api as db_api
 from neutron.db import db_base_plugin_common
 from neutron.db import ipam_non_pluggable_backend
 from neutron.db import ipam_pluggable_backend
 from neutron.db import models_v2
+from neutron.db import rbac_db_mixin as rbac_mixin
 from neutron.db import rbac_db_models as rbac_db
 from neutron.db import sqlalchemyutils
 from neutron.extensions import l3
@@ -72,7 +74,8 @@ def _check_subnet_not_used(context, subnet_id):
 
 
 class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon,
-                        neutron_plugin_base_v2.NeutronPluginBaseV2):
+                        neutron_plugin_base_v2.NeutronPluginBaseV2,
+                        rbac_mixin.RbacPluginMixin):
     """V2 Neutron plugin interface implementation using SQLAlchemy models.
 
     Whenever a non-read call happens the plugin will call an event handler
@@ -101,6 +104,79 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon,
                          self.nova_notifier.send_port_status)
             event.listen(models_v2.Port.status, 'set',
                          self.nova_notifier.record_port_status_changed)
+        for e in (events.BEFORE_CREATE, events.BEFORE_UPDATE,
+                  events.BEFORE_DELETE):
+            registry.subscribe(self.validate_network_rbac_policy_change,
+                               rbac_mixin.RBAC_POLICY, e)
+
+    def validate_network_rbac_policy_change(self, resource, event, trigger,
+                                            context, object_type, policy,
+                                            **kwargs):
+        """Validates network RBAC policy changes.
+
+        On creation, verify that the creator is an admin or that it owns the
+        network it is sharing.
+
+        On update and delete, make sure the tenant losing access does not have
+        resources that depend on that access.
+        """
+        if object_type != 'network':
+            # we only care about network policies
+            return
+        # The object a policy targets cannot be changed so we can look
+        # at the original network for the update event as well.
+        net = self._get_network(context, policy['object_id'])
+        if event in (events.BEFORE_CREATE, events.BEFORE_UPDATE):
+            # we still have to verify that the caller owns the network because
+            # _get_network will succeed on a shared network
+            if not context.is_admin and net['tenant_id'] != context.tenant_id:
+                msg = _("Only admins can manipulate policies on networks "
+                        "they do not own.")
+                raise n_exc.InvalidInput(error_message=msg)
+
+        tenant_to_check = None
+        if event == events.BEFORE_UPDATE:
+            new_tenant = kwargs['policy_update']['target_tenant']
+            if policy['target_tenant'] != new_tenant:
+                tenant_to_check = policy['target_tenant']
+
+        if event == events.BEFORE_DELETE:
+            tenant_to_check = policy['target_tenant']
+
+        if tenant_to_check:
+            self.ensure_no_tenant_ports_on_network(net['id'], net['tenant_id'],
+                                                   tenant_to_check)
+
+    def ensure_no_tenant_ports_on_network(self, network_id, net_tenant_id,
+                                          tenant_id):
+        ctx_admin = ctx.get_admin_context()
+        rb_model = rbac_db.NetworkRBAC
+        other_rbac_entries = self._model_query(ctx_admin, rb_model).filter(
+            and_(rb_model.object_id == network_id,
+                 rb_model.action == 'access_as_shared'))
+        ports = self._model_query(ctx_admin, models_v2.Port).filter(
+            models_v2.Port.network_id == network_id)
+        if tenant_id == '*':
+            # for the wildcard we need to get all of the rbac entries to
+            # see if any allow the remaining ports on the network.
+            other_rbac_entries = other_rbac_entries.filter(
+                rb_model.target_tenant != tenant_id)
+            # any port with another RBAC entry covering it or one belonging to
+            # the same tenant as the network owner is ok
+            allowed_tenants = [entry['target_tenant']
+                               for entry in other_rbac_entries]
+            allowed_tenants.append(net_tenant_id)
+            ports = ports.filter(
+                ~models_v2.Port.tenant_id.in_(allowed_tenants))
+        else:
+            # if there is a wildcard rule, we can return early because it
+            # allows any ports
+            query = other_rbac_entries.filter(rb_model.target_tenant == '*')
+            if query.count():
+                return
+            ports = ports.filter(models_v2.Port.tenant_id == tenant_id)
+        if ports.count():
+            raise n_exc.InvalidSharedSetting(network=network_id)
 
     def set_ipam_backend(self):
         if cfg.CONF.ipam_driver:
diff --git a/neutron/db/rbac_db_mixin.py b/neutron/db/rbac_db_mixin.py
new file mode 100644 (file)
index 0000000..182a956
--- /dev/null
@@ -0,0 +1,123 @@
+# Copyright (c) 2015 Mirantis, 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 sqlalchemy.orm import exc
+
+from neutron.callbacks import events
+from neutron.callbacks import exceptions as c_exc
+from neutron.callbacks import registry
+from neutron.common import exceptions as n_exc
+from neutron.db import common_db_mixin
+from neutron.db import rbac_db_models as models
+from neutron.extensions import rbac as ext_rbac
+
+# resource name using in callbacks
+RBAC_POLICY = 'rbac-policy'
+
+
+class RbacPluginMixin(common_db_mixin.CommonDbMixin):
+    """Plugin mixin that implements the RBAC DB operations."""
+
+    object_type_cache = {}
+    supported_extension_aliases = ['rbac-policies']
+
+    def create_rbac_policy(self, context, rbac_policy):
+        e = rbac_policy['rbac_policy']
+        try:
+            registry.notify(RBAC_POLICY, events.BEFORE_CREATE, self,
+                            context=context, object_type=e['object_type'],
+                            policy=e)
+        except c_exc.CallbackFailure as e:
+            raise n_exc.InvalidInput(error_message=e)
+        dbmodel = models.get_type_model_map()[e['object_type']]
+        tenant_id = self._get_tenant_id_for_create(context, e)
+        with context.session.begin(subtransactions=True):
+            db_entry = dbmodel(object_id=e['object_id'],
+                               target_tenant=e['target_tenant'],
+                               action=e['action'],
+                               tenant_id=tenant_id)
+            context.session.add(db_entry)
+        return self._make_rbac_policy_dict(db_entry)
+
+    def _make_rbac_policy_dict(self, db_entry, fields=None):
+        res = {f: db_entry[f] for f in ('id', 'tenant_id', 'target_tenant',
+                                        'action', 'object_id')}
+        res['object_type'] = db_entry.object_type
+        return self._fields(res, fields)
+
+    def update_rbac_policy(self, context, id, rbac_policy):
+        pol = rbac_policy['rbac_policy']
+        entry = self._get_rbac_policy(context, id)
+        object_type = entry['object_type']
+        try:
+            registry.notify(RBAC_POLICY, events.BEFORE_UPDATE, self,
+                            context=context, policy=entry,
+                            object_type=object_type, policy_update=pol)
+        except c_exc.CallbackFailure as ex:
+            raise ext_rbac.RbacPolicyInUse(object_id=entry['object_id'],
+                                           details=ex)
+        with context.session.begin(subtransactions=True):
+            entry.update(pol)
+        return self._make_rbac_policy_dict(entry)
+
+    def delete_rbac_policy(self, context, id):
+        entry = self._get_rbac_policy(context, id)
+        object_type = entry['object_type']
+        try:
+            registry.notify(RBAC_POLICY, events.BEFORE_DELETE, self,
+                            context=context, object_type=object_type,
+                            policy=entry)
+        except c_exc.CallbackFailure as ex:
+            raise ext_rbac.RbacPolicyInUse(object_id=entry['object_id'],
+                                           details=ex)
+        with context.session.begin(subtransactions=True):
+            context.session.delete(entry)
+        self.object_type_cache.pop(id, None)
+
+    def _get_rbac_policy(self, context, id):
+        object_type = self._get_object_type(context, id)
+        dbmodel = models.get_type_model_map()[object_type]
+        try:
+            return self._model_query(context,
+                                     dbmodel).filter(dbmodel.id == id).one()
+        except exc.NoResultFound:
+            raise ext_rbac.RbacPolicyNotFound(id=id, object_type=object_type)
+
+    def get_rbac_policy(self, context, id, fields=None):
+        return self._make_rbac_policy_dict(
+            self._get_rbac_policy(context, id), fields=fields)
+
+    def get_rbac_policies(self, context, filters=None, fields=None,
+                          sorts=None, limit=None, page_reverse=False):
+        model = common_db_mixin.UnionModel(
+            models.get_type_model_map(), 'object_type')
+        return self._get_collection(
+            context, model, self._make_rbac_policy_dict, filters=filters,
+            sorts=sorts, limit=limit, page_reverse=page_reverse)
+
+    def _get_object_type(self, context, entry_id):
+        """Scans all RBAC tables for an ID to figure out the type.
+
+        This will be an expensive operation as the number of RBAC tables grows.
+        The result is cached since object types cannot be updated for a policy.
+        """
+        if entry_id in self.object_type_cache:
+            return self.object_type_cache[entry_id]
+        for otype, model in models.get_type_model_map().items():
+            if (context.session.query(model).
+                    filter(model.id == entry_id).first()):
+                self.object_type_cache[entry_id] = otype
+                return otype
+        raise ext_rbac.RbacPolicyNotFound(id=entry_id, object_type='unknown')
diff --git a/neutron/extensions/rbac.py b/neutron/extensions/rbac.py
new file mode 100644 (file)
index 0000000..23c9e77
--- /dev/null
@@ -0,0 +1,120 @@
+# Copyright (c) 2015 Mirantis, 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 neutron.api import extensions
+from neutron.api.v2 import attributes as attr
+from neutron.api.v2 import base
+from neutron.common import exceptions as n_exc
+from neutron.db import rbac_db_models
+from neutron import manager
+from neutron.quota import resource_registry
+
+
+class RbacPolicyNotFound(n_exc.NotFound):
+    message = _("RBAC policy of type %(object_type)s with ID %(id)s not found")
+
+
+class RbacPolicyInUse(n_exc.Conflict):
+    message = _("RBAC policy on object %(object_id)s cannot be removed "
+                "because other objects depend on it.\nDetails: %(details)s")
+
+
+def convert_valid_object_type(otype):
+    normalized = otype.strip().lower()
+    if normalized in rbac_db_models.get_type_model_map():
+        return normalized
+    msg = _("'%s' is not a valid RBAC object type") % otype
+    raise n_exc.InvalidInput(error_message=msg)
+
+
+RESOURCE_NAME = 'rbac_policy'
+RESOURCE_COLLECTION = 'rbac_policies'
+
+RESOURCE_ATTRIBUTE_MAP = {
+    RESOURCE_COLLECTION: {
+        'id': {'allow_post': False, 'allow_put': False,
+               'validate': {'type:uuid': None},
+               'is_visible': True, 'primary_key': True},
+        'object_type': {'allow_post': True, 'allow_put': False,
+                        'convert_to': convert_valid_object_type,
+                        'is_visible': True, 'default': None,
+                        'enforce_policy': True},
+        'object_id': {'allow_post': True, 'allow_put': False,
+                      'validate': {'type:uuid': None},
+                      'is_visible': True, 'default': None,
+                      'enforce_policy': True},
+        'target_tenant': {'allow_post': True, 'allow_put': True,
+                          'is_visible': True, 'enforce_policy': True,
+                          'default': None},
+        'tenant_id': {'allow_post': True, 'allow_put': False,
+                      'required_by_policy': True, 'is_visible': True},
+        'action': {'allow_post': True, 'allow_put': False,
+                   # action depends on type so validation has to occur in
+                   # the extension
+                   'validate': {'type:string': attr.DESCRIPTION_MAX_LEN},
+                   'is_visible': True},
+    }
+}
+
+rbac_quota_opts = [
+    cfg.IntOpt('quota_rbac_entry', default=10,
+               help=_('Default number of RBAC entries allowed per tenant. '
+                      'A negative value means unlimited.'))
+]
+cfg.CONF.register_opts(rbac_quota_opts, 'QUOTAS')
+
+
+class Rbac(extensions.ExtensionDescriptor):
+    """RBAC policy support."""
+
+    @classmethod
+    def get_name(cls):
+        return "RBAC Policies"
+
+    @classmethod
+    def get_alias(cls):
+        return 'rbac-policies'
+
+    @classmethod
+    def get_description(cls):
+        return ("Allows creation and modification of policies that control "
+                "tenant access to resources.")
+
+    @classmethod
+    def get_updated(cls):
+        return "2015-06-17T12:15:12-30:00"
+
+    @classmethod
+    def get_resources(cls):
+        """Returns Ext Resources."""
+        plural_mappings = {'rbac_policies': 'rbac_policy'}
+        attr.PLURALS.update(plural_mappings)
+        plugin = manager.NeutronManager.get_plugin()
+        params = RESOURCE_ATTRIBUTE_MAP['rbac_policies']
+        collection_name = 'rbac-policies'
+        resource_name = 'rbac_policy'
+        resource_registry.register_resource_by_name(resource_name)
+        controller = base.create_resource(collection_name, resource_name,
+                                          plugin, params, allow_bulk=True,
+                                          allow_pagination=False,
+                                          allow_sorting=True)
+        return [extensions.ResourceExtension(collection_name, controller,
+                                             attr_map=params)]
+
+    def get_extended_resources(self, version):
+        if version == "2.0":
+            return RESOURCE_ATTRIBUTE_MAP
+        return {}
diff --git a/neutron/services/rbac/__init__.py b/neutron/services/rbac/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
index 569e07f1a72e3f24ca9c579988c23d873bb034a2..78215e417044cdfe216095fead78cbd9141dae91 100644 (file)
@@ -18,6 +18,7 @@ from tempest_lib import exceptions as lib_exc
 import testtools
 
 from neutron.tests.api import base
+from neutron.tests.api import clients
 from neutron.tests.tempest import config
 from neutron.tests.tempest import test
 from tempest_lib.common.utils import data_utils
@@ -172,3 +173,180 @@ class AllowedAddressPairSharedNetworkTest(base.BaseAdminNetworkTest):
         with testtools.ExpectedException(lib_exc.Forbidden):
             self.update_port(
                 port, allowed_address_pairs=self.allowed_address_pairs)
+
+
+class RBACSharedNetworksTest(base.BaseAdminNetworkTest):
+
+    force_tenant_isolation = True
+
+    @classmethod
+    def resource_setup(cls):
+        super(RBACSharedNetworksTest, cls).resource_setup()
+        extensions = cls.admin_client.list_extensions()
+        if not test.is_extension_enabled('rbac_policies', 'network'):
+            msg = "rbac extension not enabled."
+            raise cls.skipException(msg)
+        # NOTE(kevinbenton): the following test seems to be necessary
+        # since the default is 'all' for the above check and these tests
+        # need to get into the gate and be disabled until the service plugin
+        # is enabled in devstack. Is there a better way to do this?
+        if 'rbac-policies' not in [x['alias']
+                                   for x in extensions['extensions']]:
+            msg = "rbac extension is not in extension listing."
+            raise cls.skipException(msg)
+        creds = cls.isolated_creds.get_alt_creds()
+        cls.client2 = clients.Manager(credentials=creds).network_client
+
+    def _make_admin_net_and_subnet_shared_to_tenant_id(self, tenant_id):
+        net = self.admin_client.create_network(
+            name=data_utils.rand_name('test-network-'))['network']
+        self.addCleanup(self.admin_client.delete_network, net['id'])
+        subnet = self.create_subnet(net, client=self.admin_client)
+        # network is shared to first unprivileged client by default
+        pol = self.admin_client.create_rbac_policy(
+            object_type='network', object_id=net['id'],
+            action='access_as_shared', target_tenant=tenant_id
+        )['rbac_policy']
+        return {'network': net, 'subnet': subnet, 'policy': pol}
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff1fff')
+    def test_network_only_visible_to_policy_target(self):
+        net = self._make_admin_net_and_subnet_shared_to_tenant_id(
+            self.client.tenant_id)['network']
+        self.client.show_network(net['id'])
+        with testtools.ExpectedException(lib_exc.NotFound):
+            # client2 has not been granted access
+            self.client2.show_network(net['id'])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff2fff')
+    def test_subnet_on_network_only_visible_to_policy_target(self):
+        sub = self._make_admin_net_and_subnet_shared_to_tenant_id(
+            self.client.tenant_id)['subnet']
+        self.client.show_subnet(sub['id'])
+        with testtools.ExpectedException(lib_exc.NotFound):
+            # client2 has not been granted access
+            self.client2.show_subnet(sub['id'])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff2eee')
+    def test_policy_target_update(self):
+        res = self._make_admin_net_and_subnet_shared_to_tenant_id(
+            self.client.tenant_id)
+        # change to client2
+        update_res = self.admin_client.update_rbac_policy(
+                res['policy']['id'], target_tenant=self.client2.tenant_id)
+        self.assertEqual(self.client2.tenant_id,
+                         update_res['rbac_policy']['target_tenant'])
+        # make sure everything else stayed the same
+        res['policy'].pop('target_tenant')
+        update_res['rbac_policy'].pop('target_tenant')
+        self.assertEqual(res['policy'], update_res['rbac_policy'])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff3fff')
+    def test_port_presence_prevents_network_rbac_policy_deletion(self):
+        res = self._make_admin_net_and_subnet_shared_to_tenant_id(
+            self.client.tenant_id)
+        port = self.client.create_port(network_id=res['network']['id'])['port']
+        # a port on the network should prevent the deletion of a policy
+        # required for it to exist
+        with testtools.ExpectedException(lib_exc.Conflict):
+            self.admin_client.delete_rbac_policy(res['policy']['id'])
+
+        # a wildcard policy should allow the specific policy to be deleted
+        # since it allows the remaining port
+        wild = self.admin_client.create_rbac_policy(
+            object_type='network', object_id=res['network']['id'],
+            action='access_as_shared', target_tenant='*')['rbac_policy']
+        self.admin_client.delete_rbac_policy(res['policy']['id'])
+
+        # now that wilcard is the only remainin, it should be subjected to
+        # to the same restriction
+        with testtools.ExpectedException(lib_exc.Conflict):
+            self.admin_client.delete_rbac_policy(wild['id'])
+        # similarily, we can't update the policy to a different tenant
+        with testtools.ExpectedException(lib_exc.Conflict):
+            self.admin_client.update_rbac_policy(
+                wild['id'], target_tenant=self.client2.tenant_id)
+
+        self.client.delete_port(port['id'])
+        # anchor is gone, delete should pass
+        self.admin_client.delete_rbac_policy(wild['id'])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-beefbeefbeef')
+    def test_tenant_can_delete_port_on_own_network(self):
+        # TODO(kevinbenton): make adjustments to the db lookup to
+        # make this work.
+        msg = "Non-admin cannot currently delete other's ports."
+        raise self.skipException(msg)
+        # pylint: disable=unreachable
+        net = self.create_network()  # owned by self.client
+        self.client.create_rbac_policy(
+            object_type='network', object_id=net['id'],
+            action='access_as_shared', target_tenant=self.client2.tenant_id)
+        port = self.client2.create_port(network_id=net['id'])['port']
+        self.client.delete_port(port['id'])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff4fff')
+    def test_regular_client_shares_to_another_regular_client(self):
+        net = self.create_network()  # owned by self.client
+        with testtools.ExpectedException(lib_exc.NotFound):
+            self.client2.show_network(net['id'])
+        pol = self.client.create_rbac_policy(
+            object_type='network', object_id=net['id'],
+            action='access_as_shared', target_tenant=self.client2.tenant_id)
+        self.client2.show_network(net['id'])
+
+        self.assertIn(pol['rbac_policy'],
+                      self.client.list_rbac_policies()['rbac_policies'])
+        # ensure that 'client2' can't see the policy sharing the network to it
+        # because the policy belongs to 'client'
+        self.assertNotIn(pol['rbac_policy']['id'],
+            [p['id']
+             for p in self.client2.list_rbac_policies()['rbac_policies']])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff5fff')
+    def test_policy_show(self):
+        res = self._make_admin_net_and_subnet_shared_to_tenant_id(
+            self.client.tenant_id)
+        p1 = res['policy']
+        p2 = self.admin_client.create_rbac_policy(
+            object_type='network', object_id=res['network']['id'],
+            action='access_as_shared',
+            target_tenant='*')['rbac_policy']
+
+        self.assertEqual(
+            p1, self.admin_client.show_rbac_policy(p1['id'])['rbac_policy'])
+        self.assertEqual(
+            p2, self.admin_client.show_rbac_policy(p2['id'])['rbac_policy'])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff6fff')
+    def test_regular_client_blocked_from_sharing_anothers_network(self):
+        net = self._make_admin_net_and_subnet_shared_to_tenant_id(
+            self.client.tenant_id)['network']
+        with testtools.ExpectedException(lib_exc.BadRequest):
+            self.client.create_rbac_policy(
+                object_type='network', object_id=net['id'],
+                action='access_as_shared', target_tenant=self.client.tenant_id)
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('86c3529b-1231-40de-803c-afffffff7fff')
+    def test_regular_client_blocked_from_sharing_with_wildcard(self):
+        net = self.create_network()
+        with testtools.ExpectedException(lib_exc.Forbidden):
+            self.client.create_rbac_policy(
+                object_type='network', object_id=net['id'],
+                action='access_as_shared', target_tenant='*')
+        # ensure it works on update as well
+        pol = self.client.create_rbac_policy(
+            object_type='network', object_id=net['id'],
+            action='access_as_shared', target_tenant=self.client2.tenant_id)
+        with testtools.ExpectedException(lib_exc.Forbidden):
+            self.client.update_rbac_policy(pol['rbac_policy']['id'],
+                                           target_tenant='*')
index a07a80c29ae084c6bae8770bfcd9d7c61d2564ea..ac5a27ee8102677d04c61e998016b2eb86eb5d9f 100644 (file)
@@ -1,8 +1,10 @@
 {
     "context_is_admin":  "role:admin",
-    "admin_or_owner": "rule:context_is_admin or tenant_id:%(tenant_id)s",
+    "owner": "tenant_id:%(tenant_id)s",
+    "admin_or_owner": "rule:context_is_admin or rule:owner",
     "context_is_advsvc":  "role:advsvc",
     "admin_or_network_owner": "rule:context_is_admin or tenant_id:%(network:tenant_id)s",
+    "admin_owner_or_network_owner": "rule:admin_or_network_owner or rule:owner",
     "admin_only": "rule:context_is_admin",
     "regular_user": "",
     "shared": "field:networks:shared=True",
@@ -62,7 +64,7 @@
     "create_port:binding:profile": "rule:admin_only",
     "create_port:mac_learning_enabled": "rule:admin_or_network_owner or rule:context_is_advsvc",
     "create_port:allowed_address_pairs": "rule:admin_or_network_owner",
-    "get_port": "rule:admin_or_owner or rule:context_is_advsvc",
+    "get_port": "rule:admin_owner_or_network_owner or rule:context_is_advsvc",
     "get_port:queue_id": "rule:admin_only",
     "get_port:binding:vif_type": "rule:admin_only",
     "get_port:binding:vif_details": "rule:admin_only",
@@ -76,7 +78,7 @@
     "update_port:binding:profile": "rule:admin_only",
     "update_port:mac_learning_enabled": "rule:admin_or_network_owner or rule:context_is_advsvc",
     "update_port:allowed_address_pairs": "rule:admin_or_network_owner",
-    "delete_port": "rule:admin_or_owner or rule:context_is_advsvc",
+    "delete_port": "rule:admin_owner_or_network_owner or rule:context_is_advsvc",
 
     "get_router:ha": "rule:admin_only",
     "create_router": "rule:regular_user",
     "get_policy_bandwidth_limit_rule": "rule:regular_user",
     "create_policy_bandwidth_limit_rule": "rule:admin_only",
     "delete_policy_bandwidth_limit_rule": "rule:admin_only",
-    "update_policy_bandwidth_limit_rule": "rule:admin_only"
-
+    "update_policy_bandwidth_limit_rule": "rule:admin_only",
+
+    "restrict_wildcard": "(not field:rbac_policy:target_tenant=*) or rule:admin_only",
+    "create_rbac_policy": "",
+    "create_rbac_policy:target_tenant": "rule:restrict_wildcard",
+    "update_rbac_policy": "rule:admin_or_owner",
+    "update_rbac_policy:target_tenant": "rule:restrict_wildcard and rule:admin_or_owner",
+    "get_rbac_policy": "rule:admin_or_owner",
+    "delete_rbac_policy": "rule:admin_or_owner"
 }
index 3fb233e98a72b5abd6d0e8257e18fe8af51b3856..25400ca2a84d902167a8c34174bd3d84c6c6bd49 100644 (file)
@@ -71,6 +71,7 @@ class NetworkClientJSON(service_client.ServiceClient):
             'policies': 'qos',
             'bandwidth_limit_rules': 'qos',
             'rule_types': 'qos',
+            'rbac-policies': '',
         }
         service_prefix = service_resource_prefix_map.get(
             plural_name)
@@ -96,7 +97,8 @@ class NetworkClientJSON(service_client.ServiceClient):
             'ipsec_site_connection': 'ipsec-site-connections',
             'quotas': 'quotas',
             'firewall_policy': 'firewall_policies',
-            'qos_policy': 'policies'
+            'qos_policy': 'policies',
+            'rbac_policy': 'rbac_policies',
         }
         return resource_plural_map.get(resource_name, resource_name + 's')
 
index 3ceefd2b9499b063646adde7421d135131710b5c..0aacc316ba8d827027474b740b06be17d64800db 100644 (file)
@@ -30,10 +30,8 @@ from neutron.api import extensions
 from neutron.api.v2 import attributes
 from neutron.common import config
 from neutron.common import exceptions
-from neutron.db import db_base_plugin_v2
 from neutron import manager
 from neutron.plugins.common import constants
-from neutron.plugins.ml2 import plugin as ml2_plugin
 from neutron import quota
 from neutron.tests import base
 from neutron.tests.unit.api.v2 import test_base
@@ -60,7 +58,7 @@ class ExtensionsTestApp(wsgi.Router):
         super(ExtensionsTestApp, self).__init__(mapper)
 
 
-class FakePluginWithExtension(db_base_plugin_v2.NeutronDbPluginV2):
+class FakePluginWithExtension(object):
     """A fake plugin used only for extension testing in this file."""
 
     supported_extension_aliases = ["FOXNSOX"]
@@ -736,8 +734,7 @@ class SimpleExtensionManager(object):
         return request_extensions
 
 
-class ExtensionExtendedAttributeTestPlugin(
-    ml2_plugin.Ml2Plugin):
+class ExtensionExtendedAttributeTestPlugin(object):
 
     supported_extension_aliases = [
         'ext-obj-test', "extended-ext-attr"
@@ -778,7 +775,7 @@ class ExtensionExtendedAttributeTestCase(base.BaseTestCase):
 
         ext_mgr = extensions.PluginAwareExtensionManager(
             extensions_path,
-            {constants.CORE: ExtensionExtendedAttributeTestPlugin}
+            {constants.CORE: ExtensionExtendedAttributeTestPlugin()}
         )
         ext_mgr.extend_resources("2.0", {})
         extensions.PluginAwareExtensionManager._instance = ext_mgr