Skip to content

Commit

Permalink
Merge pull request #3442 from esl/graphql/extend-permissions-checking
Browse files Browse the repository at this point in the history
GraphQL - extend permissions checking
  • Loading branch information
chrzaszcz authored Dec 17, 2021
2 parents 22b483c + f491972 commit c218659
Show file tree
Hide file tree
Showing 8 changed files with 266 additions and 52 deletions.
2 changes: 1 addition & 1 deletion priv/graphql/schemas/global/protected_dir.gql
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"Marks the resource to be accessed only by authorized requests"
directive @protected on FIELD_DEFINITION | OBJECT
directive @protected on FIELD_DEFINITION | OBJECT | INTERFACE
4 changes: 2 additions & 2 deletions priv/graphql/schemas/user/user_schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ schema{

"""
Contains all user available queries.
Only authenticated user can execute this queries.
Only an authenticated user can execute these queries.
"""
type UserQuery @protected{
"Something to not leave type without fields"
Expand All @@ -14,7 +14,7 @@ type UserQuery @protected{

"""
Contains all user available mutations.
Only authenticated user can execute this mutations.
Only an authenticated user can execute these mutations.
"""
type UserMutation @protected{
"Something to not leave type without fields"
Expand Down
20 changes: 9 additions & 11 deletions src/mongoose_graphql.erl
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,18 @@ graphql_parse(Doc) ->

admin_mapping_rules() ->
#{objects => #{
'AdminQuery' => mongoose_graphql_admin_query,
'AdminMutation' => mongoose_graphql_admin_mutation,
'Domain' => mongoose_graphql_domain,
default => mongoose_graphql_default
}
}.
'AdminQuery' => mongoose_graphql_admin_query,
'AdminMutation' => mongoose_graphql_admin_mutation,
'Domain' => mongoose_graphql_domain,
default => mongoose_graphql_default},
interfaces => #{default => mongoose_graphql_default}}.

user_mapping_rules() ->
#{objects => #{
'UserQuery' => mongoose_graphql_user_query,
'UserMutation' => mongoose_graphql_user_mutation,
default => mongoose_graphql_default
}
}.
'UserQuery' => mongoose_graphql_user_query,
'UserMutation' => mongoose_graphql_user_mutation,
default => mongoose_graphql_default},
interfaces => #{default => mongoose_graphql_default}}.

load_multiple_file_schema(Patterns) ->
Paths = lists:flatmap(fun(P) -> filelib:wildcard(P) end, Patterns),
Expand Down
68 changes: 56 additions & 12 deletions src/mongoose_graphql/mongoose_graphql_permissions.erl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
%% to objects, to fields, and more. The custom directive `@protected' is created
%% to mark which objects or fields could be accessed only by an authorized request.
%% This module analyzes the AST and tries to find if there is at least one protected
%% resource.
%% resource. The `@protected' directive can be attached to <b>field definitions</b>
%% to <b>objects</b>, or to <b>interfaces</b>.
%%
%% Interfaces and objects permissions are checked independently. This means that when
%% an interface is protected or has protected fields, then all implementing objects
%% should be protected or have the same fields protected. <strong>This demands to mark all
%% protected resources at every occurrence with the directive</strong>. Otherwise permissions
%% will be different for interface and implementing objects.
%%
%% If an unauthorized request wants to execute a query that contains protected resources,
%% an error is thrown.
Expand All @@ -22,19 +29,17 @@
-include_lib("graphql/include/graphql.hrl").

-type auth_status() :: boolean().
-type document() :: #document{}.

%% @doc Checks if query can be executed by unauthorized request. If not, throws
%% an error. When request is authorized, just skip.
%% @end
-spec check_permissions(binary(), auth_status(), #document{}) -> ok.
-spec check_permissions(binary(), auth_status(), document()) -> ok.
check_permissions(OpName, false, #document{definitions = Definitions}) ->
% Currently permissions are checked only for root Query/Mutation objects.
% TODO Check permissions for fields.
% TODO Check permissions of child objects and their fields (check deeper).
Op = lists:filter(fun(D) -> is_req_operation(D, OpName) end, Definitions),
case Op of
[#op{schema = Schema} = Op1] ->
case is_object_protected(Schema) of
[#op{schema = Schema, selection_set = Set} = Op1] ->
case is_object_protected(Schema, Set, Definitions) of
true ->
% Seems that the introspection fields belong to the query object.
% When an object is protected we need to ensure that the request
Expand Down Expand Up @@ -65,11 +70,6 @@ is_req_operation(#op{id = {name, _, Name}}, Name) ->
is_req_operation(_, _) ->
false.

is_object_protected(#object_type{directives = Directives}) ->
lists:any(fun is_protected_directive/1, Directives);
is_object_protected(_) ->
false.

is_protected_directive(#directive{id = {name, _, <<"protected">>}}) ->
true;
is_protected_directive(_) ->
Expand All @@ -84,3 +84,47 @@ is_introspection_field(#field{id = {name, _, <<"__type">>}}) ->
true;
is_introspection_field(_) ->
false.

is_object_protected(_, [], _) ->
false;
is_object_protected(#schema_field{ty = Ty}, Set, Definitions) ->
is_object_protected(Ty, Set, Definitions);
is_object_protected({non_null, Obj}, Set, Definitions) ->
is_object_protected(Obj, Set, Definitions);
is_object_protected({list, Obj}, Set, Definitions) ->
is_object_protected(Obj, Set, Definitions);
is_object_protected(Object, Set, Definitions) ->
case is_object_protected(Object) of
false ->
lists:any(fun(S) -> is_field_protected(Object, S, Definitions) end, Set);
true ->
true
end.

is_object_protected(#interface_type{directives = Directives}) ->
lists:any(fun is_protected_directive/1, Directives);
is_object_protected(#object_type{directives = Directives}) ->
lists:any(fun is_protected_directive/1, Directives);
is_object_protected(_) ->
false.

is_field_protected(_, #frag_spread{id = {name, _, Name}}, Definitions) ->
[#frag{schema = Schema, selection_set = Set}] =
lists:filter(fun(#frag{id = {name, _, Name2}}) -> Name == Name2;
(_) -> false end, Definitions),
is_object_protected(Schema, Set, Definitions);
is_field_protected(_, #frag{schema = Object, selection_set = Set}, Definitions) ->
is_object_protected(Object, Set, Definitions);
is_field_protected(Parent,
#field{id = {name, _, Name}, schema = Object, selection_set = Set},
Definitions) ->
{ok, #schema_field{directives = Directives}} = maps:find(Name, fields(Parent)),
case lists:any(fun is_protected_directive/1, Directives) of
false ->
is_object_protected(Object, Set, Definitions);
true ->
true
end.

fields(#object_type{fields = Fields}) -> Fields;
fields(#interface_type{fields = Fields}) -> Fields.
152 changes: 134 additions & 18 deletions test/mongoose_graphql_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,57 @@
-include_lib("common_test/include/ct.hrl").
-include_lib("graphql/src/graphql_schema.hrl").

-define(assertPermissionsFailed(Config, Doc),
?assertThrow({error, no_permissions},
check_permissions(Config, Doc))).
-define(assertPermissionsSuccess(Config, Doc),
?assertMatch(ok, check_permissions(Config, Doc))).

all() ->
[can_create_endpoint,
can_load_split_schema,
admin_and_user_load_global_types,
{group, unprotected_graphql},
{group, protected_graphql},
{group, errors_handling}].
{group, error_handling},
{group, permissions}].

groups() ->
[{protected_graphql, [parallel],
[auth_can_execute_protected_query,
auth_can_execute_protected_mutation,
unauth_cannot_execute_protected_query,
unauth_cannot_execute_protected_mutation,
unauth_can_access_introspection]},
{unprotected_graphql, [parallel],
[can_execute_query_with_vars,
auth_can_execute_query,
auth_can_execute_mutation,
unauth_can_execute_query,
unauth_can_execute_mutation]},
{errors_handling, [parallel],
[should_catch_parsing_errors,
should_catch_type_check_params_errors,
should_catch_type_check_errors
]}].
[{protected_graphql, [parallel], protected_graphql()},
{unprotected_graphql, [parallel], unprotected_graphql()},
{error_handling, [parallel], error_handling()},
{permissions, [parallel], permissions()}].

protected_graphql() ->
[auth_can_execute_protected_query,
auth_can_execute_protected_mutation,
unauth_cannot_execute_protected_query,
unauth_cannot_execute_protected_mutation,
unauth_can_access_introspection].

unprotected_graphql() ->
[can_execute_query_with_vars,
auth_can_execute_query,
auth_can_execute_mutation,
unauth_can_execute_query,
unauth_can_execute_mutation].

error_handling() ->
[should_catch_parsing_errors,
should_catch_type_check_params_errors,
should_catch_type_check_errors].

permissions() ->
[check_object_permissions,
check_field_permissions,
check_child_object_permissions,
check_child_object_field_permissions,
check_fragment_permissions,
check_interface_permissions,
check_interface_field_permissions,
check_inline_fragment_permissions,
check_union_permissions
].

init_per_testcase(C, Config) when C =:= auth_can_execute_protected_query;
C =:= auth_can_execute_protected_mutation;
Expand All @@ -54,6 +79,19 @@ init_per_testcase(C, Config) when C =:= can_execute_query_with_vars;
{ok, _} = mongoose_graphql:create_endpoint(C, Mapping, [Pattern]),
Ep = mongoose_graphql:get_endpoint(C),
[{endpoint, Ep} | Config];
init_per_testcase(C, Config) when C =:= check_object_permissions;
C =:= check_field_permissions;
C =:= check_child_object_permissions;
C =:= check_child_object_field_permissions;
C =:= check_fragment_permissions;
C =:= check_interface_permissions;
C =:= check_interface_field_permissions;
C =:= check_inline_fragment_permissions;
C =:= check_union_permissions ->
{Mapping, Pattern} = example_permissions_schema_data(Config),
{ok, _} = mongoose_graphql:create_endpoint(C, Mapping, [Pattern]),
Ep = mongoose_graphql:get_endpoint(C),
[{endpoint, Ep} | Config];
init_per_testcase(C, Config) ->
[{endpoint_name, C} | Config].

Expand Down Expand Up @@ -194,8 +232,75 @@ should_catch_type_check_params_errors(Config) ->
Res = mongoose_graphql:execute(Ep, request(Doc, false)),
?assertMatch({error, _}, Res).

check_object_permissions(Config) ->
Doc = <<"query { field }">>,
FDoc = <<"mutation { field }">>,
?assertPermissionsSuccess(Config, Doc),
?assertPermissionsFailed(Config, FDoc).

check_field_permissions(Config) ->
Doc = <<"{ field protectedField }">>,
?assertPermissionsFailed(Config, Doc).

check_child_object_permissions(Config) ->
Doc = <<"{ protectedObj{ type } }">>,
?assertPermissionsFailed(Config, Doc).

check_child_object_field_permissions(Config) ->
Doc = <<"{ obj { field } }">>,
FDoc = <<"{ obj { field protectedField } }">>,
?assertPermissionsSuccess(Config, Doc),
?assertPermissionsFailed(Config, FDoc).

check_fragment_permissions(Config) ->
Config2 = [{op, <<"Q1">>} | Config],
Doc = <<"query Q1{ obj { ...body } } fragment body on Object { name field }">>,
FDoc = <<"query Q1{ obj { ...body } } fragment body on Object { name field protectedField }">>,
?assertPermissionsSuccess(Config2, Doc),
?assertPermissionsFailed(Config2, FDoc).

check_interface_permissions(Config) ->
Doc = <<"{ interface { name } }">>,
FDoc = <<"{ protInterface { name } }">>,
?assertPermissionsSuccess(Config, Doc),
?assertPermissionsFailed(Config, FDoc).

check_interface_field_permissions(Config) ->
Doc = <<"{ interface { protectedName } }">>,
FieldProtectedNotEnaugh = <<"{ obj { protectedName } }">>,
FieldProtectedEnaugh = <<"{ obj { otherName } }">>,
% field is protected in interface and object, so cannnot be accessed.
?assertPermissionsFailed(Config, Doc),
?assertPermissionsFailed(Config, FieldProtectedEnaugh),
% field is protected only in interface, so can by accessed from implementing objects.
?assertPermissionsSuccess(Config, FieldProtectedNotEnaugh).

check_inline_fragment_permissions(Config) ->
Doc = <<"{ interface { name otherName ... on Object { field } } }">>,
FDoc = <<"{ interface { name otherName ... on Object { field protectedField } } }">>,
FDoc2 = <<"{ interface { name ... on Object { field otherName} } }">>,
?assertPermissionsSuccess(Config, Doc),
?assertPermissionsFailed(Config, FDoc),
?assertPermissionsFailed(Config, FDoc2).

check_union_permissions(Config) ->
Doc = <<"{ union { ... on O1 { field1 } } }">>,
FDoc = <<"{ union { ... on O1 { field1 field1Protected } } }">>,
FDoc2 = <<"{ union { ... on O1 { field1 } ... on O2 { field2 } } }">>,
?assertPermissionsSuccess(Config, Doc),
?assertPermissionsFailed(Config, FDoc),
?assertPermissionsFailed(Config, FDoc2).

%% Helpers

check_permissions(Config, Doc) ->
Ep = ?config(endpoint, Config),
Op = proplists:get_value(op, Config, undefined),
{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).

request(Doc, Authorized) ->
#{document => Doc,
operation_name => undefined,
Expand Down Expand Up @@ -230,3 +335,14 @@ example_schema_data(Config) ->
'UserMutation' => mongoose_graphql_default_resolver,
default => mongoose_graphql_default_resolver}},
{Mapping, Pattern}.

example_permissions_schema_data(Config) ->
Pattern = filename:join([proplists:get_value(data_dir, Config), "permissions_schema.gql"]),
Mapping =
#{objects =>
#{'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}},
{Mapping, Pattern}.
Loading

0 comments on commit c218659

Please sign in to comment.