mirror of
https://github.com/valitydev/erlang_uac.git
synced 2024-11-06 01:35:23 +00:00
Universal auth control (#1)
This commit is contained in:
parent
0b8044f19a
commit
18c76d54ab
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# general
|
||||
log
|
||||
/_build/
|
||||
*~
|
||||
erl_crash.dump
|
||||
.tags*
|
||||
*.sublime-*
|
||||
|
||||
.DS_Store
|
||||
|
||||
# rebar
|
||||
/_checkouts/
|
||||
|
||||
*.beam
|
||||
|
||||
# generated
|
||||
apps/swag_server/*
|
||||
apps/swag_client/*
|
||||
|
||||
|
||||
# containerization
|
||||
\#*
|
||||
.\#*
|
||||
Dockerfile
|
||||
docker-compose.yml
|
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
[submodule "build_utils"]
|
||||
path = build_utils
|
||||
url = git@github.com:rbkmoney/build_utils.git
|
||||
branch = master
|
45
Jenkinsfile
vendored
Normal file
45
Jenkinsfile
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
#!groovy
|
||||
// -*- mode: groovy -*-
|
||||
|
||||
def finalHook = {
|
||||
runStage('store CT logs') {
|
||||
archive '_build/test/logs/'
|
||||
}
|
||||
}
|
||||
|
||||
build('erlang_uac', 'docker-host', finalHook) {
|
||||
checkoutRepo()
|
||||
loadBuildUtils()
|
||||
|
||||
def pipeDefault
|
||||
def withWsCache
|
||||
runStage('load pipeline') {
|
||||
env.JENKINS_LIB = "build_utils/jenkins_lib"
|
||||
pipeDefault = load("${env.JENKINS_LIB}/pipeDefault.groovy")
|
||||
withWsCache = load("${env.JENKINS_LIB}/withWsCache.groovy")
|
||||
}
|
||||
|
||||
pipeDefault() {
|
||||
runStage('compile') {
|
||||
withGithubPrivkey {
|
||||
sh 'make wc_compile'
|
||||
}
|
||||
}
|
||||
runStage('lint') {
|
||||
sh 'make wc_lint'
|
||||
}
|
||||
runStage('xref') {
|
||||
sh 'make wc_xref'
|
||||
}
|
||||
runStage('dialyze') {
|
||||
withWsCache("_build/default/rebar3_19.3_plt") {
|
||||
sh 'make wc_dialyze'
|
||||
}
|
||||
}
|
||||
runStage('test') {
|
||||
sh "make wc_test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
60
Makefile
Normal file
60
Makefile
Normal file
@ -0,0 +1,60 @@
|
||||
REBAR := $(shell which rebar3 2>/dev/null || which ./rebar3)
|
||||
SUBMODULES = build_utils
|
||||
SUBTARGETS = $(patsubst %,%/.git,$(SUBMODULES))
|
||||
|
||||
UTILS_PATH := build_utils
|
||||
TEMPLATES_PATH := .
|
||||
|
||||
# Name of the service
|
||||
SERVICE_NAME := erlang_uac
|
||||
|
||||
BUILD_IMAGE_TAG := 562313697353c29d4b34fb081a8b70e8c2207134
|
||||
|
||||
CALL_ANYWHERE := \
|
||||
submodules \
|
||||
all compile xref lint dialyze test cover \
|
||||
start clean distclean
|
||||
|
||||
CALL_W_CONTAINER := $(CALL_ANYWHERE)
|
||||
|
||||
.PHONY: $(CALL_W_CONTAINER) all
|
||||
|
||||
all: compile
|
||||
|
||||
-include $(UTILS_PATH)/make_lib/utils_container.mk
|
||||
|
||||
$(SUBTARGETS): %/.git: %
|
||||
git submodule update --init $<
|
||||
touch $@
|
||||
|
||||
submodules: $(SUBTARGETS)
|
||||
|
||||
compile:
|
||||
$(REBAR) compile
|
||||
|
||||
xref:
|
||||
$(REBAR) xref
|
||||
|
||||
lint:
|
||||
elvis rock
|
||||
|
||||
dialyze:
|
||||
$(REBAR) dialyzer
|
||||
|
||||
start: submodules
|
||||
$(REBAR) run
|
||||
|
||||
clean:
|
||||
$(REBAR) cover -r
|
||||
$(REBAR) clean
|
||||
|
||||
distclean:
|
||||
$(REBAR) clean
|
||||
rm -rf _build
|
||||
|
||||
cover:
|
||||
$(REBAR) cover
|
||||
|
||||
# CALL_W_CONTAINER
|
||||
test:
|
||||
$(REBAR) ct
|
@ -0,0 +1,3 @@
|
||||
# Erlang UAC
|
||||
|
||||
Вспомогательное приложение для авторизации и работы с jwt-токенами на capi/wapi
|
1
build_utils
Submodule
1
build_utils
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 39039dfc60249c8631b996cf60d4f8c4d64c30cd
|
57
elvis.config
Normal file
57
elvis.config
Normal file
@ -0,0 +1,57 @@
|
||||
[
|
||||
{elvis, [
|
||||
{config, [
|
||||
#{
|
||||
dirs => [
|
||||
"src",
|
||||
"test"
|
||||
],
|
||||
filter => "*.erl",
|
||||
ignore => ["_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 => [capi_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 => [
|
||||
capi_tests_SUITE
|
||||
]
|
||||
}},
|
||||
{elvis_style, no_debug_call, #{}}
|
||||
]
|
||||
},
|
||||
#{
|
||||
dirs => ["."],
|
||||
filter => "Makefile",
|
||||
ruleset => makefiles
|
||||
},
|
||||
#{
|
||||
dirs => ["."],
|
||||
filter => "elvis.config",
|
||||
ruleset => elvis_config
|
||||
},
|
||||
#{
|
||||
dirs => ["."],
|
||||
filter => "rebar.config",
|
||||
rules => [
|
||||
{elvis_style, line_length, #{limit => 120, skip_comments => false}},
|
||||
{elvis_style, no_tabs},
|
||||
{elvis_style, no_trailing_whitespace}
|
||||
]
|
||||
}
|
||||
]}
|
||||
]}
|
||||
].
|
75
rebar.config
Normal file
75
rebar.config
Normal file
@ -0,0 +1,75 @@
|
||||
%% 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
|
||||
]}.
|
||||
|
||||
%% Common project dependencies.
|
||||
{deps, [
|
||||
{rfc3339, "0.2.2"},
|
||||
{jsx, "2.8.2"},
|
||||
{jose, "1.7.9"},
|
||||
{base64url, "0.0.1"},
|
||||
{genlib,
|
||||
{git, "https://github.com/rbkmoney/genlib.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}.
|
||||
|
||||
{dialyzer, [
|
||||
{warnings, [
|
||||
% mandatory
|
||||
unmatched_returns,
|
||||
error_handling,
|
||||
race_conditions,
|
||||
unknown
|
||||
]},
|
||||
{plt_apps, all_deps}
|
||||
]}.
|
||||
|
||||
{profiles, [
|
||||
{test, [
|
||||
{deps, [
|
||||
{snowflake,
|
||||
{git, "https://github.com/rbkmoney/snowflake.git",
|
||||
{branch, "master"}
|
||||
}
|
||||
}
|
||||
]}
|
||||
]}
|
||||
]}.
|
16
rebar.lock
Normal file
16
rebar.lock
Normal file
@ -0,0 +1,16 @@
|
||||
{"1.1.0",
|
||||
[{<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},0},
|
||||
{<<"genlib">>,
|
||||
{git,"https://github.com/rbkmoney/genlib.git",
|
||||
{ref,"41920d7774d119c294f3aaba4043ced12da2a815"}},
|
||||
0},
|
||||
{<<"jose">>,{pkg,<<"jose">>,<<"1.7.9">>},0},
|
||||
{<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.2">>},0},
|
||||
{<<"rfc3339">>,{pkg,<<"rfc3339">>,<<"0.2.2">>},0}]}.
|
||||
[
|
||||
{pkg_hash,[
|
||||
{<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
|
||||
{<<"jose">>, <<"9DC5A14AB62DB4E41677FCC97993752562FB57AD0B8BA062589682EDD3ACB91F">>},
|
||||
{<<"jsx">>, <<"7ACC7D785B5ABE8A6E9ADBDE926A24E481F29956DD8B4DF49E3E4E7BCC92A018">>},
|
||||
{<<"rfc3339">>, <<"1552DF616ACA368D982E9F085A0E933B6688A3F4938A671798978EC2C0C58730">>}]}
|
||||
].
|
23
src/uac.app.src
Normal file
23
src/uac.app.src
Normal file
@ -0,0 +1,23 @@
|
||||
{application, uac, [
|
||||
{description, "Universal authorizer"},
|
||||
{vsn, "0.1.0"},
|
||||
{registered, []},
|
||||
{mod, {uac, []}},
|
||||
{applications, [
|
||||
kernel,
|
||||
stdlib,
|
||||
public_key,
|
||||
genlib,
|
||||
jose,
|
||||
rfc3339,
|
||||
base64url,
|
||||
jsx
|
||||
]},
|
||||
{env, []},
|
||||
{modules, []},
|
||||
{maintainers, [
|
||||
""
|
||||
]},
|
||||
{licenses, []},
|
||||
{links, []}
|
||||
]}.
|
129
src/uac.erl
Normal file
129
src/uac.erl
Normal file
@ -0,0 +1,129 @@
|
||||
-module(uac).
|
||||
|
||||
%% App
|
||||
|
||||
-behaviour(application).
|
||||
-export([start/2, stop/1]).
|
||||
|
||||
%% Supervisor
|
||||
|
||||
-behaviour(supervisor).
|
||||
-export([init/1]).
|
||||
|
||||
%%API
|
||||
|
||||
-export([configure/1]).
|
||||
-export([authorize_api_key/2]).
|
||||
-export([authorize_operation/2]).
|
||||
|
||||
-type context() :: uac_authorizer_jwt:t().
|
||||
-type claims() :: uac_authorizer_jwt:claims().
|
||||
|
||||
-type configuration() :: #{
|
||||
jwt := uac_authorizer_jwt:options(),
|
||||
access := uac_conf:options()
|
||||
}.
|
||||
|
||||
-type verification_opts() :: #{
|
||||
check_expired_as_of => genlib_time:ts()
|
||||
}.
|
||||
|
||||
-type api_key() :: binary().
|
||||
|
||||
-export_type([context/0]).
|
||||
-export_type([claims/0]).
|
||||
-export_type([verification_opts/0]).
|
||||
|
||||
%%
|
||||
% API
|
||||
%%
|
||||
|
||||
-spec configure(configuration()) ->
|
||||
ok.
|
||||
configure(Config) ->
|
||||
AuthorizerConfig = maps:get(jwt, Config),
|
||||
AccessConfig = maps:get(access, Config),
|
||||
ok = uac_authorizer_jwt:configure(AuthorizerConfig),
|
||||
ok = uac_conf:configure(AccessConfig).
|
||||
|
||||
-spec authorize_api_key(
|
||||
ApiKey :: api_key(),
|
||||
VerificationOpts :: verification_opts()
|
||||
) -> {ok, Context :: context()} | {error, Reason :: atom()}.
|
||||
|
||||
authorize_api_key(ApiKey, VerificationOpts) ->
|
||||
case parse_api_key(ApiKey) of
|
||||
{ok, {Type, Credentials}} ->
|
||||
authorize_api_key(Type, Credentials, VerificationOpts);
|
||||
{error, Error} ->
|
||||
{error, Error}
|
||||
end.
|
||||
|
||||
-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 authorize_api_key(
|
||||
Type :: atom(),
|
||||
Credentials :: binary(),
|
||||
VerificationOpts :: verification_opts()
|
||||
) ->
|
||||
{ok, Context :: context()} | {error, Reason :: atom()}.
|
||||
|
||||
authorize_api_key(bearer, Token, VerificationOpts) ->
|
||||
uac_authorizer_jwt:verify(Token, VerificationOpts).
|
||||
|
||||
%%
|
||||
|
||||
-spec authorize_operation(
|
||||
AccessScope :: uac_conf:operation_access_scopes(),
|
||||
Auth :: uac_authorizer_jwt:t()
|
||||
) ->
|
||||
ok | {error, unauthorized}.
|
||||
|
||||
authorize_operation(AccessScope, {_, {_SubjectID, ACL}, _}) ->
|
||||
case lists:all(
|
||||
fun ({Scope, Permission}) ->
|
||||
lists:member(Permission, uac_acl:match(Scope, ACL))
|
||||
end,
|
||||
AccessScope
|
||||
) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
{error, unauthorized}
|
||||
end.
|
||||
|
||||
%%
|
||||
% App
|
||||
%%
|
||||
|
||||
-spec start(any(), any()) ->
|
||||
{ok, pid()} | {error, Reason :: term()}.
|
||||
start(_StartType, _StartArgs) ->
|
||||
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
|
||||
|
||||
-spec stop(any()) ->
|
||||
ok.
|
||||
stop(_State) ->
|
||||
ok.
|
||||
|
||||
%%
|
||||
% Supervisor
|
||||
%%
|
||||
|
||||
-spec init([]) ->
|
||||
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
|
||||
init([]) ->
|
||||
AuthorizerSpec = uac_authorizer_jwt:get_child_spec(),
|
||||
AccessSpec = uac_conf:get_child_spec(),
|
||||
SupFlags = #{},
|
||||
Children = AuthorizerSpec ++ AccessSpec,
|
||||
{ok, {SupFlags, Children}}.
|
242
src/uac_acl.erl
Normal file
242
src/uac_acl.erl
Normal file
@ -0,0 +1,242 @@
|
||||
-module(uac_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([resource/0]).
|
||||
-export_type([permission/0]).
|
||||
|
||||
-export([new/0]).
|
||||
-export([to_list/1]).
|
||||
-export([from_list/1]).
|
||||
-export([insert_scope/3]).
|
||||
-export([remove_scope/3]).
|
||||
|
||||
-export([match/2]).
|
||||
|
||||
-export([decode/1]).
|
||||
-export([encode/1]).
|
||||
|
||||
%%
|
||||
|
||||
-spec new() ->
|
||||
t().
|
||||
|
||||
new() ->
|
||||
[].
|
||||
|
||||
-spec to_list(t()) ->
|
||||
[{scope(), permission()}].
|
||||
|
||||
to_list(ACL) ->
|
||||
[{S, P} || {{_, S}, P} <- ACL].
|
||||
|
||||
-spec from_list([{scope(), permission()}]) ->
|
||||
t().
|
||||
|
||||
from_list(L) ->
|
||||
lists:foldl(fun ({S, P}, ACL) -> insert_scope(S, P, ACL) end, new(), L).
|
||||
|
||||
-spec insert_scope(scope(), permission(), t()) ->
|
||||
t().
|
||||
|
||||
insert_scope(Scope, Permission, ACL) ->
|
||||
Priority = compute_priority(Scope, Permission),
|
||||
insert({{Priority, Scope}, [Permission]}, ACL).
|
||||
|
||||
insert({PS, _} = V, [{PS0, _} = V0 | Vs]) when PS < PS0 ->
|
||||
[V0 | insert(V, Vs)];
|
||||
insert({PS, Perms}, [{PS, Perms0} | Vs]) ->
|
||||
% NOTE squashing permissions of entries with the same scope
|
||||
[{PS, lists:usort(Perms ++ Perms0)} | Vs];
|
||||
insert({PS, _} = V, [{PS0, _} | _] = Vs) when PS > PS0 ->
|
||||
[V | Vs];
|
||||
insert(V, []) ->
|
||||
[V].
|
||||
|
||||
-spec remove_scope(scope(), permission(), t()) ->
|
||||
t().
|
||||
|
||||
remove_scope(Scope, Permission, ACL) ->
|
||||
Priority = compute_priority(Scope, Permission),
|
||||
remove({{Priority, Scope}, [Permission]}, ACL).
|
||||
|
||||
remove(V, [V | Vs]) ->
|
||||
Vs;
|
||||
remove({PS, Perms}, [{PS, Perms0} | Vs]) ->
|
||||
[{PS, Perms0 -- Perms} | Vs];
|
||||
remove(V, [V0 | Vs]) ->
|
||||
[V0 | remove(V, Vs)];
|
||||
remove(_, []) ->
|
||||
[].
|
||||
|
||||
compute_priority(Scope, Permission) ->
|
||||
% NOTE
|
||||
% Scope priority depends on the following attributes, in the order of decreasing
|
||||
% importance:
|
||||
% 1. Depth, deeper is more important
|
||||
% 2. Scope element specificity, element marked with an ID is more important
|
||||
compute_scope_priority(Scope) + compute_permission_priority(Permission).
|
||||
|
||||
compute_scope_priority(Scope) when length(Scope) > 0 ->
|
||||
compute_scope_priority(Scope, get_resource_hierarchy(), 0);
|
||||
compute_scope_priority(Scope) ->
|
||||
error({badarg, {scope, Scope}}).
|
||||
|
||||
compute_scope_priority([{Resource, _ID} | Rest], H, P) ->
|
||||
compute_scope_priority(Rest, delve(Resource, H), P * 10 + 2);
|
||||
compute_scope_priority([Resource | Rest], H, P) ->
|
||||
compute_scope_priority(Rest, delve(Resource, H), P * 10 + 1);
|
||||
compute_scope_priority([], _, P) ->
|
||||
P * 10.
|
||||
|
||||
compute_permission_priority(read) ->
|
||||
0;
|
||||
compute_permission_priority(write) ->
|
||||
0;
|
||||
compute_permission_priority(V) ->
|
||||
error({badarg, {permission, V}}).
|
||||
|
||||
%%
|
||||
|
||||
-spec match(scope(), t()) ->
|
||||
[permission()].
|
||||
|
||||
match(Scope, ACL) when length(Scope) > 0 ->
|
||||
match_rules(Scope, ACL);
|
||||
match(Scope, _) ->
|
||||
error({badarg, {scope, Scope}}).
|
||||
|
||||
match_rules(Scope, [{{_Priority, ScopePrefix}, Permissions} | Rest]) ->
|
||||
% NOTE
|
||||
% The `Scope` matches iff `ScopePrefix` is scope prefix of the `Scope`.
|
||||
% An element of a scope matches corresponding element of a scope prefix
|
||||
% according to the following rules:
|
||||
% 1. Scope prefix element marked with resource and ID matches exactly the same
|
||||
% scope element.
|
||||
% 2. Scope prefix element marked with only resource matches any scope element
|
||||
% marked with the same resource.
|
||||
case match_scope(Scope, ScopePrefix) of
|
||||
true ->
|
||||
Permissions;
|
||||
false ->
|
||||
match_rules(Scope, Rest)
|
||||
end;
|
||||
match_rules(_Scope, []) ->
|
||||
[].
|
||||
|
||||
match_scope([V | Ss], [V | Ss0]) ->
|
||||
match_scope(Ss, Ss0);
|
||||
match_scope([{V, _ID} | Ss], [V | Ss0]) ->
|
||||
match_scope(Ss, Ss0);
|
||||
match_scope(_, []) ->
|
||||
true;
|
||||
match_scope(_, _) ->
|
||||
false.
|
||||
|
||||
%%
|
||||
|
||||
-spec decode([binary()]) ->
|
||||
t().
|
||||
|
||||
decode(V) ->
|
||||
lists:foldl(fun decode_entry/2, new(), V).
|
||||
|
||||
decode_entry(V, ACL) ->
|
||||
case binary:split(V, <<":">>, [global]) of
|
||||
[V1, V2] ->
|
||||
Scope = decode_scope(V1),
|
||||
Permission = decode_permission(V2),
|
||||
insert_scope(Scope, Permission, ACL);
|
||||
_ ->
|
||||
error({badarg, {role, V}})
|
||||
end.
|
||||
|
||||
decode_scope(V) ->
|
||||
Hierarchy = get_resource_hierarchy(),
|
||||
decode_scope_frags(binary:split(V, <<".">>, [global]), Hierarchy).
|
||||
|
||||
decode_scope_frags([V1, V2 | Vs], H) ->
|
||||
{Resource, H1} = decode_scope_frag_resource(V1, V2, H),
|
||||
[Resource | decode_scope_frags(Vs, H1)];
|
||||
decode_scope_frags([V], H) ->
|
||||
decode_scope_frags([V, <<"*">>], H);
|
||||
decode_scope_frags([], _) ->
|
||||
[].
|
||||
|
||||
decode_scope_frag_resource(V, <<"*">>, H) ->
|
||||
R = decode_resource(V),
|
||||
{R, delve(R, H)};
|
||||
decode_scope_frag_resource(V, ID, H) ->
|
||||
R = decode_resource(V),
|
||||
{{R, ID}, delve(R, H)}.
|
||||
|
||||
decode_resource(V) ->
|
||||
try binary_to_existing_atom(V, utf8) catch
|
||||
error:badarg ->
|
||||
error({badarg, {resource, V}})
|
||||
end.
|
||||
|
||||
decode_permission(<<"read">>) ->
|
||||
read;
|
||||
decode_permission(<<"write">>) ->
|
||||
write;
|
||||
decode_permission(V) ->
|
||||
error({badarg, {permission, V}}).
|
||||
|
||||
%%
|
||||
|
||||
-spec encode(t()) ->
|
||||
[binary()].
|
||||
|
||||
encode(ACL) ->
|
||||
lists:flatmap(fun encode_entry/1, ACL).
|
||||
|
||||
encode_entry({{_Priority, Scope}, Permissions}) ->
|
||||
S = encode_scope(Scope),
|
||||
[begin P = encode_permission(Permission), <<S/binary, ":", P/binary>> end
|
||||
|| Permission <- Permissions].
|
||||
|
||||
encode_scope(Scope) ->
|
||||
Hierarchy = get_resource_hierarchy(),
|
||||
genlib_string:join($., encode_scope_frags(Scope, Hierarchy)).
|
||||
|
||||
encode_scope_frags([{Resource, ID} | Rest], H) ->
|
||||
[encode_resource(Resource), ID | encode_scope_frags(Rest, delve(Resource, H))];
|
||||
encode_scope_frags([Resource], H) ->
|
||||
_ = delve(Resource, H),
|
||||
[encode_resource(Resource)];
|
||||
encode_scope_frags([Resource | Rest], H) ->
|
||||
[encode_resource(Resource), <<"*">> | encode_scope_frags(Rest, delve(Resource, H))];
|
||||
encode_scope_frags([], _) ->
|
||||
[].
|
||||
|
||||
encode_resource(V) ->
|
||||
atom_to_binary(V, utf8).
|
||||
|
||||
encode_permission(read) ->
|
||||
<<"read">>;
|
||||
encode_permission(write) ->
|
||||
<<"write">>.
|
||||
|
||||
%%
|
||||
|
||||
get_resource_hierarchy() ->
|
||||
uac_conf:get_resource_hierarchy().
|
||||
|
||||
delve(Resource, Hierarchy) ->
|
||||
case maps:find(Resource, Hierarchy) of
|
||||
{ok, Sub} ->
|
||||
Sub;
|
||||
error ->
|
||||
error({badarg, {resource, Resource}})
|
||||
end.
|
388
src/uac_authorizer_jwt.erl
Normal file
388
src/uac_authorizer_jwt.erl
Normal file
@ -0,0 +1,388 @@
|
||||
-module(uac_authorizer_jwt).
|
||||
|
||||
%%
|
||||
-export([init/1]).
|
||||
-export([get_child_spec/0]).
|
||||
|
||||
% TODO
|
||||
% Extend interface to support proper keystore manipulation
|
||||
|
||||
-export([configure/1]).
|
||||
-export([issue/4]).
|
||||
-export([verify/2]).
|
||||
|
||||
%%
|
||||
|
||||
-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(), uac_acl:t()}.
|
||||
-type subject_id() :: binary().
|
||||
-type t() :: {id(), subject(), claims()}.
|
||||
-type expiration() ::
|
||||
{lifetime, Seconds :: pos_integer()} |
|
||||
{deadline, UnixTs :: pos_integer()} |
|
||||
unlimited.
|
||||
-type id() :: binary().
|
||||
|
||||
-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()
|
||||
}.
|
||||
|
||||
-export_type([options/0]).
|
||||
|
||||
-type keyset() :: #{
|
||||
keyname() => keysource()
|
||||
}.
|
||||
|
||||
-type keysource() ::
|
||||
{pem_file, file:filename()}.
|
||||
|
||||
-spec get_child_spec() ->
|
||||
[supervisor:child_spec()].
|
||||
|
||||
get_child_spec() ->
|
||||
[#{
|
||||
id => ?MODULE,
|
||||
start => {supervisor, start_link, [?MODULE, []]},
|
||||
type => supervisor
|
||||
}].
|
||||
|
||||
-spec init([]) ->
|
||||
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
|
||||
|
||||
init([]) ->
|
||||
ok = create_table(),
|
||||
{ok, {#{}, []}}.
|
||||
|
||||
%%
|
||||
|
||||
-spec configure(options()) ->
|
||||
ok.
|
||||
configure(Options) ->
|
||||
Keyset = parse_options(Options),
|
||||
_ = maps:map(fun ensure_store_key/2, Keyset),
|
||||
ok.
|
||||
|
||||
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
|
||||
),
|
||||
Keyset.
|
||||
|
||||
is_keysource({pem_file, Fn}) ->
|
||||
is_list(Fn) orelse is_binary(Fn);
|
||||
is_keysource(_) ->
|
||||
false.
|
||||
|
||||
ensure_store_key(Keyname, Source) ->
|
||||
case store_key(Keyname, Source) of
|
||||
ok ->
|
||||
ok;
|
||||
{error, Reason} ->
|
||||
exit({import_error, Keyname, Source, Reason})
|
||||
end.
|
||||
|
||||
%%
|
||||
|
||||
-spec store_key(keyname(), {pem_file, file:filename()}) ->
|
||||
ok | {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);
|
||||
Error = {error, _} ->
|
||||
Error
|
||||
end.
|
||||
|
||||
derive_kid(JWK, #{kid := DeriveFun}) when is_function(DeriveFun, 1) ->
|
||||
DeriveFun(JWK).
|
||||
|
||||
construct_key(KID, JWK) ->
|
||||
Signer = try jose_jwk:signer(JWK) catch error:_ -> undefined end,
|
||||
Verifier = try jose_jwk:verifier(JWK) catch error:_ -> undefined end,
|
||||
#{
|
||||
jwk => JWK,
|
||||
kid => KID,
|
||||
signer => Signer,
|
||||
can_sign => Signer /= undefined,
|
||||
verifier => Verifier,
|
||||
can_verify => Verifier /= undefined
|
||||
}.
|
||||
|
||||
%%
|
||||
|
||||
-spec issue(id(), expiration(), t(), keyname()) ->
|
||||
{ok, token()} |
|
||||
{error, nonexistent_key} |
|
||||
{error, {invalid_signee, Reason :: atom()}}.
|
||||
|
||||
issue(JTI, Expiration, Auth, Signee) ->
|
||||
case try_get_key_for_sign(Signee) of
|
||||
{ok, Key} ->
|
||||
Claims = construct_final_claims(Auth, Expiration, JTI),
|
||||
sign(Key, Claims);
|
||||
{error, Error} ->
|
||||
{error, Error}
|
||||
end.
|
||||
|
||||
try_get_key_for_sign(Keyname) ->
|
||||
case get_key_by_name(Keyname) of
|
||||
#{can_sign := true} = Key ->
|
||||
{ok, Key};
|
||||
#{} ->
|
||||
{error, {invalid_signee, signing_not_allowed}};
|
||||
undefined ->
|
||||
{error, nonexistent_key}
|
||||
end.
|
||||
|
||||
construct_final_claims({{Subject, ACL}, Claims}, Expiration, JTI) ->
|
||||
maps:merge(
|
||||
Claims#{
|
||||
<<"jti">> => JTI,
|
||||
<<"sub">> => Subject,
|
||||
<<"exp">> => get_expires_at(Expiration)
|
||||
},
|
||||
encode_roles(uac_acl:encode(ACL))
|
||||
).
|
||||
|
||||
get_expires_at({lifetime, Lt}) ->
|
||||
genlib_time:unow() + Lt;
|
||||
get_expires_at({deadline, Dl}) ->
|
||||
Dl;
|
||||
get_expires_at(unlimited) ->
|
||||
0.
|
||||
|
||||
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(), uac:verification_opts()) ->
|
||||
{ok, t()} |
|
||||
{error,
|
||||
{invalid_token,
|
||||
badarg |
|
||||
{badarg, term()} |
|
||||
{missing, atom()} |
|
||||
expired |
|
||||
{malformed_acl, term()}
|
||||
} |
|
||||
{nonexistent_key, kid()} |
|
||||
invalid_operation |
|
||||
invalid_signature
|
||||
}.
|
||||
|
||||
verify(Token, VerificationOpts) ->
|
||||
try
|
||||
{_, ExpandedToken} = jose_jws:expand(Token),
|
||||
#{<<"protected">> := ProtectedHeader} = ExpandedToken,
|
||||
Header = base64url_to_map(ProtectedHeader),
|
||||
Alg = get_alg(Header),
|
||||
KID = get_kid(Header),
|
||||
verify(KID, Alg, ExpandedToken, VerificationOpts)
|
||||
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, VerificationOpts) ->
|
||||
case get_key_by_kid(KID) of
|
||||
#{jwk := JWK, verifier := Algs} ->
|
||||
_ = lists:member(Alg, Algs) orelse throw(invalid_operation),
|
||||
verify(JWK, ExpandedToken, VerificationOpts);
|
||||
undefined ->
|
||||
{error, {nonexistent_key, KID}}
|
||||
end.
|
||||
|
||||
verify(JWK, ExpandedToken, VerificationOpts) ->
|
||||
case jose_jwt:verify(JWK, ExpandedToken) of
|
||||
{true, #jose_jwt{fields = Claims}, _JWS} ->
|
||||
{KeyMeta, Claims1} = validate_claims(Claims, VerificationOpts),
|
||||
get_result(KeyMeta, decode_roles(Claims1));
|
||||
{false, _JWT, _JWS} ->
|
||||
{error, invalid_signature}
|
||||
end.
|
||||
|
||||
validate_claims(Claims, VerificationOpts) ->
|
||||
validate_claims(Claims, get_validators(), VerificationOpts, #{}).
|
||||
|
||||
validate_claims(Claims, [{Name, Claim, Validator} | Rest], VerificationOpts, Acc) ->
|
||||
V = Validator(Name, maps:get(Claim, Claims, undefined), VerificationOpts),
|
||||
validate_claims(maps:without([Claim], Claims), Rest, VerificationOpts, Acc#{Name => V});
|
||||
validate_claims(Claims, [], _, Acc) ->
|
||||
{Acc, Claims}.
|
||||
|
||||
get_result(KeyMeta, {Roles, Claims}) ->
|
||||
#{token_id := TokenID, subject_id := SubjectID} = KeyMeta,
|
||||
try
|
||||
Subject = {SubjectID, uac_acl:decode(Roles)},
|
||||
{ok, {TokenID, 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/3},
|
||||
{subject_id , <<"sub">> , fun check_presence/3},
|
||||
{expires_at , <<"exp">> , fun check_expiration/3}
|
||||
].
|
||||
|
||||
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, Opts) when is_integer(Exp) ->
|
||||
case get_check_expiry(Opts) of
|
||||
{true, Now} when Exp > Now ->
|
||||
Exp;
|
||||
false when Exp > 0 ->
|
||||
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}}}).
|
||||
|
||||
get_check_expiry(Opts) ->
|
||||
case maps:get(check_expired_as_of, Opts, undefined) of
|
||||
Now when is_integer(Now) ->
|
||||
{true, Now};
|
||||
undefined ->
|
||||
false
|
||||
end.
|
||||
|
||||
%%
|
||||
|
||||
encode_roles(Roles) ->
|
||||
IssuerResource = uac_conf:get_service_name(),
|
||||
#{
|
||||
<<"resource_access">> => #{
|
||||
IssuerResource => #{
|
||||
<<"roles">> => Roles
|
||||
}
|
||||
}
|
||||
}.
|
||||
|
||||
decode_roles(Claims = #{
|
||||
<<"resource_access">> := Resources
|
||||
}) when is_map(Resources) andalso map_size(Resources) > 0 ->
|
||||
Accepted = uac_conf:get_service_name(),
|
||||
Roles = try_get_roles(Resources, Accepted),
|
||||
{Roles, maps:remove(<<"resource_access">>, Claims)};
|
||||
decode_roles(_) ->
|
||||
throw({invalid_token, {missing, acl}}).
|
||||
|
||||
try_get_roles(Resources, Accepted) ->
|
||||
case maps:get(Accepted, Resources, undefined) of
|
||||
#{<<"roles">> := Roles} ->
|
||||
Roles;
|
||||
undefined ->
|
||||
[]
|
||||
end.
|
||||
|
||||
%%
|
||||
|
||||
insert_key(Keyname, KeyInfo = #{kid := KID}) ->
|
||||
insert_values(#{
|
||||
{keyname, Keyname} => KeyInfo,
|
||||
{kid, KID} => KeyInfo
|
||||
}).
|
||||
|
||||
get_key_by_name(Keyname) ->
|
||||
lookup_value({keyname, Keyname}).
|
||||
|
||||
get_key_by_kid(KID) ->
|
||||
lookup_value({kid, KID}).
|
||||
|
||||
base64url_to_map(Base64) when is_binary(Base64) ->
|
||||
jsx:decode(base64url:decode(Base64), [return_maps]).
|
||||
|
||||
%%
|
||||
|
||||
-define(TABLE, ?MODULE).
|
||||
|
||||
create_table() ->
|
||||
_ = ets:new(?TABLE, [set, public, named_table, {read_concurrency, true}]),
|
||||
ok.
|
||||
|
||||
insert_values(Values) ->
|
||||
true = ets:insert(?TABLE, maps:to_list(Values)),
|
||||
ok.
|
||||
|
||||
lookup_value(Key) ->
|
||||
case ets:lookup(?TABLE, Key) of
|
||||
[{Key, Value}] ->
|
||||
Value;
|
||||
[] ->
|
||||
undefined
|
||||
end.
|
86
src/uac_conf.erl
Normal file
86
src/uac_conf.erl
Normal file
@ -0,0 +1,86 @@
|
||||
-module(uac_conf).
|
||||
|
||||
%%
|
||||
|
||||
-export([get_child_spec/0]).
|
||||
-export([init/1]).
|
||||
|
||||
%% API
|
||||
|
||||
-export([configure/1]).
|
||||
-export([get_service_name/0]).
|
||||
-export([get_resource_hierarchy/0]).
|
||||
|
||||
-type operation_access_scopes() :: [{uac_acl:scope(), uac_acl:permission()}].
|
||||
-type service_name() :: binary().
|
||||
-type resource_hierarchy() :: #{uac_acl:resource() => resource_hierarchy() | #{}}.
|
||||
|
||||
-type options() :: #{
|
||||
service_name := service_name(),
|
||||
resource_hierarchy := resource_hierarchy()
|
||||
}.
|
||||
-export_type([options/0]).
|
||||
-export_type([operation_access_scopes/0]).
|
||||
|
||||
%%
|
||||
|
||||
-spec get_child_spec() ->
|
||||
[supervisor:child_spec()].
|
||||
|
||||
get_child_spec() ->
|
||||
[#{
|
||||
id => ?MODULE,
|
||||
start => {supervisor, start_link, [?MODULE, []]},
|
||||
type => supervisor
|
||||
}].
|
||||
|
||||
-spec init([]) ->
|
||||
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
|
||||
|
||||
init([]) ->
|
||||
ok = create_table(),
|
||||
{ok, {#{}, []}}.
|
||||
|
||||
%%
|
||||
%% API
|
||||
%%
|
||||
|
||||
-spec get_service_name() ->
|
||||
service_name().
|
||||
get_service_name() ->
|
||||
lookup_value(service_name).
|
||||
|
||||
-spec get_resource_hierarchy() ->
|
||||
resource_hierarchy().
|
||||
get_resource_hierarchy() ->
|
||||
lookup_value(resource_hierarchy).
|
||||
|
||||
%%
|
||||
|
||||
-spec configure(options()) ->
|
||||
ok.
|
||||
configure(Config) ->
|
||||
ok = insert_values(Config).
|
||||
|
||||
%%
|
||||
|
||||
-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) ->
|
||||
lookup_value(Key, undefined).
|
||||
|
||||
lookup_value(Key, Default) ->
|
||||
case ets:lookup(?TABLE, Key) of
|
||||
[{Key, Value}] ->
|
||||
Value;
|
||||
[] ->
|
||||
Default
|
||||
end.
|
181
test/uac_acl_SUITE.erl
Normal file
181
test/uac_acl_SUITE.erl
Normal file
@ -0,0 +1,181 @@
|
||||
-module(uac_acl_SUITE).
|
||||
|
||||
-include_lib("stdlib/include/assert.hrl").
|
||||
-include_lib("common_test/include/ct.hrl").
|
||||
|
||||
-export([all/0]).
|
||||
-export([init_per_suite/1]).
|
||||
-export([end_per_suite/1]).
|
||||
-export([init_per_group/2]).
|
||||
-export([end_per_group/2]).
|
||||
-export([init_per_testcase/2]).
|
||||
-export([end_per_testcase/2]).
|
||||
|
||||
-export([
|
||||
illegal_input_test/1,
|
||||
empty_test/1,
|
||||
stable_encoding_test/1,
|
||||
remove_scopes_test/1,
|
||||
redundancy_test/1,
|
||||
match_scope_test/1
|
||||
]).
|
||||
|
||||
-spec illegal_input_test(config()) -> _.
|
||||
-spec empty_test(config()) -> _.
|
||||
-spec stable_encoding_test(config()) -> _.
|
||||
-spec remove_scopes_test(config()) -> _.
|
||||
-spec redundancy_test(config()) -> _.
|
||||
-spec match_scope_test(config()) -> _.
|
||||
|
||||
-type test_case_name() :: atom().
|
||||
-type config() :: [{atom(), any()}].
|
||||
-type group_name() :: atom().
|
||||
|
||||
-spec all() ->
|
||||
[test_case_name()].
|
||||
all() ->
|
||||
[
|
||||
illegal_input_test,
|
||||
empty_test,
|
||||
stable_encoding_test,
|
||||
remove_scopes_test,
|
||||
redundancy_test,
|
||||
match_scope_test
|
||||
].
|
||||
|
||||
-spec init_per_suite(config()) ->
|
||||
config().
|
||||
init_per_suite(Config) ->
|
||||
Apps = genlib_app:start_application(uac),
|
||||
uac:configure(#{
|
||||
jwt => #{
|
||||
keyset => #{
|
||||
test => {pem_file, get_keysource("keys/local/private.pem", Config)}
|
||||
}
|
||||
},
|
||||
access => #{
|
||||
service_name => <<"test">>,
|
||||
resource_hierarchy => #{
|
||||
party => #{invoice_templates => #{invoice_template_invoices => #{}}},
|
||||
customers => #{bindings => #{}},
|
||||
invoices => #{payments => #{}},
|
||||
payment_resources => #{}
|
||||
}
|
||||
}
|
||||
}),
|
||||
[{apps, Apps}] ++ Config.
|
||||
|
||||
-spec init_per_group(group_name(), config()) ->
|
||||
config().
|
||||
init_per_group(_Name, Config) ->
|
||||
Config.
|
||||
|
||||
-spec init_per_testcase(group_name(), config()) ->
|
||||
config().
|
||||
init_per_testcase(_Name, Config) ->
|
||||
Config.
|
||||
|
||||
-spec end_per_suite(config()) ->
|
||||
config().
|
||||
end_per_suite(Config) ->
|
||||
Config.
|
||||
|
||||
-spec end_per_group(group_name(), config()) ->
|
||||
config().
|
||||
end_per_group(_Name, Config) ->
|
||||
Config.
|
||||
|
||||
-spec end_per_testcase(test_case_name(), config()) ->
|
||||
config().
|
||||
end_per_testcase(_Name, Config) ->
|
||||
Config.
|
||||
|
||||
|
||||
illegal_input_test(_C) ->
|
||||
?assertError({badarg, {scope , _}}, from_list([{[], read}])),
|
||||
?assertError({badarg, {permission, _}}, from_list([{[invoices], wread}])),
|
||||
?assertError({badarg, {resource , _}}, from_list([{[payments], read}])).
|
||||
|
||||
empty_test(_C) ->
|
||||
[] = encode(from_list([])),
|
||||
[] = to_list(decode([])).
|
||||
|
||||
stable_encoding_test(_C) ->
|
||||
ACL1 = from_list([
|
||||
{[party], read},
|
||||
{[party], write},
|
||||
{[invoices], read},
|
||||
{[invoices, payments], read},
|
||||
{[{invoices, <<"42">>}, payments], write}
|
||||
]),
|
||||
Enc1 = [
|
||||
<<"invoices.42.payments:write">>,
|
||||
<<"invoices.*.payments:read">>,
|
||||
<<"party:read">>,
|
||||
<<"party:write">>,
|
||||
<<"invoices:read">>
|
||||
],
|
||||
Enc1 = encode(ACL1),
|
||||
ACL1 = decode(Enc1),
|
||||
ACL1 = decode(encode(ACL1)).
|
||||
|
||||
redundancy_test(_C) ->
|
||||
[<<"party:read">>] = encode(from_list([{[party], read}, {[party], read}])).
|
||||
|
||||
remove_scopes_test(_C) ->
|
||||
?assertEqual(new(), remove([party], read, new())),
|
||||
?assertEqual(
|
||||
from_list([{[party], write}]),
|
||||
remove([invoices], read, from_list([{[party], write}, {[invoices], read}]))
|
||||
),
|
||||
?assertEqual(
|
||||
new(),
|
||||
remove([party], read,
|
||||
remove([party], write,
|
||||
remove([party], read,
|
||||
from_list([{[party], read}, {[party], write}])
|
||||
)
|
||||
)
|
||||
)
|
||||
).
|
||||
|
||||
match_scope_test(_C) ->
|
||||
ACL = from_list([
|
||||
{[party], read},
|
||||
{[party], write},
|
||||
{[invoices], read},
|
||||
{[invoices, payments], write},
|
||||
{[{invoices, <<"42">>}], write},
|
||||
{[{invoices, <<"42">>}, payments], read}
|
||||
]),
|
||||
?assertError({badarg, _} , match([], ACL)),
|
||||
?assertEqual([write] , match([{invoices, <<"42">>}], ACL)),
|
||||
?assertEqual([read] , match([{invoices, <<"43">>}], ACL)),
|
||||
?assertEqual([read] , match([{invoices, <<"42">>}, {payments, <<"1">>}], ACL)),
|
||||
?assertEqual([write] , match([{invoices, <<"43">>}, {payments, <<"1">>}], ACL)),
|
||||
?assertEqual([read, write] , match([{party, <<"BLARGH">>}], ACL)),
|
||||
?assertEqual([] , match([payment_resources], ACL)).
|
||||
|
||||
new() ->
|
||||
uac_acl:new().
|
||||
|
||||
from_list(L) ->
|
||||
uac_acl:from_list(L).
|
||||
|
||||
to_list(L) ->
|
||||
uac_acl:to_list(L).
|
||||
|
||||
remove(S, P, ACL) ->
|
||||
uac_acl:remove_scope(S, P, ACL).
|
||||
|
||||
match(S, ACL) ->
|
||||
uac_acl:match(S, ACL).
|
||||
|
||||
encode(ACL) ->
|
||||
uac_acl:encode(ACL).
|
||||
|
||||
decode(Bin) ->
|
||||
uac_acl:decode(Bin).
|
||||
|
||||
get_keysource(Key, Config) ->
|
||||
filename:join(?config(data_dir, Config), Key).
|
9
test/uac_acl_SUITE_data/keys/local/private.pem
Normal file
9
test/uac_acl_SUITE_data/keys/local/private.pem
Normal file
@ -0,0 +1,9 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIBOwIBAAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg7F/ZMtGbPFikJnnvRWvF
|
||||
B5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQJABUY5KIgr4JZEjwLYxQ9T
|
||||
9uIbLP1Xe/E7yqoqmBk2GGhSrPY0OeRkYnUVLcP96UPQhF63iuG8VF6uZ7oAPsq+
|
||||
gQIhANZy3jSCzPjXYHRU1kRqQzpt2S+OqoEiqQ6YG1HrC/VxAiEA0Vq6JlQK2tOX
|
||||
37SS00dK0Qog4Qi8dN73GliFQNP18EkCIQC4epSA48zkfJMzQBAbRraSuxDNApPX
|
||||
BzQbo+pMrEDbYQIgY4AncQgIkLB4Qk5kah48JNYXglzQlQtTjiX8Ty9ueGECIQCM
|
||||
GD3UbQKiA0gf5plBA24I4wFVKxxa4wXbW/7SfP6XmQ==
|
||||
-----END RSA PRIVATE KEY-----
|
235
test/uac_tests_SUITE.erl
Normal file
235
test/uac_tests_SUITE.erl
Normal file
@ -0,0 +1,235 @@
|
||||
-module(uac_tests_SUITE).
|
||||
|
||||
-include_lib("common_test/include/ct.hrl").
|
||||
-include_lib("jose/include/jose_jwk.hrl").
|
||||
|
||||
-export([all/0]).
|
||||
-export([groups/0]).
|
||||
-export([init_per_suite/1]).
|
||||
-export([end_per_suite/1]).
|
||||
-export([init_per_group/2]).
|
||||
-export([end_per_group/2]).
|
||||
-export([init_per_testcase/2]).
|
||||
-export([end_per_testcase/2]).
|
||||
|
||||
-export([
|
||||
successful_auth_test/1,
|
||||
invalid_permissions_test/1,
|
||||
bad_token_test/1,
|
||||
no_token_test/1,
|
||||
|
||||
force_expiration_test/1,
|
||||
force_expiration_fail_test/1,
|
||||
|
||||
bad_signee_test/1,
|
||||
|
||||
different_issuers_test/1
|
||||
]).
|
||||
|
||||
-type test_case_name() :: atom().
|
||||
-type config() :: [{atom(), any()}].
|
||||
-type group_name() :: atom().
|
||||
|
||||
-define(expire_as_of_now, #{
|
||||
check_expired_as_of => genlib_time:unow()
|
||||
}).
|
||||
|
||||
-define(test_service_acl(Access), [{[test_resource], Access}]).
|
||||
|
||||
-spec all() ->
|
||||
[test_case_name()].
|
||||
all() ->
|
||||
[
|
||||
{group, general_tests},
|
||||
{group, different_issuers}
|
||||
].
|
||||
|
||||
-spec groups() ->
|
||||
[{group_name(), list(), [test_case_name()]}].
|
||||
groups() ->
|
||||
[
|
||||
{general_tests, [],
|
||||
[
|
||||
successful_auth_test,
|
||||
invalid_permissions_test,
|
||||
bad_token_test,
|
||||
no_token_test,
|
||||
force_expiration_test,
|
||||
force_expiration_fail_test,
|
||||
bad_signee_test
|
||||
]
|
||||
},
|
||||
{different_issuers, [],
|
||||
[
|
||||
different_issuers_test
|
||||
]
|
||||
}
|
||||
].
|
||||
|
||||
-spec init_per_suite(config()) ->
|
||||
config().
|
||||
init_per_suite(Config) ->
|
||||
Config.
|
||||
|
||||
-spec init_per_group(group_name(), config()) ->
|
||||
config().
|
||||
init_per_group(general_tests, Config) ->
|
||||
Apps = [
|
||||
genlib_app:start_application(snowflake),
|
||||
genlib_app:start_application(uac)
|
||||
],
|
||||
uac:configure(#{
|
||||
jwt => #{
|
||||
keyset => #{
|
||||
test => {pem_file, get_keysource("keys/local/private.pem", Config)}
|
||||
}
|
||||
},
|
||||
access => #{
|
||||
service_name => <<"test">>,
|
||||
resource_hierarchy => #{
|
||||
test_resource => #{}
|
||||
}
|
||||
}
|
||||
}),
|
||||
[{apps, Apps}] ++ Config;
|
||||
init_per_group(different_issuers, Config) ->
|
||||
Apps = [
|
||||
genlib_app:start_application(snowflake),
|
||||
genlib_app:start_application(uac)
|
||||
],
|
||||
uac:configure(#{
|
||||
jwt => #{
|
||||
keyset => #{
|
||||
test => {pem_file, get_keysource("keys/local/private.pem", Config)}
|
||||
}
|
||||
},
|
||||
access => #{
|
||||
service_name => <<"test">>,
|
||||
resource_hierarchy => #{
|
||||
test_resource => #{}
|
||||
}
|
||||
}
|
||||
}),
|
||||
[{apps, Apps}] ++ Config.
|
||||
|
||||
-spec init_per_testcase(test_case_name(), config()) ->
|
||||
config().
|
||||
init_per_testcase(_Name, Config) ->
|
||||
Config.
|
||||
|
||||
-spec end_per_suite(config()) ->
|
||||
_.
|
||||
end_per_suite(Config) ->
|
||||
Config.
|
||||
|
||||
-spec end_per_group(group_name(), config()) ->
|
||||
_.
|
||||
end_per_group(_Name, Config) ->
|
||||
[application:stop(App) || App <- ?config(apps, Config)].
|
||||
|
||||
-spec end_per_testcase(test_case_name(), config()) ->
|
||||
_.
|
||||
end_per_testcase(_Name, Config) ->
|
||||
Config.
|
||||
|
||||
%%
|
||||
|
||||
-spec successful_auth_test(config()) ->
|
||||
_.
|
||||
successful_auth_test(_) ->
|
||||
{ok, Token} = issue_token(?test_service_acl(write), unlimited),
|
||||
{ok, AccessContext} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}),
|
||||
ok = uac:authorize_operation(?test_service_acl(write), AccessContext).
|
||||
|
||||
-spec invalid_permissions_test(config()) ->
|
||||
_.
|
||||
invalid_permissions_test(_) ->
|
||||
{ok, Token} = issue_token(?test_service_acl(read), unlimited),
|
||||
{ok, AccessContext} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}),
|
||||
{error, _} = uac:authorize_operation(?test_service_acl(write), AccessContext).
|
||||
|
||||
-spec bad_token_test(config()) ->
|
||||
_.
|
||||
bad_token_test(Config) ->
|
||||
{ok, Token} = issue_dummy_token(?test_service_acl(write), Config),
|
||||
{error, _} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}).
|
||||
|
||||
-spec no_token_test(config()) ->
|
||||
_.
|
||||
no_token_test(_) ->
|
||||
Token = <<"">>,
|
||||
{error, _} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}).
|
||||
|
||||
-spec force_expiration_test(config()) ->
|
||||
_.
|
||||
force_expiration_test(_) ->
|
||||
{ok, Token} = issue_token(?test_service_acl(write), {deadline, 1}),
|
||||
{ok, AccessContext} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}),
|
||||
ok = uac:authorize_operation(?test_service_acl(write), AccessContext).
|
||||
|
||||
-spec force_expiration_fail_test(config()) ->
|
||||
_.
|
||||
force_expiration_fail_test(_) ->
|
||||
{ok, Token} = issue_token(?test_service_acl(write), {deadline, 1}),
|
||||
{error, _} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, ?expire_as_of_now).
|
||||
|
||||
-spec bad_signee_test(config()) ->
|
||||
_.
|
||||
bad_signee_test(_) ->
|
||||
ACL = ?test_service_acl(write),
|
||||
{error, nonexistent_key} =
|
||||
uac_authorizer_jwt:issue(unique_id(), unlimited, {{<<"TEST">>, uac_acl:from_list(ACL)}, #{}}, random).
|
||||
|
||||
%%
|
||||
|
||||
-spec different_issuers_test(config()) ->
|
||||
_.
|
||||
different_issuers_test(_) ->
|
||||
{ok, Token} = issue_token(?test_service_acl(write), unlimited),
|
||||
uac:configure(#{
|
||||
jwt => #{},
|
||||
access => #{
|
||||
service_name => <<"SOME_OTHER_SERVICE">>,
|
||||
resource_hierarchy => #{
|
||||
test_resource => #{}
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ok, {_, {_, []}, _}} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}).
|
||||
|
||||
%%
|
||||
|
||||
issue_token(ACL, LifeTime) ->
|
||||
PartyID = <<"TEST">>,
|
||||
Claims = #{<<"TEST">> => <<"TEST">>},
|
||||
uac_authorizer_jwt:issue(unique_id(), LifeTime, {{PartyID, uac_acl:from_list(ACL)}, Claims}, test).
|
||||
|
||||
issue_dummy_token(ACL, Config) ->
|
||||
Claims = #{
|
||||
<<"jti">> => unique_id(),
|
||||
<<"sub">> => <<"TEST">>,
|
||||
<<"exp">> => 0,
|
||||
<<"resource_access">> => #{
|
||||
<<"common-api">> => #{
|
||||
<<"roles">> => uac_acl:encode(uac_acl:from_list(ACL))
|
||||
}
|
||||
}
|
||||
},
|
||||
BadPemFile = get_keysource("keys/local/dummy.pem", Config),
|
||||
BadJWK = jose_jwk:from_pem_file(BadPemFile),
|
||||
GoodPemFile = get_keysource("keys/local/private.pem", Config),
|
||||
GoodJWK = jose_jwk:from_pem_file(GoodPemFile),
|
||||
JWKPublic = jose_jwk:to_public(GoodJWK),
|
||||
{_Module, PublicKey} = JWKPublic#jose_jwk.kty,
|
||||
{_PemEntry, Data, _} = public_key:pem_entry_encode('SubjectPublicKeyInfo', PublicKey),
|
||||
KID = base64url:encode(crypto:hash(sha256, Data)),
|
||||
JWT = jose_jwt:sign(BadJWK, #{<<"alg">> => <<"RS256">>, <<"kid">> => KID}, Claims),
|
||||
{_Modules, Token} = jose_jws:compact(JWT),
|
||||
{ok, Token}.
|
||||
|
||||
get_keysource(Key, Config) ->
|
||||
filename:join(?config(data_dir, Config), Key).
|
||||
|
||||
unique_id() ->
|
||||
<<ID:64>> = snowflake:new(),
|
||||
genlib_format:format_int_base(ID, 62).
|
13
test/uac_tests_SUITE_data/keys/local/dummy.pem
Normal file
13
test/uac_tests_SUITE_data/keys/local/dummy.pem
Normal file
@ -0,0 +1,13 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXAIBAAKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUp
|
||||
wmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ5
|
||||
1s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQABAoGAFijko56+qGyN8M0RVyaRAXz++xTqHBLh
|
||||
3tx4VgMtrQ+WEgCjhoTwo23KMBAuJGSYnRmoBZM3lMfTKevIkAidPExvYCdm5dYq3XToLkkLv5L2
|
||||
pIIVOFMDG+KESnAFV7l2c+cnzRMW0+b6f8mR1CJzZuxVLL6Q02fvLi55/mbSYxECQQDeAw6fiIQX
|
||||
GukBI4eMZZt4nscy2o12KyYner3VpoeE+Np2q+Z3pvAMd/aNzQ/W9WaI+NRfcxUJrmfPwIGm63il
|
||||
AkEAxCL5HQb2bQr4ByorcMWm/hEP2MZzROV73yF41hPsRC9m66KrheO9HPTJuo3/9s5p+sqGxOlF
|
||||
L0NDt4SkosjgGwJAFklyR1uZ/wPJjj611cdBcztlPdqoxssQGnh85BzCj/u3WqBpE2vjvyyvyI5k
|
||||
X6zk7S0ljKtt2jny2+00VsBerQJBAJGC1Mg5Oydo5NwD6BiROrPxGo2bpTbu/fhrT8ebHkTz2epl
|
||||
U9VQQSQzY1oZMVX8i1m5WUTLPz2yLJIBQVdXqhMCQBGoiuSoSjafUhV7i1cEGpb88h5NBYZzWXGZ
|
||||
37sJ5QsW+sJyoNde3xH8vdXhzU7eT82D6X/scw9RZz+/6rCJ4p0=
|
||||
-----END RSA PRIVATE KEY-----
|
9
test/uac_tests_SUITE_data/keys/local/private.pem
Normal file
9
test/uac_tests_SUITE_data/keys/local/private.pem
Normal file
@ -0,0 +1,9 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIBOwIBAAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg7F/ZMtGbPFikJnnvRWvF
|
||||
B5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQJABUY5KIgr4JZEjwLYxQ9T
|
||||
9uIbLP1Xe/E7yqoqmBk2GGhSrPY0OeRkYnUVLcP96UPQhF63iuG8VF6uZ7oAPsq+
|
||||
gQIhANZy3jSCzPjXYHRU1kRqQzpt2S+OqoEiqQ6YG1HrC/VxAiEA0Vq6JlQK2tOX
|
||||
37SS00dK0Qog4Qi8dN73GliFQNP18EkCIQC4epSA48zkfJMzQBAbRraSuxDNApPX
|
||||
BzQbo+pMrEDbYQIgY4AncQgIkLB4Qk5kah48JNYXglzQlQtTjiX8Ty9ueGECIQCM
|
||||
GD3UbQKiA0gf5plBA24I4wFVKxxa4wXbW/7SfP6XmQ==
|
||||
-----END RSA PRIVATE KEY-----
|
4
test/uac_tests_SUITE_data/keys/local/public.pem
Normal file
4
test/uac_tests_SUITE_data/keys/local/public.pem
Normal file
@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg
|
||||
7F/ZMtGbPFikJnnvRWvFB5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQ==
|
||||
-----END PUBLIC KEY-----
|
Loading…
Reference in New Issue
Block a user