Merge "RequirementsAuthority multi role support enhancement"
diff --git a/.zuul.yaml b/.zuul.yaml
index edd812c..085e775 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -135,11 +135,14 @@
         c-bak: false
 
 - job:
-    name: patrole-plugin-base
+    name: patrole-extension-base
     parent: patrole-base
     description: |
-      Patrole plugin job for admin and member roles which
-      runs RBAC tests for neutron-tempest-plugin APIs (if the plugin is installed).
+      Patrole plugin job for admin and member roles which runs RBAC tests for
+      neutron-tempest-plugin APIs (if the plugin is installed).
+
+      Covers Neutron extension functionality only. Should not be used for
+      supporting Neutron plugins like fwaas.
     required-projects:
       - name: openstack/tempest
       - name: openstack/patrole
@@ -158,22 +161,22 @@
         neutron-qos: true
 
 - job:
-    name: patrole-plugin-member
-    parent: patrole-plugin-base
+    name: patrole-extension-member
+    parent: patrole-extension-base
     voting: false
     vars:
       devstack_localrc:
         RBAC_TEST_ROLES: member
-      tempest_test_regex: (?=.*PluginRbacTest)(^patrole_tempest_plugin\.tests\.api)
+      tempest_test_regex: (?=.*ExtRbacTest)(^patrole_tempest_plugin\.tests\.api)
 
 - job:
-    name: patrole-plugin-admin
-    parent: patrole-plugin-base
+    name: patrole-extension-admin
+    parent: patrole-extension-base
     voting: false
     vars:
       devstack_localrc:
         RBAC_TEST_ROLES: admin
-      tempest_test_regex: (?=.*PluginRbacTest)(^patrole_tempest_plugin\.tests\.api)
+      tempest_test_regex: (?=.*ExtRbacTest)(^patrole_tempest_plugin\.tests\.api)
 
 - project:
     templates:
@@ -195,8 +198,8 @@
         - patrole-py35-member
         - patrole-multinode-admin
         - patrole-multinode-member
-        - patrole-plugin-admin
-        - patrole-plugin-member
+        - patrole-extension-admin
+        - patrole-extension-member
     gate:
       jobs:
         - patrole-admin
diff --git a/HACKING.rst b/HACKING.rst
index 87e3b1f..9992017 100644
--- a/HACKING.rst
+++ b/HACKING.rst
@@ -39,9 +39,9 @@
 - [P102] RBAC test class names must end in 'RbacTest'
 - [P103] ``self.client`` must not be used as a client alias; this allows for
   code that is more maintainable and easier to read
-- [P104] RBAC `plugin test class`_ names must end in 'PluginRbacTest'
+- [P104] RBAC `extension test class`_ names must end in 'ExtRbacTest'
 
-.. _plugin test class: https://github.com/openstack/patrole/tree/master/patrole_tempest_plugin/tests/api/network#neutron-plugin-tests
+.. _extension test class: https://github.com/openstack/patrole/tree/master/patrole_tempest_plugin/tests/api/network#neutron-extension-rbac-tests
 
 Role Overriding
 ---------------
diff --git a/REVIEWING.rst b/REVIEWING.rst
index 7e3c71f..4ee847f 100644
--- a/REVIEWING.rst
+++ b/REVIEWING.rst
@@ -109,6 +109,58 @@
 whether to skip or not.
 
 
+Multi-Policy Guidelines
+-----------------------
+
+Care should be taken when using multiple policies in an RBAC test. The
+following guidelines should be followed before deciding to add multiple
+policies to a Patrole test.
+
+.. _general-multi-policy-guidelines:
+
+General Multi-policy API Code Guidelines
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The list below enumerates guidelines beginning with those with the highest
+priority and ending with those with the lowest priority. A higher priority
+item takes precedence over lower priority items.
+
+#. Order the policies in the ``rules`` parameter chronologically with respect
+   to the order they're called by the API endpoint under test.
+#. Only use policies that map to the API by referencing the appropriate policy
+   in code documentation.
+#. Only include the minimum number of policies needed to test the API
+   correctly: don't add extraneous policies.
+#. If possible, only use policies that directly relate to the API. If the
+   policies are used across multiple APIs, try to omit it. If a "generic"
+   policy needs to be added to get the test to pass, then this is fair game.
+#. Limit the number of policies to a reasonable number, such as 3.
+
+Neutron Multi-policy API Code Guidelines
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Because Neutron can raise a 403 or 404 following failed authorization, Patrole
+uses the ``expected_error_codes`` parameter to accommodate this behavior.
+Each policy action enumerated in ``rules`` must have a corresponding entry
+in ``expected_error_codes``. Each expected error code must be either a 403 or a
+404, which indicates that, when policy enforcement fails for the corresponding
+policy action, that error code is expected by Patrole. For more information
+about these parameters, see :ref:`rbac-validation`.
+
+The list below enumerates additional multi-policy guidelines that apply in
+particular to Neutron. A higher priority item takes precedence over lower
+priority items.
+
+#. Order the expected error codes in the ``expected_error_codes`` parameter
+   chronologically with respect to the order each corresponding policy in
+   ``rules`` is authorized by the API under test.
+#. Ensure the :ref:`neutron-multi-policy-validation` is followed when
+   determining the expected error code for each corresponding policy.
+
+The same guidelines under :ref:`general-multi-policy-guidelines` should be
+applied afterward.
+
+
 Release Notes
 -------------
 Release notes are how we indicate to users and other consumers of Patrole what
diff --git a/doc/source/index.rst b/doc/source/index.rst
index c03aac6..a9dcdc0 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -17,6 +17,7 @@
    :maxdepth: 2
 
    rbac-overview
+   multi-policy-validation
 
 User's Guide
 ============
diff --git a/doc/source/multi-policy-validation.rst b/doc/source/multi-policy-validation.rst
new file mode 100644
index 0000000..d38b31e
--- /dev/null
+++ b/doc/source/multi-policy-validation.rst
@@ -0,0 +1,187 @@
+.. _multi-policy-validation:
+
+=======================
+Multi-policy Validation
+=======================
+
+Introduction
+------------
+
+Multi-policy validation exists in Patrole because if one policy were assumed,
+then tests could fail because they would not consider all the policies actually
+being enforced. The reasoning can be found in `this spec`_. Basically,
+since Patrole derives the expected test result dynamically in order to test any
+role, each policy enforced by the API under test must be considered to derive
+an accurate expected test result, or else the expected and actual test
+results will not always match, resulting in overall test failure. For more
+information about Patrole's RBAC validation work flow, reference
+:ref:`rbac-validation`.
+
+Multi-policy support allows Patrole to more accurately offer RBAC tests for API
+endpoints that enforce multiple policy actions.
+
+.. _this spec: https://github.com/openstack/qa-specs/blob/master/specs/patrole/rbac-testing-multiple-policies.rst
+
+Scope
+-----
+
+Multiple policies should be applied only to tests that require them. Not all
+API endpoints enforce multiple policies. Some services consistently enforce
+1 policy per API, while on the other side of the spectrum, services like
+Neutron have much more involved policy enforcement work flows. See
+:ref:`neutron-multi-policy-validation` for more information.
+
+.. _neutron-multi-policy-validation:
+
+Neutron Multi-policy Validation
+-------------------------------
+
+Neutron can raise different :ref:`policy-error-codes` following failed policy
+authorization. Many endpoints in Neutron enforce multiple policies, which
+complicates matters when trying to determine whether the endpoint raises a
+403 or a 404 following unauthorized access.
+
+Multi-policy Examples
+---------------------
+
+General Examples
+^^^^^^^^^^^^^^^^
+
+Below is an example of multi-policy validation for a carefully chosen Nova API:
+
+.. code-block:: python
+
+  @rbac_rule_validation.action(
+  service="nova",
+  rules=["os_compute_api:os-lock-server:unlock",
+         "os_compute_api:os-lock-server:unlock:unlock_override"])
+  @decorators.idempotent_id('40dfeef9-73ee-48a9-be19-a219875de457')
+  def test_unlock_server_override(self):
+      """Test force unlock server, part of os-lock-server.
+
+      In order to trigger the unlock:unlock_override policy instead
+      of the unlock policy, the server must be locked by a different
+      user than the one who is attempting to unlock it.
+      """
+      self.os_admin.servers_client.lock_server(self.server['id'])
+      self.addCleanup(self.servers_client.unlock_server, self.server['id'])
+
+      with self.rbac_utils.override_role(self):
+          self.servers_client.unlock_server(self.server['id'])
+
+While the ``expected_error_codes`` parameter is omitted in the example above,
+Patrole automatically populates it with a 403 for each policy in ``rules``.
+Therefore, in the example above, the following expected error codes/rules
+relationship is observed:
+
+* "os_compute_api:os-lock-server:unlock" => 403
+* "os_compute_api:os-lock-server:unlock:unlock_override"  => 403
+
+Below is an example that uses ``expected_error_codes`` to account for the
+fact that Neutron is expected to raise a ``404`` on the first policy that
+is enforced server-side ("get_port"). Also, in this example, soft authorization
+is performed, meaning that it is necessary to check the response body for an
+attribute that is added only following successful policy authorization.
+
+.. code-block:: python
+
+    @utils.requires_ext(extension='binding', service='network')
+    @rbac_rule_validation.action(service="neutron",
+                                 rules=["get_port",
+                                        "get_port:binding:vif_type"],
+                                 expected_error_codes=[404, 403])
+    @decorators.idempotent_id('125aff0b-8fed-4f8e-8410-338616594b06')
+    def test_show_port_binding_vif_type(self):
+
+        # Verify specific fields of a port
+        fields = ['binding:vif_type']
+
+        with self.rbac_utils.override_role(self):
+            retrieved_port = self.ports_client.show_port(
+                self.port['id'], fields=fields)['port']
+
+        # Rather than throwing a 403, the field is not present, so raise exc.
+        if fields[0] not in retrieved_port:
+            raise rbac_exceptions.RbacMalformedResponse(
+                attribute='binding:vif_type')
+
+Note that in the example above, failure to authorize
+"get_port:binding:vif_type" results in the response body getting successfully
+returned by the server, but without additional dictionary keys. If Patrole
+fails to find those expected keys, it *acts as though* a 403 was thrown (by
+raising an exception itself, the ``rbac_rule_validation`` decorator handles
+the rest).
+
+Neutron Examples
+^^^^^^^^^^^^^^^^
+
+A basic Neutron example that only expects 403's to be raised:
+
+.. code-block:: python
+
+    @utils.requires_ext(extension='external-net', service='network')
+    @rbac_rule_validation.action(service="neutron",
+                                 rules=["create_network",
+                                        "create_network:router:external"],
+                                 expected_error_codes=[403, 403])
+    @decorators.idempotent_id('51adf2a7-739c-41e0-8857-3b4c460cbd24')
+    def test_create_network_router_external(self):
+
+        """Create External Router Network Test
+
+        RBAC test for the neutron create_network:router:external policy
+        """
+        with self.rbac_utils.override_role(self):
+            self._create_network(router_external=True)
+
+Note that above the following expected error codes/rules relationship is
+observed:
+
+* "create_network" => 403
+* "create_network:router:external"  => 403
+
+A more involved example that expects a 404 to be raised, should the first
+policy under ``rules`` fail authorization, and a 403 to be raised for any
+subsequent policy authorization failure:
+
+.. code-block:: python
+
+    @rbac_rule_validation.action(service="neutron",
+                                 rules=["get_network",
+                                        "update_network",
+                                        "update_network:shared"],
+                                 expected_error_codes=[404, 403, 403])
+    @decorators.idempotent_id('37ea3e33-47d9-49fc-9bba-1af98fbd46d6')
+    def test_update_network_shared(self):
+
+        """Update Shared Network Test
+
+        RBAC test for the neutron update_network:shared policy
+        """
+        with self.rbac_utils.override_role(self):
+            self._update_network(shared_network=True)
+        self.addCleanup(self._update_network, shared_network=False)
+
+Note that above the following expected error codes/rules relationship is
+observed:
+
+* "get_network" => 404
+* "update_network"  => 403
+* "update_network:shared" => 403
+
+Limitations
+-----------
+
+Multi-policy validation in RBAC tests comes with limitations, due to technical
+and practical challenges.
+
+Technically, there are challenges associated with multiple policies across
+cross-service API communication in OpenStack, such as between Nova and Cinder
+or Nova and Neutron. The current framework does not account for these
+cross-service policy enforcement workflows, and it is still up for debate
+whether it should.
+
+Practically, it is not possible to enumerate every policy enforced by every API
+in Patrole, as the maintenance overhead would be huge.
+
+.. _Neutron policy documentation: https://docs.openstack.org/neutron/pike/contributor/internals/policy.html
diff --git a/doc/source/rbac-overview.rst b/doc/source/rbac-overview.rst
index 09ab17d..acfd66f 100644
--- a/doc/source/rbac-overview.rst
+++ b/doc/source/rbac-overview.rst
@@ -124,12 +124,9 @@
   degree of log tracing is required by developers to confirm that the expected
   policies are getting enforced, prior to the tests getting merged.
 
-.. todo::
+For more information, see :ref:`multi-policy-validation`.
 
-  Link to multi-policy validation documentation section once it has been
-  written.
-
-.. _error-codes:
+.. _policy-error-codes:
 
 Error Codes
 -----------
@@ -196,7 +193,7 @@
     in an exception getting raised or a boolean value getting returned.
     Hard authorization results in an exception getting raised. Usually, this
     results in a ``403 Forbidden`` getting returned for unauthorized requests.
-    (See :ref:`error-codes` for further details.)
+    (See :ref:`policy-error-codes` for further details.)
 
     Related term: :term:`soft authorization`.
 
diff --git a/patrole_tempest_plugin/hacking/checks.py b/patrole_tempest_plugin/hacking/checks.py
index 1f06258..d7b772d 100644
--- a/patrole_tempest_plugin/hacking/checks.py
+++ b/patrole_tempest_plugin/hacking/checks.py
@@ -36,8 +36,8 @@
 RULE_VALIDATION_DECORATOR = re.compile(
     r'\s*@rbac_rule_validation.action\(.*')
 IDEMPOTENT_ID_DECORATOR = re.compile(r'\s*@decorators\.idempotent_id\((.*)\)')
-PLUGIN_RBAC_TEST = re.compile(
-    r"class .+\(.+PluginRbacTest\)|class .+PluginRbacTest\(.+\)")
+EXT_RBAC_TEST = re.compile(
+    r"class .+\(.+ExtRbacTest\)|class .+ExtRbacTest\(.+\)")
 
 have_rbac_decorator = False
 
@@ -213,15 +213,15 @@
             return 0, "Do not use 'self.client' as a service client alias"
 
 
-def no_plugin_rbac_test_suffix_in_plugin_test_class_name(physical_line,
-                                                         filename):
-    """Check that Plugin RBAC class names end with "PluginRbacTest"
+def no_extension_rbac_test_suffix_in_plugin_test_class_name(physical_line,
+                                                            filename):
+    """Check that Extension RBAC class names end with "ExtRbacTest"
 
     P104
     """
-    suffix = "PluginRbacTest"
+    suffix = "ExtRbacTest"
     if "patrole_tempest_plugin/tests/api" in filename:
-        if PLUGIN_RBAC_TEST.match(physical_line):
+        if EXT_RBAC_TEST.match(physical_line):
             subclass, superclass = physical_line.split('(')
             subclass = subclass.split('class')[1].strip()
             superclass = superclass.split(')')[0].strip()
@@ -238,16 +238,16 @@
                         superclass.startswith("Base")):
                     return
 
-                # Case 1: Subclass of "BasePluginRbacTest" must end in `suffix`
+                # Case 1: Subclass of "BaseExtRbacTest" must end in `suffix`
                 # Case 2: Subclass that ends in `suffix` must inherit from base
                 # class ending in `suffix`.
                 if not subclass.endswith(suffix):
                     error = ("Plugin RBAC test subclasses must end in "
-                             "'PluginRbacTest'")
+                             "'ExtRbacTest'")
                     return len(subclass) - 1, error
                 elif not superclass.endswith(suffix):
                     error = ("Plugin RBAC test subclasses must inherit from a "
-                             "'PluginRbacTest' base class")
+                             "'ExtRbacTest' base class")
                     return len(superclass) - 1, error
 
 
@@ -263,4 +263,4 @@
     register(no_rbac_rule_validation_decorator)
     register(no_rbac_suffix_in_test_filename)
     register(no_rbac_test_suffix_in_test_class_name)
-    register(no_plugin_rbac_test_suffix_in_plugin_test_class_name)
+    register(no_extension_rbac_test_suffix_in_plugin_test_class_name)
diff --git a/patrole_tempest_plugin/rbac_exceptions.py b/patrole_tempest_plugin/rbac_exceptions.py
index 6bdd7df..ad697b0 100644
--- a/patrole_tempest_plugin/rbac_exceptions.py
+++ b/patrole_tempest_plugin/rbac_exceptions.py
@@ -20,16 +20,34 @@
     message = "An unknown RBAC exception occurred"
 
 
-class RbacMalformedResponse(BasePatroleException):
-    message = ("The response body is missing the expected %(attribute)s due "
-               "to policy enforcement failure.")
+class BasePatroleResponseBodyException(BasePatroleException):
+    message = "Response body incomplete due to RBAC authorization failure"
 
-    def __init__(self, empty=False, **kwargs):
-        if empty:
-            self.message = ("The response body is empty due to policy "
-                            "enforcement failure.")
-            kwargs = {}
-        super(RbacMalformedResponse, self).__init__(**kwargs)
+
+class RbacMissingAttributeResponseBody(BasePatroleResponseBodyException):
+    """Raised when a list or show action is missing an attribute following
+    RBAC authorization failure.
+    """
+    message = ("The response body is missing the expected %(attribute)s due "
+               "to policy enforcement failure")
+
+
+class RbacPartialResponseBody(BasePatroleResponseBodyException):
+    """Raised when a list action only returns a subset of the available
+    resources.
+
+    For example, admin can return more resources than member for a list action.
+    """
+    message = ("The response body only lists a subset of the available "
+               "resources due to partial policy enforcement failure. Response "
+               "body: %(body)s")
+
+
+class RbacEmptyResponseBody(BasePatroleResponseBodyException):
+    """Raised when a list or show action is empty following RBAC authorization
+    failure.
+    """
+    message = ("The response body is empty due to policy enforcement failure.")
 
 
 class RbacResourceSetupFailed(BasePatroleException):
diff --git a/patrole_tempest_plugin/rbac_rule_validation.py b/patrole_tempest_plugin/rbac_rule_validation.py
index 575e2c3..9ca437b 100644
--- a/patrole_tempest_plugin/rbac_rule_validation.py
+++ b/patrole_tempest_plugin/rbac_rule_validation.py
@@ -198,7 +198,8 @@
                     test_status = ('Error, %s' % (msg))
                     LOG.error(msg)
             except (expected_exception,
-                    rbac_exceptions.RbacMalformedResponse) as actual_exception:
+                    rbac_exceptions.BasePatroleResponseBodyException) \
+                    as actual_exception:
                 caught_exception = actual_exception
                 test_status = 'Denied'
 
diff --git a/patrole_tempest_plugin/tests/api/compute/test_flavor_access_rbac.py b/patrole_tempest_plugin/tests/api/compute/test_flavor_access_rbac.py
index 317c1ad..8d4d70f 100644
--- a/patrole_tempest_plugin/tests/api/compute/test_flavor_access_rbac.py
+++ b/patrole_tempest_plugin/tests/api/compute/test_flavor_access_rbac.py
@@ -50,7 +50,7 @@
 
         expected_attr = 'os-flavor-access:is_public'
         if expected_attr not in body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -71,7 +71,7 @@
         # If the `expected_attr` was not found in any flavor, then policy
         # enforcement failed.
         if not public_flavors:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @decorators.idempotent_id('39cb5c8f-9990-436f-9282-fc76a41d9bac')
diff --git a/patrole_tempest_plugin/tests/api/compute/test_flavor_rxtx_rbac.py b/patrole_tempest_plugin/tests/api/compute/test_flavor_rxtx_rbac.py
index 0748e67..cbb2e19 100644
--- a/patrole_tempest_plugin/tests/api/compute/test_flavor_rxtx_rbac.py
+++ b/patrole_tempest_plugin/tests/api/compute/test_flavor_rxtx_rbac.py
@@ -45,7 +45,7 @@
         with self.rbac_utils.override_role(self):
             result = self.flavors_client.list_flavors(detail=True)['flavors']
         if 'rxtx_factor' not in result[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='rxtx_factor')
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -59,5 +59,5 @@
             result = self.flavors_client.show_flavor(
                 CONF.compute.flavor_ref)['flavor']
         if 'rxtx_factor' not in result:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='rxtx_factor')
diff --git a/patrole_tempest_plugin/tests/api/compute/test_images_rbac.py b/patrole_tempest_plugin/tests/api/compute/test_images_rbac.py
index f6c1b67..e16222c 100644
--- a/patrole_tempest_plugin/tests/api/compute/test_images_rbac.py
+++ b/patrole_tempest_plugin/tests/api/compute/test_images_rbac.py
@@ -294,7 +294,7 @@
 
         expected_attr = 'OS-EXT-IMG-SIZE:size'
         if expected_attr not in body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -310,5 +310,5 @@
 
         expected_attr = 'OS-EXT-IMG-SIZE:size'
         if expected_attr not in body[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
diff --git a/patrole_tempest_plugin/tests/api/compute/test_server_actions_rbac.py b/patrole_tempest_plugin/tests/api/compute/test_server_actions_rbac.py
index a64bd20..0ff6ebe 100644
--- a/patrole_tempest_plugin/tests/api/compute/test_server_actions_rbac.py
+++ b/patrole_tempest_plugin/tests/api/compute/test_server_actions_rbac.py
@@ -403,5 +403,5 @@
             server = self.servers_client.show_server(self.server_id)['server']
 
         if 'host_status' not in server:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='host_status')
diff --git a/patrole_tempest_plugin/tests/api/compute/test_server_misc_policy_actions_rbac.py b/patrole_tempest_plugin/tests/api/compute/test_server_misc_policy_actions_rbac.py
index 88bea25..64e1300 100644
--- a/patrole_tempest_plugin/tests/api/compute/test_server_misc_policy_actions_rbac.py
+++ b/patrole_tempest_plugin/tests/api/compute/test_server_misc_policy_actions_rbac.py
@@ -143,7 +143,7 @@
         expected_attr = 'config_drive'
         # If the first server contains "config_drive", then all the others do.
         if expected_attr not in body[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -159,7 +159,7 @@
             body = self.servers_client.show_server(self.server['id'])['server']
         expected_attr = 'config_drive'
         if expected_attr not in body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @utils.requires_ext(extension='os-deferred-delete', service='compute')
@@ -188,7 +188,7 @@
             body = self.servers_client.list_servers(detail=True)['servers']
         # If the first server contains `expected_attr`, then all the others do.
         if expected_attr not in body[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -205,7 +205,7 @@
         with self.rbac_utils.override_role(self):
             body = self.servers_client.show_server(self.server['id'])['server']
         if expected_attr not in body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -229,7 +229,7 @@
         for attr in ('host', 'instance_name'):
             whole_attr = 'OS-EXT-SRV-ATTR:%s' % attr
             if whole_attr not in body[0]:
-                raise rbac_exceptions.RbacMalformedResponse(
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
                     attribute=whole_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -253,7 +253,7 @@
         for attr in ('host', 'instance_name'):
             whole_attr = 'OS-EXT-SRV-ATTR:%s' % attr
             if whole_attr not in body:
-                raise rbac_exceptions.RbacMalformedResponse(
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
                     attribute=whole_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -272,7 +272,7 @@
                           'OS-EXT-STS:power_state')
         for attr in expected_attrs:
             if attr not in body[0]:
-                raise rbac_exceptions.RbacMalformedResponse(
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
                     attribute=attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -291,7 +291,7 @@
                           'OS-EXT-STS:power_state')
         for attr in expected_attrs:
             if attr not in body:
-                raise rbac_exceptions.RbacMalformedResponse(
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
                     attribute=attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -310,7 +310,7 @@
         with self.rbac_utils.override_role(self):
             body = self.servers_client.list_servers(detail=True)['servers']
         if expected_attr not in body[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -329,7 +329,7 @@
         with self.rbac_utils.override_role(self):
             body = self.servers_client.show_server(self.server['id'])['server']
         if expected_attr not in body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @utils.requires_ext(extension='os-instance-actions', service='compute')
@@ -360,12 +360,12 @@
                 self.server['id'], request_id)['instanceAction']
 
         if 'events' not in instance_action:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='events')
         # Microversion 2.51+ returns 'events' always, but not 'traceback'. If
         # 'traceback' is also present then policy enforcement passed.
         if 'traceback' not in instance_action['events'][0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='events.traceback')
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -379,7 +379,7 @@
             result = self.servers_client.show_server(self.server['id'])[
                 'server']
         if 'key_name' not in result:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='key_name')
 
     @testtools.skipIf(CONF.policy_feature_enabled.removed_nova_policies_stein,
@@ -392,7 +392,7 @@
         with self.rbac_utils.override_role(self):
             result = self.servers_client.list_servers(detail=True)['servers']
         if 'key_name' not in result[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='key_name')
 
     @rbac_rule_validation.action(
@@ -514,7 +514,7 @@
             body = self.servers_client.show_server(self.server['id'])['server']
         for expected_attr in expected_attrs:
             if expected_attr not in body:
-                raise rbac_exceptions.RbacMalformedResponse(
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
                     attribute=expected_attr)
 
     @utils.requires_ext(extension='os-simple-tenant-usage', service='compute')
diff --git a/patrole_tempest_plugin/tests/api/network/README.rst b/patrole_tempest_plugin/tests/api/network/README.rst
index 20d6196..352af8a 100644
--- a/patrole_tempest_plugin/tests/api/network/README.rst
+++ b/patrole_tempest_plugin/tests/api/network/README.rst
@@ -10,7 +10,7 @@
 broken up into the following categories:
 
 * :ref:`neutron-rbac-tests`
-* :ref:`neutron-plugin-rbac-tests`
+* :ref:`neutron-extension-rbac-tests`
 
 .. _neutron-rbac-tests:
 
@@ -22,18 +22,16 @@
 These tests are gated in many `Zuul jobs`_ (master, n-1, n-2) against many
 roles (member, admin).
 
-.. _neutron-plugin-rbac-tests:
+.. _neutron-extension-rbac-tests:
 
-Neutron plugin tests
-^^^^^^^^^^^^^^^^^^^^
+Neutron extension tests
+^^^^^^^^^^^^^^^^^^^^^^^
 
 The Neutron RBAC plugin tests focus on testing RBAC for various Neutron
-extensions and plugins, or, stated differently:
+extensions, or, stated differently: tests that rely on
+`neutron-tempest-plugin`_.
 
-* tests that rely on `neutron-tempest-plugin`_
-* external Neutron plugins
-
-These tests inherit from the base class ``BaseNetworkPluginRbacTest``. If an
+These tests inherit from the base class ``BaseNetworkExtRbacTest``. If an
 extension or plugin is not enabled in the cloud, the corresponding tests are
 gracefully skipped.
 
diff --git a/patrole_tempest_plugin/tests/api/network/rbac_base.py b/patrole_tempest_plugin/tests/api/network/rbac_base.py
index 6102347..347651d 100644
--- a/patrole_tempest_plugin/tests/api/network/rbac_base.py
+++ b/patrole_tempest_plugin/tests/api/network/rbac_base.py
@@ -27,7 +27,7 @@
         cls.setup_rbac_utils()
 
 
-class BaseNetworkPluginRbacTest(BaseNetworkRbacTest):
+class BaseNetworkExtRbacTest(BaseNetworkRbacTest):
     """Base class to be used with tests that require neutron-tempest-plugin.
     """
 
@@ -35,14 +35,14 @@
     def get_auth_providers(cls):
         """Register auth_provider from neutron-tempest-plugin.
         """
-        providers = super(BaseNetworkPluginRbacTest, cls).get_auth_providers()
+        providers = super(BaseNetworkExtRbacTest, cls).get_auth_providers()
         if cls.is_neutron_tempest_plugin_avaliable():
             providers.append(cls.ntp_client.auth_provider)
         return providers
 
     @classmethod
     def skip_checks(cls):
-        super(BaseNetworkPluginRbacTest, cls).skip_checks()
+        super(BaseNetworkExtRbacTest, cls).skip_checks()
 
         if not cls.is_neutron_tempest_plugin_avaliable():
             msg = ("neutron-tempest-plugin not installed.")
@@ -59,7 +59,7 @@
     @classmethod
     def get_client_manager(cls, credential_type=None, roles=None,
                            force_new=None):
-        manager = super(BaseNetworkPluginRbacTest, cls).get_client_manager(
+        manager = super(BaseNetworkExtRbacTest, cls).get_client_manager(
             credential_type=credential_type,
             roles=roles,
             force_new=force_new
diff --git a/patrole_tempest_plugin/tests/api/network/test_address_scope_rbac.py b/patrole_tempest_plugin/tests/api/network/test_address_scope_rbac.py
index 893942e..6cdeccd 100644
--- a/patrole_tempest_plugin/tests/api/network/test_address_scope_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_address_scope_rbac.py
@@ -23,18 +23,18 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class AddressScopePluginRbacTest(base.BaseNetworkPluginRbacTest):
+class AddressScopeExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(AddressScopePluginRbacTest, cls).skip_checks()
+        super(AddressScopeExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('address-scope', 'network'):
             msg = "address-scope extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(AddressScopePluginRbacTest, cls).resource_setup()
+        super(AddressScopeExtRbacTest, cls).resource_setup()
         cls.network = cls.create_network()
 
     def _create_address_scope(self, name=None, **kwargs):
diff --git a/patrole_tempest_plugin/tests/api/network/test_agents_rbac.py b/patrole_tempest_plugin/tests/api/network/test_agents_rbac.py
index c778d9c..c2b23f2 100644
--- a/patrole_tempest_plugin/tests/api/network/test_agents_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_agents_rbac.py
@@ -238,18 +238,18 @@
                 self.agent['id'], network_id=network_id)
 
 
-class L3AgentsPluginRbacTest(base.BaseNetworkPluginRbacTest):
+class L3AgentsExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(L3AgentsPluginRbacTest, cls).skip_checks()
+        super(L3AgentsExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('l3_agent_scheduler', 'network'):
             msg = "l3_agent_scheduler extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(L3AgentsPluginRbacTest, cls).resource_setup()
+        super(L3AgentsExtRbacTest, cls).resource_setup()
         name = data_utils.rand_name(cls.__name__ + '-Router')
         cls.router = cls.ntp_client.create_router(name)['router']
 
diff --git a/patrole_tempest_plugin/tests/api/network/test_auto_allocated_topology_rbac.py b/patrole_tempest_plugin/tests/api/network/test_auto_allocated_topology_rbac.py
index 7098e55..4001255 100644
--- a/patrole_tempest_plugin/tests/api/network/test_auto_allocated_topology_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_auto_allocated_topology_rbac.py
@@ -20,11 +20,11 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class AutoAllocationTopologyPluginRbacTest(base.BaseNetworkPluginRbacTest):
+class AutoAllocationTopologyExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(AutoAllocationTopologyPluginRbacTest, cls).skip_checks()
+        super(AutoAllocationTopologyExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('auto-allocated-topology',
                                           'network'):
             msg = "auto-allocated-topology extension not enabled."
diff --git a/patrole_tempest_plugin/tests/api/network/test_dscp_marking_rule_rbac.py b/patrole_tempest_plugin/tests/api/network/test_dscp_marking_rule_rbac.py
index b9f8365..e03de74 100644
--- a/patrole_tempest_plugin/tests/api/network/test_dscp_marking_rule_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_dscp_marking_rule_rbac.py
@@ -22,18 +22,18 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class DscpMarkingRulePluginRbacTest(base.BaseNetworkPluginRbacTest):
+class DscpMarkingRuleExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(DscpMarkingRulePluginRbacTest, cls).skip_checks()
+        super(DscpMarkingRuleExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('qos', 'network'):
             msg = "qos extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(DscpMarkingRulePluginRbacTest, cls).resource_setup()
+        super(DscpMarkingRuleExtRbacTest, cls).resource_setup()
         name = data_utils.rand_name(cls.__class__.__name__ + '-qos')
         cls.policy_id = cls.ntp_client.create_qos_policy(
             name=name)["policy"]["id"]
diff --git a/patrole_tempest_plugin/tests/api/network/test_flavors_rbac.py b/patrole_tempest_plugin/tests/api/network/test_flavors_rbac.py
index f8ef0bb..e1a8719 100644
--- a/patrole_tempest_plugin/tests/api/network/test_flavors_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_flavors_rbac.py
@@ -23,11 +23,11 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class FlavorsPluginRbacTest(base.BaseNetworkPluginRbacTest):
+class FlavorsExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def resource_setup(cls):
-        super(FlavorsPluginRbacTest, cls).resource_setup()
+        super(FlavorsExtRbacTest, cls).resource_setup()
         providers = cls.ntp_client.list_service_providers()
         if not providers["service_providers"]:
             raise cls.skipException("No service_providers available.")
@@ -120,10 +120,10 @@
             self.ntp_client.list_flavors()
 
 
-class FlavorsServiceProfilePluginRbacTest(base.BaseNetworkPluginRbacTest):
+class FlavorsServiceProfileExtRbacTest(base.BaseNetworkExtRbacTest):
     @classmethod
     def resource_setup(cls):
-        super(FlavorsServiceProfilePluginRbacTest, cls).resource_setup()
+        super(FlavorsServiceProfileExtRbacTest, cls).resource_setup()
         providers = cls.ntp_client.list_service_providers()
         if not providers["service_providers"]:
             raise cls.skipException("No service_providers available.")
diff --git a/patrole_tempest_plugin/tests/api/network/test_network_segments_rbac.py b/patrole_tempest_plugin/tests/api/network/test_network_segments_rbac.py
index c985111..b449970 100644
--- a/patrole_tempest_plugin/tests/api/network/test_network_segments_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_network_segments_rbac.py
@@ -118,4 +118,4 @@
             LOG.info("NotFound or Forbidden exception are not thrown when "
                      "role doesn't have access to the endpoint. Instead, "
                      "the response will have an empty network body.")
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
diff --git a/patrole_tempest_plugin/tests/api/network/test_networks_rbac.py b/patrole_tempest_plugin/tests/api/network/test_networks_rbac.py
index 96ba378..b39489a 100644
--- a/patrole_tempest_plugin/tests/api/network/test_networks_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_networks_rbac.py
@@ -363,7 +363,7 @@
                 self.network['id'], **kwargs)['network']
 
         if len(retrieved_network) == 0:
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
 
     @utils.requires_ext(extension='provider', service='network')
     @rbac_rule_validation.action(service="neutron",
@@ -384,7 +384,7 @@
                 self.network['id'], **kwargs)['network']
 
         if len(retrieved_network) == 0:
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
 
     @utils.requires_ext(extension='provider', service='network')
     @rbac_rule_validation.action(
@@ -406,7 +406,7 @@
                 self.network['id'], **kwargs)['network']
 
         if len(retrieved_network) == 0:
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
 
     @utils.requires_ext(extension='provider', service='network')
     @rbac_rule_validation.action(
@@ -428,7 +428,7 @@
                 self.network['id'], **kwargs)['network']
 
         if len(retrieved_network) == 0:
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
 
     @rbac_rule_validation.action(service="neutron",
                                  rules=["get_network", "delete_network"],
diff --git a/patrole_tempest_plugin/tests/api/network/test_policy_bandwidth_limit_rule_rbac.py b/patrole_tempest_plugin/tests/api/network/test_policy_bandwidth_limit_rule_rbac.py
index 8f9635d..ab881a7 100644
--- a/patrole_tempest_plugin/tests/api/network/test_policy_bandwidth_limit_rule_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_policy_bandwidth_limit_rule_rbac.py
@@ -22,18 +22,18 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class PolicyBandwidthLimitRulePluginRbacTest(base.BaseNetworkPluginRbacTest):
+class PolicyBandwidthLimitRuleExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(PolicyBandwidthLimitRulePluginRbacTest, cls).skip_checks()
+        super(PolicyBandwidthLimitRuleExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('qos', 'network'):
             msg = "qos extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(PolicyBandwidthLimitRulePluginRbacTest, cls).resource_setup()
+        super(PolicyBandwidthLimitRuleExtRbacTest, cls).resource_setup()
         name = data_utils.rand_name(cls.__class__.__name__ + '-qos-policy')
         cls.policy_id = cls.ntp_client.create_qos_policy(
             name=name)["policy"]["id"]
diff --git a/patrole_tempest_plugin/tests/api/network/test_policy_minimum_bandwidth_rule_rbac.py b/patrole_tempest_plugin/tests/api/network/test_policy_minimum_bandwidth_rule_rbac.py
index 4f85cb6..6d108af 100644
--- a/patrole_tempest_plugin/tests/api/network/test_policy_minimum_bandwidth_rule_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_policy_minimum_bandwidth_rule_rbac.py
@@ -22,18 +22,18 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class PolicyMinimumBandwidthRulePluginRbacTest(base.BaseNetworkPluginRbacTest):
+class PolicyMinimumBandwidthRuleExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(PolicyMinimumBandwidthRulePluginRbacTest, cls).skip_checks()
+        super(PolicyMinimumBandwidthRuleExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('qos', 'network'):
             msg = "qos extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(PolicyMinimumBandwidthRulePluginRbacTest, cls).resource_setup()
+        super(PolicyMinimumBandwidthRuleExtRbacTest, cls).resource_setup()
         name = data_utils.rand_name(cls.__class__.__name__ + '-qos')
         cls.policy_id = cls.ntp_client.create_qos_policy(
             name=name)["policy"]["id"]
diff --git a/patrole_tempest_plugin/tests/api/network/test_ports_rbac.py b/patrole_tempest_plugin/tests/api/network/test_ports_rbac.py
index b65bd73..dd3537f 100644
--- a/patrole_tempest_plugin/tests/api/network/test_ports_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_ports_rbac.py
@@ -183,7 +183,7 @@
 
         # Rather than throwing a 403, the field is not present, so raise exc.
         if fields[0] not in retrieved_port:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='binding:vif_type')
 
     @utils.requires_ext(extension='binding', service='network')
@@ -203,7 +203,7 @@
 
         # Rather than throwing a 403, the field is not present, so raise exc.
         if fields[0] not in retrieved_port:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='binding:vif_details')
 
     @utils.requires_ext(extension='binding', service='network')
@@ -226,7 +226,7 @@
 
         # Rather than throwing a 403, the field is not present, so raise exc.
         if fields[0] not in retrieved_port:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='binding:host_id')
 
     @utils.requires_ext(extension='binding', service='network')
@@ -250,7 +250,7 @@
 
         # Rather than throwing a 403, the field is not present, so raise exc.
         if fields[0] not in retrieved_port:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='binding:profile')
 
     @rbac_rule_validation.action(service="neutron",
diff --git a/patrole_tempest_plugin/tests/api/network/test_qos_rbac.py b/patrole_tempest_plugin/tests/api/network/test_qos_rbac.py
index aae326c..3fcb7e4 100644
--- a/patrole_tempest_plugin/tests/api/network/test_qos_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_qos_rbac.py
@@ -22,18 +22,18 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class QosPluginRbacTest(base.BaseNetworkPluginRbacTest):
+class QosExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(QosPluginRbacTest, cls).skip_checks()
+        super(QosExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('qos', 'network'):
             msg = "qos extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(QosPluginRbacTest, cls).resource_setup()
+        super(QosExtRbacTest, cls).resource_setup()
         cls.network = cls.create_network()
 
     def create_policy(self, name=None):
diff --git a/patrole_tempest_plugin/tests/api/network/test_rbac_policies_rbac.py b/patrole_tempest_plugin/tests/api/network/test_rbac_policies_rbac.py
index 9c88bc0..2123eb3 100644
--- a/patrole_tempest_plugin/tests/api/network/test_rbac_policies_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_rbac_policies_rbac.py
@@ -20,11 +20,11 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class RbacPoliciesPluginRbacTest(base.BaseNetworkPluginRbacTest):
+class RbacPoliciesExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def resource_setup(cls):
-        super(RbacPoliciesPluginRbacTest, cls).resource_setup()
+        super(RbacPoliciesExtRbacTest, cls).resource_setup()
         cls.tenant_id = cls.os_primary.credentials.tenant_id
         cls.network_id = cls.create_network()['id']
 
diff --git a/patrole_tempest_plugin/tests/api/network/test_routers_rbac.py b/patrole_tempest_plugin/tests/api/network/test_routers_rbac.py
index f850a3e..399ad47 100644
--- a/patrole_tempest_plugin/tests/api/network/test_routers_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_routers_rbac.py
@@ -179,7 +179,7 @@
 
         # Rather than throwing a 403, the field is not present, so raise exc.
         if 'distributed' not in retrieved_fields:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='distributed')
 
     @decorators.idempotent_id('defc502c-4159-4824-b4d9-3cdcc39015b2')
@@ -201,7 +201,7 @@
 
         # Rather than throwing a 403, the field is not present, so raise exc.
         if 'ha' not in retrieved_fields:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='ha')
 
     @rbac_rule_validation.action(service="neutron",
diff --git a/patrole_tempest_plugin/tests/api/network/test_security_groups_rbac.py b/patrole_tempest_plugin/tests/api/network/test_security_groups_rbac.py
index 9112bf6..e9fa018 100644
--- a/patrole_tempest_plugin/tests/api/network/test_security_groups_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_security_groups_rbac.py
@@ -126,7 +126,7 @@
 
         # Neutron may return an empty list if access is denied.
         if not security_groups['security_groups']:
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
 
     @rbac_rule_validation.action(service="neutron",
                                  rules=["create_security_group_rule"])
@@ -170,4 +170,4 @@
 
         # Neutron may return an empty list if access is denied.
         if not security_rules['security_group_rules']:
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
diff --git a/patrole_tempest_plugin/tests/api/network/test_segments_rbac.py b/patrole_tempest_plugin/tests/api/network/test_segments_rbac.py
index 9725e2b..0b58649 100644
--- a/patrole_tempest_plugin/tests/api/network/test_segments_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_segments_rbac.py
@@ -23,18 +23,18 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class SegmentsPluginRbacTest(base.BaseNetworkPluginRbacTest):
+class SegmentsExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(SegmentsPluginRbacTest, cls).skip_checks()
+        super(SegmentsExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('segment', 'network'):
             msg = "segment extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(SegmentsPluginRbacTest, cls).resource_setup()
+        super(SegmentsExtRbacTest, cls).resource_setup()
         cls.network = cls.create_network()
 
     @classmethod
diff --git a/patrole_tempest_plugin/tests/api/network/test_subnets_rbac.py b/patrole_tempest_plugin/tests/api/network/test_subnets_rbac.py
index 9a5ebe4..8fe157a 100644
--- a/patrole_tempest_plugin/tests/api/network/test_subnets_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_subnets_rbac.py
@@ -73,7 +73,7 @@
 
         # Neutron may return an empty list if access is denied.
         if not subnets['subnets']:
-            raise rbac_exceptions.RbacMalformedResponse(empty=True)
+            raise rbac_exceptions.RbacEmptyResponseBody()
 
     @decorators.idempotent_id('f36cd821-dd22-4bd0-b43d-110fc4b553eb')
     @rbac_rule_validation.action(service="neutron",
diff --git a/patrole_tempest_plugin/tests/api/network/test_trunks_rbac.py b/patrole_tempest_plugin/tests/api/network/test_trunks_rbac.py
index 063fd55..4b2eefd 100644
--- a/patrole_tempest_plugin/tests/api/network/test_trunks_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_trunks_rbac.py
@@ -21,18 +21,18 @@
 from patrole_tempest_plugin.tests.api.network import rbac_base as base
 
 
-class TrunksPluginRbacTest(base.BaseNetworkPluginRbacTest):
+class TrunksExtRbacTest(base.BaseNetworkExtRbacTest):
 
     @classmethod
     def skip_checks(cls):
-        super(TrunksPluginRbacTest, cls).skip_checks()
+        super(TrunksExtRbacTest, cls).skip_checks()
         if not utils.is_extension_enabled('trunk', 'network'):
             msg = "trunk extension not enabled."
             raise cls.skipException(msg)
 
     @classmethod
     def resource_setup(cls):
-        super(TrunksPluginRbacTest, cls).resource_setup()
+        super(TrunksExtRbacTest, cls).resource_setup()
         cls.network = cls.create_network()
         cls.port_id = cls.create_port(cls.network)["id"]
 
diff --git a/patrole_tempest_plugin/tests/api/volume/test_groups_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_groups_rbac.py
index c117d23..730e349 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_groups_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_groups_rbac.py
@@ -208,7 +208,7 @@
             group_type = self.create_group_type(ignore_notfound=True)
 
         if 'group_specs' not in group_type:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='group_specs')
 
     @decorators.idempotent_id('8d9e2831-24c3-47b7-a76a-2e563287f12f')
@@ -221,5 +221,5 @@
             resp_body = self.group_types_client.show_group_type(
                 group_type['id'])['group_type']
         if 'group_specs' not in resp_body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute='group_specs')
diff --git a/patrole_tempest_plugin/tests/api/volume/test_limits_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_limits_rbac.py
index 3127d83..2bd0992 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_limits_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_limits_rbac.py
@@ -51,4 +51,5 @@
                 'limits']['absolute']
         for key in expected_keys:
             if key not in absolute_limits:
-                raise rbac_exceptions.RbacMalformedResponse(attribute=key)
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
+                    attribute=key)
diff --git a/patrole_tempest_plugin/tests/api/volume/test_volume_metadata_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_volume_metadata_rbac.py
index 6c2c84d..7e0044d 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_volume_metadata_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_volume_metadata_rbac.py
@@ -111,7 +111,7 @@
                 'volumes']
         expected_attr = 'volume_image_metadata'
         if expected_attr not in resp_body[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @decorators.idempotent_id('53f94d52-0dd5-42cf-a3a4-59b35150b3d5')
@@ -129,7 +129,7 @@
                 'volume']
         expected_attr = 'volume_image_metadata'
         if expected_attr not in resp_body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=expected_attr)
 
     @decorators.idempotent_id('a9d9e825-5ea3-42e6-96f3-7ac4e97b2ed0')
diff --git a/patrole_tempest_plugin/tests/api/volume/test_volumes_backup_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_volumes_backup_rbac.py
index bf22341..0efeb33 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_volumes_backup_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_volumes_backup_rbac.py
@@ -210,7 +210,7 @@
         # Show backup API attempts to inject the attribute below into the
         # response body but only if policy enforcement succeeds.
         if self.expected_attr not in body:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=self.expected_attr)
 
     @decorators.idempotent_id('aa40b7c0-5974-48be-8cbc-e23cc61c4c68')
@@ -221,7 +221,7 @@
             body = self.backups_client.list_backups(detail=True)['backups']
 
         if self.expected_attr not in body[0]:
-            raise rbac_exceptions.RbacMalformedResponse(
+            raise rbac_exceptions.RbacMissingAttributeResponseBody(
                 attribute=self.expected_attr)
 
 
diff --git a/patrole_tempest_plugin/tests/api/volume/test_volumes_snapshots_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_volumes_snapshots_rbac.py
index 40469a2..55adf1a 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_volumes_snapshots_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_volumes_snapshots_rbac.py
@@ -76,7 +76,7 @@
                 self.snapshot['id'])['snapshot']
         for expected_attr in expected_attrs:
             if expected_attr not in resp:
-                raise rbac_exceptions.RbacMalformedResponse(
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
                     attribute=expected_attr)
 
     @rbac_rule_validation.action(service="cinder",
@@ -136,5 +136,5 @@
             resp = self._list_by_param_values(with_detail=True, **params)
         for expected_attr in expected_attrs:
             if expected_attr not in resp[0]:
-                raise rbac_exceptions.RbacMalformedResponse(
+                raise rbac_exceptions.RbacMissingAttributeResponseBody(
                     attribute=expected_attr)
diff --git a/patrole_tempest_plugin/tests/unit/test_hacking.py b/patrole_tempest_plugin/tests/unit/test_hacking.py
index d35b816..a0ace76 100644
--- a/patrole_tempest_plugin/tests/unit/test_hacking.py
+++ b/patrole_tempest_plugin/tests/unit/test_hacking.py
@@ -257,10 +257,10 @@
             "  cls.client",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
 
-    def test_no_plugin_rbac_test_suffix_in_plugin_test_class_name(self):
-        check = checks.no_plugin_rbac_test_suffix_in_plugin_test_class_name
+    def no_extension_rbac_test_suffix_in_plugin_test_class_name(self):
+        check = checks.no_extension_rbac_test_suffix_in_plugin_test_class_name
 
-        # Passing cases: these do not inherit from "PluginRbacTest" base class.
+        # Passing cases: these do not inherit from "ExtRbacTest" base class.
         self.assertFalse(check(
             "class FakeRbacTest(BaseFakeRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
@@ -270,39 +270,39 @@
 
         # Passing cases: these **do** end in correct test class suffix.
         self.assertFalse(check(
-            "class FakePluginRbacTest(BaseFakePluginRbacTest)",
+            "class FakeExtRbacTest(BaseFakeExtRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
         self.assertFalse(check(
-            "class FakePluginRbacTest(base.BaseFakePluginRbacTest)",
+            "class FakeExtRbacTest(base.BaseFakeExtRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
 
         # Passing cases: plugin base class inherits from another base class.
         self.assertFalse(check(
-            "class BaseFakePluginRbacTest(base.BaseFakeRbacTest)",
+            "class BaseFakeExtRbacTest(base.BaseFakeRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
         self.assertFalse(check(
-            "class BaseFakePluginRbacTest(BaseFakeRbacTest)",
+            "class BaseFakeExtRbacTest(BaseFakeRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
 
         # Failing cases: these **do not** end in correct test class suffix.
-        # Case 1: RbacTest subclass doesn't end in PluginRbacTest.
+        # Case 1: RbacTest subclass doesn't end in ExtRbacTest.
         self.assertTrue(check(
-            "class FakeRbacTest(base.BaseFakePluginRbacTest)",
+            "class FakeRbacTest(base.BaseFakeExtRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
         self.assertTrue(check(
-            "class FakeRbacTest(BaseFakePluginRbacTest)",
+            "class FakeRbacTest(BaseFakeExtRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
         self.assertTrue(check(
-            "class FakeRbacTest(BaseFakeNetworkPluginRbacTest)",
+            "class FakeRbacTest(BaseFakeNetworkExtRbacTest)",
             "./patrole_tempest_plugin/tests/api/network/fake_test_rbac.py"))
-        # Case 2: PluginRbacTest subclass doesn't inherit from
-        # BasePluginRbacTest.
+        # Case 2: ExtRbacTest subclass doesn't inherit from
+        # BaseExtRbacTest.
         self.assertTrue(check(
-            "class FakePluginRbacTest(base.BaseFakeRbacTest)",
+            "class FakeExtRbacTest(base.BaseFakeRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
         self.assertTrue(check(
-            "class FakePluginRbacTest(BaseFakeRbacTest)",
+            "class FakeExtRbacTest(BaseFakeRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
         self.assertTrue(check(
-            "class FakeNeutronPluginRbacTest(BaseFakeNeutronRbacTest)",
+            "class FakeNeutronExtRbacTest(BaseFakeNeutronRbacTest)",
             "./patrole_tempest_plugin/tests/api/fake_test_rbac.py"))
diff --git a/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py b/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py
index 1531df1..9e547b8 100644
--- a/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py
+++ b/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py
@@ -46,8 +46,9 @@
                                project_id=mock.sentinel.project_id)
         setattr(self.mock_test_args.os_primary, 'credentials', mock_creds)
 
+        self.test_roles = ['member']
         self.useFixture(
-            patrole_fixtures.ConfPatcher(rbac_test_roles=['member'],
+            patrole_fixtures.ConfPatcher(rbac_test_roles=self.test_roles,
                                          group='patrole'))
         # Disable patrole log for unit tests.
         self.useFixture(
@@ -69,9 +70,10 @@
                                project_id=mock.sentinel.project_id)
         setattr(self.mock_test_args.os_primary, 'credentials', mock_creds)
 
+        self.test_roles = ['member', 'anotherrole']
         self.useFixture(
-            patrole_fixtures.ConfPatcher(
-                rbac_test_roles=['member', 'anotherrole'], group='patrole'))
+            patrole_fixtures.ConfPatcher(rbac_test_roles=self.test_roles,
+                                         group='patrole'))
         # Disable patrole log for unit tests.
         self.useFixture(
             patrole_fixtures.ConfPatcher(enable_reporting=False,
@@ -150,43 +152,66 @@
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
-    def test_rule_validation_rbac_malformed_response_positive(
+    def test_rule_validation_rbac_failed_response_body_positive(
             self, mock_authority, mock_log):
-        """Test RbacMalformedResponse error is thrown without permission
-        passes.
+        """Test BasePatroleResponseBodyException error is thrown without
+        permission passes.
 
-        Positive test case: if RbacMalformedResponse is thrown and the user is
-        not allowed to perform the action, then this is a success.
+        Positive test case: if subclass of BasePatroleResponseBodyException is
+        thrown and the user is not allowed to perform the action, then this is
+        a success.
         """
         mock_authority.PolicyAuthority.return_value.allowed.return_value =\
             False
 
-        @rbac_rv.action(mock.sentinel.service, rules=[mock.sentinel.action])
-        def test_policy(*args):
-            raise rbac_exceptions.RbacMalformedResponse()
+        def _do_test(exception_cls, **kwargs):
+            @rbac_rv.action(mock.sentinel.service,
+                            rules=[mock.sentinel.action])
+            def test_policy(*args):
+                raise exception_cls(**kwargs)
 
-        mock_log.error.assert_not_called()
+            mock_log.error.assert_not_called()
+            mock_log.warning.assert_not_called()
+
+        _do_test(rbac_exceptions.RbacMissingAttributeResponseBody,
+                 attribute=mock.sentinel.attr)
+        _do_test(rbac_exceptions.RbacPartialResponseBody,
+                 body=mock.sentinel.body)
+        _do_test(rbac_exceptions.RbacEmptyResponseBody)
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
-    def test_rule_validation_rbac_malformed_response_negative(
+    def test_rule_validation_soft_authorization_exceptions(
             self, mock_authority, mock_log):
-        """Test RbacMalformedResponse error is thrown with permission fails.
+        """Test RbacUnderPermissionException error is thrown when any of the
+        soft authorization-related exceptions are raised by a test.
 
-        Negative test case: if RbacMalformedResponse is thrown and the user is
-        allowed to perform the action, then this is an expected failure.
+        Negative test case: if subclass of BasePatroleResponseBodyException is
+        thrown and the user is allowed to perform the action, then this is an
+        expected failure.
         """
         mock_authority.PolicyAuthority.return_value.allowed.return_value = True
 
-        @rbac_rv.action(mock.sentinel.service, rules=[mock.sentinel.action])
-        def test_policy(*args):
-            raise rbac_exceptions.RbacMalformedResponse()
+        def _do_test(exception_cls, **kwargs):
+            @rbac_rv.action(mock.sentinel.service,
+                            rules=[mock.sentinel.action])
+            def test_policy(*args):
+                raise exception_cls(**kwargs)
 
-        test_re = ("User with roles \['member'\] was not allowed to perform "
-                   "the following actions: \[%s\]. " % (mock.sentinel.action))
-        self.assertRaisesRegex(rbac_exceptions.RbacUnderPermissionException,
-                               test_re, test_policy, self.mock_test_args)
-        self.assertRegex(mock_log.error.mock_calls[0][1][0], test_re)
+            test_re = (".*User with roles \[%s\] was not allowed to "
+                       "perform the following actions: \[%s\].*" % (
+                           ', '.join("'%s'" % r for r in self.test_roles),
+                           mock.sentinel.action))
+            self.assertRaisesRegex(
+                rbac_exceptions.RbacUnderPermissionException, test_re,
+                test_policy, self.mock_test_args)
+            self.assertRegex(mock_log.error.mock_calls[0][1][0], test_re)
+
+        _do_test(rbac_exceptions.RbacMissingAttributeResponseBody,
+                 attribute=mock.sentinel.attr)
+        _do_test(rbac_exceptions.RbacPartialResponseBody,
+                 body=mock.sentinel.body)
+        _do_test(rbac_exceptions.RbacEmptyResponseBody)
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
@@ -399,28 +424,6 @@
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
-    def test_rule_validation_rbac_malformed_response_negative(
-            self, mock_authority, mock_log):
-        """Test RbacMalformedResponse error is thrown with permission fails.
-
-        Negative test case: if RbacMalformedResponse is thrown and the user is
-        allowed to perform the action, then this is an expected failure.
-        """
-        mock_authority.PolicyAuthority.return_value.allowed.return_value = True
-
-        @rbac_rv.action(mock.sentinel.service, rules=[mock.sentinel.action])
-        def test_policy(*args):
-            raise rbac_exceptions.RbacMalformedResponse()
-
-        test_re = ("User with roles \['member', 'anotherrole'\] was not "
-                   "allowed to perform the following actions: \[%s\]. " %
-                   (mock.sentinel.action))
-        self.assertRaisesRegex(rbac_exceptions.RbacUnderPermissionException,
-                               test_re, test_policy, self.mock_test_args)
-        self.assertRegex(mock_log.error.mock_calls[0][1][0], test_re)
-
-    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
-    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
     def test_expect_not_found_and_raise_not_found(self, mock_authority,
                                                   mock_log):
         """Test that expecting 404 and getting 404 works for all scenarios.
@@ -960,7 +963,7 @@
     def test_rule_validation_override_role_patrole_exception_ignored(
             self, mock_authority):
         """Test success case where Patrole exception is raised (which is
-        valid in case of e.g. RbacMalformedException) after override_role
+        valid in case of e.g. RbacPartialResponseBody) after override_role
         passes.
         """
         mock_authority.PolicyAuthority.return_value.allowed.return_value =\
diff --git a/releasenotes/notes/break-up-rbac-malformed-exception-into-discrete-exceptions-92aedb99d0a13f58.yaml b/releasenotes/notes/break-up-rbac-malformed-exception-into-discrete-exceptions-92aedb99d0a13f58.yaml
new file mode 100644
index 0000000..0a93b64
--- /dev/null
+++ b/releasenotes/notes/break-up-rbac-malformed-exception-into-discrete-exceptions-92aedb99d0a13f58.yaml
@@ -0,0 +1,25 @@
+---
+features:
+  - |
+    The exception class ``RbacMalformedException`` has been broken up into the
+    following discrete exceptions:
+
+    * ``RbacMissingAttributeResponseBody`` - incomplete means that the
+      response body (for show or list) is missing certain attributes
+    * ``RbacPartialResponseBody`` - partial means that a list response
+      only returned a subset of the possible results available.
+    * ``RbacEmptyResponseBody`` - empty means that the show or list
+      response body is entirely empty
+
+    Each of the exception classes above deals with a different type of failure
+    related to a soft authorization failure. This means that, rather than a
+    403 error code getting returned by the server, the response body is
+    incomplete in some way.
+upgrade:
+  - |
+    The exception class ``RbacMalformedException`` has been removed. Use one
+    of the following exception classes instead:
+
+    * ``RbacMissingAttributeResponseBody``
+    * ``RbacPartialResponseBody``
+    * ``RbacEmptyResponseBody``