Repository: emqx/emqx
Pull request #14884: feat(mt): add APIs to manage explicit namespaces
Author: login: thalesmg
Created: 2025-03-17T19:59:02Z | Merged: 2025-03-19T17:32:31Z

Fixes https://emqx.atlassian.net/browse/EMQX-13821

Fixes https://emqx.atlassian.net/browse/EMQX-13822

Release version: e5.9.0

## Summary

## PR Checklist

- [x] Added tests for the changes
- [x] Change log has been added to `changes/(ce|ee)/(feat|perf|fix|breaking)-<PR-id>.en.md` files
- [x] For internal contributor: there is a jira ticket to track this change
- [x] Schema changes are backward compatible (unreleased feature)




Diff:
diff --git a/apps/emqx/priv/bpapi.versions b/apps/emqx/priv/bpapi.versions
index 5053d522490..11b8d91098c 100644
--- a/apps/emqx/priv/bpapi.versions
+++ b/apps/emqx/priv/bpapi.versions
@@ -63,6 +63,7 @@
 {emqx_mgmt_data_backup,1}.
 {emqx_mgmt_trace,1}.
 {emqx_mgmt_trace,2}.
+{emqx_mt_config,1}.
 {emqx_node_rebalance,1}.
 {emqx_node_rebalance,2}.
 {emqx_node_rebalance,3}.
diff --git a/apps/emqx/src/emqx_limiter/src/emqx_limiter_client_container.erl b/apps/emqx/src/emqx_limiter/src/emqx_limiter_client_container.erl
index 5266567e277..a8b736cab9e 100644
--- a/apps/emqx/src/emqx_limiter/src/emqx_limiter_client_container.erl
+++ b/apps/emqx/src/emqx_limiter/src/emqx_limiter_client_container.erl
@@ -25,6 +25,10 @@
     try_consume/2
 ]).
 
+%%------------------------------------------------------------------------------
+%% Type declarations
+%%------------------------------------------------------------------------------
+
 -type t() :: #{emqx_limiter:name() => emqx_limiter_client:t()}.
 -type reason() :: emqx_limiter_client:reason().
 
diff --git a/apps/emqx_mt/src/emqx_mt.erl b/apps/emqx_mt/src/emqx_mt.erl
index 2a1ea189649..72ccb51601e 100644
--- a/apps/emqx_mt/src/emqx_mt.erl
+++ b/apps/emqx_mt/src/emqx_mt.erl
@@ -8,6 +8,8 @@
 -export([
     list_ns/0,
     list_ns/2,
+    list_managed_ns/0,
+    list_managed_ns/2,
     list_clients/1,
     list_clients/2,
     list_clients/3,
@@ -59,6 +61,24 @@ list_ns() ->
 list_ns(LastNs, Limit) ->
     emqx_mt_state:list_ns(LastNs, Limit).
 
+-doc """
+List first page of managed namespaces.
+
+Default page size is 100.
+""".
+-spec list_managed_ns() -> [tns()].
+list_managed_ns() ->
+    list_managed_ns(?MIN_NS, ?DEFAULT_PAGE_SIZE).
+
+-doc """
+List managed namespaces skipping the last namespace from the previous page.
+
+The second argument is the number of managed namespaces to return.
+""".
+-spec list_managed_ns(tns(), non_neg_integer()) -> [tns()].
+list_managed_ns(LastNs, Limit) ->
+    emqx_mt_state:list_managed_ns(LastNs, Limit).
+
 %% @doc Immediately clear session records of the given node.
 %% If a node is down, the records of the node will be cleared after a delay.
 %% If it's known that the node is down is not coming back,
diff --git a/apps/emqx_mt/src/emqx_mt_api.erl b/apps/emqx_mt/src/emqx_mt_api.erl
index 00708069d55..6b068225568 100644
--- a/apps/emqx_mt/src/emqx_mt_api.erl
+++ b/apps/emqx_mt/src/emqx_mt_api.erl
@@ -9,7 +9,6 @@
 -include_lib("hocon/include/hoconsc.hrl").
 -include_lib("emqx_utils/include/emqx_utils_api.hrl").
 -include("emqx_mt.hrl").
-%% -include_lib("emqx/include/logger.hrl").
 
 %% `minirest' and `minirest_trails' API
 -export([
@@ -22,11 +21,13 @@
 
 %% `minirest' handlers
 -export([
-    ns_list/2,
-    client_list/2,
-    client_count/2,
-    tenant_limiter/2,
-    client_limiter/2
+    '/mt/ns_list'/2,
+    '/mt/managed_ns_list'/2,
+    '/mt/bulk_import_configs'/2,
+    '/mt/ns/:ns'/2,
+    '/mt/ns/:ns/config'/2,
+    '/mt/ns/:ns/client_list'/2,
+    '/mt/ns/:ns/client_count'/2
 ]).
 
 %%-------------------------------------------------------------------------------------------------
@@ -53,15 +54,17 @@ api_spec() ->
 paths() ->
     [
         "/mt/ns_list",
+        "/mt/managed_ns_list",
         "/mt/ns/:ns/client_list",
         "/mt/ns/:ns/client_count",
-        "/mt/ns/:ns/limiter/tenant",
-        "/mt/ns/:ns/limiter/client"
+        "/mt/ns/:ns",
+        "/mt/ns/:ns/config",
+        "/mt/bulk_import_configs"
     ].
 
 schema("/mt/ns_list") ->
     #{
-        'operationId' => ns_list,
+        'operationId' => '/mt/ns_list',
         get => #{
             tags => ?TAGS,
             summary => <<"List Namespaces">>,
@@ -82,7 +85,7 @@ schema("/mt/ns_list") ->
     };
 schema("/mt/ns/:ns/client_list") ->
     #{
-        'operationId' => client_list,
+        'operationId' => '/mt/ns/:ns/client_list',
         get => #{
             tags => ?TAGS,
             summary => <<"List Clients in a Namespace">>,
@@ -105,7 +108,7 @@ schema("/mt/ns/:ns/client_list") ->
     };
 schema("/mt/ns/:ns/client_count") ->
     #{
-        'operationId' => client_count,
+        'operationId' => '/mt/ns/:ns/client_count',
         get => #{
             tags => ?TAGS,
             summary => <<"Count Clients in a Namespace">>,
@@ -118,117 +121,107 @@ schema("/mt/ns/:ns/client_count") ->
                 }
         }
     };
-schema("/mt/ns/:ns/limiter/tenant") ->
+schema("/mt/managed_ns_list") ->
     #{
-        'operationId' => tenant_limiter,
+        'operationId' => '/mt/managed_ns_list',
         get => #{
             tags => ?TAGS,
-            summary => <<"Get tenant limiter configuration">>,
-            description => ?DESC("get_tenant_limiter"),
-            parameters => [param_path_ns()],
+            summary => <<"List managed namespaces">>,
+            description => ?DESC("managed_ns_list"),
+            parameters => [
+                last_ns_in_query(),
+                limit_in_query()
+            ],
             responses =>
                 #{
-                    201 => ref(limiter_out),
-                    404 => error_schema('NOT_FOUND', "Namespace not found")
+                    200 =>
+                        emqx_dashboard_swagger:schema_with_examples(
+                            array(binary()),
+                            example_ns_list()
+                        )
                 }
-        },
+        }
+    };
+schema("/mt/ns/:ns") ->
+    #{
+        'operationId' => '/mt/ns/:ns',
         post => #{
             tags => ?TAGS,
-            summary => <<"Create tenant limiter configuration">>,
-            description => ?DESC("create_tenant_limiter"),
+            summary => <<"Create managed namespace">>,
+            description => ?DESC("create_managed_ns"),
             parameters => [param_path_ns()],
-            'requestBody' => emqx_dashboard_swagger:schema_with_examples(
-                ref(limiter_in),
-                example_limiter_out()
-            ),
             responses =>
                 #{
                     204 => <<"">>,
-                    400 => error_schema('BAD_REQUEST', "Maximum number of configurations reached"),
-                    404 => error_schema('NOT_FOUND', "Namespace not found")
-                }
-        },
-        put => #{
-            tags => ?TAGS,
-            summary => <<"Update tenant limiter configuration">>,
-            description => ?DESC("update_tenant_limiter"),
-            parameters => [param_path_ns()],
-            'requestBody' => emqx_dashboard_swagger:schema_with_examples(
-                ref(limiter_in),
-                example_limiter_out()
-            ),
-            responses =>
-                #{
-                    200 => ref(limiter_out),
-                    404 => error_schema('NOT_FOUND', "Namespace not found")
+                    400 => error_schema('BAD_REQUEST', "Maximum number of configurations reached")
                 }
         },
         delete => #{
             tags => ?TAGS,
-            summary => <<"Update tenant limiter configuration">>,
-            description => ?DESC("update_tenant_limiter"),
+            summary => <<"Delete managed namespace">>,
+            description => ?DESC("delete_managed_ns"),
             parameters => [param_path_ns()],
             responses =>
                 #{
-                    204 => <<"">>,
-                    404 => error_schema('NOT_FOUND', "Namespace not found")
+                    204 => <<"">>
                 }
         }
     };
-schema("/mt/ns/:ns/limiter/client") ->
+schema("/mt/ns/:ns/config") ->
     #{
-        'operationId' => client_limiter,
+        'operationId' => '/mt/ns/:ns/config',
         get => #{
             tags => ?TAGS,
-            summary => <<"Get client limiter configuration">>,
-            description => ?DESC("get_client_limiter"),
+            summary => <<"Get managed namespace configuration">>,
+            description => ?DESC("get_managed_ns_config"),
             parameters => [param_path_ns()],
             responses =>
                 #{
-                    201 => ref(limiter_out),
+                    200 =>
+                        emqx_dashboard_swagger:schema_with_examples(
+                            ref(config_out),
+                            example_config_out()
+                        ),
                     404 => error_schema('NOT_FOUND', "Namespace not found")
                 }
         },
-        post => #{
+        put => #{
             tags => ?TAGS,
-            summary => <<"Create client limiter configuration">>,
-            description => ?DESC("create_client_limiter"),
+            summary => <<"Update managed namespace configuration">>,
+            description => ?DESC("update_managed_ns_config"),
             parameters => [param_path_ns()],
             'requestBody' => emqx_dashboard_swagger:schema_with_examples(
-                ref(limiter_in),
-                example_limiter_out()
+                ref(config_in),
+                example_config_in()
             ),
             responses =>
                 #{
-                    204 => <<"">>,
-                    400 => error_schema('BAD_REQUEST', "Maximum number of configurations reached"),
+                    200 =>
+                        emqx_dashboard_swagger:schema_with_examples(
+                            ref(config_out),
+                            example_config_out()
+                        ),
+                    400 => error_schema('BAD_REQUEST', "Invalid configuration"),
                     404 => error_schema('NOT_FOUND', "Namespace not found")
                 }
-        },
-        put => #{
+        }
+    };
+schema("/mt/bulk_import_configs") ->
+    #{
+        'operationId' => '/mt/bulk_import_configs',
+        post => #{
             tags => ?TAGS,
-            summary => <<"Update client limiter configuration">>,
-            description => ?DESC("update_client_limiter"),
-            parameters => [param_path_ns()],
+            summary => <<"Upsert namespace configurations in bulk">>,
+            description => ?DESC("bulk_import_configs"),
             'requestBody' => emqx_dashboard_swagger:schema_with_examples(
-                ref(limiter_in),
-                example_limiter_out()
+                hoconsc:array(ref(bulk_config_in)),
+                example_bulk_configs_in()
             ),
-            responses =>
-                #{
-                    200 => ref(limiter_out),
-                    404 => error_schema('NOT_FOUND', "Namespace not found")
-                }
-        },
-        delete => #{
-            tags => ?TAGS,
-            summary => <<"Update client limiter configuration">>,
-            description => ?DESC("update_client_limiter"),
-            parameters => [param_path_ns()],
             responses =>
                 #{
                     204 => <<"">>,
-                    404 => error_schema('NOT_FOUND', "Namespace not found")
+                    400 => error_schema('BAD_REQUEST', "Invalid configurations"),
+                    500 => error_schema('INTERNAL_ERROR', "Some side-effects failed to execute")
                 }
         }
     }.
@@ -281,12 +274,35 @@ last_clientid_in_query() ->
             }
         )}.
 
+fields(config_in) ->
+    [
+        {limiter, mk(ref(limiter_config_in), #{})},
+        {session, mk(ref(session_config_in), #{})}
+    ];
+fields(limiter_config_in) ->
+    [
+        {tenant, mk(hoconsc:union([disabled, ref(limiter_in)]), #{})},
+        {client, mk(hoconsc:union([disabled, ref(limiter_in)]), #{})}
+    ];
 fields(limiter_in) ->
     [
         {bytes, mk(ref(limiter_options), #{})},
         {messages, mk(ref(limiter_options), #{})}
     ];
+fields(session_config_in) ->
+    [
+        {max_sessions, mk(hoconsc:union([infinity, non_neg_integer()]), #{})}
+    ];
+fields(bulk_config_in) ->
+    [
+        {ns, mk(binary(), #{})},
+        {config, mk(ref(config_in), #{})}
+    ];
+fields(config_out) ->
+    %% At this moment, same schema as input
+    fields(config_in);
 fields(limiter_out) ->
+    %% At this moment, same schema as input
     fields(limiter_in);
 fields(limiter_options) ->
     [
@@ -302,13 +318,13 @@ error_schema(Code, Message) ->
 %% `minirest' handlers
 %%-------------------------------------------------------------------------------------------------
 
-ns_list(get, Params) ->
+'/mt/ns_list'(get, Params) ->
     QS = maps:get(query_string, Params, #{}),
     LastNs = maps:get(<<"last_ns">>, QS, ?MIN_NS),
     Limit = maps:get(<<"limit">>, QS, ?DEFAULT_PAGE_SIZE),
     ?OK(emqx_mt:list_ns(LastNs, Limit)).
 
-client_list(get, #{bindings := #{ns := Ns}} = Params) ->
+'/mt/ns/:ns/client_list'(get, #{bindings := #{ns := Ns}} = Params) ->
     QS = maps:get(query_string, Params, #{}),
     LastClientId = maps:get(<<"last_clientid">>, QS, ?MIN_CLIENTID),
     Limit = maps:get(<<"limit">>, QS, ?DEFAULT_PAGE_SIZE),
@@ -317,111 +333,93 @@ client_list(get, #{bindings := #{ns := Ns}} = Params) ->
         {error, not_found} -> ?NOT_FOUND("Namespace not found")
     end.
 
-client_count(get, #{bindings := #{ns := Ns}}) ->
+'/mt/ns/:ns/client_count'(get, #{bindings := #{ns := Ns}}) ->
     case emqx_mt:count_clients(Ns) of
         {ok, Count} -> ?OK(#{count => Count});
         {error, not_found} -> ?NOT_FOUND("Namespace not found")
     end.
 
-tenant_limiter(get, #{bindings := #{ns := Ns}}) ->
-    %% TODO: check NS exists and was explicitly created.
-    case emqx_mt_config:get_tenant_limiter_config(Ns) of
-        {ok, Limiter} ->
-            ?OK(limiter_out(Limiter));
-        {error, not_found} ->
-            ?NOT_FOUND(<<"Limiter configuration not found">>)
-    end;
-tenant_limiter(delete, #{bindings := #{ns := Ns}}) ->
-    case emqx_mt_config:delete_tenant_limiter_config(Ns) of
+'/mt/managed_ns_list'(get, Params) ->
+    QS = maps:get(query_string, Params, #{}),
+    LastNs = maps:get(<<"last_ns">>, QS, ?MIN_NS),
+    Limit = maps:get(<<"limit">>, QS, ?DEFAULT_PAGE_SIZE),
+    ?OK(emqx_mt:list_managed_ns(LastNs, Limit)).
+
+'/mt/ns/:ns'(post, #{bindings := #{ns := Ns}}) ->
+    case emqx_mt_config:create_managed_ns(Ns) of
         ok ->
             ?NO_CONTENT;
-        {error, not_found} ->
-            ?NO_CONTENT;
+        {error, table_is_full} ->
+            ?BAD_REQUEST(<<"Maximum number of managed namespaces reached">>);
         {error, Reason} ->
             ?BAD_REQUEST(Reason)
     end;
-tenant_limiter(post, #{bindings := #{ns := Ns}, body := Params}) ->
-    case emqx_mt_config:get_tenant_limiter_config(Ns) of
-        {error, not_found} ->
-            handle_create_limiter(?tenant_limiter, Ns, Params);
-        {ok, _} ->
-            ?BAD_REQUEST(<<"Limiter config already exists">>)
-    end;
-tenant_limiter(put, #{bindings := #{ns := Ns}, body := Params}) ->
-    case emqx_mt_config:get_tenant_limiter_config(Ns) of
-        {error, not_found} ->
-            ?NOT_FOUND(<<"Limiter config not found">>);
-        {ok, _} ->
-            handle_update_limiter(?tenant_limiter, Ns, Params)
-    end.
-
-client_limiter(get, #{bindings := #{ns := Ns}}) ->
-    %% TODO: check NS exists and was explicitly created.
-    case emqx_mt_config:get_client_limiter_config(Ns) of
-        {ok, Limiter} ->
-            ?OK(limiter_out(Limiter));
-        {error, not_found} ->
-            ?NOT_FOUND(<<"Limiter configuration not found">>)
-    end;
-client_limiter(delete, #{bindings := #{ns := Ns}}) ->
-    case emqx_mt_config:delete_client_limiter_config(Ns) of
+'/mt/ns/:ns'(delete, #{bindings := #{ns := Ns}}) ->
+    case emqx_mt_config:delete_managed_ns(Ns) of
         ok ->
             ?NO_CONTENT;
-        {error, not_found} ->
-            ?NO_CONTENT;
         {error, Reason} ->
             ?BAD_REQUEST(Reason)
-    end;
-client_limiter(post, #{bindings := #{ns := Ns}, body := Params}) ->
-    case emqx_mt_config:get_client_limiter_config(Ns) of
-        {error, not_found} ->
-            handle_create_limiter(?client_limiter, Ns, Params);
-        {ok, _} ->
-            ?BAD_REQUEST(<<"Limiter config already exists">>)
-    end;
-client_limiter(put, #{bindings := #{ns := Ns}, body := Params}) ->
-    case emqx_mt_config:get_client_limiter_config(Ns) of
-        {error, not_found} ->
-            ?NOT_FOUND(<<"Limiter config not found">>);
-        {ok, _} ->
-            handle_update_limiter(?client_limiter, Ns, Params)
     end.
 
+'/mt/ns/:ns/config'(get, #{bindings := #{ns := Ns}}) ->
+    with_known_managed_ns(Ns, fun() -> handle_get_managed_ns_config(Ns) end);
+'/mt/ns/:ns/config'(put, #{body := Params, bindings := #{ns := Ns}}) ->
+    with_known_managed_ns(Ns, fun() -> handle_update_managed_ns_config(Ns, Params) end).
+
+'/mt/bulk_import_configs'(post, #{body := Params}) ->
+    handle_bulk_import_configs(Params).
+
 %%-------------------------------------------------------------------------------------------------
 %% Handler implementations
 %%-------------------------------------------------------------------------------------------------
 
-%% TODO: check NS exists and was explicitly created.
-handle_create_limiter(?tenant_limiter, Ns, Limiter) ->
-    case emqx_mt_config:set_tenant_limiter_config(Ns, Limiter) of
-        ok ->
-            ?CREATED(limiter_out(Limiter));
-        {error, table_is_full} ->
-            ?BAD_REQUEST(<<"Maximum number of configurations reached">>);
-        {error, Reason} ->
-            ?BAD_REQUEST(Reason)
-    end;
-handle_create_limiter(?client_limiter, Ns, Limiter) ->
-    case emqx_mt_config:set_client_limiter_config(Ns, Limiter) of
-        ok ->
-            ?CREATED(limiter_out(Limiter));
-        {error, table_is_full} ->
-            ?BAD_REQUEST(<<"Maximum number of configurations reached">>);
-        {error, Reason} ->
-            ?BAD_REQUEST(Reason)
+handle_get_managed_ns_config(Ns) ->
+    case emqx_mt_config:get_managed_ns_config(Ns) of
+        {ok, Configs} ->
+            ?OK(configs_out(Configs));
+        {error, not_found} ->
+            managed_ns_not_found()
     end.
 
-handle_update_limiter(?tenant_limiter, Ns, Limiter) ->
-    case emqx_mt_config:set_tenant_limiter_config(Ns, Limiter) of
-        ok ->
-            ?OK(limiter_out(Limiter));
-        {error, Reason} ->
-            ?BAD_REQUEST(Reason)
-    end;
-handle_update_limiter(?client_limiter, Ns, Limiter) ->
-    case emqx_mt_config:set_client_limiter_config(Ns, Limiter) of
-        ok ->
-            ?OK(limiter_out(Limiter));
+handle_update_managed_ns_config(Ns, Configs) ->
+    case emqx_mt_config:update_managed_ns_config(Ns, Configs) of
+        {ok, #{configs := NewConfigs, errors := []}} ->
+            ?OK(configs_out(NewConfigs));
+        {error, not_found} ->
+            managed_ns_not_found();
+        {ok, #{errors := Errors}} ->
+            Msg = #{
+                hint => <<
+                    "Configurations were persisted, but some necessary"
+                    " side-effects failed to execute; please check the logs"
+                >>,
+                errors => Errors
+            },
+            ?INTERNAL_ERROR(Msg)
+    end.
+
+handle_bulk_import_configs(Entries) ->
+    case emqx_mt_config:bulk_import_configs(Entries) of
+        {ok, #{errors := []}} ->
+            ?NO_CONTENT;
+        {ok, #{errors := Errors}} ->
+            Msg = #{
+                hint => <<
+                    "Configurations were persisted, but some necessary"
+                    " side-effects failed to execute; please check the logs"
+                >>,
+                errors => Errors
+            },
+            ?INTERNAL_ERROR(Msg);
+        {error, {aborted, table_is_full}} ->
+            ?BAD_REQUEST(<<"Maximum number of managed namespaces reached">>);
+        {error, {duplicated_nss, Duplicated}} ->
+            Msg = iolist_to_binary([
+                <<"Duplicated namespaces in input: ">>,
+                lists:join(<<", ">>, Duplicated)
+            ]),
+            ?BAD_REQUEST(Msg);
         {error, Reason} ->
             ?BAD_REQUEST(Reason)
     end.
@@ -452,6 +450,48 @@ example_client_list() ->
             }
     }.
 
+example_config_in() ->
+    #{
+        <<"managed_ns_config">> =>
+            #{
+                <<"session">> => #{
+                    <<"max_sessions">> => 10
+                },
+                <<"limiter">> => #{
+                    <<"tenant">> => maps:get(<<"limiter">>, example_limiter_out()),
+                    <<"client">> => maps:get(<<"limiter">>, example_limiter_out())
+                }
+            }
+    }.
+
+example_config_out() ->
+    %% Same as input.  For now.
+    example_config_in().
+
+example_bulk_configs_in() ->
+    #{
+        <<"bulk_import_configs">> =>
+            [
+                #{
+                    <<"ns">> => <<"ns1">>,
+                    <<"config">> => #{
+                        <<"limiter">> => #{
+                            <<"tenant">> => maps:get(<<"limiter">>, example_limiter_out()),
+                            <<"client">> => maps:get(<<"limiter">>, example_limiter_out())
+                        }
+                    }
+                },
+                #{
+                    <<"ns">> => <<"ns1">>,
+                    <<"config">> => #{
+                        <<"session">> => #{
+                            <<"max_sessions">> => 10
+                        }
+                    }
+                }
+            ]
+    }.
+
 example_limiter_out() ->
     #{
         <<"limiter">> =>
@@ -467,6 +507,23 @@ example_limiter_out() ->
             }
     }.
 
+configs_out(RootConfigs) ->
+    maps:map(
+        fun
+            (limiter, Config) ->
+                maps:map(
+                    fun
+                        (_K, disabled) -> <<"disabled">>;
+                        (_K, #{} = Cfg) -> limiter_out(Cfg)
+                    end,
+                    Config
+                );
+            (_RootKey, Config) ->
+                Config
+        end,
+        RootConfigs
+    ).
+
 limiter_out(LimiterConfigs) ->
     maps:map(fun limiter_config_out/2, LimiterConfigs).
 
@@ -482,3 +539,14 @@ limiter_config_out(Unit0, LimiterConfig) ->
         end,
         LimiterConfig
     ).
+
+with_known_managed_ns(Ns, Fn) ->
+    case emqx_mt_config:is_known_managed_ns(Ns) of
+        true ->
+            Fn();
+        false ->
+            managed_ns_not_found()
+    end.
+
+managed_ns_not_found() ->
+    ?NOT_FOUND(<<"Managed namespace not found">>).
diff --git a/apps/emqx_mt/src/emqx_mt_config.erl b/apps/emqx_mt/src/emqx_mt_config.erl
index a8043faf0bb..7de58334d3a 100644
--- a/apps/emqx_mt/src/emqx_mt_config.erl
+++ b/apps/emqx_mt/src/emqx_mt_config.erl
@@ -9,14 +9,31 @@
 %% API
 -export([
     get_max_sessions/1,
+    get_allow_only_managed_namespaces/0,
+    set_allow_only_managed_namespaces/1,
 
-    set_tenant_limiter_config/2,
-    get_tenant_limiter_config/1,
-    delete_tenant_limiter_config/1,
+    create_managed_ns/1,
+    delete_managed_ns/1,
+    is_known_managed_ns/1,
+
+    get_managed_ns_config/1,
+    update_managed_ns_config/2,
+
+    bulk_import_configs/1
+]).
 
-    set_client_limiter_config/2,
-    get_client_limiter_config/1,
-    delete_client_limiter_config/1
+%% BPAPI RPC Targets
+-export([
+    execute_side_effects_v1/1,
+    execute_side_effects_v1/2,
+    cleanup_managed_ns_configs_v1/2,
+    cleanup_managed_ns_configs_v1/3
+]).
+
+%% Internal exports for `mt' application
+-export([
+    get_tenant_limiter_config/1,
+    get_client_limiter_config/1
 ]).
 
 %% Internal APIs for tests
@@ -24,77 +41,198 @@
     tmp_set_default_max_sessions/1
 ]).
 
+-export_type([
+    root_config/0,
+    side_effect/0
+]).
+
+-include_lib("emqx/include/logger.hrl").
+
 %%------------------------------------------------------------------------------
 %% Type declarations
 %%------------------------------------------------------------------------------
 
+%% Root configuration keys
+-define(limiter, limiter).
+-define(session, session).
+
 -define(tenant, tenant).
 -define(client, client).
 
+-define(disabled, disabled).
+
+-type limiter_config() :: #{
+    ?tenant => disabled | tenant_config(),
+    ?client => disabled | client_config()
+}.
 -type tenant_config() :: emqx_mt_limiter:tenant_config().
 -type client_config() :: emqx_mt_limiter:client_config().
 
+-define(max_sessions, max_sessions).
+-type session_config() :: #{
+    ?max_sessions => infinity | non_neg_integer()
+}.
+
+-type root_config() :: #{
+    ?session => session_config(),
+    ?limiter => limiter_config()
+}.
+
+-define(new, new).
+-define(changed, changed).
+-define(op, op).
+-define(path, path).
+
+-type side_effect() :: #{?op := {function(), [term()]}, ?path := [atom()]}.
+
 %%------------------------------------------------------------------------------
 %% API
 %%------------------------------------------------------------------------------
 
 %% @doc Get the maximum number of sessions allowed for the given namespace.
-%% TODO: support per-ns configs
 -spec get_max_sessions(emqx_mt:tns()) -> non_neg_integer() | infinity.
-get_max_sessions(_Ns) ->
-    emqx_config:get([multi_tenancy, default_max_sessions]).
-
-%% TODO: validate NS exists and is explicitly defined.
--spec set_tenant_limiter_config(emqx_mt:tns(), tenant_config()) ->
-    ok | {error, {aborted, _}} | {error, table_is_full}.
-set_tenant_limiter_config(Ns, Config) ->
-    case emqx_mt_state:set_limiter_config(Ns, ?tenant, Config) of
-        {ok, new} ->
-            emqx_mt_limiter:create_tenant_limiter_group(Ns, Config);
-        {ok, updated} ->
-            emqx_mt_limiter:update_tenant_limiter_group(Ns, Config);
-        {error, Reason} ->
-            {error, Reason}
+get_max_sessions(Ns) ->
+    maybe
+        {ok, #{?session := #{?max_sessions := Max}}} ?= get_managed_ns_config(Ns),
+        Max
+    else
+        _ -> emqx_config:get([multi_tenancy, default_max_sessions])
     end.
 
--spec get_tenant_limiter_config(emqx_mt:tns()) ->
-    {ok, tenant_config()} | {error, not_found}.
-get_tenant_limiter_config(Ns) ->
-    emqx_mt_state:get_limiter_config(Ns, ?tenant).
+-doc """
+When `allow_only_managed_namespaces = true`, we don't allow clients from non-managed
+namespaces to connect
+""".
+-spec get_allow_only_managed_namespaces() -> boolean().
+get_allow_only_managed_namespaces() ->
+    emqx_config:get([multi_tenancy, allow_only_managed_namespaces]).
 
--spec delete_tenant_limiter_config(emqx_mt:tns()) ->
-    ok | {error, not_found} | {error, {aborted, _}}.
-delete_tenant_limiter_config(Ns) ->
+-doc """
+When `allow_only_managed_namespaces = true`, we don't allow clients from non-managed
+namespaces to connect
+""".
+-spec set_allow_only_managed_namespaces(boolean()) -> ok.
+set_allow_only_managed_namespaces(Bool) ->
     maybe
-        ok ?= emqx_mt_state:delete_limiter_config(Ns, ?tenant),
-        emqx_mt_limiter:delete_tenant_limiter_group(Ns)
+        {ok, _} ?=
+            emqx_conf:update(
+                [multi_tenancy, allow_only_managed_namespaces],
+                Bool,
+                #{override_to => cluster}
+            ),
+        ok
     end.
 
-%% TODO: validate NS exists and is explicitly defined.
--spec set_client_limiter_config(emqx_mt:tns(), tenant_config()) ->
+-spec get_managed_ns_config(emqx_mt:tns()) ->
+    {ok, root_config()} | {error, not_found}.
+get_managed_ns_config(Ns) ->
+    emqx_mt_state:get_root_configs(Ns).
+
+-spec update_managed_ns_config(emqx_mt:tns(), root_config()) ->
+    {ok, #{
+        configs := root_config(),
+        errors := [#{?path := [atom()], error := {atom(), term()}}]
+    }}
+    | {error, not_found}.
+update_managed_ns_config(Ns, Configs) ->
+    handle_root_configs_update(Ns, Configs).
+
+-spec bulk_import_configs([#{ns := emqx_mt:tns(), config := root_config()}]) ->
+    {ok, #{
+        errors := [
+            #{
+                ?path := [atom()],
+                error := {atom(), term()}
+            }
+        ]
+    }}
+    | {error, {duplicated_nss, [emqx_mt:tns()]}}
+    | {error, {aborted, term()}}.
+bulk_import_configs(Entries) ->
+    handle_bulk_import_configs(Entries).
+
+-spec create_managed_ns(emqx_mt:tns()) ->
     ok | {error, {aborted, _}} | {error, table_is_full}.
-set_client_limiter_config(Ns, Config) ->
-    case emqx_mt_state:set_limiter_config(Ns, ?client, Config) of
-        {ok, new} ->
-            emqx_mt_limiter:create_client_limiter_group(Ns, Config);
-        {ok, updated} ->
-            emqx_mt_limiter:update_client_limiter_group(Ns, Config);
-        {error, Reason} ->
-            {error, Reason}
+create_managed_ns(Ns) ->
+    emqx_mt_state:create_managed_ns(Ns).
+
+-spec delete_managed_ns(emqx_mt:tns()) ->
+    ok | {error, {aborted, _}}.
+delete_managed_ns(Ns) ->
+    maybe
+        {ok, Configs} ?= emqx_mt_state:delete_managed_ns(Ns),
+        emqx_mt_config_proto_v1:cleanup_managed_ns_configs(Ns, maps:to_list(Configs))
     end.
 
+-spec is_known_managed_ns(emqx_mt:tns()) -> boolean().
+is_known_managed_ns(Ns) ->
+    emqx_mt_state:is_known_managed_ns(Ns).
+
+-spec get_tenant_limiter_config(emqx_mt:tns()) ->
+    {ok, tenant_config()} | {error, not_found}.
+get_tenant_limiter_config(Ns) ->
+    emqx_mt_state:get_limiter_config(Ns, ?tenant).
+
 -spec get_client_limiter_config(emqx_mt:tns()) ->
     {ok, client_config()} | {error, not_found}.
 get_client_limiter_config(Ns) ->
     emqx_mt_state:get_limiter_config(Ns, ?client).
 
--spec delete_client_limiter_config(emqx_mt:tns()) ->
-    ok | {error, not_found} | {error, {aborted, _}}.
-delete_client_limiter_config(Ns) ->
-    maybe
-        ok ?= emqx_mt_state:delete_limiter_config(Ns, ?client),
-        emqx_mt_limiter:delete_client_limiter_group(Ns)
-    end.
+%%------------------------------------------------------------------------------
+%% BPAPI RPC targets
+%%------------------------------------------------------------------------------
+
+%% Only to please BPAPI static checks...  Arity 2 version will be called directly by
+%% `emqx_cluster_rpc:multicall'.
+-spec execute_side_effects_v1([side_effect()]) -> {ok, [map()]}.
+execute_side_effects_v1(SideEffects) ->
+    execute_side_effects_v1(SideEffects, _ClusterRPCOpts = #{}).
+
+-spec execute_side_effects_v1([side_effect()], emqx_config:cluster_rpc_opts()) -> {ok, [map()]}.
+execute_side_effects_v1(SideEffects, _ClusterRPCOpts) ->
+    Errors = lists:foldl(
+        fun(#{?op := {Fn, Args}, ?path := Path}, Acc) ->
+            try
+                _ = apply(Fn, Args),
+                Acc
+            catch
+                Class:Reason:Stacktrace ->
+                    ?SLOG(warning, #{
+                        msg => "failed_to_apply_multi_tenancy_config_change_runtime_effect",
+                        ?path => Path,
+                        ?op => Fn,
+                        error => {Class, Reason},
+                        stacktrace => Stacktrace
+                    }),
+                    [
+                        #{
+                            ?path => Path,
+                            ?op => Fn,
+                            error => {Class, Reason}
+                        }
+                        | Acc
+                    ]
+            end
+        end,
+        [],
+        SideEffects
+    ),
+    %% Cluster RPC must see `{ok, _}', or else it will treat the operation as a failure...
+    {ok, Errors}.
+
+%% Only to please BPAPI static checks...  Arity 3 version will be called directly by
+%% `emqx_cluster_rpc:multicall'.
+-spec cleanup_managed_ns_configs_v1(emqx_mt:tns(), [{atom(), map()}]) -> ok.
+cleanup_managed_ns_configs_v1(Ns, ConfigsList) ->
+    cleanup_managed_ns_configs_v1(Ns, ConfigsList, _ClusterRPCOpts = #{}).
+
+-spec cleanup_managed_ns_configs_v1(
+    emqx_mt:tns(),
+    [{atom(), map()}],
+    emqx_config:cluster_rpc_opts()
+) -> ok.
+cleanup_managed_ns_configs_v1(Ns, ConfigsList, _ClusterRPCOpts) ->
+    do_cleanup_managed_ns_configs(Ns, ConfigsList).
 
 %%------------------------------------------------------------------------------
 %% Internal exports
@@ -104,3 +242,159 @@ delete_client_limiter_config(Ns) ->
 -spec tmp_set_default_max_sessions(non_neg_integer() | infinity) -> ok.
 tmp_set_default_max_sessions(Max) ->
     emqx_config:put([multi_tenancy, default_max_sessions], Max).
+
+%%------------------------------------------------------------------------------
+%% Internal fns
+%%------------------------------------------------------------------------------
+
+do_cleanup_managed_ns_configs(_Ns, []) ->
+    ok;
+do_cleanup_managed_ns_configs(Ns, [{?limiter, Configs} | Rest]) ->
+    ok = emqx_mt_limiter:cleanup_configs(Ns, Configs),
+    do_cleanup_managed_ns_configs(Ns, Rest);
+do_cleanup_managed_ns_configs(Ns, [{_RootKey, _Configs} | Rest]) ->
+    do_cleanup_managed_ns_configs(Ns, Rest).
+
+handle_root_configs_update(Ns, NewConfig) ->
+    maybe
+        {ok, #{diffs := Diffs, configs := Configs}} ?=
+            emqx_mt_state:update_root_configs(Ns, NewConfig),
+        SideEffects = compute_config_update_side_effects(Diffs, Ns),
+        Errors = execute_side_effects(SideEffects),
+        {ok, #{configs => Configs, errors => Errors}}
+    end.
+
+execute_side_effects(SideEffects) ->
+    %% Foreced to wrap erros in `{ok, _}' by cluster RPC...
+    {ok, Errors} = emqx_mt_config_proto_v1:execute_side_effects(SideEffects),
+    Errors.
+
+compute_config_update_side_effects(Diffs, Ns) ->
+    #{added := Added0, changed := Changed0} = Diffs,
+    Added = maps:map(fun(_RootKey, Cfg) -> {?new, Cfg} end, Added0),
+    Changed = maps:map(fun(_RootKey, {OldCfg, NewCfg}) -> {?changed, OldCfg, NewCfg} end, Changed0),
+    Changes = maps:merge(Added, Changed),
+    maps:fold(
+        fun
+            (?limiter, Op, Acc) ->
+                SideEffects = limiter_update_side_effects(Ns, Op),
+                SideEffects ++ Acc;
+            (_OtherRootKey, _Op, Acc) ->
+                Acc
+        end,
+        [],
+        Changes
+    ).
+
+-spec limiter_update_side_effects(emqx_mt:tns(), {?new, limiter_config()}) -> [side_effect()].
+limiter_update_side_effects(Ns, {?new, Config}) ->
+    lists:map(
+        fun
+            ({?tenant = Kind, ?disabled}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:ensure_tenant_limiter_group_absent/1, [Ns]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?tenant = Kind, #{} = Cfg}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:create_tenant_limiter_group/2, [Ns, Cfg]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?client = Kind, ?disabled}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:ensure_client_limiter_group_absent/1, [Ns]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?client = Kind, #{} = Cfg}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:create_client_limiter_group/2, [Ns, Cfg]},
+                    ?path => [?limiter, Kind]
+                }
+        end,
+        maps:to_list(Config)
+    );
+limiter_update_side_effects(Ns, {?changed, OldConfig, NewConfig}) ->
+    #{
+        added := Added,
+        changed := Changed
+    } = emqx_utils_maps:diff_maps(NewConfig, OldConfig),
+    Changes0 = maps:merge(Added, Changed),
+    Changes =
+        maps:map(
+            fun
+                (_K, ?disabled = V) -> V;
+                (_K, {_, ?disabled = V}) -> V;
+                (_K, #{} = Cfg) -> {?new, Cfg};
+                (_K, {?disabled, #{} = Cfg}) -> {?new, Cfg};
+                (_K, {_, #{} = Cfg}) -> {?changed, Cfg}
+            end,
+            Changes0
+        ),
+    lists:map(
+        fun
+            ({?tenant = Kind, ?disabled}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:ensure_tenant_limiter_group_absent/1, [Ns]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?tenant = Kind, {?new, #{} = Cfg}}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:create_tenant_limiter_group/2, [Ns, Cfg]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?tenant = Kind, {?changed, #{} = Cfg}}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:update_tenant_limiter_group/2, [Ns, Cfg]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?client = Kind, ?disabled}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:ensure_client_limiter_group_absent/1, [Ns]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?client = Kind, {?new, #{} = Cfg}}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:create_client_limiter_group/2, [Ns, Cfg]},
+                    ?path => [?limiter, Kind]
+                };
+            ({?client = Kind, {?changed, #{} = Cfg}}) ->
+                #{
+                    ?op => {fun emqx_mt_limiter:update_client_limiter_group/2, [Ns, Cfg]},
+                    ?path => [?limiter, Kind]
+                }
+        end,
+        maps:to_list(Changes)
+    ).
+
+handle_bulk_import_configs(Entries) ->
+    maybe
+        ok ?= validate_unique_nss(Entries),
+        {ok, Changes} ?= emqx_mt_state:bulk_import_configs(Entries),
+        SideEffects = compute_bulk_side_effects(Changes),
+        Errors = execute_side_effects(SideEffects),
+        {ok, #{errors => Errors}}
+    end.
+
+validate_unique_nss(Entries) ->
+    NSs0 = lists:map(fun(#{ns := Ns}) -> Ns end, Entries),
+    NSs = lists:sort(NSs0),
+    UniqueNSs = lists:usort(NSs0),
+    Duplicated = NSs -- UniqueNSs,
+    case Duplicated of
+        [] ->
+            ok;
+        _ ->
+            {error, {duplicated_nss, Duplicated}}
+    end.
+
+compute_bulk_side_effects(Changes) ->
+    maps:fold(
+        fun(Ns, #{old := Old, new := New}, Acc) ->
+            Diffs = emqx_utils_maps:diff_maps(New, Old),
+            SEs0 = compute_config_update_side_effects(Diffs, Ns),
+            SEs = lists:map(fun(#{?path := Path} = SE) -> SE#{?path := [Ns | Path]} end, SEs0),
+            SEs ++ Acc
+        end,
+        [],
+        Changes
+    ).
diff --git a/apps/emqx_mt/src/emqx_mt_hookcb.erl b/apps/emqx_mt/src/emqx_mt_hookcb.erl
index f940df4650e..8d9d40bf774 100644
--- a/apps/emqx_mt/src/emqx_mt_hookcb.erl
+++ b/apps/emqx_mt/src/emqx_mt_hookcb.erl
@@ -69,21 +69,22 @@ on_authenticate(
                             DefaultResult
                     end;
                 {error, not_found} ->
-                    %% TDOO: deny access when namespaces are managed by admin
-                    %% so far ns is created from client attributes
-                    %% case emqx_mt_config:is_managed_ns() of
-                    %%   true -> {stop, {error, not_auhorized}};
-                    %%   false -> DefaultResult
-                    %%  end
-                    ?TRACE("first_clientid_in_namespace", #{}),
-                    DefaultResult
+                    AllowOnlyManagedNSs = emqx_mt_config:get_allow_only_managed_namespaces(),
+                    case AllowOnlyManagedNSs of
+                        true ->
+                            {stop, {error, not_authorized}};
+                        false ->
+                            ?TRACE("first_clientid_in_namespace", #{}),
+                            DefaultResult
+                    end
             end
     end;
 on_authenticate(_, DefaultResult) ->
-    %% TDOO: deny access when namespaces is mandatory
-    %% case emqx_mt_config:is_ns_mandatory() of
-    %%   true -> {stop, {error, not_authorized}};
-    %%   false -> DefaultResult
-    %% end
-    ?TRACE("new_tenant_namespace", #{}),
-    DefaultResult.
+    AllowOnlyManagedNSs = emqx_mt_config:get_allow_only_managed_namespaces(),
+    case AllowOnlyManagedNSs of
+        true ->
+            {stop, {error, not_authorized}};
+        false ->
+            ?TRACE("new_tenant_namespace", #{}),
+            DefaultResult
+    end.
diff --git a/apps/emqx_mt/src/emqx_mt_limiter.erl b/apps/emqx_mt/src/emqx_mt_limiter.erl
index 5b5bf0af7b1..7ad6179549f 100644
--- a/apps/emqx_mt/src/emqx_mt_limiter.erl
+++ b/apps/emqx_mt/src/emqx_mt_limiter.erl
@@ -24,10 +24,14 @@ If one of the limiters lack configuration, we simply don't do each action above.
     create_tenant_limiter_group/2,
     update_tenant_limiter_group/2,
     delete_tenant_limiter_group/1,
+    ensure_tenant_limiter_group_absent/1,
 
     create_client_limiter_group/2,
     update_client_limiter_group/2,
-    delete_client_limiter_group/1
+    delete_client_limiter_group/1,
+    ensure_client_limiter_group_absent/1,
+
+    cleanup_configs/2
 ]).
 
 %% 'channel.limiter_adjustment' hookpoint
@@ -91,6 +95,9 @@ update_tenant_limiter_group(Ns, Config) ->
 delete_tenant_limiter_group(Ns) ->
     emqx_limiter:delete_group(tenant_group(Ns)).
 
+ensure_tenant_limiter_group_absent(Ns) ->
+    ensure_group_absent(tenant_group(Ns)).
+
 %%
 
 create_client_limiter_group(Ns, Config) ->
@@ -104,6 +111,20 @@ update_client_limiter_group(Ns, Config) ->
 delete_client_limiter_group(Ns) ->
     emqx_limiter:delete_group(client_group(Ns)).
 
+ensure_client_limiter_group_absent(Ns) ->
+    ensure_group_absent(client_group(Ns)).
+
+%%
+
+cleanup_configs(Ns, _Configs) ->
+    %% Note: we may safely delete the limiter groups here: when clients attempt to consume
+    %% from the now dangling limiters, `emqx_limiter_client' will log the error but don't
+    %% do any limiting when it fails to fetch the missing limiter group configuration.
+    %% The user may choose to later kick all clients from this namespace.
+    _ = ensure_group_absent(tenant_group(Ns)),
+    _ = ensure_group_absent(client_group(Ns)),
+    ok.
+
 %%------------------------------------------------------------------------------
 %% 'channel.limiter_adjustment' hookpoint
 %%------------------------------------------------------------------------------
@@ -187,3 +208,12 @@ create_client_limiters(ListenerId, Ns, Name) ->
             ListenerLimiterClient = emqx_limiter:connect(ListenerLimiterId),
             [ListenerLimiterClient]
     end.
+
+ensure_group_absent(Group) ->
+    try emqx_limiter:delete_group(Group) of
+        ok ->
+            ok
+    catch
+        error:{limiter_group_not_found, _} ->
+            ok
+    end.
diff --git a/apps/emqx_mt/src/emqx_mt_schema.erl b/apps/emqx_mt/src/emqx_mt_schema.erl
index 4ced27cbffb..c1dda505530 100644
--- a/apps/emqx_mt/src/emqx_mt_schema.erl
+++ b/apps/emqx_mt/src/emqx_mt_schema.erl
@@ -35,6 +35,15 @@ fields("config") ->
                     importance => ?IMPORTANCE_HIGH,
                     default => infinity
                 }
+            )},
+        {allow_only_managed_namespaces,
+            mk(
+                boolean(),
+                #{
+                    desc => ?DESC("allow_only_managed_namespaces"),
+                    importance => ?IMPORTANCE_HIGH,
+                    default => false
+                }
             )}
     ].
 
diff --git a/apps/emqx_mt/src/emqx_mt_state.erl b/apps/emqx_mt/src/emqx_mt_state.erl
index 8004eac3a6f..4c892a12d4e 100644
--- a/apps/emqx_mt/src/emqx_mt_state.erl
+++ b/apps/emqx_mt/src/emqx_mt_state.erl
@@ -26,12 +26,36 @@
     clear_self_node/0
 ]).
 
+%% Managed namespaces
 -export([
-    set_limiter_config/3,
-    get_limiter_config/2,
-    delete_limiter_config/2
+    list_managed_ns/2,
+    create_managed_ns/1,
+    delete_managed_ns/1,
+    is_known_managed_ns/1,
+
+    get_root_configs/1,
+    update_root_configs/2,
+
+    bulk_import_configs/1
 ]).
 
+%% Limiter
+-export([
+    get_limiter_config/2
+]).
+
+%% In-transaction fns
+-export([
+    create_managed_ns_txn/1,
+    delete_managed_ns_txn/1,
+    update_root_configs_txn/2,
+    bulk_update_root_configs_txn/1
+]).
+
+-ifdef(TEST).
+-export([update_ccache/1]).
+-endif.
+
 -include("emqx_mt.hrl").
 -include_lib("stdlib/include/ms_transform.hrl").
 -include_lib("snabbkaffe/include/snabbkaffe.hrl").
@@ -87,7 +111,7 @@
 %% Currently, we limit the maximum number of configurable namespaces.
 -record(?CONFIG_TAB, {
     key :: tns(),
-    configs :: tns_config(),
+    configs :: emqx_mt_config:root_config(),
     extra = #{} :: map()
 }).
 
@@ -103,10 +127,6 @@
 -type tns() :: emqx_mt:tns().
 -type clientid() :: emqx_types:clientid().
 
--type tns_config() :: #{
-    ?limiter => emqx_mt_limiter:root_config()
-}.
-
 %%------------------------------------------------------------------------------
 %% API
 %%------------------------------------------------------------------------------
@@ -136,6 +156,12 @@ create_tables() ->
         {storage, disc_copies},
         {attributes, record_info(fields, ?CONFIG_TAB)}
     ]),
+    ok = mria:wait_for_tables([
+        ?NS_TAB,
+        ?RECORD_TAB,
+        ?COUNTER_TAB,
+        ?CONFIG_TAB
+    ]),
     _ = ets:new(?MONITOR_TAB, [
         set,
         named_table,
@@ -153,18 +179,29 @@ create_tables() ->
 %% The third argument is the number of namespaces to return.
 -spec list_ns(tns(), pos_integer()) -> [tns()].
 list_ns(LastNs, Limit) ->
-    do_list_ns(LastNs, Limit).
+    do_list_ns(?NS_TAB, LastNs, Limit).
 
-do_list_ns(_LastNs, 0) ->
+do_list_ns(_Table, _LastNs, 0) ->
     [];
-do_list_ns(LastNs, Limit) ->
-    case ets:next(?NS_TAB, LastNs) of
+do_list_ns(Table, LastNs, Limit) ->
+    case ets:next(Table, LastNs) of
         '$end_of_table' ->
             [];
         Ns ->
-            [Ns | do_list_ns(Ns, Limit - 1)]
+            [Ns | do_list_ns(Table, Ns, Limit - 1)]
     end.
 
+-doc """
+List managed namespaces.
+
+The second argument is the last namespace from the previous page.
+
+The third argument is the number of namespaces to return.
+""".
+-spec list_managed_ns(tns(), pos_integer()) -> [tns()].
+list_managed_ns(LastNs, Limit) ->
+    do_list_ns(?CONFIG_TAB, LastNs, Limit).
+
 %% @doc count the number of clients for a given tns.
 -spec count_clients(tns()) -> {ok, non_neg_integer()} | {error, not_found}.
 count_clients(Ns) ->
@@ -197,14 +234,17 @@ lookup_counter_from_cache(Ns) ->
 with_ccache(Ns) ->
     case lookup_counter_from_cache(Ns) of
         false ->
-            Cnt = do_count_clients(Ns),
-            Ts = now_ts(),
-            _ = ets:insert(?CCACHE_TAB, ?CCACHE(Ns, Ts, Cnt)),
-            Cnt;
+            update_ccache(Ns);
         Cnt ->
             Cnt
     end.
 
+update_ccache(Ns) ->
+    Cnt = do_count_clients(Ns),
+    Ts = now_ts(),
+    _ = ets:insert(?CCACHE_TAB, ?CCACHE(Ns, Ts, Cnt)),
+    Cnt.
+
 do_count_clients(Ns) ->
     Ms = ets:fun2ms(fun(#?COUNTER_TAB{key = ?COUNTER_KEY(Ns0, _), count = Count}) when
         Ns0 =:= Ns
@@ -329,17 +369,42 @@ evict_ccache(Ns) ->
     ets:delete(?CCACHE_TAB, Ns),
     ok.
 
--spec set_limiter_config(
-    emqx_mt:tns(),
-    emqx_mt_limiter:limiter_kind(),
-    emqx_mt_limiter:tenant_config() | emqx_mt_limiter:client_config()
-) ->
-    {ok, new | updated} | {error, {aborted, _}} | {error, table_is_full}.
-set_limiter_config(Ns, Kind, Config) ->
-    transaction(fun set_limiter_config_txn/3, [Ns, Kind, Config]).
+-spec create_managed_ns(emqx_mt:tns()) ->
+    ok | {error, {aborted, _}} | {error, table_is_full}.
+create_managed_ns(Ns) ->
+    ensure_ns_added(Ns),
+    transaction(fun create_managed_ns_txn/1, [Ns]).
+
+-spec delete_managed_ns(emqx_mt:tns()) ->
+    {ok, emqx_mt_config:root_config()} | {error, {aborted, _}}.
+delete_managed_ns(Ns) ->
+    %% Note: we may safely delete the limiter groups here: when clients attempt to consume
+    %% from the now dangling limiters, `emqx_limiter_client' will log the error but don't
+    %% do any limiting when it fails to fetch the missing limiter group configuration.
+    %% The user may choose to later kick all clients from this namespace.
+    transaction(fun delete_managed_ns_txn/1, [Ns]).
+
+-spec is_known_managed_ns(emqx_mt:tns()) -> boolean().
+is_known_managed_ns(Ns) ->
+    case mnesia:dirty_read(?CONFIG_TAB, Ns) of
+        [] ->
+            false;
+        [_] ->
+            true
+    end.
+
+-spec get_root_configs(emqx_mt:tns()) ->
+    {ok, emqx_mt_config:root_config()} | {error, not_found}.
+get_root_configs(Ns) ->
+    case mnesia:dirty_read(?CONFIG_TAB, Ns) of
+        [#?CONFIG_TAB{configs = Configs}] ->
+            {ok, Configs};
+        _ ->
+            {error, not_found}
+    end.
 
-delete_limiter_config(Ns, Kind) ->
-    transaction(fun delete_limiter_config_txn/2, [Ns, Kind]).
+update_root_configs(Ns, NewConfig) ->
+    transaction(fun ?MODULE:update_root_configs_txn/2, [Ns, NewConfig]).
 
 get_limiter_config(Ns, Kind) ->
     case mnesia:dirty_read(?CONFIG_TAB, Ns) of
@@ -349,6 +414,9 @@ get_limiter_config(Ns, Kind) ->
             {error, not_found}
     end.
 
+bulk_import_configs(Entries) ->
+    transaction(fun ?MODULE:bulk_update_root_configs_txn/1, [Entries]).
+
 %%------------------------------------------------------------------------------
 %% Internal fns
 %%------------------------------------------------------------------------------
@@ -361,45 +429,70 @@ transaction(Fn, Args) ->
             {error, {aborted, Reason}}
     end.
 
-tns_config_size() ->
-    mnesia:table_info(?CONFIG_TAB, size).
-
-delete_limiter_config_txn(Ns, Kind) ->
-    case mnesia:read(?CONFIG_TAB, Ns, write) of
-        [#?CONFIG_TAB{configs = #{?limiter := LimiterConfig0} = Configs0} = Rec0] ->
-            LimiterConfig = maps:remove(Kind, LimiterConfig0),
-            Configs = Configs0#{?limiter := LimiterConfig},
-            Rec = Rec0#?CONFIG_TAB{configs = Configs},
-            ok = mnesia:write(?CONFIG_TAB, Rec, write),
-            ok;
-        _ ->
-            {error, not_found}
-    end.
-
-set_limiter_config_txn(Ns, Kind, Config) ->
+create_managed_ns_txn(Ns) ->
     case mnesia:read(?CONFIG_TAB, Ns, write) of
         [] ->
-            %% New record
             Size = tns_config_size(),
             case Size >= ?MAX_NUM_TNS_CONFIGS of
                 true ->
                     {error, table_is_full};
                 false ->
-                    Rec = #?CONFIG_TAB{key = Ns, configs = #{?limiter => #{Kind => Config}}},
+                    Rec = #?CONFIG_TAB{key = Ns, configs = #{}},
                     ok = mnesia:write(?CONFIG_TAB, Rec, write),
-                    {ok, new}
+                    ok
             end;
-        [#?CONFIG_TAB{configs = #{?limiter := #{Kind := _} = OldLimiter} = OldConfigs} = Rec0] ->
-            Limiter = OldLimiter#{Kind := Config},
-            Configs = OldConfigs#{?limiter := Limiter},
-            Rec = Rec0#?CONFIG_TAB{configs = Configs},
-            ok = mnesia:write(?CONFIG_TAB, Rec, write),
-            {ok, updated};
-        [#?CONFIG_TAB{configs = OldConfigs} = Rec0] ->
-            OldLimiter = maps:get(?limiter, OldConfigs, #{}),
-            Limiter = OldLimiter#{Kind => Config},
-            Configs = OldConfigs#{?limiter => Limiter},
-            Rec = Rec0#?CONFIG_TAB{configs = Configs},
-            ok = mnesia:write(?CONFIG_TAB, Rec, write),
-            {ok, new}
+        [_] ->
+            {error, already_exists}
     end.
+
+delete_managed_ns_txn(Ns) ->
+    case mnesia:read(?CONFIG_TAB, Ns, write) of
+        [] ->
+            {ok, #{}};
+        [#?CONFIG_TAB{configs = Configs}] ->
+            ok = mnesia:delete(?CONFIG_TAB, Ns, write),
+            {ok, Configs}
+    end.
+
+tns_config_size() ->
+    mnesia:table_info(?CONFIG_TAB, size).
+
+update_root_configs_txn(Ns, NewConfigs0) ->
+    maybe
+        [#?CONFIG_TAB{configs = OldConfigs} = Rec0] ?=
+            mnesia:read(?CONFIG_TAB, Ns, write),
+        Diffs = emqx_utils_maps:diff_maps(NewConfigs0, OldConfigs),
+        NewConfigs = emqx_utils_maps:deep_merge(OldConfigs, NewConfigs0),
+        Rec = Rec0#?CONFIG_TAB{configs = NewConfigs},
+        ok = mnesia:write(?CONFIG_TAB, Rec, write),
+        {ok, #{diffs => Diffs, configs => NewConfigs}}
+    else
+        [] ->
+            {error, not_found}
+    end.
+
+bulk_update_root_configs_txn(Entries) ->
+    Size0 = tns_config_size(),
+    {Changes, _} = lists:foldl(
+        fun(#{ns := Ns, config := NewConfigs0}, {Acc, SizeAcc}) ->
+            case mnesia:read(?CONFIG_TAB, Ns, write) of
+                [#?CONFIG_TAB{configs = OldConfigs} = Rec0] ->
+                    Size = SizeAcc;
+                [] ->
+                    Size = SizeAcc + 1,
+                    OldConfigs = #{},
+                    Rec0 = #?CONFIG_TAB{key = Ns, configs = OldConfigs}
+            end,
+            maybe
+                true ?= Size > ?MAX_NUM_TNS_CONFIGS,
+                mnesia:abort(table_is_full)
+            end,
+            NewConfigs = emqx_utils_maps:deep_merge(OldConfigs, NewConfigs0),
+            Rec = Rec0#?CONFIG_TAB{configs = NewConfigs},
+            ok = mnesia:write(?CONFIG_TAB, Rec, write),
+            {Acc#{Ns => #{old => OldConfigs, new => NewConfigs0}}, Size}
+        end,
+        {#{}, Size0},
+        Entries
+    ),
+    {ok, Changes}.
diff --git a/apps/emqx_mt/src/proto/emqx_mt_config_proto_v1.erl b/apps/emqx_mt/src/proto/emqx_mt_config_proto_v1.erl
new file mode 100644
index 00000000000..2814b3ff6eb
--- /dev/null
+++ b/apps/emqx_mt/src/proto/emqx_mt_config_proto_v1.erl
@@ -0,0 +1,26 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2025 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+-module(emqx_mt_config_proto_v1).
+
+-behaviour(emqx_bpapi).
+
+-include_lib("emqx/include/bpapi.hrl").
+
+-export([
+    introduced_in/0,
+
+    execute_side_effects/1,
+    cleanup_managed_ns_configs/2
+]).
+
+introduced_in() ->
+    "5.9.0".
+
+-spec execute_side_effects([emqx_mt_config:side_effect()]) -> {ok, [_Error :: map()]}.
+execute_side_effects(SideEffects) ->
+    emqx_cluster_rpc:multicall(emqx_mt_config, execute_side_effects_v1, [SideEffects]).
+
+-spec cleanup_managed_ns_configs(emqx_mt:tns(), [{atom(), map()}]) -> ok.
+cleanup_managed_ns_configs(Ns, ConfigList) ->
+    emqx_cluster_rpc:multicall(emqx_mt_config, cleanup_managed_ns_configs_v1, [Ns, ConfigList]).
diff --git a/apps/emqx_mt/test/emqx_mt_SUITE.erl b/apps/emqx_mt/test/emqx_mt_SUITE.erl
index 4614561c65a..ccf4ad7045b 100644
--- a/apps/emqx_mt/test/emqx_mt_SUITE.erl
+++ b/apps/emqx_mt/test/emqx_mt_SUITE.erl
@@ -124,7 +124,7 @@ t_session_limit_exceeded(_Config) ->
         )
     ),
     ?assertEqual({ok, 1}, emqx_mt:count_clients(Ns)),
-    %% two reaons may race
+    %% two reasons may race
     try
         {ok, _} = connect(C2, Ns)
     catch
diff --git a/apps/emqx_mt/test/emqx_mt_api_SUITE.erl b/apps/emqx_mt/test/emqx_mt_api_SUITE.erl
index 591bae505ca..a38e61addc2 100644
--- a/apps/emqx_mt/test/emqx_mt_api_SUITE.erl
+++ b/apps/emqx_mt/test/emqx_mt_api_SUITE.erl
@@ -11,6 +11,7 @@
 -include_lib("common_test/include/ct.hrl").
 -include_lib("snabbkaffe/include/snabbkaffe.hrl").
 -include_lib("emqx/include/asserts.hrl").
+-include_lib("emqx/include/emqx_mqtt.hrl").
 
 -define(NEW_CLIENTID(I),
     iolist_to_binary("c-" ++ atom_to_list(?FUNCTION_NAME) ++ "-" ++ integer_to_list(I))
@@ -18,6 +19,12 @@
 
 -define(NEW_USERNAME(), iolist_to_binary("u-" ++ atom_to_list(?FUNCTION_NAME))).
 
+-define(ON(NODE, BODY), erpc:call(NODE, fun() -> BODY end)).
+-define(ON_ALL(NODES, BODY), erpc:multicall(NODES, fun() -> BODY end)).
+
+-define(MAX_NUM_TNS_CONFIGS, 1_000).
+-define(AUTH_HEADER_PD_KEY, {?MODULE, auth_header}).
+
 %%------------------------------------------------------------------------------
 %% CT Boilerplate
 %%------------------------------------------------------------------------------
@@ -31,6 +38,27 @@ init_per_suite(Config) ->
 end_per_suite(_Config) ->
     ok.
 
+init_per_testcase(t_adjust_limiters = TestCase, Config) ->
+    Apps = [
+        emqx,
+        {emqx_conf, "mqtt.client_attrs_init = [{expression = username, set_as_attr = tns}]"},
+        emqx_mt,
+        emqx_management
+    ],
+    Cluster =
+        [N1, _N2] = emqx_cth_cluster:start(
+            [
+                {node_name(TestCase, 1), #{
+                    apps => Apps ++
+                        [emqx_mgmt_api_test_util:emqx_dashboard()]
+                }},
+                {node_name(TestCase, 2), #{apps => Apps, role => replicant}}
+            ],
+            #{work_dir => emqx_cth_suite:work_dir(TestCase, Config)}
+        ),
+    AuthHeader = ?ON(N1, emqx_mgmt_api_test_util:auth_header_()),
+    put(?AUTH_HEADER_PD_KEY, AuthHeader),
+    [{cluster, Cluster} | Config];
 init_per_testcase(TestCase, Config) ->
     Apps = emqx_cth_suite:start(
         [
@@ -45,6 +73,11 @@ init_per_testcase(TestCase, Config) ->
     snabbkaffe:start_trace(),
     [{apps, Apps} | Config].
 
+end_per_testcase(t_adjust_limiters, Config) ->
+    Cluster = ?config(cluster, Config),
+    snabbkaffe:stop(),
+    ok = emqx_cth_cluster:stop(Cluster),
+    ok;
 end_per_testcase(_TestCase, Config) ->
     Apps = ?config(apps, Config),
     snabbkaffe:stop(),
@@ -55,13 +88,34 @@ end_per_testcase(_TestCase, Config) ->
 %% Helper fns
 %%------------------------------------------------------------------------------
 
+-define(assertIntersectionEqual(EXPECTED_MAP, MAP),
+    ?assertEqual(
+        EXPECTED_MAP,
+        deep_intersect(EXPECTED_MAP, MAP),
+        #{expected => EXPECTED_MAP, got => MAP}
+    )
+).
+
+node_name(TestCase, N) ->
+    NameBin = <<(atom_to_binary(TestCase))/binary, "_", (integer_to_binary(N))/binary>>,
+    binary_to_atom(NameBin).
+
+get_mqtt_tcp_port(Node) ->
+    {_, Port} = erpc:call(Node, emqx_config, get, [[listeners, tcp, default, bind]]),
+    Port.
+
 connect(ClientId, Username) ->
-    Opts = [
-        {clientid, ClientId},
-        {username, Username},
-        {password, "123456"},
-        {proto_ver, v5}
-    ],
+    connect(#{
+        clientid => ClientId,
+        username => Username,
+        password => "123456"
+    }).
+
+connect(Overrides) ->
+    Opts0 = #{
+        proto_ver => v5
+    },
+    Opts = emqx_utils_maps:deep_merge(Opts0, Overrides),
     {ok, Pid} = emqtt:start_link(Opts),
     monitor(process, Pid),
     unlink(Pid),
@@ -114,61 +168,60 @@ simplify_result(Res) ->
     end.
 
 simple_request(Params) ->
-    emqx_mgmt_api_test_util:simple_request(Params).
+    AuthHeader =
+        case get(?AUTH_HEADER_PD_KEY) of
+            undefined -> emqx_mgmt_api_test_util:auth_header_();
+            Header -> Header
+        end,
+    emqx_mgmt_api_test_util:simple_request(Params#{auth_header => AuthHeader}).
 
 simple_request(Method, Path, Body, QueryParams) ->
-    emqx_mgmt_api_test_util:simple_request(Method, Path, Body, QueryParams).
+    simple_request(#{method => Method, url => Path, body => Body, query_params => QueryParams}).
 
 simple_request(Method, Path, Body) ->
-    emqx_mgmt_api_test_util:simple_request(Method, Path, Body).
+    simple_request(#{method => Method, url => Path, body => Body}).
 
-get_tenant_limiter(Ns) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "tenant"]),
-    Res = simple_request(get, Path, ""),
-    ct:pal("get tenant limiter result:\n  ~p", [Res]),
-    Res.
-
-create_tenant_limiter(Ns, Params) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "tenant"]),
-    Res = simple_request(post, Path, Params),
-    ct:pal("create tenant limiter result:\n  ~p", [Res]),
-    Res.
+list_managed_nss(QueryParams) ->
+    URL = emqx_mgmt_api_test_util:api_path(["mt", "managed_ns_list"]),
+    simple_request(#{method => get, url => URL, query_params => QueryParams}).
 
-update_tenant_limiter(Ns, Params) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "tenant"]),
-    Res = simple_request(put, Path, Params),
-    ct:pal("update tenant limiter result:\n  ~p", [Res]),
+create_managed_ns(Ns) ->
+    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns]),
+    Res = simple_request(post, Path, ""),
+    ct:pal("create managed ns result:\n  ~p", [Res]),
     Res.
 
-delete_tenant_limiter(Ns) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "tenant"]),
+delete_managed_ns(Ns) ->
+    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns]),
     Res = simple_request(delete, Path, ""),
-    ct:pal("delete tenant limiter result:\n  ~p", [Res]),
+    ct:pal("delete managed ns result:\n  ~p", [Res]),
     Res.
 
-get_client_limiter(Ns) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "client"]),
+get_managed_ns_config(Ns) ->
+    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "config"]),
     Res = simple_request(get, Path, ""),
-    ct:pal("get client limiter result:\n  ~p", [Res]),
+    ct:pal("get managed ns config result:\n  ~p", [Res]),
     Res.
 
-create_client_limiter(Ns, Params) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "client"]),
-    Res = simple_request(post, Path, Params),
-    ct:pal("create client limiter result:\n  ~p", [Res]),
+update_managed_ns_config(Ns, Body) ->
+    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "config"]),
+    Res = simple_request(put, Path, Body),
+    ct:pal("update managed ns config result:\n  ~p", [Res]),
     Res.
 
-update_client_limiter(Ns, Params) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "client"]),
-    Res = simple_request(put, Path, Params),
-    ct:pal("update client limiter result:\n  ~p", [Res]),
+bulk_import_configs(Body) ->
+    Path = emqx_mgmt_api_test_util:api_path(["mt", "bulk_import_configs"]),
+    Res = simple_request(post, Path, Body),
+    ct:pal("bulk import config result:\n  ~p", [Res]),
     Res.
 
-delete_client_limiter(Ns) ->
-    Path = emqx_mgmt_api_test_util:api_path(["mt", "ns", Ns, "limiter", "client"]),
-    Res = simple_request(delete, Path, ""),
-    ct:pal("delete client limiter result:\n  ~p", [Res]),
-    Res.
+disable_tenant_limiter(Ns) ->
+    Body = #{<<"limiter">> => #{<<"tenant">> => <<"disabled">>}},
+    update_managed_ns_config(Ns, Body).
+
+disable_client_limiter(Ns) ->
+    Body = #{<<"limiter">> => #{<<"client">> => <<"disabled">>}},
+    update_managed_ns_config(Ns, Body).
 
 tenant_limiter_params() ->
     tenant_limiter_params(_Overrides = #{}).
@@ -184,13 +237,41 @@ tenant_limiter_params(Overrides) ->
             <<"burst">> => <<"40/1m">>
         }
     },
-    emqx_utils_maps:deep_merge(Defaults, Overrides).
+    Merged = emqx_utils_maps:deep_merge(Defaults, Overrides),
+    #{<<"limiter">> => #{<<"tenant">> => Merged}}.
 
 client_limiter_params() ->
     client_limiter_params(_Overrides = #{}).
 
 client_limiter_params(Overrides) ->
-    tenant_limiter_params(Overrides).
+    Defaults = #{
+        <<"bytes">> => #{
+            <<"rate">> => <<"10MB/10s">>,
+            <<"burst">> => <<"200MB/1m">>
+        },
+        <<"messages">> => #{
+            <<"rate">> => <<"3000/1s">>,
+            <<"burst">> => <<"40/1m">>
+        }
+    },
+    Merged = emqx_utils_maps:deep_merge(Defaults, Overrides),
+    #{<<"limiter">> => #{<<"client">> => Merged}}.
+
+session_params() ->
+    session_params(_Overrides = #{}).
+
+session_params(Overrides) ->
+    Defaults = #{
+        <<"max_sessions">> => <<"infinity">>
+    },
+    Merged = emqx_utils_maps:deep_merge(Defaults, Overrides),
+    #{<<"session">> => Merged}.
+
+bulk_import_configs_params(Entries) when is_list(Entries) ->
+    lists:map(
+        fun({Ns, Cfg}) -> #{<<"ns">> => Ns, <<"config">> => Cfg} end,
+        Entries
+    ).
 
 set_limiter_for_zone(Key, Value) ->
     KeyBin = atom_to_binary(Key, utf8),
@@ -209,10 +290,22 @@ set_limiter_for_listener(Key, Value) ->
     ),
     ok.
 
-spawn_publisher(ClientId, Username, PayloadSize, QoS) ->
+spawn_publisher(Opts) ->
+    #{
+        clientid := ClientId,
+        username := Username,
+        qos := QoS,
+        payload_size := PayloadSize
+    } = Opts,
+    Port = maps:get(port, Opts, 1883),
     TestPid = self(),
     LoopPid = spawn_link(fun() ->
-        C = connect(ClientId, Username),
+        C = connect(#{
+            clientid => ClientId,
+            username => Username,
+            password => "123456",
+            port => Port
+        }),
         TestPid ! {client, C},
         receive
             go -> run_publisher(C, PayloadSize, QoS)
@@ -236,14 +329,10 @@ run_publisher(C, PayloadSize, QoS) ->
 
 assert_limited(Opts) ->
     #{
-        clientid := ClientId,
-        username := Username,
-        qos := QoS,
-        payload_size := PayloadSize,
         event_matcher := EventMatcher,
         timeout := Timeout
     } = Opts,
-    {LoopPid, _C} = spawn_publisher(ClientId, Username, PayloadSize, QoS),
+    {LoopPid, _C} = spawn_publisher(Opts),
     {_, {ok, _}} =
         snabbkaffe:wait_async_action(
             fun() -> LoopPid ! go end,
@@ -259,6 +348,22 @@ assert_limited(Opts) ->
         ct:fail("loop pid didn't die")
     end.
 
+deep_intersect(#{} = RefMap, #{} = Map) ->
+    lists:foldl(
+        fun({K, RefV}, Acc) ->
+            case maps:find(K, Map) of
+                {ok, #{} = V} when is_map(RefV) ->
+                    Acc#{K => deep_intersect(RefV, V)};
+                {ok, V} ->
+                    Acc#{K => V};
+                error ->
+                    Acc
+            end
+        end,
+        #{},
+        maps:to_list(RefMap)
+    ).
+
 %%------------------------------------------------------------------------------
 %% Test cases
 %%------------------------------------------------------------------------------
@@ -305,29 +410,242 @@ t_list_apis(_Config) ->
     ),
     ok.
 
+%% Smoke CRUD operations test for managed namespaces.
+%% Configuration management is tested in separate, specific test cases.
+t_managed_namespaces_crud(_Config) ->
+    ?assertMatch({200, []}, list_managed_nss(#{})),
+
+    Ns1 = <<"tns1">>,
+    Ns2 = <<"tns2">>,
+    ?assertMatch({204, _}, delete_managed_ns(Ns1)),
+    ?assertMatch({204, _}, delete_managed_ns(Ns2)),
+    ?assertMatch({404, _}, get_managed_ns_config(Ns1)),
+    ?assertMatch({404, _}, get_managed_ns_config(Ns2)),
+    ?assertMatch({200, []}, list_managed_nss(#{})),
+
+    ?assertMatch({204, _}, create_managed_ns(Ns1)),
+    ?assertMatch({204, _}, delete_managed_ns(Ns2)),
+    ?assertMatch({200, _}, get_managed_ns_config(Ns1)),
+    ?assertMatch({404, _}, get_managed_ns_config(Ns2)),
+    ?assertMatch({200, [Ns1]}, list_managed_nss(#{})),
+
+    ?assertMatch({204, _}, create_managed_ns(Ns2)),
+    ?assertMatch({200, [Ns1, Ns2]}, list_managed_nss(#{})),
+    ?assertMatch({200, _}, get_managed_ns_config(Ns1)),
+    ?assertMatch({200, _}, get_managed_ns_config(Ns2)),
+
+    ?assertMatch({200, [Ns1]}, list_managed_nss(#{<<"limit">> => <<"1">>})),
+    ?assertMatch({200, [Ns2]}, list_managed_nss(#{<<"last_ns">> => Ns1})),
+    ?assertMatch({200, []}, list_managed_nss(#{<<"last_ns">> => Ns2})),
+
+    ?assertMatch({204, _}, delete_managed_ns(Ns1)),
+    %% Idempotency
+    ?assertMatch({204, _}, delete_managed_ns(Ns1)),
+    ?assertMatch({200, [Ns2]}, list_managed_nss(#{})),
+    ?assertMatch({404, _}, get_managed_ns_config(Ns1)),
+    ?assertMatch({200, _}, get_managed_ns_config(Ns2)),
+    ?assertMatch({204, _}, delete_managed_ns(Ns2)),
+    ?assertMatch({200, []}, list_managed_nss(#{})),
+    ?assertMatch({404, _}, get_managed_ns_config(Ns1)),
+    ?assertMatch({404, _}, get_managed_ns_config(Ns2)),
+
+    ok.
+
+%% Checks that managedly declared namespaces use their own maximum session count instead
+%% of global defaults.
+t_session_limit_exceeded(_Config) ->
+    emqx_mt_config:tmp_set_default_max_sessions(1),
+    Ns = ?NEW_USERNAME(),
+    ClientId1 = ?NEW_CLIENTID(1),
+    ClientId2 = ?NEW_CLIENTID(2),
+    ClientId3 = ?NEW_CLIENTID(3),
+
+    Params = session_params(#{<<"max_sessions">> => 2}),
+    {204, _} = create_managed_ns(Ns),
+    ?assertMatch({200, _}, update_managed_ns_config(Ns, Params)),
+
+    %% First client is always fine.
+    {Pid1, {ok, _}} =
+        ?wait_async_action(
+            connect(ClientId1, Ns),
+            #{?snk_kind := multi_tenant_client_added}
+        ),
+    ?assertEqual(1, emqx_mt_state:update_ccache(Ns)),
+    %% Second would fail with quota exceeded reason, if it were to use global default
+    {Pid2, {ok, _}} =
+        ?wait_async_action(
+            connect(ClientId2, Ns),
+            #{?snk_kind := multi_tenant_client_added}
+        ),
+    ?assertEqual(2, emqx_mt_state:update_ccache(Ns)),
+    %% Now we hit the maximum session count
+    try
+        _Pid3 = connect(ClientId3, Ns),
+        ct:fail("should have not connected successfully!")
+    catch
+        error:{error, {quota_exceeded, _}} ->
+            ok;
+        exit:{shutdown, quota_exceeded} ->
+            ok
+    end,
+    emqtt:stop(Pid1),
+    emqtt:stop(Pid2),
+    ok.
+
+%% When `multi_tenancy.allow_only_managed_namespaces = true', we don't allow clients from
+%% non-managed namespaces to connect.
+t_allow_only_managed_namespaces(_Config) ->
+    ok = emqx_mt_config:set_allow_only_managed_namespaces(true),
+    UnknownNs = <<"implicit-ns">>,
+    ClientId = ?NEW_CLIENTID(1),
+    ?assertError({error, {not_authorized, _}}, connect(ClientId, UnknownNs)),
+    %% Clients without extracted namespace are also forbidden from connecting.
+    {ok, Pid} = emqtt:start_link(#{proto_ver => v5}),
+    unlink(Pid),
+    ?assertMatch({error, {not_authorized, _}}, emqtt:connect(Pid)),
+    ok.
+
+%% Verifies bulk import config endpoint
+t_bulk_import_configs(_Config) ->
+    %% Seed config with some preexisting configs and namespaces
+    ExistingNs1 = <<"ens1">>,
+    {204, _} = create_managed_ns(ExistingNs1),
+
+    %% Will receive new config keys and leave existing one untouched
+    ExistingNs2 = <<"ens2">>,
+    {204, _} = create_managed_ns(ExistingNs2),
+    ExistingConfig2 = tenant_limiter_params(),
+    {200, _} = update_managed_ns_config(ExistingNs2, ExistingConfig2),
+
+    %% Will receive both new config keys and update existing one
+    ExistingNs3 = <<"ens3">>,
+    {204, _} = create_managed_ns(ExistingNs3),
+    ExistingConfig3 = tenant_limiter_params(),
+    {200, _} = update_managed_ns_config(ExistingNs3, ExistingConfig3),
+
+    %% Untouched
+    ExistingNs4 = <<"ens4">>,
+    {204, _} = create_managed_ns(ExistingNs4),
+
+    NewConfig1 = tenant_limiter_params(),
+    NewConfig2 = client_limiter_params(),
+    NewConfig3 = emqx_utils_maps:deep_merge(
+        #{<<"limiter">> => #{<<"tenant">> => <<"disabled">>}},
+        client_limiter_params()
+    ),
+    NewNs5 = <<"new-ns5">>,
+    NewConfig5 = client_limiter_params(),
+    ImportParams1 = bulk_import_configs_params([
+        {ExistingNs1, NewConfig1},
+        {ExistingNs2, NewConfig2},
+        {ExistingNs3, NewConfig3},
+        {NewNs5, NewConfig5}
+    ]),
+    ?assertMatch({204, _}, bulk_import_configs(ImportParams1)),
+
+    {200, Config1} = get_managed_ns_config(ExistingNs1),
+    ?assertIntersectionEqual(NewConfig1, Config1),
+    {200, Config2} = get_managed_ns_config(ExistingNs2),
+    ?assertIntersectionEqual(NewConfig2, Config2),
+    {200, Config3} = get_managed_ns_config(ExistingNs3),
+    ?assertIntersectionEqual(NewConfig3, Config3),
+    {200, Config4} = get_managed_ns_config(ExistingNs4),
+    ?assertEqual(#{}, Config4),
+    {200, Config5} = get_managed_ns_config(NewNs5),
+    ?assertIntersectionEqual(NewConfig5, Config5),
+
+    ok.
+
+%% Checks that we validate for duplicate namespaces when bulk importing.
+t_bulk_import_configs_duplicated_nss(_Config) ->
+    Ns1 = <<"ns1">>,
+    Ns2 = <<"ns2">>,
+    ImportParams1 = bulk_import_configs_params([
+        {Ns1, #{}},
+        {Ns1, #{}},
+        {<<"not-duplicated">>, #{}},
+        {Ns2, #{}},
+        {Ns2, #{}}
+    ]),
+    ?assertMatch(
+        {400, #{
+            <<"message">> := <<"Duplicated namespaces in input: ns1, ns2">>
+        }},
+        bulk_import_configs(ImportParams1)
+    ),
+    ok.
+
+%% Checks that we validate the maximum allowed number of configs.
+t_bulk_import_configs_max_configs(_Config) ->
+    TooManyConfigs0 =
+        lists:map(
+            fun(N) -> {<<"ns", (integer_to_binary(N))/binary>>, #{}} end,
+            lists:seq(1, ?MAX_NUM_TNS_CONFIGS + 1)
+        ),
+    TooManyConfigs = bulk_import_configs_params(TooManyConfigs0),
+    ?assertMatch(
+        {400, #{
+            <<"message">> := <<"Maximum number of managed namespaces reached">>
+        }},
+        bulk_import_configs(TooManyConfigs)
+    ),
+    MaxConfigs = lists:sublist(TooManyConfigs, ?MAX_NUM_TNS_CONFIGS),
+    ?assertMatch({204, _}, bulk_import_configs(MaxConfigs)),
+    %% Should be fine to bulk import again if only updating existing configs
+    ?assertMatch({204, _}, bulk_import_configs(MaxConfigs)),
+    ?assertMatch(
+        {400, #{
+            <<"message">> := <<"Maximum number of managed namespaces reached">>
+        }},
+        bulk_import_configs(TooManyConfigs)
+    ),
+    ok.
+
 %% Smoke CRUD operations test for tenant limiter.
 t_tenant_limiter(_Config) ->
     Ns1 = <<"tns">>,
     Params1 = tenant_limiter_params(),
 
-    ?assertMatch({404, _}, get_tenant_limiter(Ns1)),
-    ?assertMatch({404, _}, update_tenant_limiter(Ns1, Params1)),
-    ?assertMatch({204, _}, delete_tenant_limiter(Ns1)),
+    %% Must create the managed namespace first
+    ?assertMatch({404, _}, get_managed_ns_config(Ns1)),
+    ?assertMatch({404, _}, update_managed_ns_config(Ns1, Params1)),
+    ?assertMatch({404, _}, disable_tenant_limiter(Ns1)),
+
+    ?assertMatch({204, _}, create_managed_ns(Ns1)),
 
     ?assertMatch(
-        {201, #{
-            <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+        {200, #{
+            <<"limiter">> := #{
+                <<"tenant">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+                }
+            }
+        }},
+        update_managed_ns_config(Ns1, Params1)
+    ),
+    %% Idempotency
+    ?assertMatch(
+        {200, #{
+            <<"limiter">> := #{
+                <<"tenant">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+                }
+            }
         }},
-        create_tenant_limiter(Ns1, Params1)
+        update_managed_ns_config(Ns1, Params1)
     ),
-    ?assertMatch({400, _}, create_tenant_limiter(Ns1, Params1)),
     ?assertMatch(
         {200, #{
-            <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+            <<"limiter">> := #{
+                <<"tenant">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+                }
+            }
         }},
-        get_tenant_limiter(Ns1)
+        get_managed_ns_config(Ns1)
     ),
     Params2 = tenant_limiter_params(#{
         <<"bytes">> => #{
@@ -340,22 +658,35 @@ t_tenant_limiter(_Config) ->
     }),
     ?assertMatch(
         {200, #{
-            <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+            <<"limiter">> := #{
+                <<"tenant">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+                }
+            }
         }},
-        update_tenant_limiter(Ns1, Params2)
+        update_managed_ns_config(Ns1, Params2)
     ),
     ?assertMatch(
         {200, #{
-            <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+            <<"limiter">> := #{
+                <<"tenant">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+                }
+            }
         }},
-        get_tenant_limiter(Ns1)
+        get_managed_ns_config(Ns1)
     ),
 
-    ?assertMatch({204, _}, delete_tenant_limiter(Ns1)),
-    ?assertMatch({404, _}, get_tenant_limiter(Ns1)),
-    ?assertMatch({404, _}, update_tenant_limiter(Ns1, Params1)),
+    ?assertMatch(
+        {200, #{<<"limiter">> := #{<<"tenant">> := <<"disabled">>}}},
+        disable_tenant_limiter(Ns1)
+    ),
+    ?assertMatch(
+        {200, #{<<"limiter">> := #{<<"tenant">> := <<"disabled">>}}},
+        get_managed_ns_config(Ns1)
+    ),
 
     ok.
 
@@ -364,24 +695,46 @@ t_client_limiter(_Config) ->
     Ns1 = <<"tns">>,
     Params1 = client_limiter_params(),
 
-    ?assertMatch({404, _}, get_client_limiter(Ns1)),
-    ?assertMatch({404, _}, update_client_limiter(Ns1, Params1)),
-    ?assertMatch({204, _}, delete_client_limiter(Ns1)),
+    %% Must create the managed namespace first
+    ?assertMatch({404, _}, get_managed_ns_config(Ns1)),
+    ?assertMatch({404, _}, update_managed_ns_config(Ns1, Params1)),
+    ?assertMatch({404, _}, disable_client_limiter(Ns1)),
+
+    ?assertMatch({204, _}, create_managed_ns(Ns1)),
 
     ?assertMatch(
-        {201, #{
-            <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+        {200, #{
+            <<"limiter">> := #{
+                <<"client">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+                }
+            }
         }},
-        create_client_limiter(Ns1, Params1)
+        update_managed_ns_config(Ns1, Params1)
     ),
-    ?assertMatch({400, _}, create_client_limiter(Ns1, Params1)),
+    %% Idempotency
     ?assertMatch(
         {200, #{
-            <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+            <<"limiter">> := #{
+                <<"client">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+                }
+            }
         }},
-        get_client_limiter(Ns1)
+        update_managed_ns_config(Ns1, Params1)
+    ),
+    ?assertMatch(
+        {200, #{
+            <<"limiter">> := #{
+                <<"client">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"10MB/10s">>, <<"burst">> := <<"200MB/1m">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"40/1m">>}
+                }
+            }
+        }},
+        get_managed_ns_config(Ns1)
     ),
     Params2 = client_limiter_params(#{
         <<"bytes">> => #{
@@ -394,39 +747,58 @@ t_client_limiter(_Config) ->
     }),
     ?assertMatch(
         {200, #{
-            <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+            <<"limiter">> := #{
+                <<"client">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+                }
+            }
         }},
-        update_client_limiter(Ns1, Params2)
+        update_managed_ns_config(Ns1, Params2)
     ),
     ?assertMatch(
         {200, #{
-            <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
-            <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+            <<"limiter">> := #{
+                <<"client">> := #{
+                    <<"bytes">> := #{<<"rate">> := <<"infinity">>, <<"burst">> := <<"0/1d">>},
+                    <<"messages">> := #{<<"rate">> := <<"3000/1s">>, <<"burst">> := <<"60/1m">>}
+                }
+            }
         }},
-        get_client_limiter(Ns1)
+        get_managed_ns_config(Ns1)
     ),
 
-    ?assertMatch({204, _}, delete_client_limiter(Ns1)),
-    ?assertMatch({404, _}, get_client_limiter(Ns1)),
-    ?assertMatch({404, _}, update_client_limiter(Ns1, Params1)),
+    ?assertMatch(
+        {200, #{<<"limiter">> := #{<<"client">> := <<"disabled">>}}},
+        disable_client_limiter(Ns1)
+    ),
+    ?assertMatch(
+        {200, #{<<"limiter">> := #{<<"client">> := <<"disabled">>}}},
+        get_managed_ns_config(Ns1)
+    ),
 
     ok.
 
 %% Verifies that the channel limiters are adjusted when client and/or tenant limiters are
 %% configured.
+%% We create the configs via HTTP API on one node, and connect MQTT clients to the other,
+%% so we verify that limiter groups are changed on all nodes.
 t_adjust_limiters(Config) when is_list(Config) ->
+    Nodes = [_N1, N2] = ?config(cluster, Config),
+    Port2 = get_mqtt_tcp_port(N2),
     Ns = atom_to_binary(?FUNCTION_NAME),
     ?check_trace(
         begin
+            ?assertMatch({204, _}, create_managed_ns(Ns)),
+
             %% 1) Client limiter completely replaces listener limiter.
-            set_limiter_for_listener(messages_rate, <<"infinity">>),
-            set_limiter_for_listener(bytes_rate, <<"infinity">>),
+            ?ON_ALL(Nodes, set_limiter_for_listener(messages_rate, <<"infinity">>)),
+            ?ON_ALL(Nodes, set_limiter_for_listener(bytes_rate, <<"infinity">>)),
             ClientParams1 = client_limiter_params(#{
                 <<"bytes">> => #{<<"rate">> => <<"1/500ms">>, <<"burst">> => <<"0/1s">>},
                 <<"messages">> => #{<<"rate">> => <<"1/500ms">>, <<"burst">> => <<"0/1s">>}
             }),
-            ?assertMatch({201, _}, create_client_limiter(Ns, ClientParams1)),
+            ?assertMatch({200, _}, update_managed_ns_config(Ns, ClientParams1)),
             Username = Ns,
             ClientId1 = ?NEW_CLIENTID(1),
             assert_limited(#{
@@ -434,55 +806,83 @@ t_adjust_limiters(Config) when is_list(Config) ->
                 username => Username,
                 qos => 1,
                 payload_size => 100,
+                port => Port2,
                 event_matcher => ?match_event(#{
                     ?snk_kind := limiter_exclusive_try_consume, success := false
                 }),
                 timeout => 1_000
             }),
-            {204, _} = delete_client_limiter(Ns),
+            {200, _} = disable_client_limiter(Ns),
             %% Tenant limiter composes with zone limiter.
-            set_limiter_for_zone(messages_rate, <<"infinity">>),
-            set_limiter_for_zone(bytes_rate, <<"infinity">>),
+            ?ON_ALL(Nodes, set_limiter_for_zone(messages_rate, <<"infinity">>)),
+            ?ON_ALL(Nodes, set_limiter_for_zone(bytes_rate, <<"infinity">>)),
             TenantParams1 = tenant_limiter_params(#{
                 <<"bytes">> => #{<<"rate">> => <<"1/500ms">>, <<"burst">> => <<"0/1s">>},
                 <<"messages">> => #{<<"rate">> => <<"1/500ms">>, <<"burst">> => <<"0/1s">>}
             }),
-            ?assertMatch({201, _}, create_tenant_limiter(Ns, TenantParams1)),
+            ?assertMatch({200, _}, update_managed_ns_config(Ns, TenantParams1)),
             ClientId2 = ?NEW_CLIENTID(2),
             assert_limited(#{
                 clientid => ClientId2,
                 username => Username,
                 qos => 1,
                 payload_size => 100,
+                port => Port2,
                 event_matcher => ?match_event(#{
                     ?snk_kind := limiter_shared_try_consume, success := false
                 }),
                 timeout => 1_000
             }),
             %% Other way around
-            set_limiter_for_zone(messages_rate, <<"1/500ms">>),
-            set_limiter_for_zone(bytes_rate, <<"1/500ms">>),
+            ?ON_ALL(Nodes, set_limiter_for_zone(messages_rate, <<"1/500ms">>)),
+            ?ON_ALL(Nodes, set_limiter_for_zone(bytes_rate, <<"1/500ms">>)),
             TenantParams2 = tenant_limiter_params(#{
                 <<"bytes">> => #{<<"rate">> => <<"infinity">>, <<"burst">> => <<"0/1s">>},
                 <<"messages">> => #{<<"rate">> => <<"infinity">>, <<"burst">> => <<"0/1s">>}
             }),
-            ?assertMatch({200, _}, update_tenant_limiter(Ns, TenantParams2)),
+            ?assertMatch({200, _}, update_managed_ns_config(Ns, TenantParams2)),
             ClientId3 = ?NEW_CLIENTID(3),
             assert_limited(#{
                 clientid => ClientId3,
                 username => Username,
                 qos => 1,
                 payload_size => 100,
+                port => Port2,
                 event_matcher => ?match_event(#{
                     ?snk_kind := limiter_shared_try_consume, success := false
                 }),
                 timeout => 1_000
             }),
-            {204, _} = delete_tenant_limiter(Ns),
+            {200, _} = disable_tenant_limiter(Ns),
+
+            %% Check that, if we delete an managed namespace with live clients, they
+            %% still can publish without crashing.
+            ?ON_ALL(Nodes, set_limiter_for_listener(messages_rate, <<"infinity">>)),
+            ?ON_ALL(Nodes, set_limiter_for_listener(bytes_rate, <<"infinity">>)),
+            ?ON_ALL(Nodes, set_limiter_for_zone(messages_rate, <<"infinity">>)),
+            ?ON_ALL(Nodes, set_limiter_for_zone(bytes_rate, <<"infinity">>)),
+            TenantAndClientParams1 = emqx_utils_maps:deep_merge(ClientParams1, TenantParams1),
+            ?assertMatch({200, _}, update_managed_ns_config(Ns, TenantAndClientParams1)),
+            ClientId4 = ?NEW_CLIENTID(4),
+            C = connect(#{
+                clientid => ClientId4,
+                username => Username,
+                password => "123456",
+                port => Port2
+            }),
+            ?assertMatch({204, _}, delete_managed_ns(Ns)),
+            ?assertMatch({200, []}, list_managed_nss(#{})),
+            Topic = <<"test">>,
+            {ok, _, [?RC_GRANTED_QOS_1]} = emqtt:subscribe(C, Topic, [{qos, 1}]),
+            {ok, #{reason_code := ?RC_SUCCESS}} = emqtt:publish(C, Topic, <<"hi1">>, [{qos, 1}]),
+            {ok, #{reason_code := ?RC_SUCCESS}} = emqtt:publish(C, Topic, <<"hi2">>, [{qos, 1}]),
+            ?assertReceive({publish, #{topic := Topic, payload := <<"hi1">>}}),
+            ?assertReceive({publish, #{topic := Topic, payload := <<"hi2">>}}),
+
             ok
         end,
         fun(Trace) ->
-            ?assertMatch([_, _, _], ?of_kind("channel_limiter_adjusted", Trace)),
+            ?assertMatch([_, _, _, _], ?of_kind("channel_limiter_adjusted", Trace)),
             ok
         end
     ),
diff --git a/changes/ee/feat-14884.en.md b/changes/ee/feat-14884.en.md
new file mode 100644
index 00000000000..3142229b596
--- /dev/null
+++ b/changes/ee/feat-14884.en.md
@@ -0,0 +1 @@
+Added HTTP API to manage multi-tenancy configurations.
diff --git a/rel/i18n/emqx_mt_api.hocon b/rel/i18n/emqx_mt_api.hocon
index f13b981057a..c341882dc96 100644
--- a/rel/i18n/emqx_mt_api.hocon
+++ b/rel/i18n/emqx_mt_api.hocon
@@ -1,8 +1,8 @@
 emqx_mt_api {
     ns_list {
         desc: """~
-            List all namespaces"
-            The namespace is sorted in lexicographical order,
+            List all namespaces
+            The namespaces are sorted in lexicographical order,
             so this API can be used for prefix based fuzzy search.~"""
         label: "List Namespaces"
     }
@@ -30,36 +30,31 @@ emqx_mt_api {
             For the first page, use empty string.~"""
         label: "Last Client ID"
     }
-    get_client_limiter {
-        desc: "Get client limiter configuration"
-        label: "Get client limiter"
-    }
-    create_client_limiter {
-        desc: "Create client limiter configuration"
-        label: "Create client limiter"
-    }
-    update_client_limiter {
-        desc: "Update client limiter configuration"
-        label: "Update client limiter"
+    managed_ns_list {
+        desc: """~
+            List all managed namespaces
+            The namespaces are sorted in lexicographical order,
+            so this API can be used for prefix based fuzzy search.~"""
+        label: "List managed namespaces"
     }
-    delete_client_limiter {
-        desc: "Delete client limiter configuration"
-        label: "Delete client limiter"
+    create_managed_ns {
+        desc: "Create managed namespace"
+        label: "Create managed namespace"
     }
-    get_tenant_limiter {
-        desc: "Get tenant limiter configuration"
-        label: "Get tenant limiter"
+    delete_managed_ns {
+        desc: "Delete managed namespace"
+        label: "Delete managed namespace"
     }
-    create_tenant_limiter {
-        desc: "Create tenant limiter configuration"
-        label: "Create tenant limiter"
+    get_managed_ns_config {
+        desc: "Get managed namespace configuration"
+        label: "Get managed namespace configuration"
     }
-    update_tenant_limiter {
-        desc: "Update tenant limiter configuration"
-        label: "Update tenant limiter"
+    update_managed_ns_config {
+        desc: "Update managed namespace configuration"
+        label: "Update managed namespace configuration"
     }
-    delete_tenant_limiter {
-        desc: "Delete tenant limiter configuration"
-        label: "Delete tenant limiter"
+    bulk_import_configs {
+        desc: "Upsert namespace configurations in bulk"
+        label: "Upsert namespace configurations in bulk"
     }
 }
diff --git a/rel/i18n/emqx_mt_schema.hocon b/rel/i18n/emqx_mt_schema.hocon
index a6f7c3577d9..9b964d793d5 100644
--- a/rel/i18n/emqx_mt_schema.hocon
+++ b/rel/i18n/emqx_mt_schema.hocon
@@ -10,4 +10,11 @@ emqx_mt_schema {
             Online config changes do not affect running sessions.~"""
         label: "Default Max Number of Sessions"
     }
+    allow_only_managed_namespaces {
+        desc: """~
+            If enabled, clients that belong to a non-managed namespace will be
+            denied connection.  Clients that can't have their namespace resolved
+            will also be denied connection.~"""
+        label: "Allow only managed namespaces"
+    }
 }


Reviews:
user: login: zmstone
body: I was thinking, there is perhaps no need for `explicit` APIs, if we add a config like:
```
emqx_mt {
    managed_namespaces = true | false
}
```

And deny access in authentication callback in `emqx_mt_hookcb` module if this config is set to `true` but `tns` does not exist.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T13:08:34Z
user: login: thalesmg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T09:04:02Z
user: login: zmstone
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T13:59:54Z
user: login: zmstone
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T17:10:05Z
user: login: thalesmg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T17:31:07Z

Review comments:
diff_hunk: @@ -104,3 +177,183 @@ delete_client_limiter_config(Ns) ->
 -spec tmp_set_default_max_sessions(non_neg_integer() | infinity) -> ok.
 tmp_set_default_max_sessions(Max) ->
     emqx_config:put([multi_tenancy, default_max_sessions], Max).
+
+cleanup_managed_ns_configs(_Ns, []) ->
+    ok;
+cleanup_managed_ns_configs(Ns, [{?limiter, Configs} | Rest]) ->
+    ok = emqx_mt_limiter:cleanup_configs(Ns, Configs),
+    cleanup_managed_ns_configs(Ns, Rest);
+cleanup_managed_ns_configs(Ns, [{_RootKey, _Configs} | Rest]) ->
+    cleanup_managed_ns_configs(Ns, Rest).
+
+%%------------------------------------------------------------------------------
+%% Internal fns
+%%------------------------------------------------------------------------------
+
+handle_root_configs_update(Ns, NewConfig) ->
+    maybe
+        {ok, #{diffs := Diffs, configs := Configs}} ?=
+            emqx_mt_state:update_root_configs(Ns, NewConfig),
+        SideEffects = compute_config_update_side_effects(Diffs, Ns),
+        Errors = execute_side_effects(SideEffects),
+        {ok, #{configs => Configs, errors => Errors}}
+    end.
+
+execute_side_effects(SideEffects) ->
+    lists:foldl(
+        fun(#{?op := {Fn, Args}, ?path := Path}, Acc) ->
+            try
+                _ = apply(Fn, Args),
+                Acc
+            catch
+                Class:Reason:Stacktrace ->
+                    ?SLOG(warning, #{
+                        msg => "failed_to_execute_mt_side_effect",
+                        ?path => Path,
+                        ?op => Fn,
+                        error => {Class, Reason},
+                        stacktrace => Stacktrace
+                    }),
+                    [
+                        #{
+                            ?path => Path,
+                            ?op => Fn,
+                            error => {Class, Reason}
+                        }
+                        | Acc
+                    ]
+            end
+        end,
+        [],
+        SideEffects
+    ).
+
+compute_config_update_side_effects(Diffs, Ns) ->
+    #{added := Added0, changed := Changed0} = Diffs,
+    Added = maps:map(fun(_RootKey, Cfg) -> {?new, Cfg} end, Added0),
+    Changed = maps:map(fun(_RootKey, {OldCfg, NewCfg}) -> {?changed, OldCfg, NewCfg} end, Changed0),
+    Changes = maps:merge(Added, Changed),
+    maps:fold(
+        fun
+            (?limiter, Op, Acc) ->
+                SideEffects = limiter_update_side_effects(Ns, Op),
+                SideEffects ++ Acc;
+            (_OtherRootKey, _Op, Acc) ->
+                Acc
+        end,
+        [],
+        Changes
+    ).
+
+-spec limiter_update_side_effects(emqx_mt:tns(), {?new, limiter_config()}) -> [side_effect()].
+limiter_update_side_effects(Ns, {?new, Config}) ->
path: apps/emqx_mt/src/emqx_mt_config.erl
user: login: thalesmg
body: Need to call these via `cluster_rpc` to execute on all nodes.
created_at: 2025-03-19T09:04:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,4 +10,11 @@ emqx_mt_schema {
             Online config changes do not affect running sessions.~"""
         label: "Default Max Number of Sessions"
     }
+    allow_only_managed_namespaces {
+        desc: """~
+            If enabled, clients that belong to a non-managed namespace will be
+            denied connection.  Clients that also can't have their namespace resolved
path: rel/i18n/emqx_mt_schema.hocon
user: login: zmstone
body: ```suggestion
            denied connection.  Clients that can't have their namespace resolved
```
created_at: 2025-03-19T13:29:59Z
author_association: MEMBER
diff_hunk: @@ -9,92 +9,230 @@
 %% API
 -export([
     get_max_sessions/1,
+    get_allow_only_managed_namespaces/0,
+    set_allow_only_managed_namespaces/1,
 
-    set_tenant_limiter_config/2,
-    get_tenant_limiter_config/1,
-    delete_tenant_limiter_config/1,
+    create_managed_ns/1,
+    delete_managed_ns/1,
+    is_known_managed_ns/1,
+
+    get_managed_ns_config/1,
+    update_managed_ns_config/2,
+
+    bulk_import_configs/1
+]).
 
-    set_client_limiter_config/2,
-    get_client_limiter_config/1,
-    delete_client_limiter_config/1
+%% BPAPI RPC Targets
+-export([
+    execute_side_effects_v1/1,
+    execute_side_effects_v1/2,
+    cleanup_managed_ns_configs_v1/2,
+    cleanup_managed_ns_configs_v1/3
+]).
+
+%% Internal exports for `mt' application
+-export([
+    get_tenant_limiter_config/1,
+    get_client_limiter_config/1
 ]).
 
 %% Internal APIs for tests
 -export([
     tmp_set_default_max_sessions/1
 ]).
 
+-export_type([
+    root_config/0,
+    side_effect/0
+]).
+
+-include_lib("emqx/include/logger.hrl").
+
 %%------------------------------------------------------------------------------
 %% Type declarations
 %%------------------------------------------------------------------------------
 
+%% Root configuration keys
+-define(limiter, limiter).
+-define(session, session).
+
 -define(tenant, tenant).
 -define(client, client).
 
+-define(disabled, disabled).
+
+-type limiter_config() :: #{
+    ?tenant => disabled | tenant_config(),
+    ?client => disabled | client_config()
+}.
 -type tenant_config() :: emqx_mt_limiter:tenant_config().
 -type client_config() :: emqx_mt_limiter:client_config().
 
+-define(max_sessions, max_sessions).
+-type session_config() :: #{
+    ?max_sessions => infinity | non_neg_integer()
+}.
+
+-type root_config() :: #{
+    ?session => session_config(),
+    ?limiter => limiter_config()
+}.
+
+-define(new, new).
+-define(changed, changed).
+-define(op, op).
+-define(path, path).
+
+-type side_effect() :: #{?op := {function(), [term()]}, ?path := [atom()]}.
+
 %%------------------------------------------------------------------------------
 %% API
 %%------------------------------------------------------------------------------
 
 %% @doc Get the maximum number of sessions allowed for the given namespace.
-%% TODO: support per-ns configs
 -spec get_max_sessions(emqx_mt:tns()) -> non_neg_integer() | infinity.
-get_max_sessions(_Ns) ->
-    emqx_config:get([multi_tenancy, default_max_sessions]).
-
-%% TODO: validate NS exists and is explicitly defined.
--spec set_tenant_limiter_config(emqx_mt:tns(), tenant_config()) ->
-    ok | {error, {aborted, _}} | {error, table_is_full}.
-set_tenant_limiter_config(Ns, Config) ->
-    case emqx_mt_state:set_limiter_config(Ns, ?tenant, Config) of
-        {ok, new} ->
-            emqx_mt_limiter:create_tenant_limiter_group(Ns, Config);
-        {ok, updated} ->
-            emqx_mt_limiter:update_tenant_limiter_group(Ns, Config);
-        {error, Reason} ->
-            {error, Reason}
+get_max_sessions(Ns) ->
+    maybe
+        {ok, #{?session := #{?max_sessions := Max}}} ?= get_managed_ns_config(Ns),
+        Max
+    else
+        _ -> emqx_config:get([multi_tenancy, default_max_sessions])
     end.
 
--spec get_tenant_limiter_config(emqx_mt:tns()) ->
-    {ok, tenant_config()} | {error, not_found}.
-get_tenant_limiter_config(Ns) ->
-    emqx_mt_state:get_limiter_config(Ns, ?tenant).
+-doc """
+When `allow_only_managed_namespaces = true`, we don't allow clients from non-managed
+namespaces to connect
+""".
+-spec get_allow_only_managed_namespaces() -> boolean().
+get_allow_only_managed_namespaces() ->
+    emqx_config:get([multi_tenancy, allow_only_managed_namespaces]).
 
--spec delete_tenant_limiter_config(emqx_mt:tns()) ->
-    ok | {error, not_found} | {error, {aborted, _}}.
-delete_tenant_limiter_config(Ns) ->
+-doc """
+When `allow_only_managed_namespaces = true`, we don't allow clients from non-managed
+namespaces to connect
+""".
+-spec set_allow_only_managed_namespaces(boolean()) -> ok.
+set_allow_only_managed_namespaces(Bool) ->
     maybe
-        ok ?= emqx_mt_state:delete_limiter_config(Ns, ?tenant),
-        emqx_mt_limiter:delete_tenant_limiter_group(Ns)
+        {ok, _} ?=
+            emqx_conf:update(
+                [multi_tenancy, allow_only_managed_namespaces],
+                Bool,
+                #{override_to => cluster}
+            ),
+        ok
     end.
 
-%% TODO: validate NS exists and is explicitly defined.
--spec set_client_limiter_config(emqx_mt:tns(), tenant_config()) ->
+-spec get_managed_ns_config(emqx_mt:tns()) ->
+    {ok, root_config()} | {error, not_found}.
+get_managed_ns_config(Ns) ->
+    emqx_mt_state:get_root_configs(Ns).
+
+-spec update_managed_ns_config(emqx_mt:tns(), root_config()) ->
+    {ok, #{
+        configs := root_config(),
+        errors := [#{?path := [atom()], error := {atom(), term()}}]
+    }}
+    | {error, not_found}.
+update_managed_ns_config(Ns, Configs) ->
+    handle_root_configs_update(Ns, Configs).
+
+-spec bulk_import_configs([#{ns := emqx_mt:tns(), config := root_config()}]) ->
+    {ok, #{
+        errors := [
+            #{
+                ?path := [atom()],
+                error := {atom(), term()}
+            }
+        ]
+    }}
+    | {error, {duplicated_nss, [emqx_mt:tns()]}}
+    | {error, {aborted, term()}}.
+bulk_import_configs(Entries) ->
+    handle_bulk_import_configs(Entries).
+
+-spec create_managed_ns(emqx_mt:tns()) ->
     ok | {error, {aborted, _}} | {error, table_is_full}.
-set_client_limiter_config(Ns, Config) ->
-    case emqx_mt_state:set_limiter_config(Ns, ?client, Config) of
-        {ok, new} ->
-            emqx_mt_limiter:create_client_limiter_group(Ns, Config);
-        {ok, updated} ->
-            emqx_mt_limiter:update_client_limiter_group(Ns, Config);
-        {error, Reason} ->
-            {error, Reason}
+create_managed_ns(Ns) ->
+    emqx_mt_state:create_managed_ns(Ns).
+
+-spec delete_managed_ns(emqx_mt:tns()) ->
+    ok | {error, {aborted, _}}.
+delete_managed_ns(Ns) ->
+    maybe
+        {ok, Configs} ?= emqx_mt_state:delete_managed_ns(Ns),
+        emqx_mt_config_proto_v1:cleanup_managed_ns_configs(Ns, maps:to_list(Configs))
     end.
 
+-spec is_known_managed_ns(emqx_mt:tns()) -> boolean().
+is_known_managed_ns(Ns) ->
+    emqx_mt_state:is_known_managed_ns(Ns).
+
+-spec get_tenant_limiter_config(emqx_mt:tns()) ->
+    {ok, tenant_config()} | {error, not_found}.
+get_tenant_limiter_config(Ns) ->
+    emqx_mt_state:get_limiter_config(Ns, ?tenant).
+
 -spec get_client_limiter_config(emqx_mt:tns()) ->
     {ok, client_config()} | {error, not_found}.
 get_client_limiter_config(Ns) ->
     emqx_mt_state:get_limiter_config(Ns, ?client).
 
--spec delete_client_limiter_config(emqx_mt:tns()) ->
-    ok | {error, not_found} | {error, {aborted, _}}.
-delete_client_limiter_config(Ns) ->
-    maybe
-        ok ?= emqx_mt_state:delete_limiter_config(Ns, ?client),
-        emqx_mt_limiter:delete_client_limiter_group(Ns)
-    end.
+%%------------------------------------------------------------------------------
+%% BPAPI RPC targets
+%%------------------------------------------------------------------------------
+
+%% Only to please BPAPI static checks...  Arity 2 version will be called directly by
+%% `emqx_cluster_rpc:multicall'.
+-spec execute_side_effects_v1([side_effect()]) -> {ok, [map()]}.
+execute_side_effects_v1(SideEffects) ->
+    execute_side_effects_v1(SideEffects, _ClusterRPCOpts = #{}).
+
+-spec execute_side_effects_v1([side_effect()], emqx_config:cluster_rpc_opts()) -> {ok, [map()]}.
+execute_side_effects_v1(SideEffects, _ClusterRPCOpts) ->
+    Errors = lists:foldl(
+        fun(#{?op := {Fn, Args}, ?path := Path}, Acc) ->
+            try
+                _ = apply(Fn, Args),
+                Acc
+            catch
+                Class:Reason:Stacktrace ->
+                    ?SLOG(warning, #{
+                        msg => "failed_to_execute_mt_side_effect",
path: apps/emqx_mt/src/emqx_mt_config.erl
user: login: zmstone
body: ```suggestion
                        msg => "failed_to_apply_multi_tenancy_config_change_runtime_effect",
```
created_at: 2025-03-19T13:45:57Z
author_association: MEMBER
diff_hunk: @@ -104,6 +111,20 @@ update_client_limiter_group(Ns, Config) ->
 delete_client_limiter_group(Ns) ->
     emqx_limiter:delete_group(client_group(Ns)).
 
+ensure_client_limiter_group_absent(Ns) ->
+    ensure_group_absent(client_group(Ns)).
+
+%%
+
+cleanup_configs(Ns, _Configs) ->
+    %% Note: we may safely delete the limiter groups here: when clients attempt to consume
+    %% from the now dangling limiters, `emqx_limiter_client' will log the error but don't
+    %% do any limiting when it fails to fetch the missing limiter group configuration.
+    %% The user may choose to later kick all clients from this namespace.
path: apps/emqx_mt/src/emqx_mt_limiter.erl
user: login: zmstone
body: Maybe a separate task: there should be an API to kick (async) all sessions under a namespace.

And deleting managed NS should immediately trigger such kicks.
created_at: 2025-03-19T13:58:22Z
author_association: MEMBER
diff_hunk: @@ -104,6 +111,20 @@ update_client_limiter_group(Ns, Config) ->
 delete_client_limiter_group(Ns) ->
     emqx_limiter:delete_group(client_group(Ns)).
 
+ensure_client_limiter_group_absent(Ns) ->
+    ensure_group_absent(client_group(Ns)).
+
+%%
+
+cleanup_configs(Ns, _Configs) ->
+    %% Note: we may safely delete the limiter groups here: when clients attempt to consume
+    %% from the now dangling limiters, `emqx_limiter_client' will log the error but don't
+    %% do any limiting when it fails to fetch the missing limiter group configuration.
+    %% The user may choose to later kick all clients from this namespace.
path: apps/emqx_mt/src/emqx_mt_limiter.erl
user: login: thalesmg
body: https://emqx.atlassian.net/browse/EMQX-14034
created_at: 2025-03-19T17:31:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: emqxqa
created_at: 2025-03-31T02:40:57Z
body: [TestExecution](https://emqx.atlassian.net/browse/QA-6829)
author_association: NONE
user: login: emqxqa
created_at: 2025-03-31T02:40:58Z
body: [TestExecution](https://emqx.atlassian.net/browse/QA-6829)
author_association: NONE