Repository: zulip/zulip

Issue:
Extend and document logout_all_users management command
We currently don't have a documented/recommended option if a system administrator wants to logout all users.

The undocumented `./manage.py logout_all_users` management command will delete all browser sessions, but doesn't do anything with API keys (which it should probably do by default, with an option to skip).

See https://chat.zulip.org/#narrow/stream/9-issues/topic/force.20logout/near/1235628 for some background.


Discussion:
Hello @zulip/server-production, @zulip/server-tooling members, this issue was labeled with the "area: documentation (production)", "area: tooling" labels, so you may want to check it out!

<!-- areaLabelAddition -->

Resolving patch:
diff --git a/zerver/actions/user_settings.py b/zerver/actions/user_settings.py
--- a/zerver/actions/user_settings.py
+++ b/zerver/actions/user_settings.py
@@ -1,5 +1,5 @@
 import datetime
-from typing import Optional, Union
+from typing import List, Optional, Union
 
 import orjson
 from django.db import transaction
@@ -29,6 +29,7 @@
     UserProfile,
     active_user_ids,
     bot_owner_user_ids,
+    get_user_profile_by_id,
 )
 from zerver.tornado.django_api import send_event
 
@@ -246,6 +247,12 @@ def do_regenerate_api_key(user_profile: UserProfile, acting_user: UserProfile) -
     return new_api_key
 
 
+def bulk_regenerate_api_keys(user_profile_ids: List[int]) -> None:
+    for user_profile_id in user_profile_ids:
+        user_profile = get_user_profile_by_id(user_profile_id)
+        do_regenerate_api_key(user_profile, user_profile)
+
+
 def notify_avatar_url_change(user_profile: UserProfile) -> None:
     if user_profile.is_bot:
         bot_event = dict(
diff --git a/zerver/management/commands/logout_all_users.py b/zerver/management/commands/logout_all_users.py
--- a/zerver/management/commands/logout_all_users.py
+++ b/zerver/management/commands/logout_all_users.py
@@ -1,12 +1,16 @@
 from argparse import ArgumentParser
 from typing import Any
 
+from django.db.models import Q
+
+from zerver.actions.user_settings import bulk_regenerate_api_keys
 from zerver.lib.management import ZulipBaseCommand
 from zerver.lib.sessions import (
     delete_all_deactivated_user_sessions,
     delete_all_user_sessions,
     delete_realm_user_sessions,
 )
+from zerver.models import UserProfile
 
 
 class Command(ZulipBaseCommand):
@@ -23,13 +27,29 @@ def add_arguments(self, parser: ArgumentParser) -> None:
             action="store_true",
             help="Only log out all users who are deactivated",
         )
+        parser.add_argument(
+            "--rotate-api-keys",
+            action="store_true",
+            help="Also rotate API keys of the affected users",
+        )
         self.add_realm_args(parser, help="Only log out all users in a particular realm")
 
     def handle(self, *args: Any, **options: Any) -> None:
         realm = self.get_realm(options)
+        rotate_api_keys = options["rotate_api_keys"]
         if realm:
             delete_realm_user_sessions(realm)
+            regenerate_api_key_queryset = UserProfile.objects.filter(realm=realm).values_list(
+                "id", flat=True
+            )
         elif options["deactivated_only"]:
             delete_all_deactivated_user_sessions()
+            regenerate_api_key_queryset = UserProfile.objects.filter(
+                Q(is_active=False) | Q(realm__deactivated=True)
+            ).values_list("id", flat=True)
         else:
             delete_all_user_sessions()
+            regenerate_api_key_queryset = UserProfile.objects.values_list("id", flat=True)
+
+        if rotate_api_keys:
+            bulk_regenerate_api_keys(regenerate_api_key_queryset)


Test patch:
diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py
--- a/zerver/tests/test_users.py
+++ b/zerver/tests/test_users.py
@@ -17,6 +17,7 @@
 from zerver.actions.message_send import get_recipient_info
 from zerver.actions.muted_users import do_mute_user
 from zerver.actions.realm_settings import do_set_realm_property
+from zerver.actions.user_settings import bulk_regenerate_api_keys
 from zerver.actions.users import (
     change_user_is_active,
     do_change_can_create_users,
@@ -2271,3 +2272,25 @@ def test_invalid_fake_email_domain_ip(self) -> None:
         with self.assertRaises(InvalidFakeEmailDomain):
             realm = get_realm("zulip")
             get_fake_email_domain(realm)
+
+
+class TestBulkRegenerateAPIKey(ZulipTestCase):
+    def test_bulk_regenerate_api_keys(self) -> None:
+        hamlet = self.example_user("hamlet")
+        cordelia = self.example_user("cordelia")
+        othello = self.example_user("othello")
+
+        hamlet_old_api_key = hamlet.api_key
+        cordelia_old_api_key = cordelia.api_key
+        othello_old_api_key = othello.api_key
+
+        bulk_regenerate_api_keys([hamlet.id, cordelia.id])
+
+        hamlet.refresh_from_db()
+        cordelia.refresh_from_db()
+        othello.refresh_from_db()
+
+        self.assertNotEqual(hamlet_old_api_key, hamlet.api_key)
+        self.assertNotEqual(cordelia_old_api_key, cordelia.api_key)
+
+        self.assertEqual(othello_old_api_key, othello.api_key)