From f8a6e83eae4cfab538e1e78448122def716d86bc Mon Sep 17 00:00:00 2001 From: Alexey S Date: Mon, 28 Mar 2022 15:50:06 +0300 Subject: [PATCH] TD-177: token-keeper auth (#7) --- .github/workflows/erlang-checks.yaml | 2 +- .gitignore | 1 + apps/shortener/src/shortener.app.src | 1 + apps/shortener/src/shortener.erl | 3 +- apps/shortener/src/shortener_acl.erl | 234 ---------- apps/shortener/src/shortener_auth.erl | 191 ++++---- .../src/shortener_authorizer_jwt.erl | 422 ------------------ apps/shortener/src/shortener_bouncer.erl | 50 +++ .../src/shortener_bouncer_client.erl | 31 -- .../src/shortener_bouncer_context.erl | 65 +++ apps/shortener/src/shortener_handler.erl | 90 ++-- apps/shortener/test/shortener_auth_SUITE.erl | 249 +++++------ .../shortener/test/shortener_bouncer_data.hrl | 14 + apps/shortener/test/shortener_ct_helper.erl | 71 +-- .../test/shortener_ct_helper_bouncer.erl | 100 +++++ .../test/shortener_ct_helper_token_keeper.erl | 121 +++++ .../test/shortener_general_SUITE.erl | 202 ++++----- .../shortener/test/shortener_mock_service.erl | 7 +- .../test/shortener_token_keeper_data.hrl | 9 + config/sys.config | 12 +- rebar.config | 7 +- rebar.lock | 26 +- 22 files changed, 771 insertions(+), 1137 deletions(-) delete mode 100644 apps/shortener/src/shortener_acl.erl delete mode 100644 apps/shortener/src/shortener_authorizer_jwt.erl create mode 100644 apps/shortener/src/shortener_bouncer.erl delete mode 100644 apps/shortener/src/shortener_bouncer_client.erl create mode 100644 apps/shortener/src/shortener_bouncer_context.erl create mode 100644 apps/shortener/test/shortener_bouncer_data.hrl create mode 100644 apps/shortener/test/shortener_ct_helper_bouncer.erl create mode 100644 apps/shortener/test/shortener_ct_helper_token_keeper.erl create mode 100644 apps/shortener/test/shortener_token_keeper_data.hrl diff --git a/.github/workflows/erlang-checks.yaml b/.github/workflows/erlang-checks.yaml index 0ef752b..4bb7abf 100644 --- a/.github/workflows/erlang-checks.yaml +++ b/.github/workflows/erlang-checks.yaml @@ -6,7 +6,7 @@ on: - 'master' - 'epic/**' pull_request: - branches: [ '**' ] + branches: ['**'] jobs: setup: diff --git a/.gitignore b/.gitignore index 98e9e12..e7b9601 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ deps *.beam *.plt erl_crash.dump +rebar3.crashdump ebin/*.beam rel/example_project .concrete/DEV_MODE diff --git a/apps/shortener/src/shortener.app.src b/apps/shortener/src/shortener.app.src index 18b4782..1461a09 100644 --- a/apps/shortener/src/shortener.app.src +++ b/apps/shortener/src/shortener.app.src @@ -16,6 +16,7 @@ mg_proto, bouncer_proto, bouncer_client, + token_keeper_client, woody, woody_user_identity, erl_health diff --git a/apps/shortener/src/shortener.erl b/apps/shortener/src/shortener.erl index 9a31b95..67ad1b0 100644 --- a/apps/shortener/src/shortener.erl +++ b/apps/shortener/src/shortener.erl @@ -82,7 +82,6 @@ get_processor_childspecs(Opts, AdditionalRoutes) -> ]. get_api_childspecs(Opts, HealthRoutes) -> - AuthorizerSpec = shortener_authorizer_jwt:get_child_spec(maps:get(authorizer, Opts)), HealthRouter = [{'_', HealthRoutes}], SwaggerServerSpec = shortener_swagger_server:child_spec(shortener_handler, Opts, HealthRouter), - [AuthorizerSpec, SwaggerServerSpec]. + [SwaggerServerSpec]. diff --git a/apps/shortener/src/shortener_acl.erl b/apps/shortener/src/shortener_acl.erl deleted file mode 100644 index f24bbce..0000000 --- a/apps/shortener/src/shortener_acl.erl +++ /dev/null @@ -1,234 +0,0 @@ --module(shortener_acl). - -%% - --opaque t() :: [{{priority(), scope()}, [permission()]}]. - --type priority() :: integer(). --type scope() :: [resource() | {resource(), resource_id()}, ...]. --type resource() :: atom(). --type resource_id() :: binary(). --type permission() :: read | write. - --export_type([t/0]). --export_type([scope/0]). --export_type([permission/0]). - --export([new/0]). --export([to_list/1]). --export([from_list/1]). --export([insert_scope/3]). --export([remove_scope/3]). - --export([match/2]). - --export([decode/1]). --export([encode/1]). - -%% - --spec new() -> t(). -new() -> - []. - --spec to_list(t()) -> [{scope(), permission()}]. -to_list(ACL) -> - [{S, P} || {{_, S}, P} <- ACL]. - --spec from_list([{scope(), permission()}]) -> t(). -from_list(L) -> - lists:foldl(fun({S, P}, ACL) -> insert_scope(S, P, ACL) end, new(), L). - --spec insert_scope(scope(), permission(), t()) -> t(). -insert_scope(Scope, Permission, ACL) -> - Priority = compute_priority(Scope, Permission), - insert({{Priority, Scope}, [Permission]}, ACL). - -insert({PS, _} = V, [{PS0, _} = V0 | Vs]) when PS < PS0 -> - [V0 | insert(V, Vs)]; -insert({PS, Perms}, [{PS, Perms0} | Vs]) -> - % NOTE squashing permissions of entries with the same scope - [{PS, lists:usort(Perms ++ Perms0)} | Vs]; -insert({PS, _} = V, [{PS0, _} | _] = Vs) when PS > PS0 -> - [V | Vs]; -insert(V, []) -> - [V]. - --spec remove_scope(scope(), permission(), t()) -> t(). -remove_scope(Scope, Permission, ACL) -> - Priority = compute_priority(Scope, Permission), - remove({{Priority, Scope}, [Permission]}, ACL). - -remove(V, [V | Vs]) -> - Vs; -remove({PS, Perms}, [{PS, Perms0} | Vs]) -> - [{PS, Perms0 -- Perms} | Vs]; -remove(V, [V0 | Vs]) -> - [V0 | remove(V, Vs)]; -remove(_, []) -> - []. - -compute_priority(Scope, Permission) -> - % NOTE - % Scope priority depends on the following attributes, in the order of decreasing - % importance: - % 1. Depth, deeper is more important - % 2. Scope element specificity, element marked with an ID is more important - compute_scope_priority(Scope) + compute_permission_priority(Permission). - -compute_scope_priority(Scope) when length(Scope) > 0 -> - compute_scope_priority(Scope, get_resource_hierarchy(), 0); -compute_scope_priority(Scope) -> - error({badarg, {scope, Scope}}). - -compute_scope_priority([{Resource, _ID} | Rest], H, P) -> - compute_scope_priority(Rest, delve(Resource, H), P * 10 + 2); -compute_scope_priority([Resource | Rest], H, P) -> - compute_scope_priority(Rest, delve(Resource, H), P * 10 + 1); -compute_scope_priority([], _, P) -> - P * 10. - -compute_permission_priority(read) -> - 0; -compute_permission_priority(write) -> - 0; -compute_permission_priority(V) -> - error({badarg, {permission, V}}). - -%% - --spec match(scope(), t()) -> [permission()]. -match(Scope, ACL) when length(Scope) > 0 -> - match_rules(Scope, ACL); -match(Scope, _) -> - error({badarg, {scope, Scope}}). - -match_rules(Scope, [{{_Priority, ScopePrefix}, Permissions} | Rest]) -> - % NOTE - % The `Scope` matches iff `ScopePrefix` is scope prefix of the `Scope`. - % An element of a scope matches corresponding element of a scope prefix - % according to the following rules: - % 1. Scope prefix element marked with resource and ID matches exactly the same - % scope element. - % 2. Scope prefix element marked with only resource matches any scope element - % marked with the same resource. - case match_scope(Scope, ScopePrefix) of - true -> - Permissions; - false -> - match_rules(Scope, Rest) - end; -match_rules(_Scope, []) -> - []. - -match_scope([V | Ss], [V | Ss0]) -> - match_scope(Ss, Ss0); -match_scope([{V, _ID} | Ss], [V | Ss0]) -> - match_scope(Ss, Ss0); -match_scope(_, []) -> - true; -match_scope(_, _) -> - false. - -%% - --spec decode([binary()]) -> t(). -decode(V) -> - lists:foldl(fun decode_entry/2, new(), V). - -decode_entry(V, ACL) -> - case binary:split(V, <<":">>, [global]) of - [V1, V2] -> - Scope = decode_scope(V1), - Permission = decode_permission(V2), - insert_scope(Scope, Permission, ACL); - _ -> - error({badarg, {role, V}}) - end. - -decode_scope(V) -> - Hierarchy = get_resource_hierarchy(), - decode_scope_frags(binary:split(V, <<".">>, [global]), Hierarchy). - -decode_scope_frags([V1, V2 | Vs], H) -> - {Resource, H1} = decode_scope_frag_resource(V1, V2, H), - [Resource | decode_scope_frags(Vs, H1)]; -decode_scope_frags([V], H) -> - decode_scope_frags([V, <<"*">>], H); -decode_scope_frags([], _) -> - []. - -decode_scope_frag_resource(V, <<"*">>, H) -> - R = decode_resource(V), - {R, delve(R, H)}; -decode_scope_frag_resource(V, ID, H) -> - R = decode_resource(V), - {{R, ID}, delve(R, H)}. - -decode_resource(V) -> - try - binary_to_existing_atom(V, utf8) - catch - error:badarg -> - error({badarg, {resource, V}}) - end. - -decode_permission(<<"read">>) -> - read; -decode_permission(<<"write">>) -> - write; -decode_permission(V) -> - error({badarg, {permission, V}}). - -%% - --spec encode(t()) -> [binary()]. -encode(ACL) -> - lists:flatmap(fun encode_entry/1, ACL). - -encode_entry({{_Priority, Scope}, Permissions}) -> - S = encode_scope(Scope), - [ - begin - P = encode_permission(Permission), - <> - end - || Permission <- Permissions - ]. - -encode_scope(Scope) -> - Hierarchy = get_resource_hierarchy(), - genlib_string:join($., encode_scope_frags(Scope, Hierarchy)). - -encode_scope_frags([{Resource, ID} | Rest], H) -> - [encode_resource(Resource), ID | encode_scope_frags(Rest, delve(Resource, H))]; -encode_scope_frags([Resource], H) -> - _ = delve(Resource, H), - [encode_resource(Resource)]; -encode_scope_frags([Resource | Rest], H) -> - [encode_resource(Resource), <<"*">> | encode_scope_frags(Rest, delve(Resource, H))]; -encode_scope_frags([], _) -> - []. - -encode_resource(V) -> - atom_to_binary(V, utf8). - -encode_permission(read) -> - <<"read">>; -encode_permission(write) -> - <<"write">>. - -%% - -get_resource_hierarchy() -> - #{ - 'shortened-urls' => #{} - }. - -delve(Resource, Hierarchy) -> - case maps:find(Resource, Hierarchy) of - {ok, Sub} -> - Sub; - error -> - error({badarg, {resource, Resource}}) - end. diff --git a/apps/shortener/src/shortener_auth.erl b/apps/shortener/src/shortener_auth.erl index ceee0ce..a45b279 100644 --- a/apps/shortener/src/shortener_auth.erl +++ b/apps/shortener/src/shortener_auth.erl @@ -1,113 +1,120 @@ -module(shortener_auth). --export([authorize_api_key/2]). --export([authorize_operation/4]). +-define(APP, shortener). --type context() :: shortener_authorizer_jwt:t(). --type claims() :: shortener_authorizer_jwt:claims(). +-export([get_subject_id/1]). +-export([get_party_id/1]). +-export([get_user_id/1]). +-export([get_user_email/1]). --export_type([context/0]). --export_type([claims/0]). +-export([preauthorize_api_key/1]). +-export([authenticate_api_key/3]). +-export([authorize_operation/3]). --spec authorize_api_key(swag_server_ushort:operation_id(), swag_server_ushort:api_key()) -> - {true, Context :: context()} | false. -authorize_api_key(OperationID, ApiKey) -> +-export_type([resolution/0]). +-export_type([preauth_context/0]). +-export_type([auth_context/0]). + +%% + +-type token_type() :: bearer. +-type auth_context() :: {authorized, token_keeper_client:auth_data()}. +-type preauth_context() :: {unauthorized, {token_type(), token_keeper_client:token()}}. + +-type resolution() :: + allowed + | forbidden. + +-define(AUTHORIZED(Ctx), {authorized, Ctx}). +-define(UNAUTHORIZED(Ctx), {unauthorized, Ctx}). + +%% + +-spec get_subject_id(auth_context()) -> binary() | undefined. +get_subject_id(AuthContext) -> + case get_party_id(AuthContext) of + PartyId when is_binary(PartyId) -> + PartyId; + undefined -> + get_user_id(AuthContext) + end. + +-spec get_party_id(auth_context()) -> binary() | undefined. +get_party_id(?AUTHORIZED(#{metadata := Metadata})) -> + get_metadata(get_metadata_mapped_key(party_id), Metadata). + +-spec get_user_id(auth_context()) -> binary() | undefined. +get_user_id(?AUTHORIZED(#{metadata := Metadata})) -> + get_metadata(get_metadata_mapped_key(user_id), Metadata). + +-spec get_user_email(auth_context()) -> binary() | undefined. +get_user_email(?AUTHORIZED(#{metadata := Metadata})) -> + get_metadata(get_metadata_mapped_key(user_email), Metadata). + +%% + +-spec preauthorize_api_key(swag_server_ushort:api_key()) -> {ok, preauth_context()} | {error, _Reason}. +preauthorize_api_key(ApiKey) -> case parse_api_key(ApiKey) of - {ok, {Type, Credentials}} -> - case authorize_api_key(OperationID, Type, Credentials) of - {ok, Context} -> - {true, Context}; - {error, Error} -> - _ = log_auth_error(OperationID, Error), - false - end; + {ok, Token} -> + {ok, ?UNAUTHORIZED(Token)}; {error, Error} -> - _ = log_auth_error(OperationID, Error), - false + {error, Error} end. -log_auth_error(OperationID, Error) -> - logger:info("API Key authorization failed for ~p due to ~p", [OperationID, Error]). +-spec authenticate_api_key(preauth_context(), token_keeper_client:token_context(), woody_context:ctx()) -> + {ok, auth_context()} | {error, _Reason}. +authenticate_api_key(?UNAUTHORIZED({TokenType, Token}), TokenContext, WoodyContext) -> + authenticate_token_by_type(TokenType, Token, TokenContext, WoodyContext). --spec parse_api_key(swag_server_ushort:api_key()) -> - {ok, {bearer, Credentials :: binary()}} | {error, Reason :: atom()}. -parse_api_key(ApiKey) -> - case ApiKey of - <<"Bearer ", Credentials/binary>> -> - {ok, {bearer, Credentials}}; - _ -> - {error, unsupported_auth_scheme} +authenticate_token_by_type(bearer, Token, TokenContext, WoodyContext) -> + Authenticator = token_keeper_client:authenticator(WoodyContext), + case token_keeper_authenticator:authenticate(Token, TokenContext, Authenticator) of + {ok, AuthData} -> + {ok, ?AUTHORIZED(AuthData)}; + {error, TokenKeeperError} -> + _ = logger:warning("Token keeper authorization failed: ~p", [TokenKeeperError]), + {error, {auth_failed, TokenKeeperError}} end. --spec authorize_api_key(swag_server_ushort:operation_id(), Type :: atom(), Credentials :: binary()) -> - {ok, context()} | {error, Reason :: atom()}. -authorize_api_key(_OperationID, bearer, Token) -> - shortener_authorizer_jwt:verify(Token). - --spec authorize_operation(OperationID, Slug, ReqContext, WoodyCtx) -> ok | {error, forbidden} when - OperationID :: swag_server_ushort:operation_id(), - Slug :: shortener_slug:slug() | no_slug, +-spec authorize_operation(Prototypes, ReqContext, WoodyCtx) -> resolution() when + Prototypes :: shortener_bouncer_context:prototypes(), ReqContext :: swag_server_ushort: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), - ID = get_slug_id(Slug), - Email = maps:get(<<"email">>, Claims, undefined), - #{ - id := SubjectID, - realm := Realm - } = woody_user_identity:get(WoodyCtx), - Acc0 = bouncer_context_helpers:make_env_fragment(#{}), - Acc1 = bouncer_context_helpers:add_auth( - #{ - method => <<"SessionToken">>, - expiration => genlib_rfc3339:format(ExpiresAt, second), - token => #{id => shortener_authorizer_jwt:get_token_id(Claims)} - }, - Acc0 +authorize_operation(Prototypes, SwagContext, WoodyContext) -> + AuthContext = get_auth_context(SwagContext), + Fragments = shortener_bouncer:gather_context_fragments( + get_token_keeper_fragment(AuthContext), + get_user_id(AuthContext), + SwagContext, + WoodyContext ), - Acc2 = bouncer_context_helpers:add_user( - #{ - id => SubjectID, - realm => #{id => Realm}, - email => Email - }, - Acc1 - ), - Acc3 = bouncer_context_helpers:add_requester(#{ip => IpAddress}, Acc2), - Acc4 = shortener_bouncer_client:add_shortener(genlib:to_binary(OperationID), ID, Owner, Acc3), - JudgeContext = #{fragments => #{<<"shortener">> => Acc4}}, - {ok, RulesetID} = application:get_env(shortener, bouncer_ruleset_id), - case bouncer_client:judge(RulesetID, JudgeContext, WoodyCtx) of - allowed -> - ok; - forbidden -> - {error, forbidden} - end. + Fragments1 = shortener_bouncer_context:build(Prototypes, Fragments), + shortener_bouncer:judge(Fragments1, WoodyContext). --spec get_slug_owner(shortener_slug:slug() | no_slug) -> shortener_slug:owner() | undefined. -get_slug_owner(no_slug) -> - undefined; -get_slug_owner(#{owner := Owner}) -> - Owner. +%% --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_token_keeper_fragment(?AUTHORIZED(#{context := Context})) -> + Context. + +%% + +parse_api_key(<<"Bearer ", Token/binary>>) -> + {ok, {bearer, Token}}; +parse_api_key(_) -> + {error, unsupported_auth_scheme}. 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. +%% + +get_metadata(Key, Metadata) -> + maps:get(Key, Metadata, undefined). + +get_metadata_mapped_key(Key) -> + maps:get(Key, get_meta_mappings()). + +get_meta_mappings() -> + AuthConfig = genlib_app:env(?APP, auth_config), + maps:get(metadata_mappings, AuthConfig). diff --git a/apps/shortener/src/shortener_authorizer_jwt.erl b/apps/shortener/src/shortener_authorizer_jwt.erl deleted file mode 100644 index f02c267..0000000 --- a/apps/shortener/src/shortener_authorizer_jwt.erl +++ /dev/null @@ -1,422 +0,0 @@ -% TODO -% Extend interface to support proper keystore manipulation. Refactor it into a more -% general library along with `shortener_acl` and some parts of `shortener_auth`. - --module(shortener_authorizer_jwt). - -%% - --export([get_child_spec/1]). --export([init/1]). - --export([store_key/2]). - --export([issue/2]). --export([verify/1]). - --export([get_token_id/1]). - --define(CLAIM_TOKEN_ID, <<"jti">>). --define(CLAIM_SUBJECT_ID, <<"sub">>). --define(CLAIM_EXPIRES_AT, <<"exp">>). - -%% - --include_lib("jose/include/jose_jwk.hrl"). --include_lib("jose/include/jose_jwt.hrl"). - --type keyname() :: term(). --type kid() :: binary(). --type key() :: #jose_jwk{}. --type token() :: binary(). --type claims() :: #{binary() => term()}. -%% Added expiration to subject tuple as part of token service claims -%% NOTE -%% Hacky way to pass dialyzer. As it is a depricated auth method, I don't care --type subject() :: {subject_id(), shortener_acl:t(), pos_integer() | undefined} | {subject_id(), shortener_acl:t()}. --type subject_id() :: binary(). --type t() :: {subject(), claims()}. --type expiration() :: - {lifetime, Seconds :: pos_integer()} - | {deadline, UnixTs :: pos_integer()} - | unlimited. - --export_type([t/0]). --export_type([subject/0]). --export_type([claims/0]). --export_type([token/0]). --export_type([expiration/0]). - -%% - --type options() :: #{ - %% The set of keys used to sign issued tokens and verify signatures on such - %% tokens. - keyset => keyset(), - %% The name of a key used exclusively to sign any issued token. - %% If not set any token issue is destined to fail. - signee => keyname() -}. - --type keyset() :: #{ - keyname() => keysource() -}. - --type keysource() :: - {pem_file, file:filename()}. - --spec get_child_spec(options()) -> supervisor:child_spec() | no_return(). -get_child_spec(Options) -> - #{ - id => ?MODULE, - start => {supervisor, start_link, [?MODULE, parse_options(Options)]}, - type => supervisor - }. - -parse_options(Options) -> - Keyset = maps:get(keyset, Options, #{}), - _ = is_map(Keyset) orelse exit({invalid_option, keyset, Keyset}), - _ = genlib_map:foreach( - fun(K, V) -> - is_keysource(V) orelse exit({invalid_option, K, V}) - end, - Keyset - ), - Signee = maps:find(signee, Options), - {Keyset, Signee}. - -is_keysource({pem_file, Fn}) -> - is_list(Fn) orelse is_binary(Fn); -is_keysource(_) -> - false. - -%% - --spec init({keyset(), {ok, keyname()} | error}) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. -init({Keyset, Signee}) -> - ok = create_table(), - KeyInfos = maps:map(fun ensure_store_key/2, Keyset), - ok = select_signee(Signee, KeyInfos), - {ok, {#{}, []}}. - -ensure_store_key(Keyname, Source) -> - case store_key(Keyname, Source) of - {ok, KeyInfo} -> - KeyInfo; - {error, Reason} -> - _ = logger:error("Error importing key ~p: ~p", [Keyname, Reason]), - exit({import_error, Keyname, Source, Reason}) - end. - -select_signee({ok, Keyname}, KeyInfos) -> - case maps:find(Keyname, KeyInfos) of - {ok, #{sign := true}} -> - set_signee(Keyname); - {ok, KeyInfo} -> - _ = logger:error("Error setting signee: signing with ~p is not allowed", [Keyname]), - exit({invalid_signee, Keyname, KeyInfo}); - error -> - _ = logger:error("Error setting signee: no key named ~p", [Keyname]), - exit({nonexstent_signee, Keyname}) - end; -select_signee(error, _KeyInfos) -> - ok. - -%% - --type keyinfo() :: #{ - kid => kid(), - sign => boolean(), - verify => boolean() -}. - --spec store_key(keyname(), {pem_file, file:filename()}) -> {ok, keyinfo()} | {error, file:posix() | {unknown_key, _}}. -store_key(Keyname, {pem_file, Filename}) -> - store_key(Keyname, {pem_file, Filename}, #{ - kid => fun derive_kid_from_public_key_pem_entry/1 - }). - -derive_kid_from_public_key_pem_entry(JWK) -> - JWKPublic = jose_jwk:to_public(JWK), - {_Module, PublicKey} = JWKPublic#jose_jwk.kty, - {_PemEntry, Data, _} = public_key:pem_entry_encode('SubjectPublicKeyInfo', PublicKey), - jose_base64url:encode(crypto:hash(sha256, Data)). - --type store_opts() :: #{ - kid => fun((key()) -> kid()) -}. - --spec store_key(keyname(), {pem_file, file:filename()}, store_opts()) -> - {ok, keyinfo()} | {error, file:posix() | {unknown_key, _}}. -store_key(Keyname, {pem_file, Filename}, Opts) -> - case jose_jwk:from_pem_file(Filename) of - JWK = #jose_jwk{} -> - Key = construct_key(derive_kid(JWK, Opts), JWK), - ok = insert_key(Keyname, Key), - {ok, get_key_info(Key)}; - Error = {error, _} -> - Error - end. - -get_key_info(#{kid := KID, signer := Signer, verifier := Verifier}) -> - #{ - kid => KID, - sign => Signer /= undefined, - verify => Verifier /= undefined - }. - -derive_kid(JWK, #{kid := DeriveFun}) when is_function(DeriveFun, 1) -> - DeriveFun(JWK). - -construct_key(KID, JWK) -> - #{ - jwk => JWK, - kid => KID, - signer => - try - jose_jwk:signer(JWK) - catch - error:_ -> undefined - end, - verifier => - try - jose_jwk:verifier(JWK) - catch - error:_ -> undefined - end - }. - -%% - --spec issue(t(), expiration()) -> - {ok, token()} - | {error, nonexistent_signee}. -issue(Auth, Expiration) -> - case get_signee_key() of - Key = #{} -> - Claims = construct_final_claims(Auth, Expiration), - sign(Key, Claims); - undefined -> - {error, nonexistent_signee} - end. - -construct_final_claims({{Subject, ACL}, Claims}, Expiration) -> - maps:merge( - Claims#{ - ?CLAIM_TOKEN_ID => unique_id(), - ?CLAIM_SUBJECT_ID => Subject, - ?CLAIM_EXPIRES_AT => get_expires_at(Expiration) - }, - encode_roles(shortener_acl:encode(ACL)) - ). - -get_expires_at({lifetime, Lt}) -> - genlib_time:unow() + Lt; -get_expires_at({deadline, Dl}) -> - Dl; -get_expires_at(unlimited) -> - 0. - -unique_id() -> - <> = snowflake:new(), - genlib_format:format_int_base(ID, 62). - -sign(#{kid := KID, jwk := JWK, signer := #{} = JWS}, Claims) -> - JWT = jose_jwt:sign(JWK, JWS#{<<"kid">> => KID}, Claims), - {_Modules, Token} = jose_jws:compact(JWT), - {ok, Token}. - -%% - --spec verify(token()) -> - {ok, t()} - | {error, - {invalid_token, - badarg - | {badarg, term()} - | {missing, atom()} - | expired - | {malformed_acl, term()}} - | {nonexistent_key, kid()} - | invalid_operation - | invalid_signature}. -verify(Token) -> - try - {_, ExpandedToken} = jose_jws:expand(Token), - #{<<"protected">> := ProtectedHeader} = ExpandedToken, - Header = base64url_to_map(ProtectedHeader), - Alg = get_alg(Header), - KID = get_kid(Header), - verify(KID, Alg, ExpandedToken) - catch - %% from get_alg and get_kid - throw:Reason -> - {error, Reason}; - %% TODO we're losing error information here, e.g. stacktrace - error:badarg = Reason -> - {error, {invalid_token, Reason}}; - error:{badarg, _} = Reason -> - {error, {invalid_token, Reason}} - end. - -verify(KID, Alg, ExpandedToken) -> - case get_key_by_kid(KID) of - #{jwk := JWK, verifier := Algs} -> - _ = lists:member(Alg, Algs) orelse throw(invalid_operation), - verify(JWK, ExpandedToken); - undefined -> - {error, {nonexistent_key, KID}} - end. - -verify(JWK, ExpandedToken) -> - case jose_jwt:verify(JWK, ExpandedToken) of - {true, #jose_jwt{fields = Claims}, _JWS} -> - {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. - -validate_claims(Claims) -> - validate_claims(Claims, get_validators(), #{}). - -validate_claims(Claims, [{Name, Claim, Validator} | Rest], Acc) -> - V = Validator(Name, maps:get(Claim, Claims, undefined)), - validate_claims(Claims, Rest, Acc#{Name => V}); -validate_claims(Claims, [], Acc) -> - {Acc, Claims}. - -get_result({SubjectID, ExpiresAt}, {Roles, Claims}) -> - try - Subject = {SubjectID, shortener_acl:decode(Roles), ExpiresAt}, - {ok, {Subject, Claims}} - catch - error:{badarg, _} = Reason -> - throw({invalid_token, {malformed_acl, Reason}}) - end. - -get_kid(#{<<"kid">> := KID}) when is_binary(KID) -> - KID; -get_kid(#{}) -> - throw({invalid_token, {missing, kid}}). - -get_alg(#{<<"alg">> := Alg}) when is_binary(Alg) -> - Alg; -get_alg(#{}) -> - throw({invalid_token, {missing, alg}}). - -%% - -get_validators() -> - [ - {token_id, ?CLAIM_TOKEN_ID, fun check_presence/2}, - {subject_id, ?CLAIM_SUBJECT_ID, fun check_presence/2}, - {expires_at, ?CLAIM_EXPIRES_AT, fun check_expiration/2} - ]. - -check_presence(_, V) when is_binary(V) -> - V; -check_presence(C, undefined) -> - throw({invalid_token, {missing, C}}). - -check_expiration(_, Exp = 0) -> - Exp; -check_expiration(_, Exp) when is_integer(Exp) -> - case genlib_time:unow() of - % Now when Exp > Now -> - % Exp; - % Expiration check is disabled. - % See MSPF-563 for details - _Now -> - Exp - % _ -> - % throw({invalid_token, expired}) - end; -check_expiration(C, undefined) -> - throw({invalid_token, {missing, C}}); -check_expiration(C, V) -> - throw({invalid_token, {badarg, {C, V}}}). - -%% - -encode_roles(Roles) -> - #{ - <<"resource_access">> => #{ - <<"url-shortener">> => #{ - <<"roles">> => Roles - } - } - }. - -decode_roles( - Claims = #{ - <<"resource_access">> := #{ - <<"url-shortener">> := #{ - <<"roles">> := Roles - } - } - } -) when is_list(Roles) -> - {Roles, maps:remove(<<"resource_access">>, Claims)}; -decode_roles(_) -> - throw({invalid_token, {missing, acl}}). - -%% - --spec get_token_id(claims()) -> binary(). -get_token_id(Claims) -> - maps:get(?CLAIM_TOKEN_ID, Claims). - -%% - -insert_key(Keyname, Key = #{kid := KID}) -> - insert_values(#{ - {keyname, Keyname} => Key, - {kid, KID} => Key - }). - -get_key_by_name(Keyname) -> - lookup_value({keyname, Keyname}). - -get_key_by_kid(KID) -> - lookup_value({kid, KID}). - -set_signee(Keyname) -> - insert_values(#{ - signee => {keyname, Keyname} - }). - -get_signee_key() -> - case lookup_value(signee) of - {keyname, Keyname} -> - get_key_by_name(Keyname); - undefined -> - undefined - end. - -%% - -base64url_to_map(V) when is_binary(V) -> - {ok, Decoded} = jose_base64url:decode(V), - jsx:decode(Decoded, [return_maps]). - -%% - --define(TABLE, ?MODULE). - -create_table() -> - _ = ets:new(?TABLE, [set, public, named_table, {read_concurrency, true}]), - ok. - -insert_values(Values) -> - true = ets:insert(?TABLE, maps:to_list(Values)), - ok. - -lookup_value(Key) -> - case ets:lookup(?TABLE, Key) of - [{Key, Value}] -> - Value; - [] -> - undefined - end. diff --git a/apps/shortener/src/shortener_bouncer.erl b/apps/shortener/src/shortener_bouncer.erl new file mode 100644 index 0000000..4d7f89d --- /dev/null +++ b/apps/shortener/src/shortener_bouncer.erl @@ -0,0 +1,50 @@ +-module(shortener_bouncer). + +-include_lib("bouncer_proto/include/bouncer_context_thrift.hrl"). + +-export([gather_context_fragments/4]). +-export([judge/2]). + +%% + +-spec gather_context_fragments( + TokenContextFragment :: token_keeper_client:context_fragment(), + UserID :: binary() | undefined, + RequestContext :: swag_server_ushort:request_context(), + WoodyContext :: woody_context:ctx() +) -> shortener_bouncer_context:fragments(). +gather_context_fragments(TokenContextFragment, UserID, ReqCtx, WoodyCtx) -> + {Base, External0} = shortener_bouncer_context:new(), + External1 = add_token_keeper_fragment(External0, TokenContextFragment), + External2 = maybe_add_userorg_fragment(UserID, External1, WoodyCtx), + {add_requester_context(ReqCtx, Base), External2}. + +-spec judge(shortener_bouncer_context:fragments(), woody_context:ctx()) -> shortener_auth:resolution(). +judge({Acc, External}, WoodyCtx) -> + {ok, RulesetID} = application:get_env(shortener, bouncer_ruleset_id), + JudgeContext = #{fragments => External#{<<"shortener">> => Acc}}, + bouncer_client:judge(RulesetID, JudgeContext, WoodyCtx). + +%% + +add_token_keeper_fragment(External, TokenContextFragment) -> + External#{<<"token-keeper">> => {encoded_fragment, TokenContextFragment}}. + +maybe_add_userorg_fragment(undefined, External, _WoodyCtx) -> + External; +maybe_add_userorg_fragment(UserID, External, WoodyCtx) -> + case bouncer_context_helpers:get_user_orgs_fragment(UserID, WoodyCtx) of + {ok, UserOrgsFragment} -> + External#{<<"userorg">> => UserOrgsFragment}; + {error, {user, notfound}} -> + External + end. + +-spec add_requester_context(swag_server_ushort:request_context(), shortener_bouncer_context:acc()) -> + shortener_bouncer_context:acc(). +add_requester_context(ReqCtx, FragmentAcc) -> + ClientPeer = maps:get(peer, ReqCtx, #{}), + bouncer_context_helpers:add_requester( + #{ip => maps:get(ip_address, ClientPeer, undefined)}, + FragmentAcc + ). diff --git a/apps/shortener/src/shortener_bouncer_client.erl b/apps/shortener/src/shortener_bouncer_client.erl deleted file mode 100644 index 72a745a..0000000 --- a/apps/shortener/src/shortener_bouncer_client.erl +++ /dev/null @@ -1,31 +0,0 @@ --module(shortener_bouncer_client). - --include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl"). --include_lib("bouncer_proto/include/bouncer_base_thrift.hrl"). - -%% API - --export([add_shortener/4]). - -%% - --type operation_id() :: binary(). --type id() :: shortener_slug:id(). --type owner() :: shortener_slug:owner(). - --spec add_shortener(operation_id(), id() | undefined, owner() | undefined, bouncer_client:context_fragment()) -> - bouncer_client:context_fragment(). -add_shortener(OperationID, ID, OwnerID, ContextFragment) -> - ContextFragment#bctx_v1_ContextFragment{ - shortener = #bctx_v1_ContextUrlShortener{ - op = #bctx_v1_UrlShortenerOperation{ - id = OperationID, - shortened_url = #bctx_v1_ShortenedUrl{ - id = ID, - owner = #bouncer_base_Entity{ - id = OwnerID - } - } - } - } - }. diff --git a/apps/shortener/src/shortener_bouncer_context.erl b/apps/shortener/src/shortener_bouncer_context.erl new file mode 100644 index 0000000..6219110 --- /dev/null +++ b/apps/shortener/src/shortener_bouncer_context.erl @@ -0,0 +1,65 @@ +-module(shortener_bouncer_context). + +-include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl"). + +-type fragment() :: bouncer_client:context_fragment(). +-type acc() :: bouncer_context_helpers:context_fragment(). + +-type fragments() :: {acc(), _ExternalFragments :: #{_ID => fragment()}}. + +-export_type([fragment/0]). +-export_type([acc/0]). +-export_type([fragments/0]). + +-type prototypes() :: [ + {operation, prototype_operation()} +]. + +-type prototype_operation() :: #{ + id := swag_server_ushort:operation_id(), + slug => shortener_slug:slug() +}. + +-export_type([prototypes/0]). +-export_type([prototype_operation/0]). + +-export([new/0]). +-export([build/2]). + +%% + +-spec new() -> fragments(). +new() -> + {mk_base_fragment(), #{}}. + +mk_base_fragment() -> + bouncer_context_helpers:make_env_fragment(#{ + now => genlib_rfc3339:format(genlib_time:unow(), second), + deployment => #{id => genlib_app:env(shortener, deployment, undefined)} + }). + +-spec build(prototypes(), fragments()) -> fragments(). +build(Prototypes, {Acc0, External}) -> + Acc1 = lists:foldl(fun({T, Params}, Acc) -> build(T, Params, Acc) end, Acc0, Prototypes), + {Acc1, External}. + +build(operation, #{id := OperationID} = Params, Acc) -> + Slug = maps:get(slug, Params, #{}), + Acc#bctx_v1_ContextFragment{ + shortener = #bctx_v1_ContextUrlShortener{ + op = #bctx_v1_UrlShortenerOperation{ + id = operation_id_to_binary(OperationID), + shortened_url = #bctx_v1_ShortenedUrl{ + id = maps:get(id, Slug, undefined), + owner = #bouncer_base_Entity{ + id = maps:get(owner, Slug, undefined) + } + } + } + } + }. + +%% + +operation_id_to_binary(V) -> + erlang:atom_to_binary(V, utf8). diff --git a/apps/shortener/src/shortener_handler.erl b/apps/shortener/src/shortener_handler.erl index b77192d..0ff34f7 100644 --- a/apps/shortener/src/shortener_handler.erl +++ b/apps/shortener/src/shortener_handler.erl @@ -34,26 +34,37 @@ swag_server_ushort:request_context(), swag_server_ushort:handler_opts(_) ) -> - Result :: false | {true, shortener_auth:context()}. + Result :: false | {true, shortener_auth:preauth_context()}. authorize_api_key(OperationID, ApiKey, _Context, _HandlerOpts) -> ok = scoper:add_scope('swag.server', #{operation => OperationID}), - shortener_auth:authorize_api_key(OperationID, ApiKey). + case shortener_auth:preauthorize_api_key(ApiKey) of + {ok, Context} -> + {true, Context}; + {error, Error} -> + _ = logger:info("API Key preauthorization failed for ~p due to ~p", [OperationID, Error]), + false + end. -spec handle_request(operation_id(), request_data(), request_ctx(), any()) -> {ok | error, swag_server_ushort:response()}. -handle_request(OperationID, Req, Context, _Opts) -> +handle_request(OperationID, Req, SwagContext0, _Opts) -> try - AuthContext = get_auth_context(Context), - WoodyCtx = create_woody_ctx(Req, AuthContext), - Slug = prefetch_slug(Req, WoodyCtx), - case shortener_auth:authorize_operation(OperationID, Slug, Context, WoodyCtx) of - ok -> - SubjectID = get_subject_id(AuthContext), - process_request(OperationID, Req, Slug, SubjectID, WoodyCtx); - {error, forbidden} -> + WoodyContext0 = create_woody_ctx(Req), + SwagContext1 = authenticate_api_key(SwagContext0, WoodyContext0), + AuthContext = get_auth_context(SwagContext1), + WoodyContext1 = put_user_identity(WoodyContext0, AuthContext), + Slug = prefetch_slug(Req, WoodyContext1), + case shortener_auth:authorize_operation(make_prototypes(OperationID, Slug), SwagContext1, WoodyContext1) of + allowed -> + SubjectID = shortener_auth:get_subject_id(AuthContext), + process_request(OperationID, Req, Slug, SubjectID, WoodyContext1); + forbidden -> {ok, {403, #{}, undefined}} end catch + throw:{token_auth_failed, Reason} -> + _ = logger:info("API Key authorization failed for ~p due to ~p", [OperationID, Reason]), + {error, {401, #{}, undefined}}; error:{woody_error, {Source, Class, Details}} -> {error, handle_woody_error(Source, Class, Details)} after @@ -89,28 +100,33 @@ prefetch_slug(#{'shortenedUrlID' := ID}, WoodyCtx) -> prefetch_slug(_Req, _WoodyCtx) -> no_slug. -create_woody_ctx(#{'X-Request-ID' := RequestID}, AuthContext) -> - RpcID = woody_context:new_rpc_id(genlib:to_binary(RequestID)), - WoodyCtx = woody_context:new(RpcID), - woody_user_identity:put(collect_user_identity(AuthContext), WoodyCtx). - -collect_user_identity(AuthContext) -> - genlib_map:compact(#{ - id => get_subject_id(AuthContext), - realm => ?REALM, - email => get_claim(<<"email">>, AuthContext, undefined), - username => get_claim(<<"name">>, AuthContext, undefined) - }). - -get_subject_id({{SubjectID, _ACL, _ExpiresAt}, _}) -> - SubjectID. - -get_claim(ClaimName, {_Subject, Claims}, Default) -> - maps:get(ClaimName, Claims, Default). +make_prototypes(OperationID, no_slug) -> + [{operation, #{id => OperationID}}]; +make_prototypes(OperationID, Slug) -> + [ + {operation, #{ + id => OperationID, + slug => Slug + }} + ]. get_auth_context(#{auth_context := AuthContext}) -> AuthContext. +create_woody_ctx(#{'X-Request-ID' := RequestID}) -> + RpcID = woody_context:new_rpc_id(genlib:to_binary(RequestID)), + woody_context:new(RpcID). + +put_user_identity(WoodyContext, AuthContext) -> + woody_user_identity:put(collect_user_identity(AuthContext), WoodyContext). + +collect_user_identity(AuthContext) -> + genlib_map:compact(#{ + id => shortener_auth:get_subject_id(AuthContext), + realm => ?REALM, + email => shortener_auth:get_user_email(AuthContext) + }). + handle_woody_error(_Source, result_unexpected, _Details) -> {500, #{}, <<>>}; handle_woody_error(_Source, resource_unavailable, _Details) -> @@ -118,6 +134,22 @@ handle_woody_error(_Source, resource_unavailable, _Details) -> handle_woody_error(_Source, result_unknown, _Details) -> {504, #{}, <<>>}. +authenticate_api_key(SwagContext = #{auth_context := PreAuthContext}, WoodyContext) -> + case shortener_auth:authenticate_api_key(PreAuthContext, make_token_context(SwagContext), WoodyContext) of + {ok, AuthContext} -> + SwagContext#{auth_context => AuthContext}; + {error, Error} -> + throw({token_auth_failed, Error}) + end. + +make_token_context(#{cowboy_req := CowboyReq}) -> + case cowboy_req:header(<<"origin">>, CowboyReq) of + Origin when is_binary(Origin) -> + #{request_origin => Origin}; + undefined -> + #{} + end. + %% -spec process_request(operation_id(), request_data(), shortener_slug:slug(), subject_id(), woody_context:ctx()) -> diff --git a/apps/shortener/test/shortener_auth_SUITE.erl b/apps/shortener/test/shortener_auth_SUITE.erl index 4a9010f..4f93ea5 100644 --- a/apps/shortener/test/shortener_auth_SUITE.erl +++ b/apps/shortener/test/shortener_auth_SUITE.erl @@ -1,8 +1,13 @@ -module(shortener_auth_SUITE). -include_lib("bouncer_proto/include/bouncer_decisions_thrift.hrl"). --include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl"). -include_lib("bouncer_proto/include/bouncer_base_thrift.hrl"). +-include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl"). + +-include_lib("shortener_token_keeper_data.hrl"). +-include_lib("shortener_bouncer_data.hrl"). + +-export([init/1]). -export([all/0]). -export([groups/0]). @@ -26,6 +31,9 @@ -define(config(Key, C), (element(2, lists:keyfind(Key, 1, C)))). +-define(AUTH_TOKEN, <<"LETMEIN">>). +-define(USER_EMAIL, <<"bla@bla.ru">>). + -spec all() -> [{atom(), test_case_name()} | test_case_name()]. all() -> [ @@ -44,6 +52,10 @@ groups() -> ]} ]. +-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. +init([]) -> + {ok, {#{strategy => one_for_all, intensity => 1, period => 1}, []}}. + -spec init_per_suite(config()) -> config(). init_per_suite(C) -> % _ = dbg:tracer(), @@ -55,8 +67,7 @@ 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}, @@ -72,8 +83,7 @@ init_per_group(_Group, C) -> shortener, shortener_ct_helper:get_app_config( ?config(port, C), - ?config(netloc, C), - get_keysource("keys/local/private.pem", C) + ?config(netloc, C) ) ), [ @@ -84,20 +94,20 @@ init_per_group(_Group, C) -> 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)). + _ = genlib_app:stop_unload_applications(?config(suite_apps, C)), + ok. -spec init_per_testcase(test_case_name(), config()) -> config(). init_per_testcase(_Name, C) -> - shortener_ct_helper:with_test_sup(C). + SupPid = shortener_ct_helper:start_mocked_service_sup(?MODULE), + _ = shortener_ct_helper_bouncer:mock_client(SupPid), + [{test_sup, SupPid} | C]. -spec end_per_testcase(test_case_name(), config()) -> ok. end_per_testcase(_Name, C) -> - shortener_ct_helper:stop_test_sup(C), + _ = shortener_ct_helper:stop_mocked_service_sup(?config(test_sup, C)), ok. %% @@ -116,138 +126,106 @@ failed_authorization(C) -> {ok, 401, _, _} = get_shortened_url(<<"42">>, C1). insufficient_permissions(C) -> - _ = shortener_ct_helper:mock_services( - [ - {bouncer, fun('Judge', _) -> - {ok, #bdcs_Judgement{ - resolution = {forbidden, #bdcs_ResolutionForbidden{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_forbidden(), + ?config(test_sup, C) ), - C1 = set_api_auth_token(insufficient_permissions, C), + C1 = set_api_auth_token(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}) -> - DecodedFragment = decode_shortener(Fragments), - case get_operation_id(DecodedFragment) of - <<"ShortenUrl">> -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - <<"GetShortenedUrl">> -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - <<"DeleteShortenedUrl">> -> - {ok, #bdcs_Judgement{ - resolution = {forbidden, #bdcs_ResolutionForbidden{}} - }} - end - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + fun(ContextFragment) -> + case get_operation_id(ContextFragment) of + <<"ShortenUrl">> -> {ok, ?JUDGEMENT(?ALLOWED)}; + <<"GetShortenedUrl">> -> {ok, ?JUDGEMENT(?ALLOWED)}; + <<"DeleteShortenedUrl">> -> {ok, ?JUDGEMENT(?FORBIDDEN)} + end + end, + ?config(test_sup, C) ), - C1 = set_api_auth_token(readonly_permissions, C), + C1 = set_api_auth_token(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}) -> - DecodedFragment = decode_shortener(Fragments), - case get_operation_id(DecodedFragment) of - <<"ShortenUrl">> -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - <<"GetShortenedUrl">> -> - case get_owner_info(DecodedFragment) of - {ID, ID} -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - _ -> - {ok, #bdcs_Judgement{ - resolution = {forbidden, #bdcs_ResolutionForbidden{}} - }} - end; - <<"DeleteShortenedUrl">> -> - case get_owner_info(DecodedFragment) of - {ID, ID} -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - _ -> - {ok, #bdcs_Judgement{ - resolution = {forbidden, #bdcs_ResolutionForbidden{}} - }} - end - end - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token( + fun + (<<"other_subject_delete_first">>) -> + {<<"USER1">>, ?USER_EMAIL}; + (<<"other_subject_delete_second">>) -> + {<<"USER2">>, ?USER_EMAIL} + end, + ?config(test_sup, C) + ), + _ = shortener_ct_helper_bouncer:mock_arbiter( + fun(ContextFragment) -> + case get_operation_id(ContextFragment) of + <<"ShortenUrl">> -> + {ok, ?JUDGEMENT(?ALLOWED)}; + <<"GetShortenedUrl">> -> + case get_owner_info(ContextFragment) of + {ID, ID} -> {ok, ?JUDGEMENT(?ALLOWED)}; + _ -> {ok, ?JUDGEMENT(?FORBIDDEN)} + end; + <<"DeleteShortenedUrl">> -> + case get_owner_info(ContextFragment) of + {ID, ID} -> {ok, ?JUDGEMENT(?ALLOWED)}; + _ -> {ok, ?JUDGEMENT(?FORBIDDEN)} + end + end + end, + ?config(test_sup, C) ), SourceUrl = <<"https://oops.io/">>, Params = construct_params(SourceUrl), - C1 = set_api_auth_token(other_subject_delete_first, C), + 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), + 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}) -> - DecodedFragment = decode_shortener(Fragments), - case get_operation_id(DecodedFragment) of - <<"ShortenUrl">> -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - <<"GetShortenedUrl">> -> - case get_owner_info(DecodedFragment) of - {ID, ID} -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - _ -> - {ok, #bdcs_Judgement{ - resolution = {forbidden, #bdcs_ResolutionForbidden{}} - }} - end; - <<"DeleteShortenedUrl">> -> - case get_owner_info(DecodedFragment) of - {ID, ID} -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }}; - _ -> - {ok, #bdcs_Judgement{ - resolution = {forbidden, #bdcs_ResolutionForbidden{}} - }} - end - end - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token( + fun + (<<"other_subject_read_first">>) -> + {<<"USER1">>, ?USER_EMAIL}; + (<<"other_subject_read_second">>) -> + {<<"USER2">>, ?USER_EMAIL} + end, + ?config(test_sup, C) + ), + _ = shortener_ct_helper_bouncer:mock_arbiter( + fun(ContextFragment) -> + case get_operation_id(ContextFragment) of + <<"ShortenUrl">> -> + {ok, ?JUDGEMENT(?ALLOWED)}; + <<"GetShortenedUrl">> -> + case get_owner_info(ContextFragment) of + {ID, ID} -> {ok, ?JUDGEMENT(?ALLOWED)}; + _ -> {ok, ?JUDGEMENT(?FORBIDDEN)} + end; + <<"DeleteShortenedUrl">> -> + case get_owner_info(ContextFragment) of + {ID, ID} -> {ok, ?JUDGEMENT(?ALLOWED)}; + _ -> {ok, ?JUDGEMENT(?FORBIDDEN)} + end + end + end, + ?config(test_sup, C) ), Params = construct_params(<<"https://oops.io/">>), - C1 = set_api_auth_token(other_subject_read_first, C), + 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), + C2 = set_api_auth_token(<<"other_subject_read_second">>, C1), {ok, 403, _, _} = get_shortened_url(ID, C2). %% @@ -261,18 +239,15 @@ construct_params(SourceUrl, Lifetime) -> <<"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}). +set_api_auth_token(C) -> + set_api_auth_token(?AUTH_TOKEN, C). + +set_api_auth_token(Token, C) -> + lists:keystore(api_auth_token, 1, C, {api_auth_token, Token}). 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) -> @@ -333,19 +308,6 @@ get_operation_id(#bctx_v1_ContextFragment{ get_owner_info(Context) -> {get_owner_id(Context), get_user_id(Context)}. -decode_shortener(#bdcs_Context{ - fragments = #{ - <<"shortener">> := #bctx_ContextFragment{ - type = v1_thrift_binary, - content = Fragment - } - } -}) -> - case decode(Fragment) of - #bctx_v1_ContextFragment{} = DecodedFragment -> - DecodedFragment - end. - get_owner_id(#bctx_v1_ContextFragment{ shortener = #bctx_v1_ContextUrlShortener{op = #bctx_v1_UrlShortenerOperation{shortened_url = Url}} }) -> @@ -354,14 +316,3 @@ get_owner_id(#bctx_v1_ContextFragment{ get_user_id(#bctx_v1_ContextFragment{user = #bctx_v1_User{id = UserID}}) -> UserID. - -decode(Content) -> - Type = {struct, struct, {bouncer_context_v1_thrift, 'ContextFragment'}}, - Codec = thrift_strict_binary_codec:new(Content), - {ok, CtxThrift, Codec1} = thrift_strict_binary_codec:read(Codec, Type), - case thrift_strict_binary_codec:close(Codec1) of - <<>> -> - CtxThrift; - Leftovers -> - {error, {excess_binary_data, Leftovers}} - end. diff --git a/apps/shortener/test/shortener_bouncer_data.hrl b/apps/shortener/test/shortener_bouncer_data.hrl new file mode 100644 index 0000000..a6601fc --- /dev/null +++ b/apps/shortener/test/shortener_bouncer_data.hrl @@ -0,0 +1,14 @@ +-ifndef(shortener_bouncer_data_included__). +-define(shortener_bouncer_data_included__, ok). + +-include_lib("bouncer_proto/include/bouncer_decisions_thrift.hrl"). +-include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl"). + +-define(TEST_USER_REALM, <<"external">>). +-define(TEST_RULESET_ID, <<"service/authz/api">>). + +-define(JUDGEMENT(Resolution), #bdcs_Judgement{resolution = Resolution}). +-define(ALLOWED, {allowed, #bdcs_ResolutionAllowed{}}). +-define(FORBIDDEN, {forbidden, #bdcs_ResolutionForbidden{}}). + +-endif. diff --git a/apps/shortener/test/shortener_ct_helper.erl b/apps/shortener/test/shortener_ct_helper.erl index c83bedc..985f5c4 100644 --- a/apps/shortener/test/shortener_ct_helper.erl +++ b/apps/shortener/test/shortener_ct_helper.erl @@ -2,42 +2,47 @@ -include_lib("common_test/include/ct.hrl"). --export([with_test_sup/1]). --export([stop_test_sup/1]). +-include_lib("shortener_token_keeper_data.hrl"). + +-export([start_mocked_service_sup/1]). +-export([stop_mocked_service_sup/1]). +-export([start_app/2]). -export([mock_services/2]). +-export([mock_services_/2]). +-export([get_app_config/2]). -export([get_app_config/3]). --export([get_app_config/4]). -export([get_bouncer_client_app_config/0]). -type config() :: [{atom(), any()}]. +-type app_name() :: atom(). +-type sup_or_config() :: config() | pid(). + +-export_type([app_name/0]). +-export_type([sup_or_config/0]). -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(#{}, []), +-spec start_mocked_service_sup(module()) -> pid(). +start_mocked_service_sup(Module) -> + {ok, SupPid} = supervisor:start_link(Module, []), _ = unlink(SupPid), - [{test_sup, SupPid} | C]. + SupPid. --spec stop_test_sup(config()) -> _. -stop_test_sup(C) -> - exit(?config(test_sup, C), shutdown). +-spec stop_mocked_service_sup(pid()) -> _. +stop_mocked_service_sup(SupPid) -> + exit(SupPid, shutdown). + +-spec start_app(app_name(), list()) -> [app_name()]. +start_app(AppName, Env) -> + genlib_app:start_application_with(AppName, Env). -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, @@ -47,6 +52,7 @@ set_cfg(Service, Url) -> Clients#{Service => Cfg#{url => Url}} ). +-spec mock_services_(_, _) -> _. mock_services_(Services, Config) when is_list(Config) -> mock_services_(Services, ?config(test_sup, Config)); mock_services_(Services, SupPid) when is_pid(SupPid) -> @@ -77,17 +83,12 @@ get_service_name({ServiceName, _Fun}) -> 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'}. - make_url(ServiceName, Port) -> iolist_to_binary(["http://", ?SHORTENER_HOST_NAME, ":", integer_to_list(Port), make_path(ServiceName)]). @@ -96,12 +97,12 @@ make_path(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) -> + get_app_config(Port, Netloc, <<"http://machinegun:8022/v1/automaton">>). --spec get_app_config(_, _, _, _) -> _. -get_app_config(Port, Netloc, PemFile, AutomatonUrl) -> +-spec get_app_config(_, _, _) -> _. +get_app_config(Port, Netloc, AutomatonUrl) -> [ {bouncer_ruleset_id, <<"service/authz/api">>}, {space_size, 8}, @@ -109,12 +110,6 @@ get_app_config(Port, Netloc, PemFile, AutomatonUrl) -> {api, #{ ip => "::", port => Port, - authorizer => #{ - signee => local, - keyset => #{ - local => {pem_file, PemFile} - } - }, source_url_whitelist => [ "https://*", "ftp://*", @@ -148,6 +143,14 @@ get_app_config(Port, Netloc, PemFile, AutomatonUrl) -> '_' => finish } } + }}, + {auth_config, #{ + metadata_mappings => #{ + party_id => ?TK_META_PARTY_ID, + token_consumer => ?TK_META_TOKEN_CONSUMER, + user_id => ?TK_META_USER_ID, + user_email => ?TK_META_USER_EMAIL + } }} ]. diff --git a/apps/shortener/test/shortener_ct_helper_bouncer.erl b/apps/shortener/test/shortener_ct_helper_bouncer.erl new file mode 100644 index 0000000..5f8f7f0 --- /dev/null +++ b/apps/shortener/test/shortener_ct_helper_bouncer.erl @@ -0,0 +1,100 @@ +-module(shortener_ct_helper_bouncer). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("shortener_bouncer_data.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-export([mock_client/1]). +-export([mock_arbiter/2]). +-export([judge_always_allowed/0]). +-export([judge_always_forbidden/0]). + +%% + +start_client(ServiceURLs) -> + ServiceClients = maps:map(fun(_, URL) -> #{url => URL} end, ServiceURLs), + Acc = application:get_env(bouncer_client, service_clients, #{}), + shortener_ct_helper:start_app(bouncer_client, [{service_clients, maps:merge(Acc, ServiceClients)}]). + +-spec mock_client(_) -> _. +mock_client(SupOrConfig) -> + start_client( + shortener_ct_helper:mock_services_( + [ + { + org_management, + {orgmgmt_auth_context_provider_thrift, 'AuthContextProvider'}, + fun('GetUserContext', {UserID}) -> + {encoded_fragment, Fragment} = bouncer_client:bake_context_fragment( + bouncer_context_helpers:make_user_fragment(#{ + id => UserID, + realm => #{id => ?TEST_USER_REALM}, + orgs => [#{id => <<"ORG">>, owner => #{id => UserID}, party => #{id => UserID}}] + }) + ), + {ok, Fragment} + end + } + ], + SupOrConfig + ) + ). + +-spec mock_arbiter(_, _) -> _. +mock_arbiter(JudgeFun, SupOrConfig) -> + start_client( + shortener_ct_helper:mock_services_( + [ + { + bouncer, + {bouncer_decisions_thrift, 'Arbiter'}, + fun('Judge', {?TEST_RULESET_ID, Context}) -> + Fragments = decode_context(Context), + Combined = combine_fragments(Fragments), + JudgeFun(Combined) + end + } + ], + SupOrConfig + ) + ). + +decode_context(#bdcs_Context{fragments = Fragments}) -> + maps:map(fun(_, Fragment) -> decode_fragment(Fragment) end, Fragments). + +decode_fragment(#bctx_ContextFragment{type = v1_thrift_binary, content = Content}) -> + Type = {struct, struct, {bouncer_context_v1_thrift, 'ContextFragment'}}, + Codec = thrift_strict_binary_codec:new(Content), + {ok, Fragment, _} = thrift_strict_binary_codec:read(Codec, Type), + Fragment. + +-spec judge_always_allowed() -> _. +judge_always_allowed() -> + fun(_) -> {ok, ?JUDGEMENT(?ALLOWED)} end. + +-spec judge_always_forbidden() -> _. +judge_always_forbidden() -> + fun(_) -> {ok, ?JUDGEMENT(?FORBIDDEN)} end. + +combine_fragments(Fragments) -> + [Fragment | Rest] = maps:values(Fragments), + lists:foldl(fun combine_fragments/2, Fragment, Rest). + +combine_fragments(Fragment1 = #bctx_v1_ContextFragment{}, Fragment2 = #bctx_v1_ContextFragment{}) -> + combine_records(Fragment1, Fragment2). + +combine_records(Record1, Record2) -> + [Tag | Fields1] = tuple_to_list(Record1), + [Tag | Fields2] = tuple_to_list(Record2), + list_to_tuple([Tag | lists:zipwith(fun combine_fragment_fields/2, Fields1, Fields2)]). + +combine_fragment_fields(undefined, V) -> + V; +combine_fragment_fields(V, undefined) -> + V; +combine_fragment_fields(V, V) -> + V; +combine_fragment_fields(V1, V2) when is_tuple(V1), is_tuple(V2) -> + combine_records(V1, V2); +combine_fragment_fields(V1, V2) when is_list(V1), is_list(V2) -> + ordsets:union(V1, V2). diff --git a/apps/shortener/test/shortener_ct_helper_token_keeper.erl b/apps/shortener/test/shortener_ct_helper_token_keeper.erl new file mode 100644 index 0000000..5ab81f4 --- /dev/null +++ b/apps/shortener/test/shortener_ct_helper_token_keeper.erl @@ -0,0 +1,121 @@ +-module(shortener_ct_helper_token_keeper). + +-include_lib("token_keeper_proto/include/tk_token_keeper_thrift.hrl"). +-include_lib("token_keeper_proto/include/tk_context_thrift.hrl"). +-include_lib("shortener_token_keeper_data.hrl"). + +-define(TOKEN_ID, <<"LETMEIN">>). +-define(TEST_USER_REALM, <<"external">>). +-define(TOKEN_LIFETIME, 259200). + +-type sup_or_config() :: shortener_ct_helper:sup_or_config(). +-type app_name() :: shortener_ct_helper:app_name(). +-type token_handler() :: fun(('Authenticate' | 'Create', tuple()) -> term() | no_return()). + +-export([mock_token/2]). +-export([mock_dumb_token/1]). +-export([mock_dumb_token/2]). + +-spec mock_token(token_handler(), sup_or_config()) -> list(app_name()). +mock_token(HandlerFun, SupOrConfig) -> + start_client( + shortener_ct_helper:mock_services_( + [ + { + token_authenticator, + {tk_token_keeper_thrift, 'TokenAuthenticator'}, + HandlerFun + } + ], + SupOrConfig + ) + ). + +start_client(ServiceURLs) -> + shortener_ct_helper:start_app(token_keeper_client, [ + {service_clients, #{ + authenticator => #{ + url => maps:get(token_authenticator, ServiceURLs) + }, + authorities => #{ + ephemeral => #{}, + offline => #{} + } + }} + ]). + +%% + +-spec mock_dumb_token(sup_or_config()) -> list(app_name()). +mock_dumb_token(SupOrConfig) -> + mock_dumb_token(fun(_) -> {<<"UserID">>, <<"UserEmail">>} end, SupOrConfig). + +-spec mock_dumb_token(function(), sup_or_config()) -> list(app_name()). +mock_dumb_token(UserInfoFun, SupOrConfig) -> + Handler = make_authenticator_handler(fun(Token) -> + {UserID, UserEmail} = UserInfoFun(Token), + UserParams = #{ + id => UserID, + realm => #{id => ?TEST_USER_REALM}, + email => UserEmail + }, + AuthParams = #{ + method => <<"DumbToken">>, + expiration => posix_to_rfc3339(lifetime_to_expiration(?TOKEN_LIFETIME)), + token => #{id => ?TOKEN_ID} + }, + {create_bouncer_context(AuthParams, UserParams), make_metadata(UserID, UserEmail)} + end), + mock_token(Handler, SupOrConfig). + +%% + +-spec make_authenticator_handler(function()) -> token_handler(). +make_authenticator_handler(Handler) -> + fun('Authenticate', {Token, _}) -> + {ContextFragment, Metadata} = Handler(Token), + AuthData = #token_keeper_AuthData{ + token = Token, + status = active, + context = ContextFragment, + metadata = Metadata + }, + {ok, AuthData} + end. + +%% + +make_metadata(UserID, UserEmail) -> + genlib_map:compact(#{ + ?TK_META_USER_ID => UserID, + ?TK_META_USER_EMAIL => UserEmail + }). + +create_bouncer_context(AuthParams, UserParams) -> + Fragment0 = bouncer_context_helpers:make_auth_fragment(AuthParams), + Fragment1 = bouncer_context_helpers:add_user(UserParams, Fragment0), + encode_context(Fragment1). + +%% + +encode_context(Context) -> + #bctx_ContextFragment{ + type = v1_thrift_binary, + content = encode_context_content(Context) + }. + +encode_context_content(Context) -> + Type = {struct, struct, {bouncer_context_v1_thrift, 'ContextFragment'}}, + Codec = thrift_strict_binary_codec:new(), + case thrift_strict_binary_codec:write(Codec, Type, Context) of + {ok, Codec1} -> + thrift_strict_binary_codec:close(Codec1) + end. + +%% + +lifetime_to_expiration(Lt) when is_integer(Lt) -> + genlib_time:unow() + Lt. + +posix_to_rfc3339(Timestamp) when is_integer(Timestamp) -> + genlib_rfc3339:format(Timestamp, second). diff --git a/apps/shortener/test/shortener_general_SUITE.erl b/apps/shortener/test/shortener_general_SUITE.erl index ef71bdd..5d9e283 100644 --- a/apps/shortener/test/shortener_general_SUITE.erl +++ b/apps/shortener/test/shortener_general_SUITE.erl @@ -3,6 +3,8 @@ -include_lib("bouncer_proto/include/bouncer_decisions_thrift.hrl"). -include_lib("bouncer_proto/include/bouncer_context_v1_thrift.hrl"). +-export([init/1]). + -export([all/0]). -export([groups/0]). -export([init_per_suite/1]). @@ -31,16 +33,17 @@ -type config() :: [{atom(), term()}]. -type test_case_name() :: atom(). +-type group_name() :: atom(). -define(config(Key, C), (element(2, lists:keyfind(Key, 1, C)))). +-define(AUTH_TOKEN, <<"LETMEIN">>). --spec all() -> [test_case_name()]. +-spec all() -> [test_case_name() | {group, group_name()}]. all() -> [ {group, general}, {group, cors}, - woody_timeout_test, - health_check_passing + {group, misc} ]. -spec groups() -> [{atom(), list(), [test_case_name()]}]. @@ -58,9 +61,17 @@ groups() -> supported_cors_method, unsupported_cors_header, supported_cors_header + ]}, + {misc, [], [ + woody_timeout_test, + health_check_passing ]} ]. +-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. +init([]) -> + {ok, {#{strategy => one_for_all, intensity => 1, period => 1}, []}}. + -spec init_per_suite(config()) -> config(). init_per_suite(C) -> % _ = dbg:tracer(), @@ -72,8 +83,7 @@ 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}, @@ -89,8 +99,7 @@ init_per_group(_Group, C) -> shortener, shortener_ct_helper:get_app_config( ?config(port, C), - ?config(netloc, C), - get_keysource("keys/local/private.pem", C) + ?config(netloc, C) ) ), [ @@ -101,22 +110,21 @@ init_per_group(_Group, C) -> 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)). + _ = genlib_app:stop_unload_applications(?config(suite_apps, C)), + ok. -spec init_per_testcase(test_case_name(), config()) -> config(). init_per_testcase(_Name, C) -> - shortener_ct_helper:with_test_sup(C). + SupPid = shortener_ct_helper:start_mocked_service_sup(?MODULE), + _ = shortener_ct_helper_bouncer:mock_client(SupPid), + [{test_sup, SupPid} | C]. -spec end_per_testcase(test_case_name(), config()) -> ok. end_per_testcase(_Name, C) -> - shortener_ct_helper:stop_test_sup(C), + _ = shortener_ct_helper:stop_mocked_service_sup(?config(test_sup, C)), ok. - %% -spec successful_redirect(config()) -> _. @@ -126,17 +134,12 @@ end_per_testcase(_Name, C) -> -spec always_unique_url(config()) -> _. successful_redirect(C) -> - _ = shortener_ct_helper:mock_services( - [ - {bouncer, fun('Judge', _) -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), - C1 = set_api_auth_token(successful_redirect, C), + C1 = set_api_auth_token(C), SourceUrl = <<"https://example.com/">>, Params = construct_params(SourceUrl), {ok, 201, _, #{<<"id">> := ID, <<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1), @@ -145,17 +148,12 @@ successful_redirect(C) -> {<<"location">>, SourceUrl} = lists:keyfind(<<"location">>, 1, Headers). successful_delete(C) -> - _ = shortener_ct_helper:mock_services( - [ - {bouncer, fun('Judge', _) -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), - C1 = set_api_auth_token(successful_delete, C), + C1 = set_api_auth_token(C), Params = construct_params(<<"https://oops.io/">>), {ok, 201, _, #{<<"id">> := ID, <<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1), {ok, 204, _, _} = delete_shortened_url(ID, C1), @@ -163,17 +161,12 @@ successful_delete(C) -> {ok, 404, _, _} = hackney:request(get, ShortUrl). fordidden_source_url(C) -> - _ = shortener_ct_helper:mock_services( - [ - {bouncer, fun('Judge', _) -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), - C1 = set_api_auth_token(fordidden_source_url, C), + C1 = set_api_auth_token(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), @@ -181,17 +174,12 @@ fordidden_source_url(C) -> {ok, 201, _, #{}} = shorten_url(construct_params(<<"ftp://ftp.hp.com/pub/hpcp/newsletter_july2003">>), C1). url_expired(C) -> - _ = shortener_ct_helper:mock_services( - [ - {bouncer, fun('Judge', _) -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), - C1 = set_api_auth_token(url_expired, C), + C1 = set_api_auth_token(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), @@ -200,17 +188,12 @@ url_expired(C) -> {ok, 404, _, _} = hackney:request(get, ShortUrl). always_unique_url(C) -> - _ = shortener_ct_helper:mock_services( - [ - {bouncer, fun('Judge', _) -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), - C1 = set_api_auth_token(always_unique_url, C), + C1 = set_api_auth_token(C), N = 42, Params = construct_params(<<"https://oops.io/">>, 3600), {IDs, ShortUrls} = lists:unzip([ @@ -228,38 +211,28 @@ 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, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), SourceUrl = <<"https://oops.io/">>, Params = construct_params(SourceUrl), - C1 = set_api_auth_token(unsupported_cors_method, C), + C1 = set_api_auth_token(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, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), SourceUrl = <<"https://oops.io/">>, Params = construct_params(SourceUrl), - C1 = set_api_auth_token(supported_cors_method, C), + C1 = set_api_auth_token(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), @@ -268,19 +241,14 @@ 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, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), SourceUrl = <<"https://oops.io/">>, Params = construct_params(SourceUrl), - C1 = set_api_auth_token(supported_cors_header, C), + C1 = set_api_auth_token(C), {ok, 201, _, #{<<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1), ReqHeaders = [ {<<"origin">>, <<"localhost">>}, @@ -294,19 +262,14 @@ 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, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), SourceUrl = <<"https://oops.io/">>, Params = construct_params(SourceUrl), - C1 = set_api_auth_token(unsupported_cors_header, C), + C1 = set_api_auth_token(C), {ok, 201, _, #{<<"shortenedUrl">> := ShortUrl}} = shorten_url(Params, C1), ReqHeaders = [ {<<"origin">>, <<"localhost">>}, @@ -336,21 +299,15 @@ woody_timeout_test(C) -> 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">> ) ), - _ = shortener_ct_helper:mock_services( - [ - {bouncer, fun('Judge', _) -> - {ok, #bdcs_Judgement{ - resolution = {allowed, #bdcs_ResolutionAllowed{}} - }} - end} - ], - C + _ = shortener_ct_helper_token_keeper:mock_dumb_token(?config(test_sup, C)), + _ = shortener_ct_helper_bouncer:mock_arbiter( + shortener_ct_helper_bouncer:judge_always_allowed(), + ?config(test_sup, C) ), - C2 = set_api_auth_token(woody_timeout_test, C), + C2 = set_api_auth_token(C), SourceUrl = <<"https://example.com/">>, Params = construct_params(SourceUrl), {Time, {error, {invalid_response_code, 503}}} = @@ -367,8 +324,7 @@ health_check_passing(C) -> shortener, shortener_ct_helper:get_app_config( ?config(port, C), - ?config(netloc, C), - get_keysource("keys/local/private.pem", C) + ?config(netloc, C) ) ), Path = ?config(api_endpoint, C) ++ "/health", @@ -377,14 +333,12 @@ health_check_passing(C) -> genlib_app:stop_unload_applications(Apps). %% -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}). -construct_shortener_acl(Permissions) -> - lists:map(fun(P) -> {['shortened-urls'], P} end, Permissions). +set_api_auth_token(C) -> + set_api_auth_token(?AUTH_TOKEN, C). + +set_api_auth_token(Token, C) -> + lists:keystore(api_auth_token, 1, C, {api_auth_token, Token}). %% diff --git a/apps/shortener/test/shortener_mock_service.erl b/apps/shortener/test/shortener_mock_service.erl index 739e872..783d711 100644 --- a/apps/shortener/test/shortener_mock_service.erl +++ b/apps/shortener/test/shortener_mock_service.erl @@ -6,4 +6,9 @@ -spec handle_function(woody:func(), woody:args(), woody_context:ctx(), woody:options()) -> {ok, term()}. handle_function(FunName, Args, _, #{function := Fun}) -> - Fun(FunName, Args). + case Fun(FunName, Args) of + {throwing, Exception} -> + erlang:throw(Exception); + Result -> + Result + end. diff --git a/apps/shortener/test/shortener_token_keeper_data.hrl b/apps/shortener/test/shortener_token_keeper_data.hrl new file mode 100644 index 0000000..cab72f5 --- /dev/null +++ b/apps/shortener/test/shortener_token_keeper_data.hrl @@ -0,0 +1,9 @@ +-ifndef(shortener_token_keeper_data_included__). +-define(shortener_token_keeper_data_included__, ok). + +-define(TK_META_PARTY_ID, <<"test.valitydev.party.id">>). +-define(TK_META_TOKEN_CONSUMER, <<"test.valitydev.capi.consumer">>). +-define(TK_META_USER_ID, <<"test.valitydev.user.id">>). +-define(TK_META_USER_EMAIL, <<"test.valitydev.user.email">>). + +-endif. diff --git a/config/sys.config b/config/sys.config index 37ef923..c2a3348 100644 --- a/config/sys.config +++ b/config/sys.config @@ -10,9 +10,6 @@ {api, #{ ip => "::", port => 8080, - authorizer => #{ - keyset => #{} - }, short_url_template => #{ scheme => https, netloc => "rbk.mn", @@ -61,7 +58,14 @@ {erl_health, disk, ["/", 99]}, {erl_health, cg_memory, [99]}, {erl_health, service, [<<"shortener">>]} - ]} + ]}, + {auth_config, #{ + metadata_mappings => #{ + party_id => <<"dev.vality.party.id">>, + user_id => <<"dev.vality.user.id">>, + user_email => <<"dev.vality.user.email">> + } + }} ]}, {kernel, [ {logger_level, info}, diff --git a/rebar.config b/rebar.config index b068594..156316a 100644 --- a/rebar.config +++ b/rebar.config @@ -35,8 +35,8 @@ {woody_user_identity, {git, "https://github.com/valitydev/woody_erlang_user_identity.git", {branch, "master"}}}, {scoper, {git, "https://github.com/valitydev/scoper.git", {branch, "master"}}}, {mg_proto, {git, "https://github.com/valitydev/machinegun-proto.git", {branch, "master"}}}, - {bouncer_proto, {git, "https://github.com/valitydev/bouncer-proto.git", {branch, "master"}}}, - {bouncer_client, {git, "https://github.com/valitydev/bouncer_client_erlang.git", {branch, "master"}}}, + {bouncer_client, {git, "https://github.com/valitydev/bouncer-client-erlang.git", {branch, "master"}}}, + {token_keeper_client, {git, "https://github.com/valitydev/token-keeper-client.git", {branch, "master"}}}, {erl_health, {git, "https://github.com/valitydev/erlang-health.git", {branch, "master"}}}, {cowboy_cors, {git, "https://github.com/valitydev/cowboy_cors.git", {branch, "master"}}}, @@ -46,9 +46,6 @@ {swag_client_ushort, {git, "https://github.com/valitydev/swag-url-shortener", {branch, "release/erlang/ushort-client/master"}}}, - %% NOTE - %% Remove once bouncer client deps are updated - {org_management_proto, {git, "https://github.com/valitydev/org-management-proto.git", {branch, master}}}, %% NOTE %% Pinning to version "1.11.2" from hex here causes constant upgrading and recompilation of the entire project {jose, {git, "https://github.com/potatosalad/erlang-jose.git", {tag, "1.11.2"}}} diff --git a/rebar.lock b/rebar.lock index bca9345..25b2405 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,12 +1,12 @@ {"1.2.0", [{<<"bouncer_client">>, - {git,"https://github.com/valitydev/bouncer_client_erlang.git", - {ref,"535449a459b70643836c440a863b42656f2a1409"}}, + {git,"https://github.com/valitydev/bouncer-client-erlang.git", + {ref,"9f5cb1d9b82793ba57121e4fd23912c47b7ff0c7"}}, 0}, {<<"bouncer_proto">>, - {git,"https://github.com/valitydev/bouncer-proto.git", - {ref,"96bd74dbf1db33ce1cbc6f6d3ce5a9b598ee29f5"}}, - 0}, + {git,"https://github.com/valitydev/bouncer-proto", + {ref,"8da12fe98bc751e7f8f17f64ad4f571a6a63b0fe"}}, + 1}, {<<"cache">>,{pkg,<<"cache">>,<<"2.3.3">>},1}, {<<"certifi">>,{pkg,<<"certifi">>,<<"2.6.1">>},1}, {<<"cg_mon">>, @@ -55,13 +55,13 @@ {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1}, {<<"mg_proto">>, {git,"https://github.com/valitydev/machinegun-proto.git", - {ref,"f533965771c168f3c6b61008958fb1366693476a"}}, + {ref,"1cd15771196efe9b12f09290a93f9f34bdcb317a"}}, 0}, {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},1}, {<<"org_management_proto">>, - {git,"https://github.com/valitydev/org-management-proto.git", - {ref,"abb4b4d9777ec65d4f9a1e5a8de81c7574d48422"}}, - 0}, + {git,"https://github.com/valitydev/org-management-proto", + {ref,"f433223706284000694e54e839fafb10db84e2b3"}}, + 1}, {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.4.1">>},0}, {<<"ranch">>,{pkg,<<"ranch">>,<<"1.8.0">>},1}, {<<"scoper">>, @@ -85,6 +85,14 @@ {git,"https://github.com/valitydev/thrift_erlang.git", {ref,"c280ff266ae1c1906fb0dcee8320bb8d8a4a3c75"}}, 1}, + {<<"token_keeper_client">>, + {git,"https://github.com/valitydev/token-keeper-client.git", + {ref,"06cc71293e28f3e01fe981069c48c9782d0ab1f2"}}, + 0}, + {<<"token_keeper_proto">>, + {git,"https://github.com/valitydev/token-keeper-proto.git", + {ref,"448afd93571f4f7feea655e29ea4b0e98fd808f5"}}, + 1}, {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},1}, {<<"woody">>, {git,"https://github.com/valitydev/woody_erlang.git",