bouncer/test/bouncer_tests_SUITE.erl

608 lines
17 KiB
Erlang
Raw Normal View History

-module(bouncer_tests_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("stdlib/include/assert.hrl").
-export([all/0]).
-export([groups/0]).
-export([init_per_suite/1]).
-export([end_per_suite/1]).
-export([init_per_group/2]).
-export([end_per_group/2]).
-export([init_per_testcase/2]).
-export([end_per_testcase/2]).
-export([missing_ruleset_notfound/1]).
-export([incorrect_ruleset_invalid/1]).
-export([missing_content_invalid_context/1]).
-export([junk_content_invalid_context/1]).
-export([conflicting_context_invalid/1]).
-export([distinct_sets_context_valid/1]).
-export([allowed_create_invoice_shop_manager/1]).
-export([forbidden_w_empty_context/1]).
-export([forbidden_expired/1]).
-export([forbidden_blacklisted_ip/1]).
-export([connect_failed_means_unavailable/1]).
-export([connect_timeout_means_unavailable/1]).
-export([request_timeout_means_unknown/1]).
-behaviour(bouncer_arbiter_pulse).
-export([handle_beat/3]).
-include_lib("bouncer_proto/include/bouncer_decisions_thrift.hrl").
-type config() :: [{atom(), term()}].
-type group_name() :: atom().
-type test_case_name() :: atom().
-define(CONFIG(Key, C), (element(2, lists:keyfind(Key, 1, C)))).
%%
-define(OPA_HOST, "opa").
-define(OPA_ENDPOINT, {?OPA_HOST, 8181}).
-define(API_RULESET_ID, "authz/api").
-spec all() ->
[atom()].
all() ->
[
{group, general},
{group, rules_authz_api},
{group, network_error_mapping}
].
-spec groups() ->
[{group_name(), list(), [test_case_name()]}].
groups() ->
[
{general, [parallel], [
missing_ruleset_notfound,
incorrect_ruleset_invalid,
missing_content_invalid_context,
junk_content_invalid_context,
conflicting_context_invalid,
distinct_sets_context_valid
]},
{rules_authz_api, [parallel], [
allowed_create_invoice_shop_manager,
forbidden_expired,
forbidden_blacklisted_ip,
forbidden_w_empty_context
]},
{network_error_mapping, [], [
connect_failed_means_unavailable,
connect_timeout_means_unavailable,
request_timeout_means_unknown
]}
].
-spec init_per_suite(config()) ->
config().
init_per_suite(C) ->
Apps =
genlib_app:start_application(woody) ++
genlib_app:start_application_with(scoper, [
{storage, scoper_storage_logger}
]),
[{suite_apps, Apps} | C].
-spec end_per_suite(config()) ->
ok.
end_per_suite(C) ->
genlib_app:stop_unload_applications(?CONFIG(suite_apps, C)).
-spec init_per_group(group_name(), config()) ->
config().
init_per_group(Name, C) when
Name == general;
Name == rules_authz_api
->
start_bouncer([], [{groupname, Name} | C]);
init_per_group(Name, C) ->
[{groupname, Name} | C].
start_bouncer(Env, C) ->
IP = "127.0.0.1",
Port = 8022,
ArbiterPath = <<"/v1/arbiter">>,
{ok, StashPid} = ct_stash:start(),
Apps = genlib_app:start_application_with(bouncer, [
{ip, IP},
{port, Port},
{services, #{
arbiter => #{
path => ArbiterPath,
pulse => {?MODULE, StashPid}
}
}},
{transport_opts, #{
max_connections => 1000,
num_acceptors => 4
}},
{opa, #{
endpoint => ?OPA_ENDPOINT,
transport => tcp
}}
] ++ Env),
Services = #{
arbiter => mk_url(IP, Port, ArbiterPath)
},
[{group_apps, Apps}, {service_urls, Services}, {stash, StashPid} | C].
mk_url(IP, Port, Path) ->
iolist_to_binary(["http://", IP, ":", genlib:to_binary(Port), Path]).
-spec end_per_group(group_name(), config()) ->
_.
end_per_group(_Name, C) ->
stop_bouncer(C).
stop_bouncer(C) ->
with_config(stash, C, fun (Pid) -> ?assertEqual(ok, ct_stash:destroy(Pid)) end),
with_config(group_apps, C, fun (Apps) -> genlib_app:stop_unload_applications(Apps) end).
-spec init_per_testcase(atom(), config()) ->
config().
init_per_testcase(Name, C) ->
[{testcase, Name} | C].
-spec end_per_testcase(atom(), config()) ->
config().
end_per_testcase(_Name, _C) ->
ok.
%%
-define(CONTEXT(Fragments), #bdcs_Context{fragments = Fragments}).
-define(JUDGEMENT(Resolution), #bdcs_Judgement{resolution = Resolution}).
-spec missing_ruleset_notfound(config()) -> ok.
-spec incorrect_ruleset_invalid(config()) -> ok.
-spec missing_content_invalid_context(config()) -> ok.
-spec junk_content_invalid_context(config()) -> ok.
-spec conflicting_context_invalid(config()) -> ok.
-spec distinct_sets_context_valid(config()) -> ok.
missing_ruleset_notfound(C) ->
Client = mk_client(C),
MissingRulesetID = "missing_ruleset",
?assertThrow(
#bdcs_RulesetNotFound{},
call_judge(MissingRulesetID, ?CONTEXT(#{}), Client)
),
?assertMatch(
{judgement, {failed, ruleset_notfound}},
lists:last(flush_beats(Client, C))
).
incorrect_ruleset_invalid(C) ->
Client1 = mk_client(C),
?assertThrow(
#bdcs_InvalidRuleset{},
call_judge("trivial/incorrect1", ?CONTEXT(#{}), Client1)
),
?assertMatch(
{judgement, {failed, {ruleset_invalid, [
{data_invalid, _, no_extra_properties_allowed, _, [<<"fordibben">>]}
]}}},
lists:last(flush_beats(Client1, C))
),
Client2 = mk_client(C),
?assertThrow(
#bdcs_InvalidRuleset{},
call_judge("trivial/incorrect2", ?CONTEXT(#{}), Client2)
),
?assertMatch(
{judgement, {failed, {ruleset_invalid, [
{data_invalid, _, wrong_type, _, [<<"allowed">>]}
]}}},
lists:last(flush_beats(Client2, C))
).
missing_content_invalid_context(C) ->
Client = mk_client(C),
NoContentFragment = #bctx_ContextFragment{type = v1_thrift_binary},
Context = ?CONTEXT(#{<<"missing">> => NoContentFragment}),
?assertThrow(
#bdcs_InvalidContext{},
call_judge(?API_RULESET_ID, Context, Client)
),
?assertMatch(
{judgement, {failed, {malformed_context, #{
<<"missing">> := {v1_thrift_binary, {unexpected, _, _, _}}
}}}},
lists:last(flush_beats(Client, C))
).
junk_content_invalid_context(C) ->
Client = mk_client(C),
Junk = <<"STOP RIGHT THERE YOU CRIMINAL SCUM!">>,
JunkFragment = #bctx_ContextFragment{type = v1_thrift_binary, content = Junk},
Context = ?CONTEXT(#{<<"missing">> => JunkFragment}),
?assertThrow(
#bdcs_InvalidContext{},
call_judge(?API_RULESET_ID, Context, Client)
),
?assertMatch(
{judgement, {failed, {malformed_context, #{
<<"missing">> := {v1_thrift_binary, {unexpected, _, _, _}}
}}}},
lists:last(flush_beats(Client, C))
).
conflicting_context_invalid(C) ->
Client = mk_client(C),
Fragment1 = #{
user => #{
id => <<"joeblow">>,
email => Email1 = <<"deadinside69@example.org">>
},
requester => #{
ip => <<"1.2.3.4">>
}
},
Fragment2 = #{
user => #{
id => <<"joeblow">>,
email => <<"deadinside420@example.org">>
},
requester => #{
ip => <<"1.2.3.4">>
}
},
Context = ?CONTEXT(#{
<<"frag1">> => mk_ctx_v1_fragment(Fragment1),
<<"frag2">> => mk_ctx_v1_fragment(Fragment2)
}),
?assertThrow(
#bdcs_InvalidContext{},
call_judge(?API_RULESET_ID, Context, Client)
),
?assertEqual(
{judgement, {failed, {conflicting_context, #{
<<"frag2">> => #{user => #{email => Email1}}
}}}},
lists:last(flush_beats(Client, C))
).
distinct_sets_context_valid(C) ->
Client = mk_client(C),
Fragment1 = #{
user => #{
id => <<"joeblow">>,
orgs => mk_ordset([
#{
id => <<"hoolie">>,
roles => mk_ordset([#{id => <<"Administrator">>}])
},
#{
id => <<"weewrok">>,
roles => mk_ordset([#{id => <<"Administrator">>}])
}
])
}
},
Fragment2 = #{
user => #{
id => <<"joeblow">>,
orgs => mk_ordset([
#{
id => <<"hoolie">>,
roles => mk_ordset([#{id => <<"Nobody">>}])
},
#{
id => <<"blooply">>,
roles => mk_ordset([#{id => <<"Nobody">>}])
}
])
}
},
Context = ?CONTEXT(#{
<<"frag1">> => mk_ctx_v1_fragment(Fragment1),
<<"frag2">> => mk_ctx_v1_fragment(Fragment2)
}),
?assertMatch(
#bdcs_Judgement{},
call_judge(?API_RULESET_ID, Context, Client)
),
?assertMatch(
{judgement, {completed, _}},
lists:last(flush_beats(Client, C))
).
%%
-spec allowed_create_invoice_shop_manager(config()) -> ok.
-spec forbidden_expired(config()) -> ok.
-spec forbidden_blacklisted_ip(config()) -> ok.
-spec forbidden_w_empty_context(config()) -> ok.
allowed_create_invoice_shop_manager(C) ->
Client = mk_client(C),
Fragment = lists:foldl(fun maps:merge/2, #{}, [
mk_auth_session_token(),
mk_env(),
mk_op_create_invoice(<<"BLARG">>, <<"SHOP">>, <<"PARTY">>),
mk_user(<<"USER">>, mk_ordset([
mk_user_org(<<"PARTY">>, <<"OWNER">>, mk_ordset([
mk_role(<<"Manager">>, <<"SHOP">>)
]))
]))
]),
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
?assertMatch(
?JUDGEMENT(allowed),
call_judge(?API_RULESET_ID, Context, Client)
),
?assertMatch(
{judgement, {completed, {allowed, [{<<"user_has_role">>, _}]}}},
lists:last(flush_beats(Client, C))
).
forbidden_expired(C) ->
Client = mk_client(C),
% Would be funny if this fails on some system too deep in the past.
Fragment = maps:merge(mk_env(), #{
auth => #{
method => <<"AccessToken">>,
expiration => <<"1991-12-26T17:00:00Z">> % ☭😢
}
}),
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
?assertMatch(
?JUDGEMENT(forbidden),
call_judge(?API_RULESET_ID, Context, Client)
),
?assertMatch(
{judgement, {completed, {forbidden, [{<<"auth_expired">>, _}]}}},
lists:last(flush_beats(Client, C))
).
forbidden_blacklisted_ip(C) ->
Client = mk_client(C),
Fragment = lists:foldl(fun maps:merge/2, #{}, [
mk_auth_session_token(),
mk_env(),
% See test/policies/authz/blacklists/source-ip-range/data.json#L42
#{requester => #{ip => <<"91.41.147.55">>}}
]),
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
?assertMatch(
?JUDGEMENT(forbidden),
call_judge(?API_RULESET_ID, Context, Client)
),
?assertMatch(
{judgement, {completed, {forbidden, [{<<"ip_range_blacklisted">>, _}]}}},
lists:last(flush_beats(Client, C))
).
forbidden_w_empty_context(C) ->
Client1 = mk_client(C),
EmptyFragment = mk_ctx_v1_fragment(#{}),
?assertMatch(
?JUDGEMENT(forbidden),
call_judge(?API_RULESET_ID, ?CONTEXT(#{}), Client1)
),
?assertMatch(
{judgement, {completed, {forbidden, [{<<"auth_required">>, _}]}}},
lists:last(flush_beats(Client1, C))
),
Client2 = mk_client(C),
?assertMatch(
?JUDGEMENT(forbidden),
call_judge(?API_RULESET_ID, ?CONTEXT(#{<<"empty">> => EmptyFragment}), Client2)
),
?assertMatch(
{judgement, {completed, {forbidden, [{<<"auth_required">>, _}]}}},
lists:last(flush_beats(Client2, C))
).
mk_user(UserID, UserOrgs) ->
#{user => #{
id => UserID,
orgs => UserOrgs
}}.
mk_user_org(OrgID, OwnerID, Roles) ->
#{
id => OrgID,
owner => #{id => OwnerID},
roles => Roles
}.
mk_role(RoleID, ShopID) ->
#{id => RoleID, scope => #{shop => #{id => ShopID}}}.
mk_auth_session_token() ->
mk_auth_session_token(erlang:system_time(second) + 3600).
mk_auth_session_token(ExpiresAt) ->
#{auth => #{
method => <<"SessionToken">>,
expiration => format_ts(ExpiresAt, second)
}}.
mk_op_create_invoice(InvoiceID, ShopID, PartyID) ->
#{capi => #{
op => #{
id => <<"CreateInvoice">>,
invoice => #{id => InvoiceID},
shop => #{id => ShopID},
party => #{id => PartyID}
}
}}.
mk_env() ->
#{env => #{
now => format_now()
}}.
format_now() ->
USec = erlang:system_time(second),
format_ts(USec, second).
format_ts(Ts, Unit) ->
Str = calendar:system_time_to_rfc3339(Ts, [{unit, Unit}, {offset, "Z"}]),
erlang:list_to_binary(Str).
%%
-spec connect_failed_means_unavailable(config()) -> ok.
-spec connect_timeout_means_unavailable(config()) -> ok.
-spec request_timeout_means_unknown(config()) -> ok.
connect_failed_means_unavailable(C) ->
C1 = start_bouncer([{opa, #{
endpoint => {?OPA_HOST, 65535},
transport => tcp,
event_handler => {ct_gun_event_h, []}
}}], C),
Client = mk_client(C1),
try
?assertError(
{woody_error, {external, resource_unavailable, _}},
call_judge(?API_RULESET_ID, ?CONTEXT(#{}), Client)
),
?assertMatch(
[
{judgement, started},
{judgement, {failed, {unavailable, {down, {shutdown, econnrefused}}}}}
],
flush_beats(Client, C1)
)
after
stop_bouncer(C1)
end.
connect_timeout_means_unavailable(C) ->
{ok, Proxy} = ct_proxy:start_link(?OPA_ENDPOINT, #{listen => ignore}),
C1 = start_proxy_bouncer(Proxy, C),
Client = mk_client(C1),
try
?assertError(
% NOTE
% Turns out it's somewhat hard to simulate connection timeout when connecting to the
% localhost. This is why we expect `result_unknown` here instead of
% `resource_unavailable`.
{woody_error, {external, result_unknown, _}},
call_judge(?API_RULESET_ID, ?CONTEXT(#{}), Client)
),
?assertMatch(
[
{judgement, started},
{judgement, {failed, {unknown, timeout}}}
],
flush_beats(Client, C1)
)
after
stop_bouncer(C1)
end.
request_timeout_means_unknown(C) ->
{ok, Proxy} = ct_proxy:start_link(?OPA_ENDPOINT),
C1 = start_proxy_bouncer(Proxy, C),
Client = mk_client(C1),
ok = change_proxy_mode(Proxy, connection, ignore, C1),
try
?assertError(
{woody_error, {external, result_unknown, _}},
call_judge(?API_RULESET_ID, ?CONTEXT(#{}), Client)
),
?assertMatch(
[
{judgement, started},
{judgement, {failed, {unknown, timeout}}}
],
flush_beats(Client, C1)
)
after
stop_bouncer(C1)
end.
start_proxy_bouncer(Proxy, C) ->
start_bouncer([{opa, #{
endpoint => ct_proxy:endpoint(Proxy),
transport => tcp,
event_handler => {ct_gun_event_h, []}
}}], C).
change_proxy_mode(Proxy, Scope, Mode, C) ->
ModeWas = ct_proxy:mode(Proxy, Scope, Mode),
_ = ct:pal(debug, "[~p] set proxy ~p from '~p' to '~p'",
[?CONFIG(testcase, C), Scope, ModeWas, Mode]),
ok.
%%
mk_ordset(L) ->
ordsets:from_list(L).
mk_ctx_v1_fragment(Context) ->
{ok, Content} = bouncer_context_v1:encode(thrift, Context),
#bctx_ContextFragment{type = v1_thrift_binary, content = Content}.
%%
mk_client(C) ->
WoodyCtx = woody_context:new(genlib:to_binary(?CONFIG(testcase, C))),
ServiceURLs = ?CONFIG(service_urls, C),
{WoodyCtx, ServiceURLs}.
call_judge(RulesetID, Context, Client) ->
call(arbiter, 'Judge', {genlib:to_binary(RulesetID), Context}, Client).
call(ServiceName, Fn, Args, {WoodyCtx, ServiceURLs}) ->
Service = get_service_spec(ServiceName),
Opts = #{
url => maps:get(ServiceName, ServiceURLs),
event_handler => scoper_woody_event_handler
},
case woody_client:call({Service, Fn, Args}, Opts, WoodyCtx) of
{ok, Response} ->
Response;
{exception, Exception} ->
throw(Exception)
end.
get_service_spec(arbiter) ->
{bouncer_decisions_thrift, 'Arbiter'}.
%%
-spec handle_beat(bouncer_arbiter_pulse:beat(), bouncer_arbiter_pulse:metadata(), pid()) ->
ok.
handle_beat(Beat, Metadata, StashPid) ->
_ = stash_beat(Beat, Metadata, StashPid),
ct:pal("~p [arbiter] ~0p:~nmetadata=~p", [self(), Beat, Metadata]).
%%
stash_beat(Beat, Metadata = #{woody_ctx := WoodyCtx}, StashPid) ->
ct_stash:append(StashPid, get_trace_id(WoodyCtx), {Beat, Metadata}).
flush_beats({WoodyCtx, _}, C) ->
StashPid = ?CONFIG(stash, C),
{ok, Entries} = ct_stash:flush(StashPid, get_trace_id(WoodyCtx)),
[Beat || {Beat, _Metadata} <- Entries].
get_trace_id(WoodyCtx) ->
RpcID = woody_context:get_rpc_id(WoodyCtx),
maps:get(trace_id, RpcID).
%%
-spec with_config(atom(), config(), fun ((_) -> R)) ->
R | undefined.
with_config(Name, C, Fun) ->
case lists:keyfind(Name, 1, C) of
{_, V} -> Fun(V);
false -> undefined
end.