Add reason to notifications for PCI-DSS

This adds a reason to the CADF event notifications that are emitted
for the following events related to PCI-DSS:

- Change user passwords/passphrases at least once every X days
- Limit repeated access attempts by locking out the user ID after
not more than X attempts
- Do not allow an individual to submit a new password/phrase that
is the same as any of the last X passwords/phrases he or she has used
- Passwords/phrases must meet the specificed regex
- User attempting to change password early

Implements: bp pci-dss-notifications
Co-Authored-By: Tin Lam <tinlam@gmail.com>

Change-Id: Ia678d25bdfa151c95483f5fcb77853184fbecfd1
This commit is contained in:
Gage Hugo 2016-11-04 09:16:58 -05:00
parent e274a474e8
commit 7fe14c8da0
8 changed files with 287 additions and 46 deletions

View File

@ -37,9 +37,8 @@ def validate_password(password):
if not re.match(pattern, password):
pattern_desc = (
CONF.security_compliance.password_regex_description)
detail = _("The password does not meet the requirements: "
"%(message)s") % {'message': pattern_desc}
raise exception.PasswordValidationError(detail=detail)
raise exception.PasswordRequirementsValidationError(
detail=pattern_desc)
except re.error:
msg = _LE("Unable to validate password due to invalid regular "
"expression - password_regex: ")

View File

@ -97,6 +97,18 @@ class PasswordValidationError(ValidationError):
message_format = _("Password validation error: %(detail)s")
class PasswordRequirementsValidationError(PasswordValidationError):
message_format = _("The password does not match the requirements:"
" %(detail)s")
class PasswordHistoryValidationError(PasswordValidationError):
message_format = _("The new password cannot be identical to a "
"previous password. The number of previous "
"passwords that must be unique is "
"%(unique_count)s")
class PasswordAgeValidationError(PasswordValidationError):
message_format = _("You cannot change your password at this time due "
"to the minimum password age. Once you change your "

View File

@ -180,11 +180,8 @@ class Identity(base.IdentityDriverBase):
if unique_cnt > 1:
for password_ref in user_ref.local_user.passwords:
if utils.check_password(password, password_ref.password):
detail = _('The new password cannot be identical to a '
'previous password. The number of previous '
'passwords that must be unique is: '
'%(unique_cnt)d') % {'unique_cnt': unique_cnt}
raise exception.PasswordValidationError(detail=detail)
raise exception.PasswordHistoryValidationError(
unique_count=unique_cnt)
def change_password(self, user_id, new_password):
with sql.session_for_write() as session:

View File

@ -289,7 +289,8 @@ class UserV3(controller.V3Controller):
attribute='password')
try:
self.identity_api.change_password(
request, user_id, original_password, password)
request, user_id, original_password,
password, initiator=request.audit_initiator)
except AssertionError as e:
raise exception.Unauthorized(_(
'Error when changing user password: %s') % e)

View File

@ -22,6 +22,7 @@ import uuid
from oslo_config import cfg
from oslo_log import log
from oslo_log import versionutils
from pycadf import reason
from keystone import assignment # TODO(lbragstad): Decouple this dependency
from keystone.common import cache
@ -1282,17 +1283,23 @@ class Manager(manager.Manager):
@domains_configured
def change_password(self, request, user_id, original_password,
new_password):
new_password, initiator=None):
# authenticate() will raise an AssertionError if authentication fails
self.authenticate(request, user_id, original_password)
validators.validate_password(new_password)
domain_id, driver, entity_id = (
self._get_domain_driver_and_entity_id(user_id))
driver.change_password(entity_id, new_password)
notifications.Audit.updated(self._USER, user_id)
try:
validators.validate_password(new_password)
driver.change_password(entity_id, new_password)
except exception.PasswordValidationError as ex:
audit_reason = reason.Reason(str(ex), str(ex.code))
notifications.Audit.updated(self._USER, user_id,
initiator, reason=audit_reason)
raise
notifications.Audit.updated(self._USER, user_id, initiator)
self.emit_invalidate_user_token_persistence(user_id)
@MEMOIZE

View File

@ -27,8 +27,10 @@ from pycadf import cadftaxonomy as taxonomy
from pycadf import cadftype
from pycadf import credential
from pycadf import eventfactory
from pycadf import reason
from pycadf import resource
from keystone import exception
from keystone.i18n import _, _LE
from keystone.common import dependency
from keystone.common import utils
@ -87,7 +89,7 @@ class Audit(object):
@classmethod
def _emit(cls, operation, resource_type, resource_id, initiator, public,
actor_dict=None):
actor_dict=None, reason=None):
"""Directly send an event notification.
:param operation: one of the values from ACTIONS
@ -100,6 +102,8 @@ class Audit(object):
notify_event_callbacks to in process listeners
:param actor_dict: dictionary of actor information in the event of
assignment notification
:param reason: pycadf object containing the response code and
message description
"""
# NOTE(stevemar): the _send_notification function is
# overloaded, it's used to register callbacks and to actually
@ -116,52 +120,52 @@ class Audit(object):
if CONF.notification_format == 'cadf' and public:
outcome = taxonomy.OUTCOME_SUCCESS
_create_cadf_payload(operation, resource_type, resource_id,
outcome, initiator)
outcome, initiator, reason)
@classmethod
def created(cls, resource_type, resource_id, initiator=None,
public=True):
public=True, reason=None):
cls._emit(ACTIONS.created, resource_type, resource_id, initiator,
public)
public, reason=reason)
@classmethod
def updated(cls, resource_type, resource_id, initiator=None,
public=True):
public=True, reason=None):
cls._emit(ACTIONS.updated, resource_type, resource_id, initiator,
public)
public, reason=reason)
@classmethod
def disabled(cls, resource_type, resource_id, initiator=None,
public=True):
public=True, reason=None):
cls._emit(ACTIONS.disabled, resource_type, resource_id, initiator,
public)
public, reason=reason)
@classmethod
def deleted(cls, resource_type, resource_id, initiator=None,
public=True):
public=True, reason=None):
cls._emit(ACTIONS.deleted, resource_type, resource_id, initiator,
public)
public, reason=reason)
@classmethod
def added_to(cls, target_type, target_id, actor_type, actor_id,
initiator=None, public=True):
initiator=None, public=True, reason=None):
actor_dict = {'id': actor_id,
'type': actor_type,
'actor_operation': 'added'}
cls._emit(ACTIONS.updated, target_type, target_id, initiator, public,
actor_dict=actor_dict)
actor_dict=actor_dict, reason=reason)
@classmethod
def removed_from(cls, target_type, target_id, actor_type, actor_id,
initiator=None, public=True):
initiator=None, public=True, reason=None):
actor_dict = {'id': actor_id,
'type': actor_type,
'actor_operation': 'removed'}
cls._emit(ACTIONS.updated, target_type, target_id, initiator, public,
actor_dict=actor_dict)
actor_dict=actor_dict, reason=reason)
@classmethod
def internal(cls, resource_type, resource_id):
def internal(cls, resource_type, resource_id, reason=None):
# NOTE(lbragstad): Internal notifications are never public and have
# never used the initiator variable, but the _emit() method expects
# them. Let's set them here but not expose them through the method
@ -170,7 +174,7 @@ class Audit(object):
initiator = None
public = False
cls._emit(ACTIONS.internal, resource_type, resource_id, initiator,
public)
public, reason)
def _get_callback_info(callback):
@ -336,7 +340,7 @@ def reset_notifier():
def _create_cadf_payload(operation, resource_type, resource_id,
outcome, initiator):
outcome, initiator, reason=None):
"""Prepare data for CADF audit notifier.
Transform the arguments into content to be consumed by the function that
@ -357,6 +361,8 @@ def _create_cadf_payload(operation, resource_type, resource_id,
:param resource_id: ID of resource being operated on
:param outcome: outcomes of the operation (SUCCESS, FAILURE, etc)
:param initiator: CADF representation of the user that created the request
:param reason: pycadf object containing the response code and
message description
"""
if resource_type not in CADF_TYPE_MAP:
target_uri = taxonomy.UNKNOWN
@ -370,7 +376,7 @@ def _create_cadf_payload(operation, resource_type, resource_id,
event_type = '%s.%s.%s' % (SERVICE, resource_type, operation)
_send_audit_notification(cadf_action, initiator, outcome,
target, event_type, **audit_kwargs)
target, event_type, reason=reason, **audit_kwargs)
def _send_notification(operation, resource_type, resource_id, actor_dict=None,
@ -481,12 +487,25 @@ class CadfNotificationWrapper(object):
def __call__(self, f):
@functools.wraps(f)
def wrapper(wrapped_self, request, user_id, *args, **kwargs):
"""Alway send a notification."""
"""Will always send a notification."""
target = resource.Resource(typeURI=taxonomy.ACCOUNT_USER)
try:
result = f(wrapped_self, request, user_id, *args, **kwargs)
except (exception.AccountLocked,
exception.PasswordExpired,
exception.PasswordRequirementsValidationError,
exception.PasswordHistoryValidationError,
exception.PasswordAgeValidationError) as ex:
# Send a CADF event with a reason for PCI-DSS related
# authentication failures
audit_reason = reason.Reason(str(ex), str(ex.code))
_send_audit_notification(self.action, request.audit_initiator,
taxonomy.OUTCOME_FAILURE,
target, self.event_type,
reason=audit_reason)
raise
except Exception:
# For authentication failure send a cadf event as well
# For authentication failure send a CADF event as well
_send_audit_notification(self.action, request.audit_initiator,
taxonomy.OUTCOME_FAILURE,
target, self.event_type)
@ -644,7 +663,7 @@ class _CatalogHelperObj(object):
def _send_audit_notification(action, initiator, outcome, target,
event_type, **kwargs):
event_type, reason=None, **kwargs):
"""Send CADF notification to inform observers about the affected resource.
This method logs an exception when sending the notification fails.
@ -658,7 +677,8 @@ def _send_audit_notification(action, initiator, outcome, target,
Ceilometer uses to poll events.
:param kwargs: Any additional arguments passed in will be added as
key-value pairs to the CADF event.
:param reason: Reason for the notification which contains the response
code and message description
"""
if _check_notification_opt_out(event_type, outcome):
return
@ -680,6 +700,7 @@ def _send_audit_notification(action, initiator, outcome, target,
action=action,
initiator=initiator,
target=target,
reason=reason,
observer=resource.Resource(typeURI=taxonomy.SERVICE_SECURITY))
if service_id is not None:

View File

@ -12,9 +12,11 @@
# License for the specific language governing permissions and limitations
# under the License.
import datetime
import uuid
import fixtures
import freezegun
import mock
from oslo_config import fixture as config_fixture
from oslo_log import log
@ -24,6 +26,7 @@ from pycadf import eventfactory
from pycadf import resource as cadfresource
import keystone.conf
from keystone import exception
from keystone import notifications
from keystone.tests import unit
from keystone.tests.unit import test_v3
@ -76,13 +79,14 @@ class AuditNotificationsTestCase(unit.BaseTestCase):
'keystone.notifications._create_cadf_payload') as cadf_notify:
notify_function(EXP_RESOURCE_TYPE, exp_resource_id)
initiator = None
reason = None
cadf_notify.assert_called_once_with(
operation, EXP_RESOURCE_TYPE, exp_resource_id,
notifications.taxonomy.OUTCOME_SUCCESS, initiator)
notifications.taxonomy.OUTCOME_SUCCESS, initiator, reason)
notify_function(EXP_RESOURCE_TYPE, exp_resource_id, public=False)
cadf_notify.assert_called_once_with(
operation, EXP_RESOURCE_TYPE, exp_resource_id,
notifications.taxonomy.OUTCOME_SUCCESS, initiator)
notifications.taxonomy.OUTCOME_SUCCESS, initiator, reason)
def test_resource_created_notification(self):
self._test_notification_operation_with_basic_format(
@ -248,7 +252,7 @@ class BaseNotificationTest(test_v3.RestfulTestCase):
notifications, '_send_notification', fake_notify))
def fake_audit(action, initiator, outcome, target,
event_type, **kwargs):
event_type, reason=None, **kwargs):
service_security = cadftaxonomy.SERVICE_SECURITY
event = eventfactory.EventFactory().new_event(
@ -257,13 +261,16 @@ class BaseNotificationTest(test_v3.RestfulTestCase):
action=action,
initiator=initiator,
target=target,
reason=reason,
observer=cadfresource.Resource(typeURI=service_security))
for key, value in kwargs.items():
setattr(event, key, value)
payload = event.as_dict()
audit = {
'payload': event.as_dict(),
'payload': payload,
'event_type': event_type,
'send_notification_called': True}
self._audits.append(audit)
@ -290,7 +297,7 @@ class BaseNotificationTest(test_v3.RestfulTestCase):
self.assertEqual(actor_operation, note['actor_operation'])
def _assert_last_audit(self, resource_id, operation, resource_type,
target_uri):
target_uri, reason=None):
# NOTE(stevemar): If 'cadf' format is not used, then simply
# return since this assertion is not valid.
if CONF.notification_format != 'cadf':
@ -298,13 +305,22 @@ class BaseNotificationTest(test_v3.RestfulTestCase):
self.assertGreater(len(self._audits), 0)
audit = self._audits[-1]
payload = audit['payload']
self.assertEqual(resource_id, payload['resource_info'])
action = '%s.%s' % (operation, resource_type)
if 'resource_info' in payload:
self.assertEqual(resource_id, payload['resource_info'])
action = '.'.join(filter(None, [operation, resource_type]))
self.assertEqual(action, payload['action'])
self.assertEqual(target_uri, payload['target']['typeURI'])
self.assertEqual(resource_id, payload['target']['id'])
event_type = '%s.%s.%s' % ('identity', resource_type, operation)
if resource_id:
self.assertEqual(resource_id, payload['target']['id'])
event_type = '.'.join(filter(None, ['identity',
resource_type,
operation]))
self.assertEqual(event_type, audit['event_type'])
if reason:
self.assertEqual(reason['reasonCode'],
payload['reason']['reasonCode'])
self.assertEqual(reason['reasonType'],
payload['reason']['reasonType'])
self.assertTrue(audit['send_notification_called'])
def _assert_initiator_data_is_set(self, operation, resource_type, typeURI):
@ -686,6 +702,171 @@ class NotificationsForEntities(BaseNotificationTest):
actor_operation='removed')
class CADFNotificationsForPCIDSSEvents(BaseNotificationTest):
def setUp(self):
super(CADFNotificationsForPCIDSSEvents, self).setUp()
conf = self.useFixture(config_fixture.Config(CONF))
conf.config(notification_format='cadf')
conf.config(group='security_compliance',
password_expires_days=2)
conf.config(group='security_compliance',
lockout_failure_attempts=3)
conf.config(group='security_compliance',
unique_last_password_count=2)
conf.config(group='security_compliance',
minimum_password_age=2)
conf.config(group='security_compliance',
password_regex='^(?=.*\d)(?=.*[a-zA-Z]).{7,}$')
conf.config(group='security_compliance',
password_regex_description='1 letter, 1 digit, 7 chars')
def test_password_expired_sends_notification(self):
password = uuid.uuid4().hex
password_creation_time = (
datetime.datetime.utcnow() -
datetime.timedelta(
days=CONF.security_compliance.password_expires_days + 1)
)
freezer = freezegun.freeze_time(password_creation_time)
# NOTE(gagehugo): This part below uses freezegun to spoof
# the time as being three days in the past from right now. We will
# create a user and have that user successfully authenticate,
# then stop the time machine and return to the present time,
# where the user's password is now expired.
freezer.start()
user_ref = unit.new_user_ref(domain_id=self.domain_id,
password=password)
user_ref = self.identity_api.create_user(user_ref)
self.identity_api.authenticate(self.make_request(),
user_ref['id'], password)
freezer.stop()
reason_type = (exception.PasswordExpired.message_format %
{'user_id': user_ref['id']})
expected_reason = {'reasonCode': '401',
'reasonType': reason_type}
self.assertRaises(exception.PasswordExpired,
self.identity_api.authenticate,
self.make_request(),
user_id=user_ref['id'],
password=password)
self._assert_last_audit(None, 'authenticate', None,
cadftaxonomy.ACCOUNT_USER,
reason=expected_reason)
def test_locked_out_user_sends_notification(self):
password = uuid.uuid4().hex
new_password = uuid.uuid4().hex
expected_responses = [AssertionError, AssertionError, AssertionError,
exception.AccountLocked]
user_ref = unit.new_user_ref(domain_id=self.domain_id,
password=password)
user_ref = self.identity_api.create_user(user_ref)
reason_type = (exception.AccountLocked.message_format %
{'user_id': user_ref['id']})
expected_reason = {'reasonCode': '401',
'reasonType': reason_type}
for ex in expected_responses:
self.assertRaises(ex,
self.identity_api.change_password,
self.make_request(),
user_id=user_ref['id'],
original_password=new_password,
new_password=new_password)
self._assert_last_audit(None, 'authenticate', None,
cadftaxonomy.ACCOUNT_USER,
reason=expected_reason)
def test_repeated_password_sends_notification(self):
conf = self.useFixture(config_fixture.Config(CONF))
conf.config(group='security_compliance',
minimum_password_age=0)
password = uuid.uuid4().hex
new_password = uuid.uuid4().hex
count = CONF.security_compliance.unique_last_password_count
reason_type = (exception.PasswordHistoryValidationError.message_format
% {'unique_count': count})
expected_reason = {'reasonCode': '400',
'reasonType': reason_type}
user_ref = unit.new_user_ref(domain_id=self.domain_id,
password=password)
user_ref = self.identity_api.create_user(user_ref)
self.identity_api.change_password(self.make_request(),
user_id=user_ref['id'],
original_password=password,
new_password=new_password)
self.assertRaises(exception.PasswordValidationError,
self.identity_api.change_password,
self.make_request(),
user_id=user_ref['id'],
original_password=new_password,
new_password=password)
self._assert_last_audit(user_ref['id'], UPDATED_OPERATION, 'user',
cadftaxonomy.SECURITY_ACCOUNT_USER,
reason=expected_reason)
def test_invalid_password_sends_notification(self):
password = uuid.uuid4().hex
invalid_password = '1'
regex = CONF.security_compliance.password_regex_description
reason_type = (exception.PasswordRequirementsValidationError
.message_format %
{'detail': regex})
expected_reason = {'reasonCode': '400',
'reasonType': reason_type}
user_ref = unit.new_user_ref(domain_id=self.domain_id,
password=password)
user_ref = self.identity_api.create_user(user_ref)
self.assertRaises(exception.PasswordValidationError,
self.identity_api.change_password,
self.make_request(),
user_id=user_ref['id'],
original_password=password,
new_password=invalid_password)
self._assert_last_audit(user_ref['id'], UPDATED_OPERATION, 'user',
cadftaxonomy.SECURITY_ACCOUNT_USER,
reason=expected_reason)
def test_changing_password_too_early_sends_notification(self):
password = uuid.uuid4().hex
new_password = uuid.uuid4().hex
next_password = uuid.uuid4().hex
user_ref = unit.new_user_ref(domain_id=self.domain_id,
password=password,
password_created_at=(
datetime.datetime.utcnow()))
user_ref = self.identity_api.create_user(user_ref)
min_days = CONF.security_compliance.minimum_password_age
min_age = (user_ref['password_created_at'] +
datetime.timedelta(days=min_days))
days_left = (min_age - datetime.datetime.utcnow()).days
reason_type = (exception.PasswordAgeValidationError.message_format %
{'min_age_days': min_days, 'days_left': days_left})
expected_reason = {'reasonCode': '400',
'reasonType': reason_type}
self.identity_api.change_password(self.make_request(),
user_id=user_ref['id'],
original_password=password,
new_password=new_password)
self.assertRaises(exception.PasswordValidationError,
self.identity_api.change_password,
self.make_request(),
user_id=user_ref['id'],
original_password=new_password,
new_password=next_password)
self._assert_last_audit(user_ref['id'], UPDATED_OPERATION, 'user',
cadftaxonomy.SECURITY_ACCOUNT_USER,
reason=expected_reason)
class CADFNotificationsForEntities(NotificationsForEntities):
def setUp(self):
@ -990,7 +1171,7 @@ class CadfNotificationsWrapperTestCase(test_v3.RestfulTestCase):
self._notifications = []
def fake_notify(action, initiator, outcome, target,
event_type, **kwargs):
event_type, reason=None, **kwargs):
service_security = cadftaxonomy.SERVICE_SECURITY
event = eventfactory.EventFactory().new_event(
@ -999,6 +1180,7 @@ class CadfNotificationsWrapperTestCase(test_v3.RestfulTestCase):
action=action,
initiator=initiator,
target=target,
reason=reason,
observer=cadfresource.Resource(typeURI=service_security))
for key, value in kwargs.items():

View File

@ -0,0 +1,22 @@
---
features:
- >
[`blueprint pci-dss-notifications <https://blueprints.launchpad.net/keystone/+spec/pci-dss-notifications>`_]
CADF notifications now extend to PCI-DSS events. A ``reason`` object
is added to the notification. A ``reason`` object has both a ``reasonType``
(a short description of the reason) and ``reasonCode`` (the HTTP return code).
The following events will be impacted:
* If a user does not change their passwords at least once every X days.
See ``[security_compliance] password_expires_days``.
* If a user is locked out after many failed authentication attempts.
See ``[security_compliance] lockout_failure_attempts``.
* If a user submits a new password that was recently used. See
``[security_compliance] unique_last_password_count``.
* If a password does not meet the specified criteria. See
``[security_compliance] password_regex``.
* If a user attempts to change their password too often. See
``[security_compliance] minimum_password_age``.
See http://docs.openstack.org/developer/keystone/event_notifications.html for
additional details.