TD-177: token-keeper auth (#7)

This commit is contained in:
Alexey S 2022-03-28 15:50:06 +03:00 committed by GitHub
parent e0c14191a1
commit f8a6e83eae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 771 additions and 1137 deletions

View File

@ -6,7 +6,7 @@ on:
- 'master'
- 'epic/**'
pull_request:
branches: [ '**' ]
branches: ['**']
jobs:
setup:

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ deps
*.beam
*.plt
erl_crash.dump
rebar3.crashdump
ebin/*.beam
rel/example_project
.concrete/DEV_MODE

View File

@ -16,6 +16,7 @@
mg_proto,
bouncer_proto,
bouncer_client,
token_keeper_client,
woody,
woody_user_identity,
erl_health

View File

@ -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].

View File

@ -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.

View File

@ -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).

View File

@ -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.

View 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
).

View File

@ -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
}
}
}
}
}.

View 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).

View File

@ -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()) ->

View File

@ -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.

View 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.

View File

@ -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
}
}}
].

View 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).

View 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).

View File

@ -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}).
%%

View File

@ -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.

View 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.

View File

@ -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},

View File

@ -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"}}}

View File

@ -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",