Repository: zulip/zulip

Issue:
Disable various Django internal management commands that sysadmins should never run
If you run just `./manage.py help`, it prints out all the supported management commands for a Zulip server.  This ends up being pretty awkward, in that it lists various built-in Django management commands that sound relevant but are neither used by Zulip not things that are a good idea to try to run (usually they just give confusing errors).

The main `./manage.py` script in the Zulip repository does some setup and then calls this:
```
def execute_from_command_line(argv=None):                                                            
    """Run a ManagementUtility."""                                                                   
    utility = ManagementUtility(argv)                                                                
    utility.execute()                                                                                
```

So I think we implement do this by subclassing `ManagementUtility` to override the `main_help_text` function with a variant that effectively filters the `get_commands` function to remove some commands.

A first version of this should remove everything in the `[django]` block except these 5, probably most simply implemented as an allowlist:
```
[django]
    dbshell
    makemigrations
    migrate
    shell
    showmigrations
```

But we will probably want to extend that once implemented.




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

<!-- areaLabelAddition -->
@zulipbot claim

Resolving patch:
diff --git a/analytics/management/commands/stream_stats.py b/analytics/management/commands/stream_stats.py
deleted file mode 100644
--- a/analytics/management/commands/stream_stats.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from argparse import ArgumentParser
-from typing import Any
-
-from django.core.management.base import BaseCommand, CommandError
-from django.db.models import Q
-
-from zerver.models import Message, Realm, Recipient, Stream, Subscription, get_realm
-
-
-class Command(BaseCommand):
-    help = "Generate statistics on the streams for a realm."
-
-    def add_arguments(self, parser: ArgumentParser) -> None:
-        parser.add_argument(
-            "realms", metavar="<realm>", nargs="*", help="realm to generate statistics for"
-        )
-
-    def handle(self, *args: Any, **options: str) -> None:
-        if options["realms"]:
-            try:
-                realms = [get_realm(string_id) for string_id in options["realms"]]
-            except Realm.DoesNotExist as e:
-                raise CommandError(e)
-        else:
-            realms = Realm.objects.all()
-
-        for realm in realms:
-            streams = Stream.objects.filter(realm=realm).exclude(Q(name__istartswith="tutorial-"))
-            # private stream count
-            private_count = 0
-            # public stream count
-            public_count = 0
-            for stream in streams:
-                if stream.invite_only:
-                    private_count += 1
-                else:
-                    public_count += 1
-            print("------------")
-            print(realm.string_id, end=" ")
-            print("{:>10} {} public streams and".format("(", public_count), end=" ")
-            print(f"{private_count} private streams )")
-            print("------------")
-            print("{:>25} {:>15} {:>10} {:>12}".format("stream", "subscribers", "messages", "type"))
-
-            for stream in streams:
-                if stream.invite_only:
-                    stream_type = "private"
-                else:
-                    stream_type = "public"
-                print(f"{stream.name:>25}", end=" ")
-                recipient = Recipient.objects.filter(type=Recipient.STREAM, type_id=stream.id)
-                print(
-                    "{:10}".format(
-                        len(Subscription.objects.filter(recipient=recipient, active=True))
-                    ),
-                    end=" ",
-                )
-                num_messages = len(Message.objects.filter(recipient=recipient))
-                print(f"{num_messages:12}", end=" ")
-                print(f"{stream_type:>15}")
-            print("")
diff --git a/manage.py b/manage.py
--- a/manage.py
+++ b/manage.py
@@ -2,6 +2,7 @@
 import configparser
 import os
 import sys
+from typing import Dict, List, Optional
 
 if sys.version_info <= (3, 0):
     print("Error: Zulip is a Python 3 project, and cannot be run with Python 2.")
@@ -14,8 +15,112 @@
 
 setup_path()
 
+from collections import defaultdict
+
+from django.core.management import ManagementUtility, get_commands
+from django.core.management.color import color_style
+
 from scripts.lib.zulip_tools import assert_not_running_as_root
 
+
+def get_filtered_commands() -> Dict[str, str]:
+    """Because Zulip uses management commands in production, `manage.py
+    help` is a form of documentation for users. Here we exclude from
+    that documentation built-in commands that are not constructive for
+    end users or even Zulip developers to run.
+
+    Ideally, we'd do further customization to display management
+    commands with more organization in the help text, and also hide
+    development-focused management commands in production.
+    """
+    all_commands = get_commands()
+    documented_commands = dict()
+    documented_apps = [
+        # "auth" removed because its commands are not applicable to Zulip.
+        # "contenttypes" removed because we don't use that subsystem, and
+        #   even if we did.
+        "django.core",
+        "analytics",
+        # "otp_static" removed because it's a 2FA internals detail.
+        # "sessions" removed because it's just a cron job with a misleading
+        #   name, since all it does is delete expired sessions.
+        # "social_django" removed for similar reasons to sessions.
+        # "staticfiles" removed because its commands are only usefully run when
+        #   wrapped by Zulip tooling.
+        # "two_factor" removed because it's a 2FA internals detail.
+        "zerver",
+        "zilencer",
+    ]
+    documented_command_subsets = {
+        "django.core": {
+            "dbshell",
+            "makemigrations",
+            "migrate",
+            "shell",
+            "showmigrations",
+        },
+    }
+    for command, app in all_commands.items():
+        if app not in documented_apps:
+            continue
+        if app in documented_command_subsets:
+            if command not in documented_command_subsets[app]:
+                continue
+
+        documented_commands[command] = app
+    return documented_commands
+
+
+class FilteredManagementUtility(ManagementUtility):
+    """Replaces the main_help_text function of ManagementUtility with one
+    that calls our get_filtered_commands(), rather than the default
+    get_commands() function.
+
+    All other change are just code style differences to pass the Zulip linter.
+    """
+
+    def main_help_text(self, commands_only: bool = False) -> str:
+        """Return the script's main help text, as a string."""
+        if commands_only:
+            usage = sorted(get_filtered_commands())
+        else:
+            usage = [
+                "",
+                f"Type '{self.prog_name} help <subcommand>' for help on a specific subcommand.",
+                "",
+                "Available subcommands:",
+            ]
+            commands_dict = defaultdict(lambda: [])
+            for name, app in get_filtered_commands().items():
+                if app == "django.core":
+                    app = "django"
+                else:
+                    app = app.rpartition(".")[-1]
+                commands_dict[app].append(name)
+            style = color_style()
+            for app in sorted(commands_dict):
+                usage.append("")
+                usage.append(style.NOTICE(f"[{app}]"))
+                for name in sorted(commands_dict[app]):
+                    usage.append(f"    {name}")
+            # Output an extra note if settings are not properly configured
+            if self.settings_exception is not None:
+                usage.append(
+                    style.NOTICE(
+                        "Note that only Django core commands are listed "
+                        f"as settings are not properly configured (error: {self.settings_exception})."
+                    )
+                )
+
+        return "\n".join(usage)
+
+
+def execute_from_command_line(argv: Optional[List[str]] = None) -> None:
+    """Run a FilteredManagementUtility."""
+    utility = FilteredManagementUtility(argv)
+    utility.execute()
+
+
 if __name__ == "__main__":
     assert_not_running_as_root()
 
@@ -38,7 +143,6 @@
 
     os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zproject.settings")
     from django.conf import settings
-    from django.core.management import execute_from_command_line
     from django.core.management.base import CommandError
 
     from scripts.lib.zulip_tools import log_management_command


Test patch: