diff --git a/keystone/common/validation/validators.py b/keystone/common/validation/validators.py index 2f70e9fdb9..949099fc51 100644 --- a/keystone/common/validation/validators.py +++ b/keystone/common/validation/validators.py @@ -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: ") diff --git a/keystone/exception.py b/keystone/exception.py index 39bf5d0fa5..3db1fb32bb 100644 --- a/keystone/exception.py +++ b/keystone/exception.py @@ -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 " diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index 32e7938132..3236b3c520 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -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: diff --git a/keystone/identity/controllers.py b/keystone/identity/controllers.py index b5d49528c5..c9723dab84 100644 --- a/keystone/identity/controllers.py +++ b/keystone/identity/controllers.py @@ -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) diff --git a/keystone/identity/core.py b/keystone/identity/core.py index 377a8ce226..f291bf4d9c 100644 --- a/keystone/identity/core.py +++ b/keystone/identity/core.py @@ -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 diff --git a/keystone/notifications.py b/keystone/notifications.py index 860dd5b09e..3a3a3ff5c8 100644 --- a/keystone/notifications.py +++ b/keystone/notifications.py @@ -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: diff --git a/keystone/tests/unit/common/test_notifications.py b/keystone/tests/unit/common/test_notifications.py index 6cb4175e98..2dcdd861f4 100644 --- a/keystone/tests/unit/common/test_notifications.py +++ b/keystone/tests/unit/common/test_notifications.py @@ -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(): diff --git a/releasenotes/notes/pci-dss-notifications-808a205a637bac25.yaml b/releasenotes/notes/pci-dss-notifications-808a205a637bac25.yaml new file mode 100644 index 0000000000..a837b4d6b4 --- /dev/null +++ b/releasenotes/notes/pci-dss-notifications-808a205a637bac25.yaml @@ -0,0 +1,22 @@ +--- +features: + - > + [`blueprint 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.