MSPF-605: Add bouncer integration (#35)

This commit is contained in:
Артем 2020-11-23 18:38:51 +03:00 committed by GitHub
parent bf8673d8ed
commit 71b63ffb38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 773 additions and 156 deletions

View File

@ -14,6 +14,8 @@
cowboy_access_log,
swag_server,
mg_proto,
bouncer_proto,
bouncer_client,
woody,
woody_user_identity,
erl_health,

View File

@ -1,7 +1,7 @@
-module(shortener_auth).
-export([authorize_api_key/2]).
-export([authorize_operation/3]).
-export([authorize_operation/4]).
-type context() :: shortener_authorizer_jwt:t().
-type claims() :: shortener_authorizer_jwt:claims().
@ -42,17 +42,38 @@ parse_api_key(ApiKey) ->
authorize_api_key(_OperationID, bearer, Token) ->
shortener_authorizer_jwt:verify(Token).
-spec authorize_operation(OperationID, Slug, Context) -> ok | {error, forbidden} when
-spec authorize_operation(OperationID, Slug, ReqContext, WoodyCtx) -> ok | {error, forbidden} when
OperationID :: swag_server:operation_id(),
Slug :: shortener_slug:slug() | no_slug,
Context :: context().
authorize_operation(OperationID, Slug, {{SubjectID, ACL}, _Claims}) ->
ReqContext :: swag_server:request_context(),
WoodyCtx :: woody_context:ctx().
authorize_operation(OperationID, Slug, ReqContext, WoodyCtx) ->
{{SubjectID, _ACL, ExpiresAt}, Claims} = get_auth_context(ReqContext),
IpAddress = get_peer(ReqContext),
Owner = get_slug_owner(Slug),
Permissions = shortener_acl:match(['shortened-urls'], ACL),
case is_operation_permitted(OperationID, SubjectID, Owner, Permissions) of
true ->
ID = get_slug_id(Slug),
Email = maps:get(<<"email">>, Claims, undefined),
JudgeContext = #{
fragments => #{
<<"env">> => bouncer_context_helpers:make_default_env_context_fragment(),
<<"auth">> => bouncer_context_helpers:make_auth_context_fragment(#{
method => <<"SessionToken">>,
expiration => genlib_rfc3339:format(ExpiresAt, second)
}),
<<"user">> => bouncer_context_helpers:make_user_context_fragment(#{id => SubjectID, email => Email}),
<<"requester">> => bouncer_context_helpers:make_requester_context_fragment(#{ip => IpAddress}),
<<"shortener">> => shortener_bouncer_client:make_shortener_context_fragment(
genlib:to_binary(OperationID),
ID,
Owner
)
}
},
{ok, RulesetID} = application:get_env(shortener, bouncer_ruleset_id),
case bouncer_client:judge(RulesetID, JudgeContext, WoodyCtx) of
allowed ->
ok;
false ->
forbidden ->
{error, forbidden}
end.
@ -62,13 +83,21 @@ get_slug_owner(no_slug) ->
get_slug_owner(#{owner := Owner}) ->
Owner.
is_operation_permitted('ShortenUrl', _SubjectID, undefined, Ps) ->
lists:member(write, Ps);
is_operation_permitted('DeleteShortenedUrl', _SubjectID, undefined, Ps) ->
lists:member(write, Ps);
is_operation_permitted('DeleteShortenedUrl', SubjectID, Owner, Ps) ->
(SubjectID == Owner) and lists:member(write, Ps);
is_operation_permitted('GetShortenedUrl', _SubjectID, undefined, Ps) ->
lists:member(read, Ps);
is_operation_permitted('GetShortenedUrl', SubjectID, Owner, Ps) ->
(SubjectID == Owner) and lists:member(read, Ps).
-spec get_slug_id(shortener_slug:slug() | no_slug) -> shortener_slug:id() | undefined.
get_slug_id(no_slug) ->
undefined;
get_slug_id(#{id := ID}) ->
ID.
get_auth_context(#{auth_context := AuthContext}) ->
AuthContext.
get_peer(#{peer := Peer}) ->
case maps:get(ip_address, Peer, undefined) of
undefined ->
undefined;
IP ->
inet:ntoa(IP)
end;
get_peer(_) ->
undefined.

View File

@ -24,7 +24,8 @@
-type key() :: #jose_jwk{}.
-type token() :: binary().
-type claims() :: #{binary() => term()}.
-type subject() :: {subject_id(), shortener_acl:t()}.
%% Added expiration to subject tuple as part of token service claims
-type subject() :: {subject_id(), shortener_acl:t(), pos_integer() | undefined}.
-type subject_id() :: binary().
-type t() :: {subject(), claims()}.
-type expiration() ::
@ -262,8 +263,9 @@ verify(KID, Alg, ExpandedToken) ->
verify(JWK, ExpandedToken) ->
case jose_jwt:verify(JWK, ExpandedToken) of
{true, #jose_jwt{fields = Claims}, _JWS} ->
{#{subject_id := SubjectID}, Claims1} = validate_claims(Claims),
get_result(SubjectID, decode_roles(Claims1));
{Data = #{subject_id := SubjectID}, Claims1} = validate_claims(Claims),
ExpiresAt = maps:get(expires_at, Data, undefined),
get_result({SubjectID, ExpiresAt}, decode_roles(Claims1));
{false, _JWT, _JWS} ->
{error, invalid_signature}
end.
@ -277,9 +279,9 @@ validate_claims(Claims, [{Name, Claim, Validator} | Rest], Acc) ->
validate_claims(Claims, [], Acc) ->
{Acc, Claims}.
get_result(SubjectID, {Roles, Claims}) ->
get_result({SubjectID, ExpiresAt}, {Roles, Claims}) ->
try
Subject = {SubjectID, shortener_acl:decode(Roles)},
Subject = {SubjectID, shortener_acl:decode(Roles), ExpiresAt},
{ok, {Subject, Claims}}
catch
error:{badarg, _} = Reason ->

View File

@ -0,0 +1,30 @@
-module(shortener_bouncer_client).
-include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl").
%% API
-export([make_shortener_context_fragment/3]).
%%
-type operation_id() :: binary().
-type id() :: shortener_slug:id().
-type owner() :: shortener_slug:owner().
-spec make_shortener_context_fragment(operation_id(), id() | undefined, owner() | undefined) ->
bouncer_client:context_fragment().
make_shortener_context_fragment(OperationID, ID, OwnerID) ->
{fragment, #bctx_v1_ContextFragment{
shortener = #bctx_v1_ContextUrlShortener{
op = #bctx_v1_UrlShortenerOperation{
id = OperationID,
shortened_url = #bctx_v1_ShortenedUrl{
id = ID,
owner = #bctx_v1_Entity{
id = OwnerID
}
}
}
}
}}.

View File

@ -40,7 +40,7 @@ handle_request(OperationID, Req, Context, _Opts) ->
AuthContext = get_auth_context(Context),
WoodyCtx = create_woody_ctx(Req, AuthContext),
Slug = prefetch_slug(Req, WoodyCtx),
case shortener_auth:authorize_operation(OperationID, Slug, AuthContext) of
case shortener_auth:authorize_operation(OperationID, Slug, Context, WoodyCtx) of
ok ->
SubjectID = get_subject_id(AuthContext),
process_request(OperationID, Req, Slug, SubjectID, WoodyCtx);
@ -96,7 +96,7 @@ collect_user_identity(AuthContext) ->
username => get_claim(<<"name">>, AuthContext, undefined)
}).
get_subject_id({{SubjectID, _ACL}, _}) ->
get_subject_id({{SubjectID, _ACL, _ExpiresAt}, _}) ->
SubjectID.
get_claim(ClaimName, {_Subject, Claims}, Default) ->

View File

@ -35,6 +35,7 @@
}.
-export_type([slug/0]).
-export_type([id/0]).
-export_type([owner/0]).
-type ctx() :: woody_context:ctx().

View File

@ -0,0 +1,360 @@
-module(shortener_auth_SUITE).
-include_lib("bouncer_proto/include/bouncer_decisions_thrift.hrl").
-include_lib("bouncer_proto/include/bouncer_context_v1_thrift.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([failed_authorization/1]).
-export([insufficient_permissions/1]).
-export([readonly_permissions/1]).
-export([other_subject_delete/1]).
-export([other_subject_read/1]).
%% tests descriptions
-type config() :: [{atom(), term()}].
-type test_case_name() :: atom().
-define(config(Key, C), (element(2, lists:keyfind(Key, 1, C)))).
-spec all() -> [test_case_name()].
all() ->
[
{group, general}
].
-spec groups() -> [{atom(), list(), [test_case_name()]}].
groups() ->
[
{general, [], [
failed_authorization,
insufficient_permissions,
readonly_permissions,
other_subject_delete,
other_subject_read
]}
].
-spec init_per_suite(config()) -> config().
init_per_suite(C) ->
% _ = dbg:tracer(),
% _ = dbg:p(all, c),
% _ = dbg:tpl({shortener_swagger_server, '_', '_'}, x),
Host = "url-shortener",
Port = 8080,
Netloc = Host ++ ":" ++ genlib:to_list(Port),
Apps =
genlib_app:start_application_with(scoper, [
{storage, scoper_storage_logger}
]) ++
genlib_app:start_application_with(bouncer_client, shortener_ct_helper:get_bouncer_client_app_config()),
[
{suite_apps, Apps},
{api_endpoint, "http://" ++ Netloc},
{host, Host},
{port, Port},
{netloc, Netloc}
] ++ C.
-spec init_per_group(atom(), config()) -> config().
init_per_group(_Group, C) ->
ShortenerApp =
genlib_app:start_application_with(
shortener,
shortener_ct_helper:get_app_config(
?config(port, C),
?config(netloc, C),
get_keysource("keys/local/private.pem", C)
)
),
[
{shortener_app, ShortenerApp}
] ++ C.
-spec end_per_group(atom(), config()) -> _.
end_per_group(_Group, C) ->
genlib_app:stop_unload_applications(?config(shortener_app, C)).
get_keysource(Key, C) ->
filename:join(?config(data_dir, C), Key).
-spec end_per_suite(config()) -> term().
end_per_suite(C) ->
genlib_app:stop_unload_applications(?config(suite_apps, C)).
-spec init_per_testcase(test_case_name(), config()) -> config().
init_per_testcase(_Name, C) ->
shortener_ct_helper:with_test_sup(C).
-spec end_per_testcase(test_case_name(), config()) -> config().
end_per_testcase(_Name, C) ->
shortener_ct_helper:stop_test_sup(C),
ok.
%%
-spec failed_authorization(config()) -> _.
-spec insufficient_permissions(config()) -> _.
-spec readonly_permissions(config()) -> _.
-spec other_subject_delete(config()) -> _.
-spec other_subject_read(config()) -> _.
failed_authorization(C) ->
Params = construct_params(<<"https://oops.io/">>),
C1 = clean_api_auth_token(C),
{ok, 401, _, _} = shorten_url(Params, C1),
{ok, 401, _, _} = delete_shortened_url(<<"42">>, C1),
{ok, 401, _, _} = get_shortened_url(<<"42">>, C1).
insufficient_permissions(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = forbidden}} end}
],
C
),
C1 = set_api_auth_token(insufficient_permissions, C),
Params = construct_params(<<"https://oops.io/">>),
{ok, 403, _, _} = shorten_url(Params, C1),
{ok, 403, _, _} = delete_shortened_url(<<"42">>, C1),
{ok, 403, _, _} = get_shortened_url(<<"42">>, C1).
readonly_permissions(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', {_RulesetID, Fragments}) ->
case get_operation_id(Fragments) of
<<"ShortenUrl">> ->
{ok, #bdcs_Judgement{resolution = allowed}};
<<"GetShortenedUrl">> ->
{ok, #bdcs_Judgement{resolution = allowed}};
<<"DeleteShortenedUrl">> ->
{ok, #bdcs_Judgement{resolution = forbidden}}
end
end}
],
C
),
C1 = set_api_auth_token(readonly_permissions, C),
Params = construct_params(<<"https://oops.io/">>),
{ok, 201, _, #{<<"id">> := ID}} = shorten_url(Params, C1),
{ok, 200, _, #{<<"id">> := ID}} = get_shortened_url(ID, C1),
{ok, 403, _, _} = delete_shortened_url(ID, C1).
other_subject_delete(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', {_RulesetID, Fragments}) ->
case get_operation_id(Fragments) of
<<"ShortenUrl">> ->
{ok, #bdcs_Judgement{resolution = allowed}};
<<"GetShortenedUrl">> ->
case get_owner_info(Fragments) of
{ID, ID} ->
{ok, #bdcs_Judgement{resolution = allowed}};
_ ->
{ok, #bdcs_Judgement{resolution = forbidden}}
end;
<<"DeleteShortenedUrl">> ->
case get_owner_info(Fragments) of
{ID, ID} ->
{ok, #bdcs_Judgement{resolution = allowed}};
_ ->
{ok, #bdcs_Judgement{resolution = forbidden}}
end
end
end}
],
C
),
SourceUrl = <<"https://oops.io/">>,
Params = construct_params(SourceUrl),
C1 = set_api_auth_token(other_subject_delete_first, C),
{ok, 201, _, #{<<"id">> := ID, <<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
C2 = set_api_auth_token(other_subject_delete_second, C1),
{ok, 403, _, _} = delete_shortened_url(ID, C2),
{ok, 301, Headers, _} = hackney:request(get, ShortUrl),
{<<"location">>, SourceUrl} = lists:keyfind(<<"location">>, 1, Headers).
other_subject_read(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', {_RulesetID, Fragments}) ->
case get_operation_id(Fragments) of
<<"ShortenUrl">> ->
{ok, #bdcs_Judgement{resolution = allowed}};
<<"GetShortenedUrl">> ->
case get_owner_info(Fragments) of
{ID, ID} ->
{ok, #bdcs_Judgement{resolution = allowed}};
_ ->
{ok, #bdcs_Judgement{resolution = forbidden}}
end;
<<"DeleteShortenedUrl">> ->
case get_owner_info(Fragments) of
{ID, ID} ->
{ok, #bdcs_Judgement{resolution = allowed}};
_ ->
{ok, #bdcs_Judgement{resolution = forbidden}}
end
end
end}
],
C
),
Params = construct_params(<<"https://oops.io/">>),
C1 = set_api_auth_token(other_subject_read_first, C),
{ok, 201, _, #{<<"id">> := ID}} = shorten_url(Params, C1),
C2 = set_api_auth_token(other_subject_read_second, C1),
{ok, 403, _, _} = get_shortened_url(ID, C2).
%%
construct_params(SourceUrl) ->
construct_params(SourceUrl, 3600).
construct_params(SourceUrl, Lifetime) ->
#{
<<"sourceUrl">> => SourceUrl,
<<"expiresAt">> => format_ts(genlib_time:unow() + Lifetime)
}.
set_api_auth_token(Name, C) ->
UserID = genlib:to_binary(Name),
ACL = construct_shortener_acl([]),
{ok, T} = shortener_authorizer_jwt:issue({{UserID, shortener_acl:from_list(ACL)}, #{}}, unlimited),
lists:keystore(api_auth_token, 1, C, {api_auth_token, T}).
clean_api_auth_token(C) ->
lists:keydelete(api_auth_token, 1, C).
construct_shortener_acl(Permissions) ->
lists:map(fun(P) -> {['shortened-urls'], P} end, Permissions).
%%
shorten_url(ShortenedUrlParams, C) ->
swag_client_shortener_api:shorten_url(
?config(api_endpoint, C),
append_common_params(#{body => ShortenedUrlParams}, C)
).
delete_shortened_url(ID, C) ->
swag_client_shortener_api:delete_shortened_url(
?config(api_endpoint, C),
append_common_params(#{binding => #{<<"shortenedUrlID">> => ID}}, C)
).
get_shortened_url(ID, C) ->
swag_client_shortener_api:get_shortened_url(
?config(api_endpoint, C),
append_common_params(#{binding => #{<<"shortenedUrlID">> => ID}}, C)
).
append_common_params(Params, C) ->
append_media_type(
append_auth(
append_request_id(
maps:merge(#{binding => #{}, qs_val => #{}, header => #{}, body => #{}}, Params)
),
C
)
).
append_media_type(Params = #{header := Headers}) ->
Params#{
header => Headers#{
<<"Accept">> => <<"application/json">>,
<<"Content-Type">> => <<"application/json; charset=utf-8">>
}
}.
append_auth(Params = #{header := Headers}, C) ->
case lists:keyfind(api_auth_token, 1, C) of
{api_auth_token, AuthToken} ->
Params#{header => Headers#{<<"Authorization">> => <<"Bearer ", AuthToken/binary>>}};
_ ->
Params
end.
append_request_id(Params = #{header := Headers}) ->
Params#{header => Headers#{<<"X-Request-ID">> => woody_context:new_req_id()}}.
format_ts(Ts) ->
genlib_rfc3339:format(Ts, second).
get_operation_id(#bdcs_Context{
fragments = #{
<<"shortener">> := #bctx_ContextFragment{
type = v1_thrift_binary,
content = Fragment
}
}
}) ->
case decode(Fragment) of
{error, _} = Error ->
error(Error);
#bctx_v1_ContextFragment{
shortener = #bctx_v1_ContextUrlShortener{op = #bctx_v1_UrlShortenerOperation{id = OperationID}}
} ->
OperationID
end.
get_owner_info(Context) ->
{get_owner_id(Context), get_user_id(Context)}.
get_owner_id(#bdcs_Context{
fragments = #{
<<"shortener">> := #bctx_ContextFragment{
type = v1_thrift_binary,
content = Fragment
}
}
}) ->
case decode(Fragment) of
{error, _} = Error ->
error(Error);
#bctx_v1_ContextFragment{
shortener = #bctx_v1_ContextUrlShortener{op = #bctx_v1_UrlShortenerOperation{shortened_url = Url}}
} ->
#bctx_v1_ShortenedUrl{owner = #bctx_v1_Entity{id = OwnerID}} = Url,
OwnerID
end.
get_user_id(#bdcs_Context{
fragments = #{
<<"user">> := #bctx_ContextFragment{
type = v1_thrift_binary,
content = Fragment
}
}
}) ->
case decode(Fragment) of
{error, _} = Error ->
error(Error);
#bctx_v1_ContextFragment{user = #bctx_v1_User{id = UserID}} ->
UserID
end.
decode(Content) ->
Type = {struct, struct, {bouncer_context_v1_thrift, 'ContextFragment'}},
Codec = thrift_strict_binary_codec:new(Content),
case thrift_strict_binary_codec:read(Codec, Type) of
{ok, CtxThrift, Codec1} ->
case thrift_strict_binary_codec:close(Codec1) of
<<>> ->
CtxThrift;
Leftovers ->
{error, {excess_binary_data, Leftovers}}
end;
Error ->
Error
end.

View File

@ -0,0 +1,9 @@
-----BEGIN RSA PRIVATE KEY-----
MIIBOwIBAAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg7F/ZMtGbPFikJnnvRWvF
B5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQJABUY5KIgr4JZEjwLYxQ9T
9uIbLP1Xe/E7yqoqmBk2GGhSrPY0OeRkYnUVLcP96UPQhF63iuG8VF6uZ7oAPsq+
gQIhANZy3jSCzPjXYHRU1kRqQzpt2S+OqoEiqQ6YG1HrC/VxAiEA0Vq6JlQK2tOX
37SS00dK0Qog4Qi8dN73GliFQNP18EkCIQC4epSA48zkfJMzQBAbRraSuxDNApPX
BzQbo+pMrEDbYQIgY4AncQgIkLB4Qk5kah48JNYXglzQlQtTjiX8Ty9ueGECIQCM
GD3UbQKiA0gf5plBA24I4wFVKxxa4wXbW/7SfP6XmQ==
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg
7F/ZMtGbPFikJnnvRWvFB5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQ==
-----END PUBLIC KEY-----

View File

@ -0,0 +1,184 @@
-module(shortener_ct_helper).
-include_lib("common_test/include/ct.hrl").
-export([with_test_sup/1]).
-export([stop_test_sup/1]).
-export([mock_services/2]).
-export([get_app_config/3]).
-export([get_app_config/4]).
-export([get_bouncer_client_app_config/0]).
-type config() :: [{atom(), any()}].
-define(SHORTENER_IP, "::").
-define(SHORTENER_PORT, 8080).
-define(SHORTENER_HOST_NAME, "localhost").
-define(SHORTENER_URL, ?SHORTENER_HOST_NAME ++ ":" ++ integer_to_list(?SHORTENER_PORT)).
-spec with_test_sup(config()) -> config().
with_test_sup(C) ->
{ok, SupPid} = genlib_adhoc_supervisor:start_link(#{}, []),
_ = unlink(SupPid),
[{test_sup, SupPid} | C].
-spec stop_test_sup(config()) -> _.
stop_test_sup(C) ->
exit(?config(test_sup, C), shutdown).
-spec mock_services(list(), config()) -> _.
mock_services(Services, SupOrConfig) ->
maps:map(fun set_cfg/2, mock_services_(Services, SupOrConfig)).
set_cfg(Service, Url) when Service =:= bouncer orelse Service =:= org_management ->
{ok, Clients} = application:get_env(bouncer_client, service_clients),
#{Service := Cfg} = Clients,
ok = application:set_env(
bouncer_client,
service_clients,
Clients#{Service => Cfg#{url => Url}}
);
set_cfg(Service, Url) ->
{ok, Clients} = application:get_env(shortener, service_clients),
#{Service := Cfg} = Clients,
ok = application:set_env(
shortener,
service_clients,
Clients#{Service => Cfg#{url => Url}}
).
mock_services_(Services, Config) when is_list(Config) ->
mock_services_(Services, ?config(test_sup, Config));
mock_services_(Services, SupPid) when is_pid(SupPid) ->
Name = lists:map(fun get_service_name/1, Services),
Port = get_random_port(),
{ok, IP} = inet:parse_address(?SHORTENER_IP),
ChildSpec = woody_server:child_spec(
{dummy, Name},
#{
ip => IP,
port => Port,
event_handler => scoper_woody_event_handler,
handlers => lists:map(fun mock_service_handler/1, Services)
}
),
{ok, _} = supervisor:start_child(SupPid, ChildSpec),
lists:foldl(
fun(Service, Acc) ->
ServiceName = get_service_name(Service),
Acc#{ServiceName => make_url(ServiceName, Port)}
end,
#{},
Services
).
get_service_name({ServiceName, _Fun}) ->
ServiceName;
get_service_name({ServiceName, _WoodyService, _Fun}) ->
ServiceName.
mock_service_handler({ServiceName, Fun}) ->
mock_service_handler(ServiceName, get_service_modname(ServiceName), Fun);
mock_service_handler({ServiceName, WoodyService, Fun}) ->
mock_service_handler(ServiceName, WoodyService, Fun).
mock_service_handler(ServiceName, WoodyService, Fun) ->
{make_path(ServiceName), {WoodyService, {shortener_mock_service, #{function => Fun}}}}.
get_service_modname(bouncer) ->
{bouncer_decisions_thrift, 'Arbiter'}.
% TODO not so failproof, ideally we need to bind socket first and then give to a ranch listener
get_random_port() ->
rand:uniform(32768) + 32767.
make_url(ServiceName, Port) ->
iolist_to_binary(["http://", ?SHORTENER_HOST_NAME, ":", integer_to_list(Port), make_path(ServiceName)]).
make_path(ServiceName) ->
"/" ++ atom_to_list(ServiceName).
%%
-spec get_app_config(_, _, _) -> _.
get_app_config(Port, Netloc, PemFile) ->
get_app_config(Port, Netloc, PemFile, <<"http://machinegun:8022/v1/automaton">>).
-spec get_app_config(_, _, _, _) -> _.
get_app_config(Port, Netloc, PemFile, AutomatonUrl) ->
[
{bouncer_ruleset_id, <<"service/authz/api">>},
{space_size, 8},
{hash_algorithm, sha256},
{api, #{
ip => "::",
port => Port,
authorizer => #{
signee => local,
keyset => #{
local => {pem_file, PemFile}
}
},
source_url_whitelist => [
"https://*",
"ftp://*",
"http://localhost/*"
],
short_url_template => #{
scheme => http,
netloc => Netloc,
path => "/r/e/d/i/r/"
}
}},
{processor, #{
ip => "::",
port => 8022
}},
{health_check, #{
service => {erl_health, service, [<<"shortener">>]}
}},
{service_clients, #{
automaton => #{
url => AutomatonUrl,
retries => #{
% function => retry strategy
% '_' work as "any"
% default value is 'finish'
% for more info look genlib_retry :: strategy()
% https://github.com/rbkmoney/genlib/blob/master/src/genlib_retry.erl#L19
'Start' => {linear, 3, 1000},
'GetMachine' => {linear, 3, 1000},
'Remove' => {linear, 3, 1000},
'_' => finish
}
}
}}
].
-spec get_bouncer_client_app_config() -> _.
get_bouncer_client_app_config() ->
[
{service_clients, #{
bouncer => #{
url => <<"http://bouncer:8022/">>,
retries => #{
'Judge' => {linear, 3, 1000},
'_' => finish
}
},
org_management => #{
url => <<"http://org_management:8022/">>,
retries => #{
% function => retry strategy
% '_' work as "any"
% default value is 'finish'
% for more info look genlib_retry :: strategy()
% https://github.com/rbkmoney/genlib/blob/master/src/genlib_retry.erl#L19
'GetUserContext' => {linear, 3, 1000},
'_' => finish
}
}
}}
].

View File

@ -1,5 +1,8 @@
-module(shortener_general_SUITE).
-include_lib("bouncer_proto/include/bouncer_decisions_thrift.hrl").
-include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl").
-export([all/0]).
-export([groups/0]).
-export([init_per_suite/1]).
@ -9,13 +12,8 @@
-export([init_per_testcase/2]).
-export([end_per_testcase/2]).
-export([failed_authorization/1]).
-export([insufficient_permissions/1]).
-export([readonly_permissions/1]).
-export([successful_redirect/1]).
-export([successful_delete/1]).
-export([other_subject_delete/1]).
-export([other_subject_read/1]).
-export([fordidden_source_url/1]).
-export([url_expired/1]).
-export([always_unique_url/1]).
@ -49,14 +47,8 @@ all() ->
groups() ->
[
{general, [], [
failed_authorization,
insufficient_permissions,
readonly_permissions,
successful_redirect,
successful_delete,
other_subject_delete,
other_subject_read,
fordidden_source_url,
url_expired,
always_unique_url
@ -80,7 +72,8 @@ init_per_suite(C) ->
Apps =
genlib_app:start_application_with(scoper, [
{storage, scoper_storage_logger}
]),
]) ++
genlib_app:start_application_with(bouncer_client, shortener_ct_helper:get_bouncer_client_app_config()),
[
{suite_apps, Apps},
{api_endpoint, "http://" ++ Netloc},
@ -94,7 +87,7 @@ init_per_group(_Group, C) ->
ShortenerApp =
genlib_app:start_application_with(
shortener,
get_app_config(
shortener_ct_helper:get_app_config(
?config(port, C),
?config(netloc, C),
get_keysource("keys/local/private.pem", C)
@ -117,50 +110,29 @@ end_per_suite(C) ->
-spec init_per_testcase(test_case_name(), config()) -> config().
init_per_testcase(_Name, C) ->
C.
shortener_ct_helper:with_test_sup(C).
-spec end_per_testcase(test_case_name(), config()) -> config().
end_per_testcase(_Name, _C) ->
end_per_testcase(_Name, C) ->
shortener_ct_helper:stop_test_sup(C),
ok.
%%
-spec failed_authorization(config()) -> _.
-spec insufficient_permissions(config()) -> _.
-spec readonly_permissions(config()) -> _.
-spec successful_redirect(config()) -> _.
-spec successful_delete(config()) -> _.
-spec other_subject_delete(config()) -> _.
-spec other_subject_read(config()) -> _.
-spec fordidden_source_url(config()) -> _.
-spec url_expired(config()) -> _.
-spec always_unique_url(config()) -> _.
failed_authorization(C) ->
Params = construct_params(<<"https://oops.io/">>),
C1 = clean_api_auth_token(C),
{ok, 401, _, _} = shorten_url(Params, C1),
{ok, 401, _, _} = delete_shortened_url(<<"42">>, C1),
{ok, 401, _, _} = get_shortened_url(<<"42">>, C1).
insufficient_permissions(C) ->
C1 = set_api_auth_token(insufficient_permissions, [], C),
Params = construct_params(<<"https://oops.io/">>),
{ok, 403, _, _} = shorten_url(Params, C1),
{ok, 403, _, _} = delete_shortened_url(<<"42">>, C1),
{ok, 403, _, _} = get_shortened_url(<<"42">>, C1).
readonly_permissions(C) ->
C1 = set_api_auth_token(readonly_permissions, [read, write], C),
Params = construct_params(<<"https://oops.io/">>),
{ok, 201, _, #{<<"id">> := ID}} = shorten_url(Params, C1),
C2 = set_api_auth_token(readonly_permissions, [read], C1),
{ok, 200, _, #{<<"id">> := ID}} = get_shortened_url(ID, C2),
{ok, 403, _, _} = delete_shortened_url(ID, C2).
successful_redirect(C) ->
C1 = set_api_auth_token(successful_redirect, [read, write], C),
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
C1 = set_api_auth_token(successful_redirect, C),
SourceUrl = <<"https://example.com/">>,
Params = construct_params(SourceUrl),
{ok, 201, _, #{<<"id">> := ID, <<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
@ -169,32 +141,27 @@ successful_redirect(C) ->
{<<"location">>, SourceUrl} = lists:keyfind(<<"location">>, 1, Headers).
successful_delete(C) ->
C1 = set_api_auth_token(successful_delete, [read, write], C),
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
C1 = set_api_auth_token(successful_delete, C),
Params = construct_params(<<"https://oops.io/">>),
{ok, 201, _, #{<<"id">> := ID, <<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
{ok, 204, _, _} = delete_shortened_url(ID, C1),
{ok, 404, _, _} = get_shortened_url(ID, C1),
{ok, 404, _, _} = hackney:request(get, ShortUrl).
other_subject_delete(C) ->
SourceUrl = <<"https://oops.io/">>,
Params = construct_params(SourceUrl),
C1 = set_api_auth_token(other_subject_delete_first, [read, write], C),
{ok, 201, _, #{<<"id">> := ID, <<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
C2 = set_api_auth_token(other_subject_delete_second, [read, write], C1),
{ok, 403, _, _} = delete_shortened_url(ID, C2),
{ok, 301, Headers, _} = hackney:request(get, ShortUrl),
{<<"location">>, SourceUrl} = lists:keyfind(<<"location">>, 1, Headers).
other_subject_read(C) ->
Params = construct_params(<<"https://oops.io/">>),
C1 = set_api_auth_token(other_subject_read_first, [read, write], C),
{ok, 201, _, #{<<"id">> := ID}} = shorten_url(Params, C1),
C2 = set_api_auth_token(other_subject_read_second, [read, write], C1),
{ok, 403, _, _} = get_shortened_url(ID, C2).
fordidden_source_url(C) ->
C1 = set_api_auth_token(fordidden_source_url, [read, write], C),
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
C1 = set_api_auth_token(fordidden_source_url, C),
{ok, 201, _, #{}} = shorten_url(construct_params(<<"http://localhost/hack?id=42">>), C1),
{ok, 201, _, #{}} = shorten_url(construct_params(<<"https://localhost/hack?id=42">>), C1),
{ok, 400, _, #{}} = shorten_url(construct_params(<<"http://example.io/">>), C1),
@ -202,7 +169,13 @@ fordidden_source_url(C) ->
{ok, 201, _, #{}} = shorten_url(construct_params(<<"ftp://ftp.hp.com/pub/hpcp/newsletter_july2003">>), C1).
url_expired(C) ->
C1 = set_api_auth_token(url_expired, [read, write], C),
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
C1 = set_api_auth_token(url_expired, C),
Params = construct_params(<<"https://oops.io/">>, 1),
{ok, 201, _, #{<<"id">> := ID, <<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
{ok, 200, _, #{<<"shortenedUrl">> := ShortUrl}} = get_shortened_url(ID, C1),
@ -211,7 +184,13 @@ url_expired(C) ->
{ok, 404, _, _} = hackney:request(get, ShortUrl).
always_unique_url(C) ->
C1 = set_api_auth_token(always_unique_url, [read, write], C),
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
C1 = set_api_auth_token(always_unique_url, C),
N = 42,
Params = construct_params(<<"https://oops.io/">>, 3600),
{IDs, ShortUrls} = lists:unzip([
@ -229,18 +208,30 @@ always_unique_url(C) ->
-spec supported_cors_header(config()) -> _.
unsupported_cors_method(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
SourceUrl = <<"https://oops.io/">>,
Params = construct_params(SourceUrl),
C1 = set_api_auth_token(unsupported_cors_method, [read, write], C),
C1 = set_api_auth_token(unsupported_cors_method, C),
{ok, 201, _, #{<<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
ReqHeaders = [{<<"origin">>, <<"localhost">>}, {<<"access-control-request-method">>, <<"PATCH">>}],
{ok, 200, Headers, _} = hackney:request(options, ShortUrl, ReqHeaders),
false = lists:member(<<"access-control-allow-methods">>, Headers).
supported_cors_method(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
SourceUrl = <<"https://oops.io/">>,
Params = construct_params(SourceUrl),
C1 = set_api_auth_token(supported_cors_method, [read, write], C),
C1 = set_api_auth_token(supported_cors_method, C),
{ok, 201, _, #{<<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
ReqHeaders = [{<<"origin">>, <<"localhost">>}, {<<"access-control-request-method">>, <<"GET">>}],
{ok, 200, Headers, _} = hackney:request(options, ShortUrl, ReqHeaders),
@ -249,9 +240,15 @@ supported_cors_method(C) ->
Allowed = binary:split(Returned, <<",">>, [global]).
supported_cors_header(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
SourceUrl = <<"https://oops.io/">>,
Params = construct_params(SourceUrl),
C1 = set_api_auth_token(supported_cors_header, [read, write], C),
C1 = set_api_auth_token(supported_cors_header, C),
{ok, 201, _, #{<<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
ReqHeaders = [
{<<"origin">>, <<"localhost">>},
@ -265,9 +262,15 @@ supported_cors_header(C) ->
[_ | Allowed] = binary:split(Returned, <<",">>, [global]).
unsupported_cors_header(C) ->
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
SourceUrl = <<"https://oops.io/">>,
Params = construct_params(SourceUrl),
C1 = set_api_auth_token(unsupported_cors_header, [read, write], C),
C1 = set_api_auth_token(unsupported_cors_header, C),
{ok, 201, _, #{<<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1),
ReqHeaders = [
{<<"origin">>, <<"localhost">>},
@ -294,14 +297,20 @@ construct_params(SourceUrl, Lifetime) ->
woody_timeout_test(C) ->
Apps = genlib_app:start_application_with(
shortener,
get_app_config(
shortener_ct_helper:get_app_config(
?config(port, C),
?config(netloc, C),
get_keysource("keys/local/private.pem", C),
<<"http://invalid_url:8022/v1/automaton">>
)
),
C2 = set_api_auth_token(woody_timeout_test, [read, write], C),
shortener_ct_helper:mock_services(
[
{bouncer, fun('Judge', _) -> {ok, #bdcs_Judgement{resolution = allowed}} end}
],
C
),
C2 = set_api_auth_token(woody_timeout_test, C),
SourceUrl = <<"https://example.com/">>,
Params = construct_params(SourceUrl),
{Time, {error, {invalid_response_code, 503}}} =
@ -316,7 +325,7 @@ woody_timeout_test(C) ->
health_check_passing(C) ->
Apps = genlib_app:start_application_with(
shortener,
get_app_config(
shortener_ct_helper:get_app_config(
?config(port, C),
?config(netloc, C),
get_keysource("keys/local/private.pem", C)
@ -328,15 +337,12 @@ health_check_passing(C) ->
genlib_app:stop_unload_applications(Apps).
%%
set_api_auth_token(Name, Permissions, C) ->
set_api_auth_token(Name, C) ->
UserID = genlib:to_binary(Name),
ACL = construct_shortener_acl(Permissions),
ACL = construct_shortener_acl([]),
{ok, T} = shortener_authorizer_jwt:issue({{UserID, shortener_acl:from_list(ACL)}, #{}}, unlimited),
lists:keystore(api_auth_token, 1, C, {api_auth_token, T}).
clean_api_auth_token(C) ->
lists:keydelete(api_auth_token, 1, C).
construct_shortener_acl(Permissions) ->
lists:map(fun(P) -> {['shortened-urls'], P} end, Permissions).
@ -391,57 +397,3 @@ append_request_id(Params = #{header := Headers}) ->
format_ts(Ts) ->
genlib_rfc3339:format(Ts, second).
%%
get_app_config(Port, Netloc, PemFile) ->
get_app_config(Port, Netloc, PemFile, <<"http://machinegun:8022/v1/automaton">>).
get_app_config(Port, Netloc, PemFile, AutomatonUrl) ->
[
{space_size, 8},
{hash_algorithm, sha256},
{api, #{
ip => "::",
port => Port,
authorizer => #{
signee => local,
keyset => #{
local => {pem_file, PemFile}
}
},
source_url_whitelist => [
"https://*",
"ftp://*",
"http://localhost/*"
],
short_url_template => #{
scheme => http,
netloc => Netloc,
path => "/r/e/d/i/r/"
}
}},
{processor, #{
ip => "::",
port => 8022
}},
{health_check, #{
service => {erl_health, service, [<<"shortener">>]}
}},
{service_clients, #{
automaton => #{
url => AutomatonUrl,
retries => #{
% function => retry strategy
% '_' work as "any"
% default value is 'finish'
% for more info look genlib_retry :: strategy()
% https://github.com/rbkmoney/genlib/blob/master/src/genlib_retry.erl#L19
'Start' => {linear, 3, 1000},
'GetMachine' => {linear, 3, 1000},
'Remove' => {linear, 3, 1000},
'_' => finish
}
}
}}
].

View File

@ -0,0 +1,9 @@
-module(shortener_mock_service).
-behaviour(woody_server_thrift_handler).
-export([handle_function/4]).
-spec handle_function(woody:func(), woody:args(), woody_context:ctx(), #{}) -> {ok, term()}.
handle_function(FunName, Args, _, #{function := Fun}) ->
Fun(FunName, Args).

View File

@ -4,6 +4,7 @@
]},
{shortener, [
{bouncer_ruleset_id , <<"service/authz/api">>},
{space_size , 8},
{hash_algorithm , sha256},
{api, #{
@ -42,6 +43,18 @@
'Remove' => {linear, 3, 1000},
'_' => finish
}
},
bouncer => #{
url => <<"http://bouncer:8022/">>,
retries => #{
% function => retry strategy
% '_' work as "any"
% default value is 'finish'
% for more info look genlib_retry :: strategy()
% https://github.com/rbkmoney/genlib/blob/master/src/genlib_retry.erl#L19
'Judge' => {linear, 3, 1000},
'_' => finish
}
}
}},
{health_checkers, [

View File

@ -67,6 +67,16 @@
{branch, "master"}
}
},
{bouncer_proto,
{git, "https://github.com/rbkmoney/bouncer-proto.git",
{branch, "master"}
}
},
{bouncer_client,
{git, "https://github.com/rbkmoney/bouncer_client_erlang.git",
{branch, "master"}
}
},
{erl_health,
{git, "https://github.com/rbkmoney/erlang-health.git",
{branch, "master"}

View File

@ -2,6 +2,14 @@
[{<<"accept">>,{pkg,<<"accept">>,<<"0.3.5">>},2},
{<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},1},
{<<"bear">>,{pkg,<<"bear">>,<<"0.8.7">>},3},
{<<"bouncer_client">>,
{git,"git@github.com:rbkmoney/bouncer_client_erlang.git",
{ref,"d053efc9a3d9cbf97b2ca9c7a7302b4dd6097d18"}},
0},
{<<"bouncer_proto">>,
{git,"git@github.com:rbkmoney/bouncer-proto.git",
{ref,"298356b934e097393593785560c04bfa152ea0b5"}},
0},
{<<"cache">>,{pkg,<<"cache">>,<<"2.2.0">>},1},
{<<"certifi">>,{pkg,<<"certifi">>,<<"2.3.1">>},1},
{<<"cg_mon">>,
@ -63,6 +71,10 @@
{ref,"d814d6948d4ff13f6f41d12c6613f59c805750b2"}},
0},
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.0.2">>},1},
{<<"org_management_proto">>,
{git,"git@github.com:rbkmoney/org-management-proto.git",
{ref,"06c5c8430e445cb7874e54358e457cbb5697fc32"}},
1},
{<<"parse_trans">>,
{git,"https://github.com/rbkmoney/parse_trans.git",
{ref,"5ee45f5bfa6c04329bea3281977b774f04c89f11"}},