Initial functionality framework.
Includes:
rbac_util - Utility for switching between roles for tests.
rbac_auth - Determines if a given role is valid for a given api call.
rbac_rule_validation - Determines if a allowed proper access and denied improper access (403 error)
rbac_role_converter - Converts policy.json files into a list of api's and the roles that can access them.
One example rbac_base in tests/api/rbac_base
One example test in tests/api/images/test_images_rbac.py
New config settings for rbac_flag, rbac_test_role, and rbac_roles
Implements bp: initial-framework
Co-Authored-By: Sangeet Gupta <sg774j@att.com>
Co-Authored-By: Rick Bartra <rb560u@att.com>
Co-Authored-By: Felipe Monteiro <felipe.monteiro@att.com>
Co-Authored-By: Anthony Bellino <ab2434@att.com>
Co-Authored-By: Avishek Dutta <ad620p@att.com>
Change-Id: Ic97b2558ba33ab47ac8174ae37629d36ceb1c9de
diff --git a/patrole_tempest_plugin/config.py b/patrole_tempest_plugin/config.py
index b0f25fd..2b20391 100644
--- a/patrole_tempest_plugin/config.py
+++ b/patrole_tempest_plugin/config.py
@@ -17,3 +17,17 @@
rbac_group = cfg.OptGroup(name='rbac',
title='RBAC testing options')
+
+RbacGroup = [
+ cfg.StrOpt('rbac_test_role',
+ default='admin',
+ help="The current RBAC role against which to run"
+ " Patrole tests."),
+ cfg.BoolOpt('rbac_flag',
+ default=False,
+ help="Enables RBAC tests."),
+ cfg.ListOpt('rbac_roles',
+ default=['admin'],
+ help="List of RBAC roles found in the policy files "
+ "under testing."),
+]
diff --git a/patrole_tempest_plugin/plugin.py b/patrole_tempest_plugin/plugin.py
index 62f4f15..1bc4d04 100644
--- a/patrole_tempest_plugin/plugin.py
+++ b/patrole_tempest_plugin/plugin.py
@@ -13,7 +13,6 @@
# License for the specific language governing permissions and limitations
# under the License.
-
import os
from tempest import config
diff --git a/patrole_tempest_plugin/rbac_auth.py b/patrole_tempest_plugin/rbac_auth.py
new file mode 100644
index 0000000..88b7032
--- /dev/null
+++ b/patrole_tempest_plugin/rbac_auth.py
@@ -0,0 +1,43 @@
+# Copyright 2017 AT&T Corp
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from oslo_log import log as logging
+from tempest.lib import exceptions
+
+from patrole_tempest_plugin import rbac_role_converter
+
+LOG = logging.getLogger(__name__)
+
+
+class RbacAuthority(object):
+ def __init__(self, component=None, service=None):
+ self.converter = rbac_role_converter.RbacPolicyConverter(service)
+ self.roles_dict = self.converter.rules
+
+ def get_permission(self, api, role):
+ if self.roles_dict is None:
+ raise exceptions.InvalidConfiguration("Roles dictionary is empty!")
+ try:
+ _api = self.roles_dict[api]
+ if role in _api:
+ LOG.debug("[API]: %s, [Role]: %s is allowed!", api, role)
+ return True
+ else:
+ LOG.debug("[API]: %s, [Role]: %s is NOT allowed!", api, role)
+ return False
+ except KeyError:
+ raise KeyError("'%s' API is not defined in the policy.json"
+ % api)
+ return False
diff --git a/patrole_tempest_plugin/rbac_exceptions.py b/patrole_tempest_plugin/rbac_exceptions.py
new file mode 100644
index 0000000..94e6bdd
--- /dev/null
+++ b/patrole_tempest_plugin/rbac_exceptions.py
@@ -0,0 +1,28 @@
+# Copyright 2017 AT&T Corp
+# 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.lib import exceptions
+
+
+class RbacActionFailed (exceptions.ClientRestClientException):
+ message = "Rbac action failed"
+
+
+class RbacResourceSetupFailed (exceptions.TempestException):
+ message = "Rbac resource setup failed"
+
+
+class RbacOverPermission (exceptions.TempestException):
+ message = "Action performed that should not be permitted"
diff --git a/patrole_tempest_plugin/rbac_role_converter.py b/patrole_tempest_plugin/rbac_role_converter.py
new file mode 100644
index 0000000..cfb7856
--- /dev/null
+++ b/patrole_tempest_plugin/rbac_role_converter.py
@@ -0,0 +1,153 @@
+# Copyright 2016 AT&T Corp
+# 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 os
+
+from oslo_config import cfg
+from oslo_log import log as logging
+from oslo_policy import _checks
+from oslo_policy import policy
+from tempest import config
+
+from patrole_tempest_plugin.rbac_exceptions import RbacResourceSetupFailed
+
+CONF = config.CONF
+LOG = logging.getLogger(__name__)
+
+RULES_TO_SKIP = []
+TESTED_RULES = []
+PARSED_RULES = []
+
+
+class RbacPolicyConverter(object):
+ """A class for parsing policy rules into lists of allowed roles.
+
+ RBAC testing requires that each rule in a policy file be broken up into
+ the roles that constitute it. This class automates that process.
+ """
+
+ def __init__(self, service, path=None):
+ """Initialization of Policy Converter
+
+ Parse policy files to create dictionary mapping
+ policy actions to roles.
+ :param service: type string
+ :param path: type string
+ """
+
+ if path is None:
+ path = '/etc/{0}/policy.json'.format(service)
+
+ if not os.path.isfile(path):
+ raise RbacResourceSetupFailed('Policy file for service: {0}, {1}'
+ ' not found.'.format(service, path))
+
+ self.default_roles = CONF.rbac.rbac_roles
+ self.rules = {}
+
+ self._get_roles_for_each_rule_in_policy_file(path)
+
+ def _get_roles_for_each_rule_in_policy_file(self, path):
+ """Gets the roles for each rule in the policy file at given path."""
+
+ global PARSED_RULES
+ global TESTED_RULES
+ global RULES_TO_SKIP
+
+ rule_to_roles_dict = {}
+ enforcer = self._init_policy_enforcer(path)
+
+ base_rules = set()
+ for rule_name, rule_checker in enforcer.rules.items():
+ if isinstance(rule_checker, _checks.OrCheck):
+ for sub_rule in rule_checker.rules:
+ if hasattr(sub_rule, 'match'):
+ base_rules.add(sub_rule.match)
+ elif isinstance(rule_checker, _checks.RuleCheck):
+ if hasattr(rule_checker, 'match'):
+ base_rules.add(rule_checker.match)
+
+ RULES_TO_SKIP.extend(base_rules)
+ generic_check_dict = self._get_generic_check_dict(enforcer.rules)
+
+ for rule_name, rule_checker in enforcer.rules.items():
+ PARSED_RULES.append(rule_name)
+
+ if rule_name in RULES_TO_SKIP:
+ continue
+ if isinstance(rule_checker, _checks.GenericCheck):
+ continue
+
+ # Determine whether each role is contained within the current rule.
+ for role in self.default_roles:
+ roles = {'roles': [role]}
+ roles.update(generic_check_dict)
+ is_role_in_rule = rule_checker(
+ generic_check_dict, roles, enforcer)
+ if is_role_in_rule:
+ rule_to_roles_dict.setdefault(rule_name, set())
+ rule_to_roles_dict[rule_name].add(role)
+
+ self.rules = rule_to_roles_dict
+
+ def _init_policy_enforcer(self, policy_file):
+ """Initializes oslo policy enforcer"""
+
+ def find_file(path):
+ realpath = os.path.realpath(path)
+ if os.path.isfile(realpath):
+ return realpath
+ else:
+ return None
+
+ CONF = cfg.CONF
+ CONF.find_file = find_file
+
+ enforcer = policy.Enforcer(CONF,
+ policy_file=policy_file,
+ rules=None,
+ default_rule=None,
+ use_conf=True)
+ enforcer.load_rules()
+ return enforcer
+
+ def _get_generic_check_dict(self, enforcer_rules):
+ """Creates permissions dictionary that oslo policy uses
+
+ to determine if a user can perform an action.
+ """
+
+ generic_checks = set()
+ for rule_checker in enforcer_rules.values():
+ entries = set()
+ self._get_generic_check_entries(rule_checker, entries)
+ generic_checks |= entries
+ return {e: '' for e in generic_checks}
+
+ def _get_generic_check_entries(self, rule_checker, entries):
+ if isinstance(rule_checker, _checks.GenericCheck):
+ if hasattr(rule_checker, 'match'):
+ if rule_checker.match.startswith('%(') and\
+ rule_checker.match.endswith(')s'):
+ entries.add(rule_checker.match[2:-2])
+ if hasattr(rule_checker, 'rule'):
+ if isinstance(rule_checker.rule, _checks.GenericCheck) and\
+ hasattr(rule_checker.rule, 'match'):
+ if rule_checker.rule.match.startswith('%(') and\
+ rule_checker.rule.match.endswith(')s'):
+ entries.add(rule_checker.rule.match[2:-2])
+ if hasattr(rule_checker, 'rules'):
+ for rule in rule_checker.rules:
+ self._get_generic_check_entries(rule, entries)
diff --git a/patrole_tempest_plugin/rbac_rule_validation.py b/patrole_tempest_plugin/rbac_rule_validation.py
new file mode 100644
index 0000000..7785eea
--- /dev/null
+++ b/patrole_tempest_plugin/rbac_rule_validation.py
@@ -0,0 +1,60 @@
+# Copyright 2017 AT&T Corp
+# 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 logging
+
+from tempest import config
+from tempest.lib import exceptions
+
+from patrole_tempest_plugin import rbac_auth
+from patrole_tempest_plugin import rbac_exceptions
+
+CONF = config.CONF
+LOG = logging.getLogger(__name__)
+
+
+def action(component, service, rule):
+ def decorator(func):
+ def wrapper(*args, **kwargs):
+ authority = rbac_auth.RbacAuthority(component, service)
+ allowed = authority.get_permission(rule, CONF.rbac.rbac_test_role)
+
+ try:
+ func(*args)
+ except exceptions.Forbidden as e:
+ if allowed:
+ msg = ("Role %s was not allowed to perform %s." %
+ (CONF.rbac.rbac_test_role, rule))
+ LOG.error(msg)
+ raise exceptions.Forbidden(
+ "%s exception was: %s" %
+ (msg, e))
+ except rbac_exceptions.RbacActionFailed as e:
+ if allowed:
+ msg = ("Role %s was not allowed to perform %s." %
+ (CONF.rbac.rbac_test_role, rule))
+ LOG.error(msg)
+ raise exceptions.Forbidden(
+ "%s RbacActionFailed was: %s" %
+ (msg, e))
+ else:
+ if not allowed:
+ LOG.error("Role %s was allowed to perform %s" %
+ (CONF.rbac.rbac_test_role, rule))
+ raise rbac_exceptions.RbacOverPermission(
+ "OverPermission: Role %s was allowed to perform %s" %
+ (CONF.rbac.rbac_test_role, rule))
+ return wrapper
+ return decorator
diff --git a/patrole_tempest_plugin/rbac_utils.py b/patrole_tempest_plugin/rbac_utils.py
new file mode 100644
index 0000000..7792cbd
--- /dev/null
+++ b/patrole_tempest_plugin/rbac_utils.py
@@ -0,0 +1,128 @@
+# Copyright 2017 AT&T Corp
+# 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 json
+import logging
+import six
+import time
+import urllib3
+
+from tempest import config
+
+from patrole_tempest_plugin import rbac_exceptions as rbac_exc
+
+LOG = logging.getLogger(__name__)
+CONF = config.CONF
+http = urllib3.PoolManager()
+
+
+class Singleton(type):
+ _instances = {}
+
+ def __call__(cls, *args, **kwargs):
+ if cls not in cls._instances:
+ cls._instances[cls] = super(Singleton, cls).__call__(*args,
+ **kwargs)
+ return cls._instances[cls]
+
+
+@six.add_metaclass(Singleton)
+class RbacUtils(object):
+ def __init__(self):
+ RbacUtils.dictionary = {}
+
+ @staticmethod
+ def get_roles(caller):
+ admin_role_id = None
+ rbac_role_id = None
+
+ if bool(RbacUtils.dictionary) is False:
+ admin_token = caller.admin_client.token
+ headers = {'X-Auth-Token': admin_token,
+ "Content-Type": "application/json"}
+ url_to_get_role = CONF.identity.uri_v3 + '/roles/'
+ response = http.request('GET', url_to_get_role, headers=headers)
+ if response.status != 200:
+ raise rbac_exc.RbacResourceSetupFailed('Unable to'
+ ' retrieve roles')
+ data = response.data
+ roles = json.loads(data)
+ for item in roles['roles']:
+ if item['name'] == CONF.rbac.rbac_test_role:
+ rbac_role_id = item['id']
+ if item['name'] == 'admin':
+ admin_role_id = item['id']
+
+ RbacUtils.dictionary.update({'admin_role_id': admin_role_id,
+ 'rbac_role_id': rbac_role_id})
+
+ return RbacUtils.dictionary
+
+ @staticmethod
+ def delete_all_roles(self, base_url, headers):
+ # Find the current role
+ response = http.request('GET', base_url, headers=headers)
+ if response.status != 200:
+ raise rbac_exc.RbacResourceSetupFailed('Unable to retrieve'
+ ' user role')
+ data = response.data
+ roles = json.loads(data)
+ for item in roles['roles']:
+ url = base_url + item['id']
+ response = http.request('DELETE', url, headers=headers)
+ self.assertEqual(204, response.status)
+
+ @staticmethod
+ def switch_role(self, switchToRbacRole=None):
+ LOG.debug('Switching role to: %s', switchToRbacRole)
+ if switchToRbacRole is None:
+ return
+
+ roles = rbac_utils.get_roles(self)
+ rbac_role_id = roles.get('rbac_role_id')
+ admin_role_id = roles.get('admin_role_id')
+
+ try:
+ user_id = self.auth_provider.credentials.user_id
+ project_id = self.auth_provider.credentials.tenant_id
+ admin_token = self.admin_client.token
+
+ headers = {'X-Auth-Token': admin_token,
+ "Content-Type": "application/json"}
+ base_url = (CONF.identity.uri_v3 + '/projects/' + project_id +
+ '/users/' + user_id + '/roles/')
+
+ rbac_utils.delete_all_roles(self, base_url, headers)
+
+ if switchToRbacRole:
+ url = base_url + rbac_role_id
+ response = http.request('PUT', url, headers=headers)
+ self.assertEqual(204, response.status)
+ else:
+ url = base_url + admin_role_id
+ response = http.request('PUT', url, headers=headers)
+ self.assertEqual(204, response.status)
+
+ except Exception as exp:
+ LOG.error(exp)
+ raise
+ finally:
+ self.auth_provider.clear_auth()
+ # Sleep to avoid 401 errors caused by rounding
+ # In timing of fernet token creation
+ time.sleep(1)
+ self.auth_provider.set_auth()
+
+rbac_utils = RbacUtils()
diff --git a/patrole_tempest_plugin/tests/api/image/__init__.py b/patrole_tempest_plugin/tests/api/image/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/patrole_tempest_plugin/tests/api/image/__init__.py
diff --git a/patrole_tempest_plugin/tests/api/image/test_images_rbac.py b/patrole_tempest_plugin/tests/api/image/test_images_rbac.py
new file mode 100644
index 0000000..83f4fe3
--- /dev/null
+++ b/patrole_tempest_plugin/tests/api/image/test_images_rbac.py
@@ -0,0 +1,52 @@
+# Copyright 2016 ATT 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 logging
+
+from tempest import config
+from tempest.lib.common.utils import data_utils
+from tempest import test
+
+from patrole_tempest_plugin import rbac_rule_validation
+from patrole_tempest_plugin.rbac_utils import rbac_utils
+from patrole_tempest_plugin.tests.api import rbac_base
+
+CONF = config.CONF
+LOG = logging.getLogger(__name__)
+
+
+class BasicOperationsImagesRbacTest(rbac_base.BaseV2ImageRbacTest):
+
+ @classmethod
+ def setup_clients(cls):
+ super(BasicOperationsImagesRbacTest, cls).setup_clients()
+ cls.client = cls.os.image_client_v2
+
+ def tearDown(self):
+ rbac_utils.switch_role(self, switchToRbacRole=False)
+ super(BasicOperationsImagesRbacTest, self).tearDown()
+
+ @rbac_rule_validation.action(component="Image", service="glance",
+ rule="add_image")
+ @test.idempotent_id('0f148510-63bf-11e6-b348-080027d0d606')
+ def test_create_image(self):
+ uuid = '00000000-1111-2222-3333-444455556666'
+ image_name = data_utils.rand_name('image')
+ rbac_utils.switch_role(self, switchToRbacRole=True)
+ self.create_image(name=image_name,
+ container_format='bare',
+ disk_format='raw',
+ visibility='private',
+ ramdisk_id=uuid)
diff --git a/patrole_tempest_plugin/tests/api/rbac_base.py b/patrole_tempest_plugin/tests/api/rbac_base.py
new file mode 100644
index 0000000..786927f
--- /dev/null
+++ b/patrole_tempest_plugin/tests/api/rbac_base.py
@@ -0,0 +1,39 @@
+# Copyright 2017 at&t
+# 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.
+
+# Maybe these should be in lib or recreated?
+from tempest.api.image import base as image_base
+from tempest import config
+
+CONF = config.CONF
+
+
+class BaseV2ImageRbacTest(image_base.BaseV2ImageTest):
+
+ credentials = ['primary', 'admin']
+
+ @classmethod
+ def skip_checks(cls):
+ super(BaseV2ImageRbacTest, cls).skip_checks()
+ if not CONF.rbac.rbac_flag:
+ raise cls.skipException(
+ "%s skipped as RBAC Flag not enabled" % cls.__name__)
+ if 'admin' not in CONF.auth.tempest_roles:
+ raise cls.skipException(
+ "%s skipped because tempest roles is not admin" % cls.__name__)
+
+ @classmethod
+ def setup_clients(cls):
+ super(BaseV2ImageRbacTest, cls).setup_clients()
+ cls.auth_provider = cls.os.auth_provider
+ cls.admin_client = cls.os_adm.image_client_v2