diff --git a/.gitignore b/.gitignore index 666e599..200d4ee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,13 @@ log erl_crash.dump .tags* *.sublime-workspace +.edts .DS_Store Dockerfile docker-compose.yml /.idea/ *.beam /test/log/ + +# wapi +apps/swag_* diff --git a/.gitmodules b/.gitmodules index aff70fe..9558194 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "build-utils"] path = build-utils url = git+ssh://github.com/rbkmoney/build_utils +[submodule "schemes/swag"] + path = schemes/swag + url = git@github.com:rbkmoney/swag-wallets.git + branch = release/v0 diff --git a/Dockerfile.sh b/Dockerfile.sh index a6c983b..70f1495 100755 --- a/Dockerfile.sh +++ b/Dockerfile.sh @@ -5,6 +5,10 @@ MAINTAINER Andrey Mayorov COPY ./_build/prod/rel/${SERVICE_NAME} /opt/${SERVICE_NAME} CMD /opt/${SERVICE_NAME}/bin/${SERVICE_NAME} foreground EXPOSE 8022 + +# wapi +EXPOSE 8080 + # A bit of magic below to get a proper branch name # even when the HEAD is detached (Hey Jenkins! # BRANCH_NAME is available in Jenkins env). diff --git a/Makefile b/Makefile index 819336b..dbde7cc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ -REBAR := $(or $(shell which rebar3), $(error "`rebar3' executable missing")) +REBAR = $(or $(shell which rebar3), $(error "`rebar3' executable missing")) SUBMODULES = build-utils + +# wapi +SUBMODULES += schemes/swag SUBTARGETS = $(patsubst %,%/.git,$(SUBMODULES)) UTILS_PATH := build-utils @@ -17,9 +20,10 @@ BASE_IMAGE_NAME := service_erlang BASE_IMAGE_TAG := 16e2b3ef17e5fdefac8554ced9c2c74e5c6e9e11 # Build image tag to be used -BUILD_IMAGE_TAG := 562313697353c29d4b34fb081a8b70e8c2207134 +BUILD_IMAGE_TAG := 585ec439a97bade30cfcebc36cefdb45f13f3372 CALL_ANYWHERE := all submodules compile xref lint dialyze release clean distclean +CALL_ANYWHERE += generate regenerate CALL_W_CONTAINER := $(CALL_ANYWHERE) test @@ -36,7 +40,7 @@ $(SUBTARGETS): %/.git: % submodules: $(SUBTARGETS) -compile: submodules +compile: submodules generate $(REBAR) compile xref: submodules @@ -54,12 +58,12 @@ release: submodules clean: $(REBAR) clean -distclean: +distclean: swag_server.distclean swag_client.distclean $(REBAR) clean -a rm -rf _build test: submodules - $(REBAR) ct + $(REBAR) eunit ct TESTSUITES = $(wildcard apps/*/test/*_SUITE.erl) @@ -71,3 +75,74 @@ test.$(patsubst ff_%_SUITE.erl,%,$(notdir $(1))): $(1) submodules endef $(foreach suite,$(TESTSUITES),$(eval $(call testsuite,$(suite)))) + + +# +# wapi +# + +.PHONY: generate regenerate swag_server.generate swag_server.regenerate swag_client.generate swag_client.regenerate + +generate: swag_server.generate swag_client.generate + +regenerate: swag_server.regenerate swag_client.regenerate + +SWAGGER_CODEGEN = $(call which, swagger-codegen) +SWAGGER_SCHEME_BASE_PATH := schemes/swag +APP_PATH := apps +SWAGGER_SCHEME_API_PATH := $(SWAGGER_SCHEME_BASE_PATH)/api +SWAG_SPEC_FILE := swagger.yaml + +# Swagger server + +SWAG_SERVER_PREFIX := swag_server +SWAG_SERVER_APP_TARGET := $(APP_PATH)/$(SWAG_SERVER_PREFIX) +SWAG_SERVER_APP_PATH := $(APP_PATH)/$(SWAG_SERVER_PREFIX) + +SWAG_SERVER_APP_TARGET_WALLET := $(SWAG_SERVER_APP_PATH)_wallet/rebar.config +SWAG_SERVER_APP_TARGET_PAYRES := $(SWAG_SERVER_APP_PATH)_payres/rebar.config +SWAG_SERVER_APP_TARGET_PRIVDOC := $(SWAG_SERVER_APP_PATH)_privdoc/rebar.config + +$(SWAG_SERVER_APP_PATH)_%/rebar.config: $(SWAGGER_SCHEME_BASE_PATH)/.git + $(SWAGGER_CODEGEN) generate \ + -i $(SWAGGER_SCHEME_API_PATH)/$*/$(SWAG_SPEC_FILE) \ + -l erlang-server \ + -o $(SWAG_SERVER_APP_PATH)_$* \ + --additional-properties \ + packageName=$(SWAG_SERVER_PREFIX)_$* + +swag_server.generate: $(SWAG_SERVER_APP_TARGET_WALLET) $(SWAG_SERVER_APP_TARGET_PAYRES) $(SWAG_SERVER_APP_TARGET_PRIVDOC) + +swag_server.distclean: swag_server.distclean_wallet swag_server.distclean_payres swag_server.distclean_privdoc + +swag_server.distclean_%: + rm -rf $(SWAG_SERVER_APP_PATH)_$* + +swag_server.regenerate: swag_server.distclean swag_server.generate + +# Swagger client + +SWAG_CLIENT_PREFIX := swag_client +SWAG_CLIENT_APP_TARGET := $(APP_PATH)/$(SWAG_CLIENT_PREFIX) +SWAG_CLIENT_APP_PATH := $(APP_PATH)/$(SWAG_CLIENT_PREFIX) + +SWAG_CLIENT_APP_TARGET_WALLET := $(SWAG_CLIENT_APP_PATH)_wallet/rebar.config +SWAG_CLIENT_APP_TARGET_PAYRES := $(SWAG_CLIENT_APP_PATH)_payres/rebar.config +SWAG_CLIENT_APP_TARGET_PRIVDOC := $(SWAG_CLIENT_APP_PATH)_privdoc/rebar.config + +$(SWAG_CLIENT_APP_PATH)_%/rebar.config: $(SWAGGER_SCHEME_BASE_PATH)/.git + $(SWAGGER_CODEGEN) generate \ + -i $(SWAGGER_SCHEME_API_PATH)/$*/$(SWAG_SPEC_FILE) \ + -l erlang-client \ + -o $(SWAG_CLIENT_APP_PATH)_$* \ + --additional-properties \ + packageName=$(SWAG_CLIENT_PREFIX)_$* + +swag_client.generate: $(SWAG_CLIENT_APP_TARGET_WALLET) $(SWAG_CLIENT_APP_TARGET_PAYRES) $(SWAG_CLIENT_APP_TARGET_PRIVDOC) + +swag_client.distclean: swag_client.distclean_wallet swag_client.distclean_payres swag_client.distclean_privdoc + +swag_client.distclean_%: + rm -rf $(SWAG_CLIENT_APP_PATH)_$* + +swag_client.regenerate: swag_client.distclean swag_client.generate diff --git a/apps/ff_server/src/ff_server.app.src b/apps/ff_server/src/ff_server.app.src index 9f61d24..dd5a42d 100644 --- a/apps/ff_server/src/ff_server.app.src +++ b/apps/ff_server/src/ff_server.app.src @@ -10,7 +10,8 @@ stdlib, woody, fistful, - ff_withdraw + ff_withdraw, + wapi ]}, {env, []}, {modules, []}, diff --git a/apps/ff_withdraw/src/ff_withdrawal_machine.erl b/apps/ff_withdraw/src/ff_withdrawal_machine.erl index 57ddb77..de0dd14 100644 --- a/apps/ff_withdraw/src/ff_withdrawal_machine.erl +++ b/apps/ff_withdraw/src/ff_withdrawal_machine.erl @@ -98,12 +98,12 @@ get(ID) -> -type event_cursor() :: non_neg_integer() | undefined. -spec get_status_events(id(), event_cursor()) -> - {ok, [ev()]} | - {error, notfound} . + {ok, [{integer(), machinery:timestamp(), ev()}]} | + {error, notfound} . get_status_events(ID, Cursor) -> do(fun () -> - unwrap(machinery:get(?NS, ID, {Cursor, undefined, forward}, backend())) + maps:get(history, unwrap(machinery:get(?NS, ID, {Cursor, undefined, forward}, backend()))) end). backend() -> diff --git a/apps/fistful/src/ff_ctx.erl b/apps/fistful/src/ff_ctx.erl index 461e86b..00309a1 100644 --- a/apps/fistful/src/ff_ctx.erl +++ b/apps/fistful/src/ff_ctx.erl @@ -20,6 +20,7 @@ -export_type([ctx/0]). -export([new/0]). +-export([get/2]). %% @@ -28,3 +29,10 @@ new() -> #{}. + +-spec get(namespace(), ctx()) -> + {ok, md()} | + {error, notfound}. + +get(Ns, Ctx) -> + ff_map:find(Ns, Ctx). diff --git a/apps/fistful/src/ff_identity.erl b/apps/fistful/src/ff_identity.erl index addb5cd..9e8321f 100644 --- a/apps/fistful/src/ff_identity.erl +++ b/apps/fistful/src/ff_identity.erl @@ -55,6 +55,7 @@ -export([provider/1]). -export([party/1]). -export([class/1]). +-export([level/1]). -export([contract/1]). -export([challenge/2]). -export([effective_challenge/1]). diff --git a/apps/fistful/src/ff_identity_machine.erl b/apps/fistful/src/ff_identity_machine.erl index 9ea2db2..f122c76 100644 --- a/apps/fistful/src/ff_identity_machine.erl +++ b/apps/fistful/src/ff_identity_machine.erl @@ -102,7 +102,7 @@ get(ID) -> -type challenge_params() :: #{ id := challenge_id(), class := ff_identity_class:challenge_class_id(), - proofs := [ff_identity:proof()] + proofs := [ff_identity_challenge:proof()] }. -spec start_challenge(id(), challenge_params()) -> diff --git a/apps/wapi/elvis.config b/apps/wapi/elvis.config new file mode 100644 index 0000000..761a327 --- /dev/null +++ b/apps/wapi/elvis.config @@ -0,0 +1,77 @@ +[ + {elvis, [ + {config, [ + #{ + dirs => [ + "apps/*/src", + "apps/*/test" + ], + filter => "*.erl", + ignore => ["_thrift.erl$", "src/swag_server*", "src/swag_client*", "_SUITE.erl$"], + rules => [ + {elvis_style, line_length, #{limit => 120, skip_comments => false}}, + {elvis_style, no_tabs}, + {elvis_style, no_trailing_whitespace}, + {elvis_style, macro_module_names}, + {elvis_style, operator_spaces, #{rules => [{right, ","}, {right, "++"}, {left, "++"}]}}, + {elvis_style, nesting_level, #{level => 4}}, + {elvis_style, god_modules, #{limit => 25}}, + {elvis_style, no_if_expression}, + {elvis_style, invalid_dynamic_call, #{ignore => [wapi_swagger_server]}}, + {elvis_style, used_ignored_variable}, + {elvis_style, no_behavior_info}, + {elvis_style, module_naming_convention, #{regex => "^[a-z]([a-z0-9]*_?)*(_SUITE)?$"}}, + {elvis_style, function_naming_convention, #{regex => "^[a-z]([a-z0-9]*_?)*$"}}, + {elvis_style, state_record_and_type}, + {elvis_style, no_spec_with_records}, + {elvis_style, dont_repeat_yourself, #{ + min_complexity => 30, + ignore => [ + wapi_tests_SUITE + ] + }}, + {elvis_style, no_debug_call, #{}} + ] + }, + #{ + dirs => ["."], + filter => "Makefile", + ruleset => makefiles + }, + #{ + dirs => ["."], + filter => "elvis.config", + ruleset => elvis_config + }, + #{ + dirs => ["apps", "apps/*"], + filter => "rebar.config", + ignore => ["swag_server/*", "swag_client/*"], + rules => [ + {elvis_style, line_length, #{limit => 120, skip_comments => false}}, + {elvis_style, no_tabs}, + {elvis_style, no_trailing_whitespace} + ] + }, + #{ + dirs => ["."], + filter => "rebar.config", + rules => [ + {elvis_style, line_length, #{limit => 120, skip_comments => false}}, + {elvis_style, no_tabs}, + {elvis_style, no_trailing_whitespace} + ] + }, + #{ + dirs => ["apps/*/src"], + filter => "*.app.src", + ignore => ["src/swag_server*", "src/swag_client*"], + rules => [ + {elvis_style, line_length, #{limit => 120, skip_comments => false}}, + {elvis_style, no_tabs}, + {elvis_style, no_trailing_whitespace} + ] + } + ]} + ]} +]. diff --git a/apps/wapi/rebar.config b/apps/wapi/rebar.config new file mode 100644 index 0000000..040399f --- /dev/null +++ b/apps/wapi/rebar.config @@ -0,0 +1,129 @@ +%% Common project erlang options. +{erl_opts, [ + +%% % mandatory +%% debug_info, +%% warnings_as_errors, +%% warn_export_all, +%% warn_missing_spec, +%% warn_untyped_record, +%% warn_export_vars, + +%% % by default +%% warn_unused_record, +%% warn_bif_clash, +%% warn_obsolete_guard, +%% warn_unused_vars, +%% warn_shadow_vars, +%% warn_unused_import, +%% warn_unused_function, +%% warn_deprecated_function, + +%% % at will +%% % bin_opt_info +%% % no_auto_import +%% % warn_missing_spec_all + {parse_transform, lager_transform} +]}. + +%% Common project dependencies. +{deps, [ + {cowboy, "1.0.4"}, + %% {rfc3339, "0.2.2"}, + {jose, "1.7.9"}, + %% {lager, "3.6.1"}, + {base64url, "0.0.1"}, + %% {genlib, + %% {git, "https://github.com/rbkmoney/genlib.git", {branch, "master"}} + %% }, + %% {woody, + %% {git, "git@github.com:rbkmoney/woody_erlang.git", {branch, "master"}} + %% }, + %% {woody_user_identity, + %% {git, "git@github.com:rbkmoney/woody_erlang_user_identity.git", {branch, "master"}} + %% }, + %% {dmsl, + %% {git, "git@github.com:rbkmoney/damsel.git", {branch, "release/erlang/master"}} + %% }, + %% {lager_logstash_formatter, + %% {git, "git@github.com:rbkmoney/lager_logstash_formatter.git", {branch, "master"}} + %% }, + {cowboy_cors, + {git, "https://github.com/danielwhite/cowboy_cors.git", {branch, "master"}} + }, + {cowboy_access_log, + {git, "git@github.com:rbkmoney/cowboy_access_log.git", {branch, "master"}} + }, + {payproc_errors, + {git, "git@github.com:rbkmoney/payproc-errors-erlang.git", {branch, "master"}} + }, + {erl_health, + {git, "https://github.com/rbkmoney/erlang-health.git", {branch, master}} + } +]}. + +%% XRef checks +%% {xref_checks, [ +%% undefined_function_calls, +%% undefined_functions, +%% deprecated_functions_calls, +%% deprecated_functions +%% ]}. +% at will +% {xref_warnings, true}. + +%% Tests +%% {cover_enabled, true}. + +%% Relx configuration +%% {relx, [ +%% {release, { capi , "0.1.0"}, [ +%% {recon , load }, % tools for introspection +%% {runtime_tools, load }, % debugger +%% {tools , load }, % profiler +%% capi, +%% sasl +%% ]}, +%% {sys_config, "./config/sys.config"}, +%% {vm_args, "./config/vm.args"}, +%% {dev_mode, true}, +%% {include_erts, false}, +%% {extended_start_script, true} +%% ]}. + +%% Dialyzer static analyzing +%% {dialyzer, [ +%% {warnings, [ +%% % mandatory +%% unmatched_returns, +%% error_handling, +%% race_conditions, +%% unknown +%% ]}, +%% {plt_apps, all_deps} +%% ]}. + +%% {profiles, [ +%% {prod, [ +%% {deps, [ +%% % for introspection on production +%% {recon, "2.3.2"} +%% ]}, +%% {relx, [ +%% {dev_mode, false}, +%% {include_erts, true}, +%% {overlay, [ +%% {mkdir , "var/keys/capi" }, +%% {copy , "var/keys/capi/private.pem" , "var/keys/capi/private.pem" } +%% ]} +%% ]} +%% ]}, +%% {test, [ +%% {cover_enabled, true}, +%% {deps, []} +%% ]} +%% ]}. + +%% {pre_hooks, [ +%% {thrift, "git submodule update --init"} +%% ]}. diff --git a/apps/wapi/src/wapi.app.src b/apps/wapi/src/wapi.app.src new file mode 100644 index 0000000..e9c00b2 --- /dev/null +++ b/apps/wapi/src/wapi.app.src @@ -0,0 +1,31 @@ +{application, wapi , [ + {description, "Wallet API service adapter"}, + {vsn, "0.1.0"}, + {registered, []}, + {mod, {wapi , []}}, + {applications, [ + kernel, + stdlib, + public_key, + lager, + %% lager_logstash_formatter, + genlib, + woody, + scoper, + dmsl, + swag_server_wallet, + swag_server_payres, + swag_server_privdoc, + jose, + cowboy_cors, + cowboy_access_log, + rfc3339, + base64url, + snowflake, + woody_user_identity, + payproc_errors, + erl_health, + identdocstore_proto + ]}, + {env, []} +]}. diff --git a/apps/wapi/src/wapi.erl b/apps/wapi/src/wapi.erl new file mode 100644 index 0000000..6b8cc8d --- /dev/null +++ b/apps/wapi/src/wapi.erl @@ -0,0 +1,21 @@ +%% @doc Public API and application startup. +%% @end + +-module(wapi). +-behaviour(application). + +%% Application callbacks +-export([start/2]). +-export([stop/1]). + +%% + +-spec start(normal, any()) -> {ok, pid()} | {error, any()}. + +start(_StartType, _StartArgs) -> + wapi_sup:start_link(). + +-spec stop(any()) -> ok. + +stop(_State) -> + ok. diff --git a/apps/wapi/src/wapi_acl.erl b/apps/wapi/src/wapi_acl.erl new file mode 100644 index 0000000..f40596c --- /dev/null +++ b/apps/wapi/src/wapi_acl.erl @@ -0,0 +1,241 @@ +-module(wapi_acl). + +%% + +-opaque t() :: [{{priority(), scope()}, [permission()]}]. + +-type priority() :: integer(). +-type scope() :: [resource() | {resource(), resource_id()}, ...]. +-type resource() :: atom(). +-type resource_id() :: binary(). +-type permission() :: read | write. + +-export_type([t/0]). +-export_type([scope/0]). +-export_type([permission/0]). + +-export([new/0]). +-export([to_list/1]). +-export([from_list/1]). +-export([insert_scope/3]). +-export([remove_scope/3]). + +-export([match/2]). + +-export([decode/1]). +-export([encode/1]). + +%% + +-spec new() -> + t(). + +new() -> + []. + +-spec to_list(t()) -> + [{scope(), permission()}]. + +to_list(ACL) -> + [{S, P} || {{_, S}, P} <- ACL]. + +-spec from_list([{scope(), permission()}]) -> + t(). + +from_list(L) -> + lists:foldl(fun ({S, P}, ACL) -> insert_scope(S, P, ACL) end, new(), L). + +-spec insert_scope(scope(), permission(), t()) -> + t(). + +insert_scope(Scope, Permission, ACL) -> + Priority = compute_priority(Scope, Permission), + insert({{Priority, Scope}, [Permission]}, ACL). + +insert({PS, _} = V, [{PS0, _} = V0 | Vs]) when PS < PS0 -> + [V0 | insert(V, Vs)]; +insert({PS, Perms}, [{PS, Perms0} | Vs]) -> + % NOTE squashing permissions of entries with the same scope + [{PS, lists:usort(Perms ++ Perms0)} | Vs]; +insert({PS, _} = V, [{PS0, _} | _] = Vs) when PS > PS0 -> + [V | Vs]; +insert(V, []) -> + [V]. + +-spec remove_scope(scope(), permission(), t()) -> + t(). + +remove_scope(Scope, Permission, ACL) -> + Priority = compute_priority(Scope, Permission), + remove({{Priority, Scope}, [Permission]}, ACL). + +remove(V, [V | Vs]) -> + Vs; +remove({PS, Perms}, [{PS, Perms0} | Vs]) -> + [{PS, Perms0 -- Perms} | Vs]; +remove(V, [V0 | Vs]) -> + [V0 | remove(V, Vs)]; +remove(_, []) -> + []. + +compute_priority(Scope, Permission) -> + % NOTE + % Scope priority depends on the following attributes, in the order of decreasing + % importance: + % 1. Depth, deeper is more important + % 2. Scope element specificity, element marked with an ID is more important + compute_scope_priority(Scope) + compute_permission_priority(Permission). + +compute_scope_priority(Scope) when length(Scope) > 0 -> + compute_scope_priority(Scope, get_resource_hierarchy(), 0); +compute_scope_priority(Scope) -> + error({badarg, {scope, Scope}}). + +compute_scope_priority([{Resource, _ID} | Rest], H, P) -> + compute_scope_priority(Rest, delve(Resource, H), P * 10 + 2); +compute_scope_priority([Resource | Rest], H, P) -> + compute_scope_priority(Rest, delve(Resource, H), P * 10 + 1); +compute_scope_priority([], _, P) -> + P * 10. + +compute_permission_priority(read) -> + 0; +compute_permission_priority(write) -> + 0; +compute_permission_priority(V) -> + error({badarg, {permission, V}}). + +%% + +-spec match(scope(), t()) -> + [permission()]. + +match(Scope, ACL) when length(Scope) > 0 -> + match_rules(Scope, ACL); +match(Scope, _) -> + error({badarg, {scope, Scope}}). + +match_rules(Scope, [{{_Priority, ScopePrefix}, Permissions} | Rest]) -> + % NOTE + % The `Scope` matches iff `ScopePrefix` is scope prefix of the `Scope`. + % An element of a scope matches corresponding element of a scope prefix + % according to the following rules: + % 1. Scope prefix element marked with resource and ID matches exactly the same + % scope element. + % 2. Scope prefix element marked with only resource matches any scope element + % marked with the same resource. + case match_scope(Scope, ScopePrefix) of + true -> + Permissions; + false -> + match_rules(Scope, Rest) + end; +match_rules(_Scope, []) -> + []. + +match_scope([V | Ss], [V | Ss0]) -> + match_scope(Ss, Ss0); +match_scope([{V, _ID} | Ss], [V | Ss0]) -> + match_scope(Ss, Ss0); +match_scope(_, []) -> + true; +match_scope(_, _) -> + false. + +%% + +-spec decode([binary()]) -> + t(). + +decode(V) -> + lists:foldl(fun decode_entry/2, new(), V). + +decode_entry(V, ACL) -> + case binary:split(V, <<":">>, [global]) of + [V1, V2] -> + Scope = decode_scope(V1), + Permission = decode_permission(V2), + insert_scope(Scope, Permission, ACL); + _ -> + error({badarg, {role, V}}) + end. + +decode_scope(V) -> + Hierarchy = get_resource_hierarchy(), + decode_scope_frags(binary:split(V, <<".">>, [global]), Hierarchy). + +decode_scope_frags([V1, V2 | Vs], H) -> + {Resource, H1} = decode_scope_frag_resource(V1, V2, H), + [Resource | decode_scope_frags(Vs, H1)]; +decode_scope_frags([V], H) -> + decode_scope_frags([V, <<"*">>], H); +decode_scope_frags([], _) -> + []. + +decode_scope_frag_resource(V, <<"*">>, H) -> + R = decode_resource(V), + {R, delve(R, H)}; +decode_scope_frag_resource(V, ID, H) -> + R = decode_resource(V), + {{R, ID}, delve(R, H)}. + +decode_resource(V) -> + try binary_to_existing_atom(V, utf8) catch + error:badarg -> + error({badarg, {resource, V}}) + end. + +decode_permission(<<"read">>) -> + read; +decode_permission(<<"write">>) -> + write; +decode_permission(V) -> + error({badarg, {permission, V}}). + +%% + +-spec encode(t()) -> + [binary()]. + +encode(ACL) -> + lists:flatmap(fun encode_entry/1, ACL). + +encode_entry({{_Priority, Scope}, Permissions}) -> + S = encode_scope(Scope), + [begin P = encode_permission(Permission), <> end + || Permission <- Permissions]. + +encode_scope(Scope) -> + Hierarchy = get_resource_hierarchy(), + genlib_string:join($., encode_scope_frags(Scope, Hierarchy)). + +encode_scope_frags([{Resource, ID} | Rest], H) -> + [encode_resource(Resource), ID | encode_scope_frags(Rest, delve(Resource, H))]; +encode_scope_frags([Resource], H) -> + _ = delve(Resource, H), + [encode_resource(Resource)]; +encode_scope_frags([Resource | Rest], H) -> + [encode_resource(Resource), <<"*">> | encode_scope_frags(Rest, delve(Resource, H))]; +encode_scope_frags([], _) -> + []. + +encode_resource(V) -> + atom_to_binary(V, utf8). + +encode_permission(read) -> + <<"read">>; +encode_permission(write) -> + <<"write">>. + +%% + +get_resource_hierarchy() -> + wapi_auth:get_resource_hierarchy(). + +delve(Resource, Hierarchy) -> + case maps:find(Resource, Hierarchy) of + {ok, Sub} -> + Sub; + error -> + error({badarg, {resource, Resource}}) + end. diff --git a/apps/wapi/src/wapi_auth.erl b/apps/wapi/src/wapi_auth.erl new file mode 100644 index 0000000..c236d11 --- /dev/null +++ b/apps/wapi/src/wapi_auth.erl @@ -0,0 +1,203 @@ +-module(wapi_auth). + +-export([authorize_api_key/3]). +-export([authorize_operation/3]). +-export([issue_access_token/2]). +-export([issue_access_token/3]). + +-export([get_subject_id/1]). +-export([get_claims/1]). +-export([get_claim/2]). +-export([get_claim/3]). +-export([get_consumer/1]). + +-export([get_resource_hierarchy/0]). + +-type context () :: wapi_authorizer_jwt:t(). +-type claims () :: wapi_authorizer_jwt:claims(). +-type consumer() :: client | merchant | provider. + +-export_type([context /0]). +-export_type([claims /0]). +-export_type([consumer/0]). + +-type operation_id() :: wapi_handler:operation_id(). + +-type api_key() :: + swag_wallet_server:api_key() | + swag_payres_server:api_key() | + swag_privdoc_server:api_key(). + +-type handler_opts() :: wapi_handler:handler_opts(). + +-spec authorize_api_key(operation_id(), api_key(), handler_opts()) -> + {true, context()}. %% | false. + +authorize_api_key(_OperationID, _ApiKey, _Opts) -> + %% case parse_api_key(ApiKey) of + %% {ok, {Type, Credentials}} -> + %% case do_authorize_api_key(OperationID, Type, Credentials) of + %% {ok, Context} -> + %% {true, Context}; + %% {error, Error} -> + %% _ = log_auth_error(OperationID, Error), + %% false + %% end; + %% {error, Error} -> + %% _ = log_auth_error(OperationID, Error), + %% false + %% end, + Subject = {<<"notimplemented">>, wapi_acl:new()}, + Claims = #{}, + {true, {Subject, Claims}}. + +%% log_auth_error(OperationID, Error) -> +%% lager:info("API Key authorization failed for ~p due to ~p", [OperationID, Error]). + +%% -spec parse_api_key(ApiKey :: 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} +%% end. + +%% -spec do_authorize_api_key( +%% OperationID :: operation_id(), +%% Type :: atom(), +%% Credentials :: binary() +%% ) -> +%% {ok, Context :: context()} | {error, Reason :: atom()}. + +%% do_authorize_api_key(_OperationID, bearer, Token) -> +%% % NOTE +%% % We are knowingly delegating actual request authorization to the logic handler +%% % so we could gather more data to perform fine-grained access control. +%% wapi_authorizer_jwt:verify(Token). + +%% + +% TODO +% We need shared type here, exported somewhere in swagger app +-type request_data() :: #{atom() | binary() => term()}. + +-spec authorize_operation( + OperationID :: operation_id(), + Req :: request_data(), + Auth :: wapi_authorizer_jwt:t() +) -> + ok | {error, unauthorized}. + +%% TODO +authorize_operation(_OperationID, _Req, _) -> + ok. +%% authorize_operation(OperationID, Req, {{_SubjectID, ACL}, _}) -> + %% Access = get_operation_access(OperationID, Req), + %% _ = case lists:all( + %% fun ({Scope, Permission}) -> + %% lists:member(Permission, wapi_acl:match(Scope, ACL)) + %% end, + %% Access + %% ) of + %% true -> + %% ok; + %% false -> + %% {error, unauthorized} + %% end. + +%% + +-type token_spec() :: + {destinations, DestinationID :: binary()}. + +-spec issue_access_token(wapi_handler_utils:party_id(), token_spec()) -> + wapi_authorizer_jwt:token(). +issue_access_token(PartyID, TokenSpec) -> + issue_access_token(PartyID, TokenSpec, unlimited). + +-type expiration() :: + {deadline, machinery:timestamp() | pos_integer()} | + {lifetime, Seconds :: pos_integer()} | + unlimited . + +-spec issue_access_token(wapi_handler_utils:party_id(), token_spec(), expiration()) -> + wapi_authorizer_jwt:token(). +issue_access_token(PartyID, TokenSpec, Expiration0) -> + Expiration = get_expiration(Expiration0), + {Claims, ACL} = resolve_token_spec(TokenSpec), + wapi_utils:unwrap(wapi_authorizer_jwt:issue({{PartyID, wapi_acl:from_list(ACL)}, Claims}, Expiration)). + +-spec get_expiration(expiration()) -> + wapi_authorizer_jwt:expiration(). +get_expiration(Exp = unlimited) -> + Exp; +get_expiration({deadline, {DateTime, Usec}}) -> + {deadline, genlib_time:to_unixtime(DateTime) + Usec div 1000000}; +get_expiration(Exp = {deadline, _Sec}) -> + Exp; +get_expiration(Exp = {lifetime, _Sec}) -> + Exp. + +-type acl() :: [{wapi_acl:scope(), wapi_acl:permission()}]. + +-spec resolve_token_spec(token_spec()) -> + {claims(), acl()}. +resolve_token_spec({destinations, DestinationId}) -> + Claims = #{}, + ACL = [ + {[party, {destinations, DestinationId}], read}, + {[party, {destinations, DestinationId}], write} + ], + {Claims, ACL}. + +-spec get_subject_id(context()) -> binary(). + +get_subject_id({{SubjectID, _ACL}, _}) -> + SubjectID. + +-spec get_claims(context()) -> claims(). + +get_claims({_Subject, Claims}) -> + Claims. + +-spec get_claim(binary(), context()) -> term(). + +get_claim(ClaimName, {_Subject, Claims}) -> + maps:get(ClaimName, Claims). + +-spec get_claim(binary(), context(), term()) -> term(). + +get_claim(ClaimName, {_Subject, Claims}, Default) -> + maps:get(ClaimName, Claims, Default). + +%% + +%% TODO update for the wallet swag +%% -spec get_operation_access(operation_id(), request_data()) -> +%% [{wapi_acl:scope(), wapi_acl:permission()}]. + +%% get_operation_access('StoreBankCard' , _) -> +%% [{[payment_resources], write}]. + +-spec get_resource_hierarchy() -> #{atom() => map()}. + +%% TODO add some sence in here +get_resource_hierarchy() -> + #{ + party => #{ + wallets => #{}, + destinations => #{} + } + }. + +-spec get_consumer(claims()) -> + consumer(). +get_consumer(Claims) -> + case maps:get(<<"cons">>, Claims, <<"merchant">>) of + <<"merchant">> -> merchant; + <<"client" >> -> client; + <<"provider">> -> provider + end. diff --git a/apps/wapi/src/wapi_authorizer_jwt.erl b/apps/wapi/src/wapi_authorizer_jwt.erl new file mode 100644 index 0000000..eba70cd --- /dev/null +++ b/apps/wapi/src/wapi_authorizer_jwt.erl @@ -0,0 +1,397 @@ +-module(wapi_authorizer_jwt). + +%% + +-export([get_child_spec/1]). +-export([init/1]). + +-export([store_key/2]). +% TODO +% Extend interface to support proper keystore manipulation + +-export([issue/2]). +-export([verify/1]). + +%% + +-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()}. +-type subject() :: {subject_id(), wapi_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} -> + _ = lager: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} -> + _ = lager:error("Error setting signee: signing with ~p is not allowed", [Keyname]), + exit({invalid_signee, Keyname, KeyInfo}); + error -> + _ = lager: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), + base64url:encode(crypto:hash(sha256, Data)). + +-type store_opts() :: #{ + kid => fun ((key()) -> kid()) +}. + +-spec store_key(keyname(), {pem_file, file:filename()}, store_opts()) -> + ok | {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#{ + <<"jti">> => unique_id(), + <<"sub">> => Subject, + <<"exp">> => get_expires_at(Expiration) + }, + encode_roles(wapi_acl:encode(ACL)) + ). + +get_expires_at({lifetime, Lt}) -> + genlib_time:unow() + Lt; +get_expires_at({deadline, Dl}) -> + Dl; +get_expires_at(unlimited) -> + 0. + +unique_id() -> + <> = snowflake:new(), + genlib_format:format_int_base(ID, 62). + +sign(#{kid := KID, jwk := JWK, signer := #{} = JWS}, Claims) -> + JWT = jose_jwt:sign(JWK, JWS#{<<"kid">> => KID}, Claims), + {_Modules, Token} = jose_jws:compact(JWT), + {ok, Token}. + +%% + +-spec verify(token()) -> + {ok, t()} | + {error, + {invalid_token, + badarg | + {badarg, term()} | + {missing, atom()} | + expired | + {malformed_acl, term()} + } | + {nonexistent_key, kid()} | + invalid_operation | + invalid_signature + }. + +verify(Token) -> + try + {_, ExpandedToken} = jose_jws:expand(Token), + #{<<"protected">> := ProtectedHeader} = ExpandedToken, + Header = wapi_utils: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}}; + error: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} -> + {#{subject_id := SubjectID}, Claims1} = validate_claims(Claims), + get_result(SubjectID, 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(maps:without([Claim], Claims), Rest, Acc#{Name => V}); +validate_claims(Claims, [], Acc) -> + {Acc, Claims}. + +get_result(SubjectID, {Roles, Claims}) -> + try + Subject = {SubjectID, wapi_acl:decode(Roles)}, + {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 , <<"jti">> , fun check_presence/2}, + {subject_id , <<"sub">> , fun check_presence/2}, + {expires_at , <<"exp">> , 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; + _ -> + 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">> => #{ + <<"wallet-api">> => #{ + <<"roles">> => Roles + } + } + }. + +decode_roles(Claims = #{ + <<"resource_access">> := #{ + <<"wallet-api">> := #{ + <<"roles">> := Roles + } + } +}) when is_list(Roles) -> + {Roles, maps:remove(<<"resource_access">>, Claims)}; +decode_roles(_) -> + throw({invalid_token, {missing, acl}}). + +%% + +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. + +%% + +-define(TABLE, ?MODULE). + +create_table() -> + _ = ets:new(?TABLE, [set, public, named_table, {read_concurrency, true}]), + ok. + +insert_values(Values) -> + true = ets:insert(?TABLE, maps:to_list(Values)), + ok. + +lookup_value(Key) -> + case ets:lookup(?TABLE, Key) of + [{Key, Value}] -> + Value; + [] -> + undefined + end. diff --git a/apps/wapi/src/wapi_cors_policy.erl b/apps/wapi/src/wapi_cors_policy.erl new file mode 100644 index 0000000..3c6f0c2 --- /dev/null +++ b/apps/wapi/src/wapi_cors_policy.erl @@ -0,0 +1,35 @@ +-module(wapi_cors_policy). +-behaviour(cowboy_cors_policy). + +-export([policy_init/1]). +-export([allowed_origins/2]). +-export([allowed_headers/2]). +-export([allowed_methods/2]). + +-spec policy_init(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. + +policy_init(Req) -> + {ok, Req, undefined_state}. + +-spec allowed_origins(cowboy_req:req(), any()) -> {'*', cowboy_req:req(), any()}. + +allowed_origins(Req, State) -> + {'*', Req, State}. + +-spec allowed_headers(cowboy_req:req(), any()) -> {[binary()], cowboy_req:req(), any()}. + +allowed_headers(Req, State) -> + {[ + <<"access-control-allow-headers">>, + <<"origin">>, + <<"x-requested-with">>, + <<"content-type">>, + <<"accept">>, + <<"authorization">>, + <<"x-request-id">> + ], Req, State}. + +-spec allowed_methods(cowboy_req:req(), any()) -> {[binary()], cowboy_req:req(), any()}. + +allowed_methods(Req, State) -> + {[<<"GET">>, <<"POST">>, <<"PUT">>, <<"DELETE">>, <<"OPTIONS">>], Req, State}. diff --git a/apps/wapi/src/wapi_handler.erl b/apps/wapi/src/wapi_handler.erl new file mode 100644 index 0000000..ccc1465 --- /dev/null +++ b/apps/wapi/src/wapi_handler.erl @@ -0,0 +1,108 @@ +-module(wapi_handler). + +%% API +-export([handle_request/5]). +-export([throw_result/1]). + +%% Behaviour definition + +-type operation_id() :: + swag_server_payres:operation_id() | + swag_server_wallet:operation_id() | + swag_server_privdoc:operation_id(). + +-type swagger_context() :: + swag_server_payres:request_context() | + swag_server_wallet:request_context() | + swag_server_privdoc:request_context(). + +-type handler_context() :: #{ + woody_context := woody_context:ctx(), + swagger_context := swagger_context() +}. + +-type handler_opts() :: + swag_server_wallet:handler_opts() | + swag_server_payres:handler_opts() | + swag_server_privdoc:handler_opts(). + +-type req_data() :: #{atom() | binary() => term()}. +-type status_code() :: 200..599. +-type headers() :: cowboy:http_headers(). +-type response_data() :: map() | [map()] | undefined. +-type request_result() :: {ok | error, {status_code(), headers(), response_data()}}. + +-callback process_request(operation_id(), req_data(), handler_context(), handler_opts()) -> + request_result() | no_return(). + +-export_type([operation_id/0]). +-export_type([swagger_context/0]). +-export_type([handler_context/0]). +-export_type([handler_opts/0]). +-export_type([req_data/0]). +-export_type([status_code/0]). +-export_type([response_data/0]). +-export_type([headers/0]). +-export_type([request_result/0]). + +%% API + +-define(request_result, wapi_req_result). + +-spec handle_request(operation_id(), req_data(), swagger_context(), module(), handler_opts()) -> + request_result(). +handle_request(OperationID, Req, SwagContext = #{auth_context := AuthContext}, Handler, Opts) -> + _ = lager:info("Processing request ~p", [OperationID]), + try + case wapi_auth:authorize_operation(OperationID, Req, AuthContext) of + ok -> + WoodyContext = create_woody_context(Req, AuthContext, Opts), + Context = create_handler_context(SwagContext, WoodyContext), + Handler:process_request(OperationID, Req, Context, Opts) + %% ToDo: return back as soon, as authorization is implemented + %% {error, _} = Error -> + %% _ = lager:info("Operation ~p authorization failed due to ~p", [OperationID, Error]), + %% wapi_handler_utils:reply_error(401, wapi_handler_utils:get_error_msg(<<"Unauthorized operation">>)) + end + catch + throw:{?request_result, Result} -> + Result; + error:{woody_error, {Source, Class, Details}} -> + process_woody_error(Source, Class, Details) + end. + +-spec throw_result(request_result()) -> + no_return(). +throw_result(Res) -> + erlang:throw({?request_result, Res}). + +-spec create_woody_context(req_data(), wapi_auth:context(), handler_opts()) -> + woody_context:ctx(). +create_woody_context(#{'X-Request-ID' := RequestID}, AuthContext, Opts) -> + RpcID = #{trace_id := TraceID} = woody_context:new_rpc_id(genlib:to_binary(RequestID)), + ok = scoper:add_meta(#{request_id => RequestID, trace_id => TraceID}), + _ = lager:debug("Created TraceID for the request"), + woody_user_identity:put(collect_user_identity(AuthContext, Opts), woody_context:new(RpcID)). + +-define(APP, wapi). + +collect_user_identity(AuthContext, _Opts) -> + genlib_map:compact(#{ + id => wapi_auth:get_subject_id(AuthContext), + %% TODO pass realm via Opts + realm => genlib_app:env(?APP, realm), + email => wapi_auth:get_claim(<<"email">>, AuthContext, undefined), + username => wapi_auth:get_claim(<<"name">> , AuthContext, undefined) + }). + +-spec create_handler_context(swagger_context(), woody_context:ctx()) -> + handler_context(). +create_handler_context(SwagContext, WoodyContext) -> + #{ + woody_context => WoodyContext, + swagger_context => SwagContext + }. + +process_woody_error(_Source, result_unexpected , _Details) -> wapi_handler_utils:reply_error(500); +process_woody_error(_Source, resource_unavailable, _Details) -> wapi_handler_utils:reply_error(503); +process_woody_error(_Source, result_unknown , _Details) -> wapi_handler_utils:reply_error(504). diff --git a/apps/wapi/src/wapi_handler_utils.erl b/apps/wapi/src/wapi_handler_utils.erl new file mode 100644 index 0000000..dfcfb48 --- /dev/null +++ b/apps/wapi/src/wapi_handler_utils.erl @@ -0,0 +1,87 @@ +-module(wapi_handler_utils). + +-export([get_error_msg/1]). + +-export([reply_ok/1]). +-export([reply_ok/2]). +-export([reply_ok/3]). + +-export([reply_error/1]). +-export([reply_error/2]). +-export([reply_error/3]). + +-export([get_party_id/1]). +-export([get_auth_context/1]). + +-export([get_location/3]). + +-define(APP, wapi). + +-type handler_context() :: wapi_handler:handler_context(). +-type handler_opts() :: wapi_handler:handler_opts(). + +-type error_message() :: binary() | io_lib:chars(). + +-type status_code() :: wapi_handler:status_code(). +-type headers() :: wapi_handler:headers(). +-type response_data() :: wapi_handler:response_data(). + +-type party_id() :: binary(). +-export_type([party_id/0]). + +%% API + +-spec get_party_id(handler_context()) -> + party_id(). +get_party_id(Context) -> + wapi_auth:get_subject_id(get_auth_context(Context)). + +-spec get_auth_context(handler_context()) -> + wapi_auth:context(). +get_auth_context(#{swagger_context := #{auth_context := AuthContext}}) -> + AuthContext. + +-spec get_error_msg(error_message()) -> + response_data(). +get_error_msg(Message) -> + #{<<"message">> => genlib:to_binary(Message)}. + +-spec reply_ok(status_code()) -> + {ok, {status_code(), [], undefined}}. +reply_ok(Code) -> + reply_ok(Code, undefined). + +-spec reply_ok(status_code(), response_data()) -> + {ok, {status_code(), [], response_data()}}. +reply_ok(Code, Data) -> + reply_ok(Code, Data, []). + +-spec reply_ok(status_code(), response_data(), headers()) -> + {ok, {status_code(), [], response_data()}}. +reply_ok(Code, Data, Headers) -> + reply(ok, Code, Data, Headers). + +-spec reply_error(status_code()) -> + {error, {status_code(), [], undefined}}. +reply_error(Code) -> + reply_error(Code, undefined). + +-spec reply_error(status_code(), response_data()) -> + {error, {status_code(), [], response_data()}}. +reply_error(Code, Data) -> + reply_error(Code, Data, []). + +-spec reply_error(status_code(), response_data(), headers()) -> + {error, {status_code(), [], response_data()}}. +reply_error(Code, Data, Headers) -> + reply(error, Code, Data, Headers). + +reply(Status, Code, Data, Headers) -> + {Status, {Code, Headers, Data}}. + +-spec get_location(cowboy_router:route_match(), [binary()], handler_opts()) -> + headers(). +get_location(PathSpec, Params, _Opts) -> + %% TODO pass base URL via Opts + BaseUrl = genlib_app:env(?APP, public_endpoint), + [{<<"Location">>, wapi_utils:get_url(BaseUrl, PathSpec, Params)}]. diff --git a/apps/wapi/src/wapi_payres_handler.erl b/apps/wapi/src/wapi_payres_handler.erl new file mode 100644 index 0000000..c64f3fd --- /dev/null +++ b/apps/wapi/src/wapi_payres_handler.erl @@ -0,0 +1,142 @@ +-module(wapi_payres_handler). + +-include_lib("dmsl/include/dmsl_cds_thrift.hrl"). + +-behaviour(swag_server_payres_logic_handler). +-behaviour(wapi_handler). + +%% swag_server_payres_logic_handler callbacks +-export([authorize_api_key/3]). +-export([handle_request/4]). + +%% wapi_handler callbacks +-export([process_request/4]). + +%% Types + +-type req_data() :: wapi_handler:req_data(). +-type handler_context() :: wapi_handler:handler_context(). +-type request_result() :: wapi_handler:request_result(). +-type operation_id() :: swag_server_payres:operation_id(). +-type api_key() :: swag_server_payres:api_key(). +-type request_context() :: swag_server_payres:request_context(). +-type handler_opts() :: swag_server_payres:handler_opts(). + +%% API + +-spec authorize_api_key(operation_id(), api_key(), handler_opts()) -> + false | {true, wapi_auth:context()}. +authorize_api_key(OperationID, ApiKey, Opts) -> + ok = scoper:add_meta(#{api => payres, operation_id => OperationID}), + wapi_auth:authorize_api_key(OperationID, ApiKey, Opts). + +-spec handle_request(operation_id(), req_data(), request_context(), handler_opts()) -> + request_result(). +handle_request(OperationID, Req, SwagContext, Opts) -> + wapi_handler:handle_request(OperationID, Req, SwagContext, ?MODULE, Opts). + +-spec process_request(operation_id(), req_data(), handler_context(), handler_opts()) -> + request_result(). +process_request('StoreBankCard', Req, Context, _Opts) -> + {CardData, AuthData} = process_card_data(Req, Context), + wapi_handler_utils:reply_ok(201, maps:merge(to_swag(CardData), to_swag(AuthData))); +process_request('GetBankCard', #{'token' := Token}, _Context, _Opts) -> + case decode_token(Token) of + {ok, Data} -> + wapi_handler_utils:reply_ok(200, Data); + {error, badarg} -> + wapi_handler_utils:reply_ok(404) + end. + +%% Internal functions + +process_card_data(#{'BankCard' := Data}, Context) -> + put_card_data_to_cds(to_thrift(card_data, Data), to_thrift(session_data, Data), Context). + +put_card_data_to_cds(CardData, SessionData, Context) -> + Call = {cds_storage, 'PutCardData', [CardData, SessionData]}, + case service_call(Call, Context) of + {ok, #'PutCardDataResult'{session_id = SessionID, bank_card = BankCard}} -> + {{bank_card, BankCard}, {auth_data, SessionID}}; + {exception, Exception} -> + case Exception of + #'InvalidCardData'{} -> + wapi_handler:throw_result(wapi_handler_utils:reply_ok(400, + wapi_handler_utils:get_error_msg(<<"Card data is invalid">>) + )); + #'KeyringLocked'{} -> + % TODO + % It's better for the cds to signal woody-level unavailability when the + % keyring is locked, isn't it? It could always mention keyring lock as a + % reason in a woody error definition. + wapi_handler:throw_result(wapi_handler_utils:reply_error(503)) + end + end. + +to_thrift(card_data, Data) -> + {Month, Year} = parse_exp_date(genlib_map:get(<<"expDate">>, Data)), + CardNumber = genlib:to_binary(genlib_map:get(<<"cardNumber">>, Data)), + #'CardData'{ + pan = CardNumber, + exp_date = #'ExpDate'{ + month = Month, + year = Year + }, + cardholder_name = genlib_map:get(<<"cardHolder">>, Data, undefined), + cvv = genlib_map:get(<<"cvv">>, Data, undefined) + }; +to_thrift(session_data, Data) -> + #'SessionData'{ + auth_data = {card_security_code, #'CardSecurityCode'{ + value = maps:get(<<"cvv">>, Data, <<>>) + }} + }. + +to_swag({Spec, Data}) when is_atom(Spec) -> + to_swag(Spec, Data). + +to_swag(bank_card, #domain_BankCard{ + 'token' = Token, + 'payment_system' = PaymentSystem, + 'bin' = Bin, + 'masked_pan' = MaskedPan +}) -> + BankCard = genlib_map:compact(#{ + <<"token">> => Token, + <<"paymentSystem">> => genlib:to_binary(PaymentSystem), + <<"bin">> => Bin, + <<"lastDigits">> => wapi_utils:get_last_pan_digits(MaskedPan) + }), + BankCard#{<<"token">> => encode_token(BankCard)}; + +to_swag(auth_data, PaymentSessionID) -> + #{<<"authData">> => genlib:to_binary(PaymentSessionID)}. + +encode_token(TokenData) -> + wapi_utils:map_to_base64url(TokenData). + +decode_token(Token) -> + try wapi_utils:base64url_to_map(Token) of + Data = #{<<"token">> := _} -> + {ok, maps:with([<<"token">>, <<"paymentSystem">>, <<"bin">>, <<"lastDigits">>], + Data#{<<"token">> => Token}) + }; + _ -> + {error, badarg} + catch + error:badarg -> + {error, badarg} + end. + +parse_exp_date(ExpDate) when is_binary(ExpDate) -> + [Month, Year0] = binary:split(ExpDate, <<"/">>), + Year = case genlib:to_int(Year0) of + Y when Y < 100 -> + 2000 + Y; + Y -> + Y + end, + {genlib:to_int(Month), Year}. + +service_call({ServiceName, Function, Args}, #{woody_context := WoodyContext}) -> + wapi_woody_client:call_service(ServiceName, Function, Args, WoodyContext). diff --git a/apps/wapi/src/wapi_privdoc_handler.erl b/apps/wapi/src/wapi_privdoc_handler.erl new file mode 100644 index 0000000..c0af860 --- /dev/null +++ b/apps/wapi/src/wapi_privdoc_handler.erl @@ -0,0 +1,151 @@ +-module(wapi_privdoc_handler). + +-include_lib("identdocstore_proto/include/identdocstore_identity_document_storage_thrift.hrl"). + +-behaviour(swag_server_privdoc_logic_handler). +-behaviour(wapi_handler). + +%% swag_server_privdoc_logic_handler callbacks +-export([authorize_api_key/3]). +-export([handle_request/4]). + +%% wapi_handler callbacks +-export([process_request/4]). + +%% helper +%% TODO move it somewhere else +-export([get_proof/2]). + +%% Types + +-type req_data() :: wapi_handler:req_data(). +-type handler_context() :: wapi_handler:handler_context(). +-type request_result() :: wapi_handler:request_result(). +-type operation_id() :: swag_server_privdoc:operation_id(). +-type api_key() :: swag_server_privdoc:api_key(). +-type request_context() :: swag_server_privdoc:request_context(). +-type handler_opts() :: swag_server_privdoc:handler_opts(). + + +%% API + +-spec authorize_api_key(operation_id(), api_key(), handler_opts()) -> + false | {true, wapi_auth:context()}. +authorize_api_key(OperationID, ApiKey, Opts) -> + ok = scoper:add_meta(#{api => privdoc, operation_id => OperationID}), + wapi_auth:authorize_api_key(OperationID, ApiKey, Opts). + +-spec handle_request(operation_id(), req_data(), request_context(), handler_opts()) -> + request_result(). +handle_request(OperationID, Params, SwagContext, Opts) -> + wapi_handler:handle_request(OperationID, Params, SwagContext, ?MODULE, Opts). + +-spec process_request(operation_id(), req_data(), handler_context(), handler_opts()) -> + request_result(). +process_request('StorePrivateDocument', #{'PrivateDocument' := Params}, Context, _Opts) -> + wapi_handler_utils:reply_ok(201, process_doc_data(Params, Context)). + +process_doc_data(Params, Context) -> + {ok, Token} = put_doc_data_to_cds(to_thrift(doc_data, Params), Context), + to_swag(doc, {Params, Token}). + +-spec get_proof(binary(), handler_context()) -> map(). +get_proof(Token, Context) -> + {ok, DocData} = service_call({identdoc_storage, 'Get', [Token]}, Context), + to_swag(doc_data, {DocData, Token}). + +to_thrift(doc_data, Params = #{<<"type">> := <<"RUSDomesticPassportData">>}) -> + {russian_domestic_passport, #'identdocstore_RussianDomesticPassport'{ + series = maps:get(<<"series">>, Params), + number = maps:get(<<"number">>, Params), + issuer = maps:get(<<"issuer">>, Params), + issuer_code = maps:get(<<"issuerCode">>, Params), + issued_at = maps:get(<<"issuedAt">>, Params), + family_name = maps:get(<<"familyName">>, Params), + first_name = maps:get(<<"firstName">>, Params), + patronymic = maps:get(<<"patronymic">>, Params, undefined), + birth_date = maps:get(<<"birthDate">>, Params), + birth_place = maps:get(<<"birthPlace">>, Params) + }}; +to_thrift(doc_data, Params = #{<<"type">> := <<"RUSRetireeInsuranceCertificateData">>}) -> + {russian_retiree_insurance_certificate, #'identdocstore_RussianRetireeInsuranceCertificate'{ + number = maps:get(<<"number">>, Params) + }}. + +to_swag(doc, {Params, Token}) -> + Doc = to_swag(raw_doc, {Params, Token}), + Doc#{<<"token">> => wapi_utils:map_to_base64url(Doc)}; +to_swag(raw_doc, {Params = #{<<"type">> := <<"RUSDomesticPassportData">>}, Token}) -> + #{ + <<"type">> => <<"RUSDomesticPassport">>, + <<"token">> => Token, + <<"seriesMasked">> => mask(pass_series, Params), + <<"numberMasked">> => mask(pass_number, Params), + <<"fullnameMasked">> => mask(pass_fullname, Params) + }; +to_swag(raw_doc, {Params = #{<<"type">> := <<"RUSRetireeInsuranceCertificateData">>}, Token}) -> + #{ + <<"type">> => <<"RUSRetireeInsuranceCertificate">>, + <<"token">> => Token, + <<"numberMasked">> => mask(retiree_insurance_cert_number, Params) + }; +to_swag(doc_data, {{russian_domestic_passport, D}, Token}) -> + to_swag(doc, { + #{ + <<"type">> => <<"RUSDomesticPassportData">>, + <<"series">> => D#'identdocstore_RussianDomesticPassport'.series, + <<"number">> => D#'identdocstore_RussianDomesticPassport'.number, + <<"firstName">> => D#'identdocstore_RussianDomesticPassport'.first_name, + <<"familyName">> => D#'identdocstore_RussianDomesticPassport'.family_name, + <<"patronymic">> => D#'identdocstore_RussianDomesticPassport'.patronymic + }, + Token + }); +to_swag(doc_data, {{russian_retiree_insurance_certificate, D}, Token}) -> + to_swag(doc, { + #{ + <<"type">> => <<"RUSRetireeInsuranceCertificateData">>, + <<"number">> => D#'identdocstore_RussianRetireeInsuranceCertificate'.number + }, + Token + }). + +put_doc_data_to_cds(IdentityDoc, Context) -> + service_call({identdoc_storage, 'Put', [IdentityDoc]}, Context). + +service_call({ServiceName, Function, Args}, #{woody_context := WoodyContext}) -> + wapi_woody_client:call_service(ServiceName, Function, Args, WoodyContext). + + + +-define(PATTERN_DIGIT, [<<"0">>, <<"1">>, <<"2">>, <<"3">>, <<"4">>, <<"5">>, <<"6">>, <<"7">>, <<"8">>, <<"9">>]). + +mask(pass_series, #{<<"series">> := V}) -> + wapi_utils:mask_and_keep(leading, 2, $*, V); +mask(pass_number, #{<<"number">> := V}) -> + wapi_utils:mask_and_keep(trailing, 1, $*, V); +mask(pass_fullname, Params) -> + MaskedFamilyName = mask(family_name, Params), + MaskedFirstName = mask(first_name, Params), + MaskedPatronymic = mask(patronymic, Params), + <>; +mask(family_name, #{<<"familyName">> := V}) -> + wapi_utils:mask_and_keep(leading, 1, $*, V); +mask(first_name, #{<<"firstName">> := V}) -> + <<(unicode:characters_to_binary(string:left(unicode:characters_to_list(V), 1)))/binary, "."/utf8>>; +mask(patronymic, #{<<"patronymic">> := V}) -> + <<(unicode:characters_to_binary(string:left(unicode:characters_to_list(V), 1)))/binary, "."/utf8>>; +mask(patronymic, _) -> + <<>>; +%% TODO rewrite this ugly shit +mask(retiree_insurance_cert_number, #{<<"number">> := Number}) -> + FirstPublicSymbols = 2, + LastPublicSymbols = 1, + V1 = binary:part(Number, {0 , FirstPublicSymbols}), + Rest1 = binary:part(Number, {0 + FirstPublicSymbols, size(Number) - (0 + FirstPublicSymbols)}), + + V2 = binary:part(Rest1, {size(Rest1) , -LastPublicSymbols}), + Rest2 = binary:part(Rest1, {0, size(Rest1) - LastPublicSymbols}), + + Mask = binary:replace(Rest2, ?PATTERN_DIGIT, <<"*">>, [global]), + <>. diff --git a/apps/wapi/src/wapi_sup.erl b/apps/wapi/src/wapi_sup.erl new file mode 100644 index 0000000..fd2d0a2 --- /dev/null +++ b/apps/wapi/src/wapi_sup.erl @@ -0,0 +1,54 @@ +%% @doc Top level supervisor. +%% @end + +-module(wapi_sup). +-behaviour(supervisor). + +%% API +-export([start_link/0]). + +%% Supervisor callbacks +-export([init/1]). + +%% + +-spec start_link() -> {ok, pid()} | {error, {already_started, pid()}}. + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +%% + +-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. + +init([]) -> + AuthorizerSpecs = get_authorizer_child_specs(), + {LogicHandlers, LogicHandlerSpecs} = get_logic_handler_info(), + HealthRoutes = [{'_', [erl_health_handle:get_route(genlib_app:env(wapi, health_checkers, []))]}], + SwaggerSpec = wapi_swagger_server:child_spec({HealthRoutes, LogicHandlers}), + {ok, { + {one_for_all, 0, 1}, + AuthorizerSpecs ++ LogicHandlerSpecs ++ [SwaggerSpec] + }}. + +-spec get_authorizer_child_specs() -> [supervisor:child_spec()]. + +get_authorizer_child_specs() -> + Authorizers = genlib_app:env(wapi, authorizers, #{}), + [ + get_authorizer_child_spec(jwt, maps:get(jwt, Authorizers)) + ]. + +-spec get_authorizer_child_spec(Name :: atom(), Options :: #{}) -> supervisor:child_spec(). + +get_authorizer_child_spec(jwt, Options) -> + wapi_authorizer_jwt:get_child_spec(Options). + +-spec get_logic_handler_info() -> {Handlers :: #{atom() => module()}, [Spec :: supervisor:child_spec()] | []} . + +get_logic_handler_info() -> + {#{ + wallet => wapi_wallet_handler, + payres => wapi_payres_handler, + privdoc => wapi_privdoc_handler + }, []}. diff --git a/apps/wapi/src/wapi_swagger_server.erl b/apps/wapi/src/wapi_swagger_server.erl new file mode 100644 index 0000000..00799e5 --- /dev/null +++ b/apps/wapi/src/wapi_swagger_server.erl @@ -0,0 +1,140 @@ +-module(wapi_swagger_server). + +-export([child_spec /1]). +-export([request_hook /1]). +-export([response_hook/4]). + +-define(APP, wapi). +-define(DEFAULT_ACCEPTORS_POOLSIZE, 100). +-define(DEFAULT_IP_ADDR, "::"). +-define(DEFAULT_PORT, 8080). + +-define(SWAG_HANDLER_SCOPE, swag_handler). + +-type params() :: {cowboy_router:routes(), #{atom() => module()}}. + +-spec child_spec(params()) -> + supervisor:child_spec(). +child_spec({HealthRoutes, LogicHandlers}) -> + {Transport, TransportOpts} = get_socket_transport(), + CowboyOpts = get_cowboy_config(HealthRoutes, LogicHandlers), + AcceptorsPool = genlib_app:env(?APP, acceptors_poolsize, ?DEFAULT_ACCEPTORS_POOLSIZE), + ranch:child_spec(?MODULE, AcceptorsPool, + Transport, TransportOpts, cowboy_protocol, CowboyOpts). + +get_socket_transport() -> + {ok, IP} = inet:parse_address(genlib_app:env(?APP, ip, ?DEFAULT_IP_ADDR)), + Port = genlib_app:env(?APP, port, ?DEFAULT_PORT), + {ranch_tcp, [{ip, IP}, {port, Port}]}. + +get_cowboy_config(HealthRoutes, LogicHandlers) -> + Dispatch = + cowboy_router:compile(squash_routes( + HealthRoutes ++ + swag_server_wallet_router:get_paths(maps:get(wallet, LogicHandlers)) ++ + swag_server_payres_router:get_paths(maps:get(payres, LogicHandlers)) ++ + swag_server_privdoc_router:get_paths(maps:get(privdoc, LogicHandlers)) + )), + [ + {env, [ + {dispatch, Dispatch}, + {cors_policy, wapi_cors_policy} + ]}, + {middlewares, [ + cowboy_router, + cowboy_cors, + cowboy_handler + ]}, + {onrequest, fun ?MODULE:request_hook/1}, + {onresponse, fun ?MODULE:response_hook/4} + ]. + +squash_routes(Routes) -> + orddict:to_list(lists:foldl( + fun ({K, V}, D) -> orddict:update(K, fun (V0) -> V0 ++ V end, V, D) end, + orddict:new(), + Routes + )). + +-spec request_hook(cowboy_req:req()) -> + cowboy_req:req(). + +request_hook(Req) -> + ok = scoper:add_scope(?SWAG_HANDLER_SCOPE), + HookFun = cowboy_access_log:get_request_hook(), + HookFun(Req). + +-spec response_hook(cowboy:http_status(), cowboy:http_headers(), iodata(), cowboy_req:req()) -> + cowboy_req:req(). + +response_hook(Code, Headers, Body, Req) -> + try + {Code1, Headers1, Req1} = handle_response(Code, Headers, Req), + _ = log_access(Code1, Headers1, Body, Req1), + ok = cleanup_scoper(), + Req1 + catch + Class:Reason -> + Stack = genlib_format:format_stacktrace(erlang:get_stacktrace(), [newlines]), + _ = lager:warning( + "Response hook failed for: [~p, ~p, ~p]~nwith: ~p:~p~nstacktrace: ~ts", + [Code, Headers, Req, Class, Reason, Stack] + ), + ok = cleanup_scoper(), + Req + end. + +handle_response(Code, Headers, Req) when Code >= 500 -> + send_oops_resp(Code, Headers, get_oops_body_safe(Code), Req); +handle_response(Code, Headers, Req) -> + {Code, Headers, Req}. + +%% cowboy_req:reply/4 has a faulty spec in case of response body fun. +-dialyzer({[no_contracts, no_fail_call], send_oops_resp/4}). + +send_oops_resp(Code, Headers, undefined, Req) -> + {Code, Headers, Req}; +send_oops_resp(Code, Headers, File, Req) -> + FileSize = filelib:file_size(File), + F = fun(Socket, Transport) -> + case Transport:sendfile(Socket, File) of + {ok, _} -> + ok; + {error, Error} -> + _ = lager:warning("Failed to send oops body: ~p", [Error]), + ok + end + end, + Headers1 = lists:foldl( + fun({K, V}, Acc) -> lists:keystore(K, 1, Acc, {K, V}) end, + Headers, + [ + {<<"content-type">>, <<"text/plain; charset=utf-8">>}, + {<<"content-length">>, integer_to_list(FileSize)} + ] + ), + {ok, Req1} = cowboy_req:reply(Code, Headers1, {FileSize, F}, Req), + {Code, Headers1, Req1}. + +get_oops_body_safe(Code) -> + try get_oops_body(Code) + catch + Error:Reason -> + _ = lager:warning("Invalid oops body config for code: ~p. Error: ~p:~p", [Code, Error, Reason]), + undefined + end. + +get_oops_body(Code) -> + genlib_map:get(Code, genlib_app:env(?APP, oops_bodies, #{}), undefined). + +log_access(Code, Headers, Body, Req) -> + LogFun = cowboy_access_log:get_response_hook(wapi_access_lager_event), + LogFun(Code, Headers, Body, Req). + +cleanup_scoper() -> + try scoper:get_current_scope() of + ?SWAG_HANDLER_SCOPE -> scoper:remove_scope(); + _ -> ok + catch + error:no_scopes -> ok + end. diff --git a/apps/wapi/src/wapi_utils.erl b/apps/wapi/src/wapi_utils.erl new file mode 100644 index 0000000..45134eb --- /dev/null +++ b/apps/wapi/src/wapi_utils.erl @@ -0,0 +1,222 @@ +-module(wapi_utils). + +-export([base64url_to_map/1]). +-export([map_to_base64url/1]). + +-export([to_universal_time/1]). + +-export([redact/2]). +-export([mask_and_keep/4]). +-export([mask/4]). + +-export([unwrap/1]). +-export([define/2]). + +-export([get_path/2]). +-export([get_url/2]). +-export([get_url/3]). + +-export([get_last_pan_digits/1]). + +-type binding_value() :: binary(). +-type url() :: binary(). +-type path() :: binary(). + +%% API + +-spec base64url_to_map(binary()) -> map() | no_return(). +base64url_to_map(Base64) when is_binary(Base64) -> + try jsx:decode(base64url:decode(Base64), [return_maps]) + catch + Class:Reason -> + _ = lager:debug("decoding base64 ~p to map failed with ~p:~p", [Base64, Class, Reason]), + erlang:error(badarg) + end. + +-spec map_to_base64url(map()) -> binary() | no_return(). +map_to_base64url(Map) when is_map(Map) -> + try base64url:encode(jsx:encode(Map)) + catch + Class:Reason -> + _ = lager:debug("encoding map ~p to base64 failed with ~p:~p", [Map, Class, Reason]), + erlang:error(badarg) + end. + +-spec redact(Subject :: binary(), Pattern :: binary()) -> Redacted :: binary(). +redact(Subject, Pattern) -> + case re:run(Subject, Pattern, [global, {capture, all_but_first, index}]) of + {match, Captures} -> + lists:foldl(fun redact_match/2, Subject, Captures); + nomatch -> + Subject + end. + +redact_match({S, Len}, Subject) -> + <> = Subject, + <
>, Len))/binary, Rest/binary>>;
+redact_match([Capture], Message) ->
+    redact_match(Capture, Message).
+
+%% TODO Switch to this sexy code after the upgrade to Erlang 20+
+%%
+%% -spec mask(leading|trailing, non_neg_integer(), char(), binary()) ->
+%%     binary().
+%% mask(Dir = trailing, MaskLen, MaskChar, Str) ->
+%%     mask(Dir, 0, string:length(Str) - MaskLen, MaskChar, Str);
+%% mask(Dir = leading, MaskLen, MaskChar, Str) ->
+%%     mask(Dir, MaskLen, string:length(Str), MaskChar, Str).
+
+%% mask(Dir, KeepStart, KeepLen, MaskChar, Str) ->
+%%     unicode:characters_to_binary(string:pad(string:slice(Str, KeepStart, KeepLen), string:length(Str), Dir, MaskChar)).
+
+-spec mask_and_keep(leading|trailing, non_neg_integer(), char(), binary()) ->
+    binary().
+mask_and_keep(trailing, KeepLen, MaskChar, Chardata) ->
+    StrLen = erlang:length(unicode:characters_to_list(Chardata)),
+    mask(leading, StrLen - KeepLen, MaskChar, Chardata);
+mask_and_keep(leading, KeepLen, MaskChar, Chardata) ->
+    StrLen = erlang:length(unicode:characters_to_list(Chardata)),
+    mask(trailing, StrLen - KeepLen, MaskChar, Chardata).
+
+-spec mask(leading|trailing, non_neg_integer(), char(), binary()) ->
+    binary().
+mask(trailing, MaskLen, MaskChar, Chardata) ->
+    Str = unicode:characters_to_list(Chardata),
+    unicode:characters_to_binary(
+        string:left(string:substr(Str, 1, erlang:length(Str) - MaskLen), erlang:length(Str), MaskChar)
+    );
+mask(leading, MaskLen, MaskChar, Chardata) ->
+    Str = unicode:characters_to_list(Chardata),
+    unicode:characters_to_binary(
+        string:right(string:substr(Str, MaskLen + 1), erlang:length(Str), MaskChar)
+    ).
+
+-spec to_universal_time(Timestamp :: binary()) -> TimestampUTC :: binary().
+to_universal_time(Timestamp) ->
+    {ok, {Date, Time, Usec, TZOffset}} = rfc3339:parse(Timestamp),
+    Seconds = calendar:datetime_to_gregorian_seconds({Date, Time}),
+    %% The following crappy code is a dialyzer workaround
+    %% for the wrong rfc3339:parse/1 spec.
+    {DateUTC, TimeUTC} = calendar:gregorian_seconds_to_datetime(
+        case TZOffset of
+            _ when is_integer(TZOffset) ->
+                Seconds - (60 * TZOffset);
+            _ ->
+                Seconds
+        end
+    ),
+    {ok, TimestampUTC} = rfc3339:format({DateUTC, TimeUTC, Usec, 0}),
+    TimestampUTC.
+
+-spec unwrap(ok | {ok, Value} | {error, _Error}) ->
+    Value | no_return().
+unwrap(ok) ->
+    ok;
+unwrap({ok, Value}) ->
+    Value;
+unwrap({error, Error}) ->
+    erlang:error({unwrap_error, Error}).
+
+-spec define(undefined | T, T) -> T.
+define(undefined, V) ->
+    V;
+define(V, _Default) ->
+    V.
+
+-spec get_path(cowboy_router:route_match(), [binding_value()]) ->
+    path().
+get_path(PathSpec, Params) when is_list(PathSpec) ->
+    get_path(genlib:to_binary(PathSpec), Params);
+get_path(Path, []) ->
+    Path;
+get_path(PathSpec, [Value | Rest]) ->
+    [P1, P2] = split(PathSpec),
+    P3       = get_next(P2),
+    get_path(<>, Rest).
+
+split(PathSpec) ->
+    case binary:split(PathSpec, <<":">>) of
+        Res = [_, _] -> Res;
+        [_]          -> erlang:error(param_mismatch)
+    end.
+
+get_next(PathSpec) ->
+    case binary:split(PathSpec, <<"/">>) of
+        [_, Next] -> <<"/", Next/binary>>;
+        [_]       -> <<>>
+    end.
+
+-spec get_url(url(), path()) ->
+    url().
+get_url(BaseUrl, Path) ->
+    <>.
+
+-spec get_url(url(), cowboy_router:route_match(), [binding_value()]) ->
+    url().
+get_url(BaseUrl, PathSpec, Params) ->
+    get_url(BaseUrl, get_path(PathSpec, Params)).
+
+-define(MASKED_PAN_MAX_LENGTH, 4).
+
+-spec get_last_pan_digits(binary()) ->
+    binary().
+get_last_pan_digits(MaskedPan) when byte_size(MaskedPan) > ?MASKED_PAN_MAX_LENGTH ->
+    binary:part(MaskedPan, {byte_size(MaskedPan), -?MASKED_PAN_MAX_LENGTH});
+get_last_pan_digits(MaskedPan) ->
+    MaskedPan.
+
+%%
+
+-ifdef(TEST).
+-include_lib("eunit/include/eunit.hrl").
+
+-spec test() -> _.
+
+-spec to_universal_time_test() -> _.
+to_universal_time_test() ->
+    ?assertEqual(<<"2017-04-19T13:56:07Z">>,        to_universal_time(<<"2017-04-19T13:56:07Z">>)),
+    ?assertEqual(<<"2017-04-19T13:56:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53Z">>)),
+    ?assertEqual(<<"2017-04-19T10:36:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53+03:20">>)),
+    ?assertEqual(<<"2017-04-19T17:16:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53-03:20">>)).
+
+-spec redact_test() -> _.
+redact_test() ->
+    P1 = <<"^\\+\\d(\\d{1,10}?)\\d{2,4}$">>,
+    ?assertEqual(<<"+7******3210">>, redact(<<"+79876543210">>, P1)),
+    ?assertEqual(       <<"+1*11">>, redact(<<"+1111">>, P1)).
+
+-spec get_path_test() -> _.
+get_path_test() ->
+    ?assertEqual(<<"/wallet/v0/deposits/11/events/42">>, get_path(
+        <<"/wallet/v0/deposits/:depositID/events/:eventID">>, [<<"11">>, <<"42">>]
+    )),
+    ?assertEqual(<<"/wallet/v0/deposits/11/events/42">>, get_path(
+        "/wallet/v0/deposits/:depositID/events/:eventID", [<<"11">>, <<"42">>]
+    )),
+    ?assertError(param_mismatch, get_path(
+        "/wallet/v0/deposits/:depositID/events/:eventID", [<<"11">>, <<"42">>, <<"0">>]
+    )).
+
+-spec mask_test() -> _.
+mask_test() ->
+    ?assertEqual(<<"Хуй">>, mask(leading, 0, $*, <<"Хуй">>)),
+    ?assertEqual(<<"*уй">>, mask(leading, 1, $*, <<"Хуй">>)),
+    ?assertEqual(<<"**й">>, mask(leading, 2, $*, <<"Хуй">>)),
+    ?assertEqual(<<"***">>, mask(leading, 3, $*, <<"Хуй">>)),
+    ?assertEqual(<<"Хуй">>, mask(trailing, 0, $*, <<"Хуй">>)),
+    ?assertEqual(<<"Ху*">>, mask(trailing, 1, $*, <<"Хуй">>)),
+    ?assertEqual(<<"Х**">>, mask(trailing, 2, $*, <<"Хуй">>)),
+    ?assertEqual(<<"***">>, mask(trailing, 3, $*, <<"Хуй">>)).
+
+-spec mask_and_keep_test() -> _.
+mask_and_keep_test() ->
+    ?assertEqual(<<"***">>, mask_and_keep(leading, 0, $*, <<"Хуй">>)),
+    ?assertEqual(<<"Х**">>, mask_and_keep(leading, 1, $*, <<"Хуй">>)),
+    ?assertEqual(<<"Ху*">>, mask_and_keep(leading, 2, $*, <<"Хуй">>)),
+    ?assertEqual(<<"Хуй">>, mask_and_keep(leading, 3, $*, <<"Хуй">>)),
+    ?assertEqual(<<"***">>, mask_and_keep(trailing, 0, $*, <<"Хуй">>)),
+    ?assertEqual(<<"**й">>, mask_and_keep(trailing, 1, $*, <<"Хуй">>)),
+    ?assertEqual(<<"*уй">>, mask_and_keep(trailing, 2, $*, <<"Хуй">>)),
+    ?assertEqual(<<"Хуй">>, mask_and_keep(trailing, 3, $*, <<"Хуй">>)).
+
+-endif.
diff --git a/apps/wapi/src/wapi_wallet_ff_backend.erl b/apps/wapi/src/wapi_wallet_ff_backend.erl
new file mode 100644
index 0000000..c8e31c4
--- /dev/null
+++ b/apps/wapi/src/wapi_wallet_ff_backend.erl
@@ -0,0 +1,466 @@
+%% Temporary stab for wallet handler
+
+-module(wapi_wallet_ff_backend).
+
+-include_lib("dmsl/include/dmsl_domain_thrift.hrl").
+
+%% API
+-export([get_providers/2]).
+-export([get_provider/2]).
+-export([get_provider_identity_classes/2]).
+-export([get_provider_identity_class/3]).
+-export([get_provider_identity_class_levels/3]).
+-export([get_provider_identity_class_level/4]).
+
+-export([get_identities/2]).
+-export([get_identity/2]).
+-export([create_identity/2]).
+-export([get_identity_challengies/2]).
+-export([create_identity_challenge/3]).
+-export([get_identity_challenge/3]).
+-export([cancel_identity_challenge/3]).
+-export([get_identity_challenge_events/2]).
+-export([get_identity_challenge_event/4]).
+
+-export([get_destinations/2]).
+-export([get_destination/2]).
+-export([create_destination/2]).
+-export([create_withdrawal/2]).
+-export([get_withdrawal/2]).
+-export([get_withdrawal_events/2]).
+-export([get_withdrawal_event/3]).
+
+%% Helper API
+-export([not_implemented/0]).
+
+%% API
+
+%% Providers
+
+-spec get_providers(_, _) -> no_return().
+get_providers(_Params, _Context) ->
+    not_implemented().
+
+-spec get_provider(_, _) -> _.
+get_provider(ProviderId, _Context) ->
+    case ff_provider:get(ProviderId) of
+        {ok, Provider}      -> {ok, to_swag(provider, {ProviderId, ff_provider:payinst(Provider)})};
+        Error = {error, _}  -> Error
+    end.
+
+-spec get_provider_identity_classes(_, _) -> _.
+get_provider_identity_classes(Id, _Context) ->
+    case ff_provider:get(Id) of
+        {ok, Provider} ->
+            {ok, lists:map(
+                fun(ClassId) ->
+                    {ok, Class} = do_get_provider_identity_class(ClassId, Provider),
+                    Class
+                end,
+                ff_provider:list_identity_classes(Provider)
+            )};
+        Error = {error, _} ->
+            Error
+    end.
+
+-spec get_provider_identity_class(_, _, _) -> _.
+get_provider_identity_class(ProviderId, ClassId, _Context) ->
+    case ff_provider:get(ProviderId) of
+        {ok, Provider}     -> do_get_provider_identity_class(ClassId, Provider);
+        Error = {error, _} -> Error
+    end.
+
+do_get_provider_identity_class(ClassId, Provider) ->
+    case ff_provider:get_identity_class(ClassId, Provider) of
+        {ok, Class}        -> {ok, to_swag(identity_class, Class)};
+        Error = {error, _} -> Error
+    end.
+
+-spec get_provider_identity_class_levels(_, _, _) -> no_return().
+get_provider_identity_class_levels(_ProviderId, _ClassId, _Context) ->
+    not_implemented().
+
+-spec get_provider_identity_class_level(_, _, _, _) -> no_return().
+get_provider_identity_class_level(_ProviderId, _ClassId, _LevelId, _Context) ->
+    not_implemented().
+
+%% Identities
+
+-spec get_identities(_, _) -> no_return().
+get_identities(_Params, _Context) ->
+    not_implemented().
+
+-define(NS, <<"com.rbkmoney.wapi">>).
+
+-spec get_identity(_, _) -> _.
+get_identity(IdentityId, _Context) ->
+    case ff_identity_machine:get(IdentityId) of
+        {ok, IdentityState} ->
+            {ok, to_swag(identity, IdentityState)};
+        Error = {error, _} ->
+            Error
+    end.
+
+-spec create_identity(_, _) -> _.
+create_identity(Params, Context) ->
+    IdentityId = genlib:unique(),
+    case ff_identity_machine:create(IdentityId, from_swag(identity_params, Params), make_ctx(Params, [<<"name">>])) of
+        ok                 -> get_identity(IdentityId, Context);
+        {error, exists}    -> create_identity(Params, Context);
+        Error = {error, _} -> Error
+    end.
+
+-spec get_identity_challengies(_, _) -> no_return().
+get_identity_challengies(_Params, _Context) ->
+    not_implemented().
+
+-spec create_identity_challenge(_, _, _) -> _.
+create_identity_challenge(IdentityId, Params, Context) ->
+    ChallengeId = genlib:unique(),
+    case ff_identity_machine:start_challenge(
+        IdentityId,
+        maps:merge(#{id => ChallengeId}, from_swag(identity_challenge_params, Params))
+    ) of
+        ok ->
+            get_identity_challenge(IdentityId, ChallengeId, Context);
+        {error, notfound} ->
+            {error, {identity, notfound}};
+        Error = {error, _} ->
+            Error
+    end.
+
+-spec get_identity_challenge(_, _, _) -> _.
+get_identity_challenge(IdentityId, ChallengeId, Context) ->
+    case get_identity(IdentityId, Context) of
+        {ok, IdentityState} ->
+             case ff_identity:challenge(ChallengeId, ff_identity_machine:identity(IdentityState)) of
+                 {ok, Challenge} ->
+                     Proofs = [
+                         wapi_privdoc_handler:get_proof(Token, Context) ||
+                         {_, Token} <- ff_identity_challenge:proofs(Challenge)
+                     ],
+                     {ok, to_swag(identity_challenge, {ChallengeId, Challenge, Proofs})};
+                 Error = {error, notfound} ->
+                     Error
+             end;
+        Error = {error, notfound} ->
+            Error
+    end.
+
+-spec cancel_identity_challenge(_, _, _) -> no_return().
+cancel_identity_challenge(_IdentityId, _ChallengeId, _Context) ->
+    not_implemented().
+
+-spec get_identity_challenge_events(_, _) -> no_return().
+get_identity_challenge_events(Params = #{'identityID' := _IdentityId, 'challengeID' := _ChallengeId, 'limit' := _Limit}, _Context) ->
+    _ = genlib_map:get('eventCursor', Params),
+    not_implemented().
+
+-spec get_identity_challenge_event(_, _, _, _) -> no_return().
+get_identity_challenge_event(_IdentityId, _ChallengeId, _EventId, _Context) ->
+    not_implemented().
+
+%% Withdrawals
+
+-spec get_destinations(_, _) -> no_return().
+get_destinations(_Params, _Context) ->
+    not_implemented().
+
+-spec get_destination(_, _) -> _.
+get_destination(DestinationId, _Context) ->
+    case ff_destination_machine:get(DestinationId) of
+        {ok, DestinationState} -> {ok, to_swag(destination, DestinationState)};
+        Error = {error, _}     -> Error
+    end.
+
+-spec create_destination(_, _) -> _.
+create_destination(Params, Context) ->
+    DestinationId = genlib:unique(),
+    case ff_destination_machine:create(
+        DestinationId, from_swag(destination_params, Params), make_ctx(Params, [<<"name">>])
+    ) of
+        ok                 -> get_destination(DestinationId, Context);
+        {error, exists}    -> create_destination(Params, Context);
+        Error = {error, _} -> Error
+    end.
+
+-spec create_withdrawal(_, _) -> _.
+create_withdrawal(Params, Context) ->
+    WithdrawalId = genlib:unique(),
+    case ff_withdrawal_machine:create(WithdrawalId, from_swag(withdrawal_params, Params), make_ctx(Params, [])) of
+        ok                 -> get_withdrawal(WithdrawalId, Context);
+        {error, exists}    -> create_withdrawal(Params, Context);
+        Error = {error, _} -> Error
+    end.
+
+-spec get_withdrawal(_, _) -> _.
+get_withdrawal(WithdrawalId, _Context) ->
+    case ff_withdrawal_machine:get(WithdrawalId) of
+        {ok, State}        -> {ok, to_swag(withdrawal, State)};
+        Error = {error, _} -> Error
+    end.
+
+-spec get_withdrawal_events(_, _) -> _.
+get_withdrawal_events(Params = #{'withdrawalID' := WithdrawalId, 'limit' := Limit}, _Context) ->
+    case ff_withdrawal_machine:get_status_events(WithdrawalId, genlib_map:get('eventCursor', Params)) of
+        {ok, Events}       -> {ok, to_swag(withdrawal_events, filter_status_events(Events, Limit))};
+        Error = {error, _} -> Error
+    end.
+
+-spec get_withdrawal_event(_, _, _) -> _.
+get_withdrawal_event(WithdrawalId, EventId, _Context) ->
+    case ff_withdrawal_machine:get_status_events(WithdrawalId, undefined) of
+        {ok, Events} ->
+            case lists:keyfind(EventId, 1, filter_status_events(Events)) of
+                false -> {error, {event, notfound}};
+                Event -> {ok, to_swag(withdrawal_event, Event)}
+            end;
+        Error = {error, _} -> Error
+    end.
+
+%% Helper API
+
+-spec not_implemented() -> no_return().
+not_implemented() ->
+    wapi_handler:throw_result(wapi_handler_utils:reply_error(501)).
+
+%% Internal functions
+
+make_ctx(Params, WapiKeys) ->
+    Ctx0 = maps:with(WapiKeys, Params),
+    Ctx1 = case maps:get(<<"metadata">>, Params, undefined) of
+        undefined -> Ctx0;
+        MD        -> Ctx0#{<<"md">> => MD}
+    end,
+    #{?NS => Ctx1}.
+
+filter_status_events(Events) ->
+    filter_status_events(Events, undefined).
+
+filter_status_events(Events, Limit) ->
+    filter_status_events(Events, [], Limit).
+
+filter_status_events(_, Acc, Limit) when is_integer(Limit) andalso length(Acc) >= Limit ->
+    Acc;
+filter_status_events([], Acc, _) ->
+    Acc;
+filter_status_events([{ID, Ts, {created, _}} | Rest], Acc, Limit) ->
+    filter_status_events(Rest, [{ID, Ts, undefined} | Acc], Limit);
+filter_status_events([{ID, Ts, {status_changed, Status}} | Rest], Acc, Limit) ->
+    filter_status_events(Rest, [{ID, Ts, Status} | Acc], Limit);
+filter_status_events([_ | Rest], Acc, Limit) ->
+    filter_status_events(Rest, Acc, Limit).
+
+%% Marshalling
+from_swag(identity_params, Params) ->
+    #{
+        party    => maps:get(<<"party">>   , Params),
+        provider => maps:get(<<"provider">>, Params),
+        class    => maps:get(<<"class">>   , Params)
+    };
+from_swag(identity_challenge_params, Params) ->
+    #{
+       class  => maps:get(<<"type">>, Params),
+       proofs => from_swag(proofs, maps:get(<<"proofs">>, Params))
+    };
+from_swag(proofs, Proofs) ->
+    from_swag(list, {proof, Proofs});
+from_swag(proof, #{<<"token">> := WapiToken}) ->
+    try
+        #{<<"type">> := Type, <<"token">> := Token} = wapi_utils:base64url_to_map(WapiToken),
+        {from_swag(proof_type, Type), Token}
+    catch
+        error:badarg ->
+            wapi_handler:throw_result(wapi_handler_utils:reply_error(
+                422,
+                wapi_handler_utils:get_error_msg(io_lib:format("Invalid proof token: ~p", [WapiToken]))
+            ))
+    end;
+from_swag(proof_type, <<"RUSDomesticPassport">>) ->
+    rus_domestic_passport;
+from_swag(proof_type, <<"RUSRetireeInsuranceCertificateData">>) ->
+    rus_retiree_insurance_cert;
+from_swag(destination_params, Params) ->
+    #{
+        identity => maps:get(<<"identity">>, Params),
+        currency => maps:get(<<"currency">>, Params),
+        name     => maps:get(<<"name">>    , Params),
+        resource => from_swag(destination_resource, maps:get(<<"resource">>, Params))
+    };
+from_swag(destination_resource, #{
+    <<"type">> := <<"BankCardDestinationResource">>,
+    <<"token">> := WapiToken
+}) ->
+    #{<<"token">> := CdsToken} = wapi_utils:base64url_to_map(WapiToken),
+    {bank_card, #{token => CdsToken}};
+from_swag(withdrawal_params, Params) ->
+    #{
+        source      => maps:get(<<"wallet">>     , Params),
+        destination => maps:get(<<"destination">>, Params),
+        body        => from_swag(withdrawal_body , maps:get(<<"body">>, Params))
+    };
+from_swag(withdrawal_body, Body) ->
+    {maps:get(<<"amount">>, Body), maps:get(<<"currency">>, Body)};
+from_swag(list, {Type, List}) ->
+    lists:map(fun(V) -> from_swag(Type, V) end, List).
+
+
+to_swag(_, undefined) ->
+    undefined;
+to_swag(providers, Providers) ->
+    to_swag(list, {provider, Providers});
+to_swag(provider, {Id, Provider}) ->
+    to_swag(map, #{
+       <<"id">> => Id,
+       <<"name">> => Provider#'domain_PaymentInstitution'.name,
+       <<"residences">> => to_swag(list, {residence,
+           ordsets:to_list(Provider#'domain_PaymentInstitution'.residences)
+       })
+     });
+to_swag(residence, Residence) ->
+    genlib_string:to_upper(genlib:to_binary(Residence));
+to_swag(identity_class, Class) ->
+    to_swag(map, maps:with([id, name], Class));
+to_swag(identity, #{identity := Identity, times := {CreatedAt, _}, ctx := Ctx}) ->
+    {ok, WapiCtx}    = ff_ctx:get(?NS, Ctx),
+    ProviderId       = ff_provider:id(ff_identity:provider(Identity)),
+    #{id := ClassId} = ff_identity:class(Identity),
+    to_swag(map, #{
+        <<"id">>                 => ff_identity:id(Identity),
+        <<"name">>               => maps:get(<<"name">>, WapiCtx),
+        <<"metadata">>           => maps:get(<<"md">>, WapiCtx, undefined),
+        <<"createdAt">>          => to_swag(timestamp, CreatedAt),
+        <<"provider">>           => ProviderId,
+        <<"class">>              => ClassId,
+        <<"level">>              => ff_identity_class:level_id(ff_identity:level(Identity)),
+        <<"effectiveChallenge">> => to_swag(identity_effective_challenge, ff_identity:effective_challenge(Identity)),
+        <<"isBlocked">>          => to_swag(is_blocked, ff_identity:is_accessible(Identity))
+    });
+to_swag(identity_effective_challenge, {ok, ChallegeId}) ->
+    ChallegeId;
+to_swag(identity_effective_challenge, {error, notfound}) ->
+    undefined;
+to_swag(identity_challenge, {ChallengeId, Challenge, Proofs}) ->
+    ChallengeClass = ff_identity_challenge:class(Challenge),
+    to_swag(map,#{
+        <<"id">>            => ChallengeId,
+        <<"createdAt">>     => <<"TODO">>,
+        <<"level">>         => ff_identity_class:level_id(ff_identity_class:target_level(ChallengeClass)),
+        <<"type">>          => ff_identity_class:challenge_class_id(ChallengeClass),
+        <<"proofs">>        => Proofs,
+        <<"status">>        => to_swag(challenge_status,
+            {ff_identity_challenge:status(Challenge), ff_identity_challenge:resolution(Challenge)}
+        ),
+        <<"validUntil">>    => to_swag(idenification_expiration, ff_identity_challenge:status(Challenge)),
+        <<"failureReason">> => to_swag(identity_challenge_failure_reason, ff_identity_challenge:status(Challenge))
+    });
+to_swag(challenge_status, {pending, _}) ->
+    <<"Pending">>;
+to_swag(challenge_status, {completed, {ok, approved}}) ->
+    <<"Completed">>;
+to_swag(challenge_status, {completed, {ok, denied}}) ->
+    <<"Failed">>;
+to_swag(challenge_status, {failed, _}) ->
+    <<"Failed">>;
+to_swag(challenge_status, cancelled) ->
+    <<"Cancelled">>;
+to_swag(idenification_expiration, {completed, #{resolution := approved, valid_until := Timestamp}}) ->
+    to_swag(timestamp, Timestamp);
+to_swag(idenification_expiration, _) ->
+    undefined;
+to_swag(identity_challenge_failure_reason, {completed, #{resolution := denied}}) ->
+    <<"Denied">>;
+to_swag(identity_challenge_failure_reason, {failed, Reason}) ->
+    genlib:to_binary(Reason);
+to_swag(identity_challenge_failure_reason, _) ->
+    undefined;
+to_swag(destination, #{destination := Destination, times := {CreatedAt, _}, ctx := Ctx}) ->
+    {ok, WapiCtx} = ff_ctx:get(?NS, Ctx),
+    Wallet  = ff_destination:wallet(Destination),
+    to_swag(map, #{
+        <<"id">>         => ff_destination:id(Destination),
+        <<"name">>       => maps:get(<<"name">>, WapiCtx),
+        <<"metadata">>   => maps:get(<<"md">>, WapiCtx, undefined),
+        <<"createdAt">>  => to_swag(timestamp, CreatedAt),
+        %% TODO
+        <<"isBlocked">>  => to_swag(is_blocked, {ok, accessible}), %% ff_destination:is_accessible(Destination)),
+        <<"identity">>   => ff_identity:id(ff_wallet:identity(Wallet)),
+        <<"currency">>   => to_swag(currency, ff_wallet:currency(Wallet)),
+        <<"resource">>   => to_swag(destination_resource, ff_destination:resource(Destination)),
+        <<"status">>     => to_swag(destination_status, ff_destination:status(Destination)),
+        <<"validUntil">> => to_swag(destination_expiration, Destination)
+    });
+to_swag(destination_status, authorized) ->
+    <<"Authorized">>;
+to_swag(destination_status, unauthorized) ->
+    <<"Unauthorized">>;
+to_swag(destination_expiration, #{status := authorized, timeout := Timeout}) ->
+    Timeout;
+to_swag(destination_expiration, _) ->
+    undefined;
+to_swag(destination_resource, {bank_card, BankCard}) ->
+    to_swag(map, #{
+        <<"type">>          => <<"BankCardDestinationResource">>,
+        <<"token">>         => maps:get(token, BankCard),
+        <<"paymentSystem">> => genlib_map:get(payment_system, BankCard),
+        <<"bin">>           => genlib_map:get(bin, BankCard),
+        <<"lastDigits">>    => to_swag(pan_last_digits, genlib_map:get(masked_pan, BankCard))
+    });
+to_swag(pan_last_digits, MaskedPan) ->
+    wapi_utils:get_last_pan_digits(MaskedPan);
+to_swag(withdrawal, St = #{withdrawal := W, times := {CreatedAt, _}, ctx := Ctx}) ->
+    {ok, WapiCtx} = ff_ctx:get(?NS, Ctx),
+    Status = genlib_map:get(status, St),
+    to_swag(map, #{
+        <<"id">>          => ff_withdrawal:id(W),
+        <<"createdAt">>   => to_swag(timestamp, CreatedAt),
+        <<"metadata">>    => maps:get(<<"md">>, WapiCtx, undefined),
+        <<"wallet">>      => ff_wallet:id(ff_withdrawal:source(W)),
+        <<"destination">> => ff_destination:id(ff_withdrawal:destination(W)),
+        <<"body">>        => to_swag(withdrawal_body, ff_withdrawal:body(W)),
+        <<"status">>      => to_swag(withdrawal_status, Status),
+        <<"failure">>     => to_swag(withdrawal_failure, Status)
+    });
+to_swag(withdrawal_body, Body) ->
+    to_swag(map, #{
+        <<"amount">>   => maps:get(amount, Body),
+        <<"currency">> => to_swag(currency, maps:get(currency, Body))
+    });
+to_swag(withdrawal_status, succeeded) ->
+    <<"Succeeded">>;
+to_swag(withdrawal_status, failed) ->
+    <<"Failed">>;
+to_swag(withdrawal_status, {failed, _Reason}) ->
+    <<"Failed">>;
+to_swag(withdrawal_status, _) ->
+    <<"Pending">>;
+to_swag(withdrawal_failure, {failed, Reason}) ->
+    genlib:to_binary(Reason);
+to_swag(withdrawal_failure, _) ->
+    undefined;
+to_swag(withdrawal_events, Events) ->
+    to_swag(list, {withdrawal_event, Events});
+to_swag(withdrawal_event, {EventId, Ts, Status}) ->
+    to_swag(map, #{
+        <<"eventID">>   => EventId,
+        <<"occuredAt">> => to_swag(timestamp, Ts),
+        <<"changes">> => [#{
+            <<"type">>    => <<"WithdrawalStatusChanged">>,
+            <<"status">>  => to_swag(withdrawal_status, Status),
+            <<"failure">> => to_swag(withdrawal_failure, Status)
+        }]
+    });
+to_swag(timestamp, {{Date, Time}, Usec}) ->
+    rfc3339:format({Date, Time, Usec, undefined});
+to_swag(currency, Currency) ->
+    genlib_string:to_upper(genlib:to_binary(Currency));
+to_swag(is_blocked, {ok, accessible}) ->
+    false;
+to_swag(is_blocked, _) ->
+    true;
+to_swag(_Type, V) when is_map(V) ->
+    to_swag(map, V);
+to_swag(list, {Type, List}) ->
+    lists:map(fun(V) -> to_swag(Type, V) end, List);
+to_swag(map, Map) ->
+    genlib_map:compact(Map).
diff --git a/apps/wapi/src/wapi_wallet_handler.erl b/apps/wapi/src/wapi_wallet_handler.erl
new file mode 100644
index 0000000..6f24720
--- /dev/null
+++ b/apps/wapi/src/wapi_wallet_handler.erl
@@ -0,0 +1,267 @@
+-module(wapi_wallet_handler).
+
+-behaviour(swag_server_wallet_logic_handler).
+-behaviour(wapi_handler).
+
+%% swag_server_wallet_logic_handler callbacks
+-export([authorize_api_key/3]).
+-export([handle_request/4]).
+
+%% wapi_handler callbacks
+-export([process_request/4]).
+
+%% Types
+
+-type req_data()        :: wapi_handler:req_data().
+-type handler_context() :: wapi_handler:handler_context().
+-type request_result()  :: wapi_handler:request_result().
+-type operation_id()    :: swag_server_wallet:operation_id().
+-type api_key()         :: swag_server_wallet:api_key().
+-type request_context() :: swag_server_wallet:request_context().
+-type handler_opts()    :: swag_server_wallet:handler_opts().
+
+%% API
+
+-spec authorize_api_key(operation_id(), api_key(), handler_opts()) ->
+    false | {true, wapi_auth:context()}.
+authorize_api_key(OperationID, ApiKey, Opts) ->
+    ok = scoper:add_meta(#{api => wallet, operation_id => OperationID}),
+    wapi_auth:authorize_api_key(OperationID, ApiKey, Opts).
+
+-spec handle_request(swag_server_wallet:operation_id(), req_data(), request_context(), handler_opts()) ->
+    request_result().
+handle_request(OperationID, Req, SwagContext, Opts) ->
+    wapi_handler:handle_request(OperationID, Req, SwagContext, ?MODULE, Opts).
+
+
+%% Providers
+-spec process_request(operation_id(), req_data(), handler_context(), handler_opts()) ->
+    request_result().
+process_request('ListProviders', Req, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_providers(maps:with(['residence'], Req), Context) of
+        {ok, Providers}   -> wapi_handler_utils:reply_ok(200, Providers);
+        {error, notfound} -> wapi_handler_utils:reply_ok(200, [])
+    end;
+process_request('GetProvider', #{'providerID' := Id}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_provider(Id, Context) of
+        {ok, Provider}    -> wapi_handler_utils:reply_ok(200, Provider);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('ListProviderIdentityClasses', #{'providerID' := Id}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_provider_identity_classes(Id, Context) of
+        {ok, Classes}     -> wapi_handler_utils:reply_ok(200, Classes);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('GetProviderIdentityClass', #{
+    'providerID'      := ProviderId,
+    'identityClassID' := ClassId
+}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_provider_identity_class(ProviderId, ClassId, Context) of
+        {ok, Class}       -> wapi_handler_utils:reply_ok(200, Class);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('ListProviderIdentityLevels', #{
+    'providerID'      := ProviderId,
+    'identityClassID' := ClassId
+}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_provider_identity_class_levels(ProviderId, ClassId, Context) of
+        {ok, Levels}      -> wapi_handler_utils:reply_ok(200, Levels);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('GetProviderIdentityLevel', #{
+    'providerID'      := ProviderId,
+    'identityClassID' := ClassId,
+    'identityLevelID' := LevelId
+}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_provider_identity_class_level(ProviderId, ClassId, LevelId, Context) of
+        {ok, Level}       -> wapi_handler_utils:reply_ok(200, Level);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+
+%% Identities
+process_request('ListIdentities', Req, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_identities(maps:with(['provider', 'class', 'level'], Req), Context) of
+        {ok, Identities}  -> wapi_handler_utils:reply_ok(200, Identities);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('GetIdentity', #{'identityID' := IdentityId}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_identity(IdentityId, Context) of
+        {ok, Identity}    -> wapi_handler_utils:reply_ok(200, Identity);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request(O = 'CreateIdentity', #{'Identity' := Params}, C = #{woody_context := Context}, Opts) ->
+    case wapi_wallet_ff_backend:create_identity(Params#{<<"party">> => wapi_handler_utils:get_party_id(C)}, Context) of
+        {ok, Identity = #{<<"id">> := IdentityId}} ->
+            wapi_handler_utils:reply_ok(201, Identity, get_location(O, [IdentityId], Opts));
+        {error, {provider, notfound}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such provider">>));
+        {error, {identity_class, notfound}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such identity class">>))
+    end;
+process_request('ListIdentityChallenges', Req, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_identity_challengies(maps:with(['status', 'identityID'], Req), Context) of
+        {ok, Challengies}  -> wapi_handler_utils:reply_ok(200, Challengies);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request(O = 'StartIdentityChallenge', #{
+    'identityID'        := IdentityId,
+    'IdentityChallenge' := Params
+}, #{woody_context := Context}, Opts) ->
+    case wapi_wallet_ff_backend:create_identity_challenge(IdentityId, Params, Context) of
+        {ok, Challenge = #{<<"id">> := ChallengeId}} ->
+            wapi_handler_utils:reply_ok(202, Challenge, get_location(O, [ChallengeId], Opts));
+        {error, {identity, notfound}} ->
+            wapi_handler_utils:reply_ok(404);
+        {error, {challenge, {pending, _}}} ->
+            wapi_handler_utils:reply_ok(409);
+        {error, {challenge, {class, notfound}}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such identity class">>));
+        {error, {challenge, {proof, notfound}}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"Proof not found">>));
+        {error, {challenge, {proof, insufficient}}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"Insufficient proof">>))
+        %% TODO any other possible errors here?
+    end;
+process_request('GetIdentityChallenge', #{
+    'identityID'  := IdentityId,
+    'challengeID' := ChallengeId
+}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_identity_challenge(IdentityId, ChallengeId, Context) of
+        {ok, Challenge}   -> wapi_handler_utils:reply_ok(200, Challenge);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('CancelIdentityChallenge', #{
+    'identityID'  := IdentityId,
+    'challengeID' := ChallengeId
+}, #{woody_context := Context}, _Opts) ->
+    wapi_wallet_ff_backend:cancel_identity_challenge(IdentityId, ChallengeId, Context);
+process_request('PollIdentityChallengeEvents', Params, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_identity_challenge_events(Params, Context) of
+        {ok, Events}      -> wapi_handler_utils:reply_ok(200, Events);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('GetIdentityChallengeEvent', #{
+    'identityID'  := IdentityId,
+    'challengeID' := ChallengeId,
+    'eventID'      := EventId
+}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_identity_challenge_event(IdentityId, ChallengeId, EventId, Context) of
+        {ok, Event}      -> wapi_handler_utils:reply_ok(200, Event);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404);
+        {error, {event, notfound}} -> wapi_handler_utils:reply_ok(404)
+    end;
+
+%% Wallets
+process_request(O, _Req, _Context, _Opts) when
+    O =:= 'ListWallets'      orelse
+    O =:= 'CreateWallet'     orelse
+    O =:= 'GetWallet'        orelse
+    O =:= 'GetWalletAccount' orelse
+    O =:= 'IssueWalletGrant'
+->
+    wapi_wallet_ff_backend:not_implemented();
+
+%% Deposits
+process_request(O, _Req, _Context, _Opts) when
+    O =:= 'CreateDeposit'     orelse
+    O =:= 'GetDeposit'        orelse
+    O =:= 'PollDepositEvents' orelse
+    O =:= 'GetDepositEvents'
+->
+    wapi_wallet_ff_backend:not_implemented();
+
+%% Withdrawals
+process_request('ListDestinations', Req, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_destinations(maps:with(['identity', 'currency'], Req), Context) of
+        {ok, Destinations} -> wapi_handler_utils:reply_ok(200, Destinations);
+        {error, notfound}  -> wapi_handler_utils:reply_ok(200, [])
+    end;
+process_request('GetDestination', #{'destinationID' := DestinationId}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_destination(DestinationId, Context) of
+        {ok, Destination} -> wapi_handler_utils:reply_ok(200, Destination);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request(O = 'CreateDestination', #{'Destination' := Params}, C = #{woody_context := Context}, Opts) ->
+    case wapi_wallet_ff_backend:create_destination(Params#{party => wapi_handler_utils:get_party_id(C)}, Context) of
+        {ok, Destination = #{<<"id">> := DestinationId}} ->
+            wapi_handler_utils:reply_ok(201, Destination, get_location(O, [DestinationId], Opts));
+        {error, {identity, notfound}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such identity">>));
+        {error, {currency, notfound}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"Currency not supported">>))
+    end;
+process_request('IssueDestinationGrant', #{
+    'destinationID'           := DestinationId,
+    'DestinationGrantRequest' := #{<<"validUntil">> := Expiration}
+}, #{woody_context := Context}, _Opts) ->
+    ExpirationUTC = wapi_utils:to_universal_time(Expiration),
+    ok = check_expiration(ExpirationUTC),
+    case wapi_wallet_ff_backend:get_destination(DestinationId, Context) of
+        {ok, _Destination} ->
+            {ok, {Date, Time, Usec, _Tz}} = rfc3339:parse(ExpirationUTC),
+            wapi_handler_utils:reply_ok(201, #{
+                <<"token">> => wapi_auth:issue_access_token(
+                    wapi_handler_utils:get_party_id(Context),
+                    {destinations, DestinationId},
+                    {deadline, {{Date, Time}, Usec}}
+                ),
+                <<"validUntil">> => Expiration
+            });
+        {error, notfound} ->
+            wapi_handler_utils:reply_ok(404)
+    end;
+process_request(O = 'CreateWithdrawal', #{'WithdrawalParameters' := Params}, #{woody_context := Context}, Opts) ->
+    %% TODO: check authorization crap here (or on the backend)
+    case wapi_wallet_ff_backend:create_withdrawal(Params, Context) of
+        {ok, Withdrawal = #{<<"id">> := WithdrawalId}} ->
+            wapi_handler_utils:reply_ok(201, Withdrawal, get_location(O, [WithdrawalId], Opts));
+        {error, {source, notfound}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such wallet">>));
+        {error, {destination, notfound}} ->
+            wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such wallet">>))
+    end;
+process_request('GetWithdrawal', #{'withdrawalID' := WithdrawalId}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_withdrawal(WithdrawalId, Context) of
+        {ok, Withdrawal}  -> wapi_handler_utils:reply_ok(200, Withdrawal);
+        {error, notfound} -> wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such withdrawal">>))
+    end;
+process_request('PollWithdrawalEvents', Params, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_withdrawal_events(Params, Context) of
+        {ok, Events}      -> wapi_handler_utils:reply_ok(200, Events);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404)
+    end;
+process_request('GetWithdrawalEvents', #{
+    'withdrawalID' := WithdrawalId,
+    'eventID'      := EventId
+}, #{woody_context := Context}, _Opts) ->
+    case wapi_wallet_ff_backend:get_withdrawal_event(WithdrawalId, EventId, Context) of
+        {ok, Event}      -> wapi_handler_utils:reply_ok(200, Event);
+        {error, notfound} -> wapi_handler_utils:reply_ok(404);
+        {error, {event, notfound}} -> wapi_handler_utils:reply_ok(404)
+    end;
+
+%% Residences
+process_request('GetResidence', _Req, _Context, _Opts) ->
+    wapi_wallet_ff_backend:not_implemented();
+
+%% Currencies
+process_request('GetCurrency', _Req, _Context, _Opts) ->
+    wapi_wallet_ff_backend:not_implemented().
+
+%% Internal functions
+
+get_location(OperationId, Params, Opts) ->
+    #{path := PathSpec} = swag_server_wallet_router:get_operation(OperationId),
+    wapi_handler_utils:get_location(PathSpec, Params, Opts).
+
+check_expiration(Expiration) ->
+    {ok, ExpirationSec} = rfc3339:to_time(Expiration, second),
+    case (genlib_time:unow() -  ExpirationSec) >= 0 of
+        true ->
+            wapi_handler:throw_result(wapi_handler_utils:reply_ok(
+                422,
+                wapi_handler_utils:get_error_msg(<<"Already expired">>)
+            ));
+        false ->
+            ok
+    end.
diff --git a/apps/wapi/src/wapi_wallet_mock_backend.erl b/apps/wapi/src/wapi_wallet_mock_backend.erl
new file mode 100644
index 0000000..cf5730a
--- /dev/null
+++ b/apps/wapi/src/wapi_wallet_mock_backend.erl
@@ -0,0 +1,227 @@
+%% Temporary stab for wallet handler
+
+-module(wapi_wallet_mock_backend).
+
+-include_lib("dmsl/include/dmsl_domain_thrift.hrl").
+
+-export([get_providers/2]).
+-export([get_provider/2]).
+-export([get_provider_identity_classes/2]).
+-export([get_provider_identity_class/3]).
+-export([get_provider_identity_class_levels/3]).
+-export([get_provider_identity_class_level/4]).
+
+-export([get_identities/2]).
+-export([get_identity/2]).
+-export([create_identity/2]).
+
+-export([get_destinations/2]).
+-export([get_destination/2]).
+-export([create_destination/2]).
+-export([create_withdrawal/2]).
+-export([get_withdrawal/2]).
+-export([get_withdrawal_events/2]).
+-export([get_withdrawal_event/3]).
+
+%% API
+
+-spec get_providers(_, _) -> _.
+get_providers(_Params, _Context) ->
+    {ok, [#{
+        <<"id">>       => <<"1">>,
+        <<"name">>     => <<"НКО «ЭПС»">>,
+        <<"residences">> => [<<"RUS">>]
+    }]}.
+
+-spec get_provider(_, _) -> _.
+get_provider(_Id, _Context) ->
+    {ok, #{
+        <<"id">>       => <<"1">>,
+        <<"name">>     => <<"НКО «ЭПС»">>,
+        <<"residences">> => [<<"RUS">>]
+    }}.
+
+-spec get_provider_identity_classes(_, _) -> _.
+get_provider_identity_classes(_Id, _Context) ->
+    {ok, [#{
+        <<"id">>   => <<"person">>,
+        <<"name">> => <<"Частная харя">>
+    }]}.
+
+-spec get_provider_identity_class(_, _, _) -> _.
+get_provider_identity_class(_ProviderId, _ClassId, _Context) ->
+    {ok, #{id => <<"person">>, name => <<"Частная харя">>}}.
+
+-spec get_provider_identity_class_levels(_, _, _) -> _.
+get_provider_identity_class_levels(_ProviderId, _ClassId, _Context) ->
+    {ok, [
+        #{
+            <<"id">>   => <<"partial">>,
+            <<"name">> => <<"Частично идентифицирован(а/о)">>,
+            <<"challenges">> => #{
+                <<"id">>   => <<"esia">>,
+                <<"name">> => <<"Упрощённая идентификация">>,
+                <<"requiredProofs">> => [
+                    <<"RUSDomesticPassport">>,
+                    <<"RUSRetireeInsuranceCertificate">>
+                ]
+            }
+        },
+        #{
+            <<"id">>   => <<"full">>,
+            <<"name">> => <<"Полностью идентифицирован(а/о)">>,
+            <<"challenges">> => #{
+                <<"id">>   => <<"svyaznoi bpa">>,
+                <<"name">> => <<"Полная идентификацияв Связном">>,
+                <<"requiredProofs">> => [
+                    <<"RUSDomesticPassport">>,
+                    <<"RUSRetireeInsuranceCertificate">>
+                ]
+            }
+        }
+    ]}.
+
+-spec get_provider_identity_class_level(_, _, _, _) -> _.
+get_provider_identity_class_level(_ProviderId, _ClassId, _LevelId, _Context) ->
+    {ok, #{
+        <<"id">>   => <<"partial">>,
+        <<"name">> => <<"Частично идентифицирован(а/о)">>,
+        <<"challenges">> => #{
+            <<"id">>   => <<"esia">>,
+            <<"name">> => <<"Упрощённая идентификация">>,
+            <<"requiredProofs">> => [
+                <<"RUSDomesticPassport">>,
+                <<"RUSRetireeInsuranceCertificate">>
+            ]
+        }
+    }}.
+
+-spec get_identities(_, _) -> _.
+get_identities(_Params, _Context) ->
+    {ok, [#{
+        <<"id">>                 => <<"douknowdawae">>,
+        <<"name">>               => <<"Keyn Fawkes aka Slug">>,
+        <<"metadata">>           => #{<<"is real">> => false},
+        <<"createdAt">>          => {{{1989, 01, 17}, {12, 01, 45}}, 0},
+        <<"provider">>           => <<"1">>,
+        <<"class">>              => <<"person">>,
+        <<"level">>              => <<"partial">>,
+        <<"effectiveChallenge">> => <<"25">>,
+        <<"isBlocked">>          => false
+    }]}.
+
+-spec get_identity(_, _) -> _.
+get_identity(IdentityId, _Context) ->
+    {ok, #{
+        <<"id">>                 => IdentityId,
+        <<"name">>               => <<"Keyn Fawkes aka Slug">>,
+        <<"metadata">>           => #{<<"is real">> => false},
+        <<"createdAt">>          => {{{1989, 01, 17}, {12, 01, 45}}, 0},
+        <<"provider">>           => <<"1">>,
+        <<"class">>              => <<"person">>,
+        <<"level">>              => <<"partial">>,
+        <<"effectiveChallenge">> => <<"25">>,
+        <<"isBlocked">>          => false
+    }}.
+
+-spec create_identity(_, _) -> _.
+create_identity(_Params, Context) ->
+    get_identity(woody_context:new_req_id(), Context).
+
+-spec get_destinations(_, _) -> _.
+get_destinations(_Params, _Context) ->
+    {ok, [#{
+        <<"id">>         => <<"107498">>,
+        <<"name">>       => <<"Squarey plastic thingy">>,
+        <<"metadata">>   => #{<<"display_name">> => <<"Картофан СБЕР">>},
+        <<"createdAt">>  => <<"2018-06-20T08:56:02Z">>,
+        <<"isBlocked">>  => false,
+        <<"identity">>   => <<"douknowdawae">>,
+        <<"currency">>   => <<"RUB">>,
+        <<"resource">>   => get_destination_resource(what, ever),
+        <<"status">>     => <<"Authorized">>,
+        <<"validUntil">> => <<"2018-06-20T08:56:02Z">>
+    }]}.
+
+-spec get_destination(_, _) -> _.
+get_destination(_DestinationId, _Context) ->
+    {ok, #{
+        <<"id">>         => <<"107498">>,
+        <<"name">>       => <<"Squarey plastic thingy">>,
+        <<"metadata">>   => #{<<"display_name">> => <<"Картофан СБЕР">>},
+        <<"createdAt">>  => <<"2018-06-20T08:56:02Z">>,
+        <<"isBlocked">>  => false,
+        <<"identity">>   => <<"douknowdawae">>,
+        <<"currency">>   => <<"RUB">>,
+        <<"resource">>   => get_destination_resource(what, ever),
+        <<"status">>     => <<"Authorized">>,
+        <<"validUntil">> => <<"2018-06-20T08:56:02Z">>
+    }}.
+
+-spec create_destination(_, _) -> _.
+create_destination(_Params, Context) ->
+    get_destination(woody_context:new_req_id(), Context).
+
+-spec get_withdrawal(_, _) -> _.
+get_withdrawal(WithdrawalId, _Context) ->
+    {ok, #{
+        <<"id">>          => WithdrawalId,
+        <<"createdAt">>   => {{{2018, 06, 17}, {12, 01, 45}}, 0},
+        <<"wallet">>      => woody_context:new_req_id(),
+        <<"destination">> => woody_context:new_req_id(),
+        <<"body">> => #{
+            <<"amount">> => 1430000,
+            <<"currency">> => <<"RUB">>
+        },
+        <<"status">>   => <<"Pending">>,
+        <<"metadata">> => #{<<"who'sthedaddy">> => <<"me">>}
+    }}.
+
+-spec create_withdrawal(_, _) -> _.
+create_withdrawal(_Params, Context) ->
+    get_withdrawal(woody_context:new_req_id(), Context).
+
+-spec get_withdrawal_events(_, _) -> _.
+get_withdrawal_events(_, _) ->
+    [#{
+        <<"eventID">> => 1,
+        <<"occuredAt">> => "2018-06-28T12:49:12Z",
+        <<"changes">> => [#{
+            <<"type">> => <<"WithdrawalStatusChanged">>,
+            <<"status">> => <<"Pending">>
+        }]
+    },
+    #{
+        <<"eventID">> => 5,
+        <<"occuredAt">> => "2018-06-28T12:49:13Z",
+        <<"changes">> => [#{
+            <<"type">> => <<"WithdrawalStatusChanged">>,
+            <<"status">> => <<"Failed">>,
+            <<"failure">> => <<"tolkonepiu is not a function">>
+        }]
+    }].
+
+-spec get_withdrawal_event(_, _, _) -> _.
+get_withdrawal_event(_WithdrawalId, EventId, _) ->
+    #{
+        <<"eventID">> => EventId,
+        <<"occuredAt">> => "2018-07-24T04:37:45Z",
+        <<"changes">> => [#{
+            <<"type">> => <<"WithdrawalStatusChanged">>,
+            <<"status">> => <<"Succeeded">>
+        }]
+    }.
+
+%% Internals
+
+get_destination_resource(_, _) ->
+    #{
+       <<"type">>          => <<"BankCardDestinationResource">>,
+       <<"bin">>           => <<"424242">>,
+       <<"lastDigits">>    => <<"4242">>,
+       <<"paymentSystem">> => <<"visa">>,
+       <<"token">>         => <<
+           "eyJiaW4iOiI0MjQyNDIiLCJsYXN0RGlnaXRzIjoiNDI0MiIsInBheW1lbnRTeXN0ZW"
+           "0iOiJ2aXNhIiwidG9rZW4iOiI3NXlQSkZac1lCOEFvdEFUS0dFa3p6In0"
+       >>
+    }.
diff --git a/apps/wapi/src/wapi_woody_client.erl b/apps/wapi/src/wapi_woody_client.erl
new file mode 100644
index 0000000..7ee166d
--- /dev/null
+++ b/apps/wapi/src/wapi_woody_client.erl
@@ -0,0 +1,41 @@
+-module(wapi_woody_client).
+
+-export([call_service/4]).
+-export([call_service/5]).
+
+-export([get_service_modname/1]).
+
+%%
+-define(APP, wapi).
+
+-type service_name() :: atom().
+
+-spec call_service(service_name(), woody:func(), [term()], woody_context:ctx()) ->
+    woody:result().
+
+call_service(ServiceName, Function, Args, Context) ->
+    call_service(ServiceName, Function, Args, Context, scoper_woody_event_handler).
+
+-spec call_service(service_name(), woody:func(), [term()], woody_context:ctx(), woody:ev_handler()) ->
+    woody:result().
+
+call_service(ServiceName, Function, Args, Context, EventHandler) ->
+    {Url, Service} = get_service_spec(ServiceName),
+    Request = {Service, Function, Args},
+    woody_client:call(Request, #{url => Url, event_handler => EventHandler}, Context).
+
+get_service_spec(ServiceName) ->
+    {get_service_url(ServiceName), get_service_modname(ServiceName)}.
+
+get_service_url(ServiceName) ->
+    maps:get(ServiceName, genlib_app:env(?APP, service_urls)).
+
+-spec get_service_modname(service_name()) -> woody:service().
+
+get_service_modname(cds_storage) ->
+    {dmsl_cds_thrift, 'Storage'};
+get_service_modname(identdoc_storage) ->
+    {identdocstore_identity_document_storage_thrift, 'IdentityDocumentStorage'}.
+
+%% get_service_modname(webhook_manager) ->
+%%     {dmsl_webhooker_thrift, 'WebhookManager'}.
diff --git a/apps/wapi/var/keys/wapi/private.pem b/apps/wapi/var/keys/wapi/private.pem
new file mode 100644
index 0000000..670e82a
--- /dev/null
+++ b/apps/wapi/var/keys/wapi/private.pem
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA4MUtYkvoIAHNgvYtHSydanyY1qD8nJ+D/A1FFp5LF4SmM9nn
+vSfTFC2T3D53sCR/DtUzCFIQwZIXXHob22ndFydZqhahrYLLJkpH5IXMy593Sho/
+oXzxgwkbXaOMevcLFZcj5AneG+q2vFjaDGeQAJaAAPGinMo6UN94DYguNH2s6zqo
+yRc8ng6KWD5UgEFTIEWni1RIZvp2NAnSkh/SeI1zs9uY6AR7bf6oFSChTd9m+li5
+d20L5tc0aX7LG842SJEM2dJKckI4ZDZHvU6nDitH3TGrxkMa0CqLe7nUOfvSff2c
+H9m0CzSbPy/SnyTQLklWoFsi9z2cqqtY6SvR7QIDAQABAoIBADAoz1KSZQgGmtwG
+lx/7ITdhvvWtxLJiU0s8JKN2Ayzk1R+i/s4+rDFUmqvEDq0FBNxOvgJ4YvK2tJ6x
+4yoeAqslWUbiVn3w2ko3/DNwn7K5VjvgZ+XX+X9UAjMMCduG9y8HFT+VBawBnGm6
+t+2UevxFQuPw4iCqC9isKPLtTMkeBXfaCA+tzBqVytlBeW5nJG1Bh9GSV6OeeNoc
+x6lh1X+7kxk/qLQZsogNwZXxPLuIK0qJCfsGzMYodSi43nv2mFtl5vBt0M+iU42i
+KrL32SlQmkBI4st/HIie9YpSjj55llOU6L0KBPhH58wc8LDEc2Kwcxeow4/McO0E
+fSwf9pkCgYEA+4v+371szXbUTfOBOBO7+bGbTo0gzJ8JnMaSdVDLhBaqyp5dbztS
+TPiaCqfEYk4AYnl2dR7nLYRca/WRDle7hvDqB7K2RWWS58RDifiQ4gfJM9lW4Ocu
+SIhnxVmr4iVdo4eOs6pxe8yRtF1U+uK8WuoV06+lgL/esEJB2JPyeQsCgYEA5L/U
+osQFOogSk1Ycjl66UEXm0Y2HzFONTKMSellUnkdSSscx6+mLOn7eL5voSNSJrnCw
+Tfh3uZ0NOh63Yw3aPGCwtn+EIflW1hzx+DJMvCS5TaU3BZF954rljklJL6VpaIPP
+fXrc0z1FcsAT2s3aQNmEK2SWp7Y44V6mpQn7a+cCgYEA0Tf+dD+MOFRmfrNSvb6E
+MUkMwMfXCPoaN6BdfmAF9cYYpdAULIjtigGXtdcWGyF/ZmhaI03hv9UAPfcQgBpu
+ae0E6gQ1YAD8r/Jorl/kuWr6aTqS7Rq7Py7dCKLtuHmVqYb9JOhV3T8nzRl3rfhZ
+61AZeWj1QeHUKUvikm1zVkMCgYEAyan42xn3BhgKUEw9VqJanQRTLnEYxGDwlBy7
+4JM6j2OPQA+GilXVgddxKAXJ7dM6IkiElei0HDZB//gucqw2tr4DbJDUu2LnVFIm
+XEpz7fZuSu6ZqFYQ6n1ATYV8eP3aBOMXnKchYTWGMVj26BJNFJju9ZZzXx293aol
+PiCjwAcCgYAmOtRZRtf/p1eXPz1JN1OwEVSrnghJP5KBA8XGsnBmQUTeMmHo3Wl7
+rELKg0O2bsPtTTAvm5bfLsRgvee+EY28mAY6MA8xJNHB6OabOHuRHqX7ow/LOagK
+15mUtZ9f8AaKamZ3Bmg/XWWJxNmeCt5LJDr1OnmCDyItbfF9DxnXXg==
+-----END RSA PRIVATE KEY-----
diff --git a/config/sys.config b/config/sys.config
index 4f63aaf..a1e9eb2 100644
--- a/config/sys.config
+++ b/config/sys.config
@@ -62,8 +62,33 @@
             }
         }},
         {services, #{
-            'partymgmt' => "http://hellgate:8022/v1/processing/partymgmt",
-            'accounter' => "http://shumway:8022/accounter"
+            'partymgmt'      => "http://hellgate:8022/v1/processing/partymgmt",
+            'accounter'      => "http://shumway:8022/accounter",
+            'identification' => "http://identification:8022/v1/identification"
+        }}
+    ]},
+
+    %% wapi
+    {wapi, [
+        {ip, "::"},
+        {port, 8080},
+        %% To send ASCII text in 5xx replies
+        %% {oops_bodies, #{
+        %%     500 => "oops_bodies/500_body"
+        %% }},
+        {realm, <<"external">>},
+        {authorizers, #{
+            jwt => #{
+                signee => wapi,
+                keyset => #{
+                    wapi     => {pem_file, "var/keys/wapi/private.pem"}
+                }
+            }
+        }},
+        {service_urls, #{
+            webhook_manager     => "http://hooker:8022/hook",
+            cds_storage         => "http://cds:8022/v1/storage",
+            identdoc_storage    => "http://cds:8022/v1/identity_document_storage"
         }}
     ]},
 
@@ -78,7 +103,16 @@
         }},
         {services, #{
             'automaton' => "http://machinegun:8022/v1/automaton"
-        }}
+        }},
+        {net_opts, [
+            % Bump keepalive timeout up to a minute
+            {timeout, 60000}
+        ]},
+        {health_checkers, [
+            {erl_health, disk     , ["/", 99]   },
+            {erl_health, cg_memory, [99]        },
+            {erl_health, service  , [<<"wapi">>]}
+        ]}
     ]}
 
 ].
diff --git a/rebar.config b/rebar.config
index 24cbddc..f43c34a 100644
--- a/rebar.config
+++ b/rebar.config
@@ -116,7 +116,13 @@
             {vm_args               , "./config/vm.args"},
             {dev_mode              , false},
             {include_erts          , true},
-            {extended_start_script , true}
+            {extended_start_script , true},
+            %% wapi
+            {overlay, [
+                {mkdir , "var/keys/wapi"                                              },
+                {copy  , "apps/wapi/var/keys/wapi/private.pem", "var/keys/wapi/private.pem" }
+            ]}
+
         ]}
 
     ]},
diff --git a/rebar.lock b/rebar.lock
index 709c50d..812b5a4 100644
--- a/rebar.lock
+++ b/rebar.lock
@@ -1,7 +1,20 @@
 {"1.1.0",
-[{<<"certifi">>,{pkg,<<"certifi">>,<<"0.7.0">>},2},
- {<<"cowboy">>,{pkg,<<"cowboy">>,<<"1.0.4">>},1},
- {<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.0.2">>},2},
+[{<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},0},
+ {<<"certifi">>,{pkg,<<"certifi">>,<<"0.7.0">>},1},
+ {<<"cg_mon">>,
+  {git,"https://github.com/rbkmoney/cg_mon.git",
+       {ref,"5a87a37694e42b6592d3b4164ae54e0e87e24e18"}},
+  1},
+ {<<"cowboy">>,{pkg,<<"cowboy">>,<<"1.0.4">>},0},
+ {<<"cowboy_access_log">>,
+  {git,"git@github.com:rbkmoney/cowboy_access_log.git",
+       {ref,"99df4e8069d3d581a55d287e6bc0cb575c67d5ec"}},
+  0},
+ {<<"cowboy_cors">>,
+  {git,"https://github.com/danielwhite/cowboy_cors.git",
+       {ref,"392f5804b63fff2bd0fda67671d5b2fbe0badd37"}},
+  0},
+ {<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.0.2">>},1},
  {<<"dmsl">>,
   {git,"git@github.com:rbkmoney/damsel.git",
        {ref,"62d932bc3984301a6394aa8addade6f2892ac79f"}},
@@ -14,6 +27,10 @@
   {git,"git@github.com:rbkmoney/dmt_core.git",
        {ref,"045c78132ecce5a8ec4a2e6ccd2c6b0b65bade1f"}},
   1},
+ {<<"erl_health">>,
+  {git,"https://github.com/rbkmoney/erlang-health.git",
+       {ref,"ab3ca1ccab6e77905810aa270eb936dbe70e02f8"}},
+  0},
  {<<"erlang_localtime">>,
   {git,"https://github.com/kpy3/erlang_localtime",
        {ref,"c79fa7dd454343e7cbbdcce0c7a95ad86af1485d"}},
@@ -24,25 +41,43 @@
   0},
  {<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1},
  {<<"gproc">>,{pkg,<<"gproc">>,<<"0.6.1">>},0},
- {<<"hackney">>,{pkg,<<"hackney">>,<<"1.6.2">>},1},
+ {<<"hackney">>,{pkg,<<"hackney">>,<<"1.6.2">>},0},
  {<<"id_proto">>,
   {git,"git@github.com:rbkmoney/identification-proto.git",
        {ref,"2da2c4717afd7fd7ef7caba3ef9013a9eb557250"}},
   0},
- {<<"idna">>,{pkg,<<"idna">>,<<"1.2.0">>},2},
+ {<<"identdocstore_proto">>,
+  {git,"git@github.com:rbkmoney/identdocstore-proto.git",
+       {ref,"ccd301e37c128810c9f68d7a64dd8183af91b2bf"}},
+  0},
+ {<<"idna">>,{pkg,<<"idna">>,<<"1.2.0">>},1},
+ {<<"jesse">>,
+  {git,"https://github.com/rbkmoney/jesse.git",
+       {ref,"39105922d1ce5834383d8e8aa877c60319b9834a"}},
+  0},
+ {<<"jose">>,{pkg,<<"jose">>,<<"1.7.9">>},0},
+ {<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.2">>},0},
  {<<"lager">>,{pkg,<<"lager">>,<<"3.6.1">>},0},
  {<<"machinery">>,
   {git,"git@github.com:rbkmoney/machinery.git",
        {ref,"eb1beed9a287d8b6ab8c68b782b2143ef574c99d"}},
   0},
- {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2},
+ {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1},
  {<<"mg_proto">>,
   {git,"git@github.com:rbkmoney/machinegun_proto.git",
        {ref,"5c07c579014f9900357f7a72f9d10a03008b9da1"}},
   0},
- {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.0.2">>},2},
+ {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.0.2">>},1},
+ {<<"parse_trans">>,
+  {git,"https://github.com/rbkmoney/parse_trans.git",
+       {ref,"5ee45f5bfa6c04329bea3281977b774f04c89f11"}},
+  0},
+ {<<"payproc_errors">>,
+  {git,"git@github.com:rbkmoney/payproc-errors-erlang.git",
+       {ref,"9c720534eb88edc6ba47af084939efabceb9b2d6"}},
+  0},
  {<<"quickrand">>,{pkg,<<"quickrand">>,<<"1.7.3">>},1},
- {<<"ranch">>,{pkg,<<"ranch">>,<<"1.5.0">>},2},
+ {<<"ranch">>,{pkg,<<"ranch">>,<<"1.5.0">>},1},
  {<<"rfc3339">>,{pkg,<<"rfc3339">>,<<"0.2.2">>},0},
  {<<"scoper">>,
   {git,"git@github.com:rbkmoney/scoper.git",
@@ -52,7 +87,7 @@
   {git,"https://github.com/rbkmoney/snowflake.git",
        {ref,"0a598108f6582affe3b4ae550fc5b9f2062e318a"}},
   1},
- {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.1">>},2},
+ {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.1">>},1},
  {<<"thrift">>,
   {git,"https://github.com/rbkmoney/thrift_erlang.git",
        {ref,"240bbc842f6e9b90d01bd07838778cf48752b510"}},
@@ -68,6 +103,7 @@
   0}]}.
 [
 {pkg_hash,[
+ {<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
  {<<"certifi">>, <<"861A57F3808F7EB0C2D1802AFEAAE0FA5DE813B0DF0979153CBAFCD853ABABAF">>},
  {<<"cowboy">>, <<"A324A8DF9F2316C833A470D918AAF73AE894278B8AA6226CE7A9BF699388F878">>},
  {<<"cowlib">>, <<"9D769A1D062C9C3AC753096F868CA121E2730B9A377DE23DEC0F7E08B1DF84EE">>},
@@ -75,6 +111,8 @@
  {<<"gproc">>, <<"4579663E5677970758A05D8F65D13C3E9814EC707AD51D8DCEF7294EDA1A730C">>},
  {<<"hackney">>, <<"96A0A5E7E65B7ACAD8031D231965718CC70A9B4131A8B033B7543BBD673B8210">>},
  {<<"idna">>, <<"AC62EE99DA068F43C50DC69ACF700E03A62A348360126260E87F2B54ECED86B2">>},
+ {<<"jose">>, <<"9DC5A14AB62DB4E41677FCC97993752562FB57AD0B8BA062589682EDD3ACB91F">>},
+ {<<"jsx">>, <<"7ACC7D785B5ABE8A6E9ADBDE926A24E481F29956DD8B4DF49E3E4E7BCC92A018">>},
  {<<"lager">>, <<"9D29C5FF7F926D25ECD9899990867C9152DCF34EEE65BAC8EC0DFC0D16A26E0C">>},
  {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
  {<<"mimerl">>, <<"993F9B0E084083405ED8252B99460C4F0563E41729AB42D9074FD5E52439BE88">>},
diff --git a/schemes/swag b/schemes/swag
new file mode 160000
index 0000000..ab89264
--- /dev/null
+++ b/schemes/swag
@@ -0,0 +1 @@
+Subproject commit ab892644583d093e78137feb5a65a3fdbd170c7d