mirror of
https://github.com/valitydev/url-shortener.git
synced 2024-11-06 01:55:19 +00:00
TD-177: token-keeper auth (#7)
This commit is contained in:
parent
e0c14191a1
commit
f8a6e83eae
2
.github/workflows/erlang-checks.yaml
vendored
2
.github/workflows/erlang-checks.yaml
vendored
@ -6,7 +6,7 @@ on:
|
||||
- 'master'
|
||||
- 'epic/**'
|
||||
pull_request:
|
||||
branches: [ '**' ]
|
||||
branches: ['**']
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,6 +5,7 @@ deps
|
||||
*.beam
|
||||
*.plt
|
||||
erl_crash.dump
|
||||
rebar3.crashdump
|
||||
ebin/*.beam
|
||||
rel/example_project
|
||||
.concrete/DEV_MODE
|
||||
|
@ -16,6 +16,7 @@
|
||||
mg_proto,
|
||||
bouncer_proto,
|
||||
bouncer_client,
|
||||
token_keeper_client,
|
||||
woody,
|
||||
woody_user_identity,
|
||||
erl_health
|
||||
|
@ -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].
|
||||
|
@ -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),
|
||||
<<S/binary, ":", P/binary>>
|
||||
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.
|
@ -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).
|
||||
|
@ -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() ->
|
||||
<<ID:64>> = 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.
|
50
apps/shortener/src/shortener_bouncer.erl
Normal file
50
apps/shortener/src/shortener_bouncer.erl
Normal file
@ -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
|
||||
).
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.
|
65
apps/shortener/src/shortener_bouncer_context.erl
Normal file
65
apps/shortener/src/shortener_bouncer_context.erl
Normal file
@ -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).
|
@ -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()) ->
|
||||
|
@ -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.
|
||||
|
14
apps/shortener/test/shortener_bouncer_data.hrl
Normal file
14
apps/shortener/test/shortener_bouncer_data.hrl
Normal file
@ -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.
|
@ -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
|
||||
}
|
||||
}}
|
||||
].
|
||||
|
||||
|
100
apps/shortener/test/shortener_ct_helper_bouncer.erl
Normal file
100
apps/shortener/test/shortener_ct_helper_bouncer.erl
Normal file
@ -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).
|
121
apps/shortener/test/shortener_ct_helper_token_keeper.erl
Normal file
121
apps/shortener/test/shortener_ct_helper_token_keeper.erl
Normal file
@ -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).
|
@ -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}).
|
||||
|
||||
%%
|
||||
|
||||
|
@ -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.
|
||||
|
9
apps/shortener/test/shortener_token_keeper_data.hrl
Normal file
9
apps/shortener/test/shortener_token_keeper_data.hrl
Normal file
@ -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.
|
@ -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},
|
||||
|
@ -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"}}}
|
||||
|
26
rebar.lock
26
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",
|
||||
|
Loading…
Reference in New Issue
Block a user