1 |
|
%% @doc Checks if a requested query can be executed with provided permissions. |
2 |
|
%% |
3 |
|
%% GraphQL has directives that allow attaching additional information to schema, |
4 |
|
%% to objects, to fields, and more. The custom directive `@protected' is created |
5 |
|
%% to mark which objects or fields could be accessed only by an authorized request. |
6 |
|
%% This module analyzes the AST and tries to find if there is at least one protected |
7 |
|
%% resource. The `@protected' directive can be attached to <b>field definitions</b> |
8 |
|
%% to <b>objects</b>, or to <b>interfaces</b>. |
9 |
|
%% |
10 |
|
%% Interfaces and objects permissions are checked independently. This means that when |
11 |
|
%% an interface is protected or has protected fields, then all implementing objects |
12 |
|
%% should be protected or have the same fields protected. <strong>This demands to mark all |
13 |
|
%% protected resources at every occurrence with the directive</strong>. Otherwise permissions |
14 |
|
%% will be different for interface and implementing objects. |
15 |
|
%% |
16 |
|
%% If an unauthorized request wants to execute a query that contains protected resources, |
17 |
|
%% an error is thrown. |
18 |
|
%% |
19 |
|
%% Directives can have arguments, so if needed this functionality can be easily |
20 |
|
%% extended. For example, to allow access to resources only to the user that belongs |
21 |
|
%% to a specific group. |
22 |
|
%% @end |
23 |
|
-module(mongoose_graphql_permissions). |
24 |
|
|
25 |
|
-export([check_permissions/2]). |
26 |
|
|
27 |
|
-include_lib("graphql/src/graphql_schema.hrl"). |
28 |
|
-include_lib("graphql/src/graphql_internal.hrl"). |
29 |
|
-include_lib("graphql/include/graphql.hrl"). |
30 |
|
-include_lib("jid/include/jid.hrl"). |
31 |
|
|
32 |
|
-type auth_status() :: boolean(). |
33 |
|
-type auth_role() :: user | admin | domain_admin. |
34 |
|
-type params() :: map(). |
35 |
|
-type auth_ctx() :: #{operation_name := binary(), |
36 |
|
params := params(), |
37 |
|
authorized := auth_status(), |
38 |
|
authorized_as => auth_role(), |
39 |
|
user => jid:jid(), |
40 |
|
admin => jid:jid(), |
41 |
|
atom() => any()}. |
42 |
|
-type no_access_info() :: #{path := [binary()], |
43 |
|
type := atom(), |
44 |
|
invalid_args => [binary()]}. |
45 |
|
-type field_check_result() :: ok | no_access_info(). |
46 |
|
-type document() :: #document{}. |
47 |
|
-type definitions() :: [any()]. |
48 |
|
|
49 |
|
%% @doc Checks if query can be executed by unauthorized request or authorized as one |
50 |
|
%% of the roles (USER, ADMIN, DOMAIN_ADMIN). If not, throw an error. |
51 |
|
%% |
52 |
|
%% The USER and ADMIN can execute each query because they are on separated GraphQL |
53 |
|
%% instances that serves different queries. |
54 |
|
%% |
55 |
|
%% The DOMAIN_ADMIN use the same GraphQL instance as ADMIN, but have permissions |
56 |
|
%% only to administrate own domain. |
57 |
|
%% @end |
58 |
|
-spec check_permissions(auth_ctx(), document()) -> ok. |
59 |
|
check_permissions(#{operation_name := OpName, authorized := false}, |
60 |
|
#document{definitions = Definitions}) -> |
61 |
3 |
check_unauthorized_request_permissions(OpName, Definitions); |
62 |
|
check_permissions(#{operation_name := OpName, authorized_as := domain_admin, |
63 |
|
admin := #jid{lserver = Domain}, params := Params}, |
64 |
|
#document{definitions = Definitions}) -> |
65 |
1 |
check_domain_authorized_request_permissions(OpName, Domain, Params, Definitions); |
66 |
|
check_permissions(#{authorized := true}, _) -> |
67 |
373 |
ok. |
68 |
|
|
69 |
|
-spec check_unauthorized_request_permissions(binary(), definitions()) -> ok. |
70 |
|
check_unauthorized_request_permissions(OpName, Definitions) -> |
71 |
3 |
Op = lists:filter(fun(D) -> is_req_operation(D, OpName) end, Definitions), |
72 |
3 |
case Op of |
73 |
|
[#op{schema = Schema, selection_set = Set} = Op1] -> |
74 |
3 |
case is_object_protected(Schema, Set, Definitions) of |
75 |
|
true -> |
76 |
|
% Seems that the introspection fields belong to the query object. |
77 |
|
% When an object is protected we need to ensure that the request |
78 |
|
% query contains only introspection fields to execute it without |
79 |
|
% authorization. Otherwise, a user couldn't access documentation |
80 |
|
% without logging in. |
81 |
:-( |
case is_introspection_op(Op1) of |
82 |
|
true -> |
83 |
:-( |
ok; |
84 |
|
false -> |
85 |
:-( |
OpName2 = op_name(OpName), |
86 |
:-( |
graphql_err:abort([OpName2], authorize, {no_permissions, OpName2}) |
87 |
|
end; |
88 |
|
false -> |
89 |
3 |
ok |
90 |
|
end; |
91 |
|
_ -> |
92 |
:-( |
ok |
93 |
|
end. |
94 |
|
|
95 |
|
-spec check_domain_authorized_request_permissions(binary(), binary(), |
96 |
|
params(), definitions()) -> ok. |
97 |
|
check_domain_authorized_request_permissions(OpName, Domain, Params, Definitions) -> |
98 |
1 |
Op = lists:filter(fun(D) -> is_req_operation(D, OpName) end, Definitions), |
99 |
1 |
case Op of |
100 |
|
[#op{selection_set = Set}] -> |
101 |
1 |
case check_fields(#{domain => Domain}, Params, Set) of |
102 |
|
ok -> |
103 |
1 |
ok; |
104 |
|
#{path := Path} = NoAccessInfo -> |
105 |
:-( |
OpName2 = op_name(OpName), |
106 |
:-( |
Error = {no_permissions, OpName2, NoAccessInfo}, |
107 |
:-( |
Path2 = lists:reverse([OpName2 | Path]), |
108 |
:-( |
graphql_err:abort(Path2, authorize, Error) |
109 |
|
end; |
110 |
|
_ -> |
111 |
:-( |
ok |
112 |
|
end. |
113 |
|
|
114 |
|
% Internal |
115 |
|
|
116 |
|
-spec check_fields(map(), map(), [any()]) -> field_check_result(). |
117 |
|
check_fields(Ctx, Params, Fields) -> |
118 |
5 |
Fun = fun(F, ok) -> check_field(F, Ctx, Params); |
119 |
:-( |
(_, NoAccessInfo) -> NoAccessInfo |
120 |
|
end, |
121 |
5 |
lists:foldl(Fun, ok, Fields). |
122 |
|
|
123 |
|
-spec check_field(field() | any(), map(), map()) -> field_check_result(). |
124 |
|
check_field(#field{id = Name, selection_set = Set, args = Args, |
125 |
|
schema = #schema_field{directives = Directives}}, Ctx, Params) -> |
126 |
4 |
Args2 = maps:from_list([prepare_arg(ArgName, Type, Params) || {ArgName, Type} <- Args]), |
127 |
4 |
Res = check_field_args(Ctx, Args2, Directives), |
128 |
4 |
Res2 = check_field_type(Res, Ctx, Params, Set), |
129 |
4 |
add_path(Res2, name(Name)); |
130 |
:-( |
check_field(_, _, _) -> ok. |
131 |
|
|
132 |
|
-spec check_field_args(map(), map(), [graphql:directive()]) -> field_check_result(). |
133 |
|
check_field_args(Ctx, Args, Directives) -> |
134 |
4 |
case lists:filter(fun is_protected_directive/1, Directives) of |
135 |
|
[#directive{} = Dir] -> |
136 |
:-( |
#{type := {enum, Type}, args := PArgs} = protected_dir_args_to_map(Dir), |
137 |
:-( |
check_field_args(Type, Ctx, PArgs, Args); |
138 |
|
[] -> |
139 |
4 |
ok |
140 |
|
end. |
141 |
|
|
142 |
|
-spec check_field_args(binary(), map(), [binary()], map()) -> field_check_result(). |
143 |
|
check_field_args(<<"DOMAIN">>, #{domain := Domain}, ProtectedArgs, Args) -> |
144 |
:-( |
case lists:filter(fun(N) -> not arg_eq(get_arg(N, Args), Domain) end, ProtectedArgs) of |
145 |
|
[] -> |
146 |
:-( |
ok; |
147 |
|
InvalidArgs -> |
148 |
:-( |
#{type => domain, path => [], invalid_args => InvalidArgs} |
149 |
|
end; |
150 |
|
check_field_args(<<"GLOBAL">>, #{domain := _}, _, _Args) -> |
151 |
:-( |
#{type => global, path => []}; |
152 |
|
check_field_args(<<"DEFAULT">>, _Ctx, _ProtectedArgs, _Args) -> |
153 |
:-( |
ok. |
154 |
|
|
155 |
|
-spec check_field_type(field_check_result(), map(), map(), [any()]) -> field_check_result(). |
156 |
|
check_field_type(ok, Ctx, Params, Set) -> |
157 |
4 |
check_fields(Ctx, Params, Set); |
158 |
|
check_field_type(NoAccessInfo, _, _, _) -> |
159 |
:-( |
NoAccessInfo. |
160 |
|
|
161 |
|
prepare_arg(ArgName, #{value := #var{id = Name}}, Vars) -> |
162 |
:-( |
{ArgName, maps:get(name(Name), Vars)}; |
163 |
|
prepare_arg(ArgName, #{value := Val}, _) -> |
164 |
:-( |
{ArgName, Val}. |
165 |
|
|
166 |
|
get_arg(Name, Args) when is_binary(Name)-> |
167 |
:-( |
Path = binary:split(Name, <<".">>, [global]), |
168 |
:-( |
get_arg(Path, Args); |
169 |
:-( |
get_arg([], Value) -> Value; |
170 |
:-( |
get_arg(_, undefined) -> undefined; |
171 |
|
get_arg(Path, List) when is_list(List) -> |
172 |
:-( |
[get_arg(Path, ArgsMap) || ArgsMap <- List]; |
173 |
|
get_arg([Name | Path], ArgsMap) -> |
174 |
:-( |
get_arg(Path, maps:get(Name, ArgsMap, undefined)). |
175 |
|
|
176 |
|
arg_eq(Args, Domain) when is_list(Args) -> |
177 |
:-( |
lists:all(fun(Arg) -> arg_eq(Arg, Domain) end, Args); |
178 |
|
arg_eq(Domain, Domain) -> |
179 |
:-( |
true; |
180 |
|
arg_eq(Subdomain, Domain) when is_binary(Subdomain), is_binary(Domain) -> |
181 |
:-( |
check_subdomain(Subdomain, Domain); |
182 |
|
arg_eq(#jid{lserver = Domain1}, Domain2) -> |
183 |
:-( |
arg_eq(Domain1, Domain2); |
184 |
|
arg_eq(undefined, _) -> |
185 |
|
% The arg is optional, and the value is not present, so we assume that |
186 |
|
% the domain admin has access. |
187 |
:-( |
true; |
188 |
|
arg_eq(_, _) -> |
189 |
:-( |
false. |
190 |
|
|
191 |
|
check_subdomain(Subdomain, Domain) -> |
192 |
:-( |
case mongoose_domain_api:get_subdomain_info(Subdomain) of |
193 |
:-( |
{ok, #{parent_domain := ParentDomain}} -> ParentDomain =:= Domain; |
194 |
:-( |
{error, not_found} -> false |
195 |
|
end. |
196 |
|
|
197 |
4 |
add_path(ok, _) -> ok; |
198 |
|
add_path(#{path := Path} = Acc, FieldName) -> |
199 |
:-( |
Acc#{path => [FieldName | Path]}. |
200 |
|
|
201 |
|
protected_dir_args_to_map(#directive{args = Args}) -> |
202 |
:-( |
Default = #{type => {enum, <<"DEFAULT">>}, args => []}, |
203 |
:-( |
ArgsMap = maps:from_list([{binary_to_atom(name(N)), V} || {N, V} <- Args]), |
204 |
:-( |
maps:merge(Default, ArgsMap). |
205 |
|
|
206 |
4 |
name({name, _, N}) -> N; |
207 |
:-( |
name(N) when is_binary(N) -> N. |
208 |
|
|
209 |
|
op_name(undefined) -> |
210 |
:-( |
<<"ROOT">>; |
211 |
|
op_name(Name) -> |
212 |
:-( |
Name. |
213 |
|
|
214 |
|
is_req_operation(#op{id = 'ROOT'}, undefined) -> |
215 |
4 |
true; |
216 |
|
is_req_operation(#op{id = {name, _, Name}}, Name) -> |
217 |
:-( |
true; |
218 |
|
is_req_operation(_, _) -> |
219 |
:-( |
false. |
220 |
|
|
221 |
|
is_protected_directive(#directive{id = {name, _, <<"protected">>}}) -> |
222 |
:-( |
true; |
223 |
|
is_protected_directive(_) -> |
224 |
:-( |
false. |
225 |
|
|
226 |
|
is_introspection_op(#op{selection_set = Set}) -> |
227 |
:-( |
lists:all(fun is_introspection_field/1, Set). |
228 |
|
|
229 |
|
is_introspection_field(#field{id = {name, _, <<"__schema">>}}) -> |
230 |
:-( |
true; |
231 |
|
is_introspection_field(#field{id = {name, _, <<"__type">>}}) -> |
232 |
:-( |
true; |
233 |
|
is_introspection_field(_) -> |
234 |
:-( |
false. |
235 |
|
|
236 |
|
is_object_protected(_, [], _) -> |
237 |
8 |
false; |
238 |
|
is_object_protected(#schema_field{ty = Ty}, Set, Definitions) -> |
239 |
3 |
is_object_protected(Ty, Set, Definitions); |
240 |
|
is_object_protected({non_null, Obj}, Set, Definitions) -> |
241 |
:-( |
is_object_protected(Obj, Set, Definitions); |
242 |
|
is_object_protected({list, Obj}, Set, Definitions) -> |
243 |
:-( |
is_object_protected(Obj, Set, Definitions); |
244 |
|
is_object_protected(Object, Set, Definitions) -> |
245 |
6 |
case is_object_protected(Object) of |
246 |
|
false -> |
247 |
6 |
lists:any(fun(S) -> is_field_protected(Object, S, Definitions) end, Set); |
248 |
|
true -> |
249 |
:-( |
true |
250 |
|
end. |
251 |
|
|
252 |
|
is_object_protected(#interface_type{directives = Directives}) -> |
253 |
:-( |
lists:any(fun is_protected_directive/1, Directives); |
254 |
|
is_object_protected(#object_type{directives = Directives}) -> |
255 |
6 |
lists:any(fun is_protected_directive/1, Directives); |
256 |
|
is_object_protected(_) -> |
257 |
:-( |
false. |
258 |
|
|
259 |
|
is_field_protected(_, #frag_spread{id = {name, _, Name}}, Definitions) -> |
260 |
:-( |
[#frag{schema = Schema, selection_set = Set}] = |
261 |
:-( |
lists:filter(fun(#frag{id = {name, _, Name2}}) -> Name == Name2; |
262 |
:-( |
(_) -> false end, Definitions), |
263 |
:-( |
is_object_protected(Schema, Set, Definitions); |
264 |
|
is_field_protected(_, #frag{schema = Object, selection_set = Set}, Definitions) -> |
265 |
:-( |
is_object_protected(Object, Set, Definitions); |
266 |
|
is_field_protected(Parent, |
267 |
|
#field{id = {name, _, Name}, schema = Object, selection_set = Set}, |
268 |
|
Definitions) -> |
269 |
11 |
{ok, #schema_field{directives = Directives}} = maps:find(Name, fields(Parent)), |
270 |
11 |
case lists:any(fun is_protected_directive/1, Directives) of |
271 |
|
false -> |
272 |
11 |
is_object_protected(Object, Set, Definitions); |
273 |
|
true -> |
274 |
:-( |
true |
275 |
|
end. |
276 |
|
|
277 |
11 |
fields(#object_type{fields = Fields}) -> Fields; |
278 |
:-( |
fields(#interface_type{fields = Fields}) -> Fields. |