fix: Redact SSO PII before deletion#38425
Conversation
|
@robrap We’re dealing with multiple ways SSO records can get deleted through Django admin, user actions like unlinking accounts, bulk retirement scripts. The challenge is that we don’t control all of these paths, so we can’t reliably add PII redaction directly into each one. Instead, we’ve set up a two-layer approach. The first layer is a Django signal that runs automatically right before any SSO record is deleted. This acts as a safety net. No matter how the deletion is triggered whether it’s from admin, user action, the signal ensures sensitive fields like the UID and extra data are redacted. It’s centralized, consistent, so it won’t cause issues if it runs more than once. The second layer is used only in cases we fully control, like user retirement flows. There, we proactively run a bulk redaction step before deleting records. This is much faster because it uses efficient database operations. When the delete happens afterward, the signal still fires, but it detects that the data is already redacted and simply exits without doing extra work. Together, these two layers cover both safety and performance. The signal guarantees we never miss redaction, even in code we don’t control, while the explicit bulk step keeps large-scale operations efficient. |
|
|
||
| try: | ||
| update_fields = {} | ||
| redacted_uid = f'redacted_{instance.pk}@retired.invalid' |
There was a problem hiding this comment.
- We should use something more generic, like
redact-before-delete@safe.com(or whatever we've come up with). This is not a retired email in all cases. - Since we are using this email as a flag between the bulk retirement and here, we should be using a constant that both pieces of code make use of, to ensure they stay in sync.
- I'd comment the short-circuit code below to mention why we are doing it. Something like:
These fields may have already been redacted as part of a bulk retirement,
so we skip the update if it is already done to reduce query count.
There was a problem hiding this comment.
[for future] I wonder if we should have generic code for models with annotated PII that automatically introduce this redaction into the pre_delete signal?
| social_auth_records = list(UserSocialAuth.objects.filter(user_id=user.id)) | ||
| for auth in social_auth_records: | ||
| auth.uid = get_redacted_social_auth_uid(auth.pk) | ||
| auth.extra_data = {} | ||
| UserSocialAuth.objects.bulk_update(social_auth_records, ['uid', 'extra_data']) |
There was a problem hiding this comment.
- See "Using F() Expressions" in https://websitehurdles.com/django-bulk-update/ as an example of how you could refer to the pk without an extra call.
- Doing so may make it impossible to use the shared method, but you could use the constants you had started with.
| UserRetirementStatus.create_retirement(user) | ||
| # Unlink LMS social auth accounts | ||
| # Redact and unlink LMS social auth accounts. | ||
| social_auth_records = list(UserSocialAuth.objects.filter(user_id=user.id)) |
There was a problem hiding this comment.
Is it possible to put UserSocialAuth.objects.filter(user_id=user.id) in a variable and call the bulk_update and delete off of this? It might make it more clear that we're working from the same set.
| social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) | ||
| social_auth_queryset.update( | ||
| uid=Concat( | ||
| Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), | ||
| Cast('id', output_field=CharField()), | ||
| Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), | ||
| ), | ||
| extra_data={}, | ||
| ) | ||
| social_auth_queryset.delete() |
There was a problem hiding this comment.
Move to new method in utils called redact_and_delete_social_auth(user_id). This can be called from both locations, rather than duplicating code. Docstring can remind why we are redacting before deleting.
| # Redact and unlink LMS social auth accounts. | ||
| social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) | ||
| social_auth_queryset.update( | ||
| uid=Concat( | ||
| Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), | ||
| Cast('id', output_field=CharField()), | ||
| Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), | ||
| ), | ||
| extra_data={}, | ||
| ) | ||
| social_auth_queryset.delete() |
There was a problem hiding this comment.
See related comment about redact_and_delete_social_auth.
| and clears extra_data. | ||
| Blocks deletion if redaction fails to prevent PII leaks to downstream systems. | ||
| """ | ||
| if not instance or not instance.pk: |
There was a problem hiding this comment.
I want to greatly simplify this safety-net method to something like the following:
redacted_uid = get_redacted_social_auth_uid(instance.pk)
# safety-net in case the record wasn't redacted before delete.
if instance.extra_data or instance.uid != redacted_uid:
logger.warn('Social auth link for ... was deleted without first being redacted.')
redact_and_delete_social_auth(instance.user_id, skip_delete=True)
- Reuses existing call (see other comments) and sends optional argument to skip the delete step.
- The optional
skip_deleteis a little hacky, but the docstring for the method could note that it is only to be used with the delete signal, where delete was already called. - It is also a little hacky that the first link for the user will trigger redaction for all the remaining links (because it takes
user_idand notpk, but it greatly simplifies the code to reuse.
I'd also hope that we wouldn't need any additional exception handling.
| # Prefix and suffix used to build a per-record redacted uid for UserSocialAuth. | ||
| REDACTED_SOCIAL_AUTH_UID_PREFIX = 'redacted-before-delete-' | ||
| REDACTED_SOCIAL_AUTH_UID_SUFFIX = '@safe.com' |
There was a problem hiding this comment.
It may make more sense for these to live in utils.py instead.
|
|
||
| def get_redacted_social_auth_uid(pk): | ||
| """ | ||
| Return the redacted uid for a UserSocialAuth record. Single source of truth for this format. |
There was a problem hiding this comment.
The following is a possible update for this docstring.
| Return the redacted uid for a UserSocialAuth record. Single source of truth for this format. | |
| Return the redacted uid for a UserSocialAuth record. | |
| This must match the format used in redact_and_delete_social_auth. |
| # Safety-net in case the record wasn't redacted before delete. | ||
| if instance.extra_data or instance.uid != redacted_uid: | ||
| logger.warning( | ||
| 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted.', |
There was a problem hiding this comment.
I guess we should make it less scary, since we are fixing the issue.
| 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted.', | |
| 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted. Redacting in pre_delete.', |
robrap
left a comment
There was a problem hiding this comment.
I still need to look at tests, but some minor comments. Looking good so far.
robrap
left a comment
There was a problem hiding this comment.
Mostly test clean-up comments at this point.
| 'extra_data': dict(instance.extra_data) if instance.extra_data else {}, | ||
| }) | ||
|
|
||
| from django.db.models.signals import pre_delete |
There was a problem hiding this comment.
Is this needed in the method for a reason, rather than with the other imports at the top of the file?
| captured_states = [] | ||
|
|
||
| def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument | ||
| instance.refresh_from_db() | ||
| captured_states.append({ | ||
| 'id': instance.id, | ||
| 'uid': instance.uid, | ||
| 'extra_data': dict(instance.extra_data) if instance.extra_data else {}, | ||
| }) |
There was a problem hiding this comment.
I think using a pre_delete' signal for testing a pre_deletesignal makes this confusing. Is that what is being done here? How do you know what order thepre_delete` signals will get called? I'd rather it wasn't confusing in this way, and you used some other mechanism to test, like checking that there is an appropriate UPDATE query before the DELETE query, as we did in the earlier PR. You can retain the not exists assertion at the end.
Also, If this were needed, you've got a lot of code redundancy. You could use setUpClass or setUp and tearDownClass or tearDown, or helper functions to keep things DRY (Don't Repeat Yourself).
| Safety-net signal handler that redacts PII on any UserSocialAuth before deletion. | ||
|
|
||
| Records deleted via ``redact_and_delete_social_auth`` will already be redacted; | ||
| this handler is a fallback for any other deletion path. |
There was a problem hiding this comment.
| this handler is a fallback for any other deletion path. | |
| this handler is a fallback for any missed deletion path. |
| Redaction happens before deletion so that any observers see only sanitised data. | ||
| Downstream copies of data may use soft-deletes, and redacting before deleting | ||
| ensures PII for retired users (or future retirements) is not retained. | ||
| The uid format matches ``get_redacted_social_auth_uid()``. |
There was a problem hiding this comment.
Moving this below...
| The uid format matches ``get_redacted_social_auth_uid()``. |
| """ | ||
| social_auth_queryset = UserSocialAuth.objects.filter(user_id=user_id) | ||
| social_auth_queryset.update( | ||
| uid=Concat( |
There was a problem hiding this comment.
Moved (and edited) comment:
| uid=Concat( | |
| # Important: this redacted uid must match the format used by ``get_redacted_social_auth_uid()``. | |
| uid=Concat( |
| """ | ||
| Redact PII from all UserSocialAuth records for the given user, then delete them. | ||
|
|
||
| Redaction happens before deletion so that any observers see only sanitised data. |
There was a problem hiding this comment.
Consider dropping this comment. The comment about soft-deletes is probably enough.
| Redaction happens before deletion so that any observers see only sanitised data. |
|
|
||
|
|
||
| @skip_unless_lms | ||
| class RedactUserSocialAuthPIITest(TestCase): |
There was a problem hiding this comment.
- The signal tests belong in a
test_signals.pyfile with an appropriate class name. Some reasonable signal tests:
- Does the signal warn and redact if not already redacted?
- Does the signal skip warning (and redaction) if already redacted?
- Optional: Using mock, confirm
redact_and_delete_social_authis called withskip_delete=True.
- For utils tests of direct calls to
redact_and_delete_social_auth, you can cover any items you didn't cover in signals (like maybetest_delete_redacts_multiple_sso_providers), and this shouldn't require signal setup and teardown.
Note: You have much of what you need, so hopefully this is minor refactoring and clean-up.
|
|
||
| captured_states = [] | ||
|
|
||
| def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument |
There was a problem hiding this comment.
- You may want to use the same UPDATE/DELETE query assertion you set up for the other test. See other comment for details.
- You'll also want to ensure that the real receiver you set up is not interfering with this test. For example, if you deleted the redaction from
retire_user.py, would this test still pass because the signal is taking care of the redaction for you? One way to to fix this would be to disconnect that signal in setUpClass (with an appropriate comment) and to re-connect it in tearDownClass. An alternative is to mock logging and ensure that there is no log.warn from the signal (about redacting). You can test that these assertions work by temporarily removing the redaction you are testing.
3f3977a to
667de73
Compare
7fc7ec0 to
ebb2f96
Compare
2fa49b0 to
2af3cb4
Compare
373d581 to
9a8ba84
Compare
Description
Implements automatic PII redaction for UserSocialAuth records before deletion to prevent personally identifiable information from persisting after records are removed.
Jira Ticket
https://2u-internal.atlassian.net/browse/BOMS-514