diff --git a/big_tests/tests/domain_helper.erl b/big_tests/tests/domain_helper.erl index a18e76d1fc9..ee60dc03304 100644 --- a/big_tests/tests/domain_helper.erl +++ b/big_tests/tests/domain_helper.erl @@ -4,6 +4,8 @@ delete_configured_domains/0, insert_domain/3, delete_domain/2, + set_domain_password/3, + delete_domain_password/2, make_metrics_prefix/1, host_types/0, host_types/1, @@ -66,6 +68,12 @@ insert_persistent_domain(Node, Domain, HostType) -> delete_persistent_domain(Node, Domain, HostType) -> ok = rpc(Node, mongoose_domain_api, delete_domain, [Domain, HostType]). +set_domain_password(Node, Domain, Password) -> + ok = rpc(Node, mongoose_domain_api, set_domain_password, [Domain, Password]). + +delete_domain_password(Node, Domain) -> + ok = rpc(Node, mongoose_domain_api, delete_domain_password, [Domain]). + for_each_configured_domain(F) -> [for_each_configured_domain(F, Opts) || {_, Opts} <- ct:get_config(hosts)], ok. diff --git a/big_tests/tests/graphql_SUITE.erl b/big_tests/tests/graphql_SUITE.erl index 2424b6f1172..ca682d4fc79 100644 --- a/big_tests/tests/graphql_SUITE.erl +++ b/big_tests/tests/graphql_SUITE.erl @@ -7,9 +7,12 @@ -compile([export_all, nowarn_export_all]). -import(distributed_helper, [mim/0, require_rpc_nodes/1, rpc/4]). --import(graphql_helper, [execute/3]). +-import(graphql_helper, [execute/3, execute_auth/2, execute_domain_auth/2, execute_user/3]). --define(assertAdminAuth(Auth, Data), assert_auth(atom_to_binary(Auth), Data)). +-define(assertAdminAuth(Domain, Type, Auth, Data), + assert_auth(#{<<"domain">> => Domain, + <<"authStatus">> => atom_to_binary(Auth), + <<"authType">> => maybe_atom_to_bin(Type)}, Data)). -define(assertUserAuth(Username, Auth, Data), assert_auth(#{<<"username">> => Username, <<"authStatus">> => atom_to_binary(Auth)}, Data)). @@ -20,15 +23,18 @@ suite() -> all() -> [{group, cowboy_handler}, {group, admin_handler}, + {group, domain_admin_handler}, {group, user_handler}]. groups() -> [{cowboy_handler, [parallel], cowboy_handler()}, {user_handler, [parallel], user_handler()}, + {domain_admin_handler, [parallel], domain_admin_handler()}, {admin_handler, [parallel], admin_handler()}]. cowboy_handler() -> [can_connect_to_admin, + can_connect_to_domain_admin, can_connect_to_user]. user_handler() -> @@ -37,6 +43,9 @@ user_handler() -> admin_handler() -> [admin_checks_auth, auth_admin_checks_auth | common_tests()]. +domain_admin_handler() -> + [domain_admin_checks_auth, + auth_domain_admin_checks_auth | common_tests()]. common_tests() -> [can_load_graphiql]. @@ -52,14 +61,23 @@ end_per_suite(Config) -> init_per_group(admin_handler, Config) -> graphql_helper:init_admin_handler(Config); +init_per_group(domain_admin_handler, Config) -> + case mongoose_helper:is_rdbms_enabled(domain_helper:host_type()) of + true -> + graphql_helper:init_domain_admin_handler(Config); + false -> + {skip, require_rdbms} + end; init_per_group(user_handler, Config) -> Config1 = escalus:create_users(Config, escalus:get_users([alice])), [{schema_endpoint, user} | Config1]; -init_per_group(_, Config) -> +init_per_group(cowboy_handler, Config) -> Config. end_per_group(user_handler, Config) -> escalus:delete_users(Config, escalus:get_users([alice])); +end_per_group(domain_admin_handler, Config) -> + graphql_helper:end_domain_admin_handler(Config); end_per_group(_, _Config) -> ok. @@ -72,6 +90,9 @@ end_per_testcase(CaseName, Config) -> can_connect_to_admin(_Config) -> ?assertMatch({{<<"400">>, <<"Bad Request">>}, _}, execute(admin, #{}, undefined)). +can_connect_to_domain_admin(_Config) -> + ?assertMatch({{<<"400">>, <<"Bad Request">>}, _}, execute(domain_admin, #{}, undefined)). + can_connect_to_user(_Config) -> ?assertMatch({{<<"400">>, <<"Bad Request">>}, _}, execute(user, #{}, undefined)). @@ -83,36 +104,37 @@ can_load_graphiql(Config) -> user_checks_auth(Config) -> Ep = ?config(schema_endpoint, Config), - Body = #{query => "{ checkAuth { username authStatus } }"}, - StatusData = execute(Ep, Body, undefined), + StatusData = execute(Ep, user_check_auth_body(), undefined), ?assertUserAuth(null, 'UNAUTHORIZED', StatusData). auth_user_checks_auth(Config) -> escalus:fresh_story( Config, [{alice, 1}], fun(Alice) -> - Password = user_password(alice), - AliceJID = escalus_client:short_jid(Alice), - Ep = ?config(schema_endpoint, Config), - Body = #{query => "{ checkAuth { username authStatus } }"}, - StatusData = execute(Ep, Body, {AliceJID, Password}), + AliceJID = escalus_utils:jid_to_lower(escalus_client:short_jid(Alice)), + StatusData = execute_user(user_check_auth_body(), Alice, Config), ?assertUserAuth(AliceJID, 'AUTHORIZED', StatusData) end). admin_checks_auth(Config) -> Ep = ?config(schema_endpoint, Config), - Body = #{query => "{ checkAuth }"}, - StatusData = execute(Ep, Body, undefined), - ?assertAdminAuth('UNAUTHORIZED', StatusData). + StatusData = execute(Ep, admin_check_auth_body(), undefined), + ?assertAdminAuth(null, null, 'UNAUTHORIZED', StatusData). auth_admin_checks_auth(Config) -> + StatusData = execute_auth(admin_check_auth_body(), Config), + ?assertAdminAuth(null, 'ADMIN', 'AUTHORIZED', StatusData). + +domain_admin_checks_auth(Config) -> Ep = ?config(schema_endpoint, Config), - Opts = ?config(listener_opts, Config), - User = proplists:get_value(username, Opts), - Password = proplists:get_value(password, Opts), - Body = #{query => "{ checkAuth }"}, - StatusData = execute(Ep, Body, {User, Password}), - ?assertAdminAuth('AUTHORIZED', StatusData). + Res = execute(Ep, admin_check_auth_body(), undefined), + ?assertAdminAuth(null, null, 'UNAUTHORIZED', Res). + +auth_domain_admin_checks_auth(Config) -> + {Username, _} = ?config(domain_admin, Config), + Domain = escalus_utils:get_server(Username), + Res = execute_domain_auth(admin_check_auth_body(), Config), + ?assertAdminAuth(Domain, 'DOMAIN_ADMIN', 'AUTHORIZED', Res). %% Helpers @@ -120,10 +142,6 @@ assert_auth(Auth, {Status, Data}) -> ?assertEqual({<<"200">>, <<"OK">>}, Status), ?assertMatch(#{<<"data">> := #{<<"checkAuth">> := Auth}}, Data). -user_password(User) -> - [{User, Props}] = escalus:get_users([User]), - proplists:get_value(password, Props). - get_graphiql_website(EpName) -> Request = #{port => graphql_helper:get_listener_port(EpName), @@ -133,3 +151,12 @@ get_graphiql_website(EpName) -> return_maps => true, path => "/graphql"}, rest_helper:make_request(Request). + +maybe_atom_to_bin(null) -> null; +maybe_atom_to_bin(X) -> atom_to_binary(X). + +admin_check_auth_body() -> + #{query => "{ checkAuth { domain authType authStatus } }"}. + +user_check_auth_body() -> + #{query => "{ checkAuth { username authStatus } }"}. diff --git a/big_tests/tests/graphql_domain_SUITE.erl b/big_tests/tests/graphql_domain_SUITE.erl index df4abbbdd59..91f2553dd41 100644 --- a/big_tests/tests/graphql_domain_SUITE.erl +++ b/big_tests/tests/graphql_domain_SUITE.erl @@ -35,7 +35,11 @@ domain_handler() -> get_domains_by_host_type, get_domain_details, delete_domain, - get_domains_after_deletion]. + get_domains_after_deletion, + set_domain_password, + set_nonexistent_domain_password, + delete_domain_password + ]. init_per_suite(Config) -> case mongoose_helper:is_rdbms_enabled(?HOST_TYPE) of @@ -206,6 +210,29 @@ get_domains_after_deletion(Config) -> ParsedResult = ok_result(<<"domains">>, <<"domainsByHostType">>, Result), ?assertEqual([], ParsedResult). +set_domain_password(Config) -> + Result = execute_auth(#{query => set_domain_password_call(), + variables => #{domain => domain_helper:domain(), + password => <<"secret">>}, + operationName => <<"M1">>}, Config), + ParsedResult = ok_result(<<"domains">>, <<"setDomainPassword">>, Result), + ?assertNotEqual(nomatch, binary:match(ParsedResult, <<"successfully">>)). + +set_nonexistent_domain_password(Config) -> + Domain = <<"unknown-domain.com">>, + Result = execute_auth(#{query => set_domain_password_call(), + variables => #{domain => Domain, + password => <<"secret">>}, + operationName => <<"M1">>}, Config), + domain_not_found_error_formatting(Result, Domain, <<"setDomainPassword">>). + +delete_domain_password(Config) -> + Result = execute_auth(#{query => delete_domain_password_call(), + variables => #{domain => domain_helper:domain()}, + operationName => <<"M1">>}, Config), + ParsedResult = ok_result(<<"domains">>, <<"deleteDomainPassword">>, Result), + ?assertNotEqual(nomatch, binary:match(ParsedResult, <<"successfully">>)). + create_domain_call() -> <<"mutation M1($domain: String!, $hostType: String!) {domains @@ -270,6 +297,14 @@ delete_domain_call() -> } }">>. +set_domain_password_call() -> + <<"mutation M1($domain: String!, $password: String!)" + "{ domains { setDomainPassword(domain: $domain, password: $password)} }">>. + +delete_domain_password_call() -> + <<"mutation M1($domain: String!)" + "{ domains { deleteDomainPassword(domain: $domain)} }">>. + %% Helpers ok_result(What1, What2, {{<<"200">>, <<"OK">>}, #{<<"data">> := Data}}) -> maps:get(What2, maps:get(What1, Data)). diff --git a/big_tests/tests/graphql_helper.erl b/big_tests/tests/graphql_helper.erl index a94ba7abec2..671d7d9bd87 100644 --- a/big_tests/tests/graphql_helper.erl +++ b/big_tests/tests/graphql_helper.erl @@ -2,8 +2,9 @@ -import(distributed_helper, [mim/0, rpc/4]). --export([execute/3, execute_auth/2, execute_user/3, get_listener_port/1, get_listener_config/1]). --export([init_admin_handler/1]). +-export([execute/3, execute_auth/2, execute_domain_auth/2, execute_user/3]). +-export([init_admin_handler/1, init_domain_admin_handler/1, end_domain_admin_handler/1]). +-export([get_listener_port/1, get_listener_config/1]). -export([get_ok_value/2, get_err_msg/1, get_err_msg/2, make_creds/1, user_to_bin/1, user_to_jid/1, user_to_full_bin/1]). @@ -30,6 +31,11 @@ execute_auth(Body, Config) -> Password = proplists:get_value(password, Opts), execute(Ep, Body, {User, Password}). +execute_domain_auth(Body, Config) -> + Ep = ?config(schema_endpoint, Config), + Creds = ?config(domain_admin, Config), + execute(Ep, Body, Creds). + execute_user(Body, User, Config) -> Ep = ?config(schema_endpoint, Config), Creds = make_creds(User), @@ -57,6 +63,18 @@ init_admin_handler(Config) -> ct:fail(<<"Admin credentials are not defined in config">>) end. +init_domain_admin_handler(Config) -> + Domain = domain_helper:domain(), + Password = base16:encode(crypto:strong_rand_bytes(8)), + Creds = {<<"admin@", Domain/binary>>, Password}, + ok = domain_helper:set_domain_password(mim(), Domain, Password), + [{domain_admin, Creds}, {schema_endpoint, domain_admin} | Config]. + +end_domain_admin_handler(Config) -> + {JID, _} = ?config(domain_admin, Config), + Domain = escalus_utils:get_server(JID), + domain_helper:delete_domain_password(mim(), Domain). + get_listener_opts(EpName) -> #{handlers := Handlers} = get_listener_config(EpName), [Opts2] = lists:filtermap( diff --git a/big_tests/tests/mongooseimctl_SUITE.erl b/big_tests/tests/mongooseimctl_SUITE.erl index b37c2570bc8..2eacdd0ca56 100644 --- a/big_tests/tests/mongooseimctl_SUITE.erl +++ b/big_tests/tests/mongooseimctl_SUITE.erl @@ -1120,7 +1120,7 @@ stats_host(Config) -> %%-------------------------------------------------------------------- can_execute_admin_queries_with_permissions(Config) -> - Query = "query { checkAuth }", + Query = "query { checkAuth { authStatus } }", Res = mongooseimctl("graphql", [Query], Config), ?assertMatch({_, 0}, Res), Data = element(1, Res), diff --git a/big_tests/tests/service_domain_db_SUITE.erl b/big_tests/tests/service_domain_db_SUITE.erl index 9ded7d72ce1..5d23dbc1b90 100644 --- a/big_tests/tests/service_domain_db_SUITE.erl +++ b/big_tests/tests/service_domain_db_SUITE.erl @@ -70,6 +70,12 @@ db_cases() -> [ db_cannot_enable_domain_with_unknown_host_type, db_cannot_disable_domain_with_unknown_host_type, db_domains_with_unknown_host_type_are_ignored_by_core, + db_can_insert_update_delete_dynamic_domain_password, + db_can_insert_update_delete_static_domain_password, + db_cannot_set_password_for_unknown_domain, + db_can_check_domain_password, + db_cannot_check_password_for_unknown_domain, + db_deleting_domain_deletes_domain_admin, sql_select_from, sql_find_gaps_between, db_records_are_restored_on_mim_restart, @@ -451,6 +457,46 @@ db_domains_with_unknown_host_type_are_ignored_by_core(_) -> {ok, <<"type1">>} = get_host_type(mim(), <<"example.org">>), %% Counter-case {error, not_found} = get_host_type(mim(), <<"example.com">>). +db_can_insert_update_delete_dynamic_domain_password(_) -> + Domain = <<"password-example.com">>, + ok = insert_domain(mim(), Domain, <<"type1">>), + sync(), + ok = set_domain_password(mim(), Domain, <<"rocky1">>), + ok = check_domain_password(mim(), Domain, <<"rocky1">>), + ok = set_domain_password(mim(), Domain, <<"rocky2">>), + ok = check_domain_password(mim(), Domain, <<"rocky2">>), + ok = delete_domain_password(mim(), Domain), + {error, not_found} = select_domain_admin(mim(), Domain). + +db_can_insert_update_delete_static_domain_password(_) -> + StaticDomain = <<"example.cfg">>, + ok = set_domain_password(mim(), StaticDomain, <<"rocky1">>), + ok = check_domain_password(mim(), StaticDomain, <<"rocky1">>), + ok = set_domain_password(mim(), StaticDomain, <<"rocky2">>), + ok = check_domain_password(mim(), StaticDomain, <<"rocky2">>), + ok = delete_domain_password(mim(), StaticDomain), + {error, not_found} = select_domain_admin(mim(), StaticDomain). + +db_cannot_set_password_for_unknown_domain(_) -> + {error, not_found} = set_domain_password(mim(), <<"unknown_domain">>, <<>>). + +db_can_check_domain_password(_) -> + StaticDomain = <<"example.cfg">>, + ok = set_domain_password(mim(), StaticDomain, <<"myrock">>), + ok = check_domain_password(mim(), StaticDomain, <<"myrock">>), + {error, wrong_password} = check_domain_password(mim(), StaticDomain, <<"wrongrock">>). + +db_cannot_check_password_for_unknown_domain(_) -> + {error, not_found} = check_domain_password(mim(), <<"unknown_domain">>, <<>>). + +db_deleting_domain_deletes_domain_admin(_) -> + Domain = <<"password-del-example.db">>, + ok = insert_domain(mim(), Domain, <<"type1">>), + sync(), + ok = set_domain_password(mim(), Domain, <<"deleteme">>), + ok = delete_domain(mim(), Domain, <<"type1">>), + {error, not_found} = select_domain_admin(mim(), Domain). + sql_select_from(_) -> ok = insert_domain(mim(), <<"example.db">>, <<"type1">>), [{_, <<"example.db">>, <<"type1">>}] = @@ -1065,6 +1111,18 @@ delete_domain(Node, Domain, HostType) -> select_domain(Node, Domain) -> rpc(Node, mongoose_domain_sql, select_domain, [Domain]). +check_domain_password(Node, Domain, Password) -> + rpc(Node, mongoose_domain_api, check_domain_password, [Domain, Password]). + +set_domain_password(Node, Domain, Password) -> + rpc(Node, mongoose_domain_api, set_domain_password, [Domain, Password]). + +delete_domain_password(Node, Domain) -> + rpc(Node, mongoose_domain_api, delete_domain_password, [Domain]). + +select_domain_admin(Node, Domain) -> + rpc(Node, mongoose_domain_sql, select_domain_admin, [Domain]). + insert_full_event(Node, EventId, Domain) -> rpc(Node, mongoose_domain_sql, insert_full_event, [EventId, Domain]). diff --git a/priv/graphql/schemas/admin/admin_auth_status.gql b/priv/graphql/schemas/admin/admin_auth_status.gql new file mode 100644 index 00000000000..d59db22647a --- /dev/null +++ b/priv/graphql/schemas/admin/admin_auth_status.gql @@ -0,0 +1,18 @@ +"Information about user request authorization" +type AdminAuthInfo{ + "Authorized for a domain" + domain: String + "Authorization status" + authStatus: AuthStatus! + "Authorization as a " + authType: AuthType +} + +enum AuthType{ + "" + DOMAIN_ADMIN + "" + ADMIN + "" + UNAUTHORIZED +} diff --git a/priv/graphql/schemas/admin/admin_schema.gql b/priv/graphql/schemas/admin/admin_schema.gql index f40ad07b65b..318b2ab9516 100644 --- a/priv/graphql/schemas/admin/admin_schema.gql +++ b/priv/graphql/schemas/admin/admin_schema.gql @@ -9,7 +9,7 @@ Only an authenticated admin can execute these queries. """ type AdminQuery{ "Check authorization status" - checkAuth: AuthStatus + checkAuth: AdminAuthInfo "Domain management" domains: DomainAdminQuery "Account management" diff --git a/priv/graphql/schemas/admin/domain.gql b/priv/graphql/schemas/admin/domain.gql index 42de21d12b2..8adc84bb8ed 100644 --- a/priv/graphql/schemas/admin/domain.gql +++ b/priv/graphql/schemas/admin/domain.gql @@ -14,6 +14,10 @@ type DomainAdminMutation @protected{ enableDomain(domain: String!): Domain "Disable domain" disableDomain(domain: String!): Domain + "Create or update domain admin password" + setDomainPassword(domain: String!, password: String!): String + "Delete domain admin password" + deleteDomainPassword(domain: String!): String } "A result of domain removal" diff --git a/priv/graphql/schemas/global/protected_dir.gql b/priv/graphql/schemas/global/protected_dir.gql index 466520ee414..cf02b4a5bb8 100644 --- a/priv/graphql/schemas/global/protected_dir.gql +++ b/priv/graphql/schemas/global/protected_dir.gql @@ -1,2 +1,7 @@ "Marks the resource to be accessed only by authorized requests" -directive @protected on FIELD_DEFINITION | OBJECT | INTERFACE +directive @protected (type: String = ALL, args: [String!] = []) on FIELD_DEFINITION | OBJECT | INTERFACE + +enum ProtectedType{ + DOMAIN + ALL +} diff --git a/priv/mssql2012.sql b/priv/mssql2012.sql index 668de8abae0..447748a1ed2 100644 --- a/priv/mssql2012.sql +++ b/priv/mssql2012.sql @@ -730,6 +730,10 @@ CREATE TABLE offline_markers ( CREATE INDEX i_offline_markers ON offline_markers(jid); +CREATE TABLE domain_admins( + domain VARCHAR(250) NOT NULL PRIMARY KEY, + password VARCHAR(250) NOT NULL +); -- Mapping from domain hostname to host_type. -- Column id is used for ordering only. diff --git a/priv/mysql.sql b/priv/mysql.sql index 41d8489bdaf..052858f980f 100644 --- a/priv/mysql.sql +++ b/priv/mysql.sql @@ -521,6 +521,12 @@ CREATE TABLE offline_markers ( CREATE INDEX i_offline_markers ON offline_markers(jid); +CREATE TABLE domain_admins( + domain VARCHAR(250) NOT NULL, + password VARCHAR(250) NOT NULL, + PRIMARY KEY(domain) +); + -- Mapping from domain hostname to host_type. -- Column id is used for ordering only. CREATE TABLE domain_settings ( diff --git a/priv/pg.sql b/priv/pg.sql index 23f8ba5c9df..f10892d17f8 100644 --- a/priv/pg.sql +++ b/priv/pg.sql @@ -478,6 +478,12 @@ CREATE TABLE offline_markers ( CREATE INDEX i_offline_markers ON offline_markers(jid); +CREATE TABLE domain_admins( + domain VARCHAR(250) NOT NULL, + password VARCHAR(250) NOT NULL, + PRIMARY KEY(domain) +); + -- Mapping from domain hostname to host_type. -- Column id is used for ordering only. CREATE TABLE domain_settings ( diff --git a/rebar.lock b/rebar.lock index 916827b24f4..2fc70d48d83 100644 --- a/rebar.lock +++ b/rebar.lock @@ -49,7 +49,7 @@ {<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1}, {<<"graphql">>, {git,"https://github.com/Premwoik/graphql-erlang.git", - {ref,"469728e9126f132450ad0b8239bd2ad12ee7b605"}}, + {ref,"72bf290d50b4b5e2f462e06ccfdc5645a6d6401a"}}, 0}, {<<"gun">>,{pkg,<<"gun">>,<<"1.3.3">>},0}, {<<"hackney">>,{pkg,<<"hackney">>,<<"1.13.0">>},1}, diff --git a/rel/fed1.vars-toml.config b/rel/fed1.vars-toml.config index c2877339d9a..fc6841d13cd 100644 --- a/rel/fed1.vars-toml.config +++ b/rel/fed1.vars-toml.config @@ -7,7 +7,8 @@ {http_api_endpoint_port, 5294}. {http_api_old_endpoint_port, 5293}. {http_api_client_endpoint_port, 8095}. -{http_qraphql_api_admin_endpoint_port, 5556}. +{http_graphql_api_admin_endpoint_port, 5556}. +{http_graphql_api_domain_admin_endpoint_port, 5546}. {http_graphql_api_user_endpoint_port, 5566}. %% This node is for s2s testing. @@ -50,7 +51,9 @@ port = {{ http_api_endpoint_port }}"}. {http_api_client_endpoint, "port = {{ http_api_client_endpoint_port }}"}. {http_graphql_api_admin_endpoint, "ip_address = \"127.0.0.1\" - port = {{http_qraphql_api_admin_endpoint_port}}"}. + port = {{http_graphql_api_admin_endpoint_port}}"}. +{http_graphql_api_domain_admin_endpoint, "ip_address = \"0.0.0.0\" + port = {{http_graphql_api_domain_admin_endpoint_port}}"}. {http_graphql_api_user_endpoint, "ip_address = \"0.0.0.0\" port = {{http_graphql_api_user_endpoint_port}}"}. diff --git a/rel/files/mongooseim.toml b/rel/files/mongooseim.toml index 51de15da35e..163c7aa86dd 100644 --- a/rel/files/mongooseim.toml +++ b/rel/files/mongooseim.toml @@ -132,6 +132,18 @@ username = "admin" password = "secret" +[[listen.http]] + {{#http_graphql_api_domain_admin_endpoint}} + {{{http_graphql_api_domain_admin_endpoint}}} + {{/http_graphql_api_domain_admin_endpoint}} + transport.num_acceptors = 10 + transport.max_connections = 1024 + + [[listen.http.handlers.mongoose_graphql_cowboy_handler]] + host = "_" + path = "/api/graphql" + schema_endpoint = "domain_admin" + [[listen.http]] {{#http_graphql_api_user_endpoint}} {{{http_graphql_api_user_endpoint}}} diff --git a/rel/mim2.vars-toml.config b/rel/mim2.vars-toml.config index eb885e31030..e82c0afe814 100644 --- a/rel/mim2.vars-toml.config +++ b/rel/mim2.vars-toml.config @@ -9,7 +9,8 @@ {http_api_endpoint_port, 8090}. {http_api_client_endpoint_port, 8091}. {service_port, 8899}. -{http_qraphql_api_admin_endpoint_port, 5552}. +{http_graphql_api_admin_endpoint_port, 5552}. +{http_graphql_api_domain_admin_endpoint_port, 5542}. {http_graphql_api_user_endpoint_port, 5562}. {hosts, "\"localhost\", \"anonymous.localhost\", \"localhost.bis\""}. @@ -22,7 +23,9 @@ {highload_vm_args, ""}. {http_graphql_api_admin_endpoint, "ip_address = \"127.0.0.1\" - port = {{http_qraphql_api_admin_endpoint_port}}"}. + port = {{http_graphql_api_admin_endpoint_port}}"}. +{http_graphql_api_domain_admin_endpoint, "ip_address = \"0.0.0.0\" + port = {{http_graphql_api_domain_admin_endpoint_port}}"}. {http_graphql_api_user_endpoint, "ip_address = \"0.0.0.0\" port = {{http_graphql_api_user_endpoint_port}}"}. {http_api_old_endpoint, "ip_address = \"127.0.0.1\" diff --git a/rel/mim3.vars-toml.config b/rel/mim3.vars-toml.config index 3d6e066689b..3ed84a1b6bd 100644 --- a/rel/mim3.vars-toml.config +++ b/rel/mim3.vars-toml.config @@ -9,7 +9,8 @@ {http_api_old_endpoint_port, 5292}. {http_api_endpoint_port, 8092}. {http_api_client_endpoint_port, 8193}. -{http_qraphql_api_admin_endpoint_port, 5553}. +{http_graphql_api_admin_endpoint_port, 5553}. +{http_graphql_api_domain_admin_endpoint_port, 5543}. {http_graphql_api_user_endpoint_port, 5563}. {hosts, "\"localhost\", \"anonymous.localhost\", \"localhost.bis\""}. @@ -39,7 +40,9 @@ tls.ciphers = \"ECDHE-RSA-AES256-GCM-SHA384\""}. {http_graphql_api_admin_endpoint, "ip_address = \"127.0.0.1\" - port = {{http_qraphql_api_admin_endpoint_port}}"}. + port = {{http_graphql_api_admin_endpoint_port}}"}. +{http_graphql_api_domain_admin_endpoint, "ip_address = \"0.0.0.0\" + port = {{http_graphql_api_domain_admin_endpoint_port}}"}. {http_graphql_api_user_endpoint, "ip_address = \"0.0.0.0\" port = {{http_graphql_api_user_endpoint_port}}"}. {http_api_old_endpoint, "ip_address = \"127.0.0.1\" diff --git a/rel/reg1.vars-toml.config b/rel/reg1.vars-toml.config index 2e33096a94b..b90cbb65b1f 100644 --- a/rel/reg1.vars-toml.config +++ b/rel/reg1.vars-toml.config @@ -9,6 +9,7 @@ {http_api_old_endpoint_port, 5273}. {http_api_client_endpoint_port, 8075}. {http_qraphql_api_admin_endpoint_port, 5554}. +{http_graphql_api_domain_admin_endpoint_port, 5544}. {http_graphql_api_user_endpoint_port, 5564}. %% This node is for global distribution testing. @@ -40,6 +41,8 @@ {http_graphql_api_admin_endpoint, "ip_address = \"127.0.0.1\" port = {{http_qraphql_api_admin_endpoint_port}}"}. +{http_graphql_api_domain_admin_endpoint, "ip_address = \"0.0.0.0\" + port = {{http_graphql_api_domain_admin_endpoint_port}}"}. {http_graphql_api_user_endpoint, "ip_address = \"0.0.0.0\" port = {{http_graphql_api_user_endpoint_port}}"}. {http_api_old_endpoint, "ip_address = \"127.0.0.1\" diff --git a/rel/vars-toml.config.in b/rel/vars-toml.config.in index f5274beba02..46964aea256 100644 --- a/rel/vars-toml.config.in +++ b/rel/vars-toml.config.in @@ -6,6 +6,7 @@ {http_port, 5280}. {https_port, 5285}. {http_graphql_api_admin_endpoint_port, 5551}. +{http_graphql_api_domain_admin_endpoint_port, 5541}. {http_graphql_api_user_endpoint_port, 5561}. % vm.args @@ -42,6 +43,8 @@ port = 5288"}. {http_graphql_api_admin_endpoint, "ip_address = \"127.0.0.1\" port = {{http_graphql_api_admin_endpoint_port}}"}. +{http_graphql_api_domain_admin_endpoint, "ip_address = \"0.0.0.0\" + port = {{http_graphql_api_domain_admin_endpoint_port}}"}. {http_graphql_api_user_endpoint, "ip_address = \"0.0.0.0\" port = {{http_graphql_api_user_endpoint_port}}"}. {http_api_endpoint, "ip_address = \"127.0.0.1\" diff --git a/src/domain/mongoose_domain_api.erl b/src/domain/mongoose_domain_api.erl index 1b5476e6711..9fc62c2ba79 100644 --- a/src/domain/mongoose_domain_api.erl +++ b/src/domain/mongoose_domain_api.erl @@ -15,6 +15,11 @@ get_all_static/0, get_domains_by_host_type/1]). +%% domain admin API +-export([check_domain_password/2, + set_domain_password/2, + delete_domain_password/1]). + %% subdomain API -export([register_subdomain/3, unregister_subdomain/2, @@ -72,6 +77,7 @@ delete_domain(Domain, HostType) -> Res = check_db(mongoose_domain_sql:delete_domain(Domain, HostType)), case Res of ok -> + delete_domain_password(Domain), mongoose_hooks:remove_domain(HostType, Domain); _ -> ok @@ -180,6 +186,33 @@ check_domain(Domain, HostType) -> ok end. +-type password() :: binary(). + +-spec check_domain_password(domain(), password()) -> ok | {error, wrong_password | not_found}. +check_domain_password(Domain, Password) -> + case mongoose_domain_sql:select_domain_admin(Domain) of + {ok, {Domain, Password}} -> + ok; + {ok, _} -> + {error, wrong_password}; + {error, not_found} -> + {error, not_found} + end. + +-spec set_domain_password(domain(), password()) -> ok | {error, not_found}. +set_domain_password(Domain, Password) -> + HostType = get_host_type(Domain), + case HostType of + {ok, _} -> + mongoose_domain_sql:set_domain_admin(Domain, Password); + {error, not_found} -> + {error, not_found} + end. + +-spec delete_domain_password(domain()) -> ok. +delete_domain_password(Domain) -> + mongoose_domain_sql:delete_domain_admin(Domain). + -spec register_subdomain(host_type(), subdomain_pattern(), mongoose_packet_handler:t()) -> ok | {error, already_registered | subdomain_already_exists}. diff --git a/src/domain/mongoose_domain_sql.erl b/src/domain/mongoose_domain_sql.erl index c7d3e7eb824..631fff72525 100644 --- a/src/domain/mongoose_domain_sql.erl +++ b/src/domain/mongoose_domain_sql.erl @@ -7,6 +7,10 @@ disable_domain/1, enable_domain/1]). +-export([select_domain_admin/1, + set_domain_admin/2, + delete_domain_admin/1]). + -export([select_domain/1, get_minmax_event_id/0, count_events_between_ids/2, @@ -80,10 +84,24 @@ start(#{db_pool := Pool}) -> "domain_settings.enabled = ", True/binary, ") " " WHERE domain_events.id >= ? AND domain_events.id <= ? " " ORDER BY domain_events.id ">>), + %% Admins + prepare(domain_insert_admin, domain_admins, [domain, password], + <<"INSERT INTO domain_admins (domain, password) VALUES (?, ?)">>), + prepare(domain_update_admin, domain_admins, [password, domain], + <<"UPDATE domain_admins" + " SET password = ? " + " WHERE domain = ?">>), + prepare(domain_delete_admin, domain_admins, [domain], + <<"DELETE FROM domain_admins WHERE domain = ?">>), + prepare(domain_select_admin, domain_admins, [domain], + <<"SELECT domain, password" + " FROM domain_admins WHERE domain = ?">>), ok. prepare_test_queries(Pool) -> True = sql_true(Pool), + prepare(domain_erase_admins, domain_admins, [], + <<"DELETE FROM domain_admins">>), prepare(domain_erase_settings, domain_settings, [], <<"DELETE FROM domain_settings">>), prepare(domain_erase_events, domain_events, [], @@ -148,6 +166,47 @@ disable_domain(Domain) -> enable_domain(Domain) -> set_enabled(Domain, true). +select_domain_admin(Domain) -> + Pool = get_db_pool(), + case execute_successfully(Pool, domain_select_admin, [Domain]) of + {selected, []} -> + {error, not_found}; + {selected, [Row]} -> + {ok, Row} + end. + +set_domain_admin(Domain, Password) -> + transaction(fun(Pool) -> + case select_domain_admin(Domain) of + {ok, _} -> + update_domain_admin(Pool, Domain, Password), + ok; + {error, not_found} -> + insert_domain_admin(Pool, Domain, Password), + ok + end + end). + +delete_domain_admin(Domain) -> + transaction(fun(Pool) -> + case select_domain_admin(Domain) of + {ok, _} -> + {updated, 1} = delete_domain_admin(Pool, Domain), + ok; + {error, not_found} -> + ok + end + end). + +insert_domain_admin(Pool, Domain, Password) -> + execute_successfully(Pool, domain_insert_admin, [Domain, Password]). + +update_domain_admin(Pool, Domain, Password) -> + execute_successfully(Pool, domain_update_admin, [Password, Domain]). + +delete_domain_admin(Pool, Domain) -> + execute_successfully(Pool, domain_delete_admin, [Domain]). + %% Returns smallest id first select_from(FromId, Limit) -> Pool = get_db_pool(), @@ -230,7 +289,8 @@ insert_full_event_mssql(EventId, Domain) -> erase_database(Pool) -> execute_successfully(Pool, domain_erase_events, []), - execute_successfully(Pool, domain_erase_settings, []). + execute_successfully(Pool, domain_erase_settings, []), + execute_successfully(Pool, domain_erase_admins, []). insert_domain_settings_without_event(Domain, HostType) -> Pool = get_db_pool(), diff --git a/src/graphql/admin/mongoose_graphql_admin_auth_info.erl b/src/graphql/admin/mongoose_graphql_admin_auth_info.erl new file mode 100644 index 00000000000..b6d460e69da --- /dev/null +++ b/src/graphql/admin/mongoose_graphql_admin_auth_info.erl @@ -0,0 +1,27 @@ +-module(mongoose_graphql_admin_auth_info). +-behaviour(mongoose_graphql). + +-export([execute/4]). + +-ignore_xref([execute/4]). + +-include_lib("jid/include/jid.hrl"). + +execute(#{authorized := Authorized}, admin, <<"authStatus">>, _Args) -> + case Authorized of + true -> + {ok, 'AUTHORIZED'}; + false -> + {ok, 'UNAUTHORIZED'} + end; +execute(Ctx, admin, <<"domain">>, _Args) -> + case maps:get(admin, Ctx, null) of + null -> {ok, null}; + #jid{lserver = Domain} -> {ok, Domain} + end; +execute(Ctx, admin, <<"authType">>, _Args) -> + case maps:get(authorized_as, Ctx, null) of + null -> {ok, null}; + domain_admin -> {ok, domain_admin}; + admin -> {ok, admin} + end. diff --git a/src/graphql/admin/mongoose_graphql_admin_query.erl b/src/graphql/admin/mongoose_graphql_admin_query.erl index 77c61a5d433..b80db073c74 100644 --- a/src/graphql/admin/mongoose_graphql_admin_query.erl +++ b/src/graphql/admin/mongoose_graphql_admin_query.erl @@ -21,10 +21,5 @@ execute(_Ctx, _Obj, <<"stanza">>, _Args) -> {ok, #{}}; execute(_Ctx, _Obj, <<"roster">>, _Args) -> {ok, roster}; -execute(#{authorized := Authorized}, _Obj, <<"checkAuth">>, _Args) -> - case Authorized of - true -> - {ok, 'AUTHORIZED'}; - false -> - {ok, 'UNAUTHORIZED'} - end. +execute(_Ctx, _Obj, <<"checkAuth">>, _Args) -> + {ok, admin}. diff --git a/src/graphql/admin/mongoose_graphql_domain_admin_mutation.erl b/src/graphql/admin/mongoose_graphql_domain_admin_mutation.erl index 6e8ad1a0ee6..311cc7d8b80 100644 --- a/src/graphql/admin/mongoose_graphql_domain_admin_mutation.erl +++ b/src/graphql/admin/mongoose_graphql_domain_admin_mutation.erl @@ -27,15 +27,26 @@ execute(_Ctx, admin, <<"enableDomain">>, #{<<"domain">> := Domain}) -> ok -> {ok, #domain{enabled = true, domain = Domain}}; {error, Error} -> - error_handler(Error, Domain, <<"">>) + error_handler(Error, Domain, <<>>) end; execute(_Ctx, admin, <<"disableDomain">>, #{<<"domain">> := Domain}) -> case mongoose_domain_api:disable_domain(Domain) of ok -> {ok, #domain{enabled = false, domain = Domain}}; {error, Error} -> - error_handler(Error, Domain, <<"">>) - end. + error_handler(Error, Domain, <<>>) + end; +execute(_Ctx, admin, <<"setDomainPassword">>, + #{<<"domain">> := Domain, <<"password">> := Password}) -> + case mongoose_domain_api:set_domain_password(Domain, Password) of + ok -> + {ok, <<"Domain password set successfully">>}; + {error, Error} -> + error_handler(Error, Domain, <<>>) + end; +execute(_Ctx, admin, <<"deleteDomainPassword">>, #{<<"domain">> := Domain}) -> + ok = mongoose_domain_api:delete_domain_password(Domain), + {ok, <<"Domain admin deleted successfully">>}. error_handler(Error, Domain, HostType) -> case {error, Error} of diff --git a/src/graphql/mongoose_graphql.erl b/src/graphql/mongoose_graphql.erl index b197256742b..1d482e5d64f 100644 --- a/src/graphql/mongoose_graphql.erl +++ b/src/graphql/mongoose_graphql.erl @@ -44,6 +44,8 @@ init() -> -spec get_endpoint(atom()) -> graphql:endpoint_context(). get_endpoint(admin) -> graphql_schema:get_endpoint_ctx(?ADMIN_EP_NAME); +get_endpoint(domain_admin) -> + graphql_schema:get_endpoint_ctx(?ADMIN_EP_NAME); get_endpoint(user) -> graphql_schema:get_endpoint_ctx(?USER_EP_NAME); get_endpoint(Name) -> @@ -72,12 +74,12 @@ execute(Ep, #{document := Doc, {ok, #{ast := Ast2, fun_env := FunEnv}} = graphql:type_check(Ep, Ast), ok = graphql:validate(Ast2), - ok = mongoose_graphql_permissions:check_permissions(OpName, AuthStatus, Ast2), Coerced = graphql:type_check_params(Ep, FunEnv, OpName, Vars), Ctx2 = Ctx#{params => Coerced, operation_name => OpName, authorized => AuthStatus, error_module => mongoose_graphql_errors}, + ok = mongoose_graphql_permissions:check_permissions(Ctx2, Ast2), {ok, graphql:execute(Ep, Ctx2, Ast2)} catch throw:{error, Err} -> @@ -126,6 +128,7 @@ graphql_parse(Doc) -> admin_mapping_rules() -> #{objects => #{ 'AdminQuery' => mongoose_graphql_admin_query, + 'AdminAuthInfo' => mongoose_graphql_admin_auth_info, 'DomainAdminQuery' => mongoose_graphql_domain_admin_query, 'AdminMutation' => mongoose_graphql_admin_mutation, 'DomainAdminMutation' => mongoose_graphql_domain_admin_mutation, diff --git a/src/graphql/mongoose_graphql_cowboy_handler.erl b/src/graphql/mongoose_graphql_cowboy_handler.erl index 3a30da2a2a2..bddb2f6d2cb 100644 --- a/src/graphql/mongoose_graphql_cowboy_handler.erl +++ b/src/graphql/mongoose_graphql_cowboy_handler.erl @@ -95,6 +95,8 @@ to_json(Req, State) -> json_request(Req, State). %% Internal +check_auth(Auth, #{schema_endpoint := <<"domain_admin">>} = State) -> + auth_domain_admin(Auth, State); check_auth(Auth, #{schema_endpoint := <<"admin">>} = State) -> auth_admin(Auth, State); check_auth(Auth, #{schema_endpoint := <<"user">>} = State) -> @@ -103,21 +105,43 @@ check_auth(Auth, #{schema_endpoint := <<"user">>} = State) -> auth_user({basic, User, Password}, State) -> JID = jid:from_binary(User), case mongoose_api_common:check_password(JID, Password) of - {true, _} -> {ok, State#{authorized => true, schema_ctx => #{user => JID}}}; + {true, _} -> {ok, State#{authorized => true, + authorized_as => user, + schema_ctx => #{user => JID}}}; _ -> error end; auth_user(_, State) -> {ok, State#{authorized => false}}. auth_admin({basic, Username, Password}, #{username := Username, password := Password} = State) -> - {ok, State#{authorized => true}}; + {ok, State#{authorized => true, + schema_ctx => #{authorized_as => admin} + }}; auth_admin({basic, _, _}, _) -> error; auth_admin(_, #{username := _, password := _} = State) -> {ok, State#{authorized => false}}; auth_admin(_, State) -> % auth credentials not provided in config - {ok, State#{authorized => true}}. + {ok, State#{authorized => true, + schema_ctx => #{authorized_as => admin}}}. + +auth_domain_admin({basic, Username, Password}, State) -> + case jid:to_lus(jid:from_binary(Username)) of + {<<"admin">>, Domain} -> + case mongoose_domain_api:check_domain_password(Domain, Password) of + ok -> + {ok, State#{authorized => true, + schema_ctx => #{authorized_as => domain_admin, + admin => jid:from_binary(Username)}}}; + {error, _} -> + error + end; + _ -> + error + end; +auth_domain_admin(_, State) -> + {ok, State#{authorized => false}}. run_request(#{document := undefined}, Req, State) -> reply_error(make_error(decode, no_query_supplied), Req, State); diff --git a/src/graphql/mongoose_graphql_enum.erl b/src/graphql/mongoose_graphql_enum.erl index 930bb971aaa..5608680bc59 100644 --- a/src/graphql/mongoose_graphql_enum.erl +++ b/src/graphql/mongoose_graphql_enum.erl @@ -8,8 +8,6 @@ input(<<"PresenceShow">>, Show) -> {ok, list_to_binary(string:to_lower(binary_to_list(Show)))}; input(<<"PresenceType">>, Type) -> {ok, list_to_binary(string:to_lower(binary_to_list(Type)))}; -input(<<"AuthStatus">>, <<"AUTHORIZED">>) -> {ok, 'AUTHORIZED'}; -input(<<"AuthStatus">>, <<"UNAUTHORIZED">>) -> {ok, 'UNAUTHORIZED'}; input(<<"Affiliation">>, <<"OWNER">>) -> {ok, owner}; input(<<"Affiliation">>, <<"MEMBER">>) -> {ok, member}; input(<<"Affiliation">>, <<"NONE">>) -> {ok, none}; @@ -34,6 +32,8 @@ output(<<"PresenceType">>, Type) -> {ok, list_to_binary(string:to_upper(binary_to_list(Type)))}; output(<<"AuthStatus">>, Status) -> {ok, atom_to_binary(Status, utf8)}; +output(<<"AuthType">>, Type) -> + {ok, list_to_binary(string:to_upper(atom_to_list(Type)))}; output(<<"Affiliation">>, Aff) -> {ok, list_to_binary(string:to_upper(atom_to_list(Aff)))}; output(<<"BlockingAction">>, Action) -> diff --git a/src/graphql/mongoose_graphql_errors.erl b/src/graphql/mongoose_graphql_errors.erl index e222c42c418..a23b4e62246 100644 --- a/src/graphql/mongoose_graphql_errors.erl +++ b/src/graphql/mongoose_graphql_errors.erl @@ -98,7 +98,12 @@ authorize_err_msg({request_error, {header, <<"authorization">>}, _}) -> authorize_err_msg(wrong_credentials) -> "The provided credentials are wrong"; authorize_err_msg({no_permissions, Op}) -> - io_lib:format("Cannot execute query ~s without permissions", [Op]). + io_lib:format("Cannot execute query ~s without permissions", [Op]); +authorize_err_msg({no_permissions, Op, Res, InvalidArgs}) -> + InvalidArgs2 = lists:join(", ", InvalidArgs), + Format = "Cannot execute query ~s without permissions to the given ~s. " + ++ "Args with invalid value: ~s", + io_lib:format(Format, [Op, Res, InvalidArgs2]). parse_err_msg({parser_error, {Line, graphql_parser, Msg}}) -> io_lib:format("Cannot parse line ~B because of ~s", [Line, Msg]); diff --git a/src/graphql/mongoose_graphql_permissions.erl b/src/graphql/mongoose_graphql_permissions.erl index d165404b7fb..cc3a24003be 100644 --- a/src/graphql/mongoose_graphql_permissions.erl +++ b/src/graphql/mongoose_graphql_permissions.erl @@ -22,20 +22,52 @@ %% @end -module(mongoose_graphql_permissions). --export([check_permissions/3]). +-export([check_permissions/2]). -include_lib("graphql/src/graphql_schema.hrl"). -include_lib("graphql/src/graphql_internal.hrl"). -include_lib("graphql/include/graphql.hrl"). +-include_lib("jid/include/jid.hrl"). -type auth_status() :: boolean(). +-type auth_role() :: user | admin | domain_admin. +-type params() :: map(). +-type auth_ctx() :: #{operation_name := binary(), + params := params(), + authorized := auth_status(), + authorized_as => auth_role(), + user => jid:jid(), + admin => jid:jid(), + atom() => any()}. +-type no_access_info() :: #{path := [binary()], + type := atom(), + invalid := [binary()]}. +-type field_check_result() :: ok | no_access_info(). -type document() :: #document{}. +-type definitions() :: [any()]. -%% @doc Checks if query can be executed by unauthorized request. If not, throws -%% an error. When request is authorized, just skip. +%% @doc Checks if query can be executed by unauthorized request or authorized as one +%% of the roles (USER, ADMIN, DOMAIN_ADMIN). If not, throw an error. +%% +%% The USER and ADMIN can execute each query because they are on separated GraphQL +%% instances that serves different queries. +%% +%% The DOMAIN_ADMIN use the same GraphQL instance as ADMIN, but have permissions +%% only to administrate own domain. %% @end --spec check_permissions(binary(), auth_status(), document()) -> ok. -check_permissions(OpName, false, #document{definitions = Definitions}) -> +-spec check_permissions(auth_ctx(), document()) -> ok. +check_permissions(#{operation_name := OpName, authorized := false}, + #document{definitions = Definitions}) -> + check_unauthorized_request_permissions(OpName, Definitions); +check_permissions(#{operation_name := OpName, authorized_as := domain_admin, + admin := #jid{lserver = Domain}, params := Params}, + #document{definitions = Definitions}) -> + check_domain_authorized_request_permissions(OpName, Domain, Params, Definitions); +check_permissions(#{authorized := true}, _) -> + ok. + +-spec check_unauthorized_request_permissions(binary(), definitions()) -> ok. +check_unauthorized_request_permissions(OpName, Definitions) -> Op = lists:filter(fun(D) -> is_req_operation(D, OpName) end, Definitions), case Op of [#op{schema = Schema, selection_set = Set} = Op1] -> @@ -58,12 +90,95 @@ check_permissions(OpName, false, #document{definitions = Definitions}) -> end; _ -> ok - end; -check_permissions(_, true, _) -> - ok. + end. + +-spec check_domain_authorized_request_permissions(binary(), binary(), + params(), definitions()) -> ok. +check_domain_authorized_request_permissions(OpName, Domain, Params, Definitions) -> + Op = lists:filter(fun(D) -> is_req_operation(D, OpName) end, Definitions), + case Op of + [#op{selection_set = Set}] -> + case check_fields(#{domain => Domain}, Params, Set) of + ok -> + ok; + #{invalid := Args, path := Path, type := Type} -> + OpName2 = op_name(OpName), + Error = {no_permissions, OpName2, Type, Args}, + Path2 = lists:reverse([OpName2 | Path]), + graphql_err:abort(Path2, authorize, Error) + end; + _ -> + ok + end. % Internal +-spec check_fields(map(), map(), [any()]) -> field_check_result(). +check_fields(Ctx, Params, Fields) -> + Fun = fun(F, ok) -> check_field(F, Ctx, Params); + (_, NoAccessInfo) -> NoAccessInfo + end, + lists:foldl(Fun, ok, Fields). + +-spec check_field(field() | any(), map(), map()) -> field_check_result(). +check_field(#field{id = Name, selection_set = Set, args = Args, + schema = #schema_field{directives = Directives}}, Ctx, Params) -> + Args2 = maps:from_list([prepare_arg(ArgName, Type, Params) || {ArgName, Type} <- Args]), + Res = check_field_args(Ctx, Args2, Directives), + Res2 = check_field_type(Res, Ctx, Params, Set), + add_path(Res2, name(Name)); +check_field(_, _, _) -> ok. + +-spec check_field_args(map(), map(), [graphql:directive()]) -> field_check_result(). +check_field_args(Ctx, Args, Directives) -> + case lists:filter(fun is_protected_directive/1, Directives) of + [#directive{} = Dir] -> + #{type := {enum, Type}, args := PArgs} = protected_dir_args_to_map(Dir), + check_field_args(Type, Ctx, PArgs, Args); + [] -> + ok + end. + +-spec check_field_args(binary(), map(), [binary()], map()) -> field_check_result(). +check_field_args(<<"DOMAIN">>, #{domain := Domain}, ProtectedArgs, Args) -> + InvalidArgs = + lists:filter(fun(N) -> not arg_eq(maps:get(N, Args), Domain) end, ProtectedArgs), + make_result(InvalidArgs, domain); +check_field_args(<<"DEFAULT">>, _Ctx, _ProtectedArgs, _Args) -> + ok. + +-spec check_field_type(field_check_result(), map(), map(), [any()]) -> field_check_result(). +check_field_type(ok, Ctx, Params, Set) -> + check_fields(Ctx, Params, Set); +check_field_type(NoAccessInfo, _, _, _) -> + NoAccessInfo. + +prepare_arg(ArgName, #{value := #var{id = Name}}, Vars) -> + {ArgName, maps:get(name(Name), Vars)}; +prepare_arg(ArgName, #{value := Val}, _) -> + {ArgName, Val}. + +arg_eq(Domain, Domain) -> true; +arg_eq(#jid{lserver = Domain}, Domain) -> true; +arg_eq(_, _) -> false. + +make_result([], _) -> + ok; +make_result(InvalidArgs, Type) when is_atom(Type) -> + #{type => Type, path => [], invalid => InvalidArgs}. + +add_path(ok, _) -> ok; +add_path(#{path := Path} = Acc, FieldName) -> + Acc#{path => [FieldName | Path]}. + +protected_dir_args_to_map(#directive{args = Args}) -> + Default = #{type => {enum, <<"DEFAULT">>}, args => []}, + ArgsMap = maps:from_list([{binary_to_atom(name(N)), V} || {N, V} <- Args]), + maps:merge(Default, ArgsMap). + +name({name, _, N}) -> N; +name(N) when is_binary(N) -> N. + op_name(undefined) -> <<"ROOT">>; op_name(Name) -> diff --git a/test/mongoose_graphql_SUITE.erl b/test/mongoose_graphql_SUITE.erl index f1695d5466e..fd3e271c2e7 100644 --- a/test/mongoose_graphql_SUITE.erl +++ b/test/mongoose_graphql_SUITE.erl @@ -13,6 +13,12 @@ -define(assertPermissionsSuccess(Config, Doc), ?assertMatch(ok, check_permissions(Config, Doc))). +-define(assertDomainPermissionsFailed(Config, Domain, Args, Doc), + ?assertThrow({error, #{error_term := {no_permissions, _, domain, Args}}}, + check_domain_permissions(Config, Domain, Doc))). +-define(assertPermissionsSuccess(Config, Domain, Doc), + ?assertMatch(ok, check_domain_permissions(Config, Domain, Doc))). + -define(assertErrMsg(Code, MsgContains, ErrorMsg), assert_err_msg(Code, MsgContains, ErrorMsg)). @@ -26,8 +32,10 @@ all() -> {group, error_handling}, {group, error_formatting}, {group, permissions}, + {group, domain_permissions}, {group, user_listener}, - {group, admin_listener}]. + {group, admin_listener}, + {group, domain_admin_listener}]. groups() -> [{protected_graphql, [parallel], protected_graphql()}, @@ -35,7 +43,9 @@ groups() -> {error_handling, [parallel], error_handling()}, {error_formatting, [parallel], error_formatting()}, {permissions, [parallel], permissions()}, + {domain_permissions, [parallel], domain_permissions()}, {admin_listener, [parallel], admin_listener()}, + {domain_admin_listener, [parallel], domain_admin_listener()}, {user_listener, [parallel], user_listener()}]. protected_graphql() -> @@ -81,12 +91,27 @@ permissions() -> check_union_permissions ]. +domain_permissions() -> + [check_field_domain_permissions, + check_child_object_field_domain_permissions + %check_interface_field_domain_permissions TODO + ]. + user_listener() -> [auth_user_can_access_protected_types | common_tests()]. + admin_listener() -> [no_creds_defined_admin_can_access_protected, auth_admin_can_access_protected_types | common_tests()]. +domain_admin_listener() -> + [auth_domain_admin_can_access_protected_types, + auth_domain_admin_wrong_password_error, + auth_domain_admin_nonexistent_domain_error, + auth_domain_admin_cannot_access_other_domain, + auth_domain_admin_can_access_owned_domain + | common_tests()]. + common_tests() -> [malformed_auth_header_error, auth_wrong_creds_error, @@ -122,9 +147,16 @@ init_per_group(admin_listener, Config) -> {password, <<"secret">>}, {schema_endpoint, <<"admin">>}], init_ep_listener(5558, admin_schema_ep, ListenerOpts, Config); -init_per_group(no_creds_admin_listener, Config) -> - ListenerOpts = [{schema_endpoint, <<"admin">>}], - init_ep_listener(5559, admin_schema_ep, ListenerOpts, Config); +init_per_group(domain_admin_listener, Config) -> + meck:new(mongoose_domain_api, [no_link]), + meck:expect(mongoose_domain_api, check_domain_password, + fun + (<<"localhost">>, <<"makota">>) -> ok; + (<<"localhost">>, _) -> {error, wrong_password}; + (_, _) -> {error, not_found} + end), + ListenerOpts = [{schema_endpoint, <<"domain_admin">>}], + init_ep_listener(5560, adminn_schema_ep, ListenerOpts, Config); init_per_group(_G, Config) -> Config. @@ -135,6 +167,10 @@ end_per_group(user_listener, Config) -> end_per_group(admin_listener, Config) -> ?config(test_process, Config) ! stop, Config; +end_per_group(domain_admin_listener, Config) -> + meck:unload(mongoose_domain_api), + ?config(test_process, Config) ! stop, + Config; end_per_group(_, Config) -> Config. @@ -168,7 +204,10 @@ init_per_testcase(C, Config) when C =:= check_object_permissions; C =:= check_interface_permissions; C =:= check_interface_field_permissions; C =:= check_inline_fragment_permissions; - C =:= check_union_permissions -> + C =:= check_union_permissions; + C =:= check_field_domain_permissions; + C =:= check_child_object_field_domain_permissions; + C =:= check_interface_field_domain_permissions -> {Mapping, Pattern} = example_permissions_schema_data(Config), {ok, _} = mongoose_graphql:create_endpoint(C, Mapping, [Pattern]), Ep = mongoose_graphql:get_endpoint(C), @@ -396,6 +435,36 @@ check_union_permissions(Config) -> ?assertPermissionsFailed(Config, FDoc), ?assertPermissionsFailed(Config, FDoc2). +%% Domain permissions + +check_field_domain_permissions(Config) -> + Domain = <<"my-domain.com">>, + Config2 = [{op, <<"Q1">>}, {args, #{<<"domain">> => Domain}} | Config], + Doc = <<"{ field protectedField }">>, + Doc2 = <<"query Q1($domain: String) { protectedField domainProtectedField(argA: $domain" + ", argB: \"domain\") }">>, + FDoc = <<"{protectedField domainProtectedField(argA: \"domain.com\"," + " argB: \"domain.com\") }">>, + ?assertPermissionsSuccess(Config, Domain, Doc), + ?assertPermissionsSuccess(Config2, Domain, Doc2), + ?assertDomainPermissionsFailed(Config, Domain, [<<"argA">>], FDoc). + +check_child_object_field_domain_permissions(Config) -> + Domain = <<"my-domain.com">>, + Config2 = [{op, <<"Q1">>}, {args, #{<<"domain">> => Domain}} | Config], + Doc = <<"{ obj { field protectedField } }">>, + Doc2 = <<"query Q1($domain: String) { obj { protectedField domainProtectedField(argA: $domain" + ", argB: \"domain\") } }">>, + FDoc = <<"{ obj {protectedField domainProtectedField(argA: \"domain.com\"," + " argB: \"domain.com\") } }">>, + ?assertPermissionsSuccess(Config, Domain, Doc), + ?assertPermissionsSuccess(Config2, Domain, Doc2), + ?assertDomainPermissionsFailed(Config, Domain, [<<"argA">>], FDoc). + +check_interface_field_domain_permissions(_Config) -> + %% FIXME provide implementation + ok. + %% Error formatting format_internal_crash(_Config) -> @@ -494,6 +563,36 @@ auth_admin_can_access_protected_types(Config) -> {Status, Data} = execute(Ep, Body, {<<"admin">>, <<"secret">>}), assert_access_granted(Status, Data). +auth_domain_admin_can_access_protected_types(Config) -> + Ep = ?config(endpoint_addr, Config), + Body = #{query => "{ field }"}, + {Status, Data} = execute(Ep, Body, {<<"admin@localhost">>, <<"makota">>}), + assert_access_granted(Status, Data). + +auth_domain_admin_wrong_password_error(Config) -> + Ep = ?config(endpoint_addr, Config), + Body = #{query => "{ field }"}, + {Status, Data} = execute(Ep, Body, {<<"admin@localhost">>, <<"mapsa">>}), + assert_no_permissions(wrong_credentials, Status, Data). + +auth_domain_admin_nonexistent_domain_error(Config) -> + Ep = ?config(endpoint_addr, Config), + Body = #{query => "{ field }"}, + {Status, Data} = execute(Ep, Body, {<<"admin@localhost2">>, <<"makota">>}), + assert_no_permissions(wrong_credentials, Status, Data). + +auth_domain_admin_can_access_owned_domain(Config) -> + Ep = ?config(endpoint_addr, Config), + Body = #{query => "{ fieldDP(argA: \"localhost\") }"}, + {Status, Data} = execute(Ep, Body, {<<"admin@localhost">>, <<"makota">>}), + assert_access_granted(Status, Data). + +auth_domain_admin_cannot_access_other_domain(Config) -> + Ep = ?config(endpoint_addr, Config), + Body = #{query => "{ field fieldDP(argA: \"domain.com\") }"}, + {Status, Data} = execute(Ep, Body, {<<"admin@localhost">>, <<"makota">>}), + assert_no_permissions(no_permissions, Status, Data). + malformed_auth_header_error(Config) -> Ep = ?config(endpoint_addr, Config), % The encoded credentials value is malformed and cannot be decoded. @@ -609,7 +708,21 @@ check_permissions(Config, Doc) -> {ok, Ast} = graphql:parse(Doc), {ok, #{ast := Ast2}} = graphql:type_check(Ep, Ast), ok = graphql:validate(Ast2), - ok = mongoose_graphql_permissions:check_permissions(Op, false, Ast2). + Ctx = #{operation_name => Op, authorized => false, params => #{}}, + ok = mongoose_graphql_permissions:check_permissions(Ctx, Ast2). + +check_domain_permissions(Config, Domain, Doc) -> + Ep = ?config(endpoint, Config), + Args = proplists:get_value(args, Config, #{}), + Op = proplists:get_value(op, Config, undefined), + {ok, Ast} = graphql:parse(Doc), + {ok, #{ast := Ast2, fun_env := FunEnv}} = graphql:type_check(Ep, Ast), + ok = graphql:validate(Ast2), + Coerced = graphql:type_check_params(Ep, FunEnv, Op, Args), + Admin = jid:make_bare(<<"admin">>, Domain), + Ctx = #{operation_name => Op, authorized => true, authorized_as => domain_admin, + admin => Admin, params => Coerced}, + ok = mongoose_graphql_permissions:check_permissions(Ctx, Ast2). request(Doc, Authorized) -> request(undefined, Doc, Authorized). @@ -656,8 +769,9 @@ example_permissions_schema_data(Config) -> #{'UserQuery' => mongoose_graphql_default_resolver, 'UserMutation' => mongoose_graphql_default_resolver, default => mongoose_graphql_default_resolver}, - interfaces => #{default => mongoose_graphql_default_resolver}, - unions => #{default => mongoose_graphql_default_resolver}}, + enums => #{default => mongoose_graphql_default_resolver}, + interfaces => #{default => mongoose_graphql_default_resolver}, + unions => #{default => mongoose_graphql_default_resolver}}, {Mapping, Pattern}. example_listener_schema_data(Config) -> @@ -666,7 +780,8 @@ example_listener_schema_data(Config) -> #{objects => #{'UserQuery' => mongoose_graphql_default_resolver, 'UserMutation' => mongoose_graphql_default_resolver, - default => mongoose_graphql_default_resolver}}, + default => mongoose_graphql_default_resolver}, + enums => #{default => mongoose_graphql_default_resolver}}, {Mapping, Pattern}. -spec init_ep_listener(integer(), atom(), [{atom(), term()}], [{atom(), term()}]) -> diff --git a/test/mongoose_graphql_SUITE_data/listener_schema.gql b/test/mongoose_graphql_SUITE_data/listener_schema.gql index e34b79826c5..06881d21562 100644 --- a/test/mongoose_graphql_SUITE_data/listener_schema.gql +++ b/test/mongoose_graphql_SUITE_data/listener_schema.gql @@ -3,13 +3,20 @@ schema{ mutation: Mutation } -directive @protected on FIELD_DEFINITION | OBJECT +directive @protected (type: ProtectionType = DEFAULT, args: [String!] = []) + on FIELD_DEFINITION | OBJECT + +enum ProtectionType{ + DOMAIN + DEFAULT +} """ Contains all available queries. """ type Query @protected{ field: String + fieldDP(argA: String): String @protected(type: DOMAIN, args: ["argA"]) } """ diff --git a/test/mongoose_graphql_SUITE_data/permissions_schema.gql b/test/mongoose_graphql_SUITE_data/permissions_schema.gql index da8f4748f66..67f67b65607 100644 --- a/test/mongoose_graphql_SUITE_data/permissions_schema.gql +++ b/test/mongoose_graphql_SUITE_data/permissions_schema.gql @@ -3,11 +3,19 @@ schema{ mutation: UserMutation } -directive @protected on FIELD_DEFINITION | OBJECT | INTERFACE +directive @protected (type: ProtectionType = DEFAULT, args: [String!] = []) + on FIELD_DEFINITION | OBJECT | INTERFACE + +enum ProtectionType{ + DOMAIN + DEFAULT +} type UserQuery{ field: String protectedField: String @protected + domainProtectedField(argA: String, argB: String): + String @protected(type: DOMAIN, args: ["argA"]) interface: Interface union: UnionT obj: Object @@ -35,6 +43,8 @@ type Object implements Interface{ otherName: String @protected protectedName: String protectedField: String @protected + domainProtectedField(argA: String, argB: String): + String @protected(type: DOMAIN, args: ["argA"]) field: String } diff --git a/test/mongoose_graphql_default_resolver.erl b/test/mongoose_graphql_default_resolver.erl index 53ae509f588..c9c79e3251c 100644 --- a/test/mongoose_graphql_default_resolver.erl +++ b/test/mongoose_graphql_default_resolver.erl @@ -6,6 +6,8 @@ execute(_Ctx, _Obj, <<"field">>, _Attrs) -> {ok, <<"Test field">>}; +execute(_Ctx, _Obj, <<"fieldDP">>, _Attrs) -> + {ok, <<"Test field">>}; execute(_Ctx, _Obj, <<"id">>, #{<<"value">> := Value}) -> {ok, Value}; execute(_Ctx, _Obj, Field, _Attrs) ->