From 6e02444fbdc0d8c50e4f9c8b2389d43645f5199e Mon Sep 17 00:00:00 2001 From: ndiezel0 Date: Fri, 21 Jul 2023 16:40:50 +0500 Subject: [PATCH] TD-651: Implement RevokeApiKey (#8) * TD-651: Implement RevokeApiKey * Fix specs and checks * Format * Fix tests * Add revoke * Fix lint * Add test * TD-651: fix formatting * TD-651: fix dialyzer checks * TD-651: fix style * TD-651: fix build * TD-651: fix typo * TD-651: fix formatting * TD-651: implement revoke tokens * TD-651: add party_id checking to sql query * TD-651:fix sql migrations --------- Co-authored-by: anatoliy.losev --- .env | 2 +- apps/akm/src/akm.app.src | 4 +- apps/akm/src/akm_apikeys_handler.erl | 62 ++++++- apps/akm/src/akm_apikeys_processing.erl | 159 ++++++++++++---- apps/akm/src/akm_auth.erl | 3 + apps/akm/src/akm_db_migration.erl | 18 +- apps/akm/src/akm_mailer.erl | 79 ++++++++ apps/akm/src/akm_sup.erl | 2 +- apps/akm/src/akm_utils.erl | 14 ++ apps/akm/test/akm_basic_test_SUITE.erl | 171 +++++++++++++++--- {test => apps/akm/test}/akm_client.erl | 37 +++- {test => apps/akm/test}/akm_ct_utils.erl | 8 +- {test => apps/akm/test}/akm_cth.erl | 34 +++- compose.yaml | 25 +++ config/sys.config | 8 + elvis.config | 5 + migrations/1686524106-create_api_keys.sql | 14 +- .../1689828848-TD-651-add-pending-status.sql | 8 + priv/mails/request_revoke.dtl | 1 + rebar.config | 20 +- rebar.lock | 16 +- 21 files changed, 584 insertions(+), 106 deletions(-) create mode 100644 apps/akm/src/akm_mailer.erl rename {test => apps/akm/test}/akm_client.erl (70%) rename {test => apps/akm/test}/akm_ct_utils.erl (73%) rename {test => apps/akm/test}/akm_cth.erl (83%) create mode 100644 migrations/1689828848-TD-651-add-pending-status.sql create mode 100644 priv/mails/request_revoke.dtl diff --git a/.env b/.env index 417e38b..58f32ea 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ SERVICE_NAME=api-key-mgmt-v2 -OTP_VERSION=24.2.0 +OTP_VERSION=25.3 REBAR_VERSION=3.18 THRIFT_VERSION=0.14.2.3 DATABASE_URL=postgresql://postgres:postgres@db/apikeymgmtv2 \ No newline at end of file diff --git a/apps/akm/src/akm.app.src b/apps/akm/src/akm.app.src index 8b833be..6e7d067 100644 --- a/apps/akm/src/akm.app.src +++ b/apps/akm/src/akm.app.src @@ -26,7 +26,9 @@ eql, swag_server_apikeys, snowflake, - woody_user_identity + woody_user_identity, + erlydtl, + gen_smtp ]}, {env, []} ]}. diff --git a/apps/akm/src/akm_apikeys_handler.erl b/apps/akm/src/akm_apikeys_handler.erl index 8c1a425..812e3d9 100644 --- a/apps/akm/src/akm_apikeys_handler.erl +++ b/apps/akm/src/akm_apikeys_handler.erl @@ -50,6 +50,7 @@ -export_type([headers/0]). -export_type([response_data/0]). -export_type([request_context/0]). +-export_type([auth_context/0]). -export_type([operation_id/0]). -export_type([handler_context/0]). -export_type([swag_server_get_schema_fun/0]). @@ -83,7 +84,7 @@ prepare(OperationID = 'GetApiKey', #{'partyId' := PartyID, 'apiKeyId' := ApiKeyI {ok, Resolution} end, Process = fun() -> - case akm_apikeys_processing:get_api_key(ApiKeyId) of + case akm_apikeys_processing:get_api_key(ApiKeyId, PartyID) of {ok, ApiKey} -> akm_handler_utils:reply_ok(200, ApiKey); {error, not_found} -> @@ -91,18 +92,67 @@ prepare(OperationID = 'GetApiKey', #{'partyId' := PartyID, 'apiKeyId' := ApiKeyI end end, {ok, #{authorize => Authorize, process => Process}}; -prepare(OperationID = 'ListApiKeys', #{'partyId' := PartyID, 'limit' := Limit, 'status' := Status0, - continuationToken := ContinuationToken0}, Context, _Opts) -> +prepare( + OperationID = 'ListApiKeys', + #{ + 'partyId' := PartyID, + 'limit' := Limit, + 'status' := Status0, + continuationToken := ContinuationToken0 + }, + Context, + _Opts +) -> Authorize = fun() -> Prototypes = [{operation, #{id => OperationID, party => PartyID}}], Resolution = akm_auth:authorize_operation(Prototypes, Context), {ok, Resolution} - end, + end, Status = genlib:define(Status0, <<"active">>), ContinuationToken = erlang:binary_to_integer(genlib:define(ContinuationToken0, <<"0">>)), Process = fun() -> {ok, Response} = akm_apikeys_processing:list_api_keys(PartyID, Status, Limit, ContinuationToken), akm_handler_utils:reply_ok(200, Response) end, - {ok, #{authorize => Authorize, process => Process}} -. + {ok, #{authorize => Authorize, process => Process}}; +prepare(OperationID = 'RequestRevokeApiKey', Params, Context, _Opts) -> + #{ + 'partyId' := PartyID, + 'apiKeyId' := ApiKeyId, + 'RequestRevoke' := #{<<"status">> := Status} + } = Params, + Authorize = fun() -> + Prototypes = [{operation, #{id => OperationID, party => PartyID}}], + Resolution = akm_auth:authorize_operation(Prototypes, Context), + {ok, Resolution} + end, + Process = fun() -> + Email = akm_auth:get_user_email(akm_auth:extract_auth_context(Context)), + case akm_apikeys_processing:request_revoke(Email, PartyID, ApiKeyId, Status) of + {ok, revoke_email_sent} -> + akm_handler_utils:reply_ok(204); + {error, not_found} -> + akm_handler_utils:reply_error(404) + end + end, + {ok, #{authorize => Authorize, process => Process}}; +prepare( + OperationID = 'RevokeApiKey', + #{'partyId' := PartyID, 'apiKeyId' := ApiKeyId, 'apiKeyRevokeToken' := Token}, + Context, + _Opts +) -> + Authorize = fun() -> + Prototypes = [{operation, #{id => OperationID, party => PartyID}}], + Resolution = akm_auth:authorize_operation(Prototypes, Context), + {ok, Resolution} + end, + Process = fun() -> + case akm_apikeys_processing:revoke(PartyID, ApiKeyId, Token) of + ok -> + akm_handler_utils:reply_ok(204); + {error, not_found} -> + akm_handler_utils:reply_error(404) + end + end, + {ok, #{authorize => Authorize, process => Process}}. diff --git a/apps/akm/src/akm_apikeys_processing.erl b/apps/akm/src/akm_apikeys_processing.erl index 268050c..9db593c 100644 --- a/apps/akm/src/akm_apikeys_processing.erl +++ b/apps/akm/src/akm_apikeys_processing.erl @@ -5,8 +5,10 @@ -include_lib("epgsql/include/epgsql.hrl"). -export([issue_api_key/3]). --export([get_api_key/1]). +-export([get_api_key/2]). -export([list_api_keys/4]). +-export([request_revoke/4]). +-export([revoke/3]). -type list_keys_response() :: #{ results => [map()], @@ -16,7 +18,7 @@ -spec issue_api_key(_, _, _) -> _. issue_api_key(PartyID, #{<<"name">> := Name} = ApiKey0, WoodyContext) -> Metadata0 = maps:get(<<"metadata">>, ApiKey0, #{}), -%% REWORK ненормальный ID, переработать + %% REWORK ненормальный ID, переработать ID = akm_id:generate_snowflake_id(), ContextV1Fragment = bouncer_context_helpers:make_auth_fragment(#{ method => <<"IssueApiKey">>, @@ -34,11 +36,11 @@ issue_api_key(PartyID, #{<<"name">> := Name} = ApiKey0, WoodyContext) -> {ok, #{token := Token}} -> {ok, _, Columns, Rows} = epgsql_pool:query( main_pool, - "INSERT INTO apikeys (id, name, party_id, status, metadata)" - "VALUES ($1, $2, $3, $4, $5) RETURNING id, name, status, metadata, created_at", - [ID, Name, PartyID, Status, jsx:encode(Metadata)] + "INSERT INTO apikeys (id, name, party_id, status, pending_status, metadata)" + "VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, status, metadata, created_at", + [ID, Name, PartyID, Status, Status, jsx:encode(Metadata)] ), - [ApiKey | _] = to_maps(Columns, Rows), + [ApiKey | _] = to_marshalled_maps(Columns, Rows), Resp = #{ <<"AccessToken">> => marshall_access_token(Token), <<"ApiKey">> => ApiKey @@ -48,18 +50,18 @@ issue_api_key(PartyID, #{<<"name">> := Name} = ApiKey0, WoodyContext) -> {error, already_exists} end. --spec get_api_key(binary()) -> {ok, map()} | {error, not_found}. -get_api_key(ApiKeyId) -> +-spec get_api_key(binary(), binary()) -> {ok, map()} | {error, not_found}. +get_api_key(ApiKeyId, PartyId) -> Result = epgsql_pool:query( main_pool, - "SELECT id, name, status, metadata, created_at FROM apikeys where id = $1", - [ApiKeyId] + "SELECT id, name, status, metadata, created_at FROM apikeys WHERE id = $1 AND party_id = $2", + [ApiKeyId, PartyId] ), case Result of {ok, _Columns, []} -> {error, not_found}; {ok, Columns, Rows} -> - [ApiKey | _ ] = to_maps(Columns, Rows), + [ApiKey | _] = to_marshalled_maps(Columns, Rows), {ok, ApiKey} end. @@ -74,9 +76,59 @@ list_api_keys(PartyId, Status, Limit, Offset) -> case erlang:length(Rows) < Limit of true -> % last piece of data - {ok, #{results => to_maps(Columns, Rows)}}; + {ok, #{results => to_marshalled_maps(Columns, Rows)}}; false -> - {ok, #{results => to_maps(Columns, Rows), continuationToken => erlang:integer_to_binary(Offset + Limit)}} + {ok, #{ + results => to_marshalled_maps(Columns, Rows), + continuationToken => erlang:integer_to_binary(Offset + Limit) + }} + end. + +-spec request_revoke(binary(), binary(), binary(), binary()) -> + {ok, revoke_email_sent} | {error, not_found}. +request_revoke(Email, PartyID, ApiKeyId, Status) -> + case get_full_api_key(ApiKeyId, PartyID) of + {error, not_found} -> + {error, not_found}; + {ok, _ApiKey} -> + Token = akm_id:generate_snowflake_id(), + try + epgsql_pool:transaction( + main_pool, + fun(Worker) -> + ok = akm_mailer:send_revoke_mail(Email, PartyID, ApiKeyId, Token), + epgsql_pool:query( + Worker, + "UPDATE apikeys SET pending_status = $1, revoke_token = $2 " + "WHERE id = $3 AND party_id = $4", + [Status, Token, ApiKeyId, PartyID] + ) + end + ) + of + {ok, 1} -> + {ok, revoke_email_sent} + catch + _Ex:_Er -> + error(failed_to_send_email) + end + end. + +-spec revoke(binary(), binary(), binary()) -> ok | {error, not_found}. +revoke(PartyId, ApiKeyId, RevokeToken) -> + case get_full_api_key(ApiKeyId, PartyId) of + {ok, #{ + <<"pending_status">> := PendingStatus, + <<"revoke_token">> := RevokeToken + }} -> + {ok, 1} = epgsql_pool:query( + main_pool, + "UPDATE apikeys SET status = $1, revoke_token = null WHERE id = $2 AND party_id = $3", + [PendingStatus, ApiKeyId, PartyId] + ), + ok; + _ -> + {error, not_found} end. %% Internal functions @@ -84,38 +136,45 @@ list_api_keys(PartyId, Status, Limit, Offset) -> get_authority_id() -> application:get_env(akm, authority_id). -%% Marshalling +get_full_api_key(ApiKeyId, PartyId) -> + Result = epgsql_pool:query( + main_pool, + "SELECT * FROM apikeys WHERE id = $1 AND party_id = $2", + [ApiKeyId, PartyId] + ), + case Result of + {ok, _Columns, []} -> + {error, not_found}; + {ok, Columns, Rows} -> + [ApiKey | _] = to_maps(Columns, Rows), + {ok, ApiKey} + end. -marshall_api_key(#{ - <<"id">> := ID, - <<"created_at">> := DateTime, - <<"name">> := Name, - <<"status">> := Status, - <<"metadata">> := Metadata - }) -> - #{ - <<"id">> => ID, - <<"createdAt">> => DateTime, - <<"name">> => Name, - <<"status">> => Status, - <<"metadata">> => decode_json(Metadata) - }. +%% Encode/Decode -marshall_access_token(Token) -> - #{ - <<"accessToken">> => Token - }. +to_marshalled_maps(Columns, Rows) -> + to_maps(Columns, Rows, fun marshall_api_key/1). to_maps(Columns, Rows) -> + to_maps(Columns, Rows, fun(V) -> V end). + +to_maps(Columns, Rows, TransformRowFun) -> ColNumbers = erlang:length(Columns), Seq = lists:seq(1, ColNumbers), - lists:map(fun(Row) -> - Data = lists:foldl(fun(Pos, Acc) -> - #column{name = Field, type = Type} = lists:nth(Pos, Columns), - Acc#{Field => convert(Type, erlang:element(Pos, Row))} - end, #{}, Seq), - marshall_api_key(Data) - end, Rows). + lists:map( + fun(Row) -> + Data = lists:foldl( + fun(Pos, Acc) -> + #column{name = Field, type = Type} = lists:nth(Pos, Columns), + Acc#{Field => convert(Type, erlang:element(Pos, Row))} + end, + #{}, + Seq + ), + TransformRowFun(Data) + end, + Rows + ). %% for reference https://github.com/epgsql/epgsql#data-representation convert(timestamp, Value) -> @@ -133,3 +192,25 @@ datetime_to_binary(DateTime) -> decode_json(null) -> #{}; decode_json(Value) -> jsx:decode(Value, [return_maps]). + +%% Marshalling + +marshall_api_key(#{ + <<"id">> := ID, + <<"created_at">> := DateTime, + <<"name">> := Name, + <<"status">> := Status, + <<"metadata">> := Metadata +}) -> + #{ + <<"id">> => ID, + <<"createdAt">> => DateTime, + <<"name">> => Name, + <<"status">> => Status, + <<"metadata">> => decode_json(Metadata) + }. + +marshall_access_token(Token) -> + #{ + <<"accessToken">> => Token + }. diff --git a/apps/akm/src/akm_auth.erl b/apps/akm/src/akm_auth.erl index 12f2e6e..d8463aa 100644 --- a/apps/akm/src/akm_auth.erl +++ b/apps/akm/src/akm_auth.erl @@ -6,6 +6,7 @@ -export([get_party_id/1]). -export([get_user_id/1]). -export([get_user_email/1]). +-export([extract_auth_context/1]). -export([preauthorize_api_key/1]). -export([authorize_api_key/3]). @@ -101,6 +102,8 @@ authorize_operation(Prototypes, Context) -> get_token_keeper_fragment(?AUTHORIZED(#{context := Context})) -> Context. +-spec extract_auth_context(akm_apikeys_handler:handler_context()) -> + akm_apikeys_handler:auth_context(). extract_auth_context(#{swagger_context := #{auth_context := AuthContext}}) -> AuthContext. diff --git a/apps/akm/src/akm_db_migration.erl b/apps/akm/src/akm_db_migration.erl index d2da363..412c8fa 100644 --- a/apps/akm/src/akm_db_migration.erl +++ b/apps/akm/src/akm_db_migration.erl @@ -33,11 +33,11 @@ handle_command({ok, {Args, ["new", Name]}}) -> "-- :down\n", "-- Down migration\n" ], - Result = case file:write_file(Filename, list_to_binary(C), [exclusive]) of - ok -> {ok, "Created migration: ~s~n", [Filename]}; - {error, Reason} -> {error, - "Migration can not be written to file ~s: ~s~n", [Filename, Reason]} - end, + Result = + case file:write_file(Filename, list_to_binary(C), [exclusive]) of + ok -> {ok, "Created migration: ~s~n", [Filename]}; + {error, Reason} -> {error, "Migration can not be written to file ~s: ~s~n", [Filename, Reason]} + end, handle_command_result(Result); handle_command({ok, {Args, ["run"]}}) -> Available = available_migrations(Args), @@ -128,10 +128,10 @@ handle_command({ok, {_, _}}) -> %% Utils -type command_result() :: -ok -| {ok, io:format(), [term()]} -| {error, string()} -| {error, io:format(), [term()]}. + ok + | {ok, io:format(), [term()]} + | {error, string()} + | {error, io:format(), [term()]}. -spec handle_command_result(command_result()) -> ok | {error, term()}. handle_command_result(ok) -> diff --git a/apps/akm/src/akm_mailer.erl b/apps/akm/src/akm_mailer.erl new file mode 100644 index 0000000..38a33e1 --- /dev/null +++ b/apps/akm/src/akm_mailer.erl @@ -0,0 +1,79 @@ +-module(akm_mailer). + +-include_lib("bouncer_proto/include/bouncer_ctx_v1_thrift.hrl"). +-include_lib("bouncer_proto/include/bouncer_ctx_thrift.hrl"). +-include_lib("epgsql/include/epgsql.hrl"). + +-define(TEMPLATE_FILE, "request_revoke.dtl"). + +-export([send_revoke_mail/4]). + +-spec send_revoke_mail(string(), binary(), binary(), binary()) -> + ok | {error, {failed_to_send, term()}}. +send_revoke_mail(Email, PartyID, ApiKeyID, Token) -> + {ok, Mod} = compile_template(), + {ok, Body} = Mod:render([ + {url, url()}, + {party_id, PartyID}, + {api_key_id, ApiKeyID}, + {revoke_token, Token} + ]), + BinaryBody = erlang:iolist_to_binary(Body), + Pid = self(), + case + gen_smtp_client:send( + {from_email(), [Email], BinaryBody}, + [{relay, relay()}, {username, username()}, {password, password()}], + fun(Result) -> erlang:send(Pid, {sending_result, Result}) end + ) + of + {error, Reason} -> + {error, {failed_to_send, Reason}}; + {ok, _SenderPid} -> + wait_result() + end. + +url() -> + #{url := URL} = get_env(), + URL. + +from_email() -> + #{from_email := From} = get_env(), + From. + +relay() -> + #{relay := Relay} = get_env(), + Relay. + +username() -> + #{username := Username} = get_env(), + Username. + +password() -> + #{password := Password} = get_env(), + Password. + +get_env() -> + genlib_app:env(akm, mailer, #{ + url => "vality.dev", + from_email => "example@example.com", + relay => "smtp.gmail.com", + username => "username", + %% NOTICE: for gmail need to generate password for application in https://myaccount.google.com/apppasswords + password => "password" + }). + +compile_template() -> + WorkDir = akm_utils:get_env_var("WORK_DIR"), + File = filename:join([WorkDir, "priv", "mails", ?TEMPLATE_FILE]), + erlydtl:compile(File, akm_mail_request_revoke). + +wait_result() -> + receive + {sending_result, {ok, _Receipt}} -> + ok; + {sending_result, Error} -> + {error, Error} + after 3000 -> + {error, {failed_to_send, sending_email_timeout}} + end. diff --git a/apps/akm/src/akm_sup.erl b/apps/akm/src/akm_sup.erl index 75da307..164ad1b 100644 --- a/apps/akm/src/akm_sup.erl +++ b/apps/akm/src/akm_sup.erl @@ -30,7 +30,7 @@ init([]) -> ok = start_epgsql_pooler(), {ok, { {one_for_all, 0, 1}, - LogicHandlerSpecs ++ [SwaggerSpec] + LogicHandlerSpecs ++ [SwaggerSpec] }}. -spec get_logic_handler_info() -> {akm_swagger_server:logic_handlers(), [supervisor:child_spec()]}. diff --git a/apps/akm/src/akm_utils.erl b/apps/akm/src/akm_utils.erl index bf3a008..dffabd7 100644 --- a/apps/akm/src/akm_utils.erl +++ b/apps/akm/src/akm_utils.erl @@ -29,6 +29,9 @@ -export([get_unique_id/0]). -export([get_random_id/0]). +-export([get_env_var/1]). +-export([get_env_var/2]). + -type binding_value() :: binary(). -type url() :: binary(). -type path() :: binary(). @@ -172,6 +175,17 @@ parse_deadline(DeadlineStr) -> ], try_parse_deadline(DeadlineStr, Parsers). +-spec get_env_var(string()) -> term(). +get_env_var(Name) -> + case os:getenv(Name) of + false -> throw({os_env_required, Name}); + V -> V + end. + +-spec get_env_var(string(), term()) -> term(). +get_env_var(Name, Default) -> + os:getenv(Name, Default). + %% %% Internals %% diff --git a/apps/akm/test/akm_basic_test_SUITE.erl b/apps/akm/test/akm_basic_test_SUITE.erl index f4a21bb..72e1bef 100644 --- a/apps/akm/test/akm_basic_test_SUITE.erl +++ b/apps/akm/test/akm_basic_test_SUITE.erl @@ -4,34 +4,87 @@ -export([ init_per_suite/1, end_per_suite/1, - all/0 + init_per_testcase/2, + end_per_testcase/2, + all/0, + groups/0 ]). -export([issue_get_key_success_test/1]). -export([get_unknown_key_test/1]). -export([list_keys_test/1]). +-export([revoke_key_w_email_error_test/1]). +-export([revoke_key_test/1]). %% also defined in ct hook module akm_cth.erl -define(ACCESS_TOKEN, <<"some.access.token">>). +-type config() :: akm_cth:config(). +-type test_case_name() :: akm_cth:test_case_name(). +-type group_name() :: akm_cth:group_name(). +-type test_result() :: any() | no_return(). + -spec init_per_suite(_) -> _. init_per_suite(Config) -> - Config. -spec end_per_suite(_) -> _. end_per_suite(_Config) -> - + ok = akm_ct_utils:cleanup_db(), ok. --spec all() -> list(). -all() -> [ - issue_get_key_success_test, - get_unknown_key_test, - list_keys_test -]. +-spec all() -> [{group, test_case_name()}]. +all() -> + [{group, basic_operations}]. --spec issue_get_key_success_test(_) -> _. +-spec groups() -> [{group_name(), list(), [test_case_name()]}]. +groups() -> + [ + {basic_operations, [], [ + issue_get_key_success_test, + get_unknown_key_test, + list_keys_test, + revoke_key_w_email_error_test, + revoke_key_test + ]} + ]. + +-spec init_per_testcase(test_case_name(), config()) -> config(). +init_per_testcase(revoke_key_w_email_error_test, C) -> + meck:expect( + gen_smtp_client, + send, + fun({_, _, _Msg}, _, CallbackFun) -> + P = spawn(fun() -> CallbackFun({error, {failed_to_send, sending_email_timeout}}) end), + {ok, P} + end + ), + C; +init_per_testcase(revoke_key_test, C) -> + meck:expect( + gen_smtp_client, + send, + fun({_, _, Msg}, _, CallbackFun) -> + application:set_env(akm, email_msg, Msg), + P = spawn(fun() -> CallbackFun({ok, <<"success">>}) end), + {ok, P} + end + ), + C; +init_per_testcase(_Name, C) -> + C. + +-spec end_per_testcase(test_case_name(), config()) -> _. +end_per_testcase(Name, C) when + Name =:= revoke_key_w_email_error_test; + Name =:= revoke_key_test +-> + meck:unload(gen_smtp_client), + C; +end_per_testcase(_Name, C) -> + C. + +-spec issue_get_key_success_test(config()) -> test_result(). issue_get_key_success_test(Config) -> Host = akm_ct_utils:lookup_config(akm_host, Config), Port = akm_ct_utils:lookup_config(akm_port, Config), @@ -59,14 +112,14 @@ issue_get_key_success_test(Config) -> %% check getApiKey ExpectedApiKey = akm_client:get_key(Host, Port, PartyId, ApiKeyId). --spec get_unknown_key_test(_) -> _. +-spec get_unknown_key_test(config()) -> test_result(). get_unknown_key_test(Config) -> Host = akm_ct_utils:lookup_config(akm_host, Config), Port = akm_ct_utils:lookup_config(akm_port, Config), PartyId = <<"unknown_key_test_party">>, not_found = akm_client:get_key(Host, Port, PartyId, <<"UnknownKeyId">>). --spec list_keys_test(_) -> _. +-spec list_keys_test(config()) -> test_result(). list_keys_test(Config) -> Host = akm_ct_utils:lookup_config(akm_host, Config), Port = akm_ct_utils:lookup_config(akm_port, Config), @@ -75,11 +128,19 @@ list_keys_test(Config) -> %% check empty list #{<<"results">> := []} = akm_client:list_keys(Host, Port, PartyId), - ListKeys = lists:foldl(fun(Num, Acc) -> - #{<<"ApiKey">> := ApiKey} = akm_client:issue_key(Host, Port, PartyId, - #{name => <<(erlang:integer_to_binary(Num))/binary, "list_keys_success">>}), - [ApiKey | Acc] - end, [], lists:seq(1, 10)), + ListKeys = lists:foldl( + fun(Num, Acc) -> + #{<<"ApiKey">> := ApiKey} = akm_client:issue_key( + Host, + Port, + PartyId, + #{name => <<(erlang:integer_to_binary(Num))/binary, "list_keys_success">>} + ), + [ApiKey | Acc] + end, + [], + lists:seq(1, 10) + ), ExpectedList = lists:reverse(ListKeys), %% check one batch @@ -89,17 +150,85 @@ list_keys_test(Config) -> %% check continuation when limit multiple of the count keys MultLimit = <<"1">>, - ExpectedList = get_list_keys(Host, Port, PartyId, MultLimit, - akm_client:list_keys(Host, Port, PartyId, [{<<"limit">>, MultLimit}]), []), + ExpectedList = get_list_keys( + Host, + Port, + PartyId, + MultLimit, + akm_client:list_keys(Host, Port, PartyId, [{<<"limit">>, MultLimit}]), + [] + ), %% check continuation when limit NOT multiple of the count keys NoMultLimit = <<"3">>, - ExpectedList = get_list_keys(Host, Port, PartyId, NoMultLimit, - akm_client:list_keys(Host, Port, PartyId, [{<<"limit">>, NoMultLimit}]), []). + ExpectedList = get_list_keys( + Host, + Port, + PartyId, + NoMultLimit, + akm_client:list_keys(Host, Port, PartyId, [{<<"limit">>, NoMultLimit}]), + [] + ). +-spec revoke_key_w_email_error_test(config()) -> test_result(). +revoke_key_w_email_error_test(Config) -> + Host = akm_ct_utils:lookup_config(akm_host, Config), + Port = akm_ct_utils:lookup_config(akm_port, Config), + PartyId = <<"revoke_party">>, + + #{ + <<"ApiKey">> := #{ + <<"id">> := ApiKeyId + } + } = akm_client:issue_key(Host, Port, PartyId, #{name => <<"live-site-integration">>}), + + {500, _, _} = akm_client:request_revoke_key(Host, Port, PartyId, ApiKeyId). + +-spec revoke_key_test(config()) -> test_result(). +revoke_key_test(Config) -> + Host = akm_ct_utils:lookup_config(akm_host, Config), + Port = akm_ct_utils:lookup_config(akm_port, Config), + PartyId = <<"revoke_party">>, + + #{ + <<"ApiKey">> := #{ + <<"id">> := ApiKeyId + } + } = akm_client:issue_key(Host, Port, PartyId, #{name => <<"live-site-integration">>}), + + %% check request with unknown ApiKeyId + not_found = akm_client:request_revoke_key(Host, Port, PartyId, <<"BadID">>), + + %% check success request revoke + {204, _, _} = akm_client:request_revoke_key(Host, Port, PartyId, ApiKeyId), + + RevokePath = extract_revoke_path(), + RevokeWithBadApiKeyId = break_api_key_id(RevokePath, ApiKeyId), + RevokeWithBadRevokeToken = break_revoke_token(RevokePath), + + %% check revoke with unknown ApiKey + not_found = akm_client:revoke_key(Host, Port, RevokeWithBadApiKeyId), + + %% check revoke with unknown RevokeToken + not_found = akm_client:revoke_key(Host, Port, RevokeWithBadRevokeToken), + + %% check success revoke + {204, _, _} = akm_client:revoke_key(Host, Port, RevokePath). get_list_keys(Host, Port, PartyId, Limit, #{<<"results">> := ListKeys, <<"continuationToken">> := Cont}, Acc) -> Params = [{<<"limit">>, Limit}, {<<"continuationToken">>, Cont}], get_list_keys(Host, Port, PartyId, Limit, akm_client:list_keys(Host, Port, PartyId, Params), Acc ++ ListKeys); get_list_keys(_Host, _Port, _PartyId, _Limit, #{<<"results">> := ListKeys}, Acc) -> Acc ++ ListKeys. + +extract_revoke_path() -> + {ok, Msg} = application:get_env(akm, email_msg), + [_, Path] = binary:split(Msg, <<".dev">>), + Path. + +break_api_key_id(Path, ApiKeyId) -> + binary:replace(Path, ApiKeyId, <<"BadID">>). + +break_revoke_token(Path) -> + [Head | _] = binary:split(Path, <<"=">>), + <>. diff --git a/test/akm_client.erl b/apps/akm/test/akm_client.erl similarity index 70% rename from test/akm_client.erl rename to apps/akm/test/akm_client.erl index d3c6959..d2a2794 100644 --- a/test/akm_client.erl +++ b/apps/akm/test/akm_client.erl @@ -5,7 +5,9 @@ issue_key/4, get_key/4, list_keys/4, - list_keys/3 + list_keys/3, + request_revoke_key/4, + revoke_key/3 ]). -spec issue_key(inet:hostname() | inet:ip_address(), inet:port_number(), binary(), map()) -> any(). @@ -53,6 +55,32 @@ list_keys(Host, Port, PartyId, QsList) -> disconnect(ConnPid), parse(Answer). +-spec request_revoke_key(inet:hostname() | inet:ip_address(), inet:port_number(), binary(), binary()) -> any(). +request_revoke_key(Host, Port, PartyId, ApiKeyId) -> + Path = <<"/apikeys/v2/orgs/", PartyId/binary, "/api-keys/", ApiKeyId/binary, "/status">>, + Headers = [ + {<<"X-Request-ID">>, <<"request_revoke">>}, + {<<"content-type">>, <<"application/json; charset=utf-8">>}, + {<<"Authorization">>, <<"Bearer sffsdfsfsdfsdfs">>} + ], + Body = jsx:encode(#{<<"status">> => <<"revoked">>}), + ConnPid = connect(Host, Port), + Answer = put(ConnPid, Path, Headers, Body), + disconnect(ConnPid), + parse(Answer). + +-spec revoke_key(inet:hostname() | inet:ip_address(), inet:port_number(), binary()) -> any(). +revoke_key(Host, Port, PathWithQuery) -> + Headers = [ + {<<"X-Request-ID">>, <<"revoke_key">>}, + {<<"content-type">>, <<"application/json; charset=utf-8">>}, + {<<"Authorization">>, <<"Bearer sffsdfsfsdfsdfs">>} + ], + ConnPid = connect(Host, Port), + Answer = get(ConnPid, PathWithQuery, Headers), + disconnect(ConnPid), + parse(Answer). + % Internal functions -spec connect(inet:hostname() | inet:ip_address(), inet:port_number()) -> any(). @@ -76,6 +104,10 @@ post(ConnPid, Path, Headers, Body) -> StreamRef = gun:post(ConnPid, Path, Headers, Body), get_response(ConnPid, StreamRef). +put(ConnPid, Path, Headers, Body) -> + StreamRef = gun:put(ConnPid, Path, Headers, Body), + get_response(ConnPid, StreamRef). + get_response(ConnPid, StreamRef) -> case gun:await(ConnPid, StreamRef) of {response, fin, Status, Headers} -> @@ -85,7 +117,8 @@ get_response(ConnPid, StreamRef) -> {Status, Headers, Body} end. -maybe_query(Path, []) -> Path; +maybe_query(Path, []) -> + Path; maybe_query(Path, QsList) -> QS = uri_string:compose_query(QsList), <>. diff --git a/test/akm_ct_utils.erl b/apps/akm/test/akm_ct_utils.erl similarity index 73% rename from test/akm_ct_utils.erl rename to apps/akm/test/akm_ct_utils.erl index 8f4797d..e1d5d59 100644 --- a/test/akm_ct_utils.erl +++ b/apps/akm/test/akm_ct_utils.erl @@ -3,7 +3,8 @@ %% API -export([ lookup_config/2, - lookup_config/3 + lookup_config/3, + cleanup_db/0 ]). -spec lookup_config(_, _) -> _. @@ -19,3 +20,8 @@ lookup_config(Key, Config, Default) -> false -> Default; {_, Value} -> Value end. + +-spec cleanup_db() -> ok. +cleanup_db() -> + {ok, _, _} = epgsql_pool:query(main_pool, "TRUNCATE apikeys"), + ok. diff --git a/test/akm_cth.erl b/apps/akm/test/akm_cth.erl similarity index 83% rename from test/akm_cth.erl rename to apps/akm/test/akm_cth.erl index aa030ac..3759a7e 100644 --- a/test/akm_cth.erl +++ b/apps/akm/test/akm_cth.erl @@ -3,6 +3,14 @@ -include_lib("common_test/include/ct.hrl"). -include_lib("bouncer_proto/include/bouncer_ctx_thrift.hrl"). +-type config() :: [{atom(), term()}]. +-type test_case_name() :: atom(). +-type group_name() :: atom(). + +-export_type([config/0]). +-export_type([test_case_name/0]). +-export_type([group_name/0]). + %% API -export([ init/2, @@ -52,12 +60,18 @@ pipe(Funs, State) -> set_environment(State) -> {_, SysConfig} = lookup_key(sys_config, State), - lists:foreach(fun({Application, Config}) -> - ok = application:load(Application), - lists:foreach(fun({Param, Value}) -> - application:set_env(Application, Param, Value) - end, Config) - end, SysConfig), + lists:foreach( + fun({Application, Config}) -> + ok = application:load(Application), + lists:foreach( + fun({Param, Value}) -> + application:set_env(Application, Param, Value) + end, + Config + ) + end, + SysConfig + ), State. prepare_config(State) -> @@ -82,6 +96,14 @@ prepare_config(State) -> user_id => <<"dev.vality.user.id">>, user_email => <<"dev.vality.user.email">> } + }}, + + {mailer, #{ + url => "http://vality.dev", + from_email => "example@example.com", + relay => "smtp4dev", + username => "username", + password => "password" }} ]} ], diff --git a/compose.yaml b/compose.yaml index e3a03d8..065cc0b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -59,3 +59,28 @@ services: interval: 5s timeout: 1s retries: 20 + + smtp4dev: + image: rnwood/smtp4dev:v3 + ports: + # Change the number before : to the port the web interface should be accessible on + - '5000:80' + # Change the number before : to the port the SMTP server should be accessible on + - '25:25' + # Change the number before : to the port the IMAP server should be accessible on + - '143:143' + volumes: + # This is where smtp4dev stores the database.. + - smtp4dev-data:/smtp4dev + environment: + #Specifies the server hostname. Used in auto-generated TLS certificate if enabled. + - ServerOptions__HostName=smtp4dev + + #The username for the SMTP server used to relay messages. If "" no authentication is attempted. + - RelayOptions__Login=username + + #The password for the SMTP server used to relay messages + - RelayOptions__Password=password + +volumes: + smtp4dev-data: diff --git a/config/sys.config b/config/sys.config index 8597bea..0162874 100644 --- a/config/sys.config +++ b/config/sys.config @@ -75,6 +75,14 @@ username => "postgres", password => "postgres", database => "apikeymgmtv2" + }}, + + {mailer, #{ + url => "vality.dev", + from_email => "example@example.com", + relay => "smtp.gmail.com", + username => "username", + password => "password" }} ]}, diff --git a/elvis.config b/elvis.config index 308cebf..df20117 100644 --- a/elvis.config +++ b/elvis.config @@ -25,6 +25,11 @@ akm_apikeys_handler, akm_apikeys_processing ] + }}, + {elvis_style, invalid_dynamic_call, #{ + ignore => [ + akm_mailer + ] }} ] }, diff --git a/migrations/1686524106-create_api_keys.sql b/migrations/1686524106-create_api_keys.sql index 6d8cf34..025cce9 100644 --- a/migrations/1686524106-create_api_keys.sql +++ b/migrations/1686524106-create_api_keys.sql @@ -4,13 +4,13 @@ CREATE TYPE apikeys_status AS ENUM ('active', 'revoked'); CREATE TABLE apikeys ( - id TEXT, - name TEXT, - party_id TEXT, - status apikeys_status, - revoke_token TEXT, - metadata TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + id TEXT, + name TEXT, + party_id TEXT, + status apikeys_status, + revoke_token TEXT, + metadata TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- :down -- Down migration diff --git a/migrations/1689828848-TD-651-add-pending-status.sql b/migrations/1689828848-TD-651-add-pending-status.sql new file mode 100644 index 0000000..a255e01 --- /dev/null +++ b/migrations/1689828848-TD-651-add-pending-status.sql @@ -0,0 +1,8 @@ +-- migrations/1689828848-TD-651-add-pending-status.sql +-- :up +-- Up migration +ALTER TABLE apikeys ADD COLUMN pending_status apikeys_status; + +-- :down +-- Down migration +ALTER TABLE apikeys DROP COLUMN pending_status; \ No newline at end of file diff --git a/priv/mails/request_revoke.dtl b/priv/mails/request_revoke.dtl new file mode 100644 index 0000000..090e69a --- /dev/null +++ b/priv/mails/request_revoke.dtl @@ -0,0 +1 @@ + To revoke key, go to link: {{ url }}/apikeys/v2/orgs/{{ party_id }}/revoke-api-key/{{ api_key_id }}?apiKeyRevokeToken={{ revoke_token }} \ No newline at end of file diff --git a/rebar.config b/rebar.config index 2b87f8c..cb77a1e 100644 --- a/rebar.config +++ b/rebar.config @@ -27,6 +27,8 @@ % Common project dependencies. {deps, [ {gun, "2.0.1"}, + {gen_smtp, "1.2.0"}, + {erlydtl, "0.14.0"}, {genlib, {git, "https://github.com/valitydev/genlib.git", {branch, "master"}}}, {cowboy_draining_server, {git, "https://github.com/valitydev/cowboy_draining_server.git", {branch, "master"}}}, {uuid, {git, "https://github.com/okeuday/uuid.git", {branch, "master"}}}, @@ -109,13 +111,12 @@ {sys_config, "./config/sys.config"}, {vm_args, "./config/vm.args"}, {mode, minimal}, - {extended_start_script, true}, + {extended_start_script, true} %% api-key-mgmt-v2 - {overlay, [ - {mkdir, "var/keys/api-key-mgmt-v2"}, - {copy, "apps/api-key-mgmt-v2/var/keys/api-key-mgmt-v2/private.pem", - "var/keys/api-key-mgmt-v2/private.pem"} - ]} + %{overlay, [ + % {mkdir, "var/keys/akm"}, + % {copy, "apps/akm/var/keys/akm/private.pem", "var/keys/akm/private.pem"} + %]} ]} ]}, @@ -152,7 +153,7 @@ {erlfmt, [ {print_width, 120}, {files, [ - "apps/api-key-mgmt*/{src,include,test}/*.{hrl,erl,app.src}", + "apps/akm*/{src,include,test}/*.{hrl,erl,app.src}", "rebar.config", "elvis.config", "config/sys.config", @@ -170,3 +171,8 @@ {ct_opts, [ {ct_hooks, [akm_cth]} ]}. + +{shell, [ + {apps, [akm]}, + {config, "config/sys.config"} +]}. diff --git a/rebar.lock b/rebar.lock index c678ea6..f7cb8bd 100644 --- a/rebar.lock +++ b/rebar.lock @@ -65,6 +65,7 @@ {git,"https://github.com/valitydev/erlang-health.git", {ref,"5958e2f35cd4d09f40685762b82b82f89b4d9333"}}, 0}, + {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},0}, {<<"file_storage_proto">>, {git,"https://github.com/valitydev/file-storage-proto.git", {ref,"1dbc0067db68780660b4f691ea6ca6d5f68d56aa"}}, @@ -77,6 +78,7 @@ {git,"https://github.com/valitydev/fistful-reporter-proto.git", {ref,"69565e48f036ded9b5ecc337b4f631d0e2fa6f8d"}}, 0}, + {<<"gen_smtp">>,{pkg,<<"gen_smtp">>,<<"1.2.0">>},0}, {<<"genlib">>, {git,"https://github.com/valitydev/genlib.git", {ref,"b08ef4d61e0dde98995ec3d2f69a4447255e79ef"}}, @@ -119,7 +121,7 @@ {git,"https://github.com/okeuday/quickrand.git", {ref,"7fe89e9cfcc1378b7164e9dac4e7f02119110b68"}}, 1}, - {<<"ranch">>,{pkg,<<"ranch">>,<<"1.8.0">>},2}, + {<<"ranch">>,{pkg,<<"ranch">>,<<"2.1.0">>},1}, {<<"scoper">>, {git,"https://github.com/valitydev/scoper.git", {ref,"87110f5bd72c0e39ba9b7d6eca88fea91b8cd357"}}, @@ -131,11 +133,11 @@ {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},2}, {<<"swag_client_apikeys">>, {git,"https://github.com/valitydev/swag-api-keys-v2.git", - {ref,"fb6c31fb680897e2073a29c795b61acebabec2e7"}}, + {ref,"de86e82de67071276030186f4de806f1a7ff0431"}}, 0}, {<<"swag_server_apikeys">>, {git,"https://github.com/valitydev/swag-api-keys-v2.git", - {ref,"4ce0440be9fef7786d86317247e53c1722d8c430"}}, + {ref,"5e27ca5e3aa6f4b44b9677e870e5c8d557fee773"}}, 0}, {<<"tds_proto">>, {git,"https://github.com/valitydev/tds-proto.git", @@ -174,6 +176,8 @@ {<<"cowlib">>, <<"A9FA9A625F1D2025FE6B462CB865881329B5CAFF8F1854D1CBC9F9533F00E1E1">>}, {<<"email_validator">>, <<"7E09A862E9AA99AE2CA6FD2A718D2B94360E32940A1339B53DFEE6B774BCDB03">>}, {<<"eql">>, <<"598ABC19A1CF6AFB8EF89FFEA869F43BAEBB1CEC3260DD5065112FEE7D8CE3E2">>}, + {<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>}, + {<<"gen_smtp">>, <<"9CFC75C72A8821588B9B9FE947AE5AB2AED95A052B81237E0928633A13276FD3">>}, {<<"getopt">>, <<"33D9B44289FE7AD08627DDFE1D798E30B2DA0033B51DA1B3A2D64E72CD581D02">>}, {<<"gproc">>, <<"853CCB7805E9ADA25D227A157BA966F7B34508F386A3E7E21992B1B484230699">>}, {<<"gun">>, <<"160A9A5394800FCBA41BC7E6D421295CF9A7894C2252C0678244948E3336AD73">>}, @@ -184,7 +188,7 @@ {<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>}, {<<"parse_trans">>, <<"6E6AA8167CB44CC8F39441D05193BE6E6F4E7C2946CB2759F015F8C56B76E5FF">>}, {<<"pooler">>, <<"898CD1FA301FC42D4A8ED598CE139B71CA85B54C16AB161152B5CC5FBDCFA1A8">>}, - {<<"ranch">>, <<"8C7A100A139FD57F17327B6413E4167AC559FBC04CA7448E9BE9057311597A1D">>}, + {<<"ranch">>, <<"2261F9ED9574DCFCC444106B9F6DA155E6E540B2F82BA3D42B339B93673B72A3">>}, {<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>}, {<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]}, {pkg_hash_ext,[ @@ -194,6 +198,8 @@ {<<"cowlib">>, <<"163B73F6367A7341B33C794C4E88E7DBFE6498AC42DCD69EF44C5BC5507C8DB0">>}, {<<"email_validator">>, <<"2B1E6DF7BB14155C8D7D131F1C95CF4676200BC056EEBA82123396833FF94DA2">>}, {<<"eql">>, <<"513BE6B36EE86E8292B2B7475C257ABB66CED5AAD40CBF7AD21E233D0A3BF51D">>}, + {<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>}, + {<<"gen_smtp">>, <<"5EE0375680BCA8F20C4D85F58C2894441443A743355430FF33A783FE03296779">>}, {<<"getopt">>, <<"A0029AEA4322FB82A61F6876A6D9C66DC9878B6CB61FAA13DF3187384FD4EA26">>}, {<<"gproc">>, <<"587E8AF698CCD3504CF4BA8D90F893EDE2B0F58CABB8A916E2BF9321DE3CF10B">>}, {<<"gun">>, <<"A10BC8D6096B9502205022334F719CC9A08D9ADCFBFC0DBEE9EF31B56274A20B">>}, @@ -204,7 +210,7 @@ {<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>}, {<<"parse_trans">>, <<"620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A">>}, {<<"pooler">>, <<"058D85C5081289B90E97E4DDDBC3BB5A3B4A19A728AB3BC88C689EFCC36A07C7">>}, - {<<"ranch">>, <<"49FBCFD3682FAB1F5D109351B61257676DA1A2FDBE295904176D5E521A2DDFE5">>}, + {<<"ranch">>, <<"244EE3FA2A6175270D8E1FC59024FD9DBC76294A321057DE8F803B1479E76916">>}, {<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>}, {<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]} ].