Repository: rabbitmq/rabbitmq-server (language: javascript)

Issue:
Protection of virtual hosts from "fat finger" deletion
## Problem Definition

Every so often we see users delete virtual hosts by accident. This can happen both over the HTTP API
and with `rabbitmqctl delete_vhost`.

When this happen, [certain kinds of virtual host data](https://www.rabbitmq.com/docs/definitions) can be
restored relatively easily. The definitions data set is small (compared to tens or hundreds of
GiBs a stream can contain, for example). Unfortunately, for messages stored in queues (not streams),
the backup and restore problem is a **very** different in scope.

[Tanzu RabbitMQ](https://docs.vmware.com/en/VMware-Tanzu-RabbitMQ/index.html) has a Warm Standby Replication feature that replicates both schema and messages (for QQs, streams, CQs) to a remote warm standby cluster or multiple clusters. This does help with certain fat finger errors but not all of them,
and only if a reasonably large schema synchronization interval is used.

Since virtual host deletion is one of the most destructive operations (up there with node reset),
it could use some extra protection, similarly to how opt-in (experimental) feature flag enablement was made much more difficult to do accidentally in https://github.com/rabbitmq/rabbitmq-server/pull/11998, https://github.com/rabbitmq/rabbitmq-server/pull/12643.

In addition, specific clusters, systems, and RabbitMQ-based distributions such as Tanzu RabbitMQ can
have system virtual host. "System" here means .


## Proposed Solution

Virtual hosts can be marked with additional [metadata](https://www.rabbitmq.com/docs/vhosts#metadata). This metadata today stores the DQT (default queue type), a description, a and set of tags.

The same metadata map can be used to enable protection from deletion. If set,
`rabbitmqctl delete_vhost` and `DELETE /api/vhosts/{name}` will fail with a reasonably specific
error asking the user to delete the metadata key from the target virtual host first, then retry.

Deletion of the key will act as a confirmation for CLI tools, and will require the same permissions
as `DELETE /api/vhosts/{name}` in the case of HTTP API.

Dedicated CLI commands and HTTP API endpoints will be provided for locking and unlocking deletion.


## Currently Available Options

`DELETE /api/vhosts/{name}` requires the user to be tagged as `administrator`.

For CLI tools, such "roles" do not exist, so an explicit confirmation is particularly important to have.


Discussion / hints:
Thank you, @michaelklishin, for developing/enhancing the extra-protection feature! Additionally, is it possible to add another feature option to duplicate virtual hosts? This would enable users to create exact copies of virtual hosts, including their configurations, messages, and resources. Such a feature would be incredibly useful for testing, backup, recovery, and migration, as it allows for modifications without affecting the original setup or provides a quick restore option.
I like this idea. I'm wondering about a "force" parameter both both `rabbitmqctl delete_vhost` and `DELETE /api/vhosts/{name}`. Similar how `rm` has `rm -f` flag. The user would be explicitly requesting JustDoIt ❗ By default, the protection, if present, will stop the deletion. 

A "force" option could improve UX, because I only have to remember to add `--force`; whereas to remove the metadata, I have to remember 2 additional pieces of information: 1) remove vhost metadata command + 2) the metadata protection tag name. I hear the argument that a "force" option would partly defeat the purpose of this protection. I think it would be a matter of preference how robust/strict we want to make this protection.
@sateeshkvk7 a virtual host can have many GiBs of data, so "duplicating" it is not something we plan on doing. WSR in Tanzu RabbitmQ "continuously duplicates" a cluster and it took several person-years to get there.

This issue is for OSS RabbitMQ and only has to do with a deletion protection mechanism.
@Zerpet that's the point, make you remember and have to run an additional command. You have opted in for deletion protection after all. There isn't really a `-f` equivalent for the HTTP API.

For CLI tools, we can also add a `-y` or `--confirm` flag but that seems a bit pointless to me because it will become a part of all scripts, shell histories, muscle memory and in the end won't really have any effect.

Explicitly opting in for deletion protection and the requirement to explicitly lift it won't be used "as automatically".
> @Zerpet that's the point, make you remember and have to run an additional command. You have opted in for deletion protection after all. There isn't really a `-f` equivalent for the HTTP API.
>
> For CLI tools, we can also add a `-y` or `--confirm` flag but that seems a bit pointless to me because it will become a part of all scripts, shell histories, muscle memory and in the end won't really have any effect.

I was thinking about an additional query parameter to support "force" delete via the HTTP API. I see your point, and I think it's reasonable. If we go down this route, it would be useful to have dedicated commands to add or remove the metadata protection tag. I agree with having to remember one command (to update vhost tags), but I think remembering the specific protection tag (in addition to the command) would lead to bad UX; specially if we expect this command to be used infrequently i.e. how often do you create/delete vhosts?
@Zerpet yes, dedicated CLI commands and HTTP API endpoints will be provided.

Fix patch:
diff --git a/deps/rabbit/src/rabbit_db_vhost.erl b/deps/rabbit/src/rabbit_db_vhost.erl
index 1b2a1d6f3c5e..9c925fcb0255 100644
--- a/deps/rabbit/src/rabbit_db_vhost.erl
+++ b/deps/rabbit/src/rabbit_db_vhost.erl
@@ -23,6 +23,8 @@
          count_all/0,
          list/0,
          update/2,
+         enable_protection_from_deletion/1,
+         disable_protection_from_deletion/1,
          with_fun_in_mnesia_tx/2,
          with_fun_in_khepri_tx/2,
          delete/1,
@@ -224,6 +226,36 @@ set_tags_in_khepri(VHostName, Tags) ->
     UpdateFun = fun(VHost) -> do_set_tags(VHost, Tags) end,
     update_in_khepri(VHostName, UpdateFun).
 
+%% -------------------------------------------------------------------
+%% Deletion protection
+%% -------------------------------------------------------------------
+
+-spec enable_protection_from_deletion(VHostName) -> Ret when
+    VHostName :: vhost:name(),
+    Ret :: {ok, VHost} |
+    {error, {no_such_vhost, VHostName}} |
+    rabbit_khepri:timeout_error(),
+    VHost :: vhost:vhost().
+enable_protection_from_deletion(VHostName) ->
+    MetadataPatch = #{
+        protected_from_deletion => true
+    },
+    rabbit_log:info("Enabling deletion protection for virtual host '~ts'", [VHostName]),
+    merge_metadata(VHostName, MetadataPatch).
+
+-spec disable_protection_from_deletion(VHostName) -> Ret when
+    VHostName :: vhost:name(),
+    Ret :: {ok, VHost} |
+    {error, {no_such_vhost, VHostName}} |
+    rabbit_khepri:timeout_error(),
+    VHost :: vhost:vhost().
+disable_protection_from_deletion(VHostName) ->
+    MetadataPatch = #{
+        protected_from_deletion => false
+    },
+    rabbit_log:info("Disabling deletion protection for virtual host '~ts'", [VHostName]),
+    merge_metadata(VHostName, MetadataPatch).
+
 %% -------------------------------------------------------------------
 %% exists().
 %% -------------------------------------------------------------------
diff --git a/deps/rabbit/src/rabbit_definitions.erl b/deps/rabbit/src/rabbit_definitions.erl
index 1a707702ec39..0f69b3ddf424 100644
--- a/deps/rabbit/src/rabbit_definitions.erl
+++ b/deps/rabbit/src/rabbit_definitions.erl
@@ -786,8 +786,17 @@ add_vhost(VHost, ActingUser) ->
     case rabbit_vhost:put_vhost(Name, Description, Tags, DefaultQueueType, IsTracingEnabled, ActingUser) of
         ok ->
             ok;
-        {error, _} = Err ->
-            throw(Err)
+        {error, _} = Err1 ->
+            throw(Err1)
+    end,
+
+    %% The newly created virtual host won't have all the metadata keys. Rather than
+    %% changing the functions above, simply update the metadata as a separate step.
+    case rabbit_vhost:update_metadata(Name, Metadata, ActingUser) of
+        ok ->
+            ok;
+        {error, _} = Err2 ->
+            throw(Err2)
     end.
 
 add_permission(Permission, ActingUser) ->
diff --git a/deps/rabbit/src/rabbit_vhost.erl b/deps/rabbit/src/rabbit_vhost.erl
index 26c25187da26..ce53154d7e08 100644
--- a/deps/rabbit/src/rabbit_vhost.erl
+++ b/deps/rabbit/src/rabbit_vhost.erl
@@ -11,11 +11,11 @@
 -include("vhost.hrl").
 
 -export([recover/0, recover/1, read_config/1]).
--export([add/2, add/3, add/4, delete/2, exists/1, assert/1,
+-export([add/2, add/3, add/4, delete/2, delete_ignoring_protection/2, exists/1, assert/1,
          set_limits/2, vhost_cluster_state/1, is_running_on_all_nodes/1, await_running_on_all_nodes/2,
         list/0, count/0, list_names/0, all/0, all_tagged_with/1]).
 -export([parse_tags/1, update_tags/3]).
--export([update_metadata/3]).
+-export([update_metadata/3, enable_protection_from_deletion/1, disable_protection_from_deletion/1]).
 -export([lookup/1, default_name/0]).
 -export([info/1, info/2, info_all/0, info_all/1, info_all/2, info_all/3]).
 -export([dir/1, msg_store_dir_path/1, msg_store_dir_wildcard/0, msg_store_dir_base/0, config_file_path/1, ensure_config_file/1]).
@@ -253,20 +253,37 @@ declare_default_exchanges(VHostName, ActingUser) ->
       end, DefaultExchanges).
 
 -spec update_metadata(vhost:name(), vhost:metadata(), rabbit_types:username()) -> rabbit_types:ok_or_error(any()).
+update_metadata(Name, undefined, _ActingUser) ->
+    case rabbit_db_vhost:exists(Name) of
+        true ->
+            ok;
+        false ->
+            {error, {no_such_vhost, Name}}
+    end;
+update_metadata(Name, Metadata0, _ActingUser) when is_map(Metadata0) andalso map_size(Metadata0) =:= 0 ->
+    case rabbit_db_vhost:exists(Name) of
+        true ->
+            ok;
+        false ->
+            {error, {no_such_vhost, Name}}
+    end;
 update_metadata(Name, Metadata0, ActingUser) ->
-    Metadata = maps:with([description, tags, default_queue_type], Metadata0),
+    KnownKeys = [description, tags, default_queue_type, protected_from_deletion],
+    Metadata = maps:with(KnownKeys, Metadata0),
 
     case rabbit_db_vhost:merge_metadata(Name, Metadata) of
         {ok, VHost} ->
             Description = vhost:get_description(VHost),
             Tags = vhost:get_tags(VHost),
             DefaultQueueType = vhost:get_default_queue_type(VHost),
+            IsProtected = vhost:is_protected_from_deletion(VHost),
             rabbit_event:notify(
               vhost_updated,
               info(VHost) ++ [{user_who_performed_action, ActingUser},
                               {description, Description},
                               {tags, Tags},
-                              {default_queue_type, DefaultQueueType}]),
+                              {default_queue_type, DefaultQueueType},
+                              {deletion_protection, IsProtected}]),
             ok;
         {error, _} = Error ->
             Error
@@ -278,45 +295,61 @@ update(Name, Description, Tags, DefaultQueueType, ActingUser) ->
     update_metadata(Name, Metadata, ActingUser).
 
 -spec delete(vhost:name(), rabbit_types:username()) -> rabbit_types:ok_or_error(any()).
-delete(VHost, ActingUser) ->
+delete(Name, ActingUser) ->
+    case rabbit_db_vhost:get(Name) of
+        %% preserve the original behavior for backwards compatibility
+        undefined -> delete_ignoring_protection(Name, ActingUser);
+        VHost ->
+            case vhost:is_protected_from_deletion(VHost) of
+                true ->
+                    Msg = "Refusing to delete virtual host '~ts' because it is protected from deletion",
+                    rabbit_log:debug(Msg, [Name]),
+                    {error, protected_from_deletion};
+                false ->
+                    delete_ignoring_protection(Name, ActingUser)
+            end
+    end.
+
+-spec delete_ignoring_protection(vhost:name(), rabbit_types:username()) -> rabbit_types:ok_or_error(any()).
+delete_ignoring_protection(Name, ActingUser) ->
     %% FIXME: We are forced to delete the queues and exchanges outside
     %% the TX below. Queue deletion involves sending messages to the queue
     %% process, which in turn results in further database actions and
     %% eventually the termination of that process. Exchange deletion causes
     %% notifications which must be sent outside the TX
-    rabbit_log:info("Deleting vhost '~ts'", [VHost]),
+    rabbit_log:info("Deleting vhost '~ts'", [Name]),
     %% TODO: This code does a lot of "list resources, walk through the list to
     %% delete each resource". This feature should be provided by each called
     %% modules, like `rabbit_amqqueue:delete_all_for_vhost(VHost)'. These new
     %% calls would be responsible for the atomicity, not this code.
     %% Clear the permissions first to prohibit new incoming connections when deleting a vhost
-    rabbit_log:info("Clearing permissions in vhost '~ts' because it's being deleted", [VHost]),
-    ok = rabbit_auth_backend_internal:clear_all_permissions_for_vhost(VHost, ActingUser),
-    rabbit_log:info("Deleting queues in vhost '~ts' because it's being deleted", [VHost]),
+    rabbit_log:info("Clearing permissions in vhost '~ts' because it's being deleted", [Name]),
+    ok = rabbit_auth_backend_internal:clear_all_permissions_for_vhost(Name, ActingUser),
+    rabbit_log:info("Deleting queues in vhost '~ts' because it's being deleted", [Name]),
     QDelFun = fun (Q) -> rabbit_amqqueue:delete(Q, false, false, ActingUser) end,
     [begin
-         Name = amqqueue:get_name(Q),
-         assert_benign(rabbit_amqqueue:with(Name, QDelFun), ActingUser)
-     end || Q <- rabbit_amqqueue:list(VHost)],
-    rabbit_log:info("Deleting exchanges in vhost '~ts' because it's being deleted", [VHost]),
-    ok = rabbit_exchange:delete_all(VHost, ActingUser),
-    rabbit_log:info("Clearing policies and runtime parameters in vhost '~ts' because it's being deleted", [VHost]),
-    _ = rabbit_runtime_parameters:clear_vhost(VHost, ActingUser),
-    rabbit_log:debug("Removing vhost '~ts' from the metadata storage because it's being deleted", [VHost]),
-    Ret = case rabbit_db_vhost:delete(VHost) of
+         QName = amqqueue:get_name(Q),
+         assert_benign(rabbit_amqqueue:with(QName, QDelFun), ActingUser)
+     end || Q <- rabbit_amqqueue:list(Name)],
+    rabbit_log:info("Deleting exchanges in vhost '~ts' because it's being deleted", [Name]),
+    ok = rabbit_exchange:delete_all(Name, ActingUser),
+    rabbit_log:info("Clearing policies and runtime parameters in vhost '~ts' because it's being deleted", [Name]),
+    _ = rabbit_runtime_parameters:clear_vhost(Name, ActingUser),
+    rabbit_log:debug("Removing vhost '~ts' from the metadata storage because it's being deleted", [Name]),
+    Ret = case rabbit_db_vhost:delete(Name) of
              true ->
                  ok = rabbit_event:notify(
                         vhost_deleted,
-                        [{name, VHost},
+                        [{name, Name},
                          {user_who_performed_action, ActingUser}]);
              false ->
-                 {error, {no_such_vhost, VHost}};
+                 {error, {no_such_vhost, Name}};
              {error, _} = Err ->
                  Err
          end,
     %% After vhost was deleted from the database, we try to stop vhost
     %% supervisors on all the nodes.
-    rabbit_vhost_sup_sup:delete_on_all_nodes(VHost),
+    rabbit_vhost_sup_sup:delete_on_all_nodes(Name),
     Ret.
 
 -spec put_vhost(vhost:name(),
@@ -530,6 +563,14 @@ lookup(VHostName) ->
         VHost     -> VHost
     end.
 
+-spec enable_protection_from_deletion(vhost:name()) -> vhost:vhost() | rabbit_types:ok_or_error(any()).
+enable_protection_from_deletion(VHostName) ->
+    rabbit_db_vhost:enable_protection_from_deletion(VHostName).
+
+-spec disable_protection_from_deletion(vhost:name()) -> vhost:vhost() | rabbit_types:ok_or_error(any()).
+disable_protection_from_deletion(VHostName) ->
+    rabbit_db_vhost:disable_protection_from_deletion(VHostName).
+
 -spec assert(vhost:name()) -> 'ok'.
 assert(VHostName) ->
     case exists(VHostName) of
@@ -624,6 +665,7 @@ i(cluster_state, VHost) -> vhost_cluster_state(vhost:get_name(VHost));
 i(description, VHost) -> vhost:get_description(VHost);
 i(tags, VHost) -> vhost:get_tags(VHost);
 i(default_queue_type, VHost) -> rabbit_queue_type:short_alias_of(default_queue_type(vhost:get_name(VHost)));
+i(protected_from_deletion, VHost) -> vhost:is_protected_from_deletion(VHost);
 i(metadata, VHost) ->
     DQT = rabbit_queue_type:short_alias_of(default_queue_type(vhost:get_name(VHost))),
     case vhost:get_metadata(VHost) of
diff --git a/deps/rabbit/src/vhost.erl b/deps/rabbit/src/vhost.erl
index f28607f7cdfa..a16116a3a99e 100644
--- a/deps/rabbit/src/vhost.erl
+++ b/deps/rabbit/src/vhost.erl
@@ -30,6 +30,11 @@
   set_limits/2,
   set_metadata/2,
   merge_metadata/2,
+
+  is_protected_from_deletion/1,
+  enable_protection_from_deletion/1,
+  disable_protection_from_deletion/1,
+
   new_metadata/3,
   is_tagged_with/2,
 
@@ -89,6 +94,8 @@
               vhost_pattern/0,
               vhost_v2_pattern/0]).
 
+-define(DELETION_PROTECTION_KEY, protected_from_deletion).
+
 -spec new(name(), limits()) -> vhost().
 new(Name, Limits) ->
     #vhost{virtual_host = Name, limits = Limits}.
@@ -123,6 +130,7 @@ info_keys() ->
      description,
      tags,
      default_queue_type,
+     protected_from_deletion,
      metadata,
      tracing,
      cluster_state].
@@ -187,6 +195,23 @@ metadata_merger(default_queue_type, _, NewVHostDefaultQueueType) ->
 metadata_merger(_, _, NewMetadataValue) ->
     NewMetadataValue.
 
+-spec is_protected_from_deletion(vhost()) -> boolean().
+is_protected_from_deletion(VHost) ->
+    case get_metadata(VHost) of
+        Map when map_size(Map) =:= 0 -> false;
+        #{?DELETION_PROTECTION_KEY := true} -> true;
+        #{?DELETION_PROTECTION_KEY := false} -> false;
+        _ -> false
+    end.
+
+-spec enable_protection_from_deletion(vhost()) -> vhost().
+enable_protection_from_deletion(VHost) ->
+    merge_metadata(VHost, #{?DELETION_PROTECTION_KEY => true}).
+
+-spec disable_protection_from_deletion(vhost()) -> vhost().
+disable_protection_from_deletion(VHost) ->
+    merge_metadata(VHost, #{?DELETION_PROTECTION_KEY => false}).
+
 -spec new_metadata(binary(), [atom()], rabbit_queue_type:queue_type() | 'undefined') -> metadata().
 new_metadata(Description, Tags, undefined) ->
     #{description => Description,
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex
index 70bc8f3de5bc..c273a621662d 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex
@@ -52,6 +52,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ClusterStatusCommand do
       status0 ->
         tags = cluster_tags(node_name, timeout)
         status = status0 ++ [{:cluster_tags, tags}]
+
         case :rabbit_misc.rpc_call(node_name, :rabbit_nodes, :list_running, []) do
           {:badrpc, _} = err ->
             err
@@ -136,10 +137,10 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ClusterStatusCommand do
       [
         "\n#{bright("Cluster Tags")}\n"
       ] ++
-      case m[:cluster_tags] do
-        [] -> ["(none)"]
-        tags -> cluster_tag_lines(tags)
-      end
+        case m[:cluster_tags] do
+          [] -> ["(none)"]
+          tags -> cluster_tag_lines(tags)
+        end
 
     disk_nodes_section =
       [
@@ -397,11 +398,12 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ClusterStatusCommand do
 
   defp cluster_tags(node, timeout) do
     case :rabbit_misc.rpc_call(
-          node,
-          :rabbit_runtime_parameters,
-          :value_global,
-          [:cluster_tags],
-          timeout) do
+           node,
+           :rabbit_runtime_parameters,
+           :value_global,
+           [:cluster_tags],
+           timeout
+         ) do
       :not_found -> []
       tags -> tags
     end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex
index da124ae55564..1c835393ed78 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex
@@ -87,6 +87,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecodeCommand do
         catch
           _, _ ->
             IO.inspect(__STACKTRACE__)
+
             {:error,
              "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"}
         end
@@ -111,6 +112,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecodeCommand do
     catch
       _, _ ->
         IO.inspect(__STACKTRACE__)
+
         {:error,
          "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"}
     end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex
index 6ac5958a96a1..d6cb628f6935 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex
@@ -91,6 +91,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do
         catch
           _, _ ->
             IO.inspect(__STACKTRACE__)
+
             {:error,
              "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"}
         end
@@ -118,6 +119,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do
     catch
       _, _ ->
         IO.inspect(__STACKTRACE__)
+
         {:error,
          "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"}
     end
@@ -130,7 +132,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do
   end
 
   def usage,
-    do: "decrypt_conf_value value passphrase [--cipher <cipher>] [--hash <hash>] [--iterations <iterations>]"
+    do:
+      "decrypt_conf_value value passphrase [--cipher <cipher>] [--hash <hash>] [--iterations <iterations>]"
 
   def usage_additional() do
     [
@@ -166,6 +169,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do
 
     {:encrypted, untagged_val}
   end
+
   defp tag_input_value_with_encrypted(value) do
     {:encrypted, value}
   end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex
index 02f741b62d0c..d799cf9fcf1e 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex
@@ -5,10 +5,12 @@
 ## Copyright (c) 2007-2023 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.  All rights reserved.
 
 defmodule RabbitMQ.CLI.Ctl.Commands.DeleteVhostCommand do
-  alias RabbitMQ.CLI.Core.{DocGuide, Helpers}
+  alias RabbitMQ.CLI.Core.{DocGuide, Helpers, ExitCodes}
 
   @behaviour RabbitMQ.CLI.CommandBehaviour
 
+  @protected_from_deletion_err "Cannot delete this virtual host: it is protected from deletion. To lift the protection, inspect and update its metadata"
+
   use RabbitMQ.CLI.Core.MergesNoDefaults
   use RabbitMQ.CLI.Core.AcceptsOnePositionalArgument
   use RabbitMQ.CLI.Core.RequiresRabbitAppRunning
@@ -17,6 +19,15 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DeleteVhostCommand do
     :rabbit_misc.rpc_call(node_name, :rabbit_vhost, :delete, [arg, Helpers.cli_acting_user()])
   end
 
+  def output({:error, :protected_from_deletion}, %{formatter: "json", node: node_name}) do
+    {:error,
+     %{"result" => "error", "node" => node_name, "reason" => @protected_from_deletion_err}}
+  end
+
+  def output({:error, :protected_from_deletion}, _opts) do
+    {:error, ExitCodes.exit_dataerr(), @protected_from_deletion_err}
+  end
+
   use RabbitMQ.CLI.DefaultOutput
 
   def usage, do: "delete_vhost <vhost>"
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/disable_vhost_deletion_protection_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/disable_vhost_deletion_protection_command.ex
new file mode 100644
index 000000000000..4a6e444310d8
--- /dev/null
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/disable_vhost_deletion_protection_command.ex
@@ -0,0 +1,58 @@
+## This Source Code Form is subject to the terms of the Mozilla Public
+## License, v. 2.0. If a copy of the MPL was not distributed with this
+## file, You can obtain one at https://mozilla.org/MPL/2.0/.
+##
+## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.  All rights reserved.
+
+defmodule RabbitMQ.CLI.Ctl.Commands.DisableVhostDeletionProtectionCommand do
+  alias RabbitMQ.CLI.Core.{DocGuide, Helpers}
+
+  @behaviour RabbitMQ.CLI.CommandBehaviour
+
+  @metadata_key :protected_from_deletion
+
+  def switches(), do: []
+  def aliases(), do: []
+
+  def merge_defaults(args, opts) do
+    {args, opts}
+  end
+
+  use RabbitMQ.CLI.Core.RequiresRabbitAppRunning
+  use RabbitMQ.CLI.Core.AcceptsOnePositionalArgument
+
+  def run([vhost], %{node: node_name}) do
+    metadata_patch = %{
+      @metadata_key => false
+    }
+    :rabbit_misc.rpc_call(node_name, :rabbit_vhost, :update_metadata, [
+      vhost,
+      metadata_patch,
+      Helpers.cli_acting_user()
+    ])
+  end
+
+  use RabbitMQ.CLI.DefaultOutput
+
+  def usage,
+    do:
+      "disable_vhost_deletion_protection <vhost>"
+
+  def usage_additional() do
+    [
+      ["<vhost>", "Virtual host name"]
+    ]
+  end
+
+  def usage_doc_guides() do
+    [
+      DocGuide.virtual_hosts()
+    ]
+  end
+
+  def help_section(), do: :virtual_hosts
+
+  def description(), do: "Removes deletion protection from a virtual host (so that it can be deleted)"
+
+  def banner([vhost], _), do: "Removing deletion protection from virtual host \"#{vhost}\" by updating its metadata..."
+end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex
index c974cc3e1cd5..c4bc05c35ba3 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex
@@ -10,20 +10,25 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do
   def switches(), do: [experimental: :boolean, opt_in: :boolean]
   def aliases(), do: [e: :experimental, o: :opt_in]
 
-  def merge_defaults(args, opts), do: { args, Map.merge(%{experimental: false, opt_in: false}, opts) }
+  def merge_defaults(args, opts),
+    do: {args, Map.merge(%{experimental: false, opt_in: false}, opts)}
 
   def validate([], _opts), do: {:validation_failure, :not_enough_args}
-  def validate([_ | _] = args, _opts) when length(args) > 1, do: {:validation_failure, :too_many_args}
+
+  def validate([_ | _] = args, _opts) when length(args) > 1,
+    do: {:validation_failure, :too_many_args}
 
   def validate([""], _opts),
-    do: {:validation_failure, {:bad_argument, "feature flag (or group) name cannot be an empty string"}}
+    do:
+      {:validation_failure,
+       {:bad_argument, "feature flag (or group) name cannot be an empty string"}}
 
   def validate([_], _opts), do: :ok
 
   use RabbitMQ.CLI.Core.RequiresRabbitAppRunning
 
   def run(["all"], %{node: node_name, opt_in: opt_in, experimental: experimental}) do
-    has_opted_in = (opt_in || experimental)
+    has_opted_in = opt_in || experimental
     enable_all(node_name, has_opted_in)
   end
 
@@ -39,9 +44,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do
     enable_all(node_name, false)
   end
 
-
   def run([feature_flag], %{node: node_name, opt_in: opt_in, experimental: experimental}) do
-    has_opted_in = (opt_in || experimental)
+    has_opted_in = opt_in || experimental
     enable_one(node_name, feature_flag, has_opted_in)
   end
 
@@ -57,10 +61,9 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do
     enable_one(node_name, feature_flag, false)
   end
 
-
   def output({:error, :unsupported}, %{node: node_name}) do
     {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(),
-      "This feature flag is not supported by node #{node_name}"}
+     "This feature flag is not supported by node #{node_name}"}
   end
 
   use RabbitMQ.CLI.DefaultOutput
@@ -96,8 +99,11 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do
   defp enable_all(node_name, has_opted_in) do
     case has_opted_in do
       true ->
-        msg = "`--opt-in` (aliased as `--experimental`) flag is not allowed when enabling all feature flags.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag"
+        msg =
+          "`--opt-in` (aliased as `--experimental`) flag is not allowed when enabling all feature flags.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag"
+
         {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(), msg}
+
       _ ->
         case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :enable_all, []) do
           {:badrpc, _} = err -> err
@@ -107,20 +113,26 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do
   end
 
   defp enable_one(node_name, feature_flag, has_opted_in) do
-    case {has_opted_in, :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :get_stability, [
-      String.to_atom(feature_flag)
-    ])} do
-        {_, {:badrpc, _} = err} -> err
-        {false, :experimental} ->
-          msg = "Feature flag #{feature_flag} requires the user to explicitly opt-in.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag"
-          {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(), msg}
-        _ ->
-          case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :enable, [
+    case {has_opted_in,
+          :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :get_stability, [
             String.to_atom(feature_flag)
-          ]) do
-            {:badrpc, _} = err -> err
-            other -> other
-          end
+          ])} do
+      {_, {:badrpc, _} = err} ->
+        err
+
+      {false, :experimental} ->
+        msg =
+          "Feature flag #{feature_flag} requires the user to explicitly opt-in.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag"
+
+        {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(), msg}
+
+      _ ->
+        case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :enable, [
+               String.to_atom(feature_flag)
+             ]) do
+          {:badrpc, _} = err -> err
+          other -> other
+        end
     end
   end
 end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_vhost_deletion_protection_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_vhost_deletion_protection_command.ex
new file mode 100644
index 000000000000..008219bfb58e
--- /dev/null
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_vhost_deletion_protection_command.ex
@@ -0,0 +1,58 @@
+## This Source Code Form is subject to the terms of the Mozilla Public
+## License, v. 2.0. If a copy of the MPL was not distributed with this
+## file, You can obtain one at https://mozilla.org/MPL/2.0/.
+##
+## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.  All rights reserved.
+
+defmodule RabbitMQ.CLI.Ctl.Commands.EnableVhostDeletionProtectionCommand do
+  alias RabbitMQ.CLI.Core.{DocGuide, Helpers}
+
+  @behaviour RabbitMQ.CLI.CommandBehaviour
+
+  @metadata_key :protected_from_deletion
+
+  def switches(), do: []
+  def aliases(), do: []
+
+  def merge_defaults(args, opts) do
+    {args, opts}
+  end
+
+  use RabbitMQ.CLI.Core.RequiresRabbitAppRunning
+  use RabbitMQ.CLI.Core.AcceptsOnePositionalArgument
+
+  def run([vhost], %{node: node_name}) do
+    metadata_patch = %{
+      @metadata_key => true
+    }
+    :rabbit_misc.rpc_call(node_name, :rabbit_vhost, :update_metadata, [
+      vhost,
+      metadata_patch,
+      Helpers.cli_acting_user()
+    ])
+  end
+
+  use RabbitMQ.CLI.DefaultOutput
+
+  def usage,
+    do:
+      "enable_vhost_deletion_protection <vhost>"
+
+  def usage_additional() do
+    [
+      ["<vhost>", "Virtual host name"]
+    ]
+  end
+
+  def usage_doc_guides() do
+    [
+      DocGuide.virtual_hosts()
+    ]
+  end
+
+  def help_section(), do: :virtual_hosts
+
+  def description(), do: "Protects a virtual host from deletion (until the protection is removed)"
+
+  def banner([vhost], _), do: "Protecting virtual host \"#{vhost}\" from removal by updating its metadata..."
+end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex
index 8eb43e688c91..2dcc0c031b71 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex
@@ -149,7 +149,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EncodeCommand do
 
   def help_section(), do: :configuration
 
-  def description(), do: "Encrypts a sensitive configuration value to be used in the advanced.config file"
+  def description(),
+    do: "Encrypts a sensitive configuration value to be used in the advanced.config file"
 
   #
   # Implementation
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex
index 914ad7debeb2..5932111ffc29 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex
@@ -125,7 +125,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EncryptConfValueCommand do
   end
 
   def usage,
-    do: "encrypt_conf_value value passphrase [--cipher <cipher>] [--hash <hash>] [--iterations <iterations>]"
+    do:
+      "encrypt_conf_value value passphrase [--cipher <cipher>] [--hash <hash>] [--iterations <iterations>]"
 
   def usage_additional() do
     [
@@ -145,7 +146,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EncryptConfValueCommand do
 
   def help_section(), do: :configuration
 
-  def description(), do: "Encrypts a sensitive configuration value to be used in the advanced.config file"
+  def description(),
+    do: "Encrypts a sensitive configuration value to be used in the advanced.config file"
 
   #
   # Implementation
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex
index 6ad98132c3e3..078bd1bc7cba 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex
@@ -11,7 +11,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ListVhostsCommand do
   @behaviour RabbitMQ.CLI.CommandBehaviour
   use RabbitMQ.CLI.DefaultOutput
 
-  @info_keys ~w(name description tags default_queue_type tracing cluster_state)a
+  @info_keys ~w(name description tags default_queue_type protected_from_deletion tracing cluster_state metadata)a
 
   def info_keys(), do: @info_keys
 
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex
index 4452b4e1faad..1b90cbe42269 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex
@@ -69,6 +69,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.StatusCommand do
 
   def output(result, %{node: node_name, unit: unit}) when is_list(result) do
     m = result_map(result)
+
     product_name_section =
       case m do
         %{:product_name => product_name} when product_name != "" ->
@@ -142,14 +143,15 @@ defmodule RabbitMQ.CLI.Ctl.Commands.StatusCommand do
         end
 
     IO.inspect(m[:tags])
+
     tags_section =
       [
         "\n#{bright("Tags")}\n"
       ] ++
         case m[:tags] do
           nil -> ["(none)"]
-          []  -> ["(none)"]
-          xs  -> tag_lines(xs)
+          [] -> ["(none)"]
+          xs -> tag_lines(xs)
         end
 
     breakdown = compute_relative_values(m[:memory])
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex
index c01a4904bb4d..4e4700d41e75 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex
@@ -9,9 +9,9 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do
 
   @behaviour RabbitMQ.CLI.CommandBehaviour
 
-  @metadata_keys [:description, :tags, :default_queue_type]
+  @metadata_keys [:description, :tags, :default_queue_type, :protected_from_deletion]
 
-  def switches(), do: [description: :string, tags: :string, default_queue_type: :string]
+  def switches(), do: [description: :string, tags: :string, default_queue_type: :string, protected_from_deletion: :boolean]
   def aliases(), do: [d: :description]
 
   def merge_defaults(args, opts) do
@@ -86,7 +86,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do
 
   def usage,
     do:
-      "update_vhost_metadata <vhost> [--description <description>] [--tags \"<tag1>,<tag2>,<...>\"] [--default-queue-type <quorum|classic|stream>]"
+      "update_vhost_metadata <vhost> [--description=<description>] [--tags=\"<tag1>,<tag2>,<...>\"] [--default-queue-type=<quorum|classic|stream>] [--protected-from-deletion=<true|false>]"
 
   def usage_additional() do
     [
@@ -96,7 +96,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do
       [
         "--default-queue-type <quorum|classic|stream>",
         "Queue type to use if no type is explicitly provided by the client"
-      ]
+      ],
+      ["--protected-from-deletion", "When set to true, will make it impossible to delete a virtual host until the protection is removed"]
     ]
   end
 
@@ -108,7 +109,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do
 
   def help_section(), do: :virtual_hosts
 
-  def description(), do: "Updates metadata (tags, description, default queue type) a virtual host"
+  def description(), do: "Updates metadata (tags, description, default queue type, protection from deletion) a virtual host"
 
   def banner([vhost], _), do: "Updating metadata of vhost \"#{vhost}\" ..."
 end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex
index 66e2ef3beab9..264c308dab0e 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex
@@ -15,25 +15,29 @@ defmodule RabbitMQ.CLI.Diagnostics.Commands.CheckIfAnyDeprecatedFeaturesAreUsedC
   use RabbitMQ.CLI.Core.RequiresRabbitAppRunning
 
   def run([], %{node: node_name, timeout: timeout}) do
-    deprecated_features_list = :rabbit_misc.rpc_call(
-      node_name,
-      :rabbit_deprecated_features,
-      :list,
-      [:used],
-      timeout
-    )
+    deprecated_features_list =
+      :rabbit_misc.rpc_call(
+        node_name,
+        :rabbit_deprecated_features,
+        :list,
+        [:used],
+        timeout
+      )
 
     # health checks return true if they pass
     case deprecated_features_list do
       {:badrpc, _} = err ->
         err
+
       {:error, _} = err ->
         err
+
       _ ->
         names = Enum.sort(Map.keys(deprecated_features_list))
+
         case names do
           [] -> true
-          _  -> {false, names}
+          _ -> {false, names}
         end
     end
   end
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex
index 6eb3242bfbcd..510a722f2da3 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex
@@ -17,8 +17,9 @@ defmodule RabbitMQ.CLI.Diagnostics.Commands.MetadataStoreStatusCommand do
     case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :is_enabled, [:khepri_db]) do
       true ->
         :rabbit_misc.rpc_call(node_name, :rabbit_khepri, :status, [])
+
       false ->
-         [[{<<"Metadata Store">>, "mnesia"}]]
+        [[{<<"Metadata Store">>, "mnesia"}]]
     end
   end
 
diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex
index 3b9d66f311e2..ae4781615a3d 100644
--- a/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex
+++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex
@@ -49,5 +49,4 @@ defmodule RabbitMQ.CLI.Queues.Commands.CheckIfNodeIsMirrorSyncCriticalCommand do
   def banner([], _) do
     "This command is DEPRECATED and is a no-op. It will be removed in a future version."
   end
-
 end
diff --git a/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl b/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl
index 6137fcf2fcbf..d68881ec1246 100644
--- a/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl
+++ b/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl
@@ -112,6 +112,8 @@
     add_vhost/3,
     add_vhost/4,
     update_vhost_metadata/3,
+    enable_vhost_protection_from_deletion/2,
+    disable_vhost_protection_from_deletion/2,
     delete_vhost/2,
     delete_vhost/3,
     delete_vhost/4,
@@ -1606,6 +1608,18 @@ add_vhost(Config, Node, VHost, Username) ->
 update_vhost_metadata(Config, VHost, Meta) ->
     catch rpc(Config, 0, rabbit_vhost, update_metadata, [VHost, Meta, <<"acting-user">>]).
 
+enable_vhost_protection_from_deletion(Config, VHost) ->
+    MetadataPatch = #{
+        protected_from_deletion => true
+    },
+    update_vhost_metadata(Config, VHost, MetadataPatch).
+
+disable_vhost_protection_from_deletion(Config, VHost) ->
+    MetadataPatch = #{
+        protected_from_deletion => false
+    },
+    update_vhost_metadata(Config, VHost, MetadataPatch).
+
 delete_vhost(Config, VHost) ->
     delete_vhost(Config, 0, VHost).
 
diff --git a/deps/rabbitmq_management/priv/www/api/index.html b/deps/rabbitmq_management/priv/www/api/index.html
index 14bcaeb36a22..d7e4a4a5214d 100644
--- a/deps/rabbitmq_management/priv/www/api/index.html
+++ b/deps/rabbitmq_management/priv/www/api/index.html
@@ -723,6 +723,14 @@ <h2>Reference</h2>
         <td class="path">/api/vhosts/<i>name</i>/topic-permissions</td>
         <td>A list of all topic permissions for a given virtual host.</td>
       </tr>
+      <tr>
+        <td></td>
+        <td></td>
+        <td>X</td>
+        <td>X</td>
+        <td class="path">/api/vhosts/<i>name</i>/deletion/protection</td>
+        <td>Enables (when used with POST) or disabled (with DELETE) deletion protection for the virtual host.</td>
+      </tr>
       <tr>
         <td></td>
         <td></td>
diff --git a/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs b/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs
index df943c4e76e9..232fa1e5017b 100644
--- a/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs
+++ b/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs
@@ -26,6 +26,10 @@
         <th>Default queue type:</th>
         <td><%= vhost.default_queue_type == "undefined" ? "&lt;not set&gt;" :vhost.default_queue_type %></td>
       </tr>
+      <tr>
+        <th>Deletion protection:</th>
+        <td><%= vhost.protected_from_deletion ? "enabled" :"disabled" %></td>
+      </tr>
       <tr>
         <th>State:</th>
         <td>
diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl b/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl
index ad6ef0414ae9..dd9d61ba5905 100644
--- a/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl
+++ b/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl
@@ -161,6 +161,7 @@ dispatcher() ->
      {"/bindings/:vhost/e/:source/:dtype/:destination/:props", rabbit_mgmt_wm_binding, []},
      {"/vhosts",                                               rabbit_mgmt_wm_vhosts, []},
      {"/vhosts/:vhost",                                        rabbit_mgmt_wm_vhost, []},
+     {"/vhosts/:vhost/deletion/protection",                    rabbit_mgmt_wm_vhost_deletion_protection, []},
      {"/vhosts/:vhost/start/:node",                            rabbit_mgmt_wm_vhost_restart, []},
      {"/vhosts/:vhost/permissions",                            rabbit_mgmt_wm_permissions_vhost, []},
      {"/vhosts/:vhost/topic-permissions",                      rabbit_mgmt_wm_topic_permissions_vhost, []},
diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_util.erl b/deps/rabbitmq_management/src/rabbit_mgmt_util.erl
index 5e737bcaf2ea..557ac0433835 100644
--- a/deps/rabbitmq_management/src/rabbit_mgmt_util.erl
+++ b/deps/rabbitmq_management/src/rabbit_mgmt_util.erl
@@ -19,7 +19,7 @@
          is_authorized_global_parameters/2]).
 -export([user/1]).
 -export([bad_request/3, service_unavailable/3, bad_request_exception/4,
-         internal_server_error/3, internal_server_error/4,
+         internal_server_error/3, internal_server_error/4, precondition_failed/3,
          id/2, parse_bool/1, parse_int/1, redirect_to_home/3]).
 -export([with_decode/4, not_found/3]).
 -export([with_channel/4, with_channel/5]).
@@ -675,6 +675,9 @@ not_found(Reason, ReqData, Context) ->
 method_not_allowed(Reason, ReqData, Context) ->
     halt_response(405, method_not_allowed, Reason, ReqData, Context).
 
+precondition_failed(Reason, ReqData, Context) ->
+    halt_response(412, precondition_failed, Reason, ReqData, Context).
+
 internal_server_error(Reason, ReqData, Context) ->
     internal_server_error(internal_server_error, Reason, ReqData, Context).
 
diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl
index 3a2b98499c48..cec419c96af2 100644
--- a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl
+++ b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl
@@ -95,15 +95,20 @@ delete_resource(ReqData, Context = #context{user = #user{username = Username}})
     case rabbit_vhost:delete(VHost, Username) of
         ok ->
             {true, ReqData, Context};
+        {error, protected_from_deletion} ->
+            Msg = "Refusing to delete virtual host '~ts' because it is protected from deletion",
+            Reason = iolist_to_binary(io_lib:format(Msg, [VHost])),
+            rabbit_log:error(Msg, [VHost]),
+            rabbit_mgmt_util:precondition_failed(Reason, ReqData, Context);
         {error, timeout} ->
             rabbit_mgmt_util:internal_server_error(
               timeout,
-              "Timed out waiting for the vhost to be deleted",
+              "Timed out waiting for the virtual host to be deleted",
               ReqData, Context);
         {error, E} ->
             Reason = iolist_to_binary(
                        io_lib:format(
-                         "Error occurred while deleting vhost: ~tp",
+                         "Error occurred while deleting virtual host '~tp'",
                          [E])),
             rabbit_mgmt_util:internal_server_error(
               Reason, ReqData, Context)
diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost_deletion_protection.erl b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost_deletion_protection.erl
new file mode 100644
index 000000000000..c391f671ef72
--- /dev/null
+++ b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost_deletion_protection.erl
@@ -0,0 +1,88 @@
+%% This Source Code Form is subject to the terms of the Mozilla Public
+%% License, v. 2.0. If a copy of the MPL was not distributed with this
+%% file, You can obtain one at https://mozilla.org/MPL/2.0/.
+%%
+%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved.
+%%
+
+-module(rabbit_mgmt_wm_vhost_deletion_protection).
+
+-export([init/2, resource_exists/2,
+    content_types_accepted/2,
+    is_authorized/2, allowed_methods/2, accept_content/2,
+    delete_resource/2, id/1]).
+-export([variances/2]).
+
+-import(rabbit_misc, [pget/2]).
+
+-include_lib("rabbitmq_management_agent/include/rabbit_mgmt_records.hrl").
+-include_lib("rabbit_common/include/rabbit.hrl").
+
+-dialyzer({nowarn_function, accept_content/2}).
+
+%%--------------------------------------------------------------------
+
+init(Req, _State) ->
+    {cowboy_rest, rabbit_mgmt_headers:set_common_permission_headers(Req, ?MODULE), #context{}}.
+
+variances(Req, Context) ->
+    {[<<"accept-encoding">>, <<"origin">>], Req, Context}.
+
+content_types_accepted(ReqData, Context) ->
+    {[{'*', accept_content}], ReqData, Context}.
+
+allowed_methods(ReqData, Context) ->
+    {[<<"GET">>, <<"POST">>, <<"DELETE">>, <<"OPTIONS">>], ReqData, Context}.
+
+resource_exists(ReqData, Context) ->
+    {rabbit_db_vhost:exists(id(ReqData)), ReqData, Context}.
+
+accept_content(ReqData, Context) ->
+    Name = id(ReqData),
+    case rabbit_db_vhost:enable_protection_from_deletion(Name) of
+        {ok, _NewRecord} ->
+            {true, ReqData, Context};
+        {error, {no_such_vhost, _}} ->
+            Msg = "Cannot enable deletion protection of virtual host '~ts' because it does not exist",
+            Reason = iolist_to_binary(io_lib:format(Msg, [Name])),
+            rabbit_log:error(Msg, [Name]),
+            rabbit_mgmt_util:not_found(
+                Reason, ReqData, Context);
+        {error, E} ->
+            Msg = "Cannot enable deletion protection of virtual host '~ts': ~tp",
+            Reason = iolist_to_binary(io_lib:format(Msg, [Name, E])),
+            rabbit_log:error(Msg, [Name]),
+            rabbit_mgmt_util:internal_server_error(
+                Reason, ReqData, Context)
+    end.
+
+delete_resource(ReqData, Context) ->
+    Name = id(ReqData),
+    case rabbit_db_vhost:disable_protection_from_deletion(Name) of
+        {ok, _NewRecord} ->
+            {true, ReqData, Context};
+        {error, {no_such_vhost, _}} ->
+            Msg = "Cannot disable deletion protection of virtual host '~ts' because it does not exist",
+            Reason = iolist_to_binary(io_lib:format(Msg, [Name])),
+            rabbit_log:error(Msg, [Name]),
+            rabbit_mgmt_util:not_found(
+                Reason, ReqData, Context);
+        {error, E} ->
+            Msg = "Cannot disable deletion protection of virtual host '~ts': ~tp",
+            Reason = iolist_to_binary(io_lib:format(Msg, [Name, E])),
+            rabbit_log:error(Msg, [Name]),
+            rabbit_mgmt_util:internal_server_error(
+                Reason, ReqData, Context)
+    end.
+
+is_authorized(ReqData, Context) ->
+    rabbit_mgmt_util:is_authorized_admin(ReqData, Context).
+
+%%--------------------------------------------------------------------
+
+id(ReqData) ->
+    case rabbit_mgmt_util:id(vhost, ReqData) of
+        [Value] -> Value;
+        Value   -> Value
+    end.
+
diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl
index 945a04e8d67c..f33e3ea3f543 100644
--- a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl
+++ b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl
@@ -69,5 +69,5 @@ augmented(ReqData, #context{user = User}) ->
 basic() ->
     Maps = lists:map(
             fun maps:from_list/1,
-            rabbit_vhost:info_all([name, description, tags, default_queue_type, metadata])),
+            rabbit_vhost:info_all([name, description, tags, default_queue_type, metadata, protected_from_deletion])),
     rabbit_queue_type:vhosts_with_dqt(Maps).


Test patch:
diff --git a/deps/rabbit/test/definition_import_SUITE.erl b/deps/rabbit/test/definition_import_SUITE.erl
index 0b3a554a384b..4d5758d0c47f 100644
--- a/deps/rabbit/test/definition_import_SUITE.erl
+++ b/deps/rabbit/test/definition_import_SUITE.erl
@@ -53,7 +53,8 @@ groups() ->
                                import_case18,
                                import_case19,
                                import_case20,
-                               import_case21
+                               import_case21,
+                               import_case22
                               ]},
 
         {boot_time_import_using_classic_source, [], [
@@ -273,7 +274,7 @@ import_case16(Config) ->
         Val when is_tuple(Val) ->
             ?assertEqual(<<"A case16 description">>, vhost:get_description(VHostRec)),
             ?assertEqual(<<"quorum">>, vhost:get_default_queue_type(VHostRec)),
-            ?assertEqual([multi_dc_replication,ab,cde], vhost:get_tags(VHostRec))
+            ?assertEqual([<<"multi_dc_replication">>,<<"ab">>,<<"cde">>], vhost:get_tags(VHostRec))
     end,
 
     ok.
@@ -315,6 +316,25 @@ import_case20(Config) ->
 
 import_case21(Config) -> import_invalid_file_case(Config, "failing_case21").
 
+import_case22(Config) ->
+    import_file_case(Config, "case22"),
+    Name = <<"protected">>,
+    VirtualHostIsImported =
+        fun () ->
+            case vhost_lookup(Config, Name) of
+                {error, not_found} -> false;
+                _       -> true
+            end
+        end,
+    rabbit_ct_helpers:await_condition(VirtualHostIsImported, 20000),
+    VHost = vhost_lookup(Config, Name),
+    ?assertEqual(true, vhost:is_protected_from_deletion(VHost)),
+
+    VHost2 = vhost_lookup(Config, <<"non-protected">>),
+    ?assertEqual(false, vhost:is_protected_from_deletion(VHost2)),
+
+    ok.
+
 export_import_round_trip_case1(Config) ->
     case rabbit_ct_helpers:is_mixed_versions() of
       false ->
diff --git a/deps/rabbit/test/definition_import_SUITE_data/case22.json b/deps/rabbit/test/definition_import_SUITE_data/case22.json
new file mode 100644
index 000000000000..a6eae42874c2
--- /dev/null
+++ b/deps/rabbit/test/definition_import_SUITE_data/case22.json
@@ -0,0 +1,66 @@
+{
+  "bindings": [],
+  "exchanges": [],
+  "global_parameters": [
+    {
+      "name": "cluster_name",
+      "value": "rabbitmq@localhost"
+    }
+  ],
+  "parameters": [],
+  "permissions": [
+    {
+      "configure": ".*",
+      "read": ".*",
+      "user": "guest",
+      "vhost": "/",
+      "write": ".*"
+    }
+  ],
+  "policies": [],
+  "queues": [],
+  "rabbit_version": "4.0.5",
+  "rabbitmq_version": "4.0.5",
+  "topic_permissions": [],
+  "users": [
+    {
+      "hashing_algorithm": "rabbit_password_hashing_sha256",
+      "limits": {"max-connections" : 2},
+      "name": "limited_guest",
+      "password_hash": "wS4AT3B4Z5RpWlFn1FA30osf2C75D7WA3gem591ACDZ6saO6",
+      "tags": [
+        "administrator"
+      ]
+    }
+  ],
+  "vhosts": [
+    {
+      "limits": [],
+      "name": "non-protected",
+      "description": "",
+      "metadata": {
+        "description": "",
+        "tags": [],
+        "default_queue_type": "classic"
+      },
+      "tags": [],
+      "default_queue_type": "classic"
+    },
+    {
+      "name": "protected",
+      "description": "protected, DQT = quorum",
+      "metadata": {
+        "description": "DQT = quorum",
+        "tags": [],
+        "default_queue_type": "quorum",
+        "protected_from_deletion": true
+      },
+      "tags": [],
+      "default_queue_type": "quorum"
+    },
+    {
+      "limits": [],
+      "name": "tagged"
+    }
+  ]
+}
diff --git a/deps/rabbitmq_cli/test/core/json_stream_test.exs b/deps/rabbitmq_cli/test/core/json_stream_test.exs
index 0d736fb8af61..84be37aeff54 100644
--- a/deps/rabbitmq_cli/test/core/json_stream_test.exs
+++ b/deps/rabbitmq_cli/test/core/json_stream_test.exs
@@ -11,9 +11,9 @@ defmodule JsonStreamTest do
 
   test "format_output map with atom keys is converted to JSON object" do
     assert @formatter.format_output(%{a: :apple, b: :beer}, %{}) ==
-             "{\"a\":\"apple\",\"b\":\"beer\"}"
-        or @formatter.format_output(%{a: :apple, b: :beer}, %{}) ==
-             "{\"b\":\"beer\",\"a\":\"apple\"}"
+             "{\"a\":\"apple\",\"b\":\"beer\"}" or
+             @formatter.format_output(%{a: :apple, b: :beer}, %{}) ==
+               "{\"b\":\"beer\",\"a\":\"apple\"}"
   end
 
   test "format_output map with binary keys is converted to JSON object" do
diff --git a/deps/rabbitmq_cli/test/ctl/disable_vhost_deletion_protection_command_test.exs b/deps/rabbitmq_cli/test/ctl/disable_vhost_deletion_protection_command_test.exs
new file mode 100644
index 000000000000..f48c8e5f3251
--- /dev/null
+++ b/deps/rabbitmq_cli/test/ctl/disable_vhost_deletion_protection_command_test.exs
@@ -0,0 +1,71 @@
+## This Source Code Form is subject to the terms of the Mozilla Public
+## License, v. 2.0. If a copy of the MPL was not distributed with this
+## file, You can obtain one at https://mozilla.org/MPL/2.0/.
+##
+## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.  All rights reserved.
+
+defmodule DisableVhostDeletionProtectionCommandTest do
+  use ExUnit.Case, async: false
+  import TestHelper
+
+  @command RabbitMQ.CLI.Ctl.Commands.DisableVhostDeletionProtectionCommand
+  @inverse_command RabbitMQ.CLI.Ctl.Commands.EnableVhostDeletionProtectionCommand
+  @vhost "disable-vhost-deletion-protection"
+
+  setup_all do
+    RabbitMQ.CLI.Core.Distribution.start()
+    {:ok, opts: %{node: get_rabbit_hostname()}}
+  end
+
+  setup context do
+    on_exit(context, fn -> delete_vhost(context[:vhost]) end)
+    :ok
+  end
+
+  test "validate: no arguments fails validation" do
+    assert @command.validate([], %{}) == {:validation_failure, :not_enough_args}
+  end
+
+  test "validate: too many arguments fails validation" do
+    assert @command.validate(["test", "extra"], %{}) == {:validation_failure, :too_many_args}
+  end
+
+  test "validate: virtual host name without options fails validation" do
+    assert @command.validate(["a-vhost"], %{}) == :ok
+  end
+
+  test "run: enabling deletion protection succeeds", context do
+    _ = @command.run([@vhost], context[:opts])
+    delete_vhost(@vhost)
+    add_vhost(@vhost)
+
+    assert @inverse_command.run([@vhost], context[:opts]) == :ok
+    vh = find_vhost(@vhost)
+    assert vh[:protected_from_deletion]
+
+    assert @command.run([@vhost], context[:opts]) == :ok
+    vh = find_vhost(@vhost)
+    assert !vh[:protected_from_deletion]
+
+    delete_vhost(@vhost)
+  end
+
+  test "run: attempt to use a non-existent virtual host fails", context do
+    vh = "a-non-existent-3882-vhost"
+
+    assert match?(
+             {:error, {:no_such_vhost, _}},
+             @command.run([vh], Map.merge(context[:opts], %{}))
+           )
+  end
+
+  test "run: attempt to use an unreachable node returns a nodedown" do
+    opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"}
+    assert match?({:badrpc, _}, @command.run(["na"], opts))
+  end
+
+  test "banner", context do
+    assert @command.banner([@vhost], context[:opts]) =~
+             ~r/Removing deletion protection/
+  end
+end
diff --git a/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs b/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs
index 635eaa07800b..76167e0557c2 100644
--- a/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs
+++ b/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs
@@ -79,20 +79,24 @@ defmodule EnableFeatureFlagCommandTest do
 
   test "run: enabling an experimental flag requires '--opt-in'", context do
     experimental_flag = Atom.to_string(context[:experimental_flag])
+
     assert match?(
              {:error, @usage_exit_code, _},
              @command.run([experimental_flag], context[:opts])
            )
+
     opts = Map.put(context[:opts], :opt_in, true)
     assert @command.run([experimental_flag], opts) == :ok
   end
 
   test "run: enabling an experimental flag accepts '--experimental'", context do
     experimental_flag = Atom.to_string(context[:experimental_flag])
+
     assert match?(
              {:error, @usage_exit_code, _},
              @command.run([experimental_flag], context[:opts])
            )
+
     opts = Map.put(context[:opts], :experimental, true)
     assert @command.run([experimental_flag], opts) == :ok
   end
diff --git a/deps/rabbitmq_cli/test/ctl/enable_vhost_deletion_protection_command_test.exs b/deps/rabbitmq_cli/test/ctl/enable_vhost_deletion_protection_command_test.exs
new file mode 100644
index 000000000000..7fdb5d5debd1
--- /dev/null
+++ b/deps/rabbitmq_cli/test/ctl/enable_vhost_deletion_protection_command_test.exs
@@ -0,0 +1,69 @@
+## This Source Code Form is subject to the terms of the Mozilla Public
+## License, v. 2.0. If a copy of the MPL was not distributed with this
+## file, You can obtain one at https://mozilla.org/MPL/2.0/.
+##
+## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.  All rights reserved.
+
+defmodule EnableVhostDeletionProtectionCommandTest do
+  use ExUnit.Case, async: false
+  import TestHelper
+
+  @command RabbitMQ.CLI.Ctl.Commands.EnableVhostDeletionProtectionCommand
+  @inverse_command RabbitMQ.CLI.Ctl.Commands.DisableVhostDeletionProtectionCommand
+  @vhost "enable-vhost-deletion-protection"
+
+  setup_all do
+    RabbitMQ.CLI.Core.Distribution.start()
+    {:ok, opts: %{node: get_rabbit_hostname()}}
+  end
+
+  setup context do
+    on_exit(context, fn -> delete_vhost(context[:vhost]) end)
+    :ok
+  end
+
+  test "validate: no arguments fails validation" do
+    assert @command.validate([], %{}) == {:validation_failure, :not_enough_args}
+  end
+
+  test "validate: too many arguments fails validation" do
+    assert @command.validate(["test", "extra"], %{}) == {:validation_failure, :too_many_args}
+  end
+
+  test "validate: virtual host name without options fails validation" do
+    assert @command.validate(["a-vhost"], %{}) == :ok
+  end
+
+  test "run: enabling deletion protection succeeds", context do
+    add_vhost(@vhost)
+
+    assert @command.run([@vhost], context[:opts]) == :ok
+    vh = find_vhost(@vhost)
+    assert vh[:protected_from_deletion]
+
+    assert @inverse_command.run([@vhost], context[:opts]) == :ok
+    vh = find_vhost(@vhost)
+    assert !vh[:protected_from_deletion]
+
+    delete_vhost(@vhost)
+  end
+
+  test "run: attempt to use a non-existent virtual host fails", context do
+    vh = "a-non-existent-3882-vhost"
+
+    assert match?(
+             {:error, {:no_such_vhost, _}},
+             @command.run([vh], Map.merge(context[:opts], %{}))
+           )
+  end
+
+  test "run: attempt to use an unreachable node returns a nodedown" do
+    opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"}
+    assert match?({:badrpc, _}, @command.run(["na"], opts))
+  end
+
+  test "banner", context do
+    assert @command.banner([@vhost], context[:opts]) =~
+             ~r/Protecting virtual host/
+  end
+end
diff --git a/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs b/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs
index 5ac152323d13..4701f489dd9a 100644
--- a/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs
+++ b/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs
@@ -38,6 +38,7 @@ defmodule ForceBootCommandTest do
 
   test "run: sets a force boot marker file on target node", context do
     node = get_rabbit_hostname()
+
     case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do
       true ->
         :ok
diff --git a/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs b/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs
index 0285a70abb0c..8783f70d423b 100644
--- a/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs
+++ b/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs
@@ -46,6 +46,7 @@ defmodule ForceResetCommandTest do
     assert vhost_exists?("some_vhost")
     node = get_rabbit_hostname()
     ret = @command.run([], context[:opts])
+
     case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do
       true ->
         assert match?({:error, :rabbitmq_unexpectedly_running}, ret)
@@ -53,6 +54,7 @@ defmodule ForceResetCommandTest do
       false ->
         assert match?({:error, :mnesia_unexpectedly_running}, ret)
     end
+
     assert vhost_exists?("some_vhost")
   end
 
diff --git a/deps/rabbitmq_cli/test/ctl/reset_command_test.exs b/deps/rabbitmq_cli/test/ctl/reset_command_test.exs
index 85452b4cb841..cc538854908c 100644
--- a/deps/rabbitmq_cli/test/ctl/reset_command_test.exs
+++ b/deps/rabbitmq_cli/test/ctl/reset_command_test.exs
@@ -46,6 +46,7 @@ defmodule ResetCommandTest do
     assert vhost_exists?("some_vhost")
     node = get_rabbit_hostname()
     ret = @command.run([], context[:opts])
+
     case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do
       true ->
         assert match?({:error, :rabbitmq_unexpectedly_running}, ret)
@@ -53,6 +54,7 @@ defmodule ResetCommandTest do
       false ->
         assert match?({:error, :mnesia_unexpectedly_running}, ret)
     end
+
     assert vhost_exists?("some_vhost")
   end
 
diff --git a/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs b/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs
index a98de49f092e..aac5bb9d64d1 100644
--- a/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs
+++ b/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs
@@ -26,12 +26,13 @@ defmodule SetDiskFreeLimitCommandTest do
     # silences warnings
     context[:tag]
     on_exit([], fn -> set_disk_free_limit(@default_limit) end)
+
     :rabbit_misc.rpc_call(
-        get_rabbit_hostname(),
-        :rabbit_disk_monitor,
-        :set_enabled,
-        [:true]
-      )
+      get_rabbit_hostname(),
+      :rabbit_disk_monitor,
+      :set_enabled,
+      [true]
+    )
 
     {:ok, opts: %{node: get_rabbit_hostname()}}
   end
@@ -110,11 +111,12 @@ defmodule SetDiskFreeLimitCommandTest do
   test "run: a valid integer input returns an ok and sets the disk free limit", context do
     set_disk_free_limit(@default_limit)
     assert @command.run([context[:limit]], context[:opts]) == :ok
+
     await_condition(
-        fn ->
-           status()[:disk_free_limit] === context[:limit]
-        end,
-        30000
+      fn ->
+        status()[:disk_free_limit] === context[:limit]
+      end,
+      30000
     )
 
     set_disk_free_limit(@default_limit)
@@ -125,11 +127,12 @@ defmodule SetDiskFreeLimitCommandTest do
        context do
     set_disk_free_limit(@default_limit)
     assert @command.run([context[:limit]], context[:opts]) == :ok
+
     await_condition(
-        fn ->
-           status()[:disk_free_limit] === round(context[:limit])
-        end,
-        30000
+      fn ->
+        status()[:disk_free_limit] === round(context[:limit])
+      end,
+      30000
     )
 
     set_disk_free_limit(@default_limit)
@@ -140,11 +143,12 @@ defmodule SetDiskFreeLimitCommandTest do
        context do
     set_disk_free_limit(@default_limit)
     assert @command.run([context[:limit]], context[:opts]) == :ok
+
     await_condition(
-        fn ->
-           status()[:disk_free_limit] === context[:limit] |> Float.floor() |> round
-        end,
-        30000
+      fn ->
+        status()[:disk_free_limit] === context[:limit] |> Float.floor() |> round
+      end,
+      30000
     )
 
     set_disk_free_limit(@default_limit)
@@ -154,11 +158,12 @@ defmodule SetDiskFreeLimitCommandTest do
   test "run: an integer string input returns an ok and sets the disk free limit", context do
     set_disk_free_limit(@default_limit)
     assert @command.run([context[:limit]], context[:opts]) == :ok
+
     await_condition(
-        fn ->
-           status()[:disk_free_limit] === String.to_integer(context[:limit])
-        end,
-        30000
+      fn ->
+        status()[:disk_free_limit] === String.to_integer(context[:limit])
+      end,
+      30000
     )
 
     set_disk_free_limit(@default_limit)
@@ -167,11 +172,12 @@ defmodule SetDiskFreeLimitCommandTest do
   @tag limit: "2MB"
   test "run: an valid unit string input returns an ok and changes the limit", context do
     assert @command.run([context[:limit]], context[:opts]) == :ok
+
     await_condition(
-        fn ->
-           status()[:disk_free_limit] === 2_000_000
-        end,
-        30000
+      fn ->
+        status()[:disk_free_limit] === 2_000_000
+      end,
+      30000
     )
 
     set_disk_free_limit(@default_limit)
diff --git a/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs b/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs
index dd44f709bb04..cad5076e094c 100644
--- a/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs
+++ b/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs
@@ -2,7 +2,7 @@
 ## License, v. 2.0. If a copy of the MPL was not distributed with this
 ## file, You can obtain one at https://mozilla.org/MPL/2.0/.
 ##
-## Copyright (c) 2007-2023 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.  All rights reserved.
+## Copyright (c) 2007-2024 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.  All rights reserved.
 
 defmodule UpdateVhostMetadataCommandTest do
   use ExUnit.Case, async: false
@@ -81,18 +81,32 @@ defmodule UpdateVhostMetadataCommandTest do
     assert vh[:tags] == [:a1, :b2, :c3]
   end
 
-  test "run: attempt to use a non-existent virtual host fails", context do
-    vh = "a-non-existent-3882-vhost"
+  test "run: enabling deletion protection succeeds", context do
+    add_vhost(@vhost)
 
-    assert match?(
-             {:error, {:no_such_vhost, _}},
-             @command.run([vh], Map.merge(context[:opts], %{description: "irrelevant"}))
-           )
+    opts =
+      Map.merge(context[:opts], %{
+        description: "Protected from deletion",
+        protected_from_deletion: true
+      })
+
+    assert @command.run([@vhost], opts) == :ok
+    vh = find_vhost(@vhost)
+    assert vh[:protected_from_deletion]
   end
 
-  test "run: attempt to use an unreachable node returns a nodedown" do
-    opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"}
-    assert match?({:badrpc, _}, @command.run(["na"], opts))
+  test "run: disabling deletion protection succeeds", context do
+    add_vhost(@vhost)
+
+    opts =
+      Map.merge(context[:opts], %{
+        description: "Protected from deletion",
+        protected_from_deletion: false
+      })
+
+    assert @command.run([@vhost], opts) == :ok
+    vh = find_vhost(@vhost)
+    assert !vh[:protected_from_deletion]
   end
 
   test "run: vhost tags are coerced to a list", context do
@@ -104,6 +118,20 @@ defmodule UpdateVhostMetadataCommandTest do
     assert vh[:tags] == [:my_tag]
   end
 
+  test "run: attempt to use a non-existent virtual host fails", context do
+    vh = "a-non-existent-3882-vhost"
+
+    assert match?(
+             {:error, {:no_such_vhost, _}},
+             @command.run([vh], Map.merge(context[:opts], %{description: "irrelevant"}))
+           )
+  end
+
+  test "run: attempt to use an unreachable node returns a nodedown" do
+    opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"}
+    assert match?({:badrpc, _}, @command.run(["na"], opts))
+  end
+
   test "banner", context do
     assert @command.banner([@vhost], context[:opts]) =~
              ~r/Updating metadata of vhost/
diff --git a/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs b/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs
index 2178bf6f9dfd..b91f1e296eb8 100644
--- a/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs
+++ b/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs
@@ -64,6 +64,7 @@ defmodule SchemaInfoCommandTest do
 
   test "run: can filter info keys", context do
     node = context[:opts][:node]
+
     case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do
       true ->
         :ok
@@ -72,9 +73,9 @@ defmodule SchemaInfoCommandTest do
         wanted_keys = ~w(name access_mode)
 
         assert match?(
-          [[name: _, access_mode: _] | _],
-          run_command_to_list(@command, [wanted_keys, context[:opts]])
-        )
+                 [[name: _, access_mode: _] | _],
+                 run_command_to_list(@command, [wanted_keys, context[:opts]])
+               )
     end
   end
 
diff --git a/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl b/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl
index 2d454d5e85aa..c1fc1437fcd9 100644
--- a/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl
+++ b/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl
@@ -68,6 +68,10 @@ http_post(Config, Path, List, CodeExp) ->
 http_post(Config, Path, List, User, Pass, CodeExp) ->
     http_post_raw(Config, Path, format_for_upload(List), User, Pass, CodeExp).
 
+http_post_json(Config, Path, Body, Assertion) ->
+    http_upload_raw(Config,  post, Path, Body, "guest", "guest",
+        Assertion, [{"content-type", "application/json"}]).
+
 http_post_accept_json(Config, Path, List, CodeExp) ->
     http_post_accept_json(Config, Path, List, "guest", "guest", CodeExp).
 
diff --git a/deps/rabbitmq_management/test/clustering_prop_SUITE.erl b/deps/rabbitmq_management/test/clustering_prop_SUITE.erl
index 56f9d5e80b66..2c335b6fd8b3 100644
--- a/deps/rabbitmq_management/test/clustering_prop_SUITE.erl
+++ b/deps/rabbitmq_management/test/clustering_prop_SUITE.erl
@@ -282,7 +282,7 @@ dump_table(Config, Table) ->
     Data0 = rabbit_ct_broker_helpers:rpc(Config, 1, ets, tab2list, [Table]),
     ct:pal(?LOW_IMPORTANCE, "Node 1: Dump of table ~tp:~n~tp~n", [Table, Data0]).
 
-retry_for(Fun, 0) ->
+retry_for(_Fun, 0) ->
     false;
 retry_for(Fun, Retries) ->
     case Fun() of
diff --git a/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl b/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl
index 00bac6dd2299..8926e0f9fd2e 100644
--- a/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl
+++ b/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl
@@ -27,6 +27,7 @@
                                 http_get_no_decode/5,
                                 http_put/4, http_put/6,
                                 http_post/4, http_post/6,
+                                http_post_json/4,
                                 http_upload_raw/8,
                                 http_delete/3, http_delete/4, http_delete/5,
                                 http_put_raw/4, http_post_accept_json/4,
@@ -3463,7 +3464,7 @@ check_cors_all_endpoints(Config) ->
     Endpoints = get_all_http_endpoints(),
 
     [begin
-        ct:pal("Options for ~tp~n", [EP]),
+        ct:pal("Verifying CORS for module ~tp using an OPTIONS request~n", [EP]),
         {ok, {{_, 200, _}, _, _}} = req(Config, options, EP, [{"origin", "https://rabbitmq.com"}])
     end
      || EP <- Endpoints].
@@ -4208,10 +4209,6 @@ publish(Ch) ->
         publish(Ch)
     end.
 
-http_post_json(Config, Path, Body, Assertion) ->
-    http_upload_raw(Config,  post, Path, Body, "guest", "guest",
-                    Assertion, [{"content-type", "application/json"}]).
-
 %% @doc encode fields and file for HTTP post multipart/form-data.
 %% @reference Inspired by <a href="http://code.activestate.com/recipes/146306/">Python implementation</a>.
 format_multipart_filedata(Boundary, Files) ->
diff --git a/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl b/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl
index 4e12d7696cda..e891cf032b17 100644
--- a/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl
+++ b/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl
@@ -47,7 +47,7 @@ all_tests() -> [
                ].
 
 %% -------------------------------------------------------------------
-%% Testsuite setup/teardown.
+%% Test suite setup/teardown
 %% -------------------------------------------------------------------
 
 init_per_group(Group, Config0) ->
@@ -90,7 +90,7 @@ end_per_testcase(Testcase, Config) ->
     rabbit_ct_helpers:testcase_finished(Config, Testcase).
 
 %% -------------------------------------------------------------------
-%% Testcases.
+%% Test cases
 %% -------------------------------------------------------------------
 
 health_checks_test(Config) ->
diff --git a/deps/rabbitmq_management/test/rabbit_mgmt_http_vhost_deletion_protection_SUITE.erl b/deps/rabbitmq_management/test/rabbit_mgmt_http_vhost_deletion_protection_SUITE.erl
new file mode 100644
index 000000000000..0aa04bddb188
--- /dev/null
+++ b/deps/rabbitmq_management/test/rabbit_mgmt_http_vhost_deletion_protection_SUITE.erl
@@ -0,0 +1,170 @@
+%% This Source Code Form is subject to the terms of the Mozilla Public
+%% License, v. 2.0. If a copy of the MPL was not distributed with this
+%% file, You can obtain one at https://mozilla.org/MPL/2.0/.
+%%
+%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved.
+%%
+
+-module(rabbit_mgmt_http_vhost_deletion_protection_SUITE).
+
+-include("rabbit_mgmt.hrl").
+-include_lib("amqp_client/include/amqp_client.hrl").
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("rabbitmq_ct_helpers/include/rabbit_mgmt_test.hrl").
+
+-import(rabbit_mgmt_test_util, [http_get/3, http_delete/3, http_post_json/4]).
+
+-compile(nowarn_export_all).
+-compile(export_all).
+
+all() ->
+    [
+     {group, cluster_size_3},
+     {group, single_node}
+    ].
+
+groups() ->
+    [
+     {cluster_size_3, [], all_tests()},
+     {single_node,    [], all_tests()}
+    ].
+
+all_tests() -> [
+                protected_virtual_host_cannot_be_deleted,
+                virtual_host_can_be_deleted_after_protection_removal,
+                protected_virtual_host_is_marked_as_such_in_definition_export
+               ].
+
+%% -------------------------------------------------------------------
+%% Testsuite setup/teardown.
+%% -------------------------------------------------------------------
+
+init_per_group(Group, Config0) ->
+    rabbit_ct_helpers:log_environment(),
+    inets:start(),
+    ClusterSize = case Group of
+                      cluster_size_3 -> 3;
+                      cluster_size_5 -> 5;
+                      single_node -> 1
+                  end,
+    NodeConf = [{rmq_nodename_suffix, Group},
+                {rmq_nodes_count, ClusterSize},
+                {tcp_ports_base}],
+    Config1 = rabbit_ct_helpers:set_config(Config0, NodeConf),
+    rabbit_ct_helpers:run_setup_steps(
+        Config1,
+      rabbit_ct_broker_helpers:setup_steps() ++
+      rabbit_ct_client_helpers:setup_steps()).
+
+end_per_group(_, Config) ->
+    inets:stop(),
+    Teardown0 = rabbit_ct_client_helpers:teardown_steps(),
+    Teardown1 = rabbit_ct_broker_helpers:teardown_steps(),
+    Steps = Teardown0 ++ Teardown1,
+    rabbit_ct_helpers:run_teardown_steps(Config, Steps).
+
+init_per_testcase(Testcase, Config) ->
+    case rabbit_ct_helpers:is_mixed_versions() of
+        true ->
+            {skip, "not mixed versions compatible"};
+        _ ->
+            rabbit_ct_helpers:testcase_started(Config, Testcase)
+    end.
+
+end_per_testcase(Testcase, Config) ->
+    rabbit_ct_helpers:testcase_finished(Config, Testcase).
+
+%% -------------------------------------------------------------------
+%% Test cases
+%% -------------------------------------------------------------------
+
+-define(DELETION_PROTECTION_KEY, protected_from_deletion).
+
+protected_virtual_host_cannot_be_deleted(Config) ->
+    VH = rabbit_data_coercion:to_binary(?FUNCTION_NAME),
+
+    MetaWhenLocked = #{
+        ?DELETION_PROTECTION_KEY => true,
+        tags => [VH, "locked"]
+    },
+
+    MetaWhenUnlocked = #{
+        ?DELETION_PROTECTION_KEY => false,
+        tags => [VH]
+    },
+
+    %% extra care needs to be taken to a delete a potentially protected virtual host
+    rabbit_ct_broker_helpers:update_vhost_metadata(Config, VH, MetaWhenUnlocked),
+    rabbit_ct_broker_helpers:delete_vhost(Config, VH),
+    rabbit_ct_broker_helpers:add_vhost(Config, VH),
+    rabbit_ct_broker_helpers:set_full_permissions(Config, VH),
+
+    %% protect the virtual host from deletion
+    rabbit_ct_broker_helpers:update_vhost_metadata(Config, VH, MetaWhenLocked),
+
+    %% deletion fails with 412 Precondition Failed
+    Path = io_lib:format("/vhosts/~ts", [VH]),
+    http_delete(Config, Path, 412),
+
+    rabbit_ct_broker_helpers:update_vhost_metadata(Config, VH, MetaWhenUnlocked),
+    http_delete(Config, Path, {group, '2xx'}),
+
+    passed.
+
+virtual_host_can_be_deleted_after_protection_removal(Config) ->
+    VH = rabbit_data_coercion:to_binary(?FUNCTION_NAME),
+
+    rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, VH),
+    rabbit_ct_broker_helpers:delete_vhost(Config, VH),
+    rabbit_ct_broker_helpers:add_vhost(Config, VH),
+    rabbit_ct_broker_helpers:set_full_permissions(Config, VH),
+
+    rabbit_ct_broker_helpers:enable_vhost_protection_from_deletion(Config, VH),
+
+    %% deletion fails with 412 Precondition Failed
+    Path = io_lib:format("/vhosts/~ts", [VH]),
+    http_delete(Config, Path, 412),
+
+    %% lift the protection
+    rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, VH),
+    %% deletion succeeds
+    http_delete(Config, Path, {group, '2xx'}),
+    %% subsequent deletion responds with a 404 Not Found
+    http_delete(Config, Path, ?NOT_FOUND),
+
+    passed.
+
+protected_virtual_host_is_marked_as_such_in_definition_export(Config) ->
+    Name = rabbit_data_coercion:to_binary(?FUNCTION_NAME),
+
+    %% extra care needs to be taken to a delete a potentially protected virtual host
+    rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, Name),
+    rabbit_ct_broker_helpers:delete_vhost(Config, Name),
+
+    rabbit_ct_broker_helpers:add_vhost(Config, Name),
+    rabbit_ct_broker_helpers:set_full_permissions(Config, Name),
+
+    %% protect the virtual host from deletion
+    rabbit_ct_broker_helpers:enable_vhost_protection_from_deletion(Config, Name),
+
+    %% Get the definitions
+    Definitions = http_get(Config, "/definitions", ?OK),
+    ct:pal("Exported definitions:~n~tp~tn", [Definitions]),
+
+    %% Check if vhost definition is correct
+    VHosts = maps:get(vhosts, Definitions),
+    {value, VHost} = lists:search(fun(VHost) ->
+                        maps:get(name, VHost) =:= Name
+                     end, VHosts),
+
+    Metadata = maps:get(metadata, VHost),
+    ?assertEqual(Name, maps:get(name, VHost)),
+    ?assertEqual(Metadata, #{
+        protected_from_deletion => true,
+        default_queue_type      => <<"classic">>
+    }),
+
+    rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, Name),
+    rabbit_ct_broker_helpers:delete_vhost(Config, Name),
+
+    passed.
\ No newline at end of file