Skip to content

Commit abedef3

Browse files
authored
Merge pull request #5824 from raft-tech/hotfix/security-event-token-email-changed-bug
Hotfix: SET Handler using `subject_type` instead of `new-value`
2 parents 32de75c + c0894bb commit abedef3

6 files changed

Lines changed: 384 additions & 62 deletions

File tree

tdrs-backend/tdpservice/security/event_handler.py

Lines changed: 89 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,21 @@ def _get_emails(security_event):
5959
"""Get the old and new emails from the event data."""
6060
event_data = security_event.event_data
6161
subject = event_data.get("subject")
62-
new_email = subject.get("subject_type")
6362
old_email = subject.get("email")
63+
new_email = event_data.get("new-value")
6464
return new_email, old_email
6565

6666
def _handle_email_changed(security_event):
67-
"""Handle email-changed event."""
67+
"""Handle email-changed event without mutating the user record."""
6868
new_email, old_email = SecurityEventHandler._get_emails(security_event)
69-
7069
user = security_event.user
71-
user.email = new_email
72-
user.username = new_email
73-
user.save()
74-
logger.info(f"User changed email from {old_email} to {new_email}")
70+
logger.info(
71+
"User %s identifier changed for email %s; SET new-value=%s. "
72+
"User email is synced from OIDC claims on login.",
73+
user.username,
74+
old_email,
75+
new_email,
76+
)
7577

7678
def _handle_email_recycled(security_event):
7779
"""Handle email-recycled event."""
@@ -112,6 +114,56 @@ def _handle_reproof_complete(security_event):
112114
SecurityEventType.REPROOF_COMPLETE: _handle_reproof_complete,
113115
}
114116

117+
user_mutation_event_types = {
118+
SecurityEventType.ACCOUNT_DISABLED,
119+
SecurityEventType.ACCOUNT_ENABLED,
120+
SecurityEventType.ACCOUNT_PURGED,
121+
}
122+
123+
@classmethod
124+
def _get_issued_at(cls, decoded_jwt):
125+
"""Convert the SET issued-at timestamp to a datetime."""
126+
iat_timestamp = decoded_jwt.get("iat")
127+
if not iat_timestamp:
128+
return None
129+
130+
try:
131+
return datetime.fromtimestamp(iat_timestamp, tz=dt_timezone.utc)
132+
except (ValueError, TypeError) as e:
133+
logger.warning(f"Error converting timestamp {iat_timestamp}: {e}")
134+
return None
135+
136+
@classmethod
137+
def _create_security_event(cls, user, event_type, event_data, decoded_jwt):
138+
"""Create a SecurityEventToken for a known or unmatched event subject."""
139+
subject = event_data.get("subject", {})
140+
return SecurityEventToken.objects.create(
141+
user=user,
142+
email=user.email if user else subject.get("email"),
143+
event_type=event_type,
144+
event_data=event_data,
145+
jwt_id=decoded_jwt.get("jti"),
146+
issuer=decoded_jwt.get("iss"),
147+
issued_at=cls._get_issued_at(decoded_jwt),
148+
)
149+
150+
@classmethod
151+
def _mark_processed(cls, security_event):
152+
"""Mark a security event as processed."""
153+
security_event.processed = True
154+
security_event.processed_at = timezone.now()
155+
security_event.save()
156+
157+
@classmethod
158+
def _has_subject_identifier(cls, subject):
159+
"""Return whether the event subject contains an identifier we understand."""
160+
return bool(subject.get("sub") or subject.get("email"))
161+
162+
@classmethod
163+
def _event_mutates_user(cls, event_type):
164+
"""Return whether handling the event is allowed to update the user model."""
165+
return event_type in cls.user_mutation_event_types
166+
115167
@classmethod
116168
def _get_user(cls, subject):
117169
"""Get User model from email or UUID."""
@@ -124,17 +176,17 @@ def _get_user(cls, subject):
124176
"No user found with login_gov_uuid: {}".format(subject.get("sub"))
125177
)
126178
elif "email" in subject:
127-
# Check both emails in the subject to see if we have the user
128-
user_qset = User.objects.filter(username=subject.get("email"))
129-
if user_qset.exists() and user_qset.count() == 1:
130-
return user_qset.first()
179+
if subject.get("subject_type") != "email":
180+
raise ValueError(
181+
"Email subject security event must have subject_type 'email'."
182+
)
131183

132-
user_qset = User.objects.filter(username=subject.get("subject_type"))
184+
user_qset = User.objects.filter(username=subject.get("email"))
133185
if user_qset.exists() and user_qset.count() == 1:
134186
return user_qset.first()
135187

136188
raise ValueError(
137-
"No user found with the provided 'email' or 'subject_type' in subject of security event."
189+
"No user found with the provided 'email' in subject of security event."
138190
)
139191

140192
raise ValueError("No user info found in subject of security event.")
@@ -144,36 +196,36 @@ def handle_event(cls, event_type, event_data, decoded_jwt):
144196
"""Handle specific event types."""
145197
try:
146198
subject = event_data.get("subject", {})
147-
user = cls._get_user(subject)
148-
149-
# Convert Unix timestamp to datetime if present
150-
iat_timestamp = decoded_jwt.get("iat")
151-
issued_at = None
152-
if iat_timestamp:
153-
try:
154-
issued_at = datetime.fromtimestamp(
155-
iat_timestamp, tz=dt_timezone.utc
156-
)
157-
except (ValueError, TypeError) as e:
158-
logger.warning(f"Error converting timestamp {iat_timestamp}: {e}")
159-
160-
security_event = SecurityEventToken.objects.create(
161-
user=user,
162-
email=user.email,
163-
event_type=event_type,
164-
event_data=event_data,
165-
jwt_id=decoded_jwt.get("jti"),
166-
issuer=decoded_jwt.get("iss"),
167-
issued_at=issued_at,
199+
try:
200+
user = cls._get_user(subject)
201+
except ValueError:
202+
if cls._event_mutates_user(
203+
event_type
204+
) or not cls._has_subject_identifier(subject):
205+
raise
206+
207+
security_event = cls._create_security_event(
208+
None, event_type, event_data, decoded_jwt
209+
)
210+
cls._mark_processed(security_event)
211+
logger.warning(
212+
"Recorded %s event with unmatched subject %s. "
213+
"No user mutation was applied; email is synced from OIDC "
214+
"claims on login.",
215+
event_type,
216+
subject,
217+
)
218+
return
219+
220+
security_event = cls._create_security_event(
221+
user, event_type, event_data, decoded_jwt
168222
)
169223

170224
# Call the appropriate handler
171225
handler = cls.handler_map.get(event_type, cls._handle_unknown_event)
172226
handler(security_event)
173227

174-
security_event.processed = True
175-
security_event.processed_at = timezone.now()
176-
security_event.save()
228+
cls._mark_processed(security_event)
177229

178230
except Exception as e:
179231
logger.exception(f"Error handling event {event_type}: {e}")

tdrs-backend/tdpservice/security/test/test_event_handler.py

Lines changed: 146 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,25 @@ def email_changed_event_data(stt_data_analyst):
4949
"""Mock event data for email changed: includes old and new emails."""
5050
old_email = stt_data_analyst.email
5151
new_email = "new_email@example.com"
52-
return {"subject": {"email": old_email, "subject_type": new_email}}
52+
return {
53+
"subject": {"email": old_email, "subject_type": "email"},
54+
"new-value": new_email,
55+
}
56+
57+
58+
@pytest.fixture
59+
def email_changed_event_data_without_new_value(stt_data_analyst):
60+
"""Mock Login.gov event data for email changed without a new email."""
61+
return {"subject": {"email": stt_data_analyst.email, "subject_type": "email"}}
5362

5463

5564
@pytest.fixture
5665
def email_recycled_event_data(stt_data_analyst):
5766
"""Mock event data for email recycled: includes the recycled email."""
58-
# Only the old email is relevant for handler logging; use a placeholder for subject_type
5967
return {
6068
"subject": {
6169
"email": stt_data_analyst.email,
62-
"subject_type": "unused@example.com",
70+
"subject_type": "email",
6371
}
6472
}
6573

@@ -222,6 +230,55 @@ def test_recovery_information_changed(
222230
decoded_jwt["iat"], tz=timezone.utc
223231
)
224232

233+
@pytest.mark.django_db
234+
@pytest.mark.parametrize(
235+
"event_type",
236+
[
237+
SecurityEventType.MFA_LOCKED,
238+
SecurityEventType.EMAIL_CHANGED,
239+
SecurityEventType.EMAIL_RECYCLED,
240+
SecurityEventType.PASSWORD_RESET,
241+
SecurityEventType.RECOVERY_ACTIVATED,
242+
SecurityEventType.RECOVERY_INFORMATION_CHANGED,
243+
SecurityEventType.REPROOF_COMPLETE,
244+
],
245+
)
246+
def test_non_mutating_events_do_not_update_user(
247+
self, stt_data_analyst, event_type, decoded_jwt
248+
):
249+
"""Test only account status and purge events mutate the user model."""
250+
prev_email = stt_data_analyst.email
251+
prev_username = stt_data_analyst.username
252+
prev_active = stt_data_analyst.is_active
253+
prev_login_gov_uuid = str(stt_data_analyst.login_gov_uuid)
254+
prev_status = stt_data_analyst.account_approval_status
255+
event_data = {"subject": {"sub": str(stt_data_analyst.login_gov_uuid)}}
256+
257+
if event_type in {
258+
SecurityEventType.EMAIL_CHANGED,
259+
SecurityEventType.EMAIL_RECYCLED,
260+
}:
261+
event_data = {
262+
"subject": {
263+
"email": stt_data_analyst.email,
264+
"subject_type": "email",
265+
}
266+
}
267+
268+
SecurityEventHandler.handle_event(event_type, event_data, decoded_jwt)
269+
270+
stt_data_analyst.refresh_from_db()
271+
272+
assert stt_data_analyst.email == prev_email
273+
assert stt_data_analyst.username == prev_username
274+
assert stt_data_analyst.is_active == prev_active
275+
assert str(stt_data_analyst.login_gov_uuid) == prev_login_gov_uuid
276+
assert stt_data_analyst.account_approval_status == prev_status
277+
278+
token = SecurityEventToken.objects.first()
279+
assert token.processed is True
280+
assert token.event_type == event_type
281+
225282
@pytest.mark.django_db
226283
def test_mfa_locked(self, stt_data_analyst, event_data, decoded_jwt):
227284
"""Test handling of mfa-locked event."""
@@ -254,17 +311,19 @@ def test_mfa_locked(self, stt_data_analyst, event_data, decoded_jwt):
254311
def test_email_changed(
255312
self, stt_data_analyst, email_changed_event_data, decoded_jwt
256313
):
257-
"""Test handling email-changed event updates user's email and username."""
314+
"""Test handling email-changed event does not update user's email and username."""
315+
prev_email = stt_data_analyst.email
316+
prev_username = stt_data_analyst.username
317+
258318
event_type = SecurityEventType.EMAIL_CHANGED
259319
SecurityEventHandler.handle_event(
260320
event_type, email_changed_event_data, decoded_jwt
261321
)
262322

263323
stt_data_analyst.refresh_from_db()
264324

265-
new_email = email_changed_event_data["subject"]["subject_type"]
266-
assert stt_data_analyst.email == new_email
267-
assert stt_data_analyst.username == new_email
325+
assert stt_data_analyst.email == prev_email
326+
assert stt_data_analyst.username == prev_username
268327

269328
assert SecurityEventToken.objects.count() == 1
270329
token = SecurityEventToken.objects.first()
@@ -278,6 +337,84 @@ def test_email_changed(
278337
decoded_jwt["iat"], tz=timezone.utc
279338
)
280339

340+
@pytest.mark.django_db
341+
def test_email_changed_without_new_value_does_not_update_user(
342+
self, stt_data_analyst, email_changed_event_data_without_new_value, decoded_jwt
343+
):
344+
"""Test Login.gov identifier-changed payload does not set email to subject_type."""
345+
prev_email = stt_data_analyst.email
346+
prev_username = stt_data_analyst.username
347+
348+
event_type = SecurityEventType.EMAIL_CHANGED
349+
SecurityEventHandler.handle_event(
350+
event_type, email_changed_event_data_without_new_value, decoded_jwt
351+
)
352+
353+
stt_data_analyst.refresh_from_db()
354+
355+
assert stt_data_analyst.email == prev_email
356+
assert stt_data_analyst.username == prev_username
357+
358+
assert SecurityEventToken.objects.count() == 1
359+
token = SecurityEventToken.objects.first()
360+
assert token.user == stt_data_analyst
361+
assert token.processed is True
362+
assert token.event_type == event_type
363+
assert token.event_data == email_changed_event_data_without_new_value
364+
365+
@pytest.mark.django_db
366+
def test_email_changed_for_unknown_email_records_processed_event(
367+
self, caplog, decoded_jwt
368+
):
369+
"""Test Login.gov identifier-changed can arrive with a new unknown email."""
370+
event_type = SecurityEventType.EMAIL_CHANGED
371+
event_data = {
372+
"subject": {
373+
"email": "new_login_email@example.com",
374+
"subject_type": "email",
375+
}
376+
}
377+
378+
with caplog.at_level(logging.WARNING):
379+
SecurityEventHandler.handle_event(event_type, event_data, decoded_jwt)
380+
381+
assert "No user found with the provided 'email'" not in caplog.text
382+
assert "unmatched subject" in caplog.text
383+
384+
assert SecurityEventToken.objects.count() == 1
385+
token = SecurityEventToken.objects.first()
386+
assert token.user is None
387+
assert token.email == "new_login_email@example.com"
388+
assert token.processed is True
389+
assert token.processed_at is not None
390+
assert token.event_type == event_type
391+
assert token.event_data == event_data
392+
assert token.jwt_id == decoded_jwt["jti"]
393+
assert token.issuer == decoded_jwt["iss"]
394+
395+
@pytest.mark.django_db
396+
def test_non_mutating_event_for_unknown_sub_records_processed_event(
397+
self, caplog, decoded_jwt
398+
):
399+
"""Test non-mutating events with an unknown sub are recorded for audit."""
400+
event_type = SecurityEventType.PASSWORD_RESET
401+
event_data = {"subject": {"sub": str(uuid.uuid4())}}
402+
403+
with caplog.at_level(logging.WARNING):
404+
SecurityEventHandler.handle_event(event_type, event_data, decoded_jwt)
405+
406+
assert "No user found with login_gov_uuid" not in caplog.text
407+
assert "unmatched subject" in caplog.text
408+
409+
assert SecurityEventToken.objects.count() == 1
410+
token = SecurityEventToken.objects.first()
411+
assert token.user is None
412+
assert token.email is None
413+
assert token.processed is True
414+
assert token.processed_at is not None
415+
assert token.event_type == event_type
416+
assert token.event_data == event_data
417+
281418
@pytest.mark.django_db
282419
def test_email_recycled(
283420
self, stt_data_analyst, email_recycled_event_data, decoded_jwt
@@ -351,9 +488,9 @@ def test_handle_event_no_sub(self, caplog):
351488

352489
@pytest.mark.django_db
353490
def test_handle_event_user_not_found(self, caplog):
354-
"""Test handling event when user is not found."""
491+
"""Test handling user-mutating event when user is not found."""
355492

356-
event_type = SecurityEventType.UNKNOWN_EVENT
493+
event_type = SecurityEventType.ACCOUNT_DISABLED
357494
login_gov_uuid = uuid.uuid4()
358495
event_data = {"subject": {"sub": str(login_gov_uuid)}}
359496
decoded_jwt = {}

0 commit comments

Comments
 (0)