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
This commit is contained in:
Lance Bragstad 2016-12-14 02:56:10 +00:00
parent 6b24c1b932
commit 100050184c
8 changed files with 663 additions and 2 deletions

View File

@ -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}

View File

@ -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"

View File

@ -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"

View File

@ -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)

View File

@ -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.

View File

@ -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'),

View File

@ -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()
)

View File

@ -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.