refactor(auth/jwt): support raw rules from jwt acl claim
This commit is contained in:
parent
da92e62e8c
commit
2be898ca4d
|
@ -51,11 +51,18 @@ parse_rule(
|
|||
Action = validate_rule_action(ActionType, RuleRaw),
|
||||
{ok, {Permission, Action, Topics}}
|
||||
catch
|
||||
throw:ValidationError ->
|
||||
{error, ValidationError}
|
||||
throw:{Invalid, Which} ->
|
||||
{error, #{
|
||||
reason => Invalid,
|
||||
value => Which
|
||||
}}
|
||||
end;
|
||||
parse_rule(RuleRaw) ->
|
||||
{error, {invalid_rule, RuleRaw}}.
|
||||
{error, #{
|
||||
reason => invalid_rule,
|
||||
value => RuleRaw,
|
||||
explain => "missing 'permission' or 'action' field"
|
||||
}}.
|
||||
|
||||
-spec format_rule({
|
||||
emqx_authz_rule:permission(),
|
||||
|
@ -88,7 +95,7 @@ validate_rule_topics(#{<<"topic">> := TopicRaw}) when is_binary(TopicRaw) ->
|
|||
validate_rule_topics(#{<<"topics">> := TopicsRaw}) when is_list(TopicsRaw) ->
|
||||
lists:map(fun validate_rule_topic/1, TopicsRaw);
|
||||
validate_rule_topics(RuleRaw) ->
|
||||
throw({invalid_topics, RuleRaw}).
|
||||
throw({missing_topic_or_topics, RuleRaw}).
|
||||
|
||||
validate_rule_topic(<<"eq ", TopicRaw/binary>>) ->
|
||||
{eq, validate_rule_topic(TopicRaw)};
|
||||
|
@ -98,8 +105,8 @@ validate_rule_permission(<<"allow">>) -> allow;
|
|||
validate_rule_permission(<<"deny">>) -> deny;
|
||||
validate_rule_permission(PermissionRaw) -> throw({invalid_permission, PermissionRaw}).
|
||||
|
||||
validate_rule_action_type(<<"publish">>) -> publish;
|
||||
validate_rule_action_type(<<"subscribe">>) -> subscribe;
|
||||
validate_rule_action_type(P) when P =:= <<"pub">> orelse P =:= <<"publish">> -> publish;
|
||||
validate_rule_action_type(S) when S =:= <<"sub">> orelse S =:= <<"subscribe">> -> subscribe;
|
||||
validate_rule_action_type(<<"all">>) -> all;
|
||||
validate_rule_action_type(ActionRaw) -> throw({invalid_action, ActionRaw}).
|
||||
|
||||
|
@ -152,7 +159,7 @@ validate_rule_qos_atomic(<<"2">>) -> 2;
|
|||
validate_rule_qos_atomic(0) -> 0;
|
||||
validate_rule_qos_atomic(1) -> 1;
|
||||
validate_rule_qos_atomic(2) -> 2;
|
||||
validate_rule_qos_atomic(_) -> throw(invalid_qos).
|
||||
validate_rule_qos_atomic(QoS) -> throw({invalid_qos, QoS}).
|
||||
|
||||
validate_rule_retain(<<"0">>) -> false;
|
||||
validate_rule_retain(<<"1">>) -> true;
|
||||
|
|
|
@ -34,6 +34,10 @@
|
|||
authorize/4
|
||||
]).
|
||||
|
||||
-define(IS_V1(Rules), is_map(Rules)).
|
||||
-define(IS_V2(Rules), is_list(Rules)).
|
||||
|
||||
%% For v1
|
||||
-define(RULE_NAMES, [
|
||||
{[pub, <<"pub">>], publish},
|
||||
{[sub, <<"sub">>], subscribe},
|
||||
|
@ -55,10 +59,46 @@ update(Source) ->
|
|||
|
||||
destroy(_Source) -> ok.
|
||||
|
||||
%% @doc Authorize based on cllientinfo enriched with `acl' data.
|
||||
%% e.g. From JWT.
|
||||
%%
|
||||
%% Supproted rules formats are:
|
||||
%%
|
||||
%% v1: (always deny when no match)
|
||||
%%
|
||||
%% #{
|
||||
%% pub => [TopicFilter],
|
||||
%% sub => [TopicFilter],
|
||||
%% all => [TopicFilter]
|
||||
%% }
|
||||
%%
|
||||
%% v2: (rules are checked in sequence, passthrough when no match)
|
||||
%%
|
||||
%% [{
|
||||
%% Permission :: emqx_authz_rule:permission(),
|
||||
%% Action :: emqx_authz_rule:action_condition(),
|
||||
%% Topics :: emqx_authz_rule:topic_condition()
|
||||
%% }]
|
||||
%%
|
||||
%% which is compiled from raw rules like below by emqx_authz_rule_raw
|
||||
%%
|
||||
%% [
|
||||
%% #{
|
||||
%% permission := allow | deny
|
||||
%% action := pub | sub | all
|
||||
%% topic => TopicFilter,
|
||||
%% topics => [TopicFilter] %% when 'topic' is not provided
|
||||
%% qos => 0 | 1 | 2 | [0, 1, 2]
|
||||
%% retain => true | false | all %% only for pub action
|
||||
%% }
|
||||
%% ]
|
||||
%%
|
||||
authorize(#{acl := Acl} = Client, PubSub, Topic, _Source) ->
|
||||
case check(Acl) of
|
||||
{ok, Rules} when is_map(Rules) ->
|
||||
do_authorize(Client, PubSub, Topic, Rules);
|
||||
{ok, Rules} when ?IS_V2(Rules) ->
|
||||
authorize_v2(Client, PubSub, Topic, Rules);
|
||||
{ok, Rules} when ?IS_V1(Rules) ->
|
||||
authorize_v1(Client, PubSub, Topic, Rules);
|
||||
{error, MatchResult} ->
|
||||
MatchResult
|
||||
end;
|
||||
|
@ -69,7 +109,7 @@ authorize(_Client, _PubSub, _Topic, _Source) ->
|
|||
%% Internal functions
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
check(#{expire := Expire, rules := Rules}) when is_map(Rules) ->
|
||||
check(#{expire := Expire, rules := Rules}) ->
|
||||
Now = erlang:system_time(second),
|
||||
case Expire of
|
||||
N when is_integer(N) andalso N > Now -> {ok, Rules};
|
||||
|
@ -83,13 +123,13 @@ check(#{rules := Rules}) ->
|
|||
check(#{}) ->
|
||||
{error, nomatch}.
|
||||
|
||||
do_authorize(Client, PubSub, Topic, AclRules) ->
|
||||
do_authorize(Client, PubSub, Topic, AclRules, ?RULE_NAMES).
|
||||
authorize_v1(Client, PubSub, Topic, AclRules) ->
|
||||
authorize_v1(Client, PubSub, Topic, AclRules, ?RULE_NAMES).
|
||||
|
||||
do_authorize(_Client, _PubSub, _Topic, _AclRules, []) ->
|
||||
authorize_v1(_Client, _PubSub, _Topic, _AclRules, []) ->
|
||||
{matched, deny};
|
||||
do_authorize(Client, PubSub, Topic, AclRules, [{Keys, Action} | RuleNames]) ->
|
||||
TopicFilters = get_topic_filters(Keys, AclRules, []),
|
||||
authorize_v1(Client, PubSub, Topic, AclRules, [{Keys, Action} | RuleNames]) ->
|
||||
TopicFilters = get_topic_filters_v1(Keys, AclRules, []),
|
||||
case
|
||||
emqx_authz_rule:match(
|
||||
Client,
|
||||
|
@ -99,13 +139,16 @@ do_authorize(Client, PubSub, Topic, AclRules, [{Keys, Action} | RuleNames]) ->
|
|||
)
|
||||
of
|
||||
{matched, Permission} -> {matched, Permission};
|
||||
nomatch -> do_authorize(Client, PubSub, Topic, AclRules, RuleNames)
|
||||
nomatch -> authorize_v1(Client, PubSub, Topic, AclRules, RuleNames)
|
||||
end.
|
||||
|
||||
get_topic_filters([], _Rules, Default) ->
|
||||
get_topic_filters_v1([], _Rules, Default) ->
|
||||
Default;
|
||||
get_topic_filters([Key | Keys], Rules, Default) ->
|
||||
get_topic_filters_v1([Key | Keys], Rules, Default) ->
|
||||
case Rules of
|
||||
#{Key := Value} -> Value;
|
||||
#{} -> get_topic_filters(Keys, Rules, Default)
|
||||
#{} -> get_topic_filters_v1(Keys, Rules, Default)
|
||||
end.
|
||||
|
||||
authorize_v2(Client, PubSub, Topic, Rules) ->
|
||||
emqx_authz_rule:matches(Client, PubSub, Topic, Rules).
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
%% -*- mode: erlang -*-
|
||||
{application, emqx_auth_jwt, [
|
||||
{description, "EMQX JWT Authentication and Authorization"},
|
||||
{vsn, "0.1.1"},
|
||||
{vsn, "0.2.0"},
|
||||
{registered, []},
|
||||
{mod, {emqx_auth_jwt_app, []}},
|
||||
{applications, [
|
||||
|
|
|
@ -219,14 +219,24 @@ verify(undefined, _, _, _) ->
|
|||
verify(JWT, JWKs, VerifyClaims, AclClaimName) ->
|
||||
case do_verify(JWT, JWKs, VerifyClaims) of
|
||||
{ok, Extra} ->
|
||||
{ok, acl(Extra, AclClaimName)};
|
||||
try
|
||||
{ok, acl(Extra, AclClaimName)}
|
||||
catch
|
||||
throw:{bad_acl_rule, Reason} ->
|
||||
%% it's a invalid token, so ok to log
|
||||
?TRACE_AUTHN_PROVIDER("bad_acl_rule", Reason#{jwt => JWT}),
|
||||
{error, bad_username_or_password}
|
||||
end;
|
||||
{error, {missing_claim, Claim}} ->
|
||||
%% it's a invalid token, so it's ok to log
|
||||
?TRACE_AUTHN_PROVIDER("missing_jwt_claim", #{jwt => JWT, claim => Claim}),
|
||||
{error, bad_username_or_password};
|
||||
{error, invalid_signature} ->
|
||||
%% it's a invalid token, so it's ok to log
|
||||
?TRACE_AUTHN_PROVIDER("invalid_jwt_signature", #{jwks => JWKs, jwt => JWT}),
|
||||
ignore;
|
||||
{error, {claims, Claims}} ->
|
||||
%% it's a invalid token, so it's ok to log
|
||||
?TRACE_AUTHN_PROVIDER("invalid_jwt_claims", #{jwt => JWT, claims => Claims}),
|
||||
{error, bad_username_or_password}
|
||||
end.
|
||||
|
@ -237,7 +247,8 @@ acl(Claims, AclClaimName) ->
|
|||
#{AclClaimName := Rules} ->
|
||||
#{
|
||||
acl => #{
|
||||
rules => Rules,
|
||||
rules => parse_rules(Rules),
|
||||
source_for_logging => jwt,
|
||||
expire => maps:get(<<"exp">>, Claims, undefined)
|
||||
}
|
||||
};
|
||||
|
@ -363,3 +374,24 @@ binary_to_number(Bin) ->
|
|||
_ -> false
|
||||
end
|
||||
end.
|
||||
|
||||
%% Pars rules which can be in two different formats:
|
||||
%% 1. #{<<"pub">> => [<<"a/b">>, <<"c/d">>], <<"sub">> => [...], <<"all">> => [...]}
|
||||
%% 2. [#{<<"permission">> => <<"allow">>, <<"action">> => <<"publish">>, <<"topic">> => <<"a/b">>}, ...]
|
||||
parse_rules(Rules) when is_map(Rules) ->
|
||||
Rules;
|
||||
parse_rules(Rules) when is_list(Rules) ->
|
||||
lists:map(fun parse_rule/1, Rules).
|
||||
|
||||
parse_rule(Rule) ->
|
||||
case emqx_authz_rule_raw:parse_rule(Rule) of
|
||||
{ok, {Permission, Action, Topics}} ->
|
||||
try
|
||||
emqx_authz_rule:compile({Permission, all, Action, Topics})
|
||||
catch
|
||||
throw:Reason ->
|
||||
throw({bad_acl_rule, Reason})
|
||||
end;
|
||||
{error, Reason} ->
|
||||
throw({bad_acl_rule, Reason})
|
||||
end.
|
||||
|
|
|
@ -78,7 +78,7 @@ end_per_testcase(_TestCase, _Config) ->
|
|||
%%------------------------------------------------------------------------------
|
||||
|
||||
t_topic_rules(_Config) ->
|
||||
Payload = #{
|
||||
JWT = #{
|
||||
<<"exp">> => erlang:system_time(second) + 60,
|
||||
<<"acl">> => #{
|
||||
<<"pub">> => [
|
||||
|
@ -99,7 +99,47 @@ t_topic_rules(_Config) ->
|
|||
},
|
||||
<<"username">> => <<"username">>
|
||||
},
|
||||
JWT = generate_jws(Payload),
|
||||
test_topic_rules(JWT).
|
||||
|
||||
t_topic_rules_v2(_Config) ->
|
||||
JWT = #{
|
||||
<<"exp">> => erlang:system_time(second) + 60,
|
||||
<<"acl">> => [
|
||||
#{
|
||||
<<"permission">> => <<"allow">>,
|
||||
<<"action">> => <<"pub">>,
|
||||
<<"topics">> => [
|
||||
<<"eq testpub1/${username}">>,
|
||||
<<"testpub2/${clientid}">>,
|
||||
<<"testpub3/#">>
|
||||
]
|
||||
},
|
||||
#{
|
||||
<<"permission">> => <<"allow">>,
|
||||
<<"action">> => <<"sub">>,
|
||||
<<"topics">> =>
|
||||
[
|
||||
<<"eq testsub1/${username}">>,
|
||||
<<"testsub2/${clientid}">>,
|
||||
<<"testsub3/#">>
|
||||
]
|
||||
},
|
||||
#{
|
||||
<<"permission">> => <<"allow">>,
|
||||
<<"action">> => <<"all">>,
|
||||
<<"topics">> => [
|
||||
<<"eq testall1/${username}">>,
|
||||
<<"testall2/${clientid}">>,
|
||||
<<"testall3/#">>
|
||||
]
|
||||
}
|
||||
],
|
||||
<<"username">> => <<"username">>
|
||||
},
|
||||
test_topic_rules(JWT).
|
||||
|
||||
test_topic_rules(JWTInput) ->
|
||||
JWT = generate_jws(JWTInput),
|
||||
|
||||
{ok, C} = emqtt:start_link(
|
||||
[
|
||||
|
@ -350,6 +390,64 @@ t_check_undefined_expire(_Config) ->
|
|||
emqx_authz_client_info:authorize(Client, ?AUTHZ_SUBSCRIBE, <<"a/bar">>, undefined)
|
||||
).
|
||||
|
||||
t_invalid_rule(_Config) ->
|
||||
emqx_logger:set_log_level(debug),
|
||||
MakeJWT = fun(Acl) ->
|
||||
generate_jws(#{
|
||||
<<"exp">> => erlang:system_time(second) + 60,
|
||||
<<"username">> => <<"username">>,
|
||||
<<"acl">> => Acl
|
||||
})
|
||||
end,
|
||||
InvalidAclList =
|
||||
[
|
||||
%% missing action
|
||||
[#{<<"permission">> => <<"invalid">>}],
|
||||
%% missing topic or topics
|
||||
[#{<<"permission">> => <<"allow">>, <<"action">> => <<"pub">>}],
|
||||
%% invlaid permission, must be allow | deny
|
||||
[
|
||||
#{
|
||||
<<"permission">> => <<"invalid">>,
|
||||
<<"action">> => <<"pub">>,
|
||||
<<"topic">> => <<"t">>
|
||||
}
|
||||
],
|
||||
%% invalid action, must be pub | sub | all
|
||||
[
|
||||
#{
|
||||
<<"permission">> => <<"allow">>,
|
||||
<<"action">> => <<"invalid">>,
|
||||
<<"topic">> => <<"t">>
|
||||
}
|
||||
],
|
||||
%% invalid qos
|
||||
[
|
||||
#{
|
||||
<<"permission">> => <<"allow">>,
|
||||
<<"action">> => <<"pub">>,
|
||||
<<"topics">> => [<<"t">>],
|
||||
<<"qos">> => 3
|
||||
}
|
||||
]
|
||||
],
|
||||
lists:foreach(
|
||||
fun(InvalidAcl) ->
|
||||
{ok, C} = emqtt:start_link(
|
||||
[
|
||||
{clean_start, true},
|
||||
{proto_ver, v5},
|
||||
{clientid, <<"clientid">>},
|
||||
{username, <<"username">>},
|
||||
{password, MakeJWT(InvalidAcl)}
|
||||
]
|
||||
),
|
||||
unlink(C),
|
||||
?assertMatch({error, {bad_username_or_password, _}}, emqtt:connect(C))
|
||||
end,
|
||||
InvalidAclList
|
||||
).
|
||||
|
||||
%%------------------------------------------------------------------------------
|
||||
%% Helpers
|
||||
%%------------------------------------------------------------------------------
|
||||
|
|
|
@ -190,7 +190,7 @@ t_normalize_rules(_Config) ->
|
|||
|
||||
?assertException(
|
||||
error,
|
||||
{invalid_rule, _},
|
||||
#{reason := invalid_rule},
|
||||
emqx_authz_mnesia:store_rules(
|
||||
{username, <<"username">>},
|
||||
[[<<"allow">>, <<"publish">>, <<"t">>]]
|
||||
|
@ -199,16 +199,22 @@ t_normalize_rules(_Config) ->
|
|||
|
||||
?assertException(
|
||||
error,
|
||||
{invalid_action, _},
|
||||
#{reason := invalid_action},
|
||||
emqx_authz_mnesia:store_rules(
|
||||
{username, <<"username">>},
|
||||
[#{<<"permission">> => <<"allow">>, <<"action">> => <<"pub">>, <<"topic">> => <<"t">>}]
|
||||
[
|
||||
#{
|
||||
<<"permission">> => <<"allow">>,
|
||||
<<"action">> => <<"badaction">>,
|
||||
<<"topic">> => <<"t">>
|
||||
}
|
||||
]
|
||||
)
|
||||
),
|
||||
|
||||
?assertException(
|
||||
error,
|
||||
{invalid_permission, _},
|
||||
#{reason := invalid_permission},
|
||||
emqx_authz_mnesia:store_rules(
|
||||
{username, <<"username">>},
|
||||
[
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
Enhanced JWT ACL Claim Format.
|
||||
|
||||
The JWT ACL claim has been upgraded to support a more versatile format.
|
||||
It now accepts an array structure, which resembles the file-based ACL rules.
|
||||
|
||||
For example:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"permission": "allow",
|
||||
"action": "pub",
|
||||
"topic": "${username}/#",
|
||||
"qos": [0,1],
|
||||
"retain": true
|
||||
},
|
||||
{
|
||||
"permission": "allow",
|
||||
"action": "sub",
|
||||
"topic": "eq ${username}/#",
|
||||
"qos": [0,1]
|
||||
},
|
||||
{
|
||||
"permission": "deny",
|
||||
"action": "all",
|
||||
"topics": ["#"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In this new format, when no matching rule is found, the action is not automatically denied.
|
||||
This allows the authorization process to proceed to other configured authorization sources.
|
||||
If no match is found throughout the chain, the final decision defers to the default permission set in `authorization.no_match`.
|
|
@ -1,7 +1,31 @@
|
|||
emqx_authn_jwt_schema {
|
||||
|
||||
acl_claim_name.desc:
|
||||
"""JWT claim name to use for getting ACL rules."""
|
||||
"""The JWT claim designated for accessing ACL (Access Control List) rules can be specified,
|
||||
such as using the `acl` claim. A typical decoded JWT with this claim might appear as:
|
||||
`{"username": "user1", "acl": ...}`.
|
||||
|
||||
Supported ACL Rule Formats:
|
||||
|
||||
- Object Format:
|
||||
Utilizes action types pub (publish), sub (subscribe), or all (both publish and subscribe).
|
||||
The value is a list of topic filters.
|
||||
Example: `{"pub": ["topic1"], "sub": [], "all": ["${username}/#"]}`.
|
||||
This example signifies that the token owner can publish to topic1 and perform both publish and subscribe
|
||||
actions on topics starting with their username.
|
||||
Note: In this format, if no topic matches, the action is denied, and the authorization process terminates.
|
||||
|
||||
- Array Format (resembles File-Based ACL Rules):
|
||||
Example: `[{"permission": "allow", "action": "all", "topic": "${username}/#"}]`.
|
||||
Additionally, the `pub` or `publish` action rules can be extended with `qos` and `retain` field,
|
||||
and `sub` or `subscribe` action rules can be extended with a `qos` field.
|
||||
Note: Here, if no rule matches, the action is not immediately denied.
|
||||
The process continues to other configured authorization sources,
|
||||
and ultimately falls back to the default permission in config `authorization.no_match`.
|
||||
|
||||
The ACL claim utilizes MQTT topic wildcard matching rules for publishing or subscribing.
|
||||
A special syntax for the 'subscribe' action allows the use of `eq` for an exact match.
|
||||
For instance, `eq t/#` permits or denies subscription to `t/#`, but not to `t/1`."""
|
||||
|
||||
acl_claim_name.label:
|
||||
"""ACL claim name"""
|
||||
|
|
Loading…
Reference in New Issue