diff --git a/apps/shortener/src/shortener.app.src b/apps/shortener/src/shortener.app.src index c14ed44..fb5fbdb 100644 --- a/apps/shortener/src/shortener.app.src +++ b/apps/shortener/src/shortener.app.src @@ -14,6 +14,8 @@ cowboy_access_log, swag_server, mg_proto, + bouncer_proto, + bouncer_client, woody, woody_user_identity, erl_health, diff --git a/apps/shortener/src/shortener_auth.erl b/apps/shortener/src/shortener_auth.erl index 08fe37d..33e601f 100644 --- a/apps/shortener/src/shortener_auth.erl +++ b/apps/shortener/src/shortener_auth.erl @@ -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. diff --git a/apps/shortener/src/shortener_authorizer_jwt.erl b/apps/shortener/src/shortener_authorizer_jwt.erl index 2b647cc..cb8900f 100644 --- a/apps/shortener/src/shortener_authorizer_jwt.erl +++ b/apps/shortener/src/shortener_authorizer_jwt.erl @@ -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 -> diff --git a/apps/shortener/src/shortener_bouncer_client.erl b/apps/shortener/src/shortener_bouncer_client.erl new file mode 100644 index 0000000..455e677 --- /dev/null +++ b/apps/shortener/src/shortener_bouncer_client.erl @@ -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 + } + } + } + } + }}. diff --git a/apps/shortener/src/shortener_handler.erl b/apps/shortener/src/shortener_handler.erl index b6dec89..50ce875 100644 --- a/apps/shortener/src/shortener_handler.erl +++ b/apps/shortener/src/shortener_handler.erl @@ -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) -> diff --git a/apps/shortener/src/shortener_slug.erl b/apps/shortener/src/shortener_slug.erl index b615047..c762119 100644 --- a/apps/shortener/src/shortener_slug.erl +++ b/apps/shortener/src/shortener_slug.erl @@ -35,6 +35,7 @@ }. -export_type([slug/0]). +-export_type([id/0]). -export_type([owner/0]). -type ctx() :: woody_context:ctx(). diff --git a/apps/shortener/test/shortener_auth_SUITE.erl b/apps/shortener/test/shortener_auth_SUITE.erl new file mode 100644 index 0000000..dd16634 --- /dev/null +++ b/apps/shortener/test/shortener_auth_SUITE.erl @@ -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. diff --git a/apps/shortener/test/shortener_auth_SUITE_data/keys/local/private.pem b/apps/shortener/test/shortener_auth_SUITE_data/keys/local/private.pem new file mode 100644 index 0000000..4e6d12c --- /dev/null +++ b/apps/shortener/test/shortener_auth_SUITE_data/keys/local/private.pem @@ -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----- diff --git a/apps/shortener/test/shortener_auth_SUITE_data/keys/local/public.pem b/apps/shortener/test/shortener_auth_SUITE_data/keys/local/public.pem new file mode 100644 index 0000000..c2f50d4 --- /dev/null +++ b/apps/shortener/test/shortener_auth_SUITE_data/keys/local/public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg +7F/ZMtGbPFikJnnvRWvFB5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/apps/shortener/test/shortener_ct_helper.erl b/apps/shortener/test/shortener_ct_helper.erl new file mode 100644 index 0000000..706a0f9 --- /dev/null +++ b/apps/shortener/test/shortener_ct_helper.erl @@ -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 + } + } + }} + ]. diff --git a/apps/shortener/test/shortener_general_SUITE.erl b/apps/shortener/test/shortener_general_SUITE.erl index fa59884..c6768f2 100644 --- a/apps/shortener/test/shortener_general_SUITE.erl +++ b/apps/shortener/test/shortener_general_SUITE.erl @@ -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 - } - } - }} - ]. diff --git a/apps/shortener/test/shortener_mock_service.erl b/apps/shortener/test/shortener_mock_service.erl new file mode 100644 index 0000000..2a1cd02 --- /dev/null +++ b/apps/shortener/test/shortener_mock_service.erl @@ -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). diff --git a/config/sys.config b/config/sys.config index 101f3c7..e92d2ff 100644 --- a/config/sys.config +++ b/config/sys.config @@ -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, [ diff --git a/rebar.config b/rebar.config index 86b12f7..92d503f 100644 --- a/rebar.config +++ b/rebar.config @@ -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"} diff --git a/rebar.lock b/rebar.lock index 398fd39..5b945a9 100644 --- a/rebar.lock +++ b/rebar.lock @@ -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"}},