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 b2a01b01297c..c17722296df1 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 7e4c0fe5d4a5..05b5cea70fbf 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 2f2c4409cb4c..485d840f0c51 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 14091954c542..943d9a99e54b 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-2025 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 f84e62f01672..54ccf70c7f2c 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 44da1ab7a570..c2525841c799 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 140b558c8155..19de1e43de0a 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 7ad8adf6aacc..71e9519f6b83 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 08e57e9489ee..c833fb484a1f 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 8270015d2724..5b1b750a034d 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 faf56ec12ca8..acabdc8a86b3 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 b7fc07e4535e..e834c9ea0cfc 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 25b66b4b69f1..77f6c247d297 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 58174c6e63f8..296650f96259 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,
@@ -1671,6 +1673,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 e8668ba3b92e..3bcfaf50072d 100644
--- a/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl
+++ b/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl
@@ -162,6 +162,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 0673d0e2568c..16558bd74da3 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 fcbb395c40a8..83000b37f666 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 aa6a634ef97a..55fa6dfcb78b 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 6e0675554267..9a26f90f53fe 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 ef96ce2b0d0f..82eacae8f932 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 306807014475..cfb28a352d3d 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 a579c3de965c..7f170d20c40d 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
@@ -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 18d9490c348a..caefb19d2f94 100644
--- a/deps/rabbitmq_management/test/clustering_prop_SUITE.erl
+++ b/deps/rabbitmq_management/test/clustering_prop_SUITE.erl
@@ -292,7 +292,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 1cac23e46080..80d77ba8a98a 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,
@@ -3595,7 +3596,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].
@@ -4340,10 +4341,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