From 100050184c589839c791bf73ca54cd273da8149c Mon Sep 17 00:00:00 2001 From: Lance Bragstad Date: Wed, 14 Dec 2016 02:56:10 +0000 Subject: [PATCH] Implement password requirements API Add an API for retrieving password requirement information from ``keystone.conf``. This should be used by user interfaces and clients if/when they enforce PCI-DSS requirements. Change-Id: I4c405da3a59e510cda5b46222cc3a20d568c7437 implements: bp pci-dss-password-requirements-api --- doc/source/policy_mapping.rst | 2 + etc/policy.json | 1 + etc/policy.v3cloudsample.json | 1 + keystone/resource/controllers.py | 19 + keystone/resource/core.py | 47 ++ keystone/resource/routers.py | 4 +- keystone/tests/unit/test_v3_domain_config.py | 584 ++++++++++++++++++ ...ord-requirements-api-87bc724b2aa554f7.yaml | 7 + 8 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/password-requirements-api-87bc724b2aa554f7.yaml diff --git a/doc/source/policy_mapping.rst b/doc/source/policy_mapping.rst index 71f87fd5a2..d3368fc929 100644 --- a/doc/source/policy_mapping.rst +++ b/doc/source/policy_mapping.rst @@ -193,6 +193,8 @@ identity:create_domain_config PUT /v3/domains/{doma identity:get_domain_config - GET /v3/domains/{domain_id}/config - GET /v3/domains/{domain_id}/config/{group} - GET /v3/domains/{domain_id}/config/{group}/{option} +identity:get_security_compliance_domain_config - GET /v3/domains/{domain_id}/config/security_compliance + - GET /v3/domains/{domain_id}/config/security_compliance/{option} identity:update_domain_config - PATCH /v3/domains/{domain_id}/config - PATCH /v3/domains/{domain_id}/config/{group} - PATCH /v3/domains/{domain_id}/config/{group}/{option} diff --git a/etc/policy.json b/etc/policy.json index 1e37bef06e..90992c66ab 100644 --- a/etc/policy.json +++ b/etc/policy.json @@ -192,6 +192,7 @@ "identity:create_domain_config": "rule:admin_required", "identity:get_domain_config": "rule:admin_required", + "identity:get_security_compliance_domain_config": "", "identity:update_domain_config": "rule:admin_required", "identity:delete_domain_config": "rule:admin_required", "identity:get_domain_config_default": "rule:admin_required" diff --git a/etc/policy.v3cloudsample.json b/etc/policy.v3cloudsample.json index b8eef0b072..9c3eada9f3 100644 --- a/etc/policy.v3cloudsample.json +++ b/etc/policy.v3cloudsample.json @@ -219,6 +219,7 @@ "identity:create_domain_config": "rule:cloud_admin", "identity:get_domain_config": "rule:cloud_admin", + "identity:get_security_compliance_domain_config": "", "identity:update_domain_config": "rule:cloud_admin", "identity:delete_domain_config": "rule:cloud_admin", "identity:get_domain_config_default": "rule:cloud_admin" diff --git a/keystone/resource/controllers.py b/keystone/resource/controllers.py index 73f523eef8..9886b43505 100644 --- a/keystone/resource/controllers.py +++ b/keystone/resource/controllers.py @@ -185,6 +185,25 @@ class DomainConfigV3(controller.V3Controller): status=(http_client.CREATED, http_client.responses[http_client.CREATED])) + def get_domain_config_wrapper(self, request, domain_id, group=None, + option=None): + if group and group == 'security_compliance': + return self.get_security_compliance_domain_config( + request, domain_id, group=group, option=option + ) + else: + return self.get_domain_config( + request, domain_id, group=group, option=option + ) + + @controller.protected() + def get_security_compliance_domain_config(self, request, domain_id, + group=None, option=None): + ref = self.domain_config_api.get_security_compliance_config( + domain_id, group, option=option + ) + return {self.member_name: ref} + @controller.protected() def get_domain_config(self, request, domain_id, group=None, option=None): self.resource_api.get_domain(domain_id) diff --git a/keystone/resource/core.py b/keystone/resource/core.py index 0a6d9ab71d..eac63c23db 100644 --- a/keystone/resource/core.py +++ b/keystone/resource/core.py @@ -1131,6 +1131,53 @@ class DomainConfigManager(manager.Manager): raise exception.DomainConfigNotFound( domain_id=domain_id, group_or_option=msg) + def get_security_compliance_config(self, domain_id, group, option=None): + r"""Get full or partial security compliance config from configuration. + + :param domain_id: the domain in question + :param group: a specific group of options + :param option: an optional specific option within the group + + :returns: a dict of group dicts containing the whitelisted options, + filtered by group and option specified + :raises keystone.exception.InvalidDomainConfig: when the config + and group/option parameters specify an option we do not + support + + An example response:: + + { + 'security_compliance': { + 'password_regex': '^(?=.*\d)(?=.*[a-zA-Z]).{7,}$' + 'password_regex_description': + 'A password must consist of at least 1 letter, ' + '1 digit, and have a minimum length of 7 characters' + } + } + + """ + if domain_id != CONF.identity.default_domain_id: + msg = _('Reading security compliance information for any domain ' + 'other than the default domain is not allowed or ' + 'supported.') + raise exception.InvalidDomainConfig(reason=msg) + + config_list = [] + readable_options = ['password_regex', 'password_regex_description'] + if option and option not in readable_options: + msg = _('Reading security compliance values other than ' + 'password_regex and password_regex_description is not ' + 'allowed.') + raise exception.InvalidDomainConfig(reason=msg) + elif option and option in readable_options: + config_list.append(self._option_dict(group, option)) + elif not option: + for op in readable_options: + config_list.append(self._option_dict(group, op)) + # We already validated that the group is the security_compliance group + # so we can move along and start validating the options + return self._list_to_config(config_list, req_option=option) + def update_config(self, domain_id, config, group=None, option=None): """Update config, or partial config, for a domain. diff --git a/keystone/resource/routers.py b/keystone/resource/routers.py index e985632a9e..ca6bf38e8d 100644 --- a/keystone/resource/routers.py +++ b/keystone/resource/routers.py @@ -62,7 +62,7 @@ class Routers(wsgi.RoutersBase): self._add_resource( mapper, config_controller, path='/domains/{domain_id}/config/{group}', - get_head_action='get_domain_config', + get_head_action='get_domain_config_wrapper', patch_action='update_domain_config_group', delete_action='delete_domain_config', rel=json_home.build_v3_resource_relation('domain_config_group'), @@ -74,7 +74,7 @@ class Routers(wsgi.RoutersBase): self._add_resource( mapper, config_controller, path='/domains/{domain_id}/config/{group}/{option}', - get_head_action='get_domain_config', + get_head_action='get_domain_config_wrapper', patch_action='update_domain_config', delete_action='delete_domain_config', rel=json_home.build_v3_resource_relation('domain_config_option'), diff --git a/keystone/tests/unit/test_v3_domain_config.py b/keystone/tests/unit/test_v3_domain_config.py index 4200d6a698..598e828f9a 100644 --- a/keystone/tests/unit/test_v3_domain_config.py +++ b/keystone/tests/unit/test_v3_domain_config.py @@ -457,3 +457,587 @@ class DomainConfigTestCase(test_v3.RestfulTestCase): # Now try a totally invalid option url = '/domains/config/ldap/%s/default' % uuid.uuid4().hex self.get(url, expected_status=http_client.FORBIDDEN) + + +class SecurityRequirementsTestCase(test_v3.RestfulTestCase): + + def setUp(self): + super(SecurityRequirementsTestCase, self).setUp() + + # Create a user in the default domain + self.non_admin_user = unit.create_user( + self.identity_api, + CONF.identity.default_domain_id + ) + + # Create an admin in the default domain + self.admin_user = unit.create_user( + self.identity_api, + CONF.identity.default_domain_id + ) + + # Create a project in the default domain and a non-admin role + self.project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + self.resource_api.create_project(self.project['id'], self.project) + self.non_admin_role = unit.new_role_ref(name='not_admin') + self.role_api.create_role( + self.non_admin_role['id'], + self.non_admin_role + ) + + # Give the non-admin user a role on the project + self.assignment_api.add_role_to_user_and_project( + self.non_admin_user['id'], + self.project['id'], + self.role['id'] + ) + + # Give the user the admin role on the project, which is technically + # `self.role` because RestfulTestCase sets that up for us. + self.assignment_api.add_role_to_user_and_project( + self.admin_user['id'], + self.project['id'], + self.role_id + ) + + def _get_non_admin_token(self): + non_admin_auth_data = self.build_authentication_request( + user_id=self.non_admin_user['id'], + password=self.non_admin_user['password'], + project_id=self.project['id'] + ) + return self.get_requested_token(non_admin_auth_data) + + def _get_admin_token(self): + non_admin_auth_data = self.build_authentication_request( + user_id=self.admin_user['id'], + password=self.admin_user['password'], + project_id=self.project['id'] + ) + return self.get_requested_token(non_admin_auth_data) + + def test_get_security_compliance_config_for_default_domain(self): + """Ask for all security compliance configuration options. + + Support for enforcing security compliance per domain currently doesn't + exist. Make sure when we ask for security compliance information, it's + only for the default domain and that it only returns whitelisted + options. + """ + password_regex = uuid.uuid4().hex + password_regex_description = uuid.uuid4().hex + self.config_fixture.config( + group='security_compliance', + password_regex=password_regex + ) + self.config_fixture.config( + group='security_compliance', + password_regex_description=password_regex_description + ) + expected_response = { + 'security_compliance': { + 'password_regex': password_regex, + 'password_regex_description': password_regex_description + } + } + url = ( + '/domains/%(domain_id)s/config/%(group)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': 'security_compliance', + } + ) + + # Make sure regular users and administrators can get security + # requirement information. + regular_response = self.get(url, token=self._get_non_admin_token()) + self.assertEqual(regular_response.result['config'], expected_response) + admin_response = self.get(url, token=self._get_admin_token()) + self.assertEqual(admin_response.result['config'], expected_response) + + def test_get_security_compliance_config_for_non_default_domain_fails(self): + """Getting security compliance opts for other domains should fail. + + Support for enforcing security compliance rules per domain currently + does not exist, so exposing security compliance information for any + domain other than the default domain should not be allowed. + """ + # Create a new domain that is not the default domain + domain = unit.new_domain_ref() + self.resource_api.create_domain(domain['id'], domain) + + # Set the security compliance configuration options + password_regex = uuid.uuid4().hex + password_regex_description = uuid.uuid4().hex + self.config_fixture.config( + group='security_compliance', + password_regex=password_regex + ) + self.config_fixture.config( + group='security_compliance', + password_regex_description=password_regex_description + ) + url = ( + '/domains/%(domain_id)s/config/%(group)s' % + { + 'domain_id': domain['id'], + 'group': 'security_compliance', + } + ) + + # Make sure regular users and administrators are forbidden from doing + # this. + self.get( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.get( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_get_non_whitelisted_security_compliance_opt_fails(self): + """We only support exposing a subset of security compliance options. + + Given that security compliance information is sensitive in nature, we + should make sure that only the options we want to expose are readable + via the API. + """ + # Set a security compliance configuration that isn't whitelisted + self.config_fixture.config( + group='security_compliance', + lockout_failure_attempts=1 + ) + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': 'security_compliance', + 'option': 'lockout_failure_attempts' + } + ) + + # Make sure regular users and administrators are unable to ask for + # sensitive information. + self.get( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.get( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_get_security_compliance_password_regex(self): + """Ask for the security compliance password regular expression.""" + password_regex = uuid.uuid4().hex + self.config_fixture.config( + group='security_compliance', + password_regex=password_regex + ) + group = 'security_compliance' + option = 'password_regex' + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + 'option': option + } + ) + + # Make sure regular users and administrators can ask for the + # password regular expression. + regular_response = self.get(url, token=self._get_non_admin_token()) + self.assertEqual( + regular_response.result['config'][option], + password_regex + ) + admin_response = self.get(url, token=self._get_admin_token()) + self.assertEqual( + admin_response.result['config'][option], + password_regex + ) + + def test_get_security_compliance_password_regex_description(self): + """Ask for the security compliance password regex description.""" + password_regex_description = uuid.uuid4().hex + self.config_fixture.config( + group='security_compliance', + password_regex_description=password_regex_description + ) + group = 'security_compliance' + option = 'password_regex_description' + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + 'option': option + } + ) + + # Make sure regular users and administrators can ask for the + # password regular expression. + regular_response = self.get(url, token=self._get_non_admin_token()) + self.assertEqual( + regular_response.result['config'][option], + password_regex_description + ) + admin_response = self.get(url, token=self._get_admin_token()) + self.assertEqual( + admin_response.result['config'][option], + password_regex_description + ) + + def test_get_security_compliance_password_regex_returns_none(self): + """When an option isn't set, we should explicitly return None.""" + group = 'security_compliance' + option = 'password_regex' + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + 'option': option + } + ) + + # Make sure regular users and administrators can ask for the password + # regular expression, but since it isn't set the returned value should + # be None. + regular_response = self.get(url, token=self._get_non_admin_token()) + self.assertIsNone(regular_response.result['config'][option]) + admin_response = self.get(url, token=self._get_admin_token()) + self.assertIsNone(admin_response.result['config'][option]) + + def test_get_security_compliance_password_regex_desc_returns_none(self): + """When an option isn't set, we should explicitly return None.""" + group = 'security_compliance' + option = 'password_regex_description' + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + 'option': option + } + ) + + # Make sure regular users and administrators can ask for the password + # regular expression description, but since it isn't set the returned + # value should be None. + regular_response = self.get(url, token=self._get_non_admin_token()) + self.assertIsNone(regular_response.result['config'][option]) + admin_response = self.get(url, token=self._get_admin_token()) + self.assertIsNone(admin_response.result['config'][option]) + + def test_get_security_compliance_config_with_user_from_other_domain(self): + """Make sure users from other domains can access password requirements. + + Even though a user is in a separate domain, they should be able to see + the security requirements for the deployment. This is because security + compliance is not yet implemented on a per domain basis. Once that + happens, then this should no longer be possible since a user should + only care about the security compliance requirements for the domain + that they are in. + """ + # Make a new domain + domain = unit.new_domain_ref() + self.resource_api.create_domain(domain['id'], domain) + + # Create a user in the new domain + user = unit.create_user(self.identity_api, domain['id']) + + # Create a project in the new domain + project = unit.new_project_ref(domain_id=domain['id']) + self.resource_api.create_project(project['id'], project) + + # Give the new user a non-admin role on the project + self.assignment_api.add_role_to_user_and_project( + user['id'], + project['id'], + self.non_admin_role['id'] + ) + + # Set our security compliance config values, we do this after we've + # created our test user otherwise password validation will fail with a + # uuid type regex. + password_regex = uuid.uuid4().hex + password_regex_description = uuid.uuid4().hex + group = 'security_compliance' + self.config_fixture.config( + group=group, + password_regex=password_regex + ) + self.config_fixture.config( + group=group, + password_regex_description=password_regex_description + ) + + # Get a token for the newly created user scoped to the project in the + # non-default domain and use it to get the password security + # requirements. + user_token = self.build_authentication_request( + user_id=user['id'], + password=user['password'], + project_id=project['id'] + ) + user_token = self.get_requested_token(user_token) + url = ( + '/domains/%(domain_id)s/config/%(group)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + } + ) + response = self.get(url, token=user_token) + self.assertEqual( + response.result['config'][group]['password_regex'], + password_regex + ) + self.assertEqual( + response.result['config'][group]['password_regex_description'], + password_regex_description + ) + + def test_update_security_compliance_config_group_fails(self): + """Make sure that updates to the entire security group section fail. + + We should only allow the ability to modify a deployments security + compliance rules through configuration. Especially since it's only + enforced on the default domain. + """ + new_config = { + 'security_compliance': { + 'password_regex': uuid.uuid4().hex, + 'password_regex_description': uuid.uuid4().hex + } + } + url = ( + '/domains/%(domain_id)s/config/%(group)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': 'security_compliance', + } + ) + + # Make sure regular users and administrators aren't allowed to modify + # security compliance configuration through the API. + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_update_security_compliance_password_regex_fails(self): + """Make sure any updates to security compliance options fail.""" + group = 'security_compliance' + option = 'password_regex' + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + 'option': option + } + ) + new_config = { + group: { + option: uuid.uuid4().hex + } + } + + # Make sure regular users and administrators aren't allowed to modify + # security compliance configuration through the API. + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_update_security_compliance_password_regex_description_fails(self): + """Make sure any updates to security compliance options fail.""" + group = 'security_compliance' + option = 'password_regex_description' + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + 'option': option + } + ) + new_config = { + group: { + option: uuid.uuid4().hex + } + } + + # Make sure regular users and administrators aren't allowed to modify + # security compliance configuration through the API. + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_update_non_whitelisted_security_compliance_option_fails(self): + """Updating security compliance options through the API is not allowed. + + Requests to update anything in the security compliance group through + the API should be Forbidden. This ensures that we are covering cases + where the option being updated isn't in the white list. + """ + group = 'security_compliance' + option = 'lockout_failure_attempts' + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': group, + 'option': option + } + ) + new_config = { + group: { + option: 1 + } + } + + # Make sure this behavior is not possible for regular users or + # administrators. + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.patch( + url, + body={'config': new_config}, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_delete_security_compliance_group_fails(self): + """The security compliance group shouldn't be deleteable.""" + url = ( + '/domains/%(domain_id)s/config/%(group)s/' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': 'security_compliance', + } + ) + + # Make sure regular users and administrators can't delete the security + # compliance configuration group. + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_delete_security_compliance_password_regex_fails(self): + """The security compliance options shouldn't be deleteable.""" + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': 'security_compliance', + 'option': 'password_regex' + } + ) + + # Make sure regular users and administrators can't delete the security + # compliance configuration group. + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_delete_security_compliance_password_regex_description_fails(self): + """The security compliance options shouldn't be deleteable.""" + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': 'security_compliance', + 'option': 'password_regex_description' + } + ) + + # Make sure regular users and administrators can't delete the security + # compliance configuration group. + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) + + def test_delete_non_whitelisted_security_compliance_options_fails(self): + """The security compliance options shouldn't be deleteable.""" + url = ( + '/domains/%(domain_id)s/config/%(group)s/%(option)s' % + { + 'domain_id': CONF.identity.default_domain_id, + 'group': 'security_compliance', + 'option': 'lockout_failure_attempts' + } + ) + + # Make sure regular users and administrators can't delete the security + # compliance configuration group. + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_non_admin_token() + ) + self.delete( + url, + expected_status=http_client.FORBIDDEN, + token=self._get_admin_token() + ) diff --git a/releasenotes/notes/password-requirements-api-87bc724b2aa554f7.yaml b/releasenotes/notes/password-requirements-api-87bc724b2aa554f7.yaml new file mode 100644 index 0000000000..f913ead8fd --- /dev/null +++ b/releasenotes/notes/password-requirements-api-87bc724b2aa554f7.yaml @@ -0,0 +1,7 @@ +--- +features: + - User interfaces and clients now have the ability + to retrieve password requirement information via the + Domain Config API. Specifically, the ``password_regex`` + and ``password_regex_description`` options of the + ``[security_compliance]`` section.