Add support for roles to credentials providers

This commit adds support for requesting credentials by a role from the
credentials providers. This entails 2 things, first adding support for
specifying roles in a test-accounts file with test-accounts based
providers and secondly adding support to isolated-creds to assign
arbitrary roles on credentials requested by role.

Change-Id: I6a0f0539d41e0cf3d04414964b289447194d2793
Partially-implements: bp test-accounts-continued
diff --git a/etc/accounts.yaml.sample b/etc/accounts.yaml.sample
index 54fdcad..64ff8a7 100644
--- a/etc/accounts.yaml.sample
+++ b/etc/accounts.yaml.sample
@@ -9,3 +9,27 @@
 - username: 'user_2'
   tenant_name: 'test_tenant_2'
   password: 'test_password'
+
+# To specify which roles a user has list them under the roles field
+- username: 'multi_role_user'
+  tenant_name: 'test_tenant_42'
+  password: 'test_password'
+  roles:
+    - 'fun_role'
+    - 'not_an_admin'
+    - 'an_admin'
+
+# To specify a user has a role specified in the config file you can use the
+# type field to specify it, valid values are admin, operator, and reseller_admin
+- username: 'swift_pseudo_admin_user_1'
+  tenant_name: 'admin_tenant_1'
+  password: 'test_password'
+  types:
+     - 'reseller_admin'
+     - 'operator'
+
+- username: 'admin_user_1'
+  tenant_name: 'admin_tenant_1'
+  password: 'test_password'
+  types:
+     - 'admin'
diff --git a/tempest/common/accounts.py b/tempest/common/accounts.py
index e21a85e..89de69b 100644
--- a/tempest/common/accounts.py
+++ b/tempest/common/accounts.py
@@ -49,12 +49,46 @@
         self.isolated_creds = {}
 
     @classmethod
+    def _append_role(cls, role, account_hash, hash_dict):
+        if role in hash_dict['roles']:
+            hash_dict['roles'][role].append(account_hash)
+        else:
+            hash_dict['roles'][role] = [account_hash]
+        return hash_dict
+
+    @classmethod
     def get_hash_dict(cls, accounts):
-        hash_dict = {}
+        hash_dict = {'roles': {}, 'creds': {}}
+        # Loop over the accounts read from the yaml file
         for account in accounts:
+            roles = []
+            types = []
+            if 'roles' in account:
+                roles = account.pop('roles')
+            if 'types' in account:
+                types = account.pop('types')
             temp_hash = hashlib.md5()
             temp_hash.update(str(account))
-            hash_dict[temp_hash.hexdigest()] = account
+            temp_hash_key = temp_hash.hexdigest()
+            hash_dict['creds'][temp_hash_key] = account
+            for role in roles:
+                hash_dict = cls._append_role(role, temp_hash_key,
+                                             hash_dict)
+            # If types are set for the account append the matching role
+            # subdict with the hash
+            for type in types:
+                if type == 'admin':
+                    hash_dict = cls._append_role(CONF.identity.admin_role,
+                                                 temp_hash_key, hash_dict)
+                elif type == 'operator':
+                    hash_dict = cls._append_role(
+                        CONF.object_storage.operator_role, temp_hash_key,
+                        hash_dict)
+                elif type == 'reseller_admin':
+                    hash_dict = cls._append_role(
+                        CONF.object_storage.reseller_admin_role,
+                        temp_hash_key,
+                        hash_dict)
         return hash_dict
 
     def is_multi_user(self):
@@ -63,7 +97,7 @@
             raise exceptions.InvalidConfiguration(
                 "Account file %s doesn't exist" % CONF.auth.test_accounts_file)
         else:
-            return len(self.hash_dict) > 1
+            return len(self.hash_dict['creds']) > 1
 
     def is_multi_tenant(self):
         return self.is_multi_user()
@@ -78,6 +112,8 @@
 
     @lockutils.synchronized('test_accounts_io', external=True)
     def _get_free_hash(self, hashes):
+        # Cast as a list because in some edge cases a set will be passed in
+        hashes = list(hashes)
         if not os.path.isdir(self.accounts_dir):
             os.mkdir(self.accounts_dir)
             # Create File from first hash (since none are in use)
@@ -97,12 +133,46 @@
                'the credentials for this allocation request' % ','.join(names))
         raise exceptions.InvalidConfiguration(msg)
 
-    def _get_creds(self):
+    def _get_match_hash_list(self, roles=None):
+        hashes = []
+        if roles:
+            # Loop over all the creds for each role in the subdict and generate
+            # a list of cred lists for each role
+            for role in roles:
+                temp_hashes = self.hash_dict['roles'].get(role, None)
+                if not temp_hashes:
+                    raise exceptions.InvalidConfiguration(
+                        "No credentials with role: %s specified in the "
+                        "accounts ""file" % role)
+                hashes.append(temp_hashes)
+            # Take the list of lists and do a boolean and between each list to
+            # find the creds which fall under all the specified roles
+            temp_list = set(hashes[0])
+            for hash_list in hashes[1:]:
+                temp_list = temp_list & set(hash_list)
+            hashes = temp_list
+        else:
+            hashes = self.hash_dict['creds'].keys()
+        # NOTE(mtreinish): admin is a special case because of the increased
+        # privlege set which could potentially cause issues on tests where that
+        # is not expected. So unless the admin role isn't specified do not
+        # allocate admin.
+        admin_hashes = self.hash_dict['roles'].get(CONF.identity.admin_role,
+                                                   None)
+        if ((not roles or CONF.identity.admin_role not in roles) and
+                admin_hashes):
+            useable_hashes = [x for x in hashes if x not in admin_hashes]
+        else:
+            useable_hashes = hashes
+        return useable_hashes
+
+    def _get_creds(self, roles=None):
         if self.use_default_creds:
             raise exceptions.InvalidConfiguration(
                 "Account file %s doesn't exist" % CONF.auth.test_accounts_file)
-        free_hash = self._get_free_hash(self.hash_dict.keys())
-        return self.hash_dict[free_hash]
+        useable_hashes = self._get_match_hash_list(roles)
+        free_hash = self._get_free_hash(useable_hashes)
+        return self.hash_dict['creds'][free_hash]
 
     @lockutils.synchronized('test_accounts_io', external=True)
     def remove_hash(self, hash_string):
@@ -116,10 +186,10 @@
                 os.rmdir(self.accounts_dir)
 
     def get_hash(self, creds):
-        for _hash in self.hash_dict:
-            # Comparing on the attributes that were read from the YAML
-            if all([getattr(creds, k) == self.hash_dict[_hash][k] for k in
-                    creds.get_init_attributes()]):
+        for _hash in self.hash_dict['creds']:
+            # Comparing on the attributes that are expected in the YAML
+            if all([getattr(creds, k) == self.hash_dict['creds'][_hash][k] for
+                   k in creds.get_init_attributes()]):
                 return _hash
         raise AttributeError('Invalid credentials %s' % creds)
 
@@ -143,14 +213,33 @@
         self.isolated_creds['alt'] = alt_credential
         return alt_credential
 
+    def get_creds_by_roles(self, roles, force_new=False):
+        roles = list(set(roles))
+        exist_creds = self.isolated_creds.get(str(roles), None)
+        # The force kwarg is used to allocate an additional set of creds with
+        # the same role list. The index used for the previously allocation
+        # in the isolated_creds dict will be moved.
+        if exist_creds and not force_new:
+            return exist_creds
+        elif exist_creds and force_new:
+            new_index = str(roles) + '-' + str(len(self.isolated_creds))
+            self.isolated_creds[new_index] = exist_creds
+        creds = self._get_creds(roles=roles)
+        role_credential = cred_provider.get_credentials(**creds)
+        self.isolated_creds[str(roles)] = role_credential
+        return role_credential
+
     def clear_isolated_creds(self):
         for creds in self.isolated_creds.values():
             self.remove_credentials(creds)
 
     def get_admin_creds(self):
-        msg = ('If admin credentials are available tenant_isolation should be'
-               ' used instead')
-        raise NotImplementedError(msg)
+        return self.get_creds_by_roles([CONF.identity.admin_role])
+
+    def admin_available(self):
+        if not self.hash_dict['roles'].get(CONF.identity.admin_role):
+            return False
+        return True
 
 
 class NotLockingAccounts(Accounts):
@@ -173,7 +262,7 @@
                 raise exceptions.InvalidConfiguration(msg)
         else:
             # TODO(andreaf) Add a uniqueness check here
-            return len(self.hash_dict) > 1
+            return len(self.hash_dict['creds']) > 1
 
     def is_multi_user(self):
         return self._unique_creds('username')
@@ -181,16 +270,17 @@
     def is_multi_tenant(self):
         return self._unique_creds('tenant_id')
 
-    def get_creds(self, id):
+    def get_creds(self, id, roles=None):
         try:
+            hashes = self._get_match_hash_list(roles)
             # No need to sort the dict as within the same python process
             # the HASH seed won't change, so subsequent calls to keys()
             # will return the same result
-            _hash = self.hash_dict.keys()[id]
+            _hash = hashes[id]
         except IndexError:
             msg = 'Insufficient number of users provided'
             raise exceptions.InvalidConfiguration(msg)
-        return self.hash_dict[_hash]
+        return self.hash_dict['creds'][_hash]
 
     def get_primary_creds(self):
         if self.isolated_creds.get('primary'):
@@ -220,5 +310,35 @@
         self.isolated_creds = {}
 
     def get_admin_creds(self):
-        return cred_provider.get_configured_credentials(
-            "identity_admin", fill_in=False)
+        if not self.use_default_creds:
+            return self.get_creds_by_roles([CONF.identity.admin_role])
+        else:
+            creds = cred_provider.get_configured_credentials(
+                "identity_admin", fill_in=False)
+            self.isolated_creds['admin'] = creds
+            return creds
+
+    def get_creds_by_roles(self, roles, force_new=False):
+        roles = list(set(roles))
+        exist_creds = self.isolated_creds.get(str(roles), None)
+        index = 0
+        if exist_creds and not force_new:
+            return exist_creds
+        elif exist_creds and force_new:
+            new_index = str(roles) + '-' + str(len(self.isolated_creds))
+            self.isolated_creds[new_index] = exist_creds
+            # Figure out how many existing creds for this roles set are present
+            # use this as the index the returning hash list to ensure separate
+            # creds are returned with force_new being True
+            for creds_names in self.isolated_creds:
+                if str(roles) in creds_names:
+                    index = index + 1
+        if not self.use_default_creds:
+            creds = self.get_creds(index, roles=roles)
+            role_credential = cred_provider.get_credentials(**creds)
+            self.isolated_creds[str(roles)] = role_credential
+        else:
+            msg = "Default credentials can not be used with specifying "\
+                  "credentials by roles"
+            raise exceptions.InvalidConfiguration(msg)
+        return role_credential
diff --git a/tempest/common/cred_provider.py b/tempest/common/cred_provider.py
index 033410e..c22dc1f 100644
--- a/tempest/common/cred_provider.py
+++ b/tempest/common/cred_provider.py
@@ -113,3 +113,7 @@
     @abc.abstractmethod
     def is_multi_tenant(self):
         return
+
+    @abc.abstractmethod
+    def get_creds_by_roles(self, roles, force_new=False):
+        return
diff --git a/tempest/common/credentials.py b/tempest/common/credentials.py
index 40761c8..3794b66 100644
--- a/tempest/common/credentials.py
+++ b/tempest/common/credentials.py
@@ -11,6 +11,8 @@
 #    See the License for the specific language governing permissions and
 #    limitations under the License.
 
+import os
+
 from tempest.common import accounts
 from tempest.common import cred_provider
 from tempest.common import isolated_creds
@@ -46,24 +48,17 @@
 # creds area vailable.
 def is_admin_available():
     is_admin = True
-    # In the case of a pre-provisioned account, if even if creds were
-    # configured, the admin credentials won't be available
-    if (CONF.auth.locking_credentials_provider and
-        not CONF.auth.allow_tenant_isolation):
-        is_admin = False
+    # If tenant isolation is enabled admin will be available
+    if CONF.auth.allow_tenant_isolation:
+        return is_admin
+    # Check whether test accounts file has the admin specified or not
+    elif os.path.isfile(CONF.auth.test_accounts_file):
+        check_accounts = accounts.Accounts(name='check_admin')
+        if not check_accounts.admin_available():
+            is_admin = False
     else:
         try:
             cred_provider.get_configured_credentials('identity_admin')
-        # NOTE(mtreinish) This should never be caught because of the if above.
-        # NotImplementedError is only raised if admin credentials are requested
-        # and the locking test accounts cred provider is being used.
-        except NotImplementedError:
-            is_admin = False
-        # NOTE(mtreinish): This will be raised by the non-locking accounts
-        # provider if there aren't admin credentials provided in the config
-        # file. This exception originates from the auth call to get configured
-        # credentials
         except exceptions.InvalidConfiguration:
             is_admin = False
-
     return is_admin
diff --git a/tempest/common/isolated_creds.py b/tempest/common/isolated_creds.py
index 3eed689..b0531fd 100644
--- a/tempest/common/isolated_creds.py
+++ b/tempest/common/isolated_creds.py
@@ -90,7 +90,7 @@
             self._cleanup_default_secgroup(tenant)
         self.identity_admin_client.delete_tenant(tenant)
 
-    def _create_creds(self, suffix="", admin=False):
+    def _create_creds(self, suffix="", admin=False, roles=None):
         """Create random credentials under the following schema.
 
         If the name contains a '.' is the full class path of something, and
@@ -121,8 +121,13 @@
             self._assign_user_role(tenant, user, swift_operator_role)
         if admin:
             self._assign_user_role(tenant, user, CONF.identity.admin_role)
-        for role in CONF.auth.tempest_roles:
-            self._assign_user_role(tenant, user, role)
+        # Add roles specified in config file
+        for conf_role in CONF.auth.tempest_roles:
+            self._assign_user_role(tenant, user, conf_role)
+        # Add roles requested by caller
+        if roles:
+            for role in roles:
+                self._assign_user_role(tenant, user, role)
         return self._get_credentials(user, tenant)
 
     def _get_credentials(self, user, tenant):
@@ -247,12 +252,15 @@
         return self.isolated_net_resources.get('alt')[2]
 
     def get_credentials(self, credential_type):
-        if self.isolated_creds.get(credential_type):
-            credentials = self.isolated_creds[credential_type]
+        if self.isolated_creds.get(str(credential_type)):
+            credentials = self.isolated_creds[str(credential_type)]
         else:
-            is_admin = (credential_type == 'admin')
-            credentials = self._create_creds(admin=is_admin)
-            self.isolated_creds[credential_type] = credentials
+            if credential_type in ['primary', 'alt', 'admin']:
+                is_admin = (credential_type == 'admin')
+                credentials = self._create_creds(admin=is_admin)
+            else:
+                credentials = self._create_creds(roles=credential_type)
+            self.isolated_creds[str(credential_type)] = credentials
             # Maintained until tests are ported
             LOG.info("Acquired isolated creds:\n credentials: %s"
                      % credentials)
@@ -260,7 +268,7 @@
                 not CONF.baremetal.driver_enabled):
                 network, subnet, router = self._create_network_resources(
                     credentials.tenant_id)
-                self.isolated_net_resources[credential_type] = (
+                self.isolated_net_resources[str(credential_type)] = (
                     network, subnet, router,)
                 LOG.info("Created isolated network resources for : \n"
                          + " credentials: %s" % credentials)
@@ -275,6 +283,26 @@
     def get_alt_creds(self):
         return self.get_credentials('alt')
 
+    def get_creds_by_roles(self, roles, force_new=False):
+        roles = list(set(roles))
+        # The roles list as a str will become the index as the dict key for
+        # the created credentials set in the isolated_creds dict.
+        exist_creds = self.isolated_creds.get(str(roles))
+        # If force_new flag is True 2 cred sets with the same roles are needed
+        # handle this by creating a separate index for old one to store it
+        # separately for cleanup
+        if exist_creds and force_new:
+            new_index = str(roles) + '-' + str(len(self.isolated_creds))
+            self.isolated_creds[new_index] = exist_creds
+            del self.isolated_creds[str(roles)]
+            # Handle isolated neutron resouces if they exist too
+            if CONF.service_available.neutron:
+                exist_net = self.isolated_net_resources.get(str(roles))
+                if exist_net:
+                    self.isolated_net_resources[new_index] = exist_net
+                    del self.isolated_net_resources[str(roles)]
+        return self.get_credentials(roles)
+
     def _clear_isolated_router(self, router_id, router_name):
         net_client = self.network_admin_client
         try:
diff --git a/tempest/tests/common/test_accounts.py b/tempest/tests/common/test_accounts.py
index 5726e69..a2302b6 100644
--- a/tempest/tests/common/test_accounts.py
+++ b/tempest/tests/common/test_accounts.py
@@ -51,7 +51,19 @@
             {'username': 'test_user5', 'tenant_name': 'test_tenant5',
              'password': 'p'},
             {'username': 'test_user6', 'tenant_name': 'test_tenant6',
-             'password': 'p'},
+             'password': 'p', 'roles': ['role1', 'role2']},
+            {'username': 'test_user7', 'tenant_name': 'test_tenant7',
+             'password': 'p', 'roles': ['role2', 'role3']},
+            {'username': 'test_user8', 'tenant_name': 'test_tenant8',
+             'password': 'p', 'roles': ['role4', 'role1']},
+            {'username': 'test_user9', 'tenant_name': 'test_tenant9',
+             'password': 'p', 'roles': ['role1', 'role2', 'role3', 'role4']},
+            {'username': 'test_user10', 'tenant_name': 'test_tenant10',
+             'password': 'p', 'roles': ['role1', 'role2', 'role3', 'role4']},
+            {'username': 'test_user11', 'tenant_name': 'test_tenant11',
+             'password': 'p', 'roles': [cfg.CONF.identity.admin_role]},
+            {'username': 'test_user12', 'tenant_name': 'test_tenant12',
+             'password': 'p', 'roles': [cfg.CONF.identity.admin_role]},
         ]
         self.useFixture(mockpatch.Patch(
             'tempest.common.accounts.read_accounts_yaml',
@@ -64,7 +76,8 @@
         for account in accounts_list:
             hash = hashlib.md5()
             hash.update(str(account))
-            hash_list.append(hash.hexdigest())
+            temp_hash = hash.hexdigest()
+            hash_list.append(temp_hash)
         return hash_list
 
     def test_get_hash(self):
@@ -83,8 +96,8 @@
         hash_dict = test_account_class.get_hash_dict(self.test_accounts)
         hash_list = self._get_hash_list(self.test_accounts)
         for hash in hash_list:
-            self.assertIn(hash, hash_dict.keys())
-            self.assertIn(hash_dict[hash], self.test_accounts)
+            self.assertIn(hash, hash_dict['creds'].keys())
+            self.assertIn(hash_dict['creds'][hash], self.test_accounts)
 
     def test_create_hash_file_previous_file(self):
         # Emulate the lock existing on the filesystem
@@ -201,6 +214,62 @@
         test_accounts_class = accounts.Accounts('test_name')
         self.assertFalse(test_accounts_class.is_multi_user())
 
+    def test__get_creds_by_roles_one_role(self):
+        self.useFixture(mockpatch.Patch(
+            'tempest.common.accounts.read_accounts_yaml',
+            return_value=self.test_accounts))
+        test_accounts_class = accounts.Accounts('test_name')
+        hashes = test_accounts_class.hash_dict['roles']['role4']
+        temp_hash = hashes[0]
+        get_free_hash_mock = self.useFixture(mockpatch.PatchObject(
+            test_accounts_class, '_get_free_hash', return_value=temp_hash))
+        # Test a single role returns all matching roles
+        test_accounts_class._get_creds(roles=['role4'])
+        calls = get_free_hash_mock.mock.mock_calls
+        self.assertEqual(len(calls), 1)
+        args = calls[0][1][0]
+        for i in hashes:
+            self.assertIn(i, args)
+
+    def test__get_creds_by_roles_list_role(self):
+        self.useFixture(mockpatch.Patch(
+            'tempest.common.accounts.read_accounts_yaml',
+            return_value=self.test_accounts))
+        test_accounts_class = accounts.Accounts('test_name')
+        hashes = test_accounts_class.hash_dict['roles']['role4']
+        hashes2 = test_accounts_class.hash_dict['roles']['role2']
+        hashes = list(set(hashes) & set(hashes2))
+        temp_hash = hashes[0]
+        get_free_hash_mock = self.useFixture(mockpatch.PatchObject(
+            test_accounts_class, '_get_free_hash', return_value=temp_hash))
+        # Test an intersection of multiple roles
+        test_accounts_class._get_creds(roles=['role2', 'role4'])
+        calls = get_free_hash_mock.mock.mock_calls
+        self.assertEqual(len(calls), 1)
+        args = calls[0][1][0]
+        for i in hashes:
+            self.assertIn(i, args)
+
+    def test__get_creds_by_roles_no_admin(self):
+        self.useFixture(mockpatch.Patch(
+            'tempest.common.accounts.read_accounts_yaml',
+            return_value=self.test_accounts))
+        test_accounts_class = accounts.Accounts('test_name')
+        hashes = test_accounts_class.hash_dict['creds'].keys()
+        admin_hashes = test_accounts_class.hash_dict['roles'][
+            cfg.CONF.identity.admin_role]
+        temp_hash = hashes[0]
+        get_free_hash_mock = self.useFixture(mockpatch.PatchObject(
+            test_accounts_class, '_get_free_hash', return_value=temp_hash))
+        # Test an intersection of multiple roles
+        test_accounts_class._get_creds()
+        calls = get_free_hash_mock.mock.mock_calls
+        self.assertEqual(len(calls), 1)
+        args = calls[0][1][0]
+        self.assertEqual(len(args), 10)
+        for i in admin_hashes:
+            self.assertNotIn(i, args)
+
 
 class TestNotLockingAccount(base.TestCase):
 
diff --git a/tempest/tests/test_tenant_isolation.py b/tempest/tests/test_tenant_isolation.py
index 6c80496..80ec193 100644
--- a/tempest/tests/test_tenant_isolation.py
+++ b/tempest/tests/test_tenant_isolation.py
@@ -74,6 +74,17 @@
                             {'id': '1', 'name': 'FakeRole'}]))))
         return roles_fix
 
+    def _mock_list_2_roles(self):
+        roles_fix = self.useFixture(mockpatch.PatchObject(
+            json_iden_client.IdentityClientJSON,
+            'list_roles',
+            return_value=(service_client.ResponseBodyList
+                          (200,
+                           [{'id': '1234', 'name': 'role1'},
+                            {'id': '1', 'name': 'FakeRole'},
+                            {'id': '12345', 'name': 'role2'}]))))
+        return roles_fix
+
     def _mock_assign_user_role(self):
         tenant_fix = self.useFixture(mockpatch.PatchObject(
             json_iden_client.IdentityClientJSON,
@@ -153,6 +164,35 @@
         self.assertEqual(admin_creds.user_id, '1234')
 
     @mock.patch('tempest_lib.common.rest_client.RestClient')
+    def test_role_creds(self, MockRestClient):
+        cfg.CONF.set_default('neutron', False, 'service_available')
+        iso_creds = isolated_creds.IsolatedCreds('test class',
+                                                 password='fake_password')
+        self._mock_list_2_roles()
+        self._mock_user_create('1234', 'fake_role_user')
+        self._mock_tenant_create('1234', 'fake_role_tenant')
+
+        user_mock = mock.patch.object(json_iden_client.IdentityClientJSON,
+                                      'assign_user_role')
+        user_mock.start()
+        self.addCleanup(user_mock.stop)
+        with mock.patch.object(json_iden_client.IdentityClientJSON,
+                               'assign_user_role') as user_mock:
+            role_creds = iso_creds.get_creds_by_roles(roles=['role1', 'role2'])
+        calls = user_mock.mock_calls
+        # Assert that the role creation is called with the 2 specified roles
+        self.assertEqual(len(calls), 3)
+        args = map(lambda x: x[1], calls)
+        self.assertIn(('1234', '1234', '1'), args)
+        self.assertIn(('1234', '1234', '1234'), args)
+        self.assertIn(('1234', '1234', '12345'), args)
+        self.assertEqual(role_creds.username, 'fake_role_user')
+        self.assertEqual(role_creds.tenant_name, 'fake_role_tenant')
+        # Verify IDs
+        self.assertEqual(role_creds.tenant_id, '1234')
+        self.assertEqual(role_creds.user_id, '1234')
+
+    @mock.patch('tempest_lib.common.rest_client.RestClient')
     def test_all_cred_cleanup(self, MockRestClient):
         cfg.CONF.set_default('neutron', False, 'service_available')
         iso_creds = isolated_creds.IsolatedCreds('test class',