mirror of
https://github.com/valitydev/api-key-mgmt-v2.git
synced 2024-11-06 02:15:19 +00:00
TD-642: fix with test-transaction (#13)
* fix key metadata * fix bouncer context * cleanup * update swag and revert security to revoke * TD-635: update code for new spec * add vault client * add tests * fix style * TD-642: fix revoke * fix vault interaction --------- Co-authored-by: anatoliy.losev <losto@nix>
This commit is contained in:
parent
d9169e2bdd
commit
e74f084f63
1
.env
1
.env
@ -2,4 +2,3 @@ SERVICE_NAME=api-key-mgmt-v2
|
|||||||
OTP_VERSION=25.3
|
OTP_VERSION=25.3
|
||||||
REBAR_VERSION=3.18
|
REBAR_VERSION=3.18
|
||||||
THRIFT_VERSION=0.14.2.3
|
THRIFT_VERSION=0.14.2.3
|
||||||
DATABASE_URL=postgresql://postgres:postgres@db/apikeymgmtv2
|
|
@ -3,4 +3,4 @@ From: no-reply@empayre.com
|
|||||||
To: You
|
To: You
|
||||||
|
|
||||||
|
|
||||||
To revoke key, go to link: {{ url }}/apikeys/v2/orgs/{{ party_id }}/revoke-api-key/{{ api_key_id }}?apiKeyRevokeToken={{ revoke_token }}
|
To revoke key, go to link: {{ url }}/apikeys/v2/orgs/revoke_party/revoke-api-key/{{ api_key_id }}?apiKeyRevokeToken={{ revoke_token }}
|
||||||
|
@ -28,7 +28,8 @@
|
|||||||
snowflake,
|
snowflake,
|
||||||
woody_user_identity,
|
woody_user_identity,
|
||||||
erlydtl,
|
erlydtl,
|
||||||
gen_smtp
|
gen_smtp,
|
||||||
|
canal
|
||||||
]},
|
]},
|
||||||
{env, []}
|
{env, []}
|
||||||
]}.
|
]}.
|
||||||
|
@ -141,21 +141,21 @@ prepare(OperationID = 'RequestRevokeApiKey', Params, Context, _Opts) ->
|
|||||||
end,
|
end,
|
||||||
{ok, #{authorize => Authorize, process => Process}};
|
{ok, #{authorize => Authorize, process => Process}};
|
||||||
prepare(
|
prepare(
|
||||||
_OperationID = 'RevokeApiKey',
|
OperationID = 'RevokeApiKey',
|
||||||
#{'partyId' := _PartyID, 'apiKeyId' := ApiKeyId, 'apiKeyRevokeToken' := Token},
|
#{'partyId' := PartyID, 'apiKeyId' := ApiKeyId, 'apiKeyRevokeToken' := Token},
|
||||||
_Context,
|
Context,
|
||||||
_Opts
|
_Opts
|
||||||
) ->
|
) ->
|
||||||
%% Result = akm_apikeys_processing:get_api_key(ApiKeyId),
|
Result = akm_apikeys_processing:get_api_key(ApiKeyId),
|
||||||
Authorize = fun() ->
|
Authorize = fun() ->
|
||||||
%% ApiKey = extract_api_key(Result),
|
ApiKey = extract_api_key(Result),
|
||||||
%% Prototypes = [{operation, #{id => OperationID, party => PartyID, api_key => ApiKey}}],
|
Prototypes = [{operation, #{id => OperationID, party => PartyID, api_key => ApiKey}}],
|
||||||
%% Resolution = akm_auth:authorize_operation(Prototypes, Context),
|
Resolution = akm_auth:authorize_operation(Prototypes, Context),
|
||||||
%% {ok, Resolution}
|
{ok, Resolution}
|
||||||
{ok, allowed}
|
|
||||||
end,
|
end,
|
||||||
Process = fun() ->
|
Process = fun() ->
|
||||||
case akm_apikeys_processing:revoke(ApiKeyId, Token) of
|
#{woody_context := WoodyContext} = Context,
|
||||||
|
case akm_apikeys_processing:revoke(ApiKeyId, Token, WoodyContext) of
|
||||||
ok ->
|
ok ->
|
||||||
akm_handler_utils:reply_ok(204);
|
akm_handler_utils:reply_ok(204);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
|
@ -8,29 +8,28 @@
|
|||||||
-export([get_api_key/1]).
|
-export([get_api_key/1]).
|
||||||
-export([list_api_keys/4]).
|
-export([list_api_keys/4]).
|
||||||
-export([request_revoke/4]).
|
-export([request_revoke/4]).
|
||||||
-export([revoke/2]).
|
-export([revoke/3]).
|
||||||
|
|
||||||
-type list_keys_response() :: #{
|
-type list_keys_response() :: #{
|
||||||
results => [map()],
|
results => [map()],
|
||||||
continuationToken := binary()
|
continuationToken := binary()
|
||||||
}.
|
}.
|
||||||
|
|
||||||
|
-type woody_context() :: woody_context:ctx().
|
||||||
|
|
||||||
-spec issue_api_key(_, _, _) -> _.
|
-spec issue_api_key(_, _, _) -> _.
|
||||||
issue_api_key(PartyID, #{<<"name">> := Name} = ApiKey0, WoodyContext) ->
|
issue_api_key(PartyID, #{<<"name">> := Name} = ApiKey0, WoodyContext) ->
|
||||||
Metadata0 = maps:get(<<"metadata">>, ApiKey0, #{}),
|
Metadata0 = maps:get(<<"metadata">>, ApiKey0, #{}),
|
||||||
%% REWORK ненормальный ID, переработать
|
%% REWORK ненормальный ID, переработать
|
||||||
ID = akm_id:generate_snowflake_id(),
|
ID = akm_id:generate_snowflake_id(),
|
||||||
ContextV1Fragment = bouncer_context_helpers:make_auth_fragment(#{
|
ContextV1Fragment = bouncer_context_helpers:make_auth_fragment(#{
|
||||||
method => <<"IssueApiKey">>,
|
method => <<"ApiKeyToken">>,
|
||||||
scope => [#{party => #{id => PartyID}}],
|
scope => [#{party => #{id => PartyID}}],
|
||||||
token => #{id => ID}
|
token => #{id => ID}
|
||||||
}),
|
}),
|
||||||
%% TODO ??? maybe wrong, review it !!!
|
{encoded_fragment, ContextFragment} = bouncer_client:bake_context_fragment(ContextV1Fragment),
|
||||||
ContextFragment = #ctx_ContextFragment{type = 'v1_thrift_binary', content = term_to_binary(ContextV1Fragment)},
|
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Metadata = Metadata0#{
|
Metadata = akm_auth:put_party_to_metadata(PartyID, Metadata0),
|
||||||
<<"party.id">> => PartyID
|
|
||||||
},
|
|
||||||
Client = token_keeper_client:offline_authority(get_authority_id(), WoodyContext),
|
Client = token_keeper_client:offline_authority(get_authority_id(), WoodyContext),
|
||||||
case token_keeper_authority_offline:create(ID, ContextFragment, Metadata, Client) of
|
case token_keeper_authority_offline:create(ID, ContextFragment, Metadata, Client) of
|
||||||
{ok, #{token := Token}} ->
|
{ok, #{token := Token}} ->
|
||||||
@ -42,8 +41,8 @@ issue_api_key(PartyID, #{<<"name">> := Name} = ApiKey0, WoodyContext) ->
|
|||||||
),
|
),
|
||||||
[ApiKey | _] = to_marshalled_maps(Columns, Rows),
|
[ApiKey | _] = to_marshalled_maps(Columns, Rows),
|
||||||
Resp = #{
|
Resp = #{
|
||||||
<<"AccessToken">> => marshall_access_token(Token),
|
<<"accessToken">> => Token,
|
||||||
<<"ApiKey">> => ApiKey
|
<<"apiKey">> => ApiKey
|
||||||
},
|
},
|
||||||
{ok, Resp};
|
{ok, Resp};
|
||||||
{error, {auth_data, already_exists}} ->
|
{error, {auth_data, already_exists}} ->
|
||||||
@ -70,7 +69,7 @@ list_api_keys(PartyId, Status, Limit, Offset) ->
|
|||||||
{ok, Columns, Rows} = epgsql_pool:query(
|
{ok, Columns, Rows} = epgsql_pool:query(
|
||||||
main_pool,
|
main_pool,
|
||||||
"SELECT id, name, status, metadata, created_at FROM apikeys where party_id = $1 AND status = $2 "
|
"SELECT id, name, status, metadata, created_at FROM apikeys where party_id = $1 AND status = $2 "
|
||||||
"ORDER BY created_at LIMIT $3 OFFSET $4",
|
"ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||||
[PartyId, Status, Limit, Offset]
|
[PartyId, Status, Limit, Offset]
|
||||||
),
|
),
|
||||||
case erlang:length(Rows) < Limit of
|
case erlang:length(Rows) < Limit of
|
||||||
@ -115,19 +114,33 @@ request_revoke(Email, PartyID, ApiKeyId, Status) ->
|
|||||||
end
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
-spec revoke(binary(), binary()) -> ok | {error, not_found}.
|
-spec revoke(binary(), binary(), woody_context()) -> ok | {error, not_found}.
|
||||||
revoke(ApiKeyId, RevokeToken) ->
|
revoke(ApiKeyId, RevokeToken, WoodyContext) ->
|
||||||
case get_full_api_key(ApiKeyId) of
|
case get_full_api_key(ApiKeyId) of
|
||||||
{ok, #{
|
{ok, #{
|
||||||
<<"pending_status">> := PendingStatus,
|
<<"pending_status">> := PendingStatus,
|
||||||
<<"revoke_token">> := RevokeToken
|
<<"revoke_token">> := RevokeToken
|
||||||
}} ->
|
}} ->
|
||||||
{ok, 1} = epgsql_pool:query(
|
Client = token_keeper_client:offline_authority(get_authority_id(), WoodyContext),
|
||||||
main_pool,
|
try
|
||||||
"UPDATE apikeys SET status = $1, revoke_token = null WHERE id = $2",
|
epgsql_pool:transaction(
|
||||||
[PendingStatus, ApiKeyId]
|
main_pool,
|
||||||
),
|
fun(Worker) ->
|
||||||
ok;
|
{ok, _} = token_keeper_authority_offline:revoke(ApiKeyId, Client),
|
||||||
|
epgsql_pool:query(
|
||||||
|
Worker,
|
||||||
|
"UPDATE apikeys SET status = $1, revoke_token = null WHERE id = $2",
|
||||||
|
[PendingStatus, ApiKeyId]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
of
|
||||||
|
{ok, 1} -> ok
|
||||||
|
catch
|
||||||
|
Ex:Er ->
|
||||||
|
logger:error("Can`t revoke ApiKey ~p with error: ~p:~p", [ApiKeyId, Ex, Er]),
|
||||||
|
{error, not_found}
|
||||||
|
end;
|
||||||
_ ->
|
_ ->
|
||||||
{error, not_found}
|
{error, not_found}
|
||||||
end.
|
end.
|
||||||
@ -210,8 +223,3 @@ marshall_api_key(#{
|
|||||||
<<"status">> => Status,
|
<<"status">> => Status,
|
||||||
<<"metadata">> => decode_json(Metadata)
|
<<"metadata">> => decode_json(Metadata)
|
||||||
}.
|
}.
|
||||||
|
|
||||||
marshall_access_token(Token) ->
|
|
||||||
#{
|
|
||||||
<<"accessToken">> => Token
|
|
||||||
}.
|
|
||||||
|
@ -14,7 +14,9 @@
|
|||||||
-export([authorize_api_key/3]).
|
-export([authorize_api_key/3]).
|
||||||
-export([authorize_operation/2]).
|
-export([authorize_operation/2]).
|
||||||
|
|
||||||
-export([make_auth_context/1]).
|
-export([put_party_to_metadata/1]).
|
||||||
|
-export([put_party_to_metadata/2]).
|
||||||
|
-export([get_party_from_metadata/1]).
|
||||||
|
|
||||||
-export_type([resolution/0]).
|
-export_type([resolution/0]).
|
||||||
-export_type([preauth_context/0]).
|
-export_type([preauth_context/0]).
|
||||||
@ -101,19 +103,6 @@ authorize_operation(Prototypes, Context) ->
|
|||||||
Fragments1 = akm_bouncer_context:build(Prototypes, Fragments),
|
Fragments1 = akm_bouncer_context:build(Prototypes, Fragments),
|
||||||
akm_bouncer:judge(Fragments1, WoodyContext).
|
akm_bouncer:judge(Fragments1, WoodyContext).
|
||||||
|
|
||||||
-spec make_auth_context(binary()) -> auth_context().
|
|
||||||
make_auth_context(PartyId) ->
|
|
||||||
{
|
|
||||||
authorized,
|
|
||||||
#{
|
|
||||||
status => active,
|
|
||||||
context => #ctx_ContextFragment{type = 'v1_thrift_binary'},
|
|
||||||
metadata => #{
|
|
||||||
get_metadata_mapped_key(party_id) => PartyId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.
|
|
||||||
|
|
||||||
%%
|
%%
|
||||||
|
|
||||||
get_token_keeper_fragment(?AUTHORIZED(#{context := Context})) ->
|
get_token_keeper_fragment(?AUTHORIZED(#{context := Context})) ->
|
||||||
@ -130,10 +119,24 @@ parse_api_key(_) ->
|
|||||||
{error, unsupported_auth_scheme}.
|
{error, unsupported_auth_scheme}.
|
||||||
|
|
||||||
%%
|
%%
|
||||||
|
-spec put_party_to_metadata(binary()) -> map().
|
||||||
|
put_party_to_metadata(PartyId) ->
|
||||||
|
put_party_to_metadata(PartyId, #{}).
|
||||||
|
|
||||||
|
-spec put_party_to_metadata(binary(), map()) -> map().
|
||||||
|
put_party_to_metadata(PartyId, MetaData) ->
|
||||||
|
put_metadata(get_metadata_mapped_key(party_id), PartyId, MetaData).
|
||||||
|
|
||||||
|
-spec get_party_from_metadata(map()) -> binary() | undefined.
|
||||||
|
get_party_from_metadata(MetaData) ->
|
||||||
|
get_metadata(get_metadata_mapped_key(party_id), MetaData).
|
||||||
|
|
||||||
get_metadata(Key, Metadata) ->
|
get_metadata(Key, Metadata) ->
|
||||||
maps:get(Key, Metadata, undefined).
|
maps:get(Key, Metadata, undefined).
|
||||||
|
|
||||||
|
put_metadata(Key, Value, Metadata) ->
|
||||||
|
maps:put(Key, Value, Metadata).
|
||||||
|
|
||||||
get_metadata_mapped_key(Key) ->
|
get_metadata_mapped_key(Key) ->
|
||||||
maps:get(Key, get_meta_mappings()).
|
maps:get(Key, get_meta_mappings()).
|
||||||
|
|
||||||
@ -217,4 +220,18 @@ determine_peer_test_() ->
|
|||||||
)
|
)
|
||||||
].
|
].
|
||||||
|
|
||||||
|
-spec metadata_test() -> _.
|
||||||
|
metadata_test() ->
|
||||||
|
application:set_env(
|
||||||
|
akm,
|
||||||
|
auth_config,
|
||||||
|
#{metadata_mappings => #{party_id => <<"dev.vality.party.id">>}}
|
||||||
|
),
|
||||||
|
?assertEqual(#{<<"dev.vality.party.id">> => <<"qqq">>}, put_party_to_metadata(<<"qqq">>)),
|
||||||
|
?assertEqual(
|
||||||
|
#{<<"dev.vality.party.id">> => <<"qqq">>, 1 => 2},
|
||||||
|
put_party_to_metadata(<<"qqq">>, #{1 => 2})
|
||||||
|
),
|
||||||
|
?assertEqual(<<"qqq">>, get_party_from_metadata(#{<<"dev.vality.party.id">> => <<"qqq">>})).
|
||||||
|
|
||||||
-endif.
|
-endif.
|
||||||
|
@ -70,10 +70,11 @@ api_key_entity(
|
|||||||
#{
|
#{
|
||||||
api_key := #{
|
api_key := #{
|
||||||
<<"id">> := ApiKeyId,
|
<<"id">> := ApiKeyId,
|
||||||
<<"metadata">> := #{<<"party.id">> := PartyId}
|
<<"metadata">> := MetaData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) ->
|
) ->
|
||||||
|
PartyId = akm_auth:get_party_from_metadata(MetaData),
|
||||||
#base_Entity{id = ApiKeyId, party = PartyId, type = <<"ApiKey">>};
|
#base_Entity{id = ApiKeyId, party = PartyId, type = <<"ApiKey">>};
|
||||||
api_key_entity(_) ->
|
api_key_entity(_) ->
|
||||||
undefined.
|
undefined.
|
||||||
|
@ -77,7 +77,7 @@ authorize_api_key(OperationID, ApiKey, _Context, _HandlerOpts) ->
|
|||||||
) ->
|
) ->
|
||||||
akm_apikeys_handler:request_result().
|
akm_apikeys_handler:request_result().
|
||||||
handle_request(OperationID, Req, SwagContext, Opts) ->
|
handle_request(OperationID, Req, SwagContext, Opts) ->
|
||||||
Header = maps:get('X-Request-Deadline', Req, undefined),
|
#{'X-Request-Deadline' := Header} = Req,
|
||||||
case akm_utils:parse_deadline(Header) of
|
case akm_utils:parse_deadline(Header) of
|
||||||
{ok, Deadline} ->
|
{ok, Deadline} ->
|
||||||
WoodyContext = attach_deadline(Deadline, create_woody_context(Req)),
|
WoodyContext = attach_deadline(Deadline, create_woody_context(Req)),
|
||||||
@ -93,7 +93,7 @@ handle_request(OperationID, Req, SwagContext, Opts) ->
|
|||||||
process_request(OperationID, Req, SwagContext0, Opts, WoodyContext0) ->
|
process_request(OperationID, Req, SwagContext0, Opts, WoodyContext0) ->
|
||||||
_ = logger:info("Processing request ~p", [OperationID]),
|
_ = logger:info("Processing request ~p", [OperationID]),
|
||||||
try
|
try
|
||||||
SwagContext = do_authorize_api_key(OperationID, SwagContext0, WoodyContext0),
|
SwagContext = do_authorize_api_key(SwagContext0, WoodyContext0),
|
||||||
WoodyContext = put_user_identity(WoodyContext0, get_auth_context(SwagContext)),
|
WoodyContext = put_user_identity(WoodyContext0, get_auth_context(SwagContext)),
|
||||||
Context = create_handler_context(OperationID, SwagContext, WoodyContext),
|
Context = create_handler_context(OperationID, SwagContext, WoodyContext),
|
||||||
ok = set_context_meta(Context),
|
ok = set_context_meta(Context),
|
||||||
@ -117,9 +117,7 @@ process_request(OperationID, Req, SwagContext0, Opts, WoodyContext0) ->
|
|||||||
end.
|
end.
|
||||||
|
|
||||||
-spec create_woody_context(akm_apikeys_handler:request_data()) -> woody_context:ctx().
|
-spec create_woody_context(akm_apikeys_handler:request_data()) -> woody_context:ctx().
|
||||||
create_woody_context(RequestData) ->
|
create_woody_context(#{'X-Request-ID' := RequestID}) ->
|
||||||
%% use dynamic request_id if not presented
|
|
||||||
RequestID = maps:get('X-Request-ID', RequestData, new_request_id()),
|
|
||||||
RpcID = #{trace_id := TraceID} = woody_context:new_rpc_id(genlib:to_binary(RequestID)),
|
RpcID = #{trace_id := TraceID} = woody_context:new_rpc_id(genlib:to_binary(RequestID)),
|
||||||
ok = scoper:add_meta(#{request_id => RequestID, trace_id => TraceID}),
|
ok = scoper:add_meta(#{request_id => RequestID, trace_id => TraceID}),
|
||||||
woody_context:new(RpcID, undefined, akm_woody_client:get_service_deadline(akm)).
|
woody_context:new(RpcID, undefined, akm_woody_client:get_service_deadline(akm)).
|
||||||
@ -153,10 +151,7 @@ attach_deadline(undefined, Context) ->
|
|||||||
attach_deadline(Deadline, Context) ->
|
attach_deadline(Deadline, Context) ->
|
||||||
woody_context:set_deadline(Deadline, Context).
|
woody_context:set_deadline(Deadline, Context).
|
||||||
|
|
||||||
do_authorize_api_key('RevokeApiKey', #{cowboy_req := Req} = SwagContext, _WoodyContext) ->
|
do_authorize_api_key(SwagContext = #{auth_context := PreAuthContext}, WoodyContext) ->
|
||||||
PartyId = cowboy_req:binding(partyId, Req),
|
|
||||||
SwagContext#{auth_context => akm_auth:make_auth_context(PartyId)};
|
|
||||||
do_authorize_api_key(_OperationID, SwagContext = #{auth_context := PreAuthContext}, WoodyContext) ->
|
|
||||||
case akm_auth:authorize_api_key(PreAuthContext, make_token_context(SwagContext), WoodyContext) of
|
case akm_auth:authorize_api_key(PreAuthContext, make_token_context(SwagContext), WoodyContext) of
|
||||||
{ok, AuthContext} ->
|
{ok, AuthContext} ->
|
||||||
SwagContext#{auth_context => AuthContext};
|
SwagContext#{auth_context => AuthContext};
|
||||||
@ -193,6 +188,3 @@ process_woody_error(_Source, resource_unavailable, _Details) ->
|
|||||||
akm_handler_utils:reply_error(504);
|
akm_handler_utils:reply_error(504);
|
||||||
process_woody_error(_Source, result_unknown, _Details) ->
|
process_woody_error(_Source, result_unknown, _Details) ->
|
||||||
akm_handler_utils:reply_error(504).
|
akm_handler_utils:reply_error(504).
|
||||||
|
|
||||||
new_request_id() ->
|
|
||||||
base64:encode(crypto:strong_rand_bytes(24)).
|
|
||||||
|
@ -93,3 +93,21 @@ wait_result() ->
|
|||||||
to_int(Value) when is_integer(Value) -> Value;
|
to_int(Value) when is_integer(Value) -> Value;
|
||||||
to_int(Value) when is_binary(Value) -> erlang:binary_to_integer(Value);
|
to_int(Value) when is_binary(Value) -> erlang:binary_to_integer(Value);
|
||||||
to_int(Value) when is_list(Value) -> erlang:list_to_integer(Value).
|
to_int(Value) when is_list(Value) -> erlang:list_to_integer(Value).
|
||||||
|
|
||||||
|
-ifdef(TEST).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
|
||||||
|
-spec test() -> _.
|
||||||
|
|
||||||
|
-spec to_int_test() -> _.
|
||||||
|
to_int_test() ->
|
||||||
|
?assertEqual(123, to_int(123)),
|
||||||
|
?assertEqual(123, to_int(<<"123">>)),
|
||||||
|
?assertEqual(123, to_int("123")).
|
||||||
|
|
||||||
|
-spec wait_test() -> _.
|
||||||
|
wait_test() ->
|
||||||
|
erlang:send_after(timeout() + 10, self(), timeout),
|
||||||
|
?assertEqual({error, {failed_to_send, sending_email_timeout}}, wait_result()).
|
||||||
|
|
||||||
|
-endif.
|
||||||
|
@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
-define(TEMPLATE_FILE, "request_revoke.dtl").
|
-define(TEMPLATE_FILE, "request_revoke.dtl").
|
||||||
-define(TEMPLATE_DIR, "/opt/api-key-mgmt-v2/templates").
|
-define(TEMPLATE_DIR, "/opt/api-key-mgmt-v2/templates").
|
||||||
|
-define(VAULT_TOKEN_PATH, "/var/run/secrets/kubernetes.io/serviceaccount/token").
|
||||||
|
-define(VAULT_ROLE, <<"api-key-mgmt-v2">>).
|
||||||
|
-define(VAULT_KEY_PG_CREDS, <<"api-key-mgmt-v2/pg_creds">>).
|
||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([start_link/0]).
|
-export([start_link/0]).
|
||||||
@ -26,6 +29,7 @@ start_link() ->
|
|||||||
|
|
||||||
-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
|
-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
|
||||||
init([]) ->
|
init([]) ->
|
||||||
|
ok = maybe_set_secrets(),
|
||||||
ok = dbinit(),
|
ok = dbinit(),
|
||||||
{ok, _} = compile_template(),
|
{ok, _} = compile_template(),
|
||||||
{LogicHandlers, LogicHandlerSpecs} = get_logic_handler_info(),
|
{LogicHandlers, LogicHandlerSpecs} = get_logic_handler_info(),
|
||||||
@ -71,10 +75,10 @@ get_env_var(Name) ->
|
|||||||
|
|
||||||
dbinit() ->
|
dbinit() ->
|
||||||
WorkDir = get_env_var("WORK_DIR"),
|
WorkDir = get_env_var("WORK_DIR"),
|
||||||
EnvPath = WorkDir ++ "/.env",
|
_ = set_database_url(),
|
||||||
MigrationsPath = WorkDir ++ "/migrations",
|
MigrationsPath = WorkDir ++ "/migrations",
|
||||||
Cmd = "run",
|
Cmd = "run",
|
||||||
case akm_db_migration:process(["-e", EnvPath, "-d", MigrationsPath, Cmd]) of
|
case akm_db_migration:process(["-d", MigrationsPath, Cmd]) of
|
||||||
ok -> ok;
|
ok -> ok;
|
||||||
{error, Reason} -> throw({migrations_error, Reason})
|
{error, Reason} -> throw({migrations_error, Reason})
|
||||||
end.
|
end.
|
||||||
@ -95,3 +99,98 @@ default_template_file() ->
|
|||||||
|
|
||||||
template_file() ->
|
template_file() ->
|
||||||
filename:join([?TEMPLATE_DIR, ?TEMPLATE_FILE]).
|
filename:join([?TEMPLATE_DIR, ?TEMPLATE_FILE]).
|
||||||
|
|
||||||
|
set_database_url() ->
|
||||||
|
{ok, #{
|
||||||
|
host := PgHost,
|
||||||
|
port := PgPort,
|
||||||
|
username := PgUser,
|
||||||
|
password := PgPassword,
|
||||||
|
database := DbName
|
||||||
|
}} = application:get_env(akm, epsql_connection),
|
||||||
|
%% DATABASE_URL=postgresql://postgres:postgres@db/apikeymgmtv2
|
||||||
|
PgPortStr = erlang:integer_to_list(PgPort),
|
||||||
|
Value =
|
||||||
|
"postgresql://" ++ PgUser ++ ":" ++ PgPassword ++ "@" ++ PgHost ++ ":" ++ PgPortStr ++ "/" ++ DbName,
|
||||||
|
true = os:putenv("DATABASE_URL", Value).
|
||||||
|
|
||||||
|
maybe_set_secrets() ->
|
||||||
|
TokenPath = application:get_env(akm, vault_token_path, ?VAULT_TOKEN_PATH),
|
||||||
|
case vault_client_auth(TokenPath) of
|
||||||
|
ok ->
|
||||||
|
Key = application:get_env(akm, vault_key_pg_creds, ?VAULT_KEY_PG_CREDS),
|
||||||
|
set_secrets(canal:read(Key));
|
||||||
|
Error ->
|
||||||
|
logger:error("can`t auth vault client with error: ~p", [Error]),
|
||||||
|
skip
|
||||||
|
end,
|
||||||
|
ok.
|
||||||
|
|
||||||
|
set_secrets(
|
||||||
|
{
|
||||||
|
ok, #{
|
||||||
|
<<"pg_creds">> := #{
|
||||||
|
<<"pg_user">> := PgUser,
|
||||||
|
<<"pg_password">> := PgPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) ->
|
||||||
|
logger:info("postgres credentials successfuly read from vault (as json)"),
|
||||||
|
{ok, ConnOpts} = application:get_env(akm, epsql_connection),
|
||||||
|
application:set_env(
|
||||||
|
akm,
|
||||||
|
epsql_connection,
|
||||||
|
ConnOpts#{
|
||||||
|
username => unicode:characters_to_list(PgUser),
|
||||||
|
password => unicode:characters_to_list(PgPassword)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ok;
|
||||||
|
set_secrets({ok, #{<<"pg_creds">> := PgCreds}}) ->
|
||||||
|
logger:info("postgres credentials successfuly read from vault (as string)"),
|
||||||
|
set_secrets({ok, #{<<"pg_creds">> => jsx:decode(PgCreds, [return_maps])}});
|
||||||
|
set_secrets(Error) ->
|
||||||
|
logger:error("can`t read postgres credentials from vault with error: ~p", [Error]),
|
||||||
|
skip.
|
||||||
|
|
||||||
|
vault_client_auth(TokenPath) ->
|
||||||
|
case read_maybe_linked_file(TokenPath) of
|
||||||
|
{ok, Token} ->
|
||||||
|
Role = application:get_env(akm, vault_role, ?VAULT_ROLE),
|
||||||
|
canal:auth({kubernetes, Role, Token});
|
||||||
|
Error ->
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
read_maybe_linked_file(MaybeLinkName) ->
|
||||||
|
case file:read_link(MaybeLinkName) of
|
||||||
|
{error, enoent} = Result ->
|
||||||
|
Result;
|
||||||
|
{error, einval} ->
|
||||||
|
file:read_file(MaybeLinkName);
|
||||||
|
{ok, Filename} ->
|
||||||
|
file:read_file(maybe_expand_relative(MaybeLinkName, Filename))
|
||||||
|
end.
|
||||||
|
|
||||||
|
maybe_expand_relative(BaseFilename, Filename) ->
|
||||||
|
filename:absname_join(filename:dirname(BaseFilename), Filename).
|
||||||
|
|
||||||
|
-ifdef(TEST).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
|
||||||
|
-spec test() -> _.
|
||||||
|
|
||||||
|
-spec set_secrets_error_test() -> _.
|
||||||
|
set_secrets_error_test() ->
|
||||||
|
?assertEqual(skip, set_secrets(error)).
|
||||||
|
|
||||||
|
-spec read_error_test() -> _.
|
||||||
|
read_error_test() ->
|
||||||
|
?assertEqual({error, enoent}, read_maybe_linked_file("unknown_file")).
|
||||||
|
|
||||||
|
-spec vault_auth_error_test() -> _.
|
||||||
|
vault_auth_error_test() ->
|
||||||
|
?assertEqual({error, enoent}, vault_client_auth("unknown_file")).
|
||||||
|
|
||||||
|
-endif.
|
||||||
|
@ -96,13 +96,13 @@ issue_get_key_success_test(Config) ->
|
|||||||
},
|
},
|
||||||
PartyId = <<"test_party">>,
|
PartyId = <<"test_party">>,
|
||||||
#{
|
#{
|
||||||
<<"AccessToken">> := #{<<"accessToken">> := ?ACCESS_TOKEN},
|
<<"accessToken">> := ?ACCESS_TOKEN,
|
||||||
<<"ApiKey">> := #{
|
<<"apiKey">> := #{
|
||||||
<<"createdAt">> := _DateTimeRfc3339,
|
<<"createdAt">> := _DateTimeRfc3339,
|
||||||
<<"id">> := ApiKeyId,
|
<<"id">> := ApiKeyId,
|
||||||
<<"metadata">> := #{
|
<<"metadata">> := #{
|
||||||
<<"key">> := <<"value">>,
|
<<"key">> := <<"value">>,
|
||||||
<<"party.id">> := <<"test_party">>
|
<<"dev.vality.party.id">> := <<"test_party">>
|
||||||
},
|
},
|
||||||
<<"name">> := <<"live-site-integration">>,
|
<<"name">> := <<"live-site-integration">>,
|
||||||
<<"status">> := <<"active">>
|
<<"status">> := <<"active">>
|
||||||
@ -128,9 +128,9 @@ list_keys_test(Config) ->
|
|||||||
%% check empty list
|
%% check empty list
|
||||||
#{<<"results">> := []} = akm_client:list_keys(Host, Port, PartyId),
|
#{<<"results">> := []} = akm_client:list_keys(Host, Port, PartyId),
|
||||||
|
|
||||||
ListKeys = lists:foldl(
|
ExpectedList = lists:foldl(
|
||||||
fun(Num, Acc) ->
|
fun(Num, Acc) ->
|
||||||
#{<<"ApiKey">> := ApiKey} = akm_client:issue_key(
|
#{<<"apiKey">> := ApiKey} = akm_client:issue_key(
|
||||||
Host,
|
Host,
|
||||||
Port,
|
Port,
|
||||||
PartyId,
|
PartyId,
|
||||||
@ -141,7 +141,6 @@ list_keys_test(Config) ->
|
|||||||
[],
|
[],
|
||||||
lists:seq(1, 10)
|
lists:seq(1, 10)
|
||||||
),
|
),
|
||||||
ExpectedList = lists:reverse(ListKeys),
|
|
||||||
|
|
||||||
%% check one batch
|
%% check one batch
|
||||||
#{
|
#{
|
||||||
@ -177,7 +176,7 @@ revoke_key_w_email_error_test(Config) ->
|
|||||||
PartyId = <<"revoke_party">>,
|
PartyId = <<"revoke_party">>,
|
||||||
|
|
||||||
#{
|
#{
|
||||||
<<"ApiKey">> := #{
|
<<"apiKey">> := #{
|
||||||
<<"id">> := ApiKeyId
|
<<"id">> := ApiKeyId
|
||||||
}
|
}
|
||||||
} = akm_client:issue_key(Host, Port, PartyId, #{name => <<"live-site-integration">>}),
|
} = akm_client:issue_key(Host, Port, PartyId, #{name => <<"live-site-integration">>}),
|
||||||
@ -191,7 +190,7 @@ revoke_key_test(Config) ->
|
|||||||
PartyId = <<"revoke_party">>,
|
PartyId = <<"revoke_party">>,
|
||||||
|
|
||||||
#{
|
#{
|
||||||
<<"ApiKey">> := #{
|
<<"apiKey">> := #{
|
||||||
<<"id">> := ApiKeyId
|
<<"id">> := ApiKeyId
|
||||||
}
|
}
|
||||||
} = akm_client:issue_key(Host, Port, PartyId, #{name => <<"live-site-integration">>}),
|
} = akm_client:issue_key(Host, Port, PartyId, #{name => <<"live-site-integration">>}),
|
||||||
|
@ -71,7 +71,11 @@ request_revoke_key(Host, Port, PartyId, ApiKeyId) ->
|
|||||||
|
|
||||||
-spec revoke_key(inet:hostname() | inet:ip_address(), inet:port_number(), binary()) -> any().
|
-spec revoke_key(inet:hostname() | inet:ip_address(), inet:port_number(), binary()) -> any().
|
||||||
revoke_key(Host, Port, PathWithQuery) ->
|
revoke_key(Host, Port, PathWithQuery) ->
|
||||||
Headers = [],
|
Headers = [
|
||||||
|
{<<"X-Request-ID">>, <<"request_revoke">>},
|
||||||
|
{<<"content-type">>, <<"application/json; charset=utf-8">>},
|
||||||
|
{<<"Authorization">>, <<"Bearer sffsdfsfsdfsdfs">>}
|
||||||
|
],
|
||||||
ConnPid = connect(Host, Port),
|
ConnPid = connect(Host, Port),
|
||||||
Answer = get(ConnPid, PathWithQuery, Headers),
|
Answer = get(ConnPid, PathWithQuery, Headers),
|
||||||
disconnect(ConnPid),
|
disconnect(ConnPid),
|
||||||
|
@ -76,6 +76,7 @@ set_environment(State) ->
|
|||||||
|
|
||||||
prepare_config(State) ->
|
prepare_config(State) ->
|
||||||
AkmAddress = "::",
|
AkmAddress = "::",
|
||||||
|
WorkDir = get_env_var("WORK_DIR"),
|
||||||
AkmPort = get_free_port(),
|
AkmPort = get_free_port(),
|
||||||
PgConfig = get_pg_config(),
|
PgConfig = get_pg_config(),
|
||||||
SysConfig = [
|
SysConfig = [
|
||||||
@ -83,6 +84,8 @@ prepare_config(State) ->
|
|||||||
{ip, AkmAddress},
|
{ip, AkmAddress},
|
||||||
{port, AkmPort},
|
{port, AkmPort},
|
||||||
{transport, thrift},
|
{transport, thrift},
|
||||||
|
{bouncer_ruleset_id, <<"service/authz/api">>},
|
||||||
|
{vault_token_path, WorkDir ++ "/rebar.config"},
|
||||||
{health_check, #{
|
{health_check, #{
|
||||||
disk => {erl_health, disk, ["/", 99]},
|
disk => {erl_health, disk, ["/", 99]},
|
||||||
memory => {erl_health, cg_memory, [99]},
|
memory => {erl_health, cg_memory, [99]},
|
||||||
@ -106,6 +109,11 @@ prepare_config(State) ->
|
|||||||
password => "password",
|
password => "password",
|
||||||
username => "username"
|
username => "username"
|
||||||
}}
|
}}
|
||||||
|
]},
|
||||||
|
|
||||||
|
{canal, [
|
||||||
|
{url, "http://vault:8200"},
|
||||||
|
{engine, kvv2}
|
||||||
]}
|
]}
|
||||||
],
|
],
|
||||||
|
|
||||||
@ -120,9 +128,29 @@ prepare_config(State) ->
|
|||||||
|
|
||||||
mock_services(State) ->
|
mock_services(State) ->
|
||||||
meck:expect(
|
meck:expect(
|
||||||
akm_bouncer,
|
canal,
|
||||||
|
auth,
|
||||||
|
fun(_) -> ok end
|
||||||
|
),
|
||||||
|
meck:expect(
|
||||||
|
canal,
|
||||||
|
read,
|
||||||
|
fun(_) ->
|
||||||
|
{
|
||||||
|
ok,
|
||||||
|
#{
|
||||||
|
<<"pg_creds">> => jsx:encode(#{
|
||||||
|
<<"pg_user">> => get_env_var("POSTGRES_USER", "postgres"),
|
||||||
|
<<"pg_password">> => get_env_var("POSTGRES_PASSWORD", "postgres")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
),
|
||||||
|
meck:expect(
|
||||||
|
bouncer_client,
|
||||||
judge,
|
judge,
|
||||||
fun(_, _) -> allowed end
|
fun(_, _, _) -> allowed end
|
||||||
),
|
),
|
||||||
meck:expect(
|
meck:expect(
|
||||||
token_keeper_authority_offline,
|
token_keeper_authority_offline,
|
||||||
@ -132,10 +160,17 @@ mock_services(State) ->
|
|||||||
end
|
end
|
||||||
),
|
),
|
||||||
meck:expect(
|
meck:expect(
|
||||||
akm_auth,
|
token_keeper_authority_offline,
|
||||||
authorize_api_key,
|
revoke,
|
||||||
|
fun(_ID, _Client) ->
|
||||||
|
{ok, ok}
|
||||||
|
end
|
||||||
|
),
|
||||||
|
meck:expect(
|
||||||
|
token_keeper_authenticator,
|
||||||
|
authenticate,
|
||||||
fun(_PreAuthContext, _TokenContext, _WoodyContext) ->
|
fun(_PreAuthContext, _TokenContext, _WoodyContext) ->
|
||||||
{ok, {authorized, ?AUTH_CTX}}
|
{ok, ?AUTH_CTX}
|
||||||
end
|
end
|
||||||
),
|
),
|
||||||
meck:expect(
|
meck:expect(
|
||||||
@ -163,8 +198,8 @@ get_pg_config() ->
|
|||||||
#{
|
#{
|
||||||
host => get_env_var("POSTGRES_HOST"),
|
host => get_env_var("POSTGRES_HOST"),
|
||||||
port => list_to_integer(get_env_var("POSTGRES_PORT", "5432")),
|
port => list_to_integer(get_env_var("POSTGRES_PORT", "5432")),
|
||||||
username => get_env_var("POSTGRES_USER", "postgres"),
|
%% username => get_env_var("POSTGRES_USER", "postgres"),
|
||||||
password => get_env_var("POSTGRES_PASSWORD", "postgres"),
|
%% password => get_env_var("POSTGRES_PASSWORD", "postgres"),
|
||||||
database => get_env_var("POSTGRES_DB", "apikeymgmtv2")
|
database => get_env_var("POSTGRES_DB", "apikeymgmtv2")
|
||||||
}.
|
}.
|
||||||
|
|
||||||
|
@ -146,5 +146,10 @@
|
|||||||
|
|
||||||
{prometheus, [
|
{prometheus, [
|
||||||
{collectors, [default]}
|
{collectors, [default]}
|
||||||
|
]},
|
||||||
|
|
||||||
|
{canal, [
|
||||||
|
{url, "http://vault:8200"},
|
||||||
|
{engine, kvv2}
|
||||||
]}
|
]}
|
||||||
].
|
].
|
||||||
|
@ -47,6 +47,9 @@
|
|||||||
{swag_client_apikeys,
|
{swag_client_apikeys,
|
||||||
{git, "https://github.com/valitydev/swag-api-keys-v2.git", {branch, "release/erlang/client/master"}}},
|
{git, "https://github.com/valitydev/swag-api-keys-v2.git", {branch, "release/erlang/client/master"}}},
|
||||||
|
|
||||||
|
%% Vault client for getting secrets
|
||||||
|
{canal, {git, "https://github.com/valitydev/canal", {branch, master}}},
|
||||||
|
|
||||||
%% Libraries for postgres interaction
|
%% Libraries for postgres interaction
|
||||||
{epgsql, {git, "https://github.com/epgsql/epgsql.git", {tag, "4.7.1"}}},
|
{epgsql, {git, "https://github.com/epgsql/epgsql.git", {tag, "4.7.1"}}},
|
||||||
{epgsql_pool, {git, "https://github.com/wgnet/epgsql_pool", {branch, "master"}}},
|
{epgsql_pool, {git, "https://github.com/wgnet/epgsql_pool", {branch, "master"}}},
|
||||||
|
11
rebar.lock
11
rebar.lock
@ -16,6 +16,10 @@
|
|||||||
{ref,"b23c905db51915737fdab80c2a3af4c546b32799"}},
|
{ref,"b23c905db51915737fdab80c2a3af4c546b32799"}},
|
||||||
0},
|
0},
|
||||||
{<<"cache">>,{pkg,<<"cache">>,<<"2.3.3">>},1},
|
{<<"cache">>,{pkg,<<"cache">>,<<"2.3.3">>},1},
|
||||||
|
{<<"canal">>,
|
||||||
|
{git,"https://github.com/valitydev/canal",
|
||||||
|
{ref,"621d3821cd0a6036fee75d8e3b2d17167f3268e4"}},
|
||||||
|
0},
|
||||||
{<<"certifi">>,{pkg,<<"certifi">>,<<"2.6.1">>},2},
|
{<<"certifi">>,{pkg,<<"certifi">>,<<"2.6.1">>},2},
|
||||||
{<<"cg_mon">>,
|
{<<"cg_mon">>,
|
||||||
{git,"https://github.com/rbkmoney/cg_mon.git",
|
{git,"https://github.com/rbkmoney/cg_mon.git",
|
||||||
@ -104,6 +108,7 @@
|
|||||||
{git,"https://github.com/potatosalad/erlang-jose.git",
|
{git,"https://github.com/potatosalad/erlang-jose.git",
|
||||||
{ref,"991649695aaccd92c8effb1c1e88e6159fe8e9a6"}},
|
{ref,"991649695aaccd92c8effb1c1e88e6159fe8e9a6"}},
|
||||||
0},
|
0},
|
||||||
|
{<<"jsone">>,{pkg,<<"jsone">>,<<"1.8.0">>},1},
|
||||||
{<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},1},
|
{<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},1},
|
||||||
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2},
|
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2},
|
||||||
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},2},
|
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},2},
|
||||||
@ -133,11 +138,11 @@
|
|||||||
{<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},2},
|
{<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},2},
|
||||||
{<<"swag_client_apikeys">>,
|
{<<"swag_client_apikeys">>,
|
||||||
{git,"https://github.com/valitydev/swag-api-keys-v2.git",
|
{git,"https://github.com/valitydev/swag-api-keys-v2.git",
|
||||||
{ref,"dd3ccc414fc7b08a9b62acad7aefdbc600566062"}},
|
{ref,"a56933dbc52bcf4ea68ae91bba1e8869730ae71d"}},
|
||||||
0},
|
0},
|
||||||
{<<"swag_server_apikeys">>,
|
{<<"swag_server_apikeys">>,
|
||||||
{git,"https://github.com/valitydev/swag-api-keys-v2.git",
|
{git,"https://github.com/valitydev/swag-api-keys-v2.git",
|
||||||
{ref,"a0f3b2d46e9eba46c89b3bf81145629da8bf0a35"}},
|
{ref,"66723162eeba4b6589df24b19066d626178067ab"}},
|
||||||
0},
|
0},
|
||||||
{<<"tds_proto">>,
|
{<<"tds_proto">>,
|
||||||
{git,"https://github.com/valitydev/tds-proto.git",
|
{git,"https://github.com/valitydev/tds-proto.git",
|
||||||
@ -183,6 +188,7 @@
|
|||||||
{<<"gun">>, <<"160A9A5394800FCBA41BC7E6D421295CF9A7894C2252C0678244948E3336AD73">>},
|
{<<"gun">>, <<"160A9A5394800FCBA41BC7E6D421295CF9A7894C2252C0678244948E3336AD73">>},
|
||||||
{<<"hackney">>, <<"99DA4674592504D3FB0CFEF0DB84C3BA02B4508BAE2DFF8C0108BAA0D6E0977C">>},
|
{<<"hackney">>, <<"99DA4674592504D3FB0CFEF0DB84C3BA02B4508BAE2DFF8C0108BAA0D6E0977C">>},
|
||||||
{<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>},
|
{<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>},
|
||||||
|
{<<"jsone">>, <<"347FF1FA700E182E1F9C5012FA6D737B12C854313B9AE6954CA75D3987D6C06D">>},
|
||||||
{<<"jsx">>, <<"D12516BAA0BB23A59BB35DCCAF02A1BD08243FCBB9EFE24F2D9D056CCFF71268">>},
|
{<<"jsx">>, <<"D12516BAA0BB23A59BB35DCCAF02A1BD08243FCBB9EFE24F2D9D056CCFF71268">>},
|
||||||
{<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
|
{<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
|
||||||
{<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>},
|
{<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>},
|
||||||
@ -205,6 +211,7 @@
|
|||||||
{<<"gun">>, <<"A10BC8D6096B9502205022334F719CC9A08D9ADCFBFC0DBEE9EF31B56274A20B">>},
|
{<<"gun">>, <<"A10BC8D6096B9502205022334F719CC9A08D9ADCFBFC0DBEE9EF31B56274A20B">>},
|
||||||
{<<"hackney">>, <<"DE16FF4996556C8548D512F4DBE22DD58A587BF3332E7FD362430A7EF3986B16">>},
|
{<<"hackney">>, <<"DE16FF4996556C8548D512F4DBE22DD58A587BF3332E7FD362430A7EF3986B16">>},
|
||||||
{<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>},
|
{<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>},
|
||||||
|
{<<"jsone">>, <<"08560B78624A12E0B5E7EC0271EC8CA38EF51F63D84D84843473E14D9B12618C">>},
|
||||||
{<<"jsx">>, <<"0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3">>},
|
{<<"jsx">>, <<"0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3">>},
|
||||||
{<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>},
|
{<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>},
|
||||||
{<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>},
|
{<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>},
|
||||||
|
Loading…
Reference in New Issue
Block a user