Skip to content

Commit 667de73

Browse files
fix: Redact SSO PII before deletion
1 parent 7528c08 commit 667de73

5 files changed

Lines changed: 146 additions & 123 deletions

File tree

openedx/core/djangoapps/user_api/accounts/signals.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin
4040
Safety-net signal handler that redacts PII on any UserSocialAuth before deletion.
4141
4242
Records deleted via ``redact_and_delete_social_auth`` will already be redacted;
43-
this handler is a fallback for any other deletion path.
43+
this handler is a fallback for any missed deletion path.
4444
"""
4545
redacted_uid = get_redacted_social_auth_uid(instance.pk)
4646

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Tests for user_api accounts signals.
3+
"""
4+
5+
import logging
6+
from unittest.mock import patch
7+
8+
from django.test import TestCase
9+
from social_django.models import UserSocialAuth
10+
11+
from common.djangoapps.student.tests.factories import UserFactory
12+
from openedx.core.djangoapps.user_api.accounts.signals import get_redacted_social_auth_uid
13+
from openedx.core.djangolib.testing.utils import skip_unless_lms
14+
15+
16+
@skip_unless_lms
17+
class RedactSocialAuthPIIOnDeleteSignalTest(TestCase):
18+
"""
19+
Tests for the redact_social_auth_pii_before_deletion pre_delete signal handler.
20+
"""
21+
22+
def setUp(self):
23+
super().setUp()
24+
self.user = UserFactory.create(username='testuser', email='testuser@example.com')
25+
26+
def _create_social_auth(self, uid='user@example.com', extra_data=None):
27+
if extra_data is None:
28+
extra_data = {'email': 'user@example.com', 'name': 'Test User'}
29+
return UserSocialAuth.objects.create(
30+
user=self.user,
31+
provider='google-oauth2',
32+
uid=uid,
33+
extra_data=extra_data,
34+
)
35+
36+
def test_signal_warns_and_redacts_when_not_already_redacted(self):
37+
"""
38+
When a UserSocialAuth is deleted without prior redaction, the signal handler
39+
should log a warning and call redact_and_delete_social_auth with skip_delete=True.
40+
"""
41+
social_auth = self._create_social_auth()
42+
43+
with patch(
44+
'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth'
45+
) as mock_redact, self.assertLogs(
46+
'openedx.core.djangoapps.user_api.accounts.signals', level=logging.WARNING
47+
) as log_ctx:
48+
social_auth.delete()
49+
50+
mock_redact.assert_called_once_with(self.user.id, skip_delete=True)
51+
assert any('was deleted without first being redacted' in msg for msg in log_ctx.output)
52+
53+
def test_signal_skips_warning_and_redaction_when_already_redacted(self):
54+
"""
55+
When a UserSocialAuth is already redacted before deletion, the signal handler
56+
should not log a warning and should not call redact_and_delete_social_auth.
57+
"""
58+
social_auth = self._create_social_auth()
59+
social_auth.uid = get_redacted_social_auth_uid(social_auth.pk)
60+
social_auth.extra_data = {}
61+
social_auth.save(update_fields=['uid', 'extra_data'])
62+
social_auth_id = social_auth.id
63+
64+
with patch(
65+
'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth'
66+
) as mock_redact:
67+
social_auth.delete()
68+
69+
mock_redact.assert_not_called()
70+
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()

openedx/core/djangoapps/user_api/accounts/tests/test_utils.py

Lines changed: 33 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
import ddt
55
from completion import models
66
from completion.test_utils import CompletionWaffleTestMixin
7+
from django.db import connection
78
from django.test import TestCase
8-
from django.test.utils import override_settings
9+
from django.test.utils import CaptureQueriesContext, override_settings
910
from social_django.models import UserSocialAuth
1011

1112
from common.djangoapps.student.models import CourseEnrollment
1213
from common.djangoapps.student.tests.factories import UserFactory
1314
from openedx.core.djangoapps.user_api.accounts.signals import get_redacted_social_auth_uid
1415
from openedx.core.djangoapps.user_api.accounts.utils import (
16+
redact_and_delete_social_auth,
1517
retrieve_last_sitewide_block_completed,
1618
)
1719
from openedx.core.djangolib.testing.utils import skip_unless_lms
@@ -140,15 +142,30 @@ def test_retrieve_last_sitewide_block_completed(self):
140142

141143

142144
@skip_unless_lms
143-
class RedactUserSocialAuthPIITest(TestCase):
145+
class RedactAndDeleteSocialAuthTest(TestCase):
144146
"""
145-
Tests for SSO PII redaction before deletion.
147+
Tests for the redact_and_delete_social_auth utility function.
146148
"""
147149

148150
def setUp(self):
149151
super().setUp()
150152
self.user = UserFactory.create(username='testuser', email='testuser@example.com')
151153

154+
def _assert_update_before_delete(self, sql_list, table='social_auth_usersocialauth'):
155+
"""
156+
Assert that at least one UPDATE on ``table`` precedes the DELETE in ``sql_list``.
157+
158+
This verifies that PII was redacted in the database before the row was removed,
159+
which matters because downstream systems may use soft-deletes and could otherwise
160+
retain unredacted data.
161+
"""
162+
table_key = table.upper()
163+
update_indices = [i for i, s in enumerate(sql_list) if 'UPDATE' in s.upper() and table_key in s.upper()]
164+
delete_indices = [i for i, s in enumerate(sql_list) if 'DELETE' in s.upper() and table_key in s.upper()]
165+
assert update_indices, f'Expected at least one UPDATE on {table}'
166+
assert delete_indices, f'Expected at least one DELETE on {table}'
167+
assert update_indices[0] < delete_indices[0], 'Expected UPDATE to precede DELETE'
168+
152169
def create_social_auth(self, provider='google-oauth2', uid='user@example.com', extra_data=None):
153170
"""
154171
Helper method to create UserSocialAuth instances for testing.
@@ -177,70 +194,23 @@ def test_get_redacted_social_auth_uid_format(self):
177194
assert get_redacted_social_auth_uid(42) == 'redacted-before-delete-42@safe.com'
178195
assert get_redacted_social_auth_uid(1) == 'redacted-before-delete-1@safe.com'
179196

180-
def test_delete_redacts_user_social_auth_pii(self):
197+
def test_redact_and_delete_issues_update_before_delete(self):
181198
"""
182-
Test that deleting social auth redacts uid and extra_data before removal.
199+
Test that redact_and_delete_social_auth issues an UPDATE (redaction) before the DELETE.
183200
"""
184201
social_auth = self.create_social_auth()
185202
social_auth_id = social_auth.id
186203

187-
captured_states = []
204+
with CaptureQueriesContext(connection) as ctx:
205+
redact_and_delete_social_auth(self.user.id)
188206

189-
def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
190-
instance.refresh_from_db()
191-
captured_states.append({
192-
'id': instance.id,
193-
'uid': instance.uid,
194-
'extra_data': dict(instance.extra_data) if instance.extra_data else {},
195-
})
196-
197-
from django.db.models.signals import pre_delete
198-
199-
pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth)
200-
try:
201-
social_auth.delete()
202-
finally:
203-
pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth)
204-
205-
assert captured_states == [{
206-
'id': social_auth_id,
207-
'uid': get_redacted_social_auth_uid(social_auth_id),
208-
'extra_data': {},
209-
}]
207+
self._assert_update_before_delete([q['sql'] for q in ctx])
210208
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()
211209

212-
def test_delete_already_redacted_user_social_auth_is_idempotent(self):
210+
def test_redact_and_delete_redacts_multiple_sso_providers(self):
213211
"""
214-
Test that deleting an already redacted social auth keeps the redacted state.
215-
"""
216-
social_auth = self.create_social_auth()
217-
social_auth.uid = get_redacted_social_auth_uid(social_auth.pk)
218-
social_auth.extra_data = {}
219-
social_auth.save(update_fields=['uid', 'extra_data'])
220-
social_auth_id = social_auth.id
221-
222-
captured_states = []
223-
224-
def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
225-
instance.refresh_from_db()
226-
captured_states.append((instance.uid, instance.extra_data))
227-
228-
from django.db.models.signals import pre_delete
229-
230-
pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth)
231-
try:
232-
social_auth.delete()
233-
finally:
234-
pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth)
235-
236-
assert captured_states == [
237-
(get_redacted_social_auth_uid(social_auth_id), {}),
238-
]
239-
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()
240-
241-
def test_delete_redacts_multiple_sso_providers(self):
242-
"""
243-
Test that deletion redacts multiple SSO providers before removal.
212+
Test that redact_and_delete_social_auth redacts and deletes records for
213+
multiple SSO providers in a single call.
244214
"""
245215
auths = [
246216
self.create_social_auth(
@@ -254,25 +224,11 @@ def test_delete_redacts_multiple_sso_providers(self):
254224
extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'}
255225
),
256226
]
257-
# Save IDs before deletion (they become None after delete)
258227
auth_ids = [auth.pk for auth in auths]
259228

260-
captured_states = []
261-
262-
def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
263-
instance.refresh_from_db()
264-
captured_states.append((instance.provider, instance.uid, instance.extra_data))
265-
266-
from django.db.models.signals import pre_delete
267-
268-
pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth)
269-
try:
270-
for auth in auths:
271-
auth.delete()
272-
finally:
273-
pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth)
229+
with CaptureQueriesContext(connection) as ctx:
230+
redact_and_delete_social_auth(self.user.id)
274231

275-
assert sorted(captured_states) == sorted([
276-
('google-oauth2', get_redacted_social_auth_uid(auth_ids[0]), {}),
277-
('tpa-saml', get_redacted_social_auth_uid(auth_ids[1]), {}),
278-
])
232+
self._assert_update_before_delete([q['sql'] for q in ctx])
233+
for auth_id in auth_ids:
234+
assert not UserSocialAuth.objects.filter(id=auth_id).exists()

openedx/core/djangoapps/user_api/accounts/utils.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,17 @@ def redact_and_delete_social_auth(user_id, skip_delete=False):
206206
"""
207207
Redact PII from all UserSocialAuth records for the given user, then delete them.
208208
209-
Redaction happens before deletion so that any observers see only sanitised data.
210209
Downstream copies of data may use soft-deletes, and redacting before deleting
211210
ensures PII for retired users (or future retirements) is not retained.
212-
The uid format matches ``get_redacted_social_auth_uid()``.
213211
214212
``skip_delete`` should only be set to True when called from the pre_delete signal
215213
handler, where deletion is already in progress.
214+
215+
The uid format matches ``get_redacted_social_auth_uid()``.
216216
"""
217217
social_auth_queryset = UserSocialAuth.objects.filter(user_id=user_id)
218218
social_auth_queryset.update(
219+
# Important: this redacted uid must match the format used by ``get_redacted_social_auth_uid()``.
219220
uid=Concat(
220221
Value(REDACTED_SOCIAL_AUTH_UID_PREFIX),
221222
Cast('id', output_field=CharField()),

openedx/core/djangoapps/user_api/management/tests/test_retire_user.py

Lines changed: 39 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99
import pytest
1010
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
1111
from django.core.management import CommandError, call_command
12+
from django.db import connection
1213
from django.db.models.signals import pre_delete
14+
from django.test.utils import CaptureQueriesContext
1315
from social_django.models import UserSocialAuth
1416

1517
from common.djangoapps.student.tests.factories import UserFactory # lint-amnesty, pylint: disable=wrong-import-order
18+
from openedx.core.djangoapps.user_api.accounts.signals import ( # lint-amnesty, pylint: disable=wrong-import-order
19+
redact_social_auth_pii_before_deletion,
20+
)
1621
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # lint-amnesty, pylint: disable=unused-import, wrong-import-order
1722
setup_retirement_states, # noqa: F401
1823
)
1924
from openedx.core.djangolib.testing.utils import skip_unless_lms # lint-amnesty, pylint: disable=wrong-import-order
2025

21-
from ...accounts.signals import get_redacted_social_auth_uid
2226
from ...models import UserRetirementStatus
2327

2428
pytestmark = pytest.mark.django_db
@@ -117,8 +121,8 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states):
117121
"""
118122
Test that SSO PII is redacted before UserSocialAuth records are deleted during retirement.
119123
120-
This test verifies the order of operations by capturing the record's state
121-
at the moment of deletion to ensure it was already redacted.
124+
The safety-net pre_delete signal handler is disconnected for this test so that
125+
we verify the redaction comes from retire_user itself, not the fallback signal.
122126
"""
123127
user = UserFactory.create(username='sso-user', email='sso-user@example.com')
124128
social_auth = UserSocialAuth.objects.create(
@@ -133,32 +137,22 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states):
133137
)
134138
social_auth_id = social_auth.id
135139

136-
captured_states = []
137-
138-
def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
139-
"""Capture the database state seen by the pre_delete signal."""
140-
instance.refresh_from_db()
141-
captured_states.append({
142-
'id': instance.id,
143-
'uid': instance.uid,
144-
'extra_data': dict(instance.extra_data) if instance.extra_data else {},
145-
})
146-
147-
pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth)
140+
# Disconnect the safety-net signal so we prove retire_user itself redacts first.
141+
pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
148142
try:
149-
call_command('retire_user', username=user.username, user_email=user.email)
143+
with CaptureQueriesContext(connection) as ctx:
144+
call_command('retire_user', username=user.username, user_email=user.email)
150145
finally:
151-
pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth)
146+
pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
152147

153-
# Verify that at the moment of deletion, the record was already redacted
154-
assert captured_states == [{
155-
'id': social_auth_id,
156-
'uid': get_redacted_social_auth_uid(social_auth_id),
157-
'extra_data': {},
158-
}], \
159-
"SSO records should be redacted before deletion"
148+
sql_list = [q['sql'] for q in ctx]
149+
table_key = 'SOCIAL_AUTH_USERSOCIALAUTH'
150+
update_indices = [i for i, s in enumerate(sql_list) if 'UPDATE' in s.upper() and table_key in s.upper()]
151+
delete_indices = [i for i, s in enumerate(sql_list) if 'DELETE' in s.upper() and table_key in s.upper()]
152+
assert update_indices, 'Expected at least one UPDATE (redaction) on social_auth_usersocialauth'
153+
assert delete_indices, 'Expected at least one DELETE on social_auth_usersocialauth'
154+
assert update_indices[0] < delete_indices[0], 'Expected UPDATE (redaction) to precede DELETE'
160155

161-
# Verify deletion completed
162156
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()
163157

164158
retired_user_status = UserRetirementStatus.objects.filter(original_username=user.username).first()
@@ -169,7 +163,10 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=
169163
@skip_unless_lms
170164
def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811
171165
"""
172-
Test that each UserSocialAuth record is redacted before bulk deletion during retirement.
166+
Test that all UserSocialAuth records are redacted before bulk deletion during retirement.
167+
168+
The safety-net pre_delete signal handler is disconnected for this test so that
169+
we verify the redaction comes from retire_user itself, not the fallback signal.
173170
"""
174171
user = UserFactory.create(username='multi-sso-user', email='multi-sso@example.com')
175172
google_auth = UserSocialAuth.objects.create(
@@ -184,25 +181,24 @@ def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirem
184181
uid='saml-multi@example.com',
185182
extra_data={'email': 'saml-multi@example.com', 'name': 'SAML User', 'uid': 'saml-123'}
186183
)
187-
# Save IDs before deletion (they become None after delete)
188184
google_auth_id = google_auth.id
189185
saml_auth_id = saml_auth.id
190186

191-
captured_states = []
192-
193-
def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
194-
"""Capture the database state seen by the pre_delete signal."""
195-
instance.refresh_from_db()
196-
extra = dict(instance.extra_data) if instance.extra_data else {}
197-
captured_states.append((instance.provider, instance.uid, extra))
198-
199-
pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth)
187+
# Disconnect the safety-net signal so we prove retire_user itself redacts first.
188+
pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
200189
try:
201-
call_command('retire_user', username=user.username, user_email=user.email)
190+
with CaptureQueriesContext(connection) as ctx:
191+
call_command('retire_user', username=user.username, user_email=user.email)
202192
finally:
203-
pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth)
204-
205-
assert sorted(captured_states) == sorted([
206-
('google-oauth2', get_redacted_social_auth_uid(google_auth_id), {}),
207-
('tpa-saml', get_redacted_social_auth_uid(saml_auth_id), {}),
208-
])
193+
pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
194+
195+
sql_list = [q['sql'] for q in ctx]
196+
table_key = 'SOCIAL_AUTH_USERSOCIALAUTH'
197+
update_indices = [i for i, s in enumerate(sql_list) if 'UPDATE' in s.upper() and table_key in s.upper()]
198+
delete_indices = [i for i, s in enumerate(sql_list) if 'DELETE' in s.upper() and table_key in s.upper()]
199+
assert update_indices, 'Expected at least one UPDATE (redaction) on social_auth_usersocialauth'
200+
assert delete_indices, 'Expected at least one DELETE on social_auth_usersocialauth'
201+
assert update_indices[0] < delete_indices[0], 'Expected UPDATE (redaction) to precede DELETE'
202+
203+
assert not UserSocialAuth.objects.filter(id=google_auth_id).exists()
204+
assert not UserSocialAuth.objects.filter(id=saml_auth_id).exists()

0 commit comments

Comments
 (0)