diff options
33 files changed, 1086 insertions, 90 deletions
diff --git a/.zuul.yaml b/.zuul.yaml index 261ecc3eb..84d757ef5 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -196,6 +196,7 @@ voting: false irrelevant-files: *irrelevant-files - keystone-dsvm-py3-functional-federation-opensuse15-k2k: + voting: false irrelevant-files: *irrelevant-files - keystoneclient-devstack-functional: voting: false @@ -225,8 +226,6 @@ irrelevant-files: *irrelevant-files - keystone-dsvm-py3-functional: irrelevant-files: *irrelevant-files - - keystone-dsvm-py3-functional-federation-opensuse15-k2k: - irrelevant-files: *irrelevant-files - tempest-full: irrelevant-files: *tempest-irrelevant-files - tempest-full-py3: diff --git a/doc/requirements.txt b/doc/requirements.txt index 7247e6aee..47cafa36c 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -2,7 +2,8 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. openstackdocstheme>=1.18.1 # Apache-2.0 -sphinx!=1.6.6,!=1.6.7,>=1.6.2 # BSD +sphinx!=1.6.6,!=1.6.7,>=1.6.2,<2.0.0;python_version=='2.7' # BSD +sphinx!=1.6.6,!=1.6.7,>=1.6.2;python_version>='3.4' # BSD sphinxcontrib-apidoc>=0.2.0 # BSD sphinxcontrib-seqdiag>=0.8.4 # BSD reno>=2.5.0 # Apache-2.0 diff --git a/keystone/api/_shared/EC2_S3_Resource.py b/keystone/api/_shared/EC2_S3_Resource.py index a1f139116..2e348bcec 100644 --- a/keystone/api/_shared/EC2_S3_Resource.py +++ b/keystone/api/_shared/EC2_S3_Resource.py @@ -12,18 +12,22 @@ # Common base resource for EC2 and S3 Authentication +import datetime import sys from oslo_serialization import jsonutils +from oslo_utils import timeutils import six from werkzeug import exceptions from keystone.common import provider_api from keystone.common import utils +import keystone.conf from keystone import exception as ks_exceptions from keystone.i18n import _ from keystone.server import flask as ks_flask +CONF = keystone.conf.CONF PROVIDERS = provider_api.ProviderAPIs CRED_TYPE_EC2 = 'ec2' @@ -39,6 +43,31 @@ class ResourceBase(ks_flask.ResourceBase): # the ABC module. raise NotImplementedError() + @staticmethod + def _check_timestamp(credentials): + timestamp = ( + # AWS Signature v1/v2 + credentials.get('params', {}).get('Timestamp') or + # AWS Signature v4 + credentials.get('headers', {}).get('X-Amz-Date') or + credentials.get('params', {}).get('X-Amz-Date') + ) + if not timestamp: + # If the signed payload doesn't include a timestamp then the signer + # must have intentionally left it off + return + try: + timestamp = timeutils.parse_isotime(timestamp) + timestamp = timeutils.normalize_time(timestamp) + except Exception as e: + raise ks_exceptions.Unauthorized( + _('Credential timestamp is invalid: %s') % e) + auth_ttl = datetime.timedelta(minutes=CONF.credential.auth_ttl) + current_time = timeutils.normalize_time(timeutils.utcnow()) + if current_time > timestamp + auth_ttl: + raise ks_exceptions.Unauthorized( + _('Credential is expired')) + def handle_authenticate(self): # TODO(morgan): convert this dirty check to JSON Schema validation # this mirrors the previous behavior of the webob system where an @@ -86,7 +115,9 @@ class ResourceBase(ks_flask.ResourceBase): project_id=cred.get('project_id'), access=loaded.get('access'), secret=loaded.get('secret'), - trust_id=loaded.get('trust_id') + trust_id=loaded.get('trust_id'), + app_cred_id=loaded.get('app_cred_id'), + access_token_id=loaded.get('access_token_id') ) # validate the signature @@ -107,8 +138,35 @@ class ResourceBase(ks_flask.ResourceBase): ks_exceptions.Unauthorized(e), sys.exc_info()[2]) - roles = PROVIDERS.assignment_api.get_roles_for_user_and_project( - user_ref['id'], project_ref['id']) + self._check_timestamp(credentials) + + trustee_user_id = None + auth_context = None + if cred_data['trust_id']: + trust = PROVIDERS.trust_api.get_trust(cred_data['trust_id']) + roles = [r['id'] for r in trust['roles']] + # NOTE(cmurphy): if this credential was created using a + # trust-scoped token with impersonation, the user_id will be for + # the trustor, not the trustee. In this case, issuing a + # trust-scoped token to the trustor will fail. In order to get a + # trust-scoped token, use the user ID of the trustee. With + # impersonation, the resulting token will still be for the trustor. + # Without impersonation, the token will be for the trustee. + if trust['impersonation'] is True: + trustee_user_id = trust['trustee_user_id'] + elif cred_data['app_cred_id']: + ac_client = PROVIDERS.application_credential_api + app_cred = ac_client.get_application_credential( + cred_data['app_cred_id']) + roles = [r['id'] for r in app_cred['roles']] + elif cred_data['access_token_id']: + access_token = PROVIDERS.oauth_api.get_access_token( + cred_data['access_token_id']) + roles = jsonutils.loads(access_token['role_ids']) + auth_context = {'access_token_id': cred_data['access_token_id']} + else: + roles = PROVIDERS.assignment_api.get_roles_for_user_and_project( + user_ref['id'], project_ref['id']) if not roles: raise ks_exceptions.Unauthorized(_('User not valid for project.')) @@ -119,7 +177,14 @@ class ResourceBase(ks_flask.ResourceBase): method_names = ['ec2credential'] + if trustee_user_id: + user_id = trustee_user_id + else: + user_id = user_ref['id'] token = PROVIDERS.token_provider_api.issue_token( - user_id=user_ref['id'], method_names=method_names, - project_id=project_ref['id']) + user_id=user_id, method_names=method_names, + project_id=project_ref['id'], + trust_id=cred_data['trust_id'], + app_cred_id=cred_data['app_cred_id'], + auth_context=auth_context) return token diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py index 08a492c1d..4ff11ec7b 100644 --- a/keystone/api/credentials.py +++ b/keystone/api/credentials.py @@ -13,6 +13,7 @@ # This file handles all flask-restful resources for /v3/credentials import hashlib +import six import flask from oslo_serialization import jsonutils @@ -60,30 +61,41 @@ class CredentialResource(ks_flask.ResourceBase): ref['blob'] = jsonutils.dumps(blob) return ref - def _assign_unique_id(self, ref, trust_id=None): + def _validate_blob_json(self, ref): + try: + blob = jsonutils.loads(ref.get('blob')) + except (ValueError, TabError): + raise exception.ValidationError( + message=_('Invalid blob in credential')) + if not blob or not isinstance(blob, dict): + raise exception.ValidationError(attribute='blob', + target='credential') + if blob.get('access') is None: + raise exception.ValidationError(attribute='access', + target='credential') + return blob + + def _assign_unique_id( + self, ref, trust_id=None, app_cred_id=None, access_token_id=None): # Generates an assigns a unique identifier to a credential reference. if ref.get('type', '').lower() == 'ec2': - try: - blob = jsonutils.loads(ref.get('blob')) - except (ValueError, TabError): - raise exception.ValidationError( - message=_('Invalid blob in credential')) - if not blob or not isinstance(blob, dict): - raise exception.ValidationError(attribute='blob', - target='credential') - if blob.get('access') is None: - raise exception.ValidationError(attribute='access', - target='credential') - + blob = self._validate_blob_json(ref) ref = ref.copy() ref['id'] = hashlib.sha256( blob['access'].encode('utf8')).hexdigest() - # update the blob with the trust_id, so credentials created with - # a trust scoped token will result in trust scoped tokens when - # authentication via ec2tokens happens + # update the blob with the trust_id or app_cred_id, so credentials + # created with a trust- or app cred-scoped token will result in + # trust- or app cred-scoped tokens when authentication via + # ec2tokens happens if trust_id is not None: blob['trust_id'] = trust_id ref['blob'] = jsonutils.dumps(blob) + if app_cred_id is not None: + blob['app_cred_id'] = app_cred_id + ref['blob'] = jsonutils.dumps(blob) + if access_token_id is not None: + blob['access_token_id'] = access_token_id + ref['blob'] = jsonutils.dumps(blob) return ref else: return super(CredentialResource, self)._assign_unique_id(ref) @@ -146,22 +158,47 @@ class CredentialResource(ks_flask.ResourceBase): ) validation.lazy_validate(schema.credential_create, credential) trust_id = getattr(self.oslo_context, 'trust_id', None) + app_cred_id = getattr( + self.auth_context['token'], 'application_credential_id', None) + access_token_id = getattr( + self.auth_context['token'], 'access_token_id', None) ref = self._assign_unique_id( - self._normalize_dict(credential), trust_id=trust_id) - ref = PROVIDERS.credential_api.create_credential(ref['id'], ref) + self._normalize_dict(credential), + trust_id=trust_id, app_cred_id=app_cred_id, + access_token_id=access_token_id) + ref = PROVIDERS.credential_api.create_credential( + ref['id'], ref, initiator=self.audit_initiator) return self.wrap_member(ref), http_client.CREATED + def _validate_blob_update_keys(self, credential, ref): + if credential.get('type', '').lower() == 'ec2': + new_blob = self._validate_blob_json(ref) + old_blob = credential.get('blob') + if isinstance(old_blob, six.string_types): + old_blob = jsonutils.loads(old_blob) + # if there was a scope set, prevent changing it or unsetting it + for key in ['trust_id', 'app_cred_id', 'access_token_id']: + if old_blob.get(key) != new_blob.get(key): + message = _('%s can not be updated for credential') % key + raise exception.ValidationError(message=message) + def patch(self, credential_id): # Update Credential ENFORCER.enforce_call( action='identity:update_credential', build_target=_build_target_enforcement ) - PROVIDERS.credential_api.get_credential(credential_id) + current = PROVIDERS.credential_api.get_credential(credential_id) credential = self.request_body_json.get('credential', {}) validation.lazy_validate(schema.credential_update, credential) + self._validate_blob_update_keys(current.copy(), credential.copy()) self._require_matching_id(credential) + # Check that the user hasn't illegally modified the owner or scope + target = {'credential': dict(current, **credential)} + ENFORCER.enforce_call( + action='identity:update_credential', target_attr=target + ) ref = PROVIDERS.credential_api.update_credential( credential_id, credential) return self.wrap_member(ref) @@ -173,7 +210,8 @@ class CredentialResource(ks_flask.ResourceBase): build_target=_build_target_enforcement ) - return (PROVIDERS.credential_api.delete_credential(credential_id), + return (PROVIDERS.credential_api.delete_credential(credential_id, + initiator=self.audit_initiator), http_client.NO_CONTENT) diff --git a/keystone/api/users.py b/keystone/api/users.py index 97626da9b..117ace2d3 100644 --- a/keystone/api/users.py +++ b/keystone/api/users.py @@ -568,6 +568,25 @@ class UserAppCredListCreateResource(ks_flask.ResourceBase): role['name'])) return roles + def _get_roles(self, app_cred_data, token): + if app_cred_data.get('roles'): + roles = self._normalize_role_list(app_cred_data['roles']) + # NOTE(cmurphy): The user is not allowed to add a role that is not + # in their token. This is to prevent trustees or application + # credential users from escallating their privileges to include + # additional roles that the trustor or application credential + # creator has assigned on the project. + token_roles = [r['id'] for r in token.roles] + for role in roles: + if role['id'] not in token_roles: + detail = _('Cannot create an application credential with ' + 'unassigned role') + raise ks_exception.ApplicationCredentialValidationError( + detail=detail) + else: + roles = token.roles + return roles + def get(self, user_id): """List application credentials for user. @@ -603,8 +622,7 @@ class UserAppCredListCreateResource(ks_flask.ResourceBase): app_cred_data['secret'] = self._generate_secret() app_cred_data['user_id'] = user_id app_cred_data['project_id'] = project_id - app_cred_data['roles'] = self._normalize_role_list( - app_cred_data.get('roles', token.roles)) + app_cred_data['roles'] = self._get_roles(app_cred_data, token) if app_cred_data.get('expires_at'): app_cred_data['expires_at'] = utils.parse_expiration_date( app_cred_data['expires_at']) diff --git a/keystone/assignment/backends/sql.py b/keystone/assignment/backends/sql.py index 6822811ca..5eda2b724 100644 --- a/keystone/assignment/backends/sql.py +++ b/keystone/assignment/backends/sql.py @@ -262,6 +262,11 @@ class Assignment(base.AssignmentDriverBase): q = q.filter_by(role_id=role_id) q.delete(False) + with sql.session_for_write() as session: + q = session.query(SystemRoleAssignment) + q = q.filter_by(role_id=role_id) + q.delete(False) + def delete_domain_assignments(self, domain_id): with sql.session_for_write() as session: q = session.query(RoleAssignment) diff --git a/keystone/common/policies/identity_provider.py b/keystone/common/policies/identity_provider.py index fb9fe75d0..006a6cc70 100644 --- a/keystone/common/policies/identity_provider.py +++ b/keystone/common/policies/identity_provider.py @@ -16,7 +16,7 @@ from oslo_policy import policy from keystone.common.policies import base deprecated_get_idp = policy.DeprecatedRule( - name=base.IDENTITY % 'get_identity_providers', + name=base.IDENTITY % 'get_identity_provider', check_str=base.RULE_ADMIN_REQUIRED ) deprecated_list_idp = policy.DeprecatedRule( @@ -24,15 +24,15 @@ deprecated_list_idp = policy.DeprecatedRule( check_str=base.RULE_ADMIN_REQUIRED ) deprecated_update_idp = policy.DeprecatedRule( - name=base.IDENTITY % 'update_identity_providers', + name=base.IDENTITY % 'update_identity_provider', check_str=base.RULE_ADMIN_REQUIRED ) deprecated_create_idp = policy.DeprecatedRule( - name=base.IDENTITY % 'create_identity_providers', + name=base.IDENTITY % 'create_identity_provider', check_str=base.RULE_ADMIN_REQUIRED ) deprecated_delete_idp = policy.DeprecatedRule( - name=base.IDENTITY % 'delete_identity_providers', + name=base.IDENTITY % 'delete_identity_provider', check_str=base.RULE_ADMIN_REQUIRED ) diff --git a/keystone/conf/credential.py b/keystone/conf/credential.py index b7877a816..048d22283 100644 --- a/keystone/conf/credential.py +++ b/keystone/conf/credential.py @@ -46,12 +46,21 @@ share this repository with the repository used to manage keys for Fernet tokens. """)) +auth_ttl = cfg.IntOpt( + 'auth_ttl', + default=15, + help=utils.fmt(""" +The length of time in minutes for which a signed EC2 or S3 token request is +valid from the timestamp contained in the token request. +""")) + GROUP_NAME = __name__.split('.')[-1] ALL_OPTS = [ driver, provider, - key_repository + key_repository, + auth_ttl ] diff --git a/keystone/credential/core.py b/keystone/credential/core.py index cb28b314e..d6c48ff16 100644 --- a/keystone/credential/core.py +++ b/keystone/credential/core.py @@ -21,6 +21,7 @@ from keystone.common import manager from keystone.common import provider_api import keystone.conf from keystone import exception +from keystone import notifications CONF = keystone.conf.CONF @@ -38,6 +39,8 @@ class Manager(manager.Manager): driver_namespace = 'keystone.credential' _provides_api = 'credential_api' + _CRED = 'credential' + def __init__(self): super(Manager, self).__init__(CONF.credential.driver) @@ -102,13 +105,18 @@ class Manager(manager.Manager): credential = self.driver.get_credential(credential_id) return self._decrypt_credential(credential) - def create_credential(self, credential_id, credential): + def create_credential(self, credential_id, credential, + initiator=None): """Create a credential.""" credential_copy = self._encrypt_credential(credential) ref = self.driver.create_credential(credential_id, credential_copy) ref.pop('key_hash', None) ref.pop('encrypted_blob', None) ref['blob'] = credential['blob'] + notifications.Audit.created( + self._CRED, + credential_id, + initiator) return ref def _validate_credential_update(self, credential_id, credential): @@ -143,3 +151,10 @@ class Manager(manager.Manager): else: ref['blob'] = existing_blob return ref + + def delete_credential(self, credential_id, + initiator=None): + """Delete a credential.""" + self.driver.delete_credential(credential_id) + notifications.Audit.deleted( + self._CRED, credential_id, initiator) diff --git a/keystone/identity/backends/ldap/common.py b/keystone/identity/backends/ldap/common.py index b9becea74..f2f36718f 100644 --- a/keystone/identity/backends/ldap/common.py +++ b/keystone/identity/backends/ldap/common.py @@ -18,6 +18,7 @@ import functools import os.path import re import sys +import uuid import weakref import ldap.controls @@ -95,7 +96,16 @@ def utf8_decode(value): :raises UnicodeDecodeError: for invalid UTF-8 encoding """ if isinstance(value, six.binary_type): - return _utf8_decoder(value)[0] + try: + return _utf8_decoder(value)[0] + except UnicodeDecodeError: + # NOTE(lbragstad): We could be dealing with a UUID in byte form, + # which some LDAP implementations use. + uuid_byte_string_length = 16 + if len(value) == uuid_byte_string_length: + return six.text_type(uuid.UUID(bytes_le=value)) + else: + raise return six.text_type(value) @@ -1781,6 +1791,7 @@ class EnabledEmuMixIn(BaseLdap): DEFAULT_GROUP_OBJECTCLASS = 'groupOfNames' DEFAULT_MEMBER_ATTRIBUTE = 'member' + DEFAULT_GROUP_MEMBERS_ARE_IDS = False def __init__(self, conf): super(EnabledEmuMixIn, self).__init__(conf) @@ -1797,9 +1808,11 @@ class EnabledEmuMixIn(BaseLdap): if not self.use_group_config: self.member_attribute = self.DEFAULT_MEMBER_ATTRIBUTE self.group_objectclass = self.DEFAULT_GROUP_OBJECTCLASS + self.group_members_are_ids = self.DEFAULT_GROUP_MEMBERS_ARE_IDS else: self.member_attribute = conf.ldap.group_member_attribute self.group_objectclass = conf.ldap.group_objectclass + self.group_members_are_ids = conf.ldap.group_members_are_ids if not self.enabled_emulation_dn: naming_attr_name = 'cn' @@ -1815,10 +1828,19 @@ class EnabledEmuMixIn(BaseLdap): naming_rdn[1]) self.enabled_emulation_naming_attr = naming_attr - def _get_enabled(self, object_id, conn): - dn = self._id_to_dn(object_id) + def _id_to_member_attribute_value(self, object_id): + """Convert id to value expected by member_attribute.""" + if self.group_members_are_ids: + return object_id + return self._id_to_dn(object_id) + + def _is_id_enabled(self, object_id, conn): + member_attr_val = self._id_to_member_attribute_value(object_id) + return self._is_member_enabled(member_attr_val, conn) + + def _is_member_enabled(self, member_attr_val, conn): query = '(%s=%s)' % (self.member_attribute, - ldap.filter.escape_filter_chars(dn)) + ldap.filter.escape_filter_chars(member_attr_val)) try: enabled_value = conn.search_s(self.enabled_emulation_dn, ldap.SCOPE_BASE, @@ -1829,24 +1851,26 @@ class EnabledEmuMixIn(BaseLdap): return bool(enabled_value) def _add_enabled(self, object_id): + member_attr_val = self._id_to_member_attribute_value(object_id) with self.get_connection() as conn: - if not self._get_enabled(object_id, conn): + if not self._is_member_enabled(member_attr_val, conn): modlist = [(ldap.MOD_ADD, self.member_attribute, - [self._id_to_dn(object_id)])] + [member_attr_val])] try: conn.modify_s(self.enabled_emulation_dn, modlist) except ldap.NO_SUCH_OBJECT: attr_list = [('objectClass', [self.group_objectclass]), (self.member_attribute, - [self._id_to_dn(object_id)]), + [member_attr_val]), self.enabled_emulation_naming_attr] conn.add_s(self.enabled_emulation_dn, attr_list) def _remove_enabled(self, object_id): + member_attr_val = self._id_to_member_attribute_value(object_id) modlist = [(ldap.MOD_DELETE, self.member_attribute, - [self._id_to_dn(object_id)])] + [member_attr_val])] with self.get_connection() as conn: try: conn.modify_s(self.enabled_emulation_dn, modlist) @@ -1871,7 +1895,7 @@ class EnabledEmuMixIn(BaseLdap): ref = super(EnabledEmuMixIn, self).get(object_id, ldap_filter) if ('enabled' not in self.attribute_ignore and self.enabled_emulation): - ref['enabled'] = self._get_enabled(object_id, conn) + ref['enabled'] = self._is_id_enabled(object_id, conn) return ref def get_all(self, ldap_filter=None, hints=None): @@ -1883,7 +1907,7 @@ class EnabledEmuMixIn(BaseLdap): if x[0] != self.enabled_emulation_dn] with self.get_connection() as conn: for obj_ref in obj_list: - obj_ref['enabled'] = self._get_enabled( + obj_ref['enabled'] = self._is_id_enabled( obj_ref['id'], conn) return obj_list else: diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index eed349630..5a97c7fd5 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -32,6 +32,10 @@ from keystone.identity.backends import sql_model as model CONF = keystone.conf.CONF +def _stale_data_exception_checker(exc): + return isinstance(exc, sqlalchemy.orm.exc.StaleDataError) + + class Identity(base.IdentityDriverBase): # NOTE(henry-nash): Override the __init__() method so as to take a # config parameter to enable sql to be used as a domain-specific driver. @@ -201,6 +205,10 @@ class Identity(base.IdentityDriverBase): return base.filter_user(user_ref.to_dict()) @sql.handle_conflicts(conflict_type='user') + # Explicitly retry on StaleDataErrors, which can happen if two clients + # update the same user's password and the second client has stale password + # information. + @oslo_db_api.wrap_db_retry(exception_checker=_stale_data_exception_checker) def update_user(self, user_id, user): with sql.session_for_write() as session: user_ref = self._get_user(session, user_id) diff --git a/keystone/models/token_model.py b/keystone/models/token_model.py index 7f190286a..413545b53 100644 --- a/keystone/models/token_model.py +++ b/keystone/models/token_model.py @@ -13,6 +13,7 @@ """Unified in-memory token model.""" from oslo_log import log +from oslo_serialization import jsonutils from oslo_serialization import msgpackutils from oslo_utils import reflection import six @@ -325,6 +326,21 @@ class TokenModel(object): return roles + def _get_oauth_roles(self): + roles = [] + access_token_roles = self.access_token['role_ids'] + access_token_roles = [ + {'role_id': r} for r in jsonutils.loads(access_token_roles)] + effective_access_token_roles = ( + PROVIDERS.assignment_api.add_implied_roles(access_token_roles) + ) + user_roles = [r['id'] for r in self._get_project_roles()] + for role in effective_access_token_roles: + if role['role_id'] in user_roles: + role = PROVIDERS.role_api.get_role(role['role_id']) + roles.append({'id': role['id'], 'name': role['name']}) + return roles + def _get_federated_roles(self): roles = [] group_ids = [group['id'] for group in self.federated_groups] @@ -428,6 +444,8 @@ class TokenModel(object): roles = self._get_system_roles() elif self.trust_scoped: roles = self._get_trust_roles() + elif self.oauth_scoped: + roles = self._get_oauth_roles() elif self.is_federated and not self.unscoped: roles = self._get_federated_roles() elif self.domain_scoped: diff --git a/keystone/tests/unit/assignment/test_backends.py b/keystone/tests/unit/assignment/test_backends.py index 589256eff..181c42a54 100644 --- a/keystone/tests/unit/assignment/test_backends.py +++ b/keystone/tests/unit/assignment/test_backends.py @@ -4226,3 +4226,22 @@ class SystemAssignmentTests(AssignmentTestHelperMixin): group_id, role['id'] ) + + def test_delete_role_with_system_assignments(self): + role = unit.new_role_ref() + PROVIDERS.role_api.create_role(role['id'], role) + domain = unit.new_domain_ref() + PROVIDERS.resource_api.create_domain(domain['id'], domain) + user = unit.new_user_ref(domain_id=domain['id']) + user = PROVIDERS.identity_api.create_user(user) + + # creating a system grant for user + PROVIDERS.assignment_api.create_system_grant_for_user( + user['id'], role['id'] + ) + # deleting the role user has on system + PROVIDERS.role_api.delete_role(role['id']) + system_roles = PROVIDERS.assignment_api.list_role_assignments( + role_id=role['id'] + ) + self.assertEqual(len(system_roles), 0) diff --git a/keystone/tests/unit/identity/backends/test_ldap_common.py b/keystone/tests/unit/identity/backends/test_ldap_common.py index e464a8a14..2a0d9ab28 100644 --- a/keystone/tests/unit/identity/backends/test_ldap_common.py +++ b/keystone/tests/unit/identity/backends/test_ldap_common.py @@ -520,6 +520,20 @@ class CommonLdapTestCase(unit.BaseTestCase): # The user name should still be a string value. self.assertEqual(user_name, py_result[0][1]['user_name'][0]) + def test_user_id_attribute_is_uuid_in_byte_form(self): + results = [( + 'cn=alice,dc=example,dc=com', + { + 'cn': [b'cn=alice'], + 'objectGUID': [b'\xdd\xd8Rt\xee]bA\x8e(\xe39\x0b\xe1\xf8\xe8'], + 'email': [uuid.uuid4().hex], + 'sn': [uuid.uuid4().hex] + } + )] + py_result = common_ldap.convert_ldap_result(results) + exp_object_guid = '7452d8dd-5dee-4162-8e28-e3390be1f8e8' + self.assertEqual(exp_object_guid, py_result[0][1]['objectGUID'][0]) + class LDAPFilterQueryCompositionTest(unit.BaseTestCase): """These test cases test LDAP filter generation.""" diff --git a/keystone/tests/unit/test_backend_ldap.py b/keystone/tests/unit/test_backend_ldap.py index aa7a50747..48fea00ec 100644 --- a/keystone/tests/unit/test_backend_ldap.py +++ b/keystone/tests/unit/test_backend_ldap.py @@ -2046,9 +2046,17 @@ class LDAPIdentityEnabledEmulation(LDAPIdentity, unit.TestCase): "Enabled emulation conflicts with enabled mask") def test_user_enabled_use_group_config(self): + # Establish enabled-emulation group name to later query its members + group_name = 'enabled_users' + driver = PROVIDERS.identity_api._select_identity_driver( + CONF.identity.default_domain_id) + group_dn = 'cn=%s,%s' % (group_name, driver.group.tree_dn) + self.config_fixture.config( group='ldap', user_enabled_emulation_use_group_config=True, + user_enabled_emulation_dn=group_dn, + group_name_attribute='cn', group_member_attribute='uniqueMember', group_objectclass='groupOfUniqueNames') self.ldapdb.clear() @@ -2064,6 +2072,46 @@ class LDAPIdentityEnabledEmulation(LDAPIdentity, unit.TestCase): user_ref = PROVIDERS.identity_api.get_user(user_ref['id']) self.assertIs(True, user_ref['enabled']) + # Ensure state matches the group config + group_ref = PROVIDERS.identity_api.get_group_by_name( + group_name, CONF.identity.default_domain_id) + PROVIDERS.identity_api.check_user_in_group( + user_ref['id'], group_ref['id']) + + def test_user_enabled_use_group_config_with_ids(self): + # Establish enabled-emulation group name to later query its members + group_name = 'enabled_users' + driver = PROVIDERS.identity_api._select_identity_driver( + CONF.identity.default_domain_id) + group_dn = 'cn=%s,%s' % (group_name, driver.group.tree_dn) + + self.config_fixture.config( + group='ldap', + user_enabled_emulation_use_group_config=True, + user_enabled_emulation_dn=group_dn, + group_name_attribute='cn', + group_member_attribute='memberUid', + group_members_are_ids=True, + group_objectclass='posixGroup') + self.ldapdb.clear() + self.load_backends() + + # Create a user and ensure they are enabled. + user1 = unit.new_user_ref(enabled=True, + domain_id=CONF.identity.default_domain_id) + user_ref = PROVIDERS.identity_api.create_user(user1) + self.assertIs(True, user_ref['enabled']) + + # Get a user and ensure they are enabled. + user_ref = PROVIDERS.identity_api.get_user(user_ref['id']) + self.assertIs(True, user_ref['enabled']) + + # Ensure state matches the group config + group_ref = PROVIDERS.identity_api.get_group_by_name( + group_name, CONF.identity.default_domain_id) + PROVIDERS.identity_api.check_user_in_group( + user_ref['id'], group_ref['id']) + def test_user_enabled_invert(self): self.config_fixture.config(group='ldap', user_enabled_invert=True, user_enabled_default='False') @@ -2157,7 +2205,7 @@ class LDAPIdentityEnabledEmulation(LDAPIdentity, unit.TestCase): # Override the tree_dn, it's used to build the enabled member filter mixin_impl.tree_dn = sample_dn - # The filter that _get_enabled is going to build contains the + # The filter, which _is_id_enabled is going to build, contains the # tree_dn, which better be escaped in this case. exp_filter = '(%s=%s=%s,%s)' % ( mixin_impl.member_attribute, mixin_impl.id_attr, object_id, @@ -2166,7 +2214,7 @@ class LDAPIdentityEnabledEmulation(LDAPIdentity, unit.TestCase): with mixin_impl.get_connection() as conn: m = self.useFixture( fixtures.MockPatchObject(conn, 'search_s')).mock - mixin_impl._get_enabled(object_id, conn) + mixin_impl._is_id_enabled(object_id, conn) # The 3rd argument is the DN. self.assertEqual(exp_filter, m.call_args[0][2]) diff --git a/keystone/tests/unit/test_backend_sql.py b/keystone/tests/unit/test_backend_sql.py index d165eb8c5..0412c6432 100644 --- a/keystone/tests/unit/test_backend_sql.py +++ b/keystone/tests/unit/test_backend_sql.py @@ -14,9 +14,11 @@ import uuid +import fixtures import mock from oslo_db import exception as db_exception from oslo_db import options +from oslo_log import log from six.moves import range import sqlalchemy from sqlalchemy import exc @@ -745,6 +747,41 @@ class SqlIdentity(SqlTests, PROVIDERS.resource_api.check_project_depth, 2) + def test_update_user_with_stale_data_forces_retry(self): + # Capture log output so we know oslo.db attempted a retry + log_fixture = self.useFixture(fixtures.FakeLogger(level=log.DEBUG)) + + # Create a new user + user_dict = unit.new_user_ref( + domain_id=CONF.identity.default_domain_id) + new_user_dict = PROVIDERS.identity_api.create_user(user_dict) + + side_effects = [ + # Raise a StaleDataError simulating that another client has + # updated the user's password while this client's request was + # being processed + sqlalchemy.orm.exc.StaleDataError, + # The oslo.db library will retry the request, so the second + # time this method is called let's return a valid session + # object + sql.session_for_write() + ] + with mock.patch('keystone.common.sql.session_for_write') as m: + m.side_effect = side_effects + + # Update a user's attribute, the first attempt will fail but + # oslo.db will handle the exception and retry, the second attempt + # will succeed + new_user_dict['email'] = uuid.uuid4().hex + PROVIDERS.identity_api.update_user( + new_user_dict['id'], new_user_dict) + + # Make sure oslo.db retried the update by checking the log output + expected_log_message = ( + 'Performing DB retry for function keystone.identity.backends.sql' + ) + self.assertIn(expected_log_message, log_fixture.output) + class SqlTrust(SqlTests, trust_tests.TrustTests): diff --git a/keystone/tests/unit/test_contrib_ec2_core.py b/keystone/tests/unit/test_contrib_ec2_core.py index e6cd96beb..967a41c01 100644 --- a/keystone/tests/unit/test_contrib_ec2_core.py +++ b/keystone/tests/unit/test_contrib_ec2_core.py @@ -12,10 +12,15 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime +import hashlib + from keystoneclient.contrib.ec2 import utils as ec2_utils +from oslo_utils import timeutils from six.moves import http_client from keystone.common import provider_api +from keystone.common import utils from keystone.tests import unit from keystone.tests.unit import test_v3 @@ -34,6 +39,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): def test_valid_authentication_response_with_proper_secret(self): signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + timestamp = utils.isotime(timeutils.utcnow()) credentials = { 'access': self.cred_blob['access'], 'secret': self.cred_blob['secret'], @@ -43,7 +49,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp }, } credentials['signature'] = signer.generate(credentials) @@ -53,6 +59,48 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): expected_status=http_client.OK) self.assertValidProjectScopedTokenResponse(resp, self.user) + def test_valid_authentication_response_with_signature_v4(self): + signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + timestamp = utils.isotime(timeutils.utcnow()) + hashed_payload = ( + 'GET\n' + '/\n' + 'Action=Test\n' + 'host:localhost\n' + 'x-amz-date:' + timestamp + '\n' + '\n' + 'host;x-amz-date\n' + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ) + body_hash = hashlib.sha256(hashed_payload.encode()).hexdigest() + amz_credential = ( + 'AKIAIOSFODNN7EXAMPLE/%s/us-east-1/iam/aws4_request,' % + timestamp[:8]) + + credentials = { + 'access': self.cred_blob['access'], + 'secret': self.cred_blob['secret'], + 'host': 'localhost', + 'verb': 'GET', + 'path': '/', + 'params': { + 'Action': 'Test', + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-SignedHeaders': 'host,x-amz-date,', + 'X-Amz-Credential': amz_credential + }, + 'headers': { + 'X-Amz-Date': timestamp + }, + 'body_hash': body_hash + } + credentials['signature'] = signer.generate(credentials) + resp = self.post( + '/ec2tokens', + body={'credentials': credentials}, + expected_status=http_client.OK) + self.assertValidProjectScopedTokenResponse(resp, self.user) + def test_authenticate_with_empty_body_returns_bad_request(self): self.post( '/ec2tokens', @@ -72,6 +120,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): def test_authenticate_without_proper_secret_returns_unauthorized(self): signer = ec2_utils.Ec2Signer('totally not the secret') + timestamp = utils.isotime(timeutils.utcnow()) credentials = { 'access': self.cred_blob['access'], 'secret': 'totally not the secret', @@ -81,8 +130,80 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp + }, + } + credentials['signature'] = signer.generate(credentials) + self.post( + '/ec2tokens', + body={'credentials': credentials}, + expected_status=http_client.UNAUTHORIZED) + + def test_authenticate_expired_request(self): + self.config_fixture.config( + group='credential', + auth_ttl=5 + ) + signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + past = timeutils.utcnow() - datetime.timedelta(minutes=10) + timestamp = utils.isotime(past) + credentials = { + 'access': self.cred_blob['access'], + 'secret': self.cred_blob['secret'], + 'host': 'localhost', + 'verb': 'GET', + 'path': '/', + 'params': { + 'SignatureVersion': '2', + 'Action': 'Test', + 'Timestamp': timestamp + }, + } + credentials['signature'] = signer.generate(credentials) + self.post( + '/ec2tokens', + body={'credentials': credentials}, + expected_status=http_client.UNAUTHORIZED) + + def test_authenticate_expired_request_v4(self): + self.config_fixture.config( + group='credential', + auth_ttl=5 + ) + signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + past = timeutils.utcnow() - datetime.timedelta(minutes=10) + timestamp = utils.isotime(past) + hashed_payload = ( + 'GET\n' + '/\n' + 'Action=Test\n' + 'host:localhost\n' + 'x-amz-date:' + timestamp + '\n' + '\n' + 'host;x-amz-date\n' + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ) + body_hash = hashlib.sha256(hashed_payload.encode()).hexdigest() + amz_credential = ( + 'AKIAIOSFODNN7EXAMPLE/%s/us-east-1/iam/aws4_request,' % + timestamp[:8]) + + credentials = { + 'access': self.cred_blob['access'], + 'secret': self.cred_blob['secret'], + 'host': 'localhost', + 'verb': 'GET', + 'path': '/', + 'params': { + 'Action': 'Test', + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-SignedHeaders': 'host,x-amz-date,', + 'X-Amz-Credential': amz_credential + }, + 'headers': { + 'X-Amz-Date': timestamp }, + 'body_hash': body_hash } credentials['signature'] = signer.generate(credentials) self.post( diff --git a/keystone/tests/unit/test_v3_application_credential.py b/keystone/tests/unit/test_v3_application_credential.py index 4e8899e56..017031873 100644 --- a/keystone/tests/unit/test_v3_application_credential.py +++ b/keystone/tests/unit/test_v3_application_credential.py @@ -166,6 +166,37 @@ class ApplicationCredentialTestCase(test_v3.RestfulTestCase): expected_status_code=http_client.FORBIDDEN, headers={'X-Auth-Token': token}) + def test_create_application_credential_with_trust(self): + second_role = unit.new_role_ref(name='reader') + PROVIDERS.role_api.create_role(second_role['id'], second_role) + PROVIDERS.assignment_api.add_role_to_user_and_project( + self.user_id, self.project_id, second_role['id']) + with self.test_client() as c: + pw_token = self.get_scoped_token() + # create a self-trust - only the roles are important for this test + trust_ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.user_id, + project_id=self.project_id, + role_ids=[second_role['id']]) + resp = c.post('/v3/OS-TRUST/trusts', + headers={'X-Auth-Token': pw_token}, + json={'trust': trust_ref}) + trust_id = resp.json['trust']['id'] + trust_auth = self.build_authentication_request( + user_id=self.user_id, + password=self.user['password'], + trust_id=trust_id) + trust_token = self.v3_create_token( + trust_auth).headers['X-Subject-Token'] + app_cred = self._app_cred_body(roles=[{'id': self.role_id}]) + # only the roles from the trust token should be allowed, even if + # the user has the role assigned on the project + c.post('/v3/users/%s/application_credentials' % self.user_id, + headers={'X-Auth-Token': trust_token}, + json=app_cred, + expected_status_code=http_client.BAD_REQUEST) + def test_create_application_credential_allow_recursion(self): with self.test_client() as c: roles = [{'id': self.role_id}] diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 2538c3a10..b9a2f482d 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -19,7 +19,7 @@ import uuid from keystoneclient.contrib.ec2 import utils as ec2_utils import mock from oslo_db import exception as oslo_db_exception -from six.moves import http_client +from six.moves import http_client, urllib from testtools import matchers from keystone.api import ec2tokens @@ -27,6 +27,7 @@ from keystone.common import provider_api from keystone.common import utils from keystone.credential.providers import fernet as credential_fernet from keystone import exception +from keystone import oauth1 from keystone.tests import unit from keystone.tests.unit import ksfixtures from keystone.tests.unit import test_v3 @@ -63,6 +64,33 @@ class CredentialBaseTestCase(test_v3.RestfulTestCase): return json.dumps(blob), credential_id + def _test_get_token(self, access, secret): + """Test signature validation with the access/secret provided.""" + signer = ec2_utils.Ec2Signer(secret) + params = {'SignatureMethod': 'HmacSHA256', + 'SignatureVersion': '2', + 'AWSAccessKeyId': access} + request = {'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params} + signature = signer.generate(request) + + # Now make a request to validate the signed dummy request via the + # ec2tokens API. This proves the v3 ec2 credentials actually work. + sig_ref = {'access': access, + 'signature': signature, + 'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params} + r = self.post( + '/ec2tokens', + body={'ec2Credentials': sig_ref}, + expected_status=http_client.OK) + self.assertValidTokenResponse(r) + return r.result['token'] + class CredentialTestCase(CredentialBaseTestCase): """Test credential CRUD.""" @@ -258,6 +286,126 @@ class CredentialTestCase(CredentialBaseTestCase): 'credential_id': credential_id}, body={'credential': update_ref}) + def test_update_credential_non_owner(self): + """Call ``PATCH /credentials/{credential_id}``.""" + alt_user = unit.create_user( + PROVIDERS.identity_api, domain_id=self.domain_id) + alt_user_id = alt_user['id'] + alt_project = unit.new_project_ref(domain_id=self.domain_id) + alt_project_id = alt_project['id'] + PROVIDERS.resource_api.create_project( + alt_project['id'], alt_project) + alt_role = unit.new_role_ref(name='reader') + alt_role_id = alt_role['id'] + PROVIDERS.role_api.create_role(alt_role_id, alt_role) + PROVIDERS.assignment_api.add_role_to_user_and_project( + alt_user_id, alt_project_id, alt_role_id) + auth = self.build_authentication_request( + user_id=alt_user_id, + password=alt_user['password'], + project_id=alt_project_id) + ref = unit.new_credential_ref(user_id=alt_user_id, + project_id=alt_project_id) + r = self.post( + '/credentials', + auth=auth, + body={'credential': ref}) + self.assertValidCredentialResponse(r, ref) + credential_id = r.result.get('credential')['id'] + + # Cannot change the credential to be owned by another user + update_ref = {'user_id': self.user_id, 'project_id': self.project_id} + self.patch( + '/credentials/%(credential_id)s' % { + 'credential_id': credential_id}, + expected_status=403, + auth=auth, + body={'credential': update_ref}) + + def test_update_ec2_credential_change_trust_id(self): + """Call ``PATCH /credentials/{credential_id}``.""" + blob, ref = unit.new_ec2_credential(user_id=self.user['id'], + project_id=self.project_id) + blob['trust_id'] = uuid.uuid4().hex + ref['blob'] = json.dumps(blob) + r = self.post( + '/credentials', + body={'credential': ref}) + self.assertValidCredentialResponse(r, ref) + credential_id = r.result.get('credential')['id'] + # Try changing to a different trust + blob['trust_id'] = uuid.uuid4().hex + update_ref = {'blob': json.dumps(blob)} + self.patch( + '/credentials/%(credential_id)s' % { + 'credential_id': credential_id}, + body={'credential': update_ref}, + expected_status=http_client.BAD_REQUEST) + # Try removing the trust + del blob['trust_id'] + update_ref = {'blob': json.dumps(blob)} + self.patch( + '/credentials/%(credential_id)s' % { + 'credential_id': credential_id}, + body={'credential': update_ref}, + expected_status=http_client.BAD_REQUEST) + + def test_update_ec2_credential_change_app_cred_id(self): + """Call ``PATCH /credentials/{credential_id}``.""" + blob, ref = unit.new_ec2_credential(user_id=self.user['id'], + project_id=self.project_id) + blob['app_cred_id'] = uuid.uuid4().hex + ref['blob'] = json.dumps(blob) + r = self.post( + '/credentials', + body={'credential': ref}) + self.assertValidCredentialResponse(r, ref) + credential_id = r.result.get('credential')['id'] + # Try changing to a different app cred + blob['app_cred_id'] = uuid.uuid4().hex + update_ref = {'blob': json.dumps(blob)} + self.patch( + '/credentials/%(credential_id)s' % { + 'credential_id': credential_id}, + body={'credential': update_ref}, + expected_status=http_client.BAD_REQUEST) + # Try removing the app cred + del blob['app_cred_id'] + update_ref = {'blob': json.dumps(blob)} + self.patch( + '/credentials/%(credential_id)s' % { + 'credential_id': credential_id}, + body={'credential': update_ref}, + expected_status=http_client.BAD_REQUEST) + + def test_update_ec2_credential_change_access_token_id(self): + """Call ``PATCH /credentials/{credential_id}``.""" + blob, ref = unit.new_ec2_credential(user_id=self.user['id'], + project_id=self.project_id) + blob['access_token_id'] = uuid.uuid4().hex + ref['blob'] = json.dumps(blob) + r = self.post( + '/credentials', + body={'credential': ref}) + self.assertValidCredentialResponse(r, ref) + credential_id = r.result.get('credential')['id'] + # Try changing to a different access token + blob['access_token_id'] = uuid.uuid4().hex + update_ref = {'blob': json.dumps(blob)} + self.patch( + '/credentials/%(credential_id)s' % { + 'credential_id': credential_id}, + body={'credential': update_ref}, + expected_status=http_client.BAD_REQUEST) + # Try removing the access token + del blob['access_token_id'] + update_ref = {'blob': json.dumps(blob)} + self.patch( + '/credentials/%(credential_id)s' % { + 'credential_id': credential_id}, + body={'credential': update_ref}, + expected_status=http_client.BAD_REQUEST) + def test_delete_credential(self): """Call ``DELETE /credentials/{credential_id}``.""" self.delete( @@ -393,7 +541,7 @@ class CredentialTestCase(CredentialBaseTestCase): self.assertValidCredentialResponse(r, ref) -class TestCredentialTrustScoped(test_v3.RestfulTestCase): +class TestCredentialTrustScoped(CredentialBaseTestCase): """Test credential with trust scoped token.""" def setUp(self): @@ -446,7 +594,7 @@ class TestCredentialTrustScoped(test_v3.RestfulTestCase): token_id = r.headers.get('X-Subject-Token') # Create the credential with the trust scoped token - blob, ref = unit.new_ec2_credential(user_id=self.user['id'], + blob, ref = unit.new_ec2_credential(user_id=self.user_id, project_id=self.project_id) r = self.post('/credentials', body={'credential': ref}, token=token_id) @@ -463,6 +611,97 @@ class TestCredentialTrustScoped(test_v3.RestfulTestCase): self.assertEqual(hashlib.sha256(access).hexdigest(), r.result['credential']['id']) + # Create a role assignment to ensure that it is ignored and only the + # trust-delegated roles are used + role = unit.new_role_ref(name='reader') + role_id = role['id'] + PROVIDERS.role_api.create_role(role_id, role) + PROVIDERS.assignment_api.add_role_to_user_and_project( + self.user_id, self.project_id, role_id) + + ret_blob = json.loads(r.result['credential']['blob']) + ec2token = self._test_get_token( + access=ret_blob['access'], secret=ret_blob['secret']) + ec2_roles = [role['id'] for role in ec2token['roles']] + self.assertIn(self.role_id, ec2_roles) + self.assertNotIn(role_id, ec2_roles) + + # Create second ec2 credential with the same access key id and check + # for conflict. + self.post( + '/credentials', + body={'credential': ref}, + token=token_id, + expected_status=http_client.CONFLICT) + + +class TestCredentialAppCreds(CredentialBaseTestCase): + """Test credential with application credential token.""" + + def setUp(self): + super(TestCredentialAppCreds, self).setUp() + self.useFixture( + ksfixtures.KeyRepository( + self.config_fixture, + 'credential', + credential_fernet.MAX_ACTIVE_KEYS + ) + ) + + def test_app_cred_ec2_credential(self): + """Test creating ec2 credential from an application credential. + + Call ``POST /credentials``. + """ + # Create the app cred + ref = { + 'name': uuid.uuid4().hex, + 'roles': [{'id': self.role_id}] + } + r = self.post('/users/%s/application_credentials' % self.user_id, + body={'application_credential': ref}) + app_cred = r.result['application_credential'] + + # Get an application credential token + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], + secret=app_cred['secret']) + r = self.v3_create_token(auth_data) + token_id = r.headers.get('X-Subject-Token') + + # Create the credential with the app cred token + blob, ref = unit.new_ec2_credential(user_id=self.user_id, + project_id=self.project_id) + r = self.post('/credentials', body={'credential': ref}, token=token_id) + + # We expect the response blob to contain the app_cred_id + ret_ref = ref.copy() + ret_blob = blob.copy() + ret_blob['app_cred_id'] = app_cred['id'] + ret_ref['blob'] = json.dumps(ret_blob) + self.assertValidCredentialResponse(r, ref=ret_ref) + + # Assert credential id is same as hash of access key id for + # ec2 credentials + access = blob['access'].encode('utf-8') + self.assertEqual(hashlib.sha256(access).hexdigest(), + r.result['credential']['id']) + + # Create a role assignment to ensure that it is ignored and only the + # roles in the app cred are used + role = unit.new_role_ref(name='reader') + role_id = role['id'] + PROVIDERS.role_api.create_role(role_id, role) + PROVIDERS.assignment_api.add_role_to_user_and_project( + self.user_id, self.project_id, role_id) + + ret_blob = json.loads(r.result['credential']['blob']) + ec2token = self._test_get_token( + access=ret_blob['access'], secret=ret_blob['secret']) + ec2_roles = [role['id'] for role in ec2token['roles']] + self.assertIn(self.role_id, ec2_roles) + self.assertNotIn(role_id, ec2_roles) + # Create second ec2 credential with the same access key id and check # for conflict. self.post( @@ -472,34 +711,155 @@ class TestCredentialTrustScoped(test_v3.RestfulTestCase): expected_status=http_client.CONFLICT) -class TestCredentialEc2(CredentialBaseTestCase): - """Test v3 credential compatibility with ec2tokens.""" +class TestCredentialAccessToken(CredentialBaseTestCase): + """Test credential with access token.""" - def _validate_signature(self, access, secret): - """Test signature validation with the access/secret provided.""" - signer = ec2_utils.Ec2Signer(secret) - params = {'SignatureMethod': 'HmacSHA256', - 'SignatureVersion': '2', - 'AWSAccessKeyId': access} - request = {'host': 'foo', - 'verb': 'GET', - 'path': '/bar', - 'params': params} - signature = signer.generate(request) + def setUp(self): + super(TestCredentialAccessToken, self).setUp() + self.useFixture( + ksfixtures.KeyRepository( + self.config_fixture, + 'credential', + credential_fernet.MAX_ACTIVE_KEYS + ) + ) + self.base_url = 'http://localhost/v3' + + def _urllib_parse_qs_text_keys(self, content): + results = urllib.parse.parse_qs(content) + return {key.decode('utf-8'): value for key, value in results.items()} + + def _create_single_consumer(self): + endpoint = '/OS-OAUTH1/consumers' + + ref = {'description': uuid.uuid4().hex} + resp = self.post(endpoint, body={'consumer': ref}) + return resp.result['consumer'] + + def _create_request_token(self, consumer, project_id, base_url=None): + endpoint = '/OS-OAUTH1/request_token' + client = oauth1.Client(consumer['key'], + client_secret=consumer['secret'], + signature_method=oauth1.SIG_HMAC, + callback_uri="oob") + headers = {'requested_project_id': project_id} + if not base_url: + base_url = self.base_url + url, headers, body = client.sign(base_url + endpoint, + http_method='POST', + headers=headers) + return endpoint, headers + + def _create_access_token(self, consumer, token, base_url=None): + endpoint = '/OS-OAUTH1/access_token' + client = oauth1.Client(consumer['key'], + client_secret=consumer['secret'], + resource_owner_key=token.key, + resource_owner_secret=token.secret, + signature_method=oauth1.SIG_HMAC, + verifier=token.verifier) + if not base_url: + base_url = self.base_url + url, headers, body = client.sign(base_url + endpoint, + http_method='POST') + headers.update({'Content-Type': 'application/json'}) + return endpoint, headers + + def _get_oauth_token(self, consumer, token): + client = oauth1.Client(consumer['key'], + client_secret=consumer['secret'], + resource_owner_key=token.key, + resource_owner_secret=token.secret, + signature_method=oauth1.SIG_HMAC) + endpoint = '/auth/tokens' + url, headers, body = client.sign(self.base_url + endpoint, + http_method='POST') + headers.update({'Content-Type': 'application/json'}) + ref = {'auth': {'identity': {'oauth1': {}, 'methods': ['oauth1']}}} + return endpoint, headers, ref + + def _authorize_request_token(self, request_id): + if isinstance(request_id, bytes): + request_id = request_id.decode() + return '/OS-OAUTH1/authorize/%s' % (request_id) + + def _get_access_token(self): + consumer = self._create_single_consumer() + consumer_id = consumer['id'] + consumer_secret = consumer['secret'] + consumer = {'key': consumer_id, 'secret': consumer_secret} + + url, headers = self._create_request_token(consumer, self.project_id) + content = self.post( + url, headers=headers, + response_content_type='application/x-www-form-urlencoded') + credentials = self._urllib_parse_qs_text_keys(content.result) + request_key = credentials['oauth_token'][0] + request_secret = credentials['oauth_token_secret'][0] + request_token = oauth1.Token(request_key, request_secret) + + url = self._authorize_request_token(request_key) + body = {'roles': [{'id': self.role_id}]} + resp = self.put(url, body=body, expected_status=http_client.OK) + verifier = resp.result['token']['oauth_verifier'] + + request_token.set_verifier(verifier) + url, headers = self._create_access_token(consumer, request_token) + content = self.post( + url, headers=headers, + response_content_type='application/x-www-form-urlencoded') + credentials = self._urllib_parse_qs_text_keys(content.result) + access_key = credentials['oauth_token'][0] + access_secret = credentials['oauth_token_secret'][0] + access_token = oauth1.Token(access_key, access_secret) + + url, headers, body = self._get_oauth_token(consumer, access_token) + content = self.post(url, headers=headers, body=body) + return access_key, content.headers['X-Subject-Token'] + + def test_access_token_ec2_credential(self): + """Test creating ec2 credential from an oauth access token. - # Now make a request to validate the signed dummy request via the - # ec2tokens API. This proves the v3 ec2 credentials actually work. - sig_ref = {'access': access, - 'signature': signature, - 'host': 'foo', - 'verb': 'GET', - 'path': '/bar', - 'params': params} - r = self.post( - '/ec2tokens', - body={'ec2Credentials': sig_ref}, - expected_status=http_client.OK) - self.assertValidTokenResponse(r) + Call ``POST /credentials``. + """ + access_key, token_id = self._get_access_token() + + # Create the credential with the access token + blob, ref = unit.new_ec2_credential(user_id=self.user_id, + project_id=self.project_id) + r = self.post('/credentials', body={'credential': ref}, token=token_id) + + # We expect the response blob to contain the access_token_id + ret_ref = ref.copy() + ret_blob = blob.copy() + ret_blob['access_token_id'] = access_key.decode('utf-8') + ret_ref['blob'] = json.dumps(ret_blob) + self.assertValidCredentialResponse(r, ref=ret_ref) + + # Assert credential id is same as hash of access key id for + # ec2 credentials + access = blob['access'].encode('utf-8') + self.assertEqual(hashlib.sha256(access).hexdigest(), + r.result['credential']['id']) + + # Create a role assignment to ensure that it is ignored and only the + # roles in the access token are used + role = unit.new_role_ref(name='reader') + role_id = role['id'] + PROVIDERS.role_api.create_role(role_id, role) + PROVIDERS.assignment_api.add_role_to_user_and_project( + self.user_id, self.project_id, role_id) + + ret_blob = json.loads(r.result['credential']['blob']) + ec2token = self._test_get_token( + access=ret_blob['access'], secret=ret_blob['secret']) + ec2_roles = [role['id'] for role in ec2token['roles']] + self.assertIn(self.role_id, ec2_roles) + self.assertNotIn(role_id, ec2_roles) + + +class TestCredentialEc2(CredentialBaseTestCase): + """Test v3 credential compatibility with ec2tokens.""" def test_ec2_credential_signature_validate(self): """Test signature validation with a v3 ec2 credential.""" @@ -514,15 +874,15 @@ class TestCredentialEc2(CredentialBaseTestCase): cred_blob = json.loads(r.result['credential']['blob']) self.assertEqual(blob, cred_blob) - self._validate_signature(access=cred_blob['access'], - secret=cred_blob['secret']) + self._test_get_token(access=cred_blob['access'], + secret=cred_blob['secret']) def test_ec2_credential_signature_validate_legacy(self): """Test signature validation with a legacy v3 ec2 credential.""" cred_json, _ = self._create_dict_blob_credential() cred_blob = json.loads(cred_json) - self._validate_signature(access=cred_blob['access'], - secret=cred_blob['secret']) + self._test_get_token(access=cred_blob['access'], + secret=cred_blob['secret']) def _get_ec2_cred_uri(self): return '/users/%s/credentials/OS-EC2' % self.user_id @@ -538,8 +898,8 @@ class TestCredentialEc2(CredentialBaseTestCase): self.assertEqual(self.user_id, ec2_cred['user_id']) self.assertEqual(self.project_id, ec2_cred['tenant_id']) self.assertIsNone(ec2_cred['trust_id']) - self._validate_signature(access=ec2_cred['access'], - secret=ec2_cred['secret']) + self._test_get_token(access=ec2_cred['access'], + secret=ec2_cred['secret']) uri = '/'.join([self._get_ec2_cred_uri(), ec2_cred['access']]) self.assertThat(ec2_cred['links']['self'], matchers.EndsWith(uri)) diff --git a/keystone/tests/unit/test_v3_oauth1.py b/keystone/tests/unit/test_v3_oauth1.py index 90378214e..4c648a23e 100644 --- a/keystone/tests/unit/test_v3_oauth1.py +++ b/keystone/tests/unit/test_v3_oauth1.py @@ -308,6 +308,19 @@ class OAuthFlowTests(OAuth1Tests): self.keystone_token = content.result['token'] self.assertIsNotNone(self.keystone_token_id) + # add a new role assignment to ensure it is ignored in the access token + new_role = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex} + PROVIDERS.role_api.create_role(new_role['id'], new_role) + PROVIDERS.assignment_api.add_role_to_user_and_project( + user_id=self.user_id, + project_id=self.project_id, + role_id=new_role['id']) + content = self.post(url, headers=headers, body=body) + token = content.result['token'] + token_roles = [r['id'] for r in token['roles']] + self.assertIn(self.role_id, token_roles) + self.assertNotIn(new_role['id'], token_roles) + class AccessTokenCRUDTests(OAuthFlowTests): def test_delete_access_token_dne(self): diff --git a/lower-constraints.txt b/lower-constraints.txt index dab11af99..48a357922 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -1,4 +1,4 @@ -amqp==2.2.2 +amqp==5.0.0 Babel==2.3.4 bashate==0.5.1 bcrypt==3.1.3 @@ -8,7 +8,7 @@ docutils==0.14 dogpile.cache==0.6.2 fixtures==3.0.0 flake8-docstrings==0.2.1.post1 -flake8==2.5.5 +flake8==2.6.0 Flask===1.0.2 Flask-RESTful===0.3.5 freezegun==0.3.6 @@ -50,8 +50,10 @@ python-ldap===3.0.0 pymongo===3.0.2 pysaml2==4.5.0 PyJWT==1.6.1 +PyMySQL==0.7.6 python-keystoneclient==3.8.0 -python-memcached===1.56 +python-memcached==1.56;python_version=='2.7' +python-memcached==1.58;python_version>='3.4' pytz==2013.6 requests==2.14.2 scrypt==0.8.0 diff --git a/releasenotes/notes/bug-1831918-c70cf87ef086d871.yaml b/releasenotes/notes/bug-1831918-c70cf87ef086d871.yaml new file mode 100644 index 000000000..33a355cc5 --- /dev/null +++ b/releasenotes/notes/bug-1831918-c70cf87ef086d871.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + [`bug 1831918 <https://bugs.launchpad.net/keystone/+bug/1831918>`_] + Credentials now logs cadf audit messages. + diff --git a/releasenotes/notes/bug-1839133-24570c9fbacb530d.yaml b/releasenotes/notes/bug-1839133-24570c9fbacb530d.yaml new file mode 100644 index 000000000..b6ed1556d --- /dev/null +++ b/releasenotes/notes/bug-1839133-24570c9fbacb530d.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + [`bug 1839133 <https://bugs.launchpad.net/keystone/+bug/1839133>`_] + Makes user_enabled_emulation_use_group_config honor group_members_are_ids. diff --git a/releasenotes/notes/bug-1872733-2377f456a57ad32c.yaml b/releasenotes/notes/bug-1872733-2377f456a57ad32c.yaml new file mode 100644 index 000000000..656822c2a --- /dev/null +++ b/releasenotes/notes/bug-1872733-2377f456a57ad32c.yaml @@ -0,0 +1,16 @@ +--- +critical: + - | + [`bug 1872733 <https://bugs.launchpad.net/keystone/+bug/1872733>`_] + Fixed a critical security issue in which an authenticated user could + escalate their privileges by altering a valid EC2 credential. +security: + - | + [`bug 1872733 <https://bugs.launchpad.net/keystone/+bug/1872733>`_] + Fixed a critical security issue in which an authenticated user could + escalate their privileges by altering a valid EC2 credential. +fixes: + - | + [`bug 1872733 <https://bugs.launchpad.net/keystone/+bug/1872733>`_] + Fixed a critical security issue in which an authenticated user could + escalate their privileges by altering a valid EC2 credential. diff --git a/releasenotes/notes/bug-1872735-0989e51d2248ce1e.yaml b/releasenotes/notes/bug-1872735-0989e51d2248ce1e.yaml new file mode 100644 index 000000000..1aed86301 --- /dev/null +++ b/releasenotes/notes/bug-1872735-0989e51d2248ce1e.yaml @@ -0,0 +1,31 @@ +--- +critical: + - | + [`bug 1872735 <https://bugs.launchpad.net/keystone/+bug/1872735>`_] + Fixed a security issue in which a trustee or an application credential user + could create an EC2 credential or an application credential that would + permit them to get a token that elevated their role assignments beyond the + subset delegated to them in the trust or application credential. A new + attribute ``app_cred_id`` is now automatically added to the access blob of + an EC2 credential and the role list in the trust or application credential + is respected. +security: + - | + [`bug 1872735 <https://bugs.launchpad.net/keystone/+bug/1872735>`_] + Fixed a security issue in which a trustee or an application credential user + could create an EC2 credential or an application credential that would + permit them to get a token that elevated their role assignments beyond the + subset delegated to them in the trust or application credential. A new + attribute ``app_cred_id`` is now automatically added to the access blob of + an EC2 credential and the role list in the trust or application credential + is respected. +fixes: + - | + [`bug 1872735 <https://bugs.launchpad.net/keystone/+bug/1872735>`_] + Fixed a security issue in which a trustee or an application credential user + could create an EC2 credential or an application credential that would + permit them to get a token that elevated their role assignments beyond the + subset delegated to them in the trust or application credential. A new + attribute ``app_cred_id`` is now automatically added to the access blob of + an EC2 credential and the role list in the trust or application credential + is respected. diff --git a/releasenotes/notes/bug-1872737-f8e1ad3b6705b766.yaml b/releasenotes/notes/bug-1872737-f8e1ad3b6705b766.yaml new file mode 100644 index 000000000..d0732ab4c --- /dev/null +++ b/releasenotes/notes/bug-1872737-f8e1ad3b6705b766.yaml @@ -0,0 +1,28 @@ +--- +feature: + - | + [`bug 1872737 <https://bugs.launchpad.net/keystone/+bug/1872737>`_] + Added a new config option ``auth_ttl`` in the ``[credential]`` config + section to allow configuring the period for which a signed token request + from AWS is valid. The default is 15 minutes in accordance with the AWS + Signature V4 API reference. +upgrade: + - | + [`bug 1872737 <https://bugs.launchpad.net/keystone/+bug/1872737>`_] + Added a default TTL of 15 minutes for signed EC2 credential requests, + where previously an EC2 signed token request was valid indefinitely. This + change in behavior is needed to protect against replay attacks. +security: + - | + [`bug 1872737 <https://bugs.launchpad.net/keystone/+bug/1872737>`_] + Fixed an incorrect EC2 token validation implementation in which the + timestamp of the signed request was ignored, which made EC2 and S3 token + requests vulnerable to replay attacks. The default TTL is 15 minutes but + is configurable. +fixes: + - | + [`bug 1872737 <https://bugs.launchpad.net/keystone/+bug/1872737>`_] + Fixed an incorrect EC2 token validation implementation in which the + timestamp of the signed request was ignored, which made EC2 and S3 token + requests vulnerable to replay attacks. The default TTL is 15 minutes but + is configurable. diff --git a/releasenotes/notes/bug-1872755-2c81d3267b89f124.yaml b/releasenotes/notes/bug-1872755-2c81d3267b89f124.yaml new file mode 100644 index 000000000..a30259ffa --- /dev/null +++ b/releasenotes/notes/bug-1872755-2c81d3267b89f124.yaml @@ -0,0 +1,19 @@ +--- +security: + - | + [`bug 1872755 <https://bugs.launchpad.net/keystone/+bug/1872755>`_] + Added validation to the EC2 credentials update API to ensure the metadata + labels 'trust_id' and 'app_cred_id' are not altered by the user. These + labels are used by keystone to determine the scope allowed by the + credential, and altering these automatic labels could enable an EC2 + credential holder to elevate their access beyond what is permitted by the + application credential or trust that was used to create the EC2 credential. +fixes: + - | + [`bug 1872755 <https://bugs.launchpad.net/keystone/+bug/1872755>`_] + Added validation to the EC2 credentials update API to ensure the metadata + labels 'trust_id' and 'app_cred_id' are not altered by the user. These + labels are used by keystone to determine the scope allowed by the + credential, and altering these automatic labels could enable an EC2 + credential holder to elevate their access beyond what is permitted by the + application credential or trust that was used to create the EC2 credential. diff --git a/releasenotes/notes/bug-1873290-ff7f8e4cee15b75a.yaml b/releasenotes/notes/bug-1873290-ff7f8e4cee15b75a.yaml new file mode 100644 index 000000000..ad35a3047 --- /dev/null +++ b/releasenotes/notes/bug-1873290-ff7f8e4cee15b75a.yaml @@ -0,0 +1,19 @@ +--- +security: + - | + [`bug 1873290 <https://bugs.launchpad.net/keystone/+bug/1873290>`_] + [`bug 1872735 <https://bugs.launchpad.net/keystone/+bug/1872735>`_] + Fixed the token model to respect the roles authorized OAuth1 access tokens. + Previously, the list of roles authorized for an OAuth1 access token were + ignored, so when an access token was used to request a keystone token, the + keystone token would contain every role assignment the creator had for the + project. This also fixed EC2 credentials to respect those roles as well. +fixes: + - | + [`bug 1873290 <https://bugs.launchpad.net/keystone/+bug/1873290>`_] + [`bug 1872735 <https://bugs.launchpad.net/keystone/+bug/1872735>`_] + Fixed the token model to respect the roles authorized OAuth1 access tokens. + Previously, the list of roles authorized for an OAuth1 access token were + ignored, so when an access token was used to request a keystone token, the + keystone token would contain every role assignment the creator had for the + project. This also fixed EC2 credentials to respect those roles as well. diff --git a/releasenotes/notes/bug-1878938-70ee2af6fdf66004.yaml b/releasenotes/notes/bug-1878938-70ee2af6fdf66004.yaml new file mode 100644 index 000000000..21a53b482 --- /dev/null +++ b/releasenotes/notes/bug-1878938-70ee2af6fdf66004.yaml @@ -0,0 +1,16 @@ +--- +fixes: + - | + [`bug 1878938 <https://bugs.launchpad.net/keystone/+bug/1878938>`_] + Previously when a user used to have system role assignment and tries to delete + the same role, the system role assignments still existed in system_assignment + table. This causes keystone to return `HTTP 404 Not Found` errors when listing + role assignments with names (e.g., `--names` or `?include_names`). + + If you are affected by this bug, you must remove stale role assignments + manually. The following is an example SQL statement you can use to fix the + issue, but you should verify it's applicability to your deployment's SQL + implementation and version. + + SQL: + - delete from system_assignment where role_id not in (select id from role); diff --git a/releasenotes/notes/bug-1885753-51df25f3ff1d9ae8.yaml b/releasenotes/notes/bug-1885753-51df25f3ff1d9ae8.yaml new file mode 100644 index 000000000..f8f2d7f9c --- /dev/null +++ b/releasenotes/notes/bug-1885753-51df25f3ff1d9ae8.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + [`bug 1885753 <https://bugs.launchpad.net/keystone/+bug/1885753>`_] + Keystone's SQL identity backend now retries update user requests to safely + handle stale data when two clients update a user at the same time. diff --git a/releasenotes/notes/bug-1889936-78d6853b5212b8f1.yaml b/releasenotes/notes/bug-1889936-78d6853b5212b8f1.yaml new file mode 100644 index 000000000..de96b27f7 --- /dev/null +++ b/releasenotes/notes/bug-1889936-78d6853b5212b8f1.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + [`bug 1889936 <https://bugs.launchpad.net/keystone/+bug/1889936>`_] + Properly decode octet strings, or byte arrays, returned from LDAP. @@ -31,7 +31,8 @@ ldap = python-ldap>=3.0.0 # PSF ldappool>=2.3.1 # MPL memcache = - python-memcached>=1.56 # PSF + python-memcached>=1.56:python_version=='2.7' # PSF + python-memcached>=1.58:python_version>='3.4' # PSF mongodb = pymongo!=3.1,>=3.0.2 # Apache-2.0 bandit = diff --git a/test-requirements.txt b/test-requirements.txt index a86a1fa44..6cdc34d31 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,7 +4,6 @@ hacking>=1.1.0,<1.2.0 # Apache-2.0 pep257==0.7.0 # MIT License -pycodestyle>=2.0.0 # MIT License flake8-docstrings==0.2.1.post1 # MIT bashate>=0.5.1 # Apache-2.0 os-testr>=1.0.0 # Apache-2.0 |