diff options
-rw-r--r-- | keystone/common/controller.py | 2 | ||||
-rw-r--r-- | keystone/contrib/ec2/core.py | 23 | ||||
-rw-r--r-- | keystone/token/controllers.py | 214 | ||||
-rw-r--r-- | keystone/token/provider.py | 3 | ||||
-rw-r--r-- | keystone/token/providers/uuid.py | 250 | ||||
-rw-r--r-- | tests/test_keystoneclient.py | 24 |
6 files changed, 287 insertions, 229 deletions
diff --git a/keystone/common/controller.py b/keystone/common/controller.py index 0ad1efa3..0fb91cf1 100644 --- a/keystone/common/controller.py +++ b/keystone/common/controller.py @@ -26,7 +26,7 @@ def _build_policy_check_credentials(self, action, context, kwargs): raise exception.Unauthorized() creds = {} - if 'token_data' in token_ref: + if 'token_data' in token_ref and 'token' in token_ref['token_data']: #V3 Tokens token_data = token_ref['token_data']['token'] try: diff --git a/keystone/contrib/ec2/core.py b/keystone/contrib/ec2/core.py index 5254b53f..fed7ee08 100644 --- a/keystone/contrib/ec2/core.py +++ b/keystone/contrib/ec2/core.py @@ -97,7 +97,7 @@ class Ec2Extension(wsgi.ExtensionRouter): conditions=dict(method=['DELETE'])) -@dependency.requires('catalog_api', 'ec2_api') +@dependency.requires('catalog_api', 'ec2_api', 'token_provider_api') class Ec2Controller(controller.V2Controller): def check_signature(self, creds_ref, credentials): signer = ec2_utils.Ec2Signer(creds_ref['secret']) @@ -172,17 +172,16 @@ class Ec2Controller(controller.V2Controller): tenant_id=tenant_ref['id'], metadata=metadata_ref) - token_ref = self.token_api.create_token( - token_id, dict(id=token_id, - user=user_ref, - tenant=tenant_ref, - metadata=metadata_ref)) - - # TODO(termie): i don't think the ec2 middleware currently expects a - # full return, but it contains a note saying that it - # would be better to expect a full return - return token.controllers.Auth.format_authenticate( - token_ref, roles_ref, catalog_ref) + auth_token_data = dict(user=user_ref, + tenant=tenant_ref, + metadata=metadata_ref, + id='placeholder') + (token_id, token_data) = self.token_provider_api.issue_token( + version=token.provider.V2, + token_ref=auth_token_data, + roles_ref=roles_ref, + catalog_ref=catalog_ref) + return token_data def create_credential(self, context, user_id, tenant_id): """Create a secret/access pair for use with ec2 style auth. diff --git a/keystone/token/controllers.py b/keystone/token/controllers.py index 1ada05ea..4914d305 100644 --- a/keystone/token/controllers.py +++ b/keystone/token/controllers.py @@ -1,16 +1,16 @@ import json -import sys -import uuid from keystone.common import cms from keystone.common import controller -from keystone.common import environment +from keystone.common import dependency from keystone.common import logging from keystone.common import utils from keystone import config from keystone import exception from keystone.openstack.common import timeutils from keystone.token import core +from keystone.token import provider as token_provider + CONF = config.CONF LOG = logging.getLogger(__name__) @@ -22,6 +22,7 @@ class ExternalAuthNotApplicable(Exception): pass +@dependency.requires('token_provider_api') class Auth(controller.V2Controller): def ca_cert(self, context, auth=None): ca_file = open(CONF.signing.ca_certs, 'r') @@ -79,7 +80,6 @@ class Auth(controller.V2Controller): user_ref, tenant_ref, metadata_ref, expiry = auth_info core.validate_auth_info(self, user_ref, tenant_ref) - trust_id = metadata_ref.get('trust_id') user_ref = self._filter_domain_id(user_ref) if tenant_ref: tenant_ref = self._filter_domain_id(tenant_ref) @@ -103,46 +103,11 @@ class Auth(controller.V2Controller): role_ref = self.identity_api.get_role(role_id) roles_ref.append(dict(name=role_ref['name'])) - token_data = Auth.format_token(auth_token_data, roles_ref) - - service_catalog = Auth.format_catalog(catalog_ref) - token_data['access']['serviceCatalog'] = service_catalog - - if CONF.signing.token_format == 'UUID': - token_id = uuid.uuid4().hex - elif CONF.signing.token_format == 'PKI': - try: - token_id = cms.cms_sign_token(json.dumps(token_data), - CONF.signing.certfile, - CONF.signing.keyfile) - except environment.subprocess.CalledProcessError: - raise exception.UnexpectedError(_( - 'Unable to sign token.')) - else: - raise exception.UnexpectedError(_( - 'Invalid value for token_format: %s.' - ' Allowed values are PKI or UUID.') % - CONF.signing.token_format) - try: - self.token_api.create_token( - token_id, dict(key=token_id, - id=token_id, - expires=auth_token_data['expires'], - user=user_ref, - tenant=tenant_ref, - metadata=metadata_ref, - trust_id=trust_id)) - except Exception: - exc_info = sys.exc_info() - # an identical token may have been created already. - # if so, return the token_data as it is also identical - try: - self.token_api.get_token(token_id) - except exception.TokenNotFound: - raise exc_info[0], exc_info[1], exc_info[2] - - token_data['access']['token']['id'] = token_id - + (token_id, token_data) = self.token_provider_api.issue_token( + version=token_provider.V2, + token_ref=auth_token_data, + roles_ref=roles_ref, + catalog_ref=catalog_ref) return token_data def _authenticate_token(self, context, auth): @@ -416,45 +381,6 @@ class Auth(controller.V2Controller): _('Token does not belong to specified tenant.')) return data - def _assert_default_domain(self, token_ref): - """Make sure we are operating on default domain only.""" - if token_ref.get('token_data'): - # this is a V3 token - msg = _('Non-default domain is not supported') - # user in a non-default is prohibited - if (token_ref['token_data']['token']['user']['domain']['id'] != - DEFAULT_DOMAIN_ID): - raise exception.Unauthorized(msg) - # domain scoping is prohibited - if token_ref['token_data']['token'].get('domain'): - raise exception.Unauthorized( - _('Domain scoped token is not supported')) - # project in non-default domain is prohibited - if token_ref['token_data']['token'].get('project'): - project = token_ref['token_data']['token']['project'] - project_domain_id = project['domain']['id'] - # scoped to project in non-default domain is prohibited - if project_domain_id != DEFAULT_DOMAIN_ID: - raise exception.Unauthorized(msg) - # if token is scoped to trust, both trustor and trustee must - # be in the default domain. Furthermore, the delegated project - # must also be in the default domain - metadata_ref = token_ref['metadata'] - if CONF.trust.enabled and 'trust_id' in metadata_ref: - trust_ref = self.trust_api.get_trust(metadata_ref['trust_id']) - trustee_user_ref = self.identity_api.get_user( - trust_ref['trustee_user_id']) - if trustee_user_ref['domain_id'] != DEFAULT_DOMAIN_ID: - raise exception.Unauthorized(msg) - trustor_user_ref = self.identity_api.get_user( - trust_ref['trustor_user_id']) - if trustor_user_ref['domain_id'] != DEFAULT_DOMAIN_ID: - raise exception.Unauthorized(msg) - project_ref = self.identity_api.get_project( - trust_ref['project_id']) - if project_ref['domain_id'] != DEFAULT_DOMAIN_ID: - raise exception.Unauthorized(msg) - @controller.protected def validate_token_head(self, context, token_id): """Check that a token is valid. @@ -465,9 +391,9 @@ class Auth(controller.V2Controller): """ belongs_to = context['query_string'].get('belongsTo') - token_ref = self._get_token_ref(token_id, belongs_to) - assert token_ref - self._assert_default_domain(token_ref) + self.token_provider_api.check_token(token_id, + belongs_to=belongs_to, + version=token_provider.V2) @controller.protected def validate_token(self, context, token_id): @@ -479,26 +405,9 @@ class Auth(controller.V2Controller): """ belongs_to = context['query_string'].get('belongsTo') - token_ref = self._get_token_ref(token_id, belongs_to) - self._assert_default_domain(token_ref) - - # TODO(termie): optimize this call at some point and put it into the - # the return for metadata - # fill out the roles in the metadata - metadata_ref = token_ref['metadata'] - roles_ref = [] - for role_id in metadata_ref.get('roles', []): - roles_ref.append(self.identity_api.get_role(role_id)) - - # Get a service catalog if possible - # This is needed for on-behalf-of requests - catalog_ref = None - if token_ref.get('tenant'): - catalog_ref = self.catalog_api.get_catalog( - user_id=token_ref['user']['id'], - tenant_id=token_ref['tenant']['id'], - metadata=metadata_ref) - return Auth.format_token(token_ref, roles_ref, catalog_ref) + return self.token_provider_api.validate_token( + token_id, belongs_to=belongs_to, + version=token_provider.V2) def delete_token(self, context, token_id): """Delete a token, effectively invalidating it for authz.""" @@ -538,99 +447,6 @@ class Auth(controller.V2Controller): return Auth.format_endpoint_list(catalog_ref) @classmethod - def format_authenticate(cls, token_ref, roles_ref, catalog_ref): - o = Auth.format_token(token_ref, roles_ref) - o['access']['serviceCatalog'] = Auth.format_catalog(catalog_ref) - return o - - @classmethod - def format_token(cls, token_ref, roles_ref, catalog_ref=None): - user_ref = token_ref['user'] - metadata_ref = token_ref['metadata'] - expires = token_ref['expires'] - if expires is not None: - if not isinstance(expires, unicode): - expires = timeutils.isotime(expires) - o = {'access': {'token': {'id': token_ref['id'], - 'expires': expires, - 'issued_at': timeutils.strtime() - }, - 'user': {'id': user_ref['id'], - 'name': user_ref['name'], - 'username': user_ref['name'], - 'roles': roles_ref, - 'roles_links': metadata_ref.get('roles_links', - []) - } - } - } - if 'tenant' in token_ref and token_ref['tenant']: - token_ref['tenant']['enabled'] = True - o['access']['token']['tenant'] = token_ref['tenant'] - if catalog_ref is not None: - o['access']['serviceCatalog'] = Auth.format_catalog(catalog_ref) - if metadata_ref: - if 'is_admin' in metadata_ref: - o['access']['metadata'] = {'is_admin': - metadata_ref['is_admin']} - else: - o['access']['metadata'] = {'is_admin': 0} - if 'roles' in metadata_ref: - o['access']['metadata']['roles'] = metadata_ref['roles'] - if CONF.trust.enabled and 'trust_id' in metadata_ref: - o['access']['trust'] = {'trustee_user_id': - metadata_ref['trustee_user_id'], - 'id': metadata_ref['trust_id'] - } - return o - - @classmethod - def format_catalog(cls, catalog_ref): - """Munge catalogs from internal to output format - Internal catalogs look like: - - {$REGION: { - {$SERVICE: { - $key1: $value1, - ... - } - } - } - - The legacy api wants them to look like - - [{'name': $SERVICE[name], - 'type': $SERVICE, - 'endpoints': [{ - 'tenantId': $tenant_id, - ... - 'region': $REGION, - }], - 'endpoints_links': [], - }] - - """ - if not catalog_ref: - return [] - - services = {} - for region, region_ref in catalog_ref.iteritems(): - for service, service_ref in region_ref.iteritems(): - new_service_ref = services.get(service, {}) - new_service_ref['name'] = service_ref.pop('name') - new_service_ref['type'] = service - new_service_ref['endpoints_links'] = [] - service_ref['region'] = region - - endpoints_ref = new_service_ref.get('endpoints', []) - endpoints_ref.append(service_ref) - - new_service_ref['endpoints'] = endpoints_ref - services[service] = new_service_ref - - return services.values() - - @classmethod def format_endpoint_list(cls, catalog_ref): """Formats a list of endpoints according to Identity API v2. diff --git a/keystone/token/provider.py b/keystone/token/provider.py index b5ad72fa..3bb14e01 100644 --- a/keystone/token/provider.py +++ b/keystone/token/provider.py @@ -81,6 +81,9 @@ class Provider(object): domain-scoped token; and 'auth_context' from the authentication plugins. + For V2 tokens, 'token_ref' must be present in kwargs. + Optionally, kwargs may contain 'roles_ref' and 'catalog_ref'. + :param context: request context :type context: dictionary :param version: version of the token to be issued diff --git a/keystone/token/providers/uuid.py b/keystone/token/providers/uuid.py index e1bd0b3b..413c7479 100644 --- a/keystone/token/providers/uuid.py +++ b/keystone/token/providers/uuid.py @@ -27,7 +27,6 @@ from keystone import config from keystone import exception from keystone.openstack.common import timeutils from keystone import token -from keystone.token import provider as token_provider from keystone import trust @@ -37,6 +36,108 @@ DEFAULT_DOMAIN_ID = CONF.identity.default_domain_id @dependency.requires('catalog_api', 'identity_api') +class V2TokenDataHelper(object): + """Creates V2 token data.""" + @classmethod + def format_token(cls, token_ref, roles_ref, catalog_ref=None): + user_ref = token_ref['user'] + metadata_ref = token_ref['metadata'] + expires = token_ref.get('expires', token.default_expire_time()) + if expires is not None: + if not isinstance(expires, unicode): + expires = timeutils.isotime(expires) + o = {'access': {'token': {'id': token_ref['id'], + 'expires': expires, + 'issued_at': timeutils.strtime() + }, + 'user': {'id': user_ref['id'], + 'name': user_ref['name'], + 'username': user_ref['name'], + 'roles': roles_ref, + 'roles_links': metadata_ref.get('roles_links', + []) + } + } + } + if 'tenant' in token_ref and token_ref['tenant']: + token_ref['tenant']['enabled'] = True + o['access']['token']['tenant'] = token_ref['tenant'] + if catalog_ref is not None: + o['access']['serviceCatalog'] = V2TokenDataHelper.format_catalog( + catalog_ref) + if metadata_ref: + if 'is_admin' in metadata_ref: + o['access']['metadata'] = {'is_admin': + metadata_ref['is_admin']} + else: + o['access']['metadata'] = {'is_admin': 0} + if 'roles' in metadata_ref: + o['access']['metadata']['roles'] = metadata_ref['roles'] + if CONF.trust.enabled and 'trust_id' in metadata_ref: + o['access']['trust'] = {'trustee_user_id': + metadata_ref['trustee_user_id'], + 'id': metadata_ref['trust_id'] + } + return o + + @classmethod + def format_catalog(cls, catalog_ref): + """Munge catalogs from internal to output format + Internal catalogs look like: + + {$REGION: { + {$SERVICE: { + $key1: $value1, + ... + } + } + } + + The legacy api wants them to look like + + [{'name': $SERVICE[name], + 'type': $SERVICE, + 'endpoints': [{ + 'tenantId': $tenant_id, + ... + 'region': $REGION, + }], + 'endpoints_links': [], + }] + + """ + if not catalog_ref: + return [] + + services = {} + for region, region_ref in catalog_ref.iteritems(): + for service, service_ref in region_ref.iteritems(): + new_service_ref = services.get(service, {}) + new_service_ref['name'] = service_ref.pop('name') + new_service_ref['type'] = service + new_service_ref['endpoints_links'] = [] + service_ref['region'] = region + + endpoints_ref = new_service_ref.get('endpoints', []) + endpoints_ref.append(service_ref) + + new_service_ref['endpoints'] = endpoints_ref + services[service] = new_service_ref + + return services.values() + + @classmethod + def get_token_data(cls, **kwargs): + if 'token_ref' not in kwargs: + raise ValueError('Require token_ref to create V2 token data') + token_ref = kwargs.get('token_ref') + roles_ref = kwargs.get('roles_ref', []) + catalog_ref = kwargs.get('catalog_ref') + return V2TokenDataHelper.format_token( + token_ref, roles_ref, catalog_ref) + + +@dependency.requires('catalog_api', 'identity_api') class V3TokenDataHelper(object): """Token data helper.""" def __init__(self): @@ -207,25 +308,56 @@ class V3TokenDataHelper(object): return {'token': token_data} -@dependency.requires('token_api', 'identity_api') -class Provider(token_provider.Provider): +@dependency.requires('token_api', 'identity_api', 'catalog_api') +class Provider(token.provider.Provider): def __init__(self, *args, **kwargs): super(Provider, self).__init__(*args, **kwargs) if CONF.trust.enabled: self.trust_api = trust.Manager() self.v3_token_data_helper = V3TokenDataHelper() + self.v2_token_data_helper = V2TokenDataHelper() def get_token_version(self, token_data): if token_data and isinstance(token_data, dict): if 'access' in token_data: - return token_provider.V2 + return token.provider.V2 if 'token' in token_data and 'methods' in token_data['token']: - return token_provider.V3 - raise token_provider.UnsupportedTokenVersionException() + return token.provider.V3 + raise token.provider.UnsupportedTokenVersionException() def _get_token_id(self, token_data): return uuid.uuid4().hex + def _issue_v2_token(self, **kwargs): + token_data = self.v2_token_data_helper.get_token_data(**kwargs) + token_id = self._get_token_id(token_data) + token_data['access']['token']['id'] = token_id + try: + expiry = token_data['access']['token']['expires'] + token_ref = kwargs.get('token_ref') + if isinstance(expiry, basestring): + expiry = timeutils.normalize_time( + timeutils.parse_isotime(expiry)) + data = dict(key=token_id, + id=token_id, + expires=expiry, + user=token_ref['user'], + tenant=token_ref['tenant'], + metadata=token_ref['metadata'], + token_data=token_data, + trust_id=token_ref['metadata'].get('trust_id')) + self.token_api.create_token(token_id, data) + except Exception: + exc_info = sys.exc_info() + # an identical token may have been created already. + # if so, return the token_data as it is also identical + try: + self.token_api.get_token(token_id) + except exception.TokenNotFound: + raise exc_info[0], exc_info[1], exc_info[2] + + return (token_id, token_data) + def _issue_v3_token(self, **kwargs): user_id = kwargs.get('user_id') method_names = kwargs.get('method_names') @@ -287,21 +419,104 @@ class Provider(token_provider.Provider): return (token_id, token_data) def issue_token(self, version='v3.0', **kwargs): - if version == token_provider.V3: + if version == token.provider.V3: return self._issue_v3_token(**kwargs) - raise token_provider.UnsupportedTokenVersionException + elif version == token.provider.V2: + return self._issue_v2_token(**kwargs) + raise token.provider.UnsupportedTokenVersionException def _verify_token(self, token_id, belongs_to=None): """Verify the given token and return the token_ref.""" token_ref = self.token_api.get_token(token_id=token_id) assert token_ref if belongs_to: - assert token_ref['tenant']['id'] == belongs_to + assert (token_ref['tenant'] and + token_ref['tenant']['id'] == belongs_to) return token_ref def revoke_token(self, token_id): self.token_api.delete_token(token_id=token_id) + def _assert_default_domain(self, token_ref): + """Make sure we are operating on default domain only.""" + if (token_ref.get('token_data') and + self.get_token_version(token_ref.get('token_data')) == + token.provider.V3): + # this is a V3 token + msg = _('Non-default domain is not supported') + # user in a non-default is prohibited + if (token_ref['token_data']['token']['user']['domain']['id'] != + DEFAULT_DOMAIN_ID): + raise exception.Unauthorized(msg) + # domain scoping is prohibited + if token_ref['token_data']['token'].get('domain'): + raise exception.Unauthorized( + _('Domain scoped token is not supported')) + # project in non-default domain is prohibited + if token_ref['token_data']['token'].get('project'): + project = token_ref['token_data']['token']['project'] + project_domain_id = project['domain']['id'] + # scoped to project in non-default domain is prohibited + if project_domain_id != DEFAULT_DOMAIN_ID: + raise exception.Unauthorized(msg) + # if token is scoped to trust, both trustor and trustee must + # be in the default domain. Furthermore, the delegated project + # must also be in the default domain + metadata_ref = token_ref['metadata'] + if CONF.trust.enabled and 'trust_id' in metadata_ref: + trust_ref = self.trust_api.get_trust(metadata_ref['trust_id']) + trustee_user_ref = self.identity_api.get_user( + trust_ref['trustee_user_id']) + if trustee_user_ref['domain_id'] != DEFAULT_DOMAIN_ID: + raise exception.Unauthorized(msg) + trustor_user_ref = self.identity_api.get_user( + trust_ref['trustor_user_id']) + if trustor_user_ref['domain_id'] != DEFAULT_DOMAIN_ID: + raise exception.Unauthorized(msg) + project_ref = self.identity_api.get_project( + trust_ref['project_id']) + if project_ref['domain_id'] != DEFAULT_DOMAIN_ID: + raise exception.Unauthorized(msg) + + def _validate_v2_token(self, token_id, belongs_to=None, **kwargs): + try: + token_ref = self._verify_token(token_id, belongs_to=belongs_to) + self._assert_default_domain(token_ref) + # FIXME(gyee): performance or correctness? Should we return the + # cached token or reconstruct it? Obviously if we are going with + # the cached token, any role, project, or domain name changes + # will not be reflected. One may argue that with PKI tokens, + # we are essentially doing cached token validation anyway. + # Lets go with the cached token strategy. Since token + # management layer is now pluggable, one can always provide + # their own implementation to suit their needs. + token_data = token_ref.get('token_data') + if (not token_data or + self.get_token_version(token_data) != + token.provider.V2): + # token is created by old v2 logic + metadata_ref = token_ref['metadata'] + role_refs = [] + for role_id in metadata_ref.get('roles', []): + role_refs.append(self.identity_api.get_role(role_id)) + + # Get a service catalog if possible + # This is needed for on-behalf-of requests + catalog_ref = None + if token_ref.get('tenant'): + catalog_ref = self.catalog_api.get_catalog( + token_ref['user']['id'], + token_ref['tenant']['id'], + metadata=metadata_ref) + token_data = self.v2_token_data_helper.get_token_data( + token_ref=token_ref, + roles_ref=role_refs, + catalog_ref=catalog_ref) + return token_data + except AssertionError as e: + LOG.exception(_('Failed to validate token')) + raise exception.Unauthorized(e) + def _validate_v3_token(self, token_id): token_ref = self._verify_token(token_id) # FIXME(gyee): performance or correctness? Should we return the @@ -327,12 +542,14 @@ class Provider(token_provider.Provider): expires=token_ref['expires']) return token_data - def validate_token(self, token_id, belongs_to=None, - version='v3.0'): + def validate_token(self, token_id, belongs_to=None, version='v3.0'): try: - if version == token_provider.V3: + if version == token.provider.V3: return self._validate_v3_token(token_id) - raise token_provider.UnsupportedTokenVersionException() + elif version == token.provider.V2: + return self._validate_v2_token(token_id, + belongs_to=belongs_to) + raise token.provider.UnsupportedTokenVersionException() except exception.TokenNotFound as e: LOG.exception(_('Failed to verify token')) raise exception.Unauthorized(e) @@ -340,10 +557,9 @@ class Provider(token_provider.Provider): def check_token(self, token_id, belongs_to=None, version='v3.0', **kwargs): try: - if version == token_provider.V3: - self._verify_token(token_id) - else: - raise token_provider.UnsupportedTokenVersionException() + token_ref = self._verify_token(token_id, belongs_to=belongs_to) + if version == token.provider.V2: + self._assert_default_domain(token_ref) except exception.TokenNotFound as e: LOG.exception(_('Failed to verify token')) raise exception.Unauthorized(e) diff --git a/tests/test_keystoneclient.py b/tests/test_keystoneclient.py index d78e885a..5c0d2f5b 100644 --- a/tests/test_keystoneclient.py +++ b/tests/test_keystoneclient.py @@ -20,6 +20,7 @@ import webob import nose.exc from keystone import test +from keystone import token from keystone import config from keystone.openstack.common import jsonutils @@ -42,6 +43,7 @@ class CompatTestCase(test.TestCase): self.clear_module('keystoneclient') self.load_backends() + self.token_provider_api = token.provider.Manager() self.load_fixtures(default_fixtures) self.public_server = self.serveapp('keystone', name='main') @@ -839,6 +841,28 @@ class KcMasterTestCase(CompatTestCase, KeystoneClientTests): def get_checkout(self): return KEYSTONECLIENT_REPO, 'master' + def test_ec2_auth(self): + client = self.get_client() + cred = client.ec2.create(user_id=self.user_foo['id'], + tenant_id=self.tenant_bar['id']) + + from keystoneclient.contrib.ec2 import utils as ec2_utils + signer = ec2_utils.Ec2Signer(cred.secret) + credentials = {'params': {'SignatureVersion': '2'}, + 'access': cred.access, + 'verb': 'GET', + 'host': 'localhost', + 'path': '/thisisgoingtowork'} + signature = signer.generate(credentials) + credentials['signature'] = signature + url = '%s/ec2tokens' % (client.auth_url) + (resp, token) = client.request(url=url, + method='POST', + body={'credentials': credentials}) + # make sure we have a v2 token + self.assertEqual(resp.status_code, 200) + self.assertIn('access', token) + def test_tenant_add_and_remove_user(self): client = self.get_client(admin=True) client.roles.add_user_role(tenant=self.tenant_bar['id'], |