Skip to content

Commit 01dfa44

Browse files
authored
Merge pull request #13757 from rtibbles/unhandle_my_lod
Robustly clear sessions on user deletion to cause immediate logout
2 parents 349b2d6 + a7848c7 commit 01dfa44

39 files changed

+713
-173
lines changed

kolibri/core/analytics/test/test_api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515

1616
class PingbackNotificationTestCase(APITestCase):
17+
databases = "__all__"
18+
1719
@classmethod
1820
def setUpTestData(cls):
1921
provision_device()
@@ -71,6 +73,8 @@ def test_filter_by_semantic_versioning(self):
7173

7274

7375
class PingbackNotificationDismissedTestCase(APITestCase):
76+
databases = "__all__"
77+
7478
@classmethod
7579
def setUpTestData(cls):
7680
provision_device()

kolibri/core/auth/api.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@
5656

5757
from .constants import collection_kinds
5858
from .constants import role_kinds
59-
from .middleware import clear_user_cache_on_delete
6059
from .models import Classroom
6160
from .models import Collection
6261
from .models import Facility
@@ -594,7 +593,6 @@ def destroy(self, request, *args, **kwargs):
594593
user = self.get_object()
595594
user.date_deleted = now()
596595
user.save()
597-
self._invalidate_removed_users_session([user])
598596
try:
599597
cleanup_expired_deleted_users.enqueue()
600598
except JobRunning:
@@ -604,22 +602,10 @@ def destroy(self, request, *args, **kwargs):
604602
# Bulk deletion
605603
return self.bulk_destroy(request, *args, **kwargs)
606604

607-
def _invalidate_removed_users_session(self, users):
608-
"""
609-
Invalidate removed users sessions by clearing their cache.
610-
So the next time they make a request, the auth middleware will try to fetch
611-
the most up-to-date information for the user, and won't find the user
612-
since it was soft deleted.
613-
"""
614-
for user in users:
615-
clear_user_cache_on_delete(None, user)
616-
617605
def perform_bulk_destroy(self, objects):
618606
if objects.filter(id=self.request.user.id).exists():
619607
raise PermissionDenied("Super user cannot delete self")
620-
removed_users = list(objects)
621608
objects.update(date_deleted=now())
622-
self._invalidate_removed_users_session(removed_users)
623609

624610
def perform_update(self, serializer):
625611
instance = serializer.save()

kolibri/core/auth/backends.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
The appropriate classes should be listed in the AUTHENTICATION_BACKENDS. Note that authentication
44
backends are checked in the order they're listed.
55
"""
6+
from django.contrib.sessions.backends.db import SessionStore as DBStore
7+
68
from kolibri.core.auth.models import FacilityUser
9+
from kolibri.core.auth.models import Session
710

811

912
FACILITY_CREDENTIAL_KEY = "facility"
@@ -71,3 +74,24 @@ def get_user(self, user_id):
7174
return FacilityUser.objects.get(pk=user_id)
7275
except FacilityUser.DoesNotExist:
7376
return None
77+
78+
79+
class SessionStore(DBStore):
80+
@classmethod
81+
def get_model_class(cls):
82+
return Session
83+
84+
def create_model_instance(self, data):
85+
obj = super().create_model_instance(data)
86+
try:
87+
user_id = data.get("_auth_user_id")
88+
except (ValueError, TypeError):
89+
user_id = None
90+
obj.user_id = user_id
91+
return obj
92+
93+
@classmethod
94+
def delete_all_sessions(cls, user_ids):
95+
store = cls()
96+
sessions = store.get_model_class().objects.filter(user_id__in=user_ids)
97+
sessions.delete()

kolibri/core/auth/kolibri_plugin.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from kolibri.core.auth.hooks import FacilityDataSyncHook
66
from kolibri.core.auth.models import FacilityUser
7+
from kolibri.core.auth.models import Session
78
from kolibri.core.auth.sync_operations import KolibriSingleUserSyncOperation
89
from kolibri.core.auth.sync_operations import KolibriSyncOperationMixin
910
from kolibri.core.auth.tasks import cleanupsync
@@ -22,6 +23,26 @@ def handle_local_user(self, context, user_id):
2223
return False
2324

2425

26+
def cleanup_sessions(user_ids):
27+
"""
28+
Clean up sessions for all hard and soft-deleted users in the synced dataset.
29+
30+
:type dataset_id: str
31+
"""
32+
33+
all_user_ids = set(user_ids)
34+
35+
# Query all soft-deleted users in this dataset
36+
still_active_users = set(
37+
FacilityUser.objects.filter(id__in=user_ids).values_list("id", flat=True)
38+
)
39+
user_ids_to_delete = all_user_ids - still_active_users
40+
41+
# Clean up sessions for these users
42+
if user_ids_to_delete:
43+
Session.delete_all_sessions(user_ids_to_delete)
44+
45+
2546
class CleanUpTaskOperation(KolibriSyncOperationMixin, LocalOperation):
2647
def handle_initial(self, context):
2748
"""
@@ -62,3 +83,18 @@ def handle_initial(self, context):
6283
class AuthSyncHook(FacilityDataSyncHook):
6384
serializing_operations = [SingleFacilityUserChangeClearingOperation()]
6485
cleanup_operations = [CleanUpTaskOperation()]
86+
87+
def post_transfer(
88+
self,
89+
dataset_id,
90+
local_is_single_user,
91+
remote_is_single_user,
92+
single_user_id,
93+
context,
94+
):
95+
96+
if context.is_receiver:
97+
user_ids = context.transfer_session.get_touched_record_ids_for_model(
98+
FacilityUser
99+
)
100+
cleanup_sessions(user_ids)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Generated by Django 3.2.25 on 2025-09-17 02:30
2+
import morango.models.fields.uuids
3+
from django.db import migrations
4+
from django.db import models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("kolibriauth", "0030_alter_facilityuser_managers"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="Session",
16+
fields=[
17+
(
18+
"session_key",
19+
models.CharField(
20+
max_length=40,
21+
primary_key=True,
22+
serialize=False,
23+
verbose_name="session key",
24+
),
25+
),
26+
("session_data", models.TextField(verbose_name="session data")),
27+
(
28+
"expire_date",
29+
models.DateTimeField(db_index=True, verbose_name="expire date"),
30+
),
31+
(
32+
"user_id",
33+
morango.models.fields.uuids.UUIDField(
34+
blank=True, db_index=True, null=True
35+
),
36+
),
37+
],
38+
options={
39+
"verbose_name": "session",
40+
"verbose_name_plural": "sessions",
41+
"abstract": False,
42+
},
43+
),
44+
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 3.2.25 on 2025-10-30 01:01
2+
from django.db import migrations
3+
4+
import kolibri.core.auth.models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("kolibriauth", "0031_session"),
11+
]
12+
13+
operations = [
14+
migrations.AlterModelManagers(
15+
name="facilityuser",
16+
managers=[
17+
(
18+
"all_objects",
19+
kolibri.core.auth.models.AllObjectsFacilityUserModelManager(),
20+
),
21+
("objects", kolibri.core.auth.models.FacilityUserModelManager()),
22+
(
23+
"syncing_objects",
24+
kolibri.core.auth.models.BaseFacilityUserModelManager(),
25+
),
26+
(
27+
"soft_deleted_objects",
28+
kolibri.core.auth.models.SoftDeletedFacilityUserModelManager(),
29+
),
30+
],
31+
),
32+
]

kolibri/core/auth/models.py

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django.contrib.auth.models import AbstractBaseUser
2525
from django.contrib.auth.models import AnonymousUser
2626
from django.contrib.auth.models import UserManager
27+
from django.contrib.sessions.base_session import AbstractBaseSession
2728
from django.core import validators
2829
from django.core.exceptions import ObjectDoesNotExist
2930
from django.core.exceptions import ValidationError
@@ -35,6 +36,8 @@
3536
from morango.models import Certificate
3637
from morango.models import SyncableModel
3738
from morango.models import SyncableModelManager
39+
from morango.models import SyncableModelQuerySet
40+
from morango.models import UUIDField
3841
from mptt.models import TreeForeignKey
3942

4043
from .constants import collection_kinds
@@ -78,12 +81,45 @@
7881
from kolibri.core.errors import KolibriValidationError
7982
from kolibri.core.fields import DateTimeTzField
8083
from kolibri.core.fields import JSONField
84+
from kolibri.core.utils.model_router import KolibriModelRouter
8185
from kolibri.core.utils.validators import JSON_Schema_Validator
86+
from kolibri.deployment.default.sqlite_db_names import SESSIONS
8287
from kolibri.plugins.app.utils import interface
8388
from kolibri.utils.time_utils import local_now
8489

8590
logger = logging.getLogger(__name__)
8691

92+
93+
class Session(AbstractBaseSession):
94+
"""
95+
Custom session model with user_id tracking for session management.
96+
Inherits from Django's AbstractBaseSession and adds user_id field.
97+
"""
98+
99+
user_id = UUIDField(blank=True, null=True, db_index=True)
100+
101+
@classmethod
102+
def get_session_store_class(cls):
103+
from .backends import SessionStore
104+
105+
return SessionStore
106+
107+
@classmethod
108+
def delete_all_sessions(cls, user_ids):
109+
store_class = cls.get_session_store_class()
110+
store_class.delete_all_sessions(user_ids)
111+
logger.info("Deleted all sessions for user IDs: {}".format(user_ids))
112+
113+
114+
class SessionRouter(KolibriModelRouter):
115+
"""
116+
Determine how to route database calls for custom Session model.
117+
"""
118+
119+
MODEL_CLASSES = {Session}
120+
DB_NAME = SESSIONS
121+
122+
87123
DEMOGRAPHIC_FIELDS_KEY = "demographic_fields"
88124

89125

@@ -631,7 +667,43 @@ def filter_readable(self, queryset):
631667
return queryset.none()
632668

633669

634-
class FacilityUserModelManager(SyncableModelManager, UserManager):
670+
class FacilityUserQuerySet(SyncableModelQuerySet):
671+
def update(self, **kwargs):
672+
# Check if date_deleted is being set to a non-null value (soft delete)
673+
user_ids = []
674+
if "date_deleted" in kwargs and kwargs["date_deleted"] is not None:
675+
# Get user IDs that are currently not soft deleted
676+
user_ids = list(
677+
self.filter(date_deleted__isnull=True).values_list("id", flat=True)
678+
)
679+
680+
# Perform the update
681+
result = super().update(**kwargs)
682+
683+
# Clean up sessions for soft deleted users
684+
if user_ids:
685+
Session.delete_all_sessions(user_ids)
686+
687+
return result
688+
689+
def delete(self):
690+
# Get user IDs before deletion for session cleanup
691+
user_ids = list(self.values_list("id", flat=True))
692+
693+
# Perform the deletion
694+
result = super().delete()
695+
696+
# Clean up sessions after successful deletion
697+
Session.delete_all_sessions(user_ids)
698+
return result
699+
700+
701+
class BaseFacilityUserModelManager(SyncableModelManager, UserManager):
702+
def get_queryset(self):
703+
return FacilityUserQuerySet(self.model, using=self._db)
704+
705+
706+
class FacilityUserModelManager(BaseFacilityUserModelManager):
635707
def get_queryset(self):
636708
return super().get_queryset().filter(date_deleted__isnull=True)
637709

@@ -718,7 +790,7 @@ def get_or_create_os_user(self, auth_token, facility=None):
718790
return user
719791

720792

721-
class SoftDeletedFacilityUserModelManager(SyncableModelManager, UserManager):
793+
class SoftDeletedFacilityUserModelManager(BaseFacilityUserModelManager):
722794
"""
723795
Custom manager for FacilityUser that only returns users who have a non-NULL value in their date_deleted field.
724796
"""
@@ -773,7 +845,7 @@ def validate_role_kinds(kinds):
773845
return kinds
774846

775847

776-
class AllObjectsFacilityUserModelManager(SyncableModelManager, UserManager):
848+
class AllObjectsFacilityUserModelManager(BaseFacilityUserModelManager):
777849
def get_queryset(self):
778850
return super(AllObjectsFacilityUserModelManager, self).get_queryset()
779851

@@ -804,7 +876,7 @@ class FacilityUser(AbstractBaseUser, KolibriBaseUserMixin, AbstractFacilityDataM
804876

805877
all_objects = AllObjectsFacilityUserModelManager()
806878
objects = FacilityUserModelManager()
807-
879+
syncing_objects = BaseFacilityUserModelManager()
808880
soft_deleted_objects = SoftDeletedFacilityUserModelManager()
809881

810882
USERNAME_FIELD = "username"
@@ -1061,6 +1133,28 @@ def __str__(self):
10611133
user=self.full_name or self.username, facility=self.facility
10621134
)
10631135

1136+
def save(self, *args, **kwargs):
1137+
# Call the parent save method first
1138+
result = super().save(*args, **kwargs)
1139+
1140+
# Clean up sessions after successful save if user is deleted
1141+
# This handles both local deletions and deletions synced via Morango
1142+
# (Morango creates new instances from deserialized data, so we can't
1143+
# reliably track changes - just clean up whenever date_deleted is set)
1144+
if self.date_deleted is not None and self.pk is not None:
1145+
Session.delete_all_sessions([self.id])
1146+
1147+
return result
1148+
1149+
def delete(self, *args, **kwargs):
1150+
"""
1151+
Override delete to ensure sessions are cleaned up during hard delete.
1152+
"""
1153+
user_id = self.id
1154+
result = super().delete(*args, **kwargs)
1155+
Session.delete_all_sessions([user_id])
1156+
return result
1157+
10641158
def has_perm(self, perm, obj=None):
10651159
# ensure the superuser has full access to the Django admin
10661160
if self.is_superuser:

0 commit comments

Comments
 (0)