Merge "Fix test_volume_upload BadRequest error"
diff --git a/.zuul.yaml b/.zuul.yaml
index 2619ed7..60f0d05 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -1,7 +1,7 @@
 - job:
     name: patrole-base
     parent: devstack-tempest
-    description: Patrole base job for admin and Member roles.
+    description: Patrole base job for admin and member roles.
     required-projects:
       - name: openstack/tempest
       - name: openstack/patrole
@@ -54,7 +54,7 @@
 - job:
     name: patrole-member
     parent: patrole-base
-    description: Patrole job for Member role.
+    description: Patrole job for member role.
     # This currently works from stable/pike onward.
     branches:
       - master
@@ -62,7 +62,7 @@
       - stable/pike
     vars:
       devstack_localrc:
-        RBAC_TEST_ROLE: Member
+        RBAC_TEST_ROLE: member
 
 - job:
     name: patrole-member-queens
@@ -93,12 +93,12 @@
 - job:
     name: patrole-py35-member
     parent: patrole-base
-    description: Patrole py3 job for Member role.
+    description: Patrole py35 job for member role.
     vars:
       devstack_localrc:
-        # Use Member for py3 because arguably negative testing is more
+        # Use member for py35 because arguably negative testing is more
         # important than admin, which is already covered by patrole-admin job.
-        RBAC_TEST_ROLE: Member
+        RBAC_TEST_ROLE: member
         USE_PYTHON3: true
       devstack_services:
         s-account: false
@@ -123,7 +123,3 @@
       jobs:
         - patrole-admin
         - patrole-member
-        - patrole-member-queens
-        - patrole-member-pike
-        - patrole-py35-member
-        - openstack-tox-lower-constraints
diff --git a/devstack/plugin.sh b/devstack/plugin.sh
index d56c963..bd0068b 100644
--- a/devstack/plugin.sh
+++ b/devstack/plugin.sh
@@ -13,16 +13,13 @@
 function install_patrole_tempest_plugin {
     setup_package $PATROLE_DIR -e
 
-    if [[ "$RBAC_TEST_ROLE" == "member" ]]; then
-        RBAC_TEST_ROLE="Member"
-    fi
-
-    iniset $TEMPEST_CONFIG patrole enable_rbac True
-    iniset $TEMPEST_CONFIG patrole rbac_test_role $RBAC_TEST_ROLE
-
     if [[ ${DEVSTACK_SERIES} == 'pike' ]]; then
+        if [[ "$RBAC_TEST_ROLE" == "member" ]]; then
+            RBAC_TEST_ROLE="Member"
+        fi
+
         # Policies used by Patrole testing that were changed in a backwards-incompatible way.
-        # TODO(fmontei): Remove these once stable/pike becomes EOL.
+        # TODO(felipemonteiro): Remove these once stable/pike becomes EOL.
         iniset $TEMPEST_CONFIG policy-feature-enabled create_port_fixed_ips_ip_address_policy False
         iniset $TEMPEST_CONFIG policy-feature-enabled update_port_fixed_ips_ip_address_policy False
         iniset $TEMPEST_CONFIG policy-feature-enabled limits_extension_used_limits_policy False
@@ -30,6 +27,15 @@
         iniset $TEMPEST_CONFIG policy-feature-enabled volume_extension_volume_actions_reserve_policy False
         iniset $TEMPEST_CONFIG policy-feature-enabled volume_extension_volume_actions_unreserve_policy False
     fi
+
+    if [[ ${DEVSTACK_SERIES} == 'queens' ]]; then
+        if [[ "$RBAC_TEST_ROLE" == "member" ]]; then
+            RBAC_TEST_ROLE="Member"
+        fi
+    fi
+
+    iniset $TEMPEST_CONFIG patrole enable_rbac True
+    iniset $TEMPEST_CONFIG patrole rbac_test_role $RBAC_TEST_ROLE
 }
 
 if is_service_enabled tempest; then
diff --git a/doc/source/framework/policy_authority.rst b/doc/source/framework/policy_authority.rst
index 7cd4421..822c7b6 100644
--- a/doc/source/framework/policy_authority.rst
+++ b/doc/source/framework/policy_authority.rst
@@ -9,7 +9,8 @@
 This module is only called for calculating the "Expected" result if
 ``[patrole] test_custom_requirements`` is ``False``.
 
-Using the Policy Authority Module, policy verification is performed by:
+Using the :class:`~patrole_tempest_plugin.policy_authority.PolicyAuthority`
+class, policy verification is performed by:
 
 #. Pooling together the default `in-code` policy rules.
 #. Overriding the defaults with custom policy rules located in a policy.json,
@@ -22,9 +23,40 @@
 #. Performing a call with all necessary data to ``oslo.policy`` and returning
    the expected result back to ``rbac_rule_validation`` decorator.
 
+When to use
+-----------
+
+This :class:`~patrole_tempest_plugin.rbac_authority.RbacAuthority` class
+can be used to validate the default OpenStack policy configuration. It
+is recommended that this approach be used for RBAC validation for clouds that
+use little to no policy customizations or overrides.
+
+This validation approach should be used when:
+
+* Validating the out-of-the-box policy-in-code OpenStack policy configuration.
+
+  It is important that the default OpenStack policy configuration be validated
+  before deploying OpenStack into production. Bugs exist in software and the
+  earlier they can be caught and prevented (via CI/CD, for example), the
+  better. Patrole continues to be used to identify default policy bugs
+  across OpenStack services.
+
+* Validating policy reliably and accurately.
+
+  Relying on ``oslo.policy`` to compute the expected test results provides
+  accurate tests, without the hassle of having to reinvent the wheel. Since
+  OpenStack APIs use ``oslo.policy`` for policy enforcement, it makes sense
+  to compute expected results by using the same library, ensuring test
+  reliability.
+
+* Continuously validating policy changes to OpenStack projects under
+  development by gating them against Patrole CI/CD jobs run by `Zuul`_.
+
+.. _Zuul: https://docs.openstack.org/infra/zuul/
+
 Implementation
 --------------
 
 .. automodule:: patrole_tempest_plugin.policy_authority
    :members:
-   :special-members:
+   :undoc-members:
diff --git a/doc/source/framework/rbac_authority.rst b/doc/source/framework/rbac_authority.rst
new file mode 100644
index 0000000..84c372b
--- /dev/null
+++ b/doc/source/framework/rbac_authority.rst
@@ -0,0 +1,37 @@
+.. rbac-authority:
+
+RBAC Authority Module
+=====================
+
+Overview
+--------
+
+This module implements an abstract class that is implemented by the classes
+below. Each implementation is used by the :ref:`rbac-validation` framework
+to determine each expected test result.
+
+:ref:`policy-authority`
+-----------------------
+
+The *default* :class:`~patrole_tempest_plugin.rbac_authority.RbacAuthority`
+implementation class which is used for policy validation. Uses ``oslo.policy``
+to determine the expected test result.
+
+All Patrole `Zuul`_ gates use this
+:class:`~patrole_tempest_plugin.rbac_authority.RbacAuthority` class by default.
+
+.. _Zuul: https://docs.openstack.org/infra/zuul/
+
+:ref:`requirements-authority`
+-----------------------------
+
+Optional :class:`~patrole_tempest_plugin.rbac_authority.RbacAuthority`
+implementation class which is used for policy validation. It uses a high-level
+requirements-driven approach to validating RBAC in Patrole.
+
+Implementation
+--------------
+
+.. automodule:: patrole_tempest_plugin.rbac_authority
+   :members:
+   :undoc-members:
diff --git a/doc/source/framework/requirements_authority.rst b/doc/source/framework/requirements_authority.rst
new file mode 100644
index 0000000..6c4fcc0
--- /dev/null
+++ b/doc/source/framework/requirements_authority.rst
@@ -0,0 +1,105 @@
+.. _requirements-authority:
+
+Requirements Authority Module
+=============================
+
+Overview
+--------
+
+Requirements-driven approach to declaring the expected RBAC test results
+referenced by Patrole. Uses a high-level YAML syntax to crystallize policy
+requirements concisely and unambiguously.
+
+.. note::
+
+    The :ref:`custom-requirements-file` is required to use this validation
+    approach and, currently, must be manually generated.
+
+This validation approach can be toggled on by setting the
+``[patrole].test_custom_requirements`` configuration option to ``True``;
+see :ref:`patrole-configuration` for more information.
+
+When to use
+-----------
+
+This :class:`~patrole_tempest_plugin.rbac_authority.RbacAuthority` class
+can be used to achieve a requirements-driven approach to validating an
+OpenStack cloud's RBAC implementation. Using this approach, Patrole computes
+expected test results by performing lookups against a
+:ref:`custom-requirements-file` which precisely defines the cloud's RBAC
+requirements.
+
+Using a high-level declarative language, the requirements are captured
+unambiguously in the :ref:`custom-requirements-file`, allowing operators to
+validate their requirements against their OpenStack cloud.
+
+This validation approach should be used when:
+
+* The cloud has heavily customized policy files that require careful validation
+  against one's requirements.
+
+  Heavily customized policy files can contain relatively nuanced/technical
+  syntax that impinges upon the goal of using a clear and concise syntax
+  present in the :ref:`custom-requirements-file` to drive RBAC validation.
+
+* The cloud has non-OpenStack services that require RBAC validation but which
+  don't leverage the ``oslo.policy`` framework.
+
+  Services like `Contrail`_ that are present in an OpenStack-based cloud that
+  interface with OpenStack services like Neutron also require RBAC validation.
+  The requirements-driven approach to RBAC validation is framework-agnostic
+  and so can work with any policy engine.
+
+* Expected results are captured as clear-cut, unambiguous requirements.
+
+  Validating a cloud's RBAC against high-level, clear-cut requirements is
+  a valid use case. Relying on ``oslo.policy`` validating customized policy
+  files is not sufficient to satisfy this use case.
+
+As mentioned above, the trade-off with this approach is having to manually
+generate the :ref:`custom-requirements-file`. There is currently no
+tooling to automatically do this.
+
+.. _Contrail: https://github.com/Juniper/contrail-controller/wiki/RBAC
+
+.. _custom-requirements-file:
+
+Custom Requirements File
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+File path of the YAML file that defines your RBAC requirements. This
+file must be located on the same host that Patrole runs on. The YAML
+file should be written as follows:
+
+.. code-block:: yaml
+
+    <service_foo>:
+      <api_action_a>:
+        - <allowed_role_1>
+        - <allowed_role_2>
+        - <allowed_role_3>
+      <api_action_b>:
+        - <allowed_role_2>
+        - <allowed_role_4>
+    <service_bar>:
+      <api_action_c>:
+        - <allowed_role_3>
+
+Where:
+
+service = the service that is being tested (Cinder, Nova, etc.).
+
+api_action = the policy action that is being tested. Examples:
+
+* volume:create
+* os_compute_api:servers:start
+* add_image
+
+allowed_role = the ``oslo.policy`` role that is allowed to perform the API.
+
+Implementation
+--------------
+
+.. automodule:: patrole_tempest_plugin.requirements_authority
+   :members:
+   :undoc-members:
diff --git a/doc/source/index.rst b/doc/source/index.rst
index d964845..8368262 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -47,7 +47,9 @@
 
    framework/overview
    framework/rbac_validation
+   framework/rbac_authority
    framework/policy_authority
+   framework/requirements_authority
    framework/rbac_utils
 
 Indices and tables
diff --git a/etc/patrole.conf.sample b/etc/patrole.conf.sample
index 5816ea9..8e7931b 100644
--- a/etc/patrole.conf.sample
+++ b/etc/patrole.conf.sample
@@ -28,15 +28,16 @@
 
 #
 # This option determines whether Patrole should run against a
-# `custom_requirements_file` which defines RBAC requirements. The
-# purpose of setting this flag to True is to verify that RBAC policy
+# ``custom_requirements_file`` which defines RBAC requirements. The
+# purpose of setting this flag to ``True`` is to verify that RBAC
+# policy
 # is in accordance to requirements. The idea is that the
-# `custom_requirements_file` perfectly defines what the RBAC
+# ``custom_requirements_file`` precisely defines what the RBAC
 # requirements are.
 #
 # Here are the possible outcomes when running the Patrole tests
 # against
-# a `custom_requirements_file`:
+# a ``custom_requirements_file``:
 #
 # YAML definition: allowed
 # test run: allowed
@@ -44,7 +45,7 @@
 #
 # YAML definition: allowed
 # test run: not allowed
-# test result: fail (under-permission)
+# test result: fail (under-permission, e.g. Forbidden exception)
 #
 # YAML definition: not allowed
 # test run: allowed
@@ -53,30 +54,36 @@
 #test_custom_requirements = false
 
 #
-# File path of the yaml file that defines your RBAC requirements. This
-# file must be located on the same host that Patrole runs on. The yaml
+# File path of the YAML file that defines your RBAC requirements. This
+# file must be located on the same host that Patrole runs on. The YAML
 # file should be written as follows:
 #
-# ```
-# <service>:
-#   <api_action>:
-#     - <allowed_role>
-#     - <allowed_role>
-#     - <allowed_role>
-#   <api_action>:
-#     - <allowed_role>
-#     - <allowed_role>
-# <service>
-#   <api_action>:
-#     - <allowed_role>
-# ```
+# .. code-block:: yaml
+#
+#     <service_foo>:
+#       <api_action_a>:
+#         - <allowed_role_1>
+#         - <allowed_role_2>
+#         - <allowed_role_3>
+#       <api_action_b>:
+#         - <allowed_role_2>
+#         - <allowed_role_4>
+#     <service_bar>:
+#       <api_action_c>:
+#         - <allowed_role_3>
+#
 # Where:
-# service = the service that is being tested (cinder, nova, etc)
+#
+# service = the service that is being tested (Cinder, Nova, etc.).
+#
 # api_action = the policy action that is being tested. Examples:
-#              - volume:create
-#              - os_compute_api:servers:start
-#              - add_image
-# allowed_role = the Keystone role that is allowed to perform the API
+#
+# * volume:create
+# * os_compute_api:servers:start
+# * add_image
+#
+# allowed_role = the ``oslo.policy`` role that is allowed to perform
+# the API.
 #  (string value)
 #custom_requirements_file = <None>
 
diff --git a/patrole_tempest_plugin/README.rst b/patrole_tempest_plugin/README.rst
deleted file mode 100644
index d678422..0000000
--- a/patrole_tempest_plugin/README.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-==============================
-Tempest Integration of Patrole
-==============================
-
-This directory contains Tempest tests to cover the Patrole project.
diff --git a/patrole_tempest_plugin/config.py b/patrole_tempest_plugin/config.py
index 5103888..ee7a6c5 100644
--- a/patrole_tempest_plugin/config.py
+++ b/patrole_tempest_plugin/config.py
@@ -41,13 +41,13 @@
                 default=False,
                 help="""
 This option determines whether Patrole should run against a
-`custom_requirements_file` which defines RBAC requirements. The
-purpose of setting this flag to True is to verify that RBAC policy
+``custom_requirements_file`` which defines RBAC requirements. The
+purpose of setting this flag to ``True`` is to verify that RBAC policy
 is in accordance to requirements. The idea is that the
-`custom_requirements_file` perfectly defines what the RBAC requirements are.
+``custom_requirements_file`` precisely defines what the RBAC requirements are.
 
 Here are the possible outcomes when running the Patrole tests against
-a `custom_requirements_file`:
+a ``custom_requirements_file``:
 
 YAML definition: allowed
 test run: allowed
@@ -55,7 +55,7 @@
 
 YAML definition: allowed
 test run: not allowed
-test result: fail (under-permission)
+test result: fail (under-permission, e.g. Forbidden exception)
 
 YAML definition: not allowed
 test run: allowed
@@ -63,30 +63,35 @@
 """),
     cfg.StrOpt('custom_requirements_file',
                help="""
-File path of the yaml file that defines your RBAC requirements. This
-file must be located on the same host that Patrole runs on. The yaml
+File path of the YAML file that defines your RBAC requirements. This
+file must be located on the same host that Patrole runs on. The YAML
 file should be written as follows:
 
-```
-<service>:
-  <api_action>:
-    - <allowed_role>
-    - <allowed_role>
-    - <allowed_role>
-  <api_action>:
-    - <allowed_role>
-    - <allowed_role>
-<service>
-  <api_action>:
-    - <allowed_role>
-```
+.. code-block:: yaml
+
+    <service_foo>:
+      <api_action_a>:
+        - <allowed_role_1>
+        - <allowed_role_2>
+        - <allowed_role_3>
+      <api_action_b>:
+        - <allowed_role_2>
+        - <allowed_role_4>
+    <service_bar>:
+      <api_action_c>:
+        - <allowed_role_3>
+
 Where:
-service = the service that is being tested (cinder, nova, etc)
+
+service = the service that is being tested (Cinder, Nova, etc.).
+
 api_action = the policy action that is being tested. Examples:
-             - volume:create
-             - os_compute_api:servers:start
-             - add_image
-allowed_role = the Keystone role that is allowed to perform the API
+
+* volume:create
+* os_compute_api:servers:start
+* add_image
+
+allowed_role = the ``oslo.policy`` role that is allowed to perform the API.
 """)
 ]
 
diff --git a/patrole_tempest_plugin/policy_authority.py b/patrole_tempest_plugin/policy_authority.py
index 99348b9..b813f88 100644
--- a/patrole_tempest_plugin/policy_authority.py
+++ b/patrole_tempest_plugin/policy_authority.py
@@ -24,8 +24,8 @@
 from tempest.common import credentials_factory as credentials
 from tempest import config
 
+from patrole_tempest_plugin.rbac_authority import RbacAuthority
 from patrole_tempest_plugin import rbac_exceptions
-from patrole_tempest_plugin.rbac_utils import RbacAuthority
 
 CONF = config.CONF
 LOG = logging.getLogger(__name__)
@@ -156,9 +156,9 @@
     def allowed(self, rule_name, role):
         """Checks if a given rule in a policy is allowed with given role.
 
-        :param string rule_name: Rule to be checked using ``oslo.policy``.
-        :param bool is_admin: Whether admin context is used.
-        :raises RbacParsingException: If `rule_name`` does not exist in the
+        :param string rule_name: Policy name to pass to``oslo.policy``.
+        :param string role: Role to validate for authorization.
+        :raises RbacParsingException: If ``rule_name`` does not exist in the
             cloud (in policy file or among registered in-code policy defaults).
         """
         is_admin_context = self._is_admin_context(role)
@@ -274,9 +274,9 @@
         access_data['is_admin'] = is_admin
         # TODO(felipemonteiro): Dynamically calculate is_admin_project rather
         # than hard-coding it to True. is_admin_project cannot be determined
-        # from the role, but rather from project and domain names. See
-        # _populate_is_admin_project in keystone.token.providers.common
-        # for more information.
+        # from the role, but rather from project and domain names. For more
+        # information, see:
+        # https://github.com/openstack/keystone/blob/37ce5417418f8acbd27f3dacb70c605b0fe48301/keystone/token/providers/common.py#L150
         access_data['is_admin_project'] = True
 
         class Object(object):
diff --git a/patrole_tempest_plugin/rbac_authority.py b/patrole_tempest_plugin/rbac_authority.py
new file mode 100644
index 0000000..294ecc5
--- /dev/null
+++ b/patrole_tempest_plugin/rbac_authority.py
@@ -0,0 +1,38 @@
+# Copyright 2017 AT&T Corporation.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import abc
+
+import six
+
+
+@six.add_metaclass(abc.ABCMeta)
+class RbacAuthority(object):
+    """Class for validating whether a given role can perform a policy action.
+
+    Any class that extends ``RbacAuthority`` provides the logic for determining
+    whether a role has permissions to execute a policy action.
+    """
+
+    @abc.abstractmethod
+    def allowed(self, rule, role):
+        """Determine whether the role should be able to perform the API.
+
+        :param rule: The name of the policy enforced by the API.
+        :param role: The role used to determine whether ``rule`` can be
+            executed.
+        :returns: True if the ``role`` has permissions to execute
+            ``rule``, else False.
+        """
diff --git a/patrole_tempest_plugin/rbac_exceptions.py b/patrole_tempest_plugin/rbac_exceptions.py
index 5ee65ae..e75b8ec 100644
--- a/patrole_tempest_plugin/rbac_exceptions.py
+++ b/patrole_tempest_plugin/rbac_exceptions.py
@@ -38,7 +38,7 @@
 
 
 class RbacResourceSetupFailed(exceptions.TempestException):
-    message = "Rbac resource setup failed"
+    message = "RBAC resource setup failed"
 
 
 class RbacOverPermission(exceptions.TempestException):
diff --git a/patrole_tempest_plugin/rbac_rule_validation.py b/patrole_tempest_plugin/rbac_rule_validation.py
index d3213cf..7d48870 100644
--- a/patrole_tempest_plugin/rbac_rule_validation.py
+++ b/patrole_tempest_plugin/rbac_rule_validation.py
@@ -17,6 +17,7 @@
 import logging
 import sys
 
+from oslo_log import versionutils
 from oslo_utils import excutils
 import six
 
@@ -32,11 +33,14 @@
 LOG = logging.getLogger(__name__)
 
 _SUPPORTED_ERROR_CODES = [403, 404]
+_DEFAULT_ERROR_CODE = 403
 
 RBACLOG = logging.getLogger('rbac_reporting')
 
 
-def action(service, rule='', expected_error_code=403, extra_target_data=None):
+def action(service, rule='', rules=None,
+           expected_error_code=_DEFAULT_ERROR_CODE, expected_error_codes=None,
+           extra_target_data=None):
     """A decorator for verifying OpenStack policy enforcement.
 
     A decorator which allows for positive and negative RBAC testing. Given:
@@ -67,15 +71,18 @@
 
     As such, negative and positive testing can be applied using this decorator.
 
-    :param service: An OpenStack service. Examples: "nova" or "neutron".
-    :param rule: A policy action defined in a policy.json file (or in
-        code).
+    :param str service: An OpenStack service. Examples: "nova" or "neutron".
+    :param str rule: (DEPRECATED) A policy action defined in a policy.json file
+        or in code.
+    :param list rules: A list of policy actions defined in a policy.json file
+        or in code. The rules are logical-ANDed together to derive the expected
+        result.
 
         .. note::
 
             Patrole currently only supports custom JSON policy files.
 
-    :param expected_error_code: Overrides default value of 403 (Forbidden)
+    :param int expected_error_code: Overrides default value of 403 (Forbidden)
         with endpoint-specific error code. Currently only supports 403 and 404.
         Support for 404 is needed because some services, like Neutron,
         intentionally throw a 404 for security reasons.
@@ -85,11 +92,30 @@
             A 404 should not be provided *unless* the endpoint masks a
             ``Forbidden`` exception as a ``NotFound`` exception.
 
-    :param extra_target_data: Dictionary, keyed with ``oslo.policy`` generic
-        check names, whose values are string literals that reference nested
-        ``tempest.test.BaseTestCase`` attributes. Used by ``oslo.policy`` for
-        performing matching against attributes that are sent along with the API
-        calls. Example::
+    :param list expected_error_codes: When the ``rules`` list parameter is
+        used, then this list indicates the expected error code to use if one
+        of the rules does not allow the role being tested. This list must
+        coincide with and its elements remain in the same order as the rules
+        in the rules list.
+
+        Example::
+            rules=["api_action1", "api_action2"]
+            expected_error_codes=[404, 403]
+
+        a) If api_action1 fails and api_action2 passes, then the expected
+           error code is 404.
+        b) if api_action2 fails and api_action1 passes, then the expected
+           error code is 403.
+        c) if both api_action1 and api_action2 fail, then the expected error
+           code is the first error seen (404).
+
+        If an error code is missing from the list, it is defaulted to 403.
+
+    :param dict extra_target_data: Dictionary, keyed with ``oslo.policy``
+        generic check names, whose values are string literals that reference
+        nested ``tempest.test.BaseTestCase`` attributes. Used by
+        ``oslo.policy`` for performing matching against attributes that are
+        sent along with the API calls. Example::
 
             extra_target_data={
                 "target.token.user_id":
@@ -113,6 +139,10 @@
     if extra_target_data is None:
         extra_target_data = {}
 
+    rules, expected_error_codes = _prepare_multi_policy(rule, rules,
+                                                        expected_error_code,
+                                                        expected_error_codes)
+
     def decorator(test_func):
         role = CONF.patrole.rbac_test_role
 
@@ -125,11 +155,27 @@
                     '`rbac_rule_validation` decorator can only be applied to '
                     'an instance of `tempest.test.BaseTestCase`.')
 
-            allowed = _is_authorized(test_obj, service, rule,
-                                     extra_target_data)
+            allowed = True
+            disallowed_rules = []
+            for rule in rules:
+                _allowed = _is_authorized(
+                    test_obj, service, rule, extra_target_data)
+                if not _allowed:
+                    disallowed_rules.append(rule)
+                allowed = allowed and _allowed
+
+            exp_error_code = expected_error_code
+            if disallowed_rules:
+                # Choose the first disallowed rule and expect the error
+                # code corresponding to it.
+                first_error_index = rules.index(disallowed_rules[0])
+                exp_error_code = expected_error_codes[first_error_index]
+                LOG.debug("%s: Expecting %d to be raised for policy name: %s",
+                          test_func.__name__, exp_error_code,
+                          disallowed_rules[0])
 
             expected_exception, irregular_msg = _get_exception_type(
-                expected_error_code)
+                exp_error_code)
 
             test_status = 'Allowed'
 
@@ -148,8 +194,12 @@
                 if irregular_msg:
                     LOG.warning(irregular_msg.format(rule, service))
                 if allowed:
-                    msg = ("Role %s was not allowed to perform %s." %
-                           (role, rule))
+                    msg = ("Role %s was not allowed to perform the following "
+                           "actions: %s. Expected allowed actions: %s. "
+                           "Expected disallowed actions: %s." % (
+                               role, sorted(rules),
+                               sorted(set(rules) - set(disallowed_rules)),
+                               sorted(disallowed_rules)))
                     LOG.error(msg)
                     raise exceptions.Forbidden(
                         "%s Exception was: %s" % (msg, e))
@@ -164,10 +214,14 @@
                     LOG.error(msg)
             else:
                 if not allowed:
-                    LOG.error("Role %s was allowed to perform %s", role, rule)
-                    raise rbac_exceptions.RbacOverPermission(
-                        "OverPermission: Role %s was allowed to perform %s" %
-                        (role, rule))
+                    msg = (
+                        "OverPermission: Role %s was allowed to perform the "
+                        "following disallowed actions: %s" % (
+                            role, sorted(disallowed_rules)
+                        )
+                    )
+                    LOG.error(msg)
+                    raise rbac_exceptions.RbacOverPermission(msg)
             finally:
                 if CONF.patrole_log.enable_reporting:
                     RBACLOG.info(
@@ -181,6 +235,59 @@
     return decorator
 
 
+def _prepare_multi_policy(rule, rules, exp_error_code, exp_error_codes):
+
+    if exp_error_codes:
+        if not rules:
+            msg = ("The `rules` list must be provided if using the "
+                   "`expected_error_codes` list.")
+            raise ValueError(msg)
+        if len(rules) != len(exp_error_codes):
+            msg = ("The `expected_error_codes` list is not the same length "
+                   "as the `rules` list.")
+            raise ValueError(msg)
+        if exp_error_code:
+            deprecation_msg = (
+                "The `exp_error_code` argument has been deprecated in favor "
+                "of `exp_error_codes` and will be removed in a future "
+                "version.")
+            versionutils.report_deprecated_feature(LOG, deprecation_msg)
+            LOG.debug("The `exp_error_codes` argument will be used instead of "
+                      "`exp_error_code`.")
+        if not isinstance(exp_error_codes, (tuple, list)):
+            exp_error_codes = [exp_error_codes]
+    else:
+        exp_error_codes = []
+        if exp_error_code:
+            exp_error_codes.append(exp_error_code)
+
+    if rules is None:
+        rules = []
+    elif not isinstance(rules, (tuple, list)):
+        rules = [rules]
+    if rule:
+        deprecation_msg = (
+            "The `rule` argument has been deprecated in favor of `rules` "
+            "and will be removed in a future version.")
+        versionutils.report_deprecated_feature(LOG, deprecation_msg)
+        if rules:
+            LOG.debug("The `rules` argument will be used instead of `rule`.")
+        else:
+            rules.append(rule)
+
+    # Fill in the exp_error_codes if needed. This is needed for the scenarios
+    # where no exp_error_codes array is provided, so the error codes must be
+    # set to the default error code value and there must be the same number
+    # of error codes as rules.
+    num_ecs = len(exp_error_codes)
+    num_rules = len(rules)
+    if (num_ecs < num_rules):
+        for i in range(num_rules - num_ecs):
+            exp_error_codes.append(_DEFAULT_ERROR_CODE)
+
+    return rules, exp_error_codes
+
+
 def _is_authorized(test_obj, service, rule, extra_target_data):
     """Validates whether current RBAC role has permission to do policy action.
 
diff --git a/patrole_tempest_plugin/rbac_utils.py b/patrole_tempest_plugin/rbac_utils.py
index 347f77f..6c40aa1 100644
--- a/patrole_tempest_plugin/rbac_utils.py
+++ b/patrole_tempest_plugin/rbac_utils.py
@@ -13,9 +13,7 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
-import abc
 from contextlib import contextmanager
-import six
 import time
 
 from oslo_log import log as logging
@@ -149,18 +147,25 @@
             test_obj.os_primary.auth_provider.set_auth()
 
     def _get_roles_by_name(self):
-        available_roles = self.admin_roles_client.list_roles()
-        admin_role_id = rbac_role_id = None
+        available_roles = self.admin_roles_client.list_roles()['roles']
+        role_map = {r['name']: r['id'] for r in available_roles}
+        LOG.debug('Available roles: %s', list(role_map.keys()))
 
-        for role in available_roles['roles']:
-            if role['name'] == CONF.patrole.rbac_test_role:
-                rbac_role_id = role['id']
-            if role['name'] == CONF.identity.admin_role:
-                admin_role_id = role['id']
+        admin_role_id = role_map.get(CONF.identity.admin_role)
+        rbac_role_id = role_map.get(CONF.patrole.rbac_test_role)
 
         if not all([admin_role_id, rbac_role_id]):
-            msg = ("Roles defined by `[patrole] rbac_test_role` and "
-                   "`[identity] admin_role` must be defined in the system.")
+            missing_roles = []
+            msg = ("Could not find `[patrole] rbac_test_role` or "
+                   "`[identity] admin_role`, both of which are required for "
+                   "RBAC testing.")
+            if not admin_role_id:
+                missing_roles.append(CONF.identity.admin_role)
+            if not rbac_role_id:
+                missing_roles.append(CONF.patrole.rbac_test_role)
+            msg += " Following roles were not found: %s." % (
+                ", ".join(missing_roles))
+            msg += " Available roles: %s." % ", ".join(list(role_map.keys()))
             raise rbac_exceptions.RbacResourceSetupFailed(msg)
 
         self.admin_role_id = admin_role_id
@@ -228,24 +233,6 @@
 
     :returns: True if ``rbac_test_role`` is the admin role.
     """
+    # TODO(felipemonteiro): Make this more robust via a context is admin
+    # lookup.
     return CONF.patrole.rbac_test_role == CONF.identity.admin_role
-
-
-@six.add_metaclass(abc.ABCMeta)
-class RbacAuthority(object):
-    """Class for validating whether a given role can perform a policy action.
-
-    Any class that extends ``RbacAuthority`` provides the logic for determining
-    whether a role has permissions to execute a policy action.
-    """
-
-    @abc.abstractmethod
-    def allowed(self, rule, role):
-        """Determine whether the role should be able to perform the API.
-
-        :param rule: The name of the policy enforced by the API.
-        :param role: The role used to determine whether ``rule`` can be
-            executed.
-        :returns: True if the ``role`` has permissions to execute
-            ``rule``, else False.
-        """
diff --git a/patrole_tempest_plugin/requirements_authority.py b/patrole_tempest_plugin/requirements_authority.py
index 2db12db..75df9f4 100644
--- a/patrole_tempest_plugin/requirements_authority.py
+++ b/patrole_tempest_plugin/requirements_authority.py
@@ -16,14 +16,17 @@
 
 from oslo_log import log as logging
 
+from tempest import config
 from tempest.lib import exceptions
 
-from patrole_tempest_plugin.rbac_utils import RbacAuthority
+from patrole_tempest_plugin.rbac_authority import RbacAuthority
 
+CONF = config.CONF
 LOG = logging.getLogger(__name__)
 
 
 class RequirementsParser(object):
+    """A class that parses a custom requirements file."""
     _inner = None
 
     class Inner(object):
@@ -40,6 +43,27 @@
 
     @staticmethod
     def parse(component):
+        """Parses a requirements file with the following format:
+
+        .. code-block:: yaml
+
+            <service_foo>:
+              <api_action_a>:
+                - <allowed_role_1>
+                - <allowed_role_2>
+                - <allowed_role_3>
+              <api_action_b>:
+                - <allowed_role_2>
+                - <allowed_role_4>
+            <service_bar>:
+              <api_action_c>:
+                - <allowed_role_3>
+
+        :param str component: Name of the OpenStack service to be validated.
+        :returns: The dictionary that maps each policy action to the list
+            of allowed roles, for the given ``component``.
+        :rtype: dict
+        """
         try:
             for section in RequirementsParser.Inner._rbac_map:
                 if component in section:
@@ -51,13 +75,39 @@
 
 
 class RequirementsAuthority(RbacAuthority):
+    """A class that uses a custom requirements file to validate RBAC."""
+
     def __init__(self, filepath=None, component=None):
-        if filepath is not None and component is not None:
+        """This class can be used to achieve a requirements-driven approach to
+        validating an OpenStack cloud's RBAC implementation. Using this
+        approach, Patrole computes expected test results by performing lookups
+        against a custom requirements file which precisely defines the cloud's
+        RBAC requirements.
+
+        :param str filepath: Path where the custom requirements file lives.
+            Defaults to ``[patrole].custom_requirements_file``.
+        :param str component: Name of the OpenStack service to be validated.
+        """
+        filepath = filepath or CONF.patrole.custom_requirements_file
+
+        if component is not None:
             self.roles_dict = RequirementsParser(filepath).parse(component)
         else:
             self.roles_dict = None
 
     def allowed(self, rule_name, role):
+        """Checks if a given rule in a policy is allowed with given role.
+
+        :param string rule_name: Rule to be checked using provided requirements
+            file specified by ``[patrole].custom_requirements_file``. Must be
+            a key present in this file, under the appropriate component.
+        :param string role: Role to validate against custom requirements file.
+        :returns: True if ``role`` is allowed to perform ``rule_name``, else
+            False.
+        :rtype: bool
+        :raises KeyError: If ``rule_name`` does not exist among the keyed
+            policy names in the custom requirements file.
+        """
         if self.roles_dict is None:
             raise exceptions.InvalidConfiguration(
                 "Roles dictionary parsed from requirements YAML file is "
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 adb5a6c..1fe52e9 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
@@ -33,6 +33,14 @@
 
 class ServerActionsRbacTest(rbac_base.BaseV2ComputeRbacTest):
 
+    # admin credentials used for waiters which invokes a show API call
+    credentials = ['primary', 'admin']
+
+    @classmethod
+    def setup_clients(cls):
+        super(ServerActionsRbacTest, cls).setup_clients()
+        cls.admin_servers_client = cls.os_admin.servers_client
+
     @classmethod
     def resource_setup(cls):
         super(ServerActionsRbacTest, cls).resource_setup()
@@ -58,17 +66,24 @@
     def _stop_server(self):
         self.servers_client.stop_server(self.server_id)
         waiters.wait_for_server_status(
-            self.servers_client, self.server_id, 'SHUTOFF')
+            self.admin_servers_client, self.server_id, 'SHUTOFF')
 
     def _resize_server(self, flavor):
+        status = self.admin_servers_client. \
+            show_server(self.server_id)['server']['status']
+        if status == 'RESIZED':
+            return
         self.servers_client.resize_server(self.server_id, flavor)
         waiters.wait_for_server_status(
-            self.servers_client, self.server_id, 'VERIFY_RESIZE')
+            self.admin_servers_client, self.server_id, 'VERIFY_RESIZE')
 
     def _confirm_resize_server(self):
-        self.servers_client.confirm_resize_server(self.server_id)
+        status = self.admin_servers_client. \
+            show_server(self.server_id)['server']['status']
+        if status == 'VERIFY_RESIZE':
+            self.servers_client.confirm_resize_server(self.server_id)
         waiters.wait_for_server_status(
-            self.servers_client, self.server_id, 'ACTIVE')
+            self.admin_servers_client, self.server_id, 'ACTIVE')
 
     def _shelve_server(self):
         self.servers_client.shelve_server(self.server_id)
@@ -77,12 +92,13 @@
                         self.server_id)
         offload_time = CONF.compute.shelved_offload_time
         if offload_time >= 0:
-            waiters.wait_for_server_status(self.servers_client,
+            waiters.wait_for_server_status(self.admin_servers_client,
                                            self.server_id,
                                            'SHELVED_OFFLOADED',
                                            extra_timeout=offload_time)
         else:
-            waiters.wait_for_server_status(self.servers_client, self.server_id,
+            waiters.wait_for_server_status(self.admin_servers_client,
+                                           self.server_id,
                                            'SHELVED')
 
     def _pause_server(self):
@@ -91,7 +107,7 @@
                         self.servers_client.unpause_server,
                         self.server_id)
         waiters.wait_for_server_status(
-            self.servers_client, self.server_id, 'PAUSED')
+            self.admin_servers_client, self.server_id, 'PAUSED')
 
     def _cleanup_server_actions(self, function, server_id, **kwargs):
         server = self.servers_client.show_server(server_id)['server']
@@ -179,6 +195,7 @@
         self._resize_server(self.flavor_ref_alt)
         self.addCleanup(self._confirm_resize_server)
         self.addCleanup(self._resize_server, self.flavor_ref)
+        self.addCleanup(self._confirm_resize_server)
 
         with self.rbac_utils.override_role(self):
             self._confirm_resize_server()
@@ -276,6 +293,7 @@
             self.compute_images_client.delete_image, image['id'])
 
     @decorators.idempotent_id('9fdd4630-731c-4f7c-bce5-69fa3792c52a')
+    @decorators.attr(type='slow')
     @testtools.skipUnless(CONF.compute_feature_enabled.snapshot,
                           'Snapshotting not available, backup not possible.')
     @utils.services('image')
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 63dee85..13faca1 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
@@ -395,7 +395,8 @@
 
     @rbac_rule_validation.action(
         service="nova",
-        rule="os_compute_api:os-lock-server:unlock:unlock_override")
+        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.
@@ -569,8 +570,7 @@
     @classmethod
     def resource_setup(cls):
         def _cleanup_ports(network_id):
-            ports = cls.ports_client.\
-                list_ports(network_id=network_id)['ports']
+            ports = cls.ports_client.list_ports(network_id=network_id)['ports']
             for port in ports:
                 test_utils.call_and_ignore_notfound_exc(
                     cls.ports_client.delete_port,
@@ -665,6 +665,9 @@
             self.interfaces_client, self.server['id'],
             interface['port_id'], 'ACTIVE')
         self.addCleanup(
+            waiters.wait_for_interface_detach, self.interfaces_client,
+            self.server['id'], interface['port_id'])
+        self.addCleanup(
             test_utils.call_and_ignore_notfound_exc,
             self.interfaces_client.delete_interface,
             self.server['id'], interface['port_id'])
@@ -683,6 +686,8 @@
         with self.rbac_utils.override_role(self):
             self.interfaces_client.delete_interface(self.server['id'],
                                                     interface['port_id'])
+        waiters.wait_for_interface_detach(
+            self.interfaces_client, self.server['id'], interface['port_id'])
 
     @decorators.idempotent_id('6886d360-0d86-4760-b1a3-882d81fbebcc')
     @utils.requires_ext(extension='os-ips', service='compute')
@@ -722,47 +727,16 @@
         if interfaces:
             network_id = interfaces[0]['net_id']
         else:
-            network_id = self.interfaces_client.create_interface(
-                self.server['id'])['interfaceAttachment']['net_id']
+            interface = self.interfaces_client.create_interface(
+                self.server['id'])['interfaceAttachment']
+            network_id = interface['net_id']
+            self.addCleanup(
+                waiters.wait_for_interface_detach, self.interfaces_client,
+                self.server['id'], interface['port_id'])
+            self.addCleanup(
+                self.interfaces_client.delete_interface,
+                self.server['id'], interface['port_id'])
 
         with self.rbac_utils.override_role(self):
             self.servers_client.add_fixed_ip(self.server['id'],
                                              networkId=network_id)
-
-
-class VirtualInterfacesRbacTest(rbac_base.BaseV2ComputeRbacTest):
-    # The compute os-virtual-interfaces API is deprecated from the Microversion
-    # 2.44 onward. For more information, see:
-    # https://developer.openstack.org/api-ref/compute/#servers-virtual-interfaces-servers-os-virtual-interfaces-deprecated
-    max_microversion = '2.43'
-
-    @classmethod
-    def setup_credentials(cls):
-        # This test needs a network and a subnet
-        cls.set_network_resources(network=True, subnet=True)
-        super(VirtualInterfacesRbacTest, cls).setup_credentials()
-
-    @classmethod
-    def resource_setup(cls):
-        super(VirtualInterfacesRbacTest, cls).resource_setup()
-        cls.server = cls.create_test_server(wait_until='ACTIVE')
-
-    @rbac_rule_validation.action(
-        service="nova",
-        rule="os_compute_api:os-virtual-interfaces")
-    @decorators.idempotent_id('fc719ae3-0f73-4689-8378-1b841f0f2818')
-    def test_list_virtual_interfaces(self):
-        """Test list virtual interfaces, part of os-virtual-interfaces.
-
-        If Neutron is available, then call the API and expect it to fail
-        with a 400 BadRequest (policy enforcement is done before that happens).
-        """
-        with self.rbac_utils.override_role(self):
-            if CONF.service_available.neutron:
-                msg = ("Listing virtual interfaces is not supported by this "
-                       "cloud.")
-                with self.assertRaisesRegex(lib_exc.BadRequest, msg):
-                    self.servers_client.list_virtual_interfaces(
-                        self.server['id'])
-            else:
-                self.servers_client.list_virtual_interfaces(self.server['id'])
diff --git a/patrole_tempest_plugin/tests/api/compute/test_server_volume_attachments_rbac.py b/patrole_tempest_plugin/tests/api/compute/test_server_volume_attachments_rbac.py
index b803fe3..a510d1e 100644
--- a/patrole_tempest_plugin/tests/api/compute/test_server_volume_attachments_rbac.py
+++ b/patrole_tempest_plugin/tests/api/compute/test_server_volume_attachments_rbac.py
@@ -25,6 +25,10 @@
 CONF = config.CONF
 
 
+# FIXME(felipemonteiro): `@decorators.attr(type='slow')` are added to tests
+# below to in effect cause the tests to be non-voting in Zuul due to a high
+# rate of spurious failures related to volume attachments. This will be
+# revisited at a later date.
 class ServerVolumeAttachmentRbacTest(rbac_base.BaseV2ComputeRbacTest):
 
     @classmethod
@@ -53,6 +57,7 @@
         with self.rbac_utils.override_role(self):
             self.servers_client.list_volume_attachments(self.server['id'])
 
+    @decorators.attr(type='slow')
     @rbac_rule_validation.action(
         service="nova",
         rule="os_compute_api:os-volumes-attachments:create")
@@ -61,6 +66,7 @@
         with self.rbac_utils.override_role(self):
             self.attach_volume(self.server, self.volume)
 
+    @decorators.attr(type='slow')
     @rbac_rule_validation.action(
         service="nova",
         rule="os_compute_api:os-volumes-attachments:show")
diff --git a/patrole_tempest_plugin/tests/api/compute/test_virtual_interfaces_rbac.py b/patrole_tempest_plugin/tests/api/compute/test_virtual_interfaces_rbac.py
new file mode 100644
index 0000000..ae77a34
--- /dev/null
+++ b/patrole_tempest_plugin/tests/api/compute/test_virtual_interfaces_rbac.py
@@ -0,0 +1,64 @@
+#    Copyright 2017 AT&T Corporation.
+#    All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from tempest import config
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+from patrole_tempest_plugin import rbac_rule_validation
+from patrole_tempest_plugin.tests.api.compute import rbac_base
+
+CONF = config.CONF
+
+
+# TODO(rb560u): Remove this test class once the nova queens branch goes into
+# extended maintenance mode.
+class VirtualInterfacesRbacTest(rbac_base.BaseV2ComputeRbacTest):
+    # The compute os-virtual-interfaces API is deprecated from the Microversion
+    # 2.44 onward. For more information, see:
+    # https://developer.openstack.org/api-ref/compute/#servers-virtual-interfaces-servers-os-virtual-interfaces-deprecated
+    depends_on_nova_network = True
+    max_microversion = '2.43'
+
+    @classmethod
+    def setup_credentials(cls):
+        # This test needs a network and a subnet
+        cls.set_network_resources(network=True, subnet=True)
+        super(VirtualInterfacesRbacTest, cls).setup_credentials()
+
+    @classmethod
+    def resource_setup(cls):
+        super(VirtualInterfacesRbacTest, cls).resource_setup()
+        cls.server = cls.create_test_server(wait_until='ACTIVE')
+
+    @rbac_rule_validation.action(
+        service="nova",
+        rule="os_compute_api:os-virtual-interfaces")
+    @decorators.idempotent_id('fc719ae3-0f73-4689-8378-1b841f0f2818')
+    def test_list_virtual_interfaces(self):
+        """Test list virtual interfaces, part of os-virtual-interfaces.
+
+        If Neutron is available, then call the API and expect it to fail
+        with a 400 BadRequest (policy enforcement is done before that happens).
+        """
+        with self.rbac_utils.override_role(self):
+            if CONF.service_available.neutron:
+                msg = ("Listing virtual interfaces is not supported by this "
+                       "cloud.")
+                with self.assertRaisesRegex(lib_exc.BadRequest, msg):
+                    self.servers_client.list_virtual_interfaces(
+                        self.server['id'])
+            else:
+                self.servers_client.list_virtual_interfaces(self.server['id'])
diff --git a/patrole_tempest_plugin/tests/api/identity/rbac_base.py b/patrole_tempest_plugin/tests/api/identity/rbac_base.py
index 90fa6aa..91b3d1e 100644
--- a/patrole_tempest_plugin/tests/api/identity/rbac_base.py
+++ b/patrole_tempest_plugin/tests/api/identity/rbac_base.py
@@ -118,6 +118,8 @@
     @classmethod
     def setup_clients(cls):
         super(BaseIdentityV3RbacTest, cls).setup_clients()
+        cls.application_credentials_client = \
+            cls.os_primary.application_credentials_client
         cls.creds_client = cls.os_primary.credentials_client
         cls.consumers_client = cls.os_primary.oauth_consumers_client
         cls.domains_client = cls.os_primary.domains_client
diff --git a/patrole_tempest_plugin/tests/api/identity/v3/test_application_credentials_rbac.py b/patrole_tempest_plugin/tests/api/identity/v3/test_application_credentials_rbac.py
new file mode 100644
index 0000000..c7a6033
--- /dev/null
+++ b/patrole_tempest_plugin/tests/api/identity/v3/test_application_credentials_rbac.py
@@ -0,0 +1,85 @@
+# Copyright 2018 AT&T Corporation.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from tempest import config
+from tempest.lib.common.utils import data_utils
+from tempest.lib.common.utils import test_utils
+from tempest.lib import decorators
+
+from patrole_tempest_plugin import rbac_rule_validation
+from patrole_tempest_plugin.tests.api.identity import rbac_base
+
+
+CONF = config.CONF
+
+
+class ApplicationCredentialsV3RbacTest(rbac_base.BaseIdentityV3RbacTest):
+
+    @classmethod
+    def skip_checks(cls):
+        super(ApplicationCredentialsV3RbacTest, cls).skip_checks()
+        if not CONF.identity_feature_enabled.application_credentials:
+            raise cls.skipException("Application credentials are not available"
+                                    " in this environment")
+
+    @classmethod
+    def resource_setup(cls):
+        super(ApplicationCredentialsV3RbacTest, cls).resource_setup()
+        cls.user_id = cls.os_primary.credentials.user_id
+
+    def _create_application_credential(self, name=None, **kwargs):
+        name = name or data_utils.rand_name('application_credential')
+        application_credential = (
+            self.application_credentials_client.create_application_credential(
+                self.user_id, name=name, **kwargs))['application_credential']
+        self.addCleanup(
+            test_utils.call_and_ignore_notfound_exc,
+            self.application_credentials_client.delete_application_credential,
+            self.user_id,
+            application_credential['id'])
+        return application_credential
+
+    @decorators.idempotent_id('b53bee14-e9df-4929-b257-6def76c12e4d')
+    @rbac_rule_validation.action(service="keystone",
+                                 rule="identity:create_application_credential")
+    def test_create_application_credential(self):
+        with self.rbac_utils.override_role(self):
+            self._create_application_credential()
+
+    @decorators.idempotent_id('58b3c3a0-5ad0-44f7-8da7-0736f71f7168')
+    @rbac_rule_validation.action(service="keystone",
+                                 rule="identity:list_application_credentials")
+    def test_list_application_credentials(self):
+        with self.rbac_utils.override_role(self):
+            self.application_credentials_client.list_application_credentials(
+                user_id=self.user_id)
+
+    @decorators.idempotent_id('d7b13968-a8a6-47fd-8e1d-7cc7f565c7f8')
+    @rbac_rule_validation.action(service="keystone",
+                                 rule="identity:get_application_credential")
+    def test_show_application_credential(self):
+        app_cred = self._create_application_credential()
+        with self.rbac_utils.override_role(self):
+            self.application_credentials_client.show_application_credential(
+                user_id=self.user_id, application_credential_id=app_cred['id'])
+
+    @decorators.idempotent_id('521b7c0f-1dd5-47a6-ae95-95c0323d7735')
+    @rbac_rule_validation.action(service="keystone",
+                                 rule="identity:delete_application_credential")
+    def test_delete_application_credential(self):
+        app_cred = self._create_application_credential()
+        with self.rbac_utils.override_role(self):
+            self.application_credentials_client.delete_application_credential(
+                user_id=self.user_id, application_credential_id=app_cred['id'])
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 84ce2c7..932683d 100644
--- a/patrole_tempest_plugin/tests/api/network/test_networks_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_networks_rbac.py
@@ -294,63 +294,6 @@
         with self.rbac_utils.override_role(self):
             self.networks_client.delete_network(network['id'])
 
-    @rbac_rule_validation.action(service="neutron",
-                                 rule="create_subnet")
-    @decorators.idempotent_id('44f42aaf-8a9a-4678-868a-b8fe82689554')
-    def test_create_subnet(self):
-
-        """Create Subnet Test
-
-        RBAC test for the neutron create_subnet policy
-        """
-        network = self._create_network()
-
-        with self.rbac_utils.override_role(self):
-            self.create_subnet(network, enable_dhcp=False)
-
-    @rbac_rule_validation.action(service="neutron",
-                                 rule="get_subnet")
-    @decorators.idempotent_id('eb88be84-2465-482b-a40b-5201acb41152')
-    def test_show_subnet(self):
-
-        """Show Subnet Test
-
-        RBAC test for the neutron get_subnet policy
-        """
-        with self.rbac_utils.override_role(self):
-            self.subnets_client.show_subnet(self.subnet['id'])
-
-    @rbac_rule_validation.action(service="neutron",
-                                 rule="update_subnet")
-    @decorators.idempotent_id('1bfeaec5-83b9-4140-8138-93a0a9d04cee')
-    def test_update_subnet(self):
-
-        """Update Subnet Test
-
-        RBAC test for the neutron update_subnet policy
-        """
-        updated_name = data_utils.rand_name(
-            self.__class__.__name__ + '-Network')
-
-        with self.rbac_utils.override_role(self):
-            self.subnets_client.update_subnet(self.subnet['id'],
-                                              name=updated_name)
-
-    @rbac_rule_validation.action(service="neutron",
-                                 rule="delete_subnet")
-    @decorators.idempotent_id('1ad1400f-dc84-4edb-9674-b33bbfb0d3e3')
-    def test_delete_subnet(self):
-
-        """Delete Subnet Test
-
-        RBAC test for the neutron delete_subnet policy
-        """
-        network = self._create_network()
-        subnet = self.create_subnet(network, enable_dhcp=False)
-
-        with self.rbac_utils.override_role(self):
-            self.subnets_client.delete_subnet(subnet['id'])
-
     @utils.requires_ext(extension='dhcp_agent_scheduler', service='network')
     @decorators.idempotent_id('b524f19f-fbb4-4d11-a85d-03bfae17bf0e')
     @rbac_rule_validation.action(service="neutron",
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 ab85745..812b0c1 100644
--- a/patrole_tempest_plugin/tests/api/network/test_routers_rbac.py
+++ b/patrole_tempest_plugin/tests/api/network/test_routers_rbac.py
@@ -330,7 +330,8 @@
             self.routers_client.delete_router(router['id'])
 
     @rbac_rule_validation.action(service="neutron",
-                                 rule="add_router_interface")
+                                 rules=["get_router", "add_router_interface"],
+                                 expected_error_codes=[404, 403])
     @decorators.idempotent_id('a0627778-d68d-4913-881b-e345360cca19')
     def test_add_router_interface(self):
         """Add Router Interface
diff --git a/patrole_tempest_plugin/tests/api/volume/test_volume_actions_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_volume_actions_rbac.py
index 013361a..a4fc3fd 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_volume_actions_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_volume_actions_rbac.py
@@ -73,6 +73,7 @@
         '"volume_extension:volume_actions:attach" must be available in the '
         'cloud.')
     @utils.services('compute')
+    @decorators.attr(type='slow')
     @rbac_rule_validation.action(
         service="cinder",
         rule="volume_extension:volume_actions:attach")
@@ -115,16 +116,6 @@
         self.addCleanup(self.volumes_client.update_volume_readonly,
                         self.volume['id'], readonly=False)
 
-    @decorators.idempotent_id('72bab13c-dfaf-4b6d-a132-c83a85fb1776')
-    @rbac_rule_validation.action(
-        service="cinder",
-        rule="volume_extension:volume_unmanage")
-    def test_unmanage_volume(self):
-        volume = self.create_volume()
-
-        with self.rbac_utils.override_role(self):
-            self.volumes_client.unmanage_volume(volume['id'])
-
     @decorators.idempotent_id('59b783c0-f4ef-430c-8a90-1bad97d4ec5c')
     @rbac_rule_validation.action(service="cinder",
                                  rule="volume:update")
diff --git a/patrole_tempest_plugin/tests/api/volume/test_volume_basic_crud_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_volume_basic_crud_rbac.py
index 1bd87d2..61532c6 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_volume_basic_crud_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_volume_basic_crud_rbac.py
@@ -62,11 +62,3 @@
         with self.rbac_utils.override_role(self):
             self.volumes_client.update_volume(self.volume['id'],
                                               name=update_name)
-
-    @rbac_rule_validation.action(
-        service="cinder",
-        rule="volume_extension:volume_image_metadata")
-    @decorators.idempotent_id('3d48ca91-f02b-4616-a69d-4a8b296c8529')
-    def test_volume_list_image_metadata(self):
-        with self.rbac_utils.override_role(self):
-            self.volumes_client.list_volumes(detail=True)
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 85547e1..2ae860c 100644
--- a/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py
+++ b/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py
@@ -13,6 +13,7 @@
 #    under the License.
 
 import mock
+from oslo_config import cfg
 
 from tempest.lib import exceptions
 from tempest import manager
@@ -24,11 +25,13 @@
 from patrole_tempest_plugin import rbac_utils
 from patrole_tempest_plugin.tests.unit import fixtures
 
+CONF = cfg.CONF
 
-class RBACRuleValidationTest(base.TestCase):
+
+class BaseRBACRuleValidationTest(base.TestCase):
 
     def setUp(self):
-        super(RBACRuleValidationTest, self).setUp()
+        super(BaseRBACRuleValidationTest, self).setUp()
         self.mock_test_args = mock.Mock(spec=test.BaseTestCase)
         self.mock_test_args.os_primary = mock.Mock(spec=manager.Manager)
         self.mock_test_args.rbac_utils = mock.Mock(
@@ -45,6 +48,12 @@
         self.useFixture(
             fixtures.ConfPatcher(enable_reporting=False, group='patrole_log'))
 
+
+class RBACRuleValidationTest(BaseRBACRuleValidationTest):
+    """Test suite for validating fundamental functionality for the
+    ``rbac_rule_validation`` decorator.
+    """
+
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
     def test_rule_validation_have_permission_no_exc(self, mock_authority,
@@ -98,11 +107,11 @@
         def test_policy(*args):
             raise exceptions.Forbidden()
 
-        test_re = "Role Member was not allowed to perform sentinel.action."
+        test_re = ("Role Member was not allowed to perform the following "
+                   "actions: \[%s\].*" % (mock.sentinel.action))
         self.assertRaisesRegex(exceptions.Forbidden, test_re, test_policy,
                                self.mock_test_args)
-        mock_log.error.assert_called_once_with("Role Member was not allowed to"
-                                               " perform sentinel.action.")
+        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)
@@ -138,11 +147,11 @@
         def test_policy(*args):
             raise rbac_exceptions.RbacMalformedResponse()
 
-        test_re = "Role Member was not allowed to perform sentinel.action."
+        test_re = ("Role Member was not allowed to perform the following "
+                   "actions: \[%s\].*" % (mock.sentinel.action))
         self.assertRaisesRegex(exceptions.Forbidden, test_re, test_policy,
                                self.mock_test_args)
-        mock_log.error.assert_called_once_with("Role Member was not allowed to"
-                                               " perform sentinel.action.")
+        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)
@@ -179,11 +188,11 @@
         def test_policy(*args):
             raise rbac_exceptions.RbacConflictingPolicies()
 
-        test_re = "Role Member was not allowed to perform sentinel.action."
+        test_re = ("Role Member was not allowed to perform the following "
+                   "actions: \[%s\].*" % (mock.sentinel.action))
         self.assertRaisesRegex(exceptions.Forbidden, test_re, test_policy,
                                self.mock_test_args)
-        mock_log.error.assert_called_once_with("Role Member was not allowed to"
-                                               " perform sentinel.action.")
+        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)
@@ -233,25 +242,26 @@
             raise exceptions.NotFound()
 
         expected_errors = [
-            "Role Member was not allowed to perform sentinel.action.", None
+            ("Role Member was not allowed to perform the following "
+             "actions: \[%s\].*" % (mock.sentinel.action)),
+            None
         ]
 
         for pos, allowed in enumerate([True, False]):
             mock_authority.PolicyAuthority.return_value.allowed\
                 .return_value = allowed
 
-            expected_error = expected_errors[pos]
+            error_re = expected_errors[pos]
 
-            if expected_error:
-                self.assertRaisesRegex(
-                    exceptions.Forbidden, '.* ' + expected_error, test_policy,
-                    self.mock_test_args)
-                mock_log.error.assert_called_once_with(expected_error)
+            if error_re:
+                self.assertRaisesRegex(exceptions.Forbidden, error_re,
+                                       test_policy, self.mock_test_args)
+                self.assertRegex(mock_log.error.mock_calls[0][1][0], error_re)
             else:
                 test_policy(self.mock_test_args)
                 mock_log.error.assert_not_called()
 
-            mock_log.warning.assert_called_once_with(
+            mock_log.warning.assert_called_with(
                 "NotFound exception was caught for policy action {0}. The "
                 "service {1} throws a 404 instead of a 403, which is "
                 "irregular.".format(mock.sentinel.action,
@@ -284,13 +294,10 @@
         for test_policy in (
             test_policy_expect_forbidden, test_policy_expect_not_found):
 
-            error_re = (".* OverPermission: Role Member was allowed to perform"
-                        " sentinel.action")
+            error_re = ".*OverPermission: .* \[%s\]$" % mock.sentinel.action
             self.assertRaisesRegex(rbac_exceptions.RbacOverPermission,
                                    error_re, test_policy, self.mock_test_args)
-            mock_log.error.assert_called_once_with(
-                'Role %s was allowed to perform %s', 'Member',
-                mock.sentinel.action)
+            self.assertRegex(mock_log.error.mock_calls[0][1][0], error_re)
             mock_log.error.reset_mock()
 
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
@@ -405,3 +412,264 @@
             mock.sentinel.action,
             "Allowed",
             "Allowed")
+
+
+class RBACRuleValidationTestMultiPolicy(BaseRBACRuleValidationTest):
+    """Test suite for validating multi-policy support for the
+    ``rbac_rule_validation`` decorator.
+    """
+
+    def _assert_policy_authority_called_with(self, rules, mock_authority):
+        m_authority = mock_authority.PolicyAuthority.return_value
+        m_authority.allowed.assert_has_calls([
+            mock.call(rule, CONF.patrole.rbac_test_role) for rule in rules
+        ])
+
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_multi_policy_have_permission_success(
+            self, mock_authority):
+        """Test that when expected result is authorized and test passes that
+        the overall evaluation succeeds.
+        """
+        mock_authority.PolicyAuthority.return_value.allowed.\
+            return_value = True
+
+        rules = [mock.sentinel.action1, mock.sentinel.action2]
+
+        @rbac_rv.action(mock.sentinel.service, rules=rules,
+                        expected_error_codes=[403, 403])
+        def test_policy(*args):
+            pass
+
+        test_policy(self.mock_test_args)
+        self._assert_policy_authority_called_with(rules, mock_authority)
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_multi_policy_overpermission_failure(
+            self, mock_authority, mock_log):
+        """Test that when expected result is unauthorized and test passes that
+        the overall evaluation results in an OverPermission getting raised.
+        """
+
+        rules = [
+            mock.sentinel.action1, mock.sentinel.action2, mock.sentinel.action3
+        ]
+        exp_ecodes = [403, 403, 403]
+
+        @rbac_rv.action(mock.sentinel.service, rules=rules,
+                        expected_error_codes=exp_ecodes)
+        def test_policy(*args):
+            pass
+
+        def _do_test(allowed_list, fail_on_action):
+            mock_authority.PolicyAuthority.return_value.allowed.side_effect = (
+                allowed_list)
+
+            error_re = ".*OverPermission: .* \[%s\]$" % fail_on_action
+            self.assertRaisesRegex(rbac_exceptions.RbacOverPermission,
+                                   error_re, test_policy, self.mock_test_args)
+            mock_log.debug.assert_any_call(
+                "%s: Expecting %d to be raised for policy name: %s",
+                'test_policy', 403, fail_on_action)
+            self.assertRegex(mock_log.error.mock_calls[0][1][0], error_re)
+            mock_log.error.reset_mock()
+            self._assert_policy_authority_called_with(rules, mock_authority)
+
+        _do_test([True, True, False], mock.sentinel.action3)
+        _do_test([False, True, True], mock.sentinel.action1)
+        _do_test([True, False, True], mock.sentinel.action2)
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_multi_policy_forbidden_success(
+            self, mock_authority, mock_log):
+        """Test that when the expected result is unauthorized and the test
+        fails that the overall evaluation results in success.
+        """
+
+        rules = [
+            mock.sentinel.action1, mock.sentinel.action2, mock.sentinel.action3
+        ]
+        exp_ecodes = [403, 403, 403]
+
+        @rbac_rv.action(mock.sentinel.service, rules=rules,
+                        expected_error_codes=exp_ecodes)
+        def test_policy(*args):
+            raise exceptions.Forbidden()
+
+        def _do_test(allowed_list, fail_on_action):
+            mock_authority.PolicyAuthority.return_value.allowed.\
+                side_effect = allowed_list
+            test_policy(self.mock_test_args)
+            mock_log.debug.assert_called_with(
+                "%s: Expecting %d to be raised for policy name: %s",
+                'test_policy', 403, fail_on_action)
+            mock_log.error.assert_not_called()
+            self._assert_policy_authority_called_with(rules, mock_authority)
+
+        _do_test([True, True, False], mock.sentinel.action3)
+        _do_test([False, True, True], mock.sentinel.action1)
+        _do_test([True, False, True], mock.sentinel.action2)
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_multi_policy_forbidden_failure(
+            self, mock_authority, mock_log):
+        """Test that when the expected result is authorized and the test
+        fails (with a Forbidden error code) that the overall evaluation
+        results a Forbidden getting raised.
+        """
+
+        # NOTE: Avoid mock.sentinel here due to weird sorting with them.
+        rules = ['action1', 'action2', 'action3']
+
+        @rbac_rv.action(mock.sentinel.service, rules=rules,
+                        expected_error_codes=[403, 403, 403])
+        def test_policy(*args):
+            raise exceptions.Forbidden()
+
+        mock_authority.PolicyAuthority.return_value.allowed\
+            .return_value = True
+
+        error_re = ("Role Member was not allowed to perform the following "
+                    "actions: %s. Expected allowed actions: %s. Expected "
+                    "disallowed actions: []." % (rules, rules)).replace(
+                        '[', '\[').replace(']', '\]')
+        self.assertRaisesRegex(exceptions.Forbidden, error_re, test_policy,
+                               self.mock_test_args)
+        self.assertRegex(mock_log.error.mock_calls[0][1][0], error_re)
+        self._assert_policy_authority_called_with(rules, mock_authority)
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_multi_actions_forbidden(
+            self, mock_authority, mock_log):
+        """Test that when the expected result is forbidden because
+        two of the actions fail and the first action specifies 403,
+        verify that the overall evaluation results in success.
+        """
+
+        rules = [
+            mock.sentinel.action1, mock.sentinel.action2, mock.sentinel.action3
+        ]
+        exp_ecodes = [403, 403, 404]
+
+        @rbac_rv.action(mock.sentinel.service, rules=rules,
+                        expected_error_codes=exp_ecodes)
+        def test_policy(*args):
+            raise exceptions.Forbidden()
+
+        def _do_test(allowed_list, fail_on_action):
+            mock_authority.PolicyAuthority.return_value.allowed.\
+                side_effect = allowed_list
+            test_policy(self.mock_test_args)
+            mock_log.debug.assert_called_with(
+                "%s: Expecting %d to be raised for policy name: %s",
+                'test_policy', 403, fail_on_action)
+            mock_log.error.assert_not_called()
+            self._assert_policy_authority_called_with(rules, mock_authority)
+
+        _do_test([False, True, False], mock.sentinel.action1)
+        _do_test([False, False, True], mock.sentinel.action1)
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_multi_actions_notfound(
+            self, mock_authority, mock_log):
+        """Test that when the expected result is not found because
+        two of the actions fail and the first action specifies 404,
+        verify that the overall evaluation results in success.
+        """
+
+        rules = [
+            mock.sentinel.action1, mock.sentinel.action2,
+            mock.sentinel.action3, mock.sentinel.action4
+        ]
+        exp_ecodes = [403, 404, 403, 403]
+
+        @rbac_rv.action(mock.sentinel.service, rules=rules,
+                        expected_error_codes=exp_ecodes)
+        def test_policy(*args):
+            raise exceptions.NotFound()
+
+        def _do_test(allowed_list, fail_on_action):
+            mock_authority.PolicyAuthority.return_value.allowed.\
+                side_effect = allowed_list
+            test_policy(self.mock_test_args)
+            mock_log.debug.assert_called_with(
+                "%s: Expecting %d to be raised for policy name: %s",
+                'test_policy', 404, fail_on_action)
+            mock_log.error.assert_not_called()
+            self._assert_policy_authority_called_with(rules, mock_authority)
+
+        _do_test([True, False, False, True], mock.sentinel.action2)
+        _do_test([True, False, True, False], mock.sentinel.action2)
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    def test_prepare_multi_policy_allowed_usages(self, mock_log):
+
+        def _do_test(rule, rules, ecode, ecodes, exp_rules, exp_ecodes):
+            rule_list, ec_list = rbac_rv._prepare_multi_policy(rule, rules,
+                                                               ecode, ecodes)
+            self.assertEqual(rule_list, exp_rules)
+            self.assertEqual(ec_list, exp_ecodes)
+
+        # Validate that using deprecated values: rule and expected_error_code
+        # are converted into rules = [rule] and expected_error_codes =
+        # [expected_error_code]
+        _do_test("rule1", None, 403, None, ["rule1"], [403])
+
+        # Validate that rules = [rule] and expected_error_codes defaults to
+        # 403 when no values are provided.
+        _do_test("rule1", None, None, None, ["rule1"], [403])
+
+        # Validate that `len(rules) == len(expected_error_codes)` works when
+        # both == 1.
+        _do_test(None, ["rule1"], None, [403], ["rule1"], [403])
+
+        # Validate that `len(rules) == len(expected_error_codes)` works when
+        # both are > 1.
+        _do_test(None, ["rule1", "rule2"], None, [403, 404],
+                 ["rule1", "rule2"], [403, 404])
+
+        # Validate that when only a default expected_error_code argument is
+        # provided, that default value and other default values (403) are
+        # filled into the expected_error_codes list.
+        # Example:
+        #     @rbac_rv.action(service, rules=[<rule>, <rule>])
+        #     def test_policy(*args):
+        #        ...
+        _do_test(None, ["rule1", "rule2"], 403, None,
+                 ["rule1", "rule2"], [403, 403])
+
+        # Validate that the deprecated values are ignored when new values are
+        # provided.
+        _do_test("rule3", ["rule1", "rule2"], 404, [403, 403],
+                 ["rule1", "rule2"], [403, 403])
+        mock_log.debug.assert_any_call(
+            "The `rules` argument will be used instead of `rule`.")
+        mock_log.debug.assert_any_call(
+            "The `exp_error_codes` argument will be used instead of "
+            "`exp_error_code`.")
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    def test_prepare_multi_policy_disallowed_usages(self, mock_log):
+
+        def _do_test(rule, rules, ecode, ecodes):
+            rule_list, ec_list = rbac_rv._prepare_multi_policy(rule, rules,
+                                                               ecode, ecodes)
+
+        error_re = ("The `expected_error_codes` list is not the same length"
+                    " as the `rules` list.")
+        # When len(rules) > 1 then len(expected_error_codes) must be same len.
+        self.assertRaisesRegex(ValueError, error_re, _do_test,
+                               None, ["rule1", "rule2"], None, [403])
+        # When len(expected_error_codes) > 1 len(rules) must be same len.
+        self.assertRaisesRegex(ValueError, error_re, _do_test,
+                               None, ["rule1"], None, [403, 404])
+        error_re = ("The `rules` list must be provided if using the "
+                    "`expected_error_codes` list.")
+        # When expected_error_codes is provided rules must be as well.
+        self.assertRaisesRegex(ValueError, error_re, _do_test,
+                               None, None, None, [404])
diff --git a/patrole_tempest_plugin/tests/unit/test_rbac_utils.py b/patrole_tempest_plugin/tests/unit/test_rbac_utils.py
index 5e730d3..4937318 100644
--- a/patrole_tempest_plugin/tests/unit/test_rbac_utils.py
+++ b/patrole_tempest_plugin/tests/unit/test_rbac_utils.py
@@ -36,17 +36,15 @@
 
     def test_override_role_with_missing_admin_role(self):
         self.rbac_utils.set_roles('member')
-        error_re = (
-            'Roles defined by `\[patrole\] rbac_test_role` and `\[identity\] '
-            'admin_role` must be defined in the system.')
+        error_re = (".*Following roles were not found: admin. Available "
+                    "roles: member.")
         self.assertRaisesRegex(rbac_exceptions.RbacResourceSetupFailed,
                                error_re, self.rbac_utils.override_role)
 
     def test_override_role_with_missing_rbac_role(self):
         self.rbac_utils.set_roles('admin')
-        error_re = (
-            'Roles defined by `\[patrole\] rbac_test_role` and `\[identity\] '
-            'admin_role` must be defined in the system.')
+        error_re = (".*Following roles were not found: member. Available "
+                    "roles: admin.")
         self.assertRaisesRegex(rbac_exceptions.RbacResourceSetupFailed,
                                error_re, self.rbac_utils.override_role)
 
diff --git a/releasenotes/notes/multi-policy-support-4e5c8b4e9e25ad9d.yaml b/releasenotes/notes/multi-policy-support-4e5c8b4e9e25ad9d.yaml
new file mode 100644
index 0000000..1f33d8f
--- /dev/null
+++ b/releasenotes/notes/multi-policy-support-4e5c8b4e9e25ad9d.yaml
@@ -0,0 +1,31 @@
+---
+features:
+  - |
+    Patrole now offers support for multiple policies. The ``rules`` argument
+    has been added to the ``rbac_rule_validation.action`` decorator, which
+    takes a list of policy names which Patrole will use to determine the
+    expected test result. This allows Patrole to more accurately determine
+    whether RBAC is configured correctly, since some API endpoints enforce
+    multiple policies.
+
+    Multiple policy support includes the capability to specify multiple
+    expected error codes, as some components may return different error codes
+    for different roles due to checking multiple policy rules. The
+    ``expected_error_codes`` argument has been added to the
+    ``rbac_rule_validation.action`` decorator, which is a list of error codes
+    expected when the corresponding rule in the ``rules`` list is disallowed
+    to perform the API action. For this reason, the error codes in the
+    ``expected_error_codes`` list must appear in the same order as their
+    corresponding rules in the ``rules`` list. For example:
+
+        expected_error_codes[0] is the error code for the rules[0] rule.
+        expected_error_codes[1] is the error code for the rules[1] rule.
+        ...
+
+deprecations:
+  - |
+    The ``rule`` argument in the ``rbac_rule_validation.action`` decorator has
+    been deprecated in favor of ``rules``.
+
+    The ``expected_error_code`` argument in the ``rbac_rule_validation.action``
+    decorator has been deprecated in favor of ``expected_error_codes``.
diff --git a/tox.ini b/tox.ini
index cca09d0..a09822f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -21,16 +21,20 @@
     stestr --test-path ./patrole_tempest_plugin/tests/unit run {posargs}
 
 [testenv:pep8]
+basepython = python3
 commands = flake8 {posargs}
            check-uuid --package patrole_tempest_plugin.tests.api
 
 [testenv:uuidgen]
+basepython = python3
 commands = check-uuid --package patrole_tempest_plugin.tests.api --fix
 
 [testenv:venv]
+basepython = python3
 commands = {posargs}
 
 [testenv:cover]
+basepython = python3
 commands = rm -rf *.pyc
            rm -rf cover
            rm -f .coverage
@@ -46,6 +50,7 @@
                       rm
 
 [testenv:docs]
+basepython = python3
 deps =
   -c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt}
   -r{toxinidir}/requirements.txt
@@ -56,6 +61,7 @@
 whitelist_externals = rm
 
 [testenv:releasenotes]
+basepython = python3
 deps =
   -c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt}
   -r{toxinidir}/requirements.txt
@@ -66,9 +72,11 @@
 whitelist_externals = rm
 
 [testenv:debug]
+basepython = python3
 commands = oslo_debug_helper -t patrole_tempest_plugin/tests {posargs}
 
 [testenv:genconfig]
+basepython = python3
 commands = oslo-config-generator --config-file etc/config-generator.patrole.conf
 
 [flake8]