Add granularity for volume_extension:volume_type_encryption

Use granular rules:
volume_extension:volume_type_encryption:create
volume_extension:volume_type_encryption:delete
volume_extension:volume_type_encryption:update
volume_extension:volume_type_encryption:get

for the corresponding create, delete, update, and
get volume_type_encryption test cases.

Depends-On: Iba58e785df934d1c4175c0877d266193ac0167b7

Change-Id: Ie5159166505d9bee3e99ca0d51949f6391c569b9
diff --git a/devstack/plugin.sh b/devstack/plugin.sh
index 01be7d6..a6259f4 100644
--- a/devstack/plugin.sh
+++ b/devstack/plugin.sh
@@ -29,6 +29,9 @@
 
         # These policies were removed in Stein but are available in Pike.
         iniset $TEMPEST_CONFIG policy-feature-enabled removed_nova_policies_stein False
+
+        # TODO(cl566n): Policies used by Patrole testing. Remove these once stable/pike becomes EOL.
+        iniset $TEMPEST_CONFIG policy-feature-enabled added_cinder_policies_stein False
     fi
 
     if [[ ${DEVSTACK_SERIES} == 'queens' ]]; then
@@ -38,6 +41,14 @@
 
         # These policies were removed in Stein but are available in Queens.
         iniset $TEMPEST_CONFIG policy-feature-enabled removed_nova_policies_stein False
+
+        # TODO(cl566n): Policies used by Patrole testing. Remove these once stable/queens becomes EOL.
+        iniset $TEMPEST_CONFIG policy-feature-enabled added_cinder_policies_stein False
+    fi
+
+    if [[ ${DEVSTACK_SERIES} == 'rocky' ]]; then
+        # TODO(cl566n): Policies used by Patrole testing. Remove these once stable/rocky becomes EOL.
+        iniset $TEMPEST_CONFIG policy-feature-enabled added_cinder_policies_stein False
     fi
 
     iniset $TEMPEST_CONFIG patrole rbac_test_role $RBAC_TEST_ROLE
diff --git a/patrole_tempest_plugin/config.py b/patrole_tempest_plugin/config.py
index 4ad7f08..54d2491 100644
--- a/patrole_tempest_plugin/config.py
+++ b/patrole_tempest_plugin/config.py
@@ -162,6 +162,11 @@
                 help="""Are the Nova API extension policies available in the
 cloud (e.g. os_compute_api:os-extended-availability-zone)? These policies were
 removed in Stein because Nova API extension concept was removed in Pike."""),
+    cfg.BoolOpt('added_cinder_policies_stein',
+                default=True,
+                help="""Are the Cinder API extension policies available in the
+cloud (e.g. [create|update|get|delete]_encryption_policy)? These policies are
+added in Stein.""")
 ]
 
 
diff --git a/patrole_tempest_plugin/rbac_rule_validation.py b/patrole_tempest_plugin/rbac_rule_validation.py
index a7927fc..d3b057c 100644
--- a/patrole_tempest_plugin/rbac_rule_validation.py
+++ b/patrole_tempest_plugin/rbac_rule_validation.py
@@ -38,8 +38,11 @@
 RBACLOG = logging.getLogger('rbac_reporting')
 
 
-def action(service, rule='', rules=None,
-           expected_error_code=_DEFAULT_ERROR_CODE, expected_error_codes=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.
 
@@ -72,16 +75,18 @@
     As such, negative and positive testing can be applied using this decorator.
 
     :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
+    :param rule: (DEPRECATED) A policy action defined in a policy.json file
+        or in code. Also accepts a callable that returns a policy action.
+    :type rule: str or callable
+    :param 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.
+        result. Also accepts list of callables that return a policy action.
 
         .. note::
 
             Patrole currently only supports custom JSON policy files.
 
+    :type rules: list[str] or list[callable]
     :param int expected_error_code: (DEPRECATED) 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
@@ -316,7 +321,11 @@
         for i in range(num_rules - num_ecs):
             exp_error_codes.append(_DEFAULT_ERROR_CODE)
 
-    return rules, exp_error_codes
+    evaluated_rules = [
+        r() if callable(r) else r for r in rules
+    ]
+
+    return evaluated_rules, exp_error_codes
 
 
 def _is_authorized(test_obj, service, rule, extra_target_data):
diff --git a/patrole_tempest_plugin/tests/api/volume/test_encryption_types_rbac.py b/patrole_tempest_plugin/tests/api/volume/test_encryption_types_rbac.py
index f10e41b..2ee80eb 100644
--- a/patrole_tempest_plugin/tests/api/volume/test_encryption_types_rbac.py
+++ b/patrole_tempest_plugin/tests/api/volume/test_encryption_types_rbac.py
@@ -13,12 +13,36 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+import functools
+
 from tempest.common import utils
+from tempest import config
 from tempest.lib import decorators
 
 from patrole_tempest_plugin import rbac_rule_validation
 from patrole_tempest_plugin.tests.api.volume import rbac_base
 
+CONF = config.CONF
+
+
+def _get_volume_type_encryption_policy(action):
+    feature_flag = CONF.policy_feature_enabled.added_cinder_policies_stein
+
+    if feature_flag:
+        return "volume_extension:volume_type_encryption:%s" % action
+
+    return "volume_extension:volume_type_encryption"
+
+
+_CREATE_VOLUME_TYPE_ENCRYPTION = functools.partial(
+    _get_volume_type_encryption_policy, "create")
+_SHOW_VOLUME_TYPE_ENCRYPTION = functools.partial(
+    _get_volume_type_encryption_policy, "get")
+_UPDATE_VOLUME_TYPE_ENCRYPTION = functools.partial(
+    _get_volume_type_encryption_policy, "update")
+_DELETE_VOLUME_TYPE_ENCRYPTION = functools.partial(
+    _get_volume_type_encryption_policy, "delete")
+
 
 class EncryptionTypesV3RbacTest(rbac_base.BaseVolumeRbacTest):
 
@@ -45,7 +69,7 @@
     @decorators.idempotent_id('ffd94ce5-c24b-4b6c-84c9-c5aad8c3010c')
     @rbac_rule_validation.action(
         service="cinder",
-        rule="volume_extension:volume_type_encryption")
+        rule=_CREATE_VOLUME_TYPE_ENCRYPTION)
     def test_create_volume_type_encryption(self):
         vol_type_id = self.create_volume_type()['id']
         with self.rbac_utils.override_role(self):
@@ -57,7 +81,7 @@
     @decorators.idempotent_id('6599e72e-acef-4c0d-a9b2-463fca30d1da')
     @rbac_rule_validation.action(
         service="cinder",
-        rule="volume_extension:volume_type_encryption")
+        rule=_DELETE_VOLUME_TYPE_ENCRYPTION)
     def test_delete_volume_type_encryption(self):
         vol_type_id = self._create_volume_type_encryption()
         with self.rbac_utils.override_role(self):
@@ -66,7 +90,7 @@
     @decorators.idempotent_id('42da9fec-32fd-4dca-9242-8a53b2fed25a')
     @rbac_rule_validation.action(
         service="cinder",
-        rule="volume_extension:volume_type_encryption")
+        rule=_UPDATE_VOLUME_TYPE_ENCRYPTION)
     def test_update_volume_type_encryption(self):
         vol_type_id = self._create_volume_type_encryption()
         with self.rbac_utils.override_role(self):
@@ -77,7 +101,7 @@
     @decorators.idempotent_id('1381a3dc-248f-4282-b231-c9399018c804')
     @rbac_rule_validation.action(
         service="cinder",
-        rule="volume_extension:volume_type_encryption")
+        rule=_SHOW_VOLUME_TYPE_ENCRYPTION)
     def test_show_volume_type_encryption(self):
         vol_type_id = self._create_volume_type_encryption()
         with self.rbac_utils.override_role(self):
@@ -86,7 +110,7 @@
     @decorators.idempotent_id('d4ed3cf8-52b2-4fa2-910d-e405361f0881')
     @rbac_rule_validation.action(
         service="cinder",
-        rule="volume_extension:volume_type_encryption")
+        rule=_SHOW_VOLUME_TYPE_ENCRYPTION)
     def test_show_encryption_specs_item(self):
         vol_type_id = self._create_volume_type_encryption()
         with self.rbac_utils.override_role(self):
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 fe36f2c..1772047 100644
--- a/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py
+++ b/patrole_tempest_plugin/tests/unit/test_rbac_rule_validation.py
@@ -14,6 +14,7 @@
 
 from __future__ import absolute_import
 
+import functools
 import mock
 from oslo_config import cfg
 
@@ -80,7 +81,6 @@
             pass
 
         test_policy(self.mock_test_args)
-        mock_log.warning.assert_not_called()
         mock_log.error.assert_not_called()
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
@@ -99,7 +99,6 @@
             raise exceptions.Forbidden()
 
         test_policy(self.mock_test_args)
-        mock_log.warning.assert_not_called()
         mock_log.error.assert_not_called()
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
@@ -130,7 +129,8 @@
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
     def test_rule_validation_rbac_malformed_response_positive(
             self, mock_authority, mock_log):
-        """Test RbacMalformedResponse error is thrown without permission passes.
+        """Test RbacMalformedResponse 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.
@@ -143,7 +143,6 @@
             raise rbac_exceptions.RbacMalformedResponse()
 
         mock_log.error.assert_not_called()
-        mock_log.warning.assert_not_called()
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
@@ -171,7 +170,8 @@
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
     def test_rule_validation_rbac_conflicting_policies_positive(
             self, mock_authority, mock_log):
-        """Test RbacConflictingPolicies error is thrown without permission passes.
+        """Test RbacConflictingPolicies error is thrown without permission
+        passes.
 
         Positive test case: if RbacConflictingPolicies is thrown and the user
         is not allowed to perform the action, then this is a success.
@@ -184,7 +184,6 @@
             raise rbac_exceptions.RbacConflictingPolicies()
 
         mock_log.error.assert_not_called()
-        mock_log.warning.assert_not_called()
 
     @mock.patch.object(rbac_rv, 'LOG', autospec=True)
     @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
@@ -448,6 +447,66 @@
             "Allowed",
             "Allowed")
 
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_with_callable_rule(self, mock_authority,
+                                                mock_log):
+        """Test that a callable as the rule is evaluated correctly."""
+        mock_authority.PolicyAuthority.return_value.allowed.return_value = True
+
+        @rbac_rv.action(mock.sentinel.service,
+                        rule=lambda: mock.sentinel.action)
+        def test_policy(*args):
+            pass
+
+        test_policy(self.mock_test_args)
+
+        policy_authority = mock_authority.PolicyAuthority.return_value
+        policy_authority.allowed.assert_called_with(
+            mock.sentinel.action,
+            CONF.patrole.rbac_test_role)
+
+        mock_log.error.assert_not_called()
+
+    @mock.patch.object(rbac_rv, 'LOG', autospec=True)
+    @mock.patch.object(rbac_rv, 'policy_authority', autospec=True)
+    def test_rule_validation_with_conditional_callable_rule(
+            self, mock_authority, mock_log):
+        """Test that a complex callable with conditional logic as the rule is
+        evaluated correctly.
+        """
+        mock_authority.PolicyAuthority.return_value.allowed.return_value = True
+
+        def partial_func(x):
+            return "foo" if x == "bar" else "qux"
+        foo_callable = functools.partial(partial_func, "bar")
+        bar_callable = functools.partial(partial_func, "baz")
+
+        @rbac_rv.action(mock.sentinel.service,
+                        rule=foo_callable)
+        def test_foo_policy(*args):
+            pass
+
+        @rbac_rv.action(mock.sentinel.service,
+                        rule=bar_callable)
+        def test_bar_policy(*args):
+            pass
+
+        test_foo_policy(self.mock_test_args)
+        policy_authority = mock_authority.PolicyAuthority.return_value
+        policy_authority.allowed.assert_called_with(
+            "foo",
+            CONF.patrole.rbac_test_role)
+        policy_authority.allowed.reset_mock()
+
+        test_bar_policy(self.mock_test_args)
+        policy_authority = mock_authority.PolicyAuthority.return_value
+        policy_authority.allowed.assert_called_with(
+            "qux",
+            CONF.patrole.rbac_test_role)
+
+        mock_log.error.assert_not_called()
+
 
 class RBACRuleValidationNegativeTest(BaseRBACRuleValidationTest):
 
diff --git a/releasenotes/notes/volume-type-encryption-policy-granularity-141ac283b9c0778e.yaml b/releasenotes/notes/volume-type-encryption-policy-granularity-141ac283b9c0778e.yaml
new file mode 100644
index 0000000..4aeb107
--- /dev/null
+++ b/releasenotes/notes/volume-type-encryption-policy-granularity-141ac283b9c0778e.yaml
@@ -0,0 +1,19 @@
+---
+features:
+  - |
+    Added new Cinder feature flag (``CONF.policy_feature_enabled.added_cinder_policies_stein``)
+    for the following newly introduced granular Cinder policies:
+
+    - ``volume_extension:volume_type_encryption:create``
+    - ``volume_extension:volume_type_encryption:get``
+    - ``volume_extension:volume_type_encryption:update``
+    - ``volume_extension:volume_type_encryption:delete``
+
+    The corresponding Patrole test cases are modified to support
+    the granularity.  The test cases also support backward
+    compatibility with the old single rule:
+    ``volume_extension:volume_type_encryption``
+
+    The ``rules`` parameter in ``rbac_rule_validation.action``
+    decorator now also accepts a list of callables; each callable
+    should return a policy action (str).