Repository: emqx/emqx
Pull request #14976: 250402 precondition for authn
Author: login: zmstone
Created: 2025-04-02T18:09:01Z | Merged: 2025-04-03T21:17:24Z

Fixes [EMQX-13883](https://emqx.atlassian.net/browse/EMQX-13883)

Release version: 5.9.0

## Summary

This will be useful when need to apply different auth mechanism based on client information.

As an example, if one wants to use HTTP auth for one listener, and MySQL auth for another, 
before 5.9, both authenticator will be called for clients from any of the listeners.
now it's possible to skip-over with a precondition like: `str_eq(listener, 'tcp:default')` -- which means invoke this authenticator only when listener is `tcp:default`.

Current auth chain on the left side, the new chain after this PR on the right side.

![image](https://github.com/user-attachments/assets/a5ab93ac-c62c-4c10-9528-300dfe88e9ea)

## PR Checklist

- [x] For internal contributor: there is a jira ticket to track this change
- [x] The changes are covered with new or existing tests
- [x] Change log for changes visible by users has been added to `changes/ee/(feat|perf|fix|breaking)-<PR-id>.en.md` files
- [x] Schema changes are backward compatible or intentionally breaking (describe the changes and the reasoning in the summary)


[EMQX-13883]: https://emqx.atlassian.net/browse/EMQX-13883?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/apps/emqx_auth/src/emqx_authn/emqx_authn_chains.erl b/apps/emqx_auth/src/emqx_authn/emqx_authn_chains.erl
index 00fa022091c..9560333fb27 100644
--- a/apps/emqx_auth/src/emqx_authn/emqx_authn_chains.erl
+++ b/apps/emqx_auth/src/emqx_authn/emqx_authn_chains.erl
@@ -35,6 +35,7 @@
     id :: binary(),
     provider :: module(),
     enable :: boolean(),
+    precondition :: undefined | emqx_variform:compiled(),
     state :: map()
 }).
 
@@ -747,7 +748,33 @@ maybe_add_stacktrace('throw', Data, _Stacktrace) ->
 maybe_add_stacktrace(_, Data, Stacktrace) ->
     Data#{stacktrace => Stacktrace}.
 
-authenticate_with_provider(#authenticator{id = ID, provider = Provider, state = State}, Credential) ->
+check_precondition(undefined, _) ->
+    true;
+check_precondition(Precondition, Credential0) ->
+    Credential = emqx_auth_template:rename_client_info_vars(Credential0),
+    case emqx_variform:render(Precondition, Credential) of
+        {ok, <<"true">>} ->
+            true;
+        Other ->
+            Other
+    end.
+
+authenticate_with_provider(#authenticator{precondition = Precondition} = A, Credential) ->
+    case check_precondition(Precondition, Credential) of
+        true ->
+            do_authenticate_with_provider(A, Credential);
+        Other ->
+            ?TRACE_AUTHN("precondition_not_met", #{
+                authenticator => A#authenticator.id,
+                expression => emqx_variform:decompile(Precondition),
+                result => Other
+            }),
+            ignore
+    end.
+
+do_authenticate_with_provider(
+    #authenticator{id = ID, provider = Provider, state = State}, Credential
+) ->
     AuthnResult = Provider:authenticate(Credential, State),
     ?TRACE_AUTHN("authenticator_result", #{
         authenticator => ID,
@@ -802,24 +829,55 @@ hook() ->
 unhook() ->
     ok = emqx_hooks:del('client.authenticate', {?MODULE, authenticate, []}).
 
-do_create_authenticator(AuthenticatorID, #{enable := Enable} = Config, Providers) ->
+do_create_authenticator(AuthenticatorID, Config, Providers) ->
     Type = authn_type(Config),
     case maps:get(Type, Providers, undefined) of
         undefined ->
-            {error, {no_available_provider_for, Type}};
+            {error, #{cause => "no_available_provider", type => Type}};
         Provider ->
-            case Provider:create(AuthenticatorID, Config) of
-                {ok, State} ->
-                    Authenticator = #authenticator{
-                        id = AuthenticatorID,
-                        provider = Provider,
-                        enable = Enable,
-                        state = State
-                    },
-                    {ok, Authenticator};
-                {error, Reason} ->
-                    {error, Reason}
-            end
+            do_create_authenticator2(AuthenticatorID, Config, Provider)
+    end.
+
+%% Compile precondition and create authenticator
+do_create_authenticator2(AuthenticatorID, Config, Provider) ->
+    case compile_precondition(Config) of
+        {ok, Precondition} ->
+            do_create_authenticator3(AuthenticatorID, Config, Provider, Precondition);
+        {error, Reason} ->
+            {error, #{cause => "bad_precondition_expression", reason => Reason}}
+    end.
+
+%% Create authenticator with precondition
+do_create_authenticator3(AuthenticatorID, Config, Provider, Precondition) ->
+    #{enable := Enable} = Config,
+    case Provider:create(AuthenticatorID, Config) of
+        {ok, State} ->
+            Authenticator = #authenticator{
+                id = AuthenticatorID,
+                provider = Provider,
+                enable = Enable,
+                state = State,
+                precondition = Precondition
+            },
+            {ok, Authenticator};
+        {error, Reason} ->
+            {error, Reason}
+    end.
+
+compile_precondition(Config) ->
+    Expr = maps:get(precondition, Config, undefined),
+    do_compile_precondition(Expr).
+
+do_compile_precondition(undefined) ->
+    {ok, undefined};
+do_compile_precondition(<<>>) ->
+    {ok, undefined};
+do_compile_precondition(Expr) ->
+    case emqx_variform:compile(Expr) of
+        {ok, Compiled} ->
+            {ok, Compiled};
+        {error, Reason} ->
+            {error, Reason}
     end.
 
 do_delete_authenticators(MatchFun, #chain{name = Name, authenticators = Authenticators} = Chain) ->
diff --git a/apps/emqx_auth/src/emqx_authn/emqx_authn_schema.erl b/apps/emqx_auth/src/emqx_authn/emqx_authn_schema.erl
index 474b0c5cc49..cd650bec6bf 100644
--- a/apps/emqx_auth/src/emqx_authn/emqx_authn_schema.erl
+++ b/apps/emqx_auth/src/emqx_authn/emqx_authn_schema.erl
@@ -209,8 +209,20 @@ backend(Name) ->
         desc => ?DESC("backend")
     }).
 
+precondition() ->
+    ?HOCON(
+        binary(),
+        #{
+            default => <<>>,
+            desc => ?DESC("precondition")
+        }
+    ).
+
 common_fields() ->
-    [{enable, fun enable/1}].
+    [
+        {enable, fun enable/1},
+        {precondition, precondition()}
+    ].
 
 enable(type) -> boolean();
 enable(default) -> true;
diff --git a/apps/emqx_auth/test/emqx_authn/emqx_authn_chains_SUITE.erl b/apps/emqx_auth/test/emqx_authn/emqx_authn_chains_SUITE.erl
index 0c1251701a1..c744655a687 100644
--- a/apps/emqx_auth/test/emqx_authn/emqx_authn_chains_SUITE.erl
+++ b/apps/emqx_auth/test/emqx_authn/emqx_authn_chains_SUITE.erl
@@ -166,7 +166,7 @@ t_authenticator(Config) when is_list(Config) ->
     % Create an authenticator when the provider does not exist
 
     ?assertEqual(
-        {error, {no_available_provider_for, {password_based, built_in_database}}},
+        {error, #{cause => "no_available_provider", type => {password_based, built_in_database}}},
         ?AUTHN:create_authenticator(ChainName, AuthenticatorConfig1)
     ),
 
diff --git a/apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl b/apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl
index 5b19300c2e7..c3ebf6f5980 100644
--- a/apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl
+++ b/apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl
@@ -80,11 +80,13 @@
 -define(EXCEPTION_ALLOW(IsSuperuser), {ok, #{is_superuser := IsSuperuser}}).
 -define(EXCEPTION_DENY, {error, not_authorized}).
 -define(EXCEPTION_IGNORE, ignore).
+-define(CLIENTID(N), iolist_to_binary([atom_to_list(?FUNCTION_NAME), "-", integer_to_binary(N)])).
 
 all() ->
     emqx_common_test_helpers:all(?MODULE).
 
 init_per_suite(Config) ->
+    emqx_utils:interactive_load(emqx_variform_bif),
     Apps = emqx_cth_suite:start([cowboy, emqx, emqx_conf, emqx_auth, emqx_auth_http], #{
         work_dir => ?config(priv_dir, Config)
     }),
@@ -98,15 +100,26 @@ end_per_suite(Config) ->
     ok = emqx_cth_suite:stop(?config(apps, Config)),
     ok.
 
-init_per_testcase(_Case, Config) ->
+init_per_testcase(Case, Config) ->
     emqx_authn_test_lib:delete_authenticators(
         [authentication],
         ?GLOBAL
     ),
     {ok, _} = emqx_authn_http_test_server:start_link(?HTTP_PORT, ?HTTP_PATH),
-    Config.
+    try
+        ?MODULE:Case(init, Config)
+    catch
+        error:undef ->
+            Config
+    end.
 
-end_per_testcase(_Case, _Config) ->
+end_per_testcase(Case, Config) ->
+    try
+        ?MODULE:Case('end', Config)
+    catch
+        error:undef ->
+            ok
+    end,
     _ = emqx_auth_cache:reset(?AUTHN_CACHE),
     ok = emqx_authn_test_lib:enable_node_cache(false),
     ok = emqx_authn_http_test_server:stop().
@@ -135,7 +148,8 @@ t_create_invalid(_Config) ->
             AuthConfig#{<<"url">> => <<"localhost">>},
             AuthConfig#{<<"url">> => <<"http://foo.com/xxx#fragment">>},
             AuthConfig#{<<"url">> => <<"http://${foo}.com/xxx">>},
-            AuthConfig#{<<"url">> => <<"//foo.com/xxx">>}
+            AuthConfig#{<<"url">> => <<"//foo.com/xxx">>},
+            AuthConfig#{<<"precondition">> => <<"not a valid precondition">>}
         ],
 
     lists:foreach(
@@ -715,10 +729,157 @@ test_acl({deny, Topic}, C) ->
         emqtt:subscribe(C, Topic)
     ).
 
+t_precondition_check_listener_id(_Config) ->
+    Config = raw_http_auth_config(),
+    Config1 = Config#{
+        <<"precondition">> => <<"str_eq(listener, 'tcp:default')">>
+    },
+
+    {ok, _} = emqx:update_config(
+        ?PATH,
+        {create_authenticator, ?GLOBAL, Config1}
+    ),
+
+    ok = emqx_authn_http_test_server:set_handler(
+        fun(Req0, State) ->
+            Req = cowboy_req:reply(
+                200,
+                #{<<"content-type">> => <<"application/json">>},
+                ?SERVER_RESPONSE_JSON(allow),
+                Req0
+            ),
+            {ok, Req, State}
+        end
+    ),
+
+    % Test TCP listener - should succeed
+    {ok, C1} = emqtt:start_link([
+        {host, "127.0.0.1"},
+        {port, 1883},
+        {clean_start, true},
+        {clientid, ?CLIENTID(1)},
+        {username, <<"plain">>},
+        {password, <<"plain">>}
+    ]),
+    {ok, _} = emqtt:connect(C1),
+    ok = emqtt:disconnect(C1),
+
+    % Test SSL listener - should fail
+    {ok, C2} = emqtt:start_link([
+        {host, "127.0.0.1"},
+        {port, 8883},
+        {clean_start, true},
+        {clientid, ?CLIENTID(2)},
+        {username, <<"plain">>},
+        {password, <<"plain">>},
+        {ssl, true},
+        {ssl_opts, [{verify, verify_none}]}
+    ]),
+    unlink(C2),
+    _ = monitor(process, C2),
+    ?assertMatch({error, {unauthorized_client, _}}, emqtt:connect(C2)),
+    receive
+        {'DOWN', _Ref, process, C2, Reason} ->
+            ?assertMatch({shutdown, unauthorized_client}, Reason)
+    after 1000 ->
+        error(timeout)
+    end,
+    ok.
+
+t_precondition_check_cert_cn(init, Config) ->
+    %% Change the listener SSL configuration: require peer certificate.
+    {ok, _} = emqx:update_config(
+        [listeners, ssl, default],
+        {update, #{
+            <<"ssl_options">> => #{
+                <<"verify">> => verify_peer,
+                <<"fail_if_no_peer_cert">> => true
+            }
+        }}
+    ),
+    Config;
+t_precondition_check_cert_cn('end', _Config) ->
+    %% Restore the listener SSL configuration to default.
+    {ok, _} = emqx:update_config(
+        [listeners, ssl, default],
+        {update, #{
+            <<"ssl_options">> => #{
+                <<"verify">> => verify_none,
+                <<"fail_if_no_peer_cert">> => false
+            }
+        }}
+    ),
+    ok.
+
+t_precondition_check_cert_cn(_Config) ->
+    Config = raw_http_auth_config(),
+    %% if cert_common_name is empty, the client will be denied
+    Config1 = Config#{
+        <<"precondition">> => <<"not(is_empty_val(cert_common_name))">>
+    },
+
+    {ok, _} = emqx:update_config(
+        ?PATH,
+        {create_authenticator, ?GLOBAL, Config1}
+    ),
+
+    ok = emqx_authn_http_test_server:set_handler(
+        fun(Req0, State) ->
+            Req = cowboy_req:reply(
+                200,
+                #{<<"content-type">> => <<"application/json">>},
+                ?SERVER_RESPONSE_JSON(allow),
+                Req0
+            ),
+            {ok, Req, State}
+        end
+    ),
+
+    % Test TCP listener - no cert_common_name, should be denied
+    {ok, C1} = emqtt:start_link([
+        {host, "127.0.0.1"},
+        {port, 1883},
+        {clean_start, true},
+        {clientid, ?CLIENTID(1)},
+        {username, <<"plain">>},
+        {password, <<"plain">>}
+    ]),
+    unlink(C1),
+    _ = monitor(process, C1),
+    ?assertMatch({error, {unauthorized_client, _}}, emqtt:connect(C1)),
+    receive
+        {'DOWN', _Ref, process, C1, Reason} ->
+            ?assertMatch({shutdown, unauthorized_client}, Reason)
+    after 1000 ->
+        error(timeout)
+    end,
+
+    % Test SSL listener - with cert_common_name, should be allowed
+    {ok, C2} = emqtt:start_link([
+        {host, "127.0.0.1"},
+        {port, 8883},
+        {clean_start, true},
+        {clientid, ?CLIENTID(2)},
+        {username, <<"plain">>},
+        {password, <<"plain">>},
+        {ssl, true},
+        {ssl_opts, [
+            {verify, verify_none},
+            {certfile, cert_path("client-cert.pem")},
+            {keyfile, cert_path("client-key.pem")}
+        ]}
+    ]),
+    {ok, _} = emqtt:connect(C2),
+    ok = emqtt:disconnect(C2).
+
 %%------------------------------------------------------------------------------
 %% Helpers
 %%------------------------------------------------------------------------------
 
+cert_path(FileName) ->
+    Dir = code:lib_dir(emqx),
+    filename:join([Dir, <<"etc/certs">>, FileName]).
+
 raw_http_auth_config() ->
     #{
         <<"mechanism">> => <<"password_based">>,
diff --git a/apps/emqx_conf/test/emqx_conf_cli_SUITE.erl b/apps/emqx_conf/test/emqx_conf_cli_SUITE.erl
index aec1db14ac2..965dbbbd5eb 100644
--- a/apps/emqx_conf/test/emqx_conf_cli_SUITE.erl
+++ b/apps/emqx_conf/test/emqx_conf_cli_SUITE.erl
@@ -105,7 +105,8 @@ t_conflict_mix_conf(Config) ->
                 <<"mechanism">> => <<"password_based">>,
                 %% password_hash_algorithm {name = sha256, salt_position = suffix}
                 <<"redis_type">> => <<"single">>,
-                <<"server">> => <<"127.0.0.1:6379">>
+                <<"server">> => <<"127.0.0.1:6379">>,
+                <<"precondition">> => <<>>
             },
             AuthN = #{<<"authentication">> => [Redis]},
             ConfBin = hocon_pp:do(AuthN, #{}),
@@ -114,8 +115,8 @@ t_conflict_mix_conf(Config) ->
             ok = emqx_conf_cli:conf(["load", "--replace", ConfFile]),
             [RedisRaw] = emqx_conf:get_raw([authentication]),
             ?assertEqual(
-                maps:to_list(Redis),
-                maps:to_list(maps:remove(<<"ssl">>, RedisRaw)),
+                lists:sort(maps:to_list(Redis)),
+                lists:sort(maps:to_list(maps:remove(<<"ssl">>, RedisRaw))),
                 {Redis, RedisRaw}
             ),
             %% change redis type from single to cluster
diff --git a/apps/emqx_utils/src/emqx_variform.erl b/apps/emqx_utils/src/emqx_variform.erl
index 3bcb28b6ffa..0e164ae8f99 100644
--- a/apps/emqx_utils/src/emqx_variform.erl
+++ b/apps/emqx_utils/src/emqx_variform.erl
@@ -35,6 +35,7 @@
 -export_type([compiled/0]).
 
 -type compiled() :: #{expr := string(), form := term()}.
+-type opts() :: #{eval_coalesce => boolean()}.
 -define(BIF_MOD, emqx_variform_bif).
 -define(IS_ALLOWED_MOD(M),
     (M =:= ?BIF_MOD orelse
@@ -64,10 +65,11 @@
 %% For unresolved variables, empty string (but not "undefined") is used.
 %% In case of runtime exeption, an error is returned.
 %% In case of unbound variable is referenced, error is returned.
--spec render(string(), map()) -> {ok, binary()} | {error, term()}.
+-spec render(string() | compiled(), map()) -> {ok, binary()} | {error, term()}.
 render(Expression, Bindings) ->
     render(Expression, Bindings, #{}).
 
+-spec render(string() | compiled(), map(), opts()) -> {ok, binary()} | {error, term()}.
 render(#{form := Form}, Bindings, Opts) ->
     eval_render(Form, Bindings, Opts);
 render(Expression, Bindings, Opts) ->
diff --git a/changes/ee/feat-14976.en.md b/changes/ee/feat-14976.en.md
new file mode 100644
index 00000000000..2acb8a67f8c
--- /dev/null
+++ b/changes/ee/feat-14976.en.md
@@ -0,0 +1,4 @@
+Added support for the precondition configuration for authenticators.
+
+This allows selective invocation of authenticators based on client information, helping avoid unnecessary authentication requests.
+For example, to trigger the HTTP authenticator only for clients connected via `tcp:default`, and Postgre authenticators for those on `ssl:default`, you can use preconditions like str_eq(listener, 'tcp:default') or str_eq(listener, 'ssl:default').
diff --git a/rel/i18n/emqx_authn_schema.hocon b/rel/i18n/emqx_authn_schema.hocon
index 6199c0b6187..ada09562e55 100644
--- a/rel/i18n/emqx_authn_schema.hocon
+++ b/rel/i18n/emqx_authn_schema.hocon
@@ -165,4 +165,31 @@ settings.desc:
 settings.label:
 """Authentication Settings"""
 
+precondition.label: "Precondition"
+precondition.desc: """~
+  A Variform expression to evaluate with a set of pre-bound variables derived from the client information.
+
+  Supported variables:
+  - `username`: The username of the client
+  - `password`: The password of the client
+  - `clientid`: The client ID of the client
+  - `client_attrs.*`: The client attributes of the client
+  - `cert_common_name`: The subject field from the client's TLS certificate
+  - `cert_subject`: The common name (CN) from the client's TLS certificate
+  - `peersni`: The SNI (Server Name Indication) sent by TLS client
+  - `listener`: The listener ID (e.g. `tcp:default`)
+  - `zone`: The associated config zone.
+
+  The expression must evaluate to a string value of 'true' for this authenticator to be invoked.
+  If the expression evaluates to any other value, this authenticator will be skipped.
+
+  Examples:
+  - Only invoke if the client is connected from listener `ssl:letsencryt`:
+    `str_eq(listener, 'ssl:letsencryt')`
+  - Skip if username is empty:
+    `not(is_empty_val(username))`
+  - Only invoke if password exists and zone is 'zone1':
+    `iif(is_empty_val(password), false, str_eq(zone, 'zone1'))`
+
+  Find more information about Variform expressions in EMQX doc.~"""
 }


Reviews:
user: login: savonarola
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:40:03Z
user: login: zmstone
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T20:03:54Z

Review comments:
diff_hunk: @@ -747,7 +748,39 @@ maybe_add_stacktrace('throw', Data, _Stacktrace) ->
 maybe_add_stacktrace(_, Data, Stacktrace) ->
     Data#{stacktrace => Stacktrace}.
 
-authenticate_with_provider(#authenticator{id = ID, provider = Provider, state = State}, Credential) ->
+check_precondition(undefined, _) ->
+    true;
+check_precondition(Precondition, Credential0) ->
+    Credential = add_cert_info(Credential0),
+    case emqx_variform:render(Precondition, Credential) of
+        {ok, <<"true">>} ->
+            true;
+        Other ->
+            Other
+    end.
+
+%% Add cert info to credential if it exists to aid the authentication placeholders.
+add_cert_info(#{cn := CN, dn := DN} = Credential) ->
path: apps/emqx_auth/src/emqx_authn/emqx_authn_chains.erl
user: login: savonarola
body: Nit: 
Now we have some duplication with `emqx_auth_template:rename_client_info_vars/1`
created_at: 2025-04-03T13:29:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -802,24 +835,55 @@ hook() ->
 unhook() ->
     ok = emqx_hooks:del('client.authenticate', {?MODULE, authenticate, []}).
 
-do_create_authenticator(AuthenticatorID, #{enable := Enable} = Config, Providers) ->
+do_create_authenticator(AuthenticatorID, Config, Providers) ->
     Type = authn_type(Config),
     case maps:get(Type, Providers, undefined) of
         undefined ->
-            {error, {no_available_provider_for, Type}};
+            {error, #{cause => "no_available_provider", type => Type}};
         Provider ->
-            case Provider:create(AuthenticatorID, Config) of
-                {ok, State} ->
-                    Authenticator = #authenticator{
-                        id = AuthenticatorID,
-                        provider = Provider,
-                        enable = Enable,
-                        state = State
-                    },
-                    {ok, Authenticator};
-                {error, Reason} ->
-                    {error, Reason}
-            end
+            do_create_authenticator2(AuthenticatorID, Config, Provider)
+    end.
path: apps/emqx_auth/src/emqx_authn/emqx_authn_chains.erl
user: login: savonarola
body: Nit:
maybe a good case for maybe, smth like:
```erlang
maybe
  Type = authn_type(Config),
  {ok, Provider} ?= find_provider(Type, Providers),
  {ok, Precondition} ?= compile_precondition(Config),
  {ok, State} ?= Provider:create(AuthenticatorID, Config),
  #{enable := Enable} = Config,
  {ok, #authenticator{...}}
end
```


created_at: 2025-04-03T13:37:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -802,24 +835,55 @@ hook() ->
 unhook() ->
     ok = emqx_hooks:del('client.authenticate', {?MODULE, authenticate, []}).
 
-do_create_authenticator(AuthenticatorID, #{enable := Enable} = Config, Providers) ->
+do_create_authenticator(AuthenticatorID, Config, Providers) ->
     Type = authn_type(Config),
     case maps:get(Type, Providers, undefined) of
         undefined ->
-            {error, {no_available_provider_for, Type}};
+            {error, #{cause => "no_available_provider", type => Type}};
         Provider ->
-            case Provider:create(AuthenticatorID, Config) of
-                {ok, State} ->
-                    Authenticator = #authenticator{
-                        id = AuthenticatorID,
-                        provider = Provider,
-                        enable = Enable,
-                        state = State
-                    },
-                    {ok, Authenticator};
-                {error, Reason} ->
-                    {error, Reason}
-            end
+            do_create_authenticator2(AuthenticatorID, Config, Provider)
+    end.
path: apps/emqx_auth/src/emqx_authn/emqx_authn_chains.erl
user: login: zmstone
body: A second thought, maybe won't fix this nit.
Current style incites better code coverage.
created_at: 2025-04-03T20:03:54Z
author_association: MEMBER

PR comments:
user: login: savonarola
created_at: 2025-04-03T13:25:16Z
body: Do we still need now individual authn chains for listeners?
author_association: CONTRIBUTOR
user: login: zmstone
created_at: 2025-04-03T13:26:11Z
body: > Do we still need now individual authn chains for listeners?

Probably not, but we'll keep it there for now.
author_association: MEMBER
user: login: emqxqa
created_at: 2025-04-03T21:36:38Z
body: [TestExecution](https://emqx.atlassian.net/browse/QA-6938)
author_association: NONE