From a7fac1a7a39b53b2babca99fd0edcb502e488cfd Mon Sep 17 00:00:00 2001 From: zhanghongtong Date: Fri, 20 Aug 2021 09:43:44 +0800 Subject: [PATCH] feat(authz): support authorization config file part 1. --- apps/emqx_authz/etc/authorization_rules.conf | 30 ++++ apps/emqx_authz/etc/emqx_authz.conf | 4 + apps/emqx_authz/include/emqx_authz.hrl | 18 ++- apps/emqx_authz/src/emqx_authz.erl | 22 +++ apps/emqx_authz/src/emqx_authz_rule.erl | 148 ++++++++++++++++++ apps/emqx_authz/src/emqx_authz_schema.erl | 14 ++ .../emqx_authz/test/emqx_authz_rule_SUITE.erl | 138 ++++++++++++++++ rebar.config.erl | 1 + 8 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 apps/emqx_authz/etc/authorization_rules.conf create mode 100644 apps/emqx_authz/src/emqx_authz_rule.erl create mode 100644 apps/emqx_authz/test/emqx_authz_rule_SUITE.erl diff --git a/apps/emqx_authz/etc/authorization_rules.conf b/apps/emqx_authz/etc/authorization_rules.conf new file mode 100644 index 000000000..79493b57a --- /dev/null +++ b/apps/emqx_authz/etc/authorization_rules.conf @@ -0,0 +1,30 @@ +%%-------------------------------------------------------------------- +%% -type(ipaddress() :: {ipaddress, string() | [string()]}) +%% +%% -type(username() :: {username, regex()}) +%% +%% -type(clientid() :: {clientid, regex()}) +%% +%% -type(who() :: ipaddress() | username() | clientid() | +%% {'and', [ipaddress() | username() | clientid()]} | +%% {'or', [ipaddress() | username() | clientid()]} | +%% all). +%% +%% -type(action() :: subscribe | publish | all). +%% +%% -type(topic_filters() :: string()). +%% +%% -type(topics() :: [topic_filters() | {eq, topic_filters()}]). +%% +%% -type(permission() :: allow | deny). +%% +%% -type(rule() :: {permission(), who(), access(), topics()}). +%%-------------------------------------------------------------------- + +{allow, {user, "dashboard"}, subscribe, ["$SYS/#"]}. + +{allow, {ipaddr, "127.0.0.1"}, pubsub, ["$SYS/#", "#"]}. + +{deny, all, subscribe, ["$SYS/#", {eq, "#"}]}. + +{allow, all}. diff --git a/apps/emqx_authz/etc/emqx_authz.conf b/apps/emqx_authz/etc/emqx_authz.conf index a100c5140..baabd8a37 100644 --- a/apps/emqx_authz/etc/emqx_authz.conf +++ b/apps/emqx_authz/etc/emqx_authz.conf @@ -1,5 +1,9 @@ authorization_rules { rules = [ + # { + # type: file + # path: {{ platform_etc_dir }}/authorization_rules.conf + # }, # { # type: http # config: { diff --git a/apps/emqx_authz/include/emqx_authz.hrl b/apps/emqx_authz/include/emqx_authz.hrl index 76aa20688..30297ac66 100644 --- a/apps/emqx_authz/include/emqx_authz.hrl +++ b/apps/emqx_authz/include/emqx_authz.hrl @@ -1,4 +1,20 @@ --type(rule() :: #{atom() => any()}). +-type(ipaddress() :: {ipaddr, esockd_cidr:cidr_string()} | + {ipaddrs, list(esockd_cidr:cidr_string())}). + +-type(username() :: {username, binary()}). + +-type(clientid() :: {clientid, binary()}). + +-type(who() :: ipaddress() | username() | clientid() | + {'and', [ipaddress() | username() | clientid()]} | + {'or', [ipaddress() | username() | clientid()]} | + all). + +-type(action() :: subscribe | publish | all). + +-type(permission() :: allow | deny). + +-type(rule() :: {permission(), who(), action(), list(emqx_topic:topic())}). -type(rules() :: [rule()]). -define(APP, emqx_authz). diff --git a/apps/emqx_authz/src/emqx_authz.erl b/apps/emqx_authz/src/emqx_authz.erl index aceb967c2..2c6395199 100644 --- a/apps/emqx_authz/src/emqx_authz.erl +++ b/apps/emqx_authz/src/emqx_authz.erl @@ -253,6 +253,28 @@ init_rule(#{topics := Topics, } = Rule) when ?ALLOW_DENY(Permission), ?PUBSUB(Action), is_list(Topics) -> init_rule(Rule#{annotations =>#{id => gen_id(simple)}}); +init_rule(#{principal := Principal, + enable := true, + type := file, + path := Path + } = Rule) -> + Rules = case file:consult(Path) of + {ok, Terms} -> + [emqx_authz_rule:compile(Term) || Term <- Terms]; + {error, eacces} -> + ?LOG(alert, "Insufficient permissions to read the ~s file", [Path]), + error(eaccess); + {error, enoent} -> + ?LOG(alert, "The ~s file does not exist", [Path]), + error(enoent); + {error, Reason} -> + ?LOG(alert, "Failed to read ~s: ~p", [Path, Reason]), + error(Reason) + end, + Rule#{annotations => + #{id => gen_id(file), + rules => Rules + }}; init_rule(#{principal := Principal, enable := true, type := http, diff --git a/apps/emqx_authz/src/emqx_authz_rule.erl b/apps/emqx_authz/src/emqx_authz_rule.erl new file mode 100644 index 000000000..8fd7b9721 --- /dev/null +++ b/apps/emqx_authz/src/emqx_authz_rule.erl @@ -0,0 +1,148 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020-2021 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_authz_rule). + +-include("emqx_authz.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-ifdef(TEST). +-compile(export_all). +-compile(nowarn_export_all). +-endif. + +%% APIs +-export([ match/4 + , compile/1 + ]). + +-export_type([rule/0]). + +compile({Permission, Who, Action, TopicFilters}) when ?ALLOW_DENY(Permission), ?PUBSUB(Action), is_list(TopicFilters) -> + {Permission, compile_who(Who), Action, [compile_topic(Topic) || Topic <- TopicFilters]}; +compile({Permission, Who, Action, Topic}) when ?ALLOW_DENY(Permission), ?PUBSUB(Action) -> + {Permission, compile_who(Who), Action, [compile_topic(Topic)]}. + +compile_who(all) -> all; +compile_who({username, Username}) -> + {ok, MP} = re:compile(bin(Username)), + {username, MP}; +compile_who({clientid, Clientid}) -> + {ok, MP} = re:compile(bin(Clientid)), + {clientid, MP}; +compile_who({ipaddr, CIDR}) -> + {ipaddr, esockd_cidr:parse(CIDR, true)}; +compile_who({ipaddrs, CIDRs}) -> + {ipaddrs, lists:map(fun(CIDR) -> esockd_cidr:parse(CIDR, true) end, CIDRs)}; +compile_who({'and', L}) when is_list(L) -> + {'and', [compile_who(Who) || Who <- L]}; +compile_who({'or', L}) when is_list(L) -> + {'or', [compile_who(Who) || Who <- L]}. + +compile_topic({eq, Topic}) -> + {eq, emqx_topic:words(bin(Topic))}; +compile_topic(Topic) -> + Words = emqx_topic:words(bin(Topic)), + case pattern(Words) of + true -> {pattern, Words}; + false -> Words + end. + +pattern(Words) -> + lists:member(<<"%u">>, Words) orelse lists:member(<<"%c">>, Words). + +bin(L) when is_list(L) -> + list_to_binary(L); +bin(B) when is_binary(B) -> + B. + +-spec(match(emqx_types:clientinfo(), emqx_types:pubsub(), emqx_types:topic(), rule()) + -> {matched, allow} | {matched, deny} | nomatch). +match(Client, PubSub, Topic, {Permission, Who, Action, TopicFilters}) -> + case match_action(PubSub, Action) andalso + match_who(Client, Who) andalso + match_topics(Client, Topic, TopicFilters) of + true -> {matched, Permission}; + _ -> nomatch + end. + +match_action(publish, publish) -> true; +match_action(subscribe, subscribe) -> true; +match_action(_, all) -> true; +match_action(_, _) -> false. + +match_who(_, all) -> true; +match_who(#{username := undefined}, {username, _MP}) -> + false; +match_who(#{username := Username}, {username, MP}) -> + case re:run(Username, MP) of + {match, _} -> true; + _ -> false + end; +match_who(#{clientid := Clientid}, {clientid, MP}) -> + case re:run(Clientid, MP) of + {match, _} -> true; + _ -> false + end; +match_who(#{peerhost := undefined}, {ipaddr, _CIDR}) -> + false; +match_who(#{peerhost := IpAddress}, {ipaddr, CIDR}) -> + esockd_cidr:match(IpAddress, CIDR); +match_who(#{peerhost := undefined}, {ipaddrs, _CIDR}) -> + false; +match_who(#{peerhost := IpAddress}, {ipaddrs, CIDRs}) -> + lists:any(fun(CIDR) -> + esockd_cidr:match(IpAddress, CIDR) + end, CIDRs); +match_who(ClientInfo, {'and', Principals}) when is_list(Principals) -> + lists:foldl(fun(Principal, Permission) -> + match_who(ClientInfo, Principal) andalso Permission + end, true, Principals); +match_who(ClientInfo, {'or', Principals}) when is_list(Principals) -> + lists:foldl(fun(Principal, Permission) -> + match_who(ClientInfo, Principal) orelse Permission + end, false, Principals); +match_who(_, _) -> false. + +match_topics(_ClientInfo, _Topic, []) -> + false; +match_topics(ClientInfo, Topic, [{pattern, PatternFilter}|Filters]) -> + TopicFilter = feed_var(ClientInfo, PatternFilter), + match_topic(emqx_topic:words(Topic), TopicFilter) + orelse match_topics(ClientInfo, Topic, Filters); +match_topics(ClientInfo, Topic, [TopicFilter|Filters]) -> + match_topic(emqx_topic:words(Topic), TopicFilter) + orelse match_topics(ClientInfo, Topic, Filters). + +match_topic(Topic, {'eq', TopicFilter}) -> + Topic =:= TopicFilter; +match_topic(Topic, TopicFilter) -> + emqx_topic:match(Topic, TopicFilter). + +feed_var(ClientInfo, Pattern) -> + feed_var(ClientInfo, Pattern, []). +feed_var(_ClientInfo, [], Acc) -> + lists:reverse(Acc); +feed_var(ClientInfo = #{clientid := undefined}, [<<"%c">>|Words], Acc) -> + feed_var(ClientInfo, Words, [<<"%c">>|Acc]); +feed_var(ClientInfo = #{clientid := ClientId}, [<<"%c">>|Words], Acc) -> + feed_var(ClientInfo, Words, [ClientId |Acc]); +feed_var(ClientInfo = #{username := undefined}, [<<"%u">>|Words], Acc) -> + feed_var(ClientInfo, Words, [<<"%u">>|Acc]); +feed_var(ClientInfo = #{username := Username}, [<<"%u">>|Words], Acc) -> + feed_var(ClientInfo, Words, [Username|Acc]); +feed_var(ClientInfo, [W|Words], Acc) -> + feed_var(ClientInfo, Words, [W|Acc]). diff --git a/apps/emqx_authz/src/emqx_authz_schema.erl b/apps/emqx_authz/src/emqx_authz_schema.erl index 0c36ccd90..958ad9dec 100644 --- a/apps/emqx_authz/src/emqx_authz_schema.erl +++ b/apps/emqx_authz/src/emqx_authz_schema.erl @@ -22,6 +22,19 @@ structs() -> ["authorization_rules"]. fields("authorization_rules") -> [ {rules, rules()} ]; +fields(file) -> + [ {principal, principal()} + , {type, #{type => http}} + , {enable, #{type => boolean(), + default => true}} + , {path, #{type => string(), + validator => fun(S) -> case filelib:is_file(S) of + true -> ok; + _ -> {error, "File does not exist"} + end + end + }} + ]; fields(http) -> [ {principal, principal()} , {type, #{type => http}} @@ -148,6 +161,7 @@ union_array(Item) when is_list(Item) -> rules() -> #{type => union_array( [ hoconsc:ref(?MODULE, simple_rule) + , hoconsc:ref(?MODULE, file) , hoconsc:ref(?MODULE, http) , hoconsc:ref(?MODULE, mysql) , hoconsc:ref(?MODULE, pgsql) diff --git a/apps/emqx_authz/test/emqx_authz_rule_SUITE.erl b/apps/emqx_authz/test/emqx_authz_rule_SUITE.erl new file mode 100644 index 000000000..e6e450a63 --- /dev/null +++ b/apps/emqx_authz/test/emqx_authz_rule_SUITE.erl @@ -0,0 +1,138 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020-2021 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_authz_rule_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include("emqx_authz.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-define(RULE1, {deny, all, all, ["#"]}). +-define(RULE2, {allow, {ipaddr, "127.0.0.1"}, all, [{eq, "#"}, {eq, "+"}]}). +-define(RULE3, {allow, {ipaddrs, ["127.0.0.1", "192.168.1.0/24"]}, subscribe, ["%c"]}). +-define(RULE4, {allow, {'and', [{clientid, "^test?"}, {username, "^test?"}]}, publish, ["topic/test"]}). +-define(RULE5, {allow, {'or', [{username, "^test"}, {clientid, "test?"}]}, publish, ["%u", "%c"]}). + +all() -> + emqx_ct:all(?MODULE). + +init_per_suite(Config) -> + ok = emqx_ct_helpers:start_apps([emqx_authz]), + Config. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_authz]), + ok. + +t_compile(_) -> + ?assertEqual({deny, all, all, [['#']]}, emqx_authz_rule:compile(?RULE1)), + + ?assertEqual({allow, {ipaddr, {{127,0,0,1}, {127,0,0,1}, 32}}, all, [{eq, ['#']}, {eq, ['+']}]}, emqx_authz_rule:compile(?RULE2)), + + ?assertEqual({allow, + {ipaddrs,[{{127,0,0,1},{127,0,0,1},32}, + {{192,168,1,0},{192,168,1,255},24}]}, + subscribe, + [{pattern,[<<"%c">>]}] + }, emqx_authz_rule:compile(?RULE3)), + + ?assertMatch({allow, + {'and', [{clientid, {re_pattern, _, _, _, _}}, {username, {re_pattern, _, _, _, _}}]}, + publish, + [[<<"topic">>, <<"test">>]] + }, emqx_authz_rule:compile(?RULE4)), + + ?assertMatch({allow, + {'or', [{username, {re_pattern, _, _, _, _}}, {clientid, {re_pattern, _, _, _, _}}]}, + publish, + [{pattern, [<<"%u">>]}, {pattern, [<<"%c">>]}] + }, emqx_authz_rule:compile(?RULE5)), + ok. + + +t_match(_) -> + ClientInfo1 = #{clientid => <<"test">>, + username => <<"test">>, + peerhost => {127,0,0,1}, + zone => default, + listener => mqtt_tcp + }, + ClientInfo2 = #{clientid => <<"test">>, + username => <<"test">>, + peerhost => {192,168,1,10}, + zone => default, + listener => mqtt_tcp + }, + ClientInfo3 = #{clientid => <<"test">>, + username => <<"fake">>, + peerhost => {127,0,0,1}, + zone => default, + listener => mqtt_tcp + }, + ClientInfo4 = #{clientid => <<"fake">>, + username => <<"test">>, + peerhost => {127,0,0,1}, + zone => default, + listener => mqtt_tcp + }, + + ?assertEqual({matched, deny}, + emqx_authz_rule:match(ClientInfo1, subscribe, <<"#">>, emqx_authz_rule:compile(?RULE1))), + ?assertEqual({matched, deny}, + emqx_authz_rule:match(ClientInfo2, subscribe, <<"+">>, emqx_authz_rule:compile(?RULE1))), + ?assertEqual({matched, deny}, + emqx_authz_rule:match(ClientInfo3, subscribe, <<"topic/test">>, emqx_authz_rule:compile(?RULE1))), + + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo1, subscribe, <<"#">>, emqx_authz_rule:compile(?RULE2))), + ?assertEqual(nomatch, + emqx_authz_rule:match(ClientInfo1, subscribe, <<"topic/test">>, emqx_authz_rule:compile(?RULE2))), + ?assertEqual(nomatch, + emqx_authz_rule:match(ClientInfo2, subscribe, <<"#">>, emqx_authz_rule:compile(?RULE2))), + + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo1, subscribe, <<"test">>, emqx_authz_rule:compile(?RULE3))), + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo2, subscribe, <<"test">>, emqx_authz_rule:compile(?RULE3))), + ?assertEqual(nomatch, + emqx_authz_rule:match(ClientInfo2, subscribe, <<"topic/test">>, emqx_authz_rule:compile(?RULE3))), + + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo1, publish, <<"topic/test">>, emqx_authz_rule:compile(?RULE4))), + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo2, publish, <<"topic/test">>, emqx_authz_rule:compile(?RULE4))), + ?assertEqual(nomatch, + emqx_authz_rule:match(ClientInfo3, publish, <<"topic/test">>, emqx_authz_rule:compile(?RULE4))), + ?assertEqual(nomatch, + emqx_authz_rule:match(ClientInfo4, publish, <<"topic/test">>, emqx_authz_rule:compile(?RULE4))), + + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo1, publish, <<"test">>, emqx_authz_rule:compile(?RULE5))), + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo2, publish, <<"test">>, emqx_authz_rule:compile(?RULE5))), + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo3, publish, <<"test">>, emqx_authz_rule:compile(?RULE5))), + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo3, publish, <<"fake">>, emqx_authz_rule:compile(?RULE5))), + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo4, publish, <<"test">>, emqx_authz_rule:compile(?RULE5))), + ?assertEqual({matched, allow}, + emqx_authz_rule:match(ClientInfo4, publish, <<"fake">>, emqx_authz_rule:compile(?RULE5))), + + ok. + diff --git a/rebar.config.erl b/rebar.config.erl index ae2cbfe88..eee5d69b1 100644 --- a/rebar.config.erl +++ b/rebar.config.erl @@ -340,6 +340,7 @@ relx_overlay(ReleaseType) -> , {copy, "bin/emqx_ctl", "bin/emqx_ctl-{{release_version}}"} %% for relup , {copy, "bin/install_upgrade.escript", "bin/install_upgrade.escript-{{release_version}}"} %% for relup , {copy, "apps/emqx_gateway/src/lwm2m/lwm2m_xml", "etc/lwm2m_xml"} + , {copy, "apps/emqx_authz/etc/authorization_rules.conf", "etc/authorization_rules.conf"} , {template, "bin/emqx.cmd", "bin/emqx.cmd"} , {template, "bin/emqx_ctl.cmd", "bin/emqx_ctl.cmd"} , {copy, "bin/nodetool", "bin/nodetool"}