Skip to content

Commit d3740c2

Browse files
authored
Merge branch 'develop' into 5413-prod-celery-mem-bump
2 parents b466342 + 5d7a269 commit d3740c2

38 files changed

+2501
-569
lines changed

tdrs-backend/tdpservice/data_files/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from typing import Union
88

99
from django.conf import settings
10-
from django.contrib.admin.models import ADDITION, ContentType, LogEntry
10+
from django.contrib.admin.models import ADDITION, LogEntry
11+
from django.contrib.contenttypes.models import ContentType
1112
from django.core.files.base import File
1213
from django.db import models
1314
from django.db.models import Max
@@ -58,7 +59,6 @@ def get_s3_upload_path(instance, filename):
5859
filename,
5960
)
6061

61-
6262
# The Data File model was starting to explode, and I think that keeping this logic
6363
# in its own abstract class is better for documentation purposes.
6464
class FileRecord(models.Model):

tdrs-backend/tdpservice/fixtures/cypress/users.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@
229229
2
230230
],
231231
"user_permissions": [
232-
193
232+
197
233233
]
234234
}
235235
},
@@ -259,7 +259,7 @@
259259
2
260260
],
261261
"user_permissions": [
262-
193
262+
197
263263
]
264264
}
265265
},
@@ -289,7 +289,7 @@
289289
4
290290
],
291291
"user_permissions": [
292-
193
292+
197
293293
]
294294
}
295295
},
@@ -320,7 +320,7 @@
320320
5
321321
],
322322
"user_permissions": [
323-
193
323+
197
324324
]
325325
}
326326
},
@@ -350,7 +350,7 @@
350350
2
351351
],
352352
"user_permissions": [
353-
193
353+
197
354354
]
355355
}
356356
},

tdrs-backend/tdpservice/security/admin.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Admin classes for the tdpservice.security app."""
22
from django.contrib import admin
33

4-
from tdpservice.core.utils import ReadOnlyAdminMixin
5-
from tdpservice.security.models import ClamAVFileScan, OwaspZapScan
4+
from tdpservice.core.admin import ReadOnlyAdminMixin
5+
from tdpservice.security.models import ClamAVFileScan, OwaspZapScan, SecurityEventToken
66

77

88
@admin.register(ClamAVFileScan)
@@ -47,3 +47,41 @@ class OwaspZapScanAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
4747
"scanned_at",
4848
"app_target",
4949
]
50+
51+
52+
@admin.register(SecurityEventToken)
53+
class SecurityEventTokenAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
54+
"""Admin interface for Security Event Tokens."""
55+
56+
list_display = (
57+
"event_type",
58+
"user",
59+
"email",
60+
"issued_at",
61+
"received_at",
62+
"processed",
63+
)
64+
list_filter = ("user", "event_type", "processed", "received_at")
65+
search_fields = (
66+
"user__username",
67+
"user__email",
68+
"email",
69+
"event_type",
70+
"event_data",
71+
)
72+
readonly_fields = (
73+
"id",
74+
"jwt_id",
75+
"issuer",
76+
"issued_at",
77+
"received_at",
78+
"event_data",
79+
)
80+
date_hierarchy = "received_at"
81+
82+
def get_actions(self, request):
83+
"""Override get_action to remove delete action."""
84+
actions = super().get_actions(request)
85+
if "delete_selected" in actions:
86+
del actions["delete_selected"]
87+
return actions
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Handler for security events."""
2+
3+
import logging
4+
from datetime import datetime
5+
6+
from django.utils import timezone
7+
8+
from tdpservice.security.models import SecurityEventToken, SecurityEventType
9+
from tdpservice.users.models import User
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class SecurityEventHandler:
15+
"""Handler for security events."""
16+
17+
def _handle_unknown_event(security_event):
18+
"""Handle unimplemented or unknown events."""
19+
logger.warning(f"No handler for event type: {security_event.event_type}")
20+
security_event.event_type = SecurityEventType.UNKNOWN_EVENT
21+
22+
def _handle_account_disabled(security_event):
23+
"""Handle account-disabled event."""
24+
user = security_event.user
25+
user.is_active = False
26+
user.save()
27+
logger.info(f"User {user.username} account disabled due to Login.gov event")
28+
29+
def _handle_account_enabled(security_event):
30+
"""Handle account-enabled event."""
31+
user = security_event.user
32+
user.is_active = True
33+
user.save()
34+
logger.info(f"User {user.username} account re-enabled due to Login.gov event")
35+
36+
def _handle_account_purged(security_event):
37+
"""Handle account-purged event when a user deletes their Login.gov account."""
38+
# Find user by Login.gov UUID
39+
user = security_event.user
40+
41+
# Set login_gov_uuid to null and deactivate user
42+
user.login_gov_uuid = None
43+
user.is_active = False
44+
user.save()
45+
46+
logger.info(
47+
f"User {user.username} Login.gov account was purged. Prepared for potential recreation."
48+
)
49+
50+
def _handle_mfa_locked(security_event):
51+
"""Handle mfa-locked event."""
52+
user = security_event.user
53+
logger.info(
54+
f"User {user.username} account locked due to multiple MFA failures."
55+
)
56+
57+
def _get_emails(security_event):
58+
"""Get the old and new emails from the event data."""
59+
event_data = security_event.event_data
60+
subject = event_data.get("subject")
61+
new_email = subject.get("subject_type")
62+
old_email = subject.get("email")
63+
return new_email, old_email
64+
65+
def _handle_email_changed(security_event):
66+
"""Handle email-changed event."""
67+
new_email, old_email = SecurityEventHandler._get_emails(security_event)
68+
69+
user = security_event.user
70+
user.email = new_email
71+
user.username = new_email
72+
user.save()
73+
logger.info(f"User changed email from {old_email} to {new_email}")
74+
75+
def _handle_email_recycled(security_event):
76+
"""Handle email-recycled event."""
77+
new_email, old_email = SecurityEventHandler._get_emails(security_event)
78+
user = security_event.user
79+
logger.info(f"User {user.username} recycled extra email address {old_email}")
80+
81+
def _handle_password_reset(security_event):
82+
"""Handle password-reset event."""
83+
user = security_event.user
84+
logger.info(f"User {user.username} performed a password reset")
85+
86+
def _handle_recovery_activated(security_event):
87+
"""Handle recovery-activated event when user initiates account recovery."""
88+
user = security_event.user
89+
logger.info(f"User {user.username} initiated account recovery")
90+
91+
def _handle_recovery_information_changed(security_event):
92+
"""Handle recovery-information-changed event when user modifies their recovery methods."""
93+
user = security_event.user
94+
logger.info(f"User {user.username} changed their recovery information")
95+
96+
def _handle_reproof_complete(security_event):
97+
"""Handle reproof-complete event when user completes account recovery."""
98+
user = security_event.user
99+
logger.info(f"User {user.username} completed account re-verification.")
100+
101+
handler_map = {
102+
SecurityEventType.ACCOUNT_DISABLED: _handle_account_disabled,
103+
SecurityEventType.ACCOUNT_ENABLED: _handle_account_enabled,
104+
SecurityEventType.ACCOUNT_PURGED: _handle_account_purged,
105+
SecurityEventType.MFA_LOCKED: _handle_mfa_locked,
106+
SecurityEventType.EMAIL_CHANGED: _handle_email_changed,
107+
SecurityEventType.EMAIL_RECYCLED: _handle_email_recycled,
108+
SecurityEventType.PASSWORD_RESET: _handle_password_reset,
109+
SecurityEventType.RECOVERY_ACTIVATED: _handle_recovery_activated,
110+
SecurityEventType.RECOVERY_INFORMATION_CHANGED: _handle_recovery_information_changed,
111+
SecurityEventType.REPROOF_COMPLETE: _handle_reproof_complete,
112+
}
113+
114+
@classmethod
115+
def _get_user(cls, subject):
116+
"""Get User model from email or UUID."""
117+
if "sub" in subject:
118+
user_qset = User.objects.filter(login_gov_uuid=subject.get("sub"))
119+
if user_qset.exists() and user_qset.count() == 1:
120+
return user_qset.first()
121+
else:
122+
raise ValueError(
123+
"No user found with login_gov_uuid: {}".format(subject.get("sub"))
124+
)
125+
elif "email" in subject:
126+
# Check both emails in the subject to see if we have the user
127+
user_qset = User.objects.filter(username=subject.get("email"))
128+
if user_qset.exists() and user_qset.count() == 1:
129+
return user_qset.first()
130+
131+
user_qset = User.objects.filter(username=subject.get("subject_type"))
132+
if user_qset.exists() and user_qset.count() == 1:
133+
return user_qset.first()
134+
135+
raise ValueError(
136+
"No user found with the provided 'email' or 'subject_type' in subject of security event."
137+
)
138+
139+
raise ValueError("No user info found in subject of security event.")
140+
141+
@classmethod
142+
def handle_event(cls, event_type, event_data, decoded_jwt):
143+
"""Handle specific event types."""
144+
try:
145+
subject = event_data.get("subject", {})
146+
user = cls._get_user(subject)
147+
148+
# Convert Unix timestamp to datetime if present
149+
iat_timestamp = decoded_jwt.get("iat")
150+
issued_at = None
151+
if iat_timestamp:
152+
try:
153+
issued_at = datetime.fromtimestamp(iat_timestamp, tz=timezone.utc)
154+
except (ValueError, TypeError) as e:
155+
logger.warning(f"Error converting timestamp {iat_timestamp}: {e}")
156+
157+
security_event = SecurityEventToken.objects.create(
158+
user=user,
159+
email=user.email,
160+
event_type=event_type,
161+
event_data=event_data,
162+
jwt_id=decoded_jwt.get("jti"),
163+
issuer=decoded_jwt.get("iss"),
164+
issued_at=issued_at,
165+
)
166+
167+
# Call the appropriate handler
168+
handler = cls.handler_map.get(event_type, cls._handle_unknown_event)
169+
handler(security_event)
170+
171+
security_event.processed = True
172+
security_event.processed_at = timezone.now()
173+
security_event.save()
174+
175+
except Exception as e:
176+
logger.exception(f"Error handling event {event_type}: {e}")

0 commit comments

Comments
 (0)