Allow sharing of address scopes via RBAC mechanism

Neutron-lib api ref: https://review.opendev.org/#/c/707407/
Client: https://review.opendev.org/#/c/709124/
Tempest tests: https://review.opendev.org/#/c/711610/

Change-Id: I74bedae4de4eb25e5427ecb129543885a020a0a8
Depends-On: https://review.opendev.org/712633
Partial-Bug: #1862968
Closes-Bug: #1697925
changes/22/709122/13
Igor Malinovskiy 3 years ago
parent c90011ee49
commit eb6104c0ac
  1. 80
      doc/source/admin/config-rbac.rst
  2. 6
      neutron/db/address_scope_db.py
  3. 13
      neutron/db/db_base_plugin_v2.py
  4. 2
      neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD
  5. 82
      neutron/db/migration/alembic_migrations/versions/ussuri/expand/e4e236b0e1ff_add_rbac_support_for_address_scope.py
  6. 18
      neutron/db/models/address_scope.py
  7. 11
      neutron/db/rbac_db_models.py
  8. 22
      neutron/extensions/rbac_address_scope.py
  9. 42
      neutron/objects/address_scope.py
  10. 33
      neutron/objects/rbac_db.py
  11. 2
      neutron/plugins/ml2/plugin.py
  12. 1
      neutron/tests/contrib/hooks/api_all_extensions
  13. 5
      neutron/tests/unit/extensions/test_address_scope.py
  14. 37
      neutron/tests/unit/objects/test_address_scope.py
  15. 2
      neutron/tests/unit/objects/test_base.py
  16. 17
      neutron/tests/unit/objects/test_network.py
  17. 3
      neutron/tests/unit/objects/test_objects.py
  18. 16
      neutron/tests/unit/objects/test_rbac.py
  19. 16
      neutron/tests/unit/objects/test_securitygroup.py
  20. 5
      releasenotes/notes/add-address-scope-rbac-a903ff28f6457606.yaml

@ -18,6 +18,7 @@ is supported by:
* Binding QoS policies permissions to networks or ports (since Mitaka).
* Attaching router gateways to networks (since Mitaka).
* Binding security groups to ports (since Stein).
* Assigning address scopes to subnet pools (since Ussuri).
Sharing an object with specific projects
@ -281,12 +282,87 @@ This process can be repeated any number of times to share a security-group
with an arbitrary number of projects.
Sharing an address scope with specific projects
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Create an address scope to share:
.. code-block:: console
$ openstack address scope create my_address_scope
+-------------------+--------------------------------------+
| Field | Value |
+-------------------+--------------------------------------+
| id | c19cb654-3489-4160-9c82-8a3015483643 |
| ip_version | 4 |
| location | ... |
| name | my_address_scope |
| project_id | 34304bc4f233470fa4a2448d153b6324 |
| shared | False |
+-------------------+--------------------------------------+
Create the RBAC policy entry using the :command:`openstack network rbac create`
command (in this example, the ID of the project we want to share with is
``32016615de5d43bb88de99e7f2e26a1e``):
.. code-block:: console
$ openstack network rbac create --target-project \
32016615de5d43bb88de99e7f2e26a1e --action access_as_shared \
--type address_scope c19cb654-3489-4160-9c82-8a3015483643
+-------------------+--------------------------------------+
| Field | Value |
+-------------------+--------------------------------------+
| action | access_as_shared |
| id | d54b1482-98c4-44aa-9115-ede80387ffe0 |
| location | ... |
| name | None |
| object_id | c19cb654-3489-4160-9c82-8a3015483643 |
| object_type | address_scope |
| project_id | 34304bc4f233470fa4a2448d153b6324 |
| target_project_id | 32016615de5d43bb88de99e7f2e26a1e |
+-------------------+--------------------------------------+
The ``target-project`` parameter specifies the project that requires
access to the address scope. The ``action`` parameter specifies what
the project is allowed to do. The ``type`` parameter says
that the target object is an address scope. The final parameter is the ID of
the address scope we are granting access to.
Project ``32016615de5d43bb88de99e7f2e26a1e`` will now be able to see
the address scope when running :command:`openstack address scope list` and
:command:`openstack address scope show` and will also be able to assign
it to its subnet pools. No other users (other than admins and the owner)
will be able to see the address scope.
To remove access for that project, delete the RBAC policy that allows
it using the :command:`openstack network rbac delete` command:
.. code-block:: console
$ openstack network rbac delete d54b1482-98c4-44aa-9115-ede80387ffe0
If that project has subnet pools with the address scope applied to them,
the server will not delete the RBAC policy until
the address scope is no longer in use:
.. code-block:: console
$ openstack network rbac delete d54b1482-98c4-44aa-9115-ede80387ffe0
RBAC policy on object c19cb654-3489-4160-9c82-8a3015483643
cannot be removed because other objects depend on it.
This process can be repeated any number of times to share an address scope
with an arbitrary number of projects.
How the 'shared' flag relates to these entries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
As introduced in other guide entries, neutron provides a means of
making an object (``network``, ``qos-policy``, ``security-group``) available
to every project.
making an object (``address-scope``, ``network``, ``qos-policy``,
``security-group``) available to every project.
This is accomplished using the ``shared`` flag on the supported object:
.. code-block:: console

@ -36,11 +36,7 @@ class AddressScopeDbMixin(ext_address_scope.AddressScopePluginBase):
@staticmethod
def _make_address_scope_dict(address_scope, fields=None):
res = {'id': address_scope['id'],
'name': address_scope['name'],
'tenant_id': address_scope['tenant_id'],
'shared': address_scope['shared'],
'ip_version': address_scope['ip_version']}
res = address_scope.to_dict()
return db_utils.resource_fields(res, fields)
def _get_address_scope(self, context, id):

@ -59,6 +59,7 @@ from neutron import ipam
from neutron.ipam import exceptions as ipam_exc
from neutron.ipam import subnet_alloc
from neutron import neutron_plugin_base_v2
from neutron.objects import address_scope as address_scope_obj
from neutron.objects import base as base_obj
from neutron.objects import network as network_obj
from neutron.objects import ports as port_obj
@ -1112,7 +1113,7 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon,
Subnetpool can associate with an address scope if
- the tenant user is the owner of both the subnetpool and
address scope
- the admin is associating the subnetpool with the shared
- the user is associating the subnetpool with a shared
address scope
- there is no prefix conflict with the existing subnetpools
associated with the address scope.
@ -1122,8 +1123,14 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon,
if not validators.is_attr_set(address_scope_id):
return
if not self.is_address_scope_owned_by_tenant(context,
address_scope_id):
address_scope = self._get_address_scope(context, address_scope_id)
is_accessible = (
address_scope_obj.AddressScope.is_accessible(
context, address_scope
)
)
if not is_accessible:
raise exc.IllegalSubnetPoolAssociationToAddressScope(
subnetpool_id=subnetpool_id, address_scope_id=address_scope_id)

@ -0,0 +1,82 @@
# Copyright 2020 OpenStack Foundation
#
# 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 alembic import op
from oslo_utils import uuidutils
import sqlalchemy as sa
from sqlalchemy import sql
"""add_rbac_support_for_address_scope
Revision ID: e4e236b0e1ff
Revises: 18a7e90ae768
Create Date: 2020-03-12 11:24:07.435031
"""
# revision identifiers, used by Alembic.
revision = 'e4e236b0e1ff'
down_revision = '18a7e90ae768'
depends_on = ('7d9d8eeec6ad',)
def upgrade():
address_scope_rbacs = op.create_table(
'addressscoperbacs', sa.MetaData(),
sa.Column('project_id', sa.String(length=255), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('target_tenant', sa.String(length=255), nullable=False),
sa.Column('action', sa.String(length=255), nullable=False),
sa.Column('object_id', sa.String(length=36), nullable=False),
sa.ForeignKeyConstraint(['object_id'], ['address_scopes.id'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('target_tenant', 'object_id', 'action',
name='uniq_address_scopes_rbacs0'
'target_tenant0object_id0action')
)
op.alter_column('address_scopes', 'shared', server_default=sql.false())
op.bulk_insert(address_scope_rbacs,
get_rbac_policies_for_shared_address_scopes())
op.create_index(op.f('ix_addressscoperbacs_project_id'),
'addressscoperbacs', ['project_id'], unique=False)
def get_rbac_policies_for_shared_address_scopes():
# A simple model of the address_scopes table with only the fields needed
# for the migration.
address_scope = sa.Table(
'address_scopes', sa.MetaData(),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('project_id', sa.String(length=255)),
sa.Column('shared', sa.Boolean(), nullable=False)
)
session = sa.orm.Session(bind=op.get_bind())
shared_address_scopes = session.query(address_scope).filter(
address_scope.c.shared).all()
values = []
for row in shared_address_scopes:
values.append({'id': uuidutils.generate_uuid(), 'object_id': row[0],
'project_id': row[1], 'target_tenant': '*',
'action': 'access_as_shared'})
# this commit appears to be necessary to allow further operations
session.commit()
return values

@ -13,6 +13,9 @@
from neutron_lib.db import constants as db_const
from neutron_lib.db import model_base
import sqlalchemy as sa
from sqlalchemy import sql
from neutron.db import rbac_db_models
class AddressScope(model_base.BASEV2, model_base.HasId, model_base.HasProject):
@ -21,5 +24,18 @@ class AddressScope(model_base.BASEV2, model_base.HasId, model_base.HasProject):
__tablename__ = "address_scopes"
name = sa.Column(sa.String(db_const.NAME_FIELD_SIZE), nullable=False)
shared = sa.Column(sa.Boolean, nullable=False)
# TODO(imalinovskiy): drop this field when contract migrations will be
# allowed again
# NOTE(imalinovskiy): this field cannot be removed from model due to
# functional test test_models_sync, trailing underscore is required to
# prevent conflicts with RBAC code
shared_ = sa.Column("shared", sa.Boolean, nullable=False,
server_default=sql.false())
ip_version = sa.Column(sa.Integer(), nullable=False)
rbac_entries = sa.orm.relationship(rbac_db_models.AddressScopeRBAC,
backref='address_scopes',
lazy='subquery',
cascade='all, delete, delete-orphan')

@ -126,3 +126,14 @@ class SecurityGroupRBAC(RBACColumns, model_base.BASEV2):
@staticmethod
def get_valid_actions():
return (ACCESS_SHARED,)
class AddressScopeRBAC(RBACColumns, model_base.BASEV2):
"""RBAC table for address_scope."""
object_id = _object_id_column('address_scopes.id')
object_type = 'address_scope'
@staticmethod
def get_valid_actions():
return (ACCESS_SHARED,)

@ -0,0 +1,22 @@
# Copyright (c) 2020 Cloudification GmbH. 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 neutron_lib.api.definitions import rbac_address_scope
from neutron_lib.api import extensions
class Rbac_address_scope(extensions.APIExtensionDescriptor):
"""Extension class supporting address scope RBAC."""
api_definition = rbac_address_scope

@ -14,17 +14,50 @@
from neutron_lib.objects import common_types
from oslo_versionedobjects import fields as obj_fields
import sqlalchemy as sa
from neutron.db.models import address_scope as models
from neutron.db import models_v2
from neutron.db import rbac_db_models
from neutron.objects import base
from neutron.objects import rbac
from neutron.objects import rbac_db
from neutron.objects import subnetpool
@base.NeutronObjectRegistry.register
class AddressScope(base.NeutronDbObject):
class AddressScopeRBAC(rbac.RBACBaseObject):
# Version 1.0: Initial version
VERSION = '1.0'
db_model = rbac_db_models.AddressScopeRBAC
@classmethod
def get_projects(cls, context, object_id=None, action=None,
target_tenant=None):
clauses = []
if object_id:
clauses.append(cls.db_model.object_id == object_id)
if action:
clauses.append(cls.db_model.action == action)
if target_tenant:
clauses.append(cls.db_model.target_tenant == target_tenant)
query = context.session.query(cls.db_model.target_tenant)
if clauses:
query = query.filter(sa.and_(*clauses))
return [data[0] for data in query]
@base.NeutronObjectRegistry.register
class AddressScope(rbac_db.NeutronRbacObject):
# Version 1.0: Initial version
# Version 1.1: Add RBAC support
VERSION = '1.1'
# required by RbacNeutronMetaclass
rbac_db_cls = AddressScopeRBAC
db_model = models.AddressScope
fields = {
@ -51,3 +84,10 @@ class AddressScope(base.NeutronDbObject):
return cls._load_object(context, scope_model_obj)
return None
@classmethod
def get_bound_tenant_ids(cls, context, obj_id):
snp_objs = subnetpool.SubnetPool.get_objects(
context, address_scope_id=obj_id
)
return {snp.project_id for snp in snp_objs}

@ -236,6 +236,37 @@ class RbacNeutronDbObjectMixin(rbac_db_mixin.RbacPluginMixin,
self._validate_rbac_policy_delete(self.obj_context, obj_id, '*')
return self.obj_context.session.delete(shared_prev)
def from_db_object(self, db_obj):
self._load_shared(db_obj)
super(RbacNeutronDbObjectMixin, self).from_db_object(db_obj)
def obj_load_attr(self, attrname):
if attrname == 'shared':
return self._load_shared()
super(RbacNeutronDbObjectMixin, self).obj_load_attr(attrname)
def _load_shared(self, db_obj=None):
# Do not override 'shared' attribute on create() or update()
if 'shared' in self.obj_get_changes():
return
if db_obj:
# NOTE(korzen) db_obj is passed when object is loaded from DB
rbac_entries = db_obj.get('rbac_entries') or {}
shared = self.is_network_shared(self.obj_context, rbac_entries)
else:
# NOTE(korzen) this case is used when object was
# instantiated and without DB interaction (get_object(s), update,
# create), it should be rare case to load 'shared' by that method
shared = self.get_shared_with_tenant(
self.obj_context.elevated(),
self.rbac_db_cls,
self.id,
self.project_id
)
setattr(self, 'shared', shared)
self.obj_reset_changes(['shared'])
def _update_post(self, obj_changes):
if "shared" in obj_changes:
@ -249,6 +280,7 @@ def _update_hook(self, update_orig):
obj_changes = self.obj_get_changes()
update_orig(self)
_update_post(self, obj_changes)
self._load_shared(db_obj=self.db_obj)
def _create_post(self):
@ -260,6 +292,7 @@ def _create_hook(self, orig_create):
with self.db_context_writer(self.obj_context):
orig_create(self)
_create_post(self)
self._load_shared(db_obj=self.db_obj)
def _to_dict_hook(self, to_dict_orig):

@ -43,6 +43,7 @@ from neutron_lib.api.definitions import port_security as psec
from neutron_lib.api.definitions import portbindings
from neutron_lib.api.definitions import portbindings_extended as pbe_ext
from neutron_lib.api.definitions import provider_net
from neutron_lib.api.definitions import rbac_address_scope
from neutron_lib.api.definitions import rbac_security_groups as rbac_sg_apidef
from neutron_lib.api.definitions import security_groups_port_filtering
from neutron_lib.api.definitions import stateful_security_group
@ -181,6 +182,7 @@ class Ml2Plugin(db_base_plugin_v2.NeutronDbPluginV2,
_supported_extension_aliases = [provider_net.ALIAS,
external_net.ALIAS, portbindings.ALIAS,
"quotas", "security-group",
rbac_address_scope.ALIAS,
rbac_sg_apidef.ALIAS,
agent_apidef.ALIAS,
dhcpagentscheduler.ALIAS,

@ -45,6 +45,7 @@ NETWORK_API_EXTENSIONS+=",qos-gateway-ip"
NETWORK_API_EXTENSIONS+=",quotas"
NETWORK_API_EXTENSIONS+=",quota_details"
NETWORK_API_EXTENSIONS+=",rbac-policies"
NETWORK_API_EXTENSIONS+=",rbac-address-scope""
NETWORK_API_EXTENSIONS+=",rbac-security-groups""
NETWORK_API_EXTENSIONS+=",router"
NETWORK_API_EXTENSIONS+=",router-admin-state-down-before-update"

@ -98,9 +98,8 @@ class AddressScopeTestCase(test_db_base_plugin_v2.NeutronDbPluginV2TestCase):
expected=None, tenant_id=None):
update_req = self.new_update_request(
'address-scopes', data, addr_scope_id)
if not admin:
neutron_context = context.Context('', tenant_id or self._tenant_id)
update_req.environ['neutron.context'] = neutron_context
update_req.environ['neutron.context'] = context.Context(
'', tenant_id or self._tenant_id, is_admin=admin)
update_res = update_req.get_response(self.ext_api)
if expected:

@ -12,17 +12,48 @@
# License for the specific language governing permissions and limitations
# under the License.
from neutron_lib import constants as lib_constants
from neutron.objects import address_scope
from neutron.tests.unit.objects import test_base as obj_test_base
from neutron.tests.unit.objects import test_base
from neutron.tests.unit.objects import test_rbac
from neutron.tests.unit import testlib_api
class AddressScopeIfaceObjectTestCase(obj_test_base.BaseObjectIfaceTestCase):
class AddressScopeIfaceObjectTestCase(test_base.BaseObjectIfaceTestCase):
_test_class = address_scope.AddressScope
class AddressScopeDbObjectTestCase(obj_test_base.BaseDbObjectTestCase,
class AddressScopeDbObjectTestCase(test_base.BaseDbObjectTestCase,
testlib_api.SqlTestCase):
_test_class = address_scope.AddressScope
class AddressScopeRBACDbObjectTestCase(test_rbac.TestRBACObjectMixin,
test_base.BaseDbObjectTestCase,
testlib_api.SqlTestCase):
_test_class = address_scope.AddressScopeRBAC
def setUp(self):
super(AddressScopeRBACDbObjectTestCase, self).setUp()
for obj in self.db_objs:
as_obj = address_scope.AddressScope(
self.context,
id=obj['object_id'],
name="test_as_%s_%s" % (obj['object_id'], obj['project_id']),
project_id=obj['project_id'],
ip_version=lib_constants.IP_ALLOWED_VERSIONS[0],
)
as_obj.create()
def _create_test_address_scope_rbac(self):
self.objs[0].create()
return self.objs[0]
class AddressScopeRBACIfaceObjectTestCase(test_rbac.TestRBACObjectMixin,
test_base.BaseObjectIfaceTestCase):
_test_class = address_scope.AddressScopeRBAC

@ -746,7 +746,7 @@ class BaseObjectIfaceTestCase(_BaseObjectTestCase, test_base.BaseTestCase):
'is_shared_with_tenant', return_value=False).start()
mock.patch.object(
rbac_db.RbacNeutronDbObjectMixin,
'get_shared_with_tenant').start()
'get_shared_with_tenant', return_value=False).start()
def fake_get_object(self, context, model, **kwargs):
objs = self.model_map[model]

@ -10,8 +10,6 @@
# License for the specific language governing permissions and limitations
# under the License.
import random
import mock
from neutron.db import rbac_db_models
@ -24,18 +22,7 @@ from neutron.tests.unit.objects import test_rbac
from neutron.tests.unit import testlib_api
class _NetworkRBACBase(object):
def get_random_object_fields(self, obj_cls=None):
fields = (super(_NetworkRBACBase, self).
get_random_object_fields(obj_cls))
rnd_actions = self._test_class.db_model.get_valid_actions()
idx = random.randint(0, len(rnd_actions) - 1)
fields['action'] = rnd_actions[idx]
return fields
class NetworkRBACDbObjectTestCase(_NetworkRBACBase,
class NetworkRBACDbObjectTestCase(test_rbac.TestRBACObjectMixin,
obj_test_base.BaseDbObjectTestCase,
testlib_api.SqlTestCase):
@ -64,7 +51,7 @@ class NetworkRBACDbObjectTestCase(_NetworkRBACBase,
self.assertNotIn('id', network_rbac_obj['versioned_object.data'])
class NetworkRBACIfaceOjectTestCase(_NetworkRBACBase,
class NetworkRBACIfaceOjectTestCase(test_rbac.TestRBACObjectMixin,
obj_test_base.BaseObjectIfaceTestCase):
_test_class = network.NetworkRBAC

@ -26,7 +26,8 @@ from neutron.tests import base as test_base
# corresponding version bump in the affected objects. Please keep the list in
# alphabetic order.
object_data = {
'AddressScope': '1.0-dd0dfdb67775892d3adc090e28e43bd8',
'AddressScope': '1.1-dd0dfdb67775892d3adc090e28e43bd8',
'AddressScopeRBAC': '1.0-192845c5ed0718e1c54fac36936fcd7d',
'Agent': '1.1-64b670752d57b3c7602cb136e0338507',
'AllowedAddressPair': '1.0-9f9186b6f952fbf31d257b0458b852c0',
'AutoAllocatedTopology': '1.0-74642e58c53bf3610dc224c59f81b242',

@ -9,9 +9,11 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import random
import mock
from neutron.objects import address_scope
from neutron.objects import network
from neutron.objects.qos import policy
from neutron.objects import rbac
@ -20,10 +22,22 @@ from neutron.tests import base as neutron_test_base
from neutron.tests.unit.objects import test_base
class TestRBACObjectMixin(object):
def get_random_object_fields(self, obj_cls=None):
fields = (super(TestRBACObjectMixin, self).
get_random_object_fields(obj_cls))
rnd_actions = self._test_class.db_model.get_valid_actions()
idx = random.randint(0, len(rnd_actions) - 1)
fields['action'] = rnd_actions[idx]
return fields
class RBACBaseObjectTestCase(neutron_test_base.BaseTestCase):
def test_get_type_class_map(self):
class_map = {'qos_policy': policy.QosPolicyRBAC,
class_map = {'address_scope': address_scope.AddressScopeRBAC,
'qos_policy': policy.QosPolicyRBAC,
'network': network.NetworkRBAC,
'security_group': securitygroup.SecurityGroupRBAC}
self.assertEqual(class_map, rbac.RBACBaseObject.get_type_class_map())

@ -12,7 +12,6 @@
import collections
import itertools
import random
from oslo_utils import uuidutils
@ -22,18 +21,7 @@ from neutron.tests.unit.objects import test_rbac
from neutron.tests.unit import testlib_api
class _SecurityGroupRBACBase(object):
def get_random_object_fields(self, obj_cls=None):
fields = (super(_SecurityGroupRBACBase, self).
get_random_object_fields(obj_cls))
rnd_actions = self._test_class.db_model.get_valid_actions()
idx = random.randint(0, len(rnd_actions) - 1)
fields['action'] = rnd_actions[idx]
return fields
class SecurityGroupRBACDbObjectTestCase(_SecurityGroupRBACBase,
class SecurityGroupRBACDbObjectTestCase(test_rbac.TestRBACObjectMixin,
test_base.BaseDbObjectTestCase,
testlib_api.SqlTestCase):
@ -59,7 +47,7 @@ class SecurityGroupRBACDbObjectTestCase(_SecurityGroupRBACBase,
security_group_rbac_dict['versioned_object.data'])
class SecurityGroupRBACIfaceObjectTestCase(_SecurityGroupRBACBase,
class SecurityGroupRBACIfaceObjectTestCase(test_rbac.TestRBACObjectMixin,
test_base.BaseObjectIfaceTestCase):
_test_class = securitygroup.SecurityGroupRBAC

@ -0,0 +1,5 @@
---
features:
- |
Address scope is now supported via the network RBAC mechanism.
Please refer to the admin guide for further details.
Loading…
Cancel
Save