MSPF-629: Add decisions (#12)

* MSPF-629: Add decisions

* MSPF-629: Fix compile

* MSPF-629: Review fix

* MSPF-629: Update bouncer_proto

* MSPF-629: Fix lint and move from_thrift_struct/4 to bouncer_thrift

* MSPF-629: Fix merge

* MSPF-629: Remove `jsx:encode/1` for restrictions

* MSPF-629: Rename decision -> judgement

* MSPF-629: Change README.md

* MSPF-629: Fix tests

* MSPF-629: Add bundle.tar.gz to gitignore

* MSPF-629: Fix test

* MSPF-629: Fix last test

* MSPF-629: Fix lint

* MSPF-629: Simplify regex

* MSPF-629: Alternative solution to atomization

* MSPF-629: Review fix

* MSPF-629: Review fix
This commit is contained in:
ndiezel0 2020-12-31 17:26:12 +03:00 committed by GitHub
parent 2850275de0
commit 530f8e646f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 482 additions and 268 deletions

1
.gitignore vendored
View File

@ -13,3 +13,4 @@ Dockerfile
docker-compose.yml docker-compose.yml
rebar3.crashdump rebar3.crashdump
/_checkouts/ /_checkouts/
/test/policies/bundle.tar.gz

View File

@ -4,6 +4,6 @@
Primary [Arbiter](https://github.com/rbkmoney/bouncer-proto/blob/97dcad6f/proto/decisions.thrift#L42) thrift service implementation. Primary [Arbiter](https://github.com/rbkmoney/bouncer-proto/blob/97dcad6f/proto/decisions.thrift#L42) thrift service implementation.
In a nutshell this service maps incoming contexts into [OPA input documents](https://www.openpolicyagent.org/docs/latest/philosophy/#the-opa-document-model) and asks OPA to compute a set of assertions allowing or forbidding actions under given input context. In a nutshell this service maps incoming contexts into [OPA input documents](https://www.openpolicyagent.org/docs/latest/philosophy/#the-opa-document-model) and asks OPA to compute a judgement allowing, restricting or forbidding actions under given input context.
From the service's point of view a **ruleset id** is a path to OPA document that define a subdocument named `assertions` with a rudimentary schema. See https://github.com/rbkmoney/bouncer-policies#authoring for more detailed information. From the service's point of view a **ruleset id** is a path to OPA document that define a subdocument named `judgement` with a rudimentary schema. See https://github.com/rbkmoney/bouncer-policies#authoring for more detailed information.

View File

@ -106,7 +106,7 @@
rules => [ rules => [
{elvis_style, invalid_dynamic_call, #{ignore => [ {elvis_style, invalid_dynamic_call, #{ignore => [
% Uses thrift reflection through `struct_info/1`. % Uses thrift reflection through `struct_info/1`.
bouncer_context_v1 bouncer_thrift
]}} ]}}
] ]
}, },

View File

@ -2,7 +2,7 @@
[{<<"bear">>,{pkg,<<"bear">>,<<"0.8.7">>},2}, [{<<"bear">>,{pkg,<<"bear">>,<<"0.8.7">>},2},
{<<"bouncer_proto">>, {<<"bouncer_proto">>,
{git,"git@github.com:rbkmoney/bouncer-proto.git", {git,"git@github.com:rbkmoney/bouncer-proto.git",
{ref,"aab9db7ecc0ff436acc81f3358ae52239559ce7b"}}, {ref,"7e00d96d6d9cd183dff50c07e547c99f51acee7e"}},
0}, 0},
{<<"cache">>,{pkg,<<"cache">>,<<"2.2.0">>},1}, {<<"cache">>,{pkg,<<"cache">>,<<"2.2.0">>},1},
{<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.1">>},2}, {<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.1">>},2},

View File

@ -15,7 +15,7 @@
-type ruleset_id() :: iodata(). -type ruleset_id() :: iodata().
-type judgement() :: {resolution(), [assertion()]}. -type judgement() :: {resolution(), [assertion()]}.
-type resolution() :: allowed | forbidden. -type resolution() :: allowed | forbidden | {restricted, map()}.
-type assertion() :: {_Code :: binary(), _Details :: #{binary() => _}}. -type assertion() :: {_Code :: binary(), _Details :: #{binary() => _}}.
-export_type([judgement/0]). -export_type([judgement/0]).
@ -35,7 +35,7 @@
judge(RulesetID, Context) -> judge(RulesetID, Context) ->
case mk_opa_client() of case mk_opa_client() of
{ok, Client} -> {ok, Client} ->
Location = join_path(RulesetID, <<"/assertions">>), Location = join_path(RulesetID, <<"/judgement">>),
case request_opa_document(Location, Context, Client) of case request_opa_document(Location, Context, Client) of
{ok, Document} -> {ok, Document} ->
infer_judgement(Document); infer_judgement(Document);
@ -53,19 +53,20 @@ judge(RulesetID, Context) ->
infer_judgement(Document) -> infer_judgement(Document) ->
case jesse:validate_with_schema(get_judgement_schema(), Document) of case jesse:validate_with_schema(get_judgement_schema(), Document) of
{ok, _} -> {ok, _} ->
Allowed = maps:get(<<"allowed">>, Document, []), {ok, parse_judgement(Document)};
Forbidden = maps:get(<<"forbidden">>, Document, []),
{ok, infer_judgement(Forbidden, Allowed)};
{error, Reason} -> {error, Reason} ->
{error, {ruleset_invalid, Reason}} {error, {ruleset_invalid, Reason}}
end. end.
infer_judgement(Forbidden = [_ | _], _Allowed) -> parse_judgement(#{<<"resolution">> := [<<"forbidden">>, Assertions]}) ->
{forbidden, extract_assertions(Forbidden)}; {forbidden, extract_assertions(Assertions)};
infer_judgement(_Forbidden = [], Allowed = [_ | _]) -> parse_judgement(#{<<"resolution">> := [<<"allowed">>, Assertions]}) ->
{allowed, extract_assertions(Allowed)}; {allowed, extract_assertions(Assertions)};
infer_judgement(_Forbidden = [], _Allowed = []) -> parse_judgement(#{
{forbidden, []}. <<"resolution">> := [<<"restricted">>, Assertions],
<<"restrictions">> := Restrictions
}) ->
{{restricted, Restrictions}, extract_assertions(Assertions)}.
extract_assertions(Assertions) -> extract_assertions(Assertions) ->
[extract_assertion(E) || E <- Assertions]. [extract_assertion(E) || E <- Assertions].
@ -91,14 +92,29 @@ get_judgement_schema() ->
]} ]}
]} ]}
], ],
ResolutionSchema = [
{<<"type">>, <<"array">>},
{<<"items">>, [
[
{<<"type">>, <<"string">>},
{<<"pattern">>, <<"allowed|forbidden|restricted">>}
],
AssertionsSchema
]},
{<<"minItems">>, 2},
{<<"additionalItems">>, false}
],
[ [
{<<"$schema">>, <<"http://json-schema.org/draft-04/schema#">>}, {<<"$schema">>, <<"http://json-schema.org/draft-04/schema#">>},
{<<"type">>, <<"object">>}, {<<"type">>, <<"object">>},
{<<"properties">>, [ {<<"properties">>, [
{<<"allowed">>, AssertionsSchema}, {<<"resolution">>, ResolutionSchema},
{<<"forbidden">>, AssertionsSchema} {<<"restrictions">>, [
{<<"type">>, <<"object">>}
]}
]}, ]},
{<<"additionalProperties">>, false} {<<"additionalProperties">>, false},
{<<"required">>, [<<"resolution">>]}
]. ].
%% %%

View File

@ -81,19 +81,21 @@ handle_network_error({unknown, Reason} = Error, St) ->
thrift_judgement(). thrift_judgement().
encode_judgement({Resolution, _Assertions}) -> encode_judgement({Resolution, _Assertions}) ->
#bdcs_Judgement{ #bdcs_Judgement{
resolution_legacy = encode_resolution_legacy(Resolution),
resolution = encode_resolution(Resolution) resolution = encode_resolution(Resolution)
}. }.
encode_resolution_legacy(allowed) ->
allowed;
encode_resolution_legacy(forbidden) ->
forbidden.
encode_resolution(allowed) -> encode_resolution(allowed) ->
{allowed, #bdcs_ResolutionAllowed{}}; {allowed, #bdcs_ResolutionAllowed{}};
encode_resolution(forbidden) -> encode_resolution(forbidden) ->
{forbidden, #bdcs_ResolutionForbidden{}}. {forbidden, #bdcs_ResolutionForbidden{}};
encode_resolution({restricted, Restrictions}) ->
{restricted, #bdcs_ResolutionRestricted{
restrictions = encode_restrictions(Restrictions)
}}.
encode_restrictions(Restrictions) ->
{struct, _, StructDef} = bouncer_restriction_thrift:struct_info('Restrictions'),
bouncer_thrift:json_to_thrift_struct(StructDef, Restrictions, #brstn_Restrictions{}).
-spec decode_context(thrift_context(), st()) -> -spec decode_context(thrift_context(), st()) ->
{bouncer_context:ctx(), st()}. {bouncer_context:ctx(), st()}.

View File

@ -217,7 +217,8 @@ get_beat_metadata({judgement, Event}) ->
#{ #{
event => completed, event => completed,
resolution => encode_resolution(Resolution), resolution => encode_resolution(Resolution),
assertions => lists:map(fun encode_assertion/1, Assertions) assertions => lists:map(fun encode_assertion/1, Assertions),
restrictions => encode_restrictions(Resolution)
}; };
{failed, Error} -> {failed, Error} ->
#{ #{
@ -228,7 +229,15 @@ get_beat_metadata({judgement, Event}) ->
}. }.
encode_resolution(allowed) -> <<"allowed">>; encode_resolution(allowed) -> <<"allowed">>;
encode_resolution(forbidden) -> <<"forbidden">>. encode_resolution(forbidden) -> <<"forbidden">>;
encode_resolution({restricted, _Restrictions}) -> <<"restricted">>.
%% NOTE
%% I judged adding restrictions parsing to be not worth it for audit log
encode_restrictions({restricted, Restrictions}) ->
Restrictions;
encode_restrictions(_) ->
undefined.
encode_assertion({Code, Details}) -> encode_assertion({Code, Details}) ->
#{code => Code, details => Details}. #{code => Code, details => Details}.

View File

@ -58,39 +58,7 @@ from_thrift_context(Ctx) ->
bouncer_context_v1_thrift:struct_info('ContextFragment'), bouncer_context_v1_thrift:struct_info('ContextFragment'),
% NOTE % NOTE
% This 3 refers to the first data field in a ContextFragment, after version field. % This 3 refers to the first data field in a ContextFragment, after version field.
from_thrift_struct(StructDef, Ctx, 3, #{}). bouncer_thrift:from_thrift_struct(StructDef, Ctx, 3, #{}).
from_thrift_struct(StructDef, Struct) ->
% NOTE
% This 2 refers to the first field in a record tuple.
from_thrift_struct(StructDef, Struct, 2, #{}).
from_thrift_struct([{_, _Req, Type, Name, _Default} | Rest], Struct, Idx, Acc) ->
Acc1 = case element(Idx, Struct) of
V when V /= undefined ->
Acc#{Name => from_thrift_value(Type, V)};
undefined ->
Acc
end,
from_thrift_struct(Rest, Struct, Idx + 1, Acc1);
from_thrift_struct([], _Struct, _, Acc) ->
Acc.
from_thrift_value({struct, struct, {Mod, Name}}, V) ->
{struct, _, StructDef} = Mod:struct_info(Name),
from_thrift_struct(StructDef, V);
from_thrift_value({set, Type}, Vs) ->
ordsets:from_list([from_thrift_value(Type, V) || V <- ordsets:to_list(Vs)]);
from_thrift_value(string, V) ->
V;
from_thrift_value(i64, V) ->
V;
from_thrift_value(i32, V) ->
V;
from_thrift_value(i16, V) ->
V;
from_thrift_value(byte, V) ->
V.
-spec try_upgrade(thrift_ctx_fragment()) -> -spec try_upgrade(thrift_ctx_fragment()) ->
thrift_ctx_fragment(). thrift_ctx_fragment().
@ -103,61 +71,16 @@ try_upgrade(#bctx_v1_ContextFragment{vsn = ?BCTX_V1_HEAD} = Ctx) ->
{ok, _Content} | {error, _}. {ok, _Content} | {error, _}.
encode(thrift, Context) -> encode(thrift, Context) ->
Codec = thrift_strict_binary_codec:new(), Codec = thrift_strict_binary_codec:new(),
try to_thrift(Context) of CtxThrift = to_thrift(Context),
CtxThrift -> case thrift_strict_binary_codec:write(Codec, ?THRIFT_TYPE, CtxThrift) of
case thrift_strict_binary_codec:write(Codec, ?THRIFT_TYPE, CtxThrift) of {ok, Codec1} ->
{ok, Codec1} -> {ok, thrift_strict_binary_codec:close(Codec1)};
{ok, thrift_strict_binary_codec:close(Codec1)}; {error, _} = Error ->
{error, _} = Error -> Error
Error
end
catch throw:{?MODULE, Reason} ->
{error, Reason}
end. end.
-spec to_thrift(bouncer_context:ctx()) -> -spec to_thrift(bouncer_context:ctx()) ->
thrift_ctx_fragment() | no_return(). thrift_ctx_fragment() | no_return().
to_thrift(Context) -> to_thrift(Context) ->
{struct, _, StructDef} = bouncer_context_v1_thrift:struct_info('ContextFragment'), {struct, _, StructDef} = bouncer_context_v1_thrift:struct_info('ContextFragment'),
to_thrift_struct(StructDef, Context, #bctx_v1_ContextFragment{}). bouncer_thrift:to_thrift_struct(StructDef, Context, #bctx_v1_ContextFragment{}).
to_thrift_struct(StructDef, Map, Acc) ->
% NOTE
% This 2 refers to the first field in a record tuple.
to_thrift_struct(StructDef, Map, 2, Acc).
to_thrift_struct([{_Tag, _Req, Type, Name, Default} | Rest], Map, Idx, Acc) ->
case maps:take(Name, Map) of
{V, MapLeft} ->
Acc1 = erlang:setelement(Idx, Acc, to_thrift_value(Type, V)),
to_thrift_struct(Rest, MapLeft, Idx + 1, Acc1);
error when Default /= undefined ->
Acc1 = erlang:setelement(Idx, Acc, Default),
to_thrift_struct(Rest, Map, Idx + 1, Acc1);
error ->
to_thrift_struct(Rest, Map, Idx + 1, Acc)
end;
to_thrift_struct([], MapLeft, _Idx, Acc) ->
case map_size(MapLeft) of
0 ->
Acc;
_ ->
throw({?MODULE, {excess_context_data, MapLeft}})
end.
to_thrift_value({struct, struct, {Mod, Name}}, V = #{}) ->
{struct, _, StructDef} = Mod:struct_info(Name),
Acc = erlang:make_tuple(length(StructDef) + 1, undefined, [{1, Mod:record_name(Name)}]),
to_thrift_struct(StructDef, V, Acc);
to_thrift_value({set, Type}, Vs) ->
ordsets:from_list([to_thrift_value(Type, V) || V <- ordsets:to_list(Vs)]);
to_thrift_value(string, V) ->
V;
to_thrift_value(i64, V) ->
V;
to_thrift_value(i32, V) ->
V;
to_thrift_value(i16, V) ->
V;
to_thrift_value(byte, V) ->
V.

112
src/bouncer_thrift.erl Normal file
View File

@ -0,0 +1,112 @@
-module(bouncer_thrift).
%% API
-export([json_to_thrift_struct/3]).
-export([to_thrift_struct/3]).
-export([from_thrift_struct/4]).
-type struct_flavour() :: struct | exception | union.
-type field_num() :: pos_integer().
-type field_name() :: atom().
-type field_req() :: required | optional | undefined.
-type type_ref() :: {module(), atom()}.
-type field_type() ::
bool | byte | i16 | i32 | i64 | string | double |
{enum, type_ref()} |
{struct, struct_flavour(), type_ref()} |
{list, field_type()} |
{set, field_type()} |
{map, field_type(), field_type()}.
-type struct_field_info() ::
{field_num(), field_req(), field_type(), field_name(), any()}.
-spec json_to_thrift_struct([struct_field_info()], map(), tuple()) -> tuple().
json_to_thrift_struct(StructDef, Map, Acc) ->
% NOTE
% This 2 refers to the first field in a record tuple.
to_thrift_struct(StructDef, Map, 2, Acc, fun genlib:to_binary/1).
-spec to_thrift_struct([struct_field_info()], map(), tuple()) -> tuple().
to_thrift_struct(StructDef, Map, Acc) ->
% NOTE
% This 2 refers to the first field in a record tuple.
to_thrift_struct(StructDef, Map, 2, Acc, fun identity/1).
to_thrift_struct([{_Tag, _Req, Type, Name, Default} | Rest], Map, Idx, Acc, NameFun) ->
TransformedName = NameFun(Name),
case maps:take(TransformedName, Map) of
{V, MapLeft} ->
Acc1 = erlang:setelement(Idx, Acc, to_thrift_value(Type, V, NameFun)),
to_thrift_struct(Rest, MapLeft, Idx + 1, Acc1, NameFun);
error when Default /= undefined ->
Acc1 = erlang:setelement(Idx, Acc, Default),
to_thrift_struct(Rest, Map, Idx + 1, Acc1, NameFun);
error ->
to_thrift_struct(Rest, Map, Idx + 1, Acc, NameFun)
end;
to_thrift_struct([], MapLeft, _Idx, Acc, _NameFun) ->
case map_size(MapLeft) of
0 ->
Acc;
_ ->
%% Some part of map was left after converting to thrift,
%% indicating that either thrift structure doesn't have
%% enough fields or there's error in map creation
error({excess_data, MapLeft})
end.
to_thrift_value({struct, struct, {Mod, Name}}, V = #{}, NameFun) ->
{struct, _, StructDef} = Mod:struct_info(Name),
Acc = erlang:make_tuple(length(StructDef) + 1, undefined, [{1, Mod:record_name(Name)}]),
to_thrift_struct(StructDef, V, 2, Acc, NameFun);
to_thrift_value({set, Type}, Vs, NameFun) ->
ordsets:from_list([to_thrift_value(Type, V, NameFun) || V <- ordsets:to_list(Vs)]);
to_thrift_value(string, V, _NameFun) ->
V;
to_thrift_value(i64, V, _NameFun) ->
V;
to_thrift_value(i32, V, _NameFun) ->
V;
to_thrift_value(i16, V, _NameFun) ->
V;
to_thrift_value(byte, V, _NameFun) ->
V.
from_thrift_struct(StructDef, Struct) ->
% NOTE
% This 2 refers to the first field in a record tuple.
from_thrift_struct(StructDef, Struct, 2, #{}).
-spec from_thrift_struct([struct_field_info()], tuple(), number(), map()) -> map().
from_thrift_struct([{_, _Req, Type, Name, _Default} | Rest], Struct, Idx, Acc) ->
Acc1 =
case element(Idx, Struct) of
V when V /= undefined ->
Acc#{Name => from_thrift_value(Type, V)};
undefined ->
Acc
end,
from_thrift_struct(Rest, Struct, Idx + 1, Acc1);
from_thrift_struct([], _Struct, _, Acc) ->
Acc.
from_thrift_value({struct, struct, {Mod, Name}}, V) ->
{struct, _, StructDef} = Mod:struct_info(Name),
from_thrift_struct(StructDef, V);
from_thrift_value({set, Type}, Vs) ->
ordsets:from_list([from_thrift_value(Type, V) || V <- ordsets:to_list(Vs)]);
from_thrift_value(string, V) ->
V;
from_thrift_value(i64, V) ->
V;
from_thrift_value(i32, V) ->
V;
from_thrift_value(i16, V) ->
V;
from_thrift_value(byte, V) ->
V.
identity(V) ->
V.

View File

@ -25,7 +25,7 @@
-define(OPA_HOST, "opa"). -define(OPA_HOST, "opa").
-define(OPA_ENDPOINT, {?OPA_HOST, 8181}). -define(OPA_ENDPOINT, {?OPA_HOST, 8181}).
-define(API_RULESET_ID, "authz/api"). -define(API_RULESET_ID, "service/authz/api").
-spec all() -> -spec all() ->
[atom()]. [atom()].

View File

@ -26,7 +26,7 @@
-define(OPA_HOST, "opa"). -define(OPA_HOST, "opa").
-define(OPA_ENDPOINT, {?OPA_HOST, 8181}). -define(OPA_ENDPOINT, {?OPA_HOST, 8181}).
-define(API_RULESET_ID, "authz/api"). -define(API_RULESET_ID, "service/authz/api").
-spec all() -> -spec all() ->
[atom()]. [atom()].

View File

@ -21,7 +21,7 @@
-export([conflicting_context_invalid/1]). -export([conflicting_context_invalid/1]).
-export([distinct_sets_context_valid/1]). -export([distinct_sets_context_valid/1]).
-export([allowed_create_invoice_shop_manager/1]). -export([restricted_search_invoices_shop_manager/1]).
-export([forbidden_w_empty_context/1]). -export([forbidden_w_empty_context/1]).
-export([forbidden_expired/1]). -export([forbidden_expired/1]).
-export([forbidden_blacklisted_ip/1]). -export([forbidden_blacklisted_ip/1]).
@ -45,7 +45,7 @@
-define(OPA_HOST, "opa"). -define(OPA_HOST, "opa").
-define(OPA_ENDPOINT, {?OPA_HOST, 8181}). -define(OPA_ENDPOINT, {?OPA_HOST, 8181}).
-define(API_RULESET_ID, "authz/api"). -define(API_RULESET_ID, "service/authz/api").
-spec all() -> -spec all() ->
[atom()]. [atom()].
@ -72,7 +72,7 @@ groups() ->
distinct_sets_context_valid distinct_sets_context_valid
]}, ]},
{rules_authz_api, [parallel], [ {rules_authz_api, [parallel], [
allowed_create_invoice_shop_manager, restricted_search_invoices_shop_manager,
forbidden_expired, forbidden_expired,
forbidden_blacklisted_ip, forbidden_blacklisted_ip,
forbidden_w_empty_context forbidden_w_empty_context
@ -167,8 +167,8 @@ end_per_testcase(_Name, _C) ->
%% %%
-define(CONTEXT(Fragments), #bdcs_Context{fragments = Fragments}). -define(CONTEXT(Fragments), #bdcs_Context{fragments = Fragments}).
-define(JUDGEMENT(Resolution, ResolutionLegacy), -define(JUDGEMENT(Resolution),
#bdcs_Judgement{resolution = Resolution, resolution_legacy = ResolutionLegacy}). #bdcs_Judgement{resolution = Resolution}).
-spec missing_ruleset_notfound(config()) -> ok. -spec missing_ruleset_notfound(config()) -> ok.
-spec incorrect_ruleset_invalid1(config()) -> ok. -spec incorrect_ruleset_invalid1(config()) -> ok.
@ -199,7 +199,7 @@ incorrect_ruleset_invalid1(C) ->
), ),
?assertMatch( ?assertMatch(
{judgement, {failed, {ruleset_invalid, [ {judgement, {failed, {ruleset_invalid, [
{data_invalid, _, no_extra_properties_allowed, _, [<<"fordibben">>]} {data_invalid, _, wrong_size, _, [<<"resolution">>]}
]}}}, ]}}},
lists:last(flush_beats(Client, C)) lists:last(flush_beats(Client, C))
). ).
@ -212,7 +212,7 @@ incorrect_ruleset_invalid2(C) ->
), ),
?assertMatch( ?assertMatch(
{judgement, {failed, {ruleset_invalid, [ {judgement, {failed, {ruleset_invalid, [
{data_invalid, _, wrong_type, _, [<<"allowed">>]} {data_invalid, _, wrong_type, _, [<<"resolution">>, _]}
]}}}, ]}}},
lists:last(flush_beats(Client, C)) lists:last(flush_beats(Client, C))
). ).
@ -225,7 +225,7 @@ incorrect_ruleset_invalid3(C) ->
), ),
?assertMatch( ?assertMatch(
{judgement, {failed, {ruleset_invalid, [ {judgement, {failed, {ruleset_invalid, [
{data_invalid, _, missing_required_property, <<"code">>, _} {data_invalid, _, no_extra_items_allowed, [<<"forbidden">>, [#{}], #{}], _}
]}}}, ]}}},
lists:last(flush_beats(Client, C)) lists:last(flush_beats(Client, C))
). ).
@ -343,17 +343,17 @@ distinct_sets_context_valid(C) ->
%% %%
-spec allowed_create_invoice_shop_manager(config()) -> ok. -spec restricted_search_invoices_shop_manager(config()) -> ok.
-spec forbidden_expired(config()) -> ok. -spec forbidden_expired(config()) -> ok.
-spec forbidden_blacklisted_ip(config()) -> ok. -spec forbidden_blacklisted_ip(config()) -> ok.
-spec forbidden_w_empty_context(config()) -> ok. -spec forbidden_w_empty_context(config()) -> ok.
allowed_create_invoice_shop_manager(C) -> restricted_search_invoices_shop_manager(C) ->
Client = mk_client(C), Client = mk_client(C),
Fragment = lists:foldl(fun maps:merge/2, #{}, [ Fragment = lists:foldl(fun maps:merge/2, #{}, [
mk_auth_session_token(), mk_auth_session_token(),
mk_env(), mk_env(),
mk_op_create_invoice(<<"BLARG">>, <<"SHOP">>, <<"PARTY">>), mk_op_search_invoices(mk_ordset([#{id => <<"SHOP">>}]), <<"PARTY">>),
mk_user(<<"USER">>, mk_ordset([ mk_user(<<"USER">>, mk_ordset([
mk_user_org(<<"PARTY">>, <<"OWNER">>, mk_ordset([ mk_user_org(<<"PARTY">>, <<"OWNER">>, mk_ordset([
mk_role(<<"Manager">>, <<"SHOP">>) mk_role(<<"Manager">>, <<"SHOP">>)
@ -362,11 +362,11 @@ allowed_create_invoice_shop_manager(C) ->
]), ]),
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}), Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
?assertMatch( ?assertMatch(
?JUDGEMENT({allowed, #bdcs_ResolutionAllowed{}}, allowed), ?JUDGEMENT({restricted, #bdcs_ResolutionRestricted{}}),
call_judge(?API_RULESET_ID, Context, Client) call_judge(?API_RULESET_ID, Context, Client)
), ),
?assertMatch( ?assertMatch(
{judgement, {completed, {allowed, [{<<"user_has_role">>, _}]}}}, {judgement, {completed, {{restricted, _}, [{<<"org_role_allows_operation">>, _}]}}},
lists:last(flush_beats(Client, C)) lists:last(flush_beats(Client, C))
). ).
@ -381,7 +381,7 @@ forbidden_expired(C) ->
}), }),
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}), Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
?assertMatch( ?assertMatch(
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden), ?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
call_judge(?API_RULESET_ID, Context, Client) call_judge(?API_RULESET_ID, Context, Client)
), ),
?assertMatch( ?assertMatch(
@ -399,7 +399,7 @@ forbidden_blacklisted_ip(C) ->
]), ]),
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}), Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
?assertMatch( ?assertMatch(
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden), ?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
call_judge(?API_RULESET_ID, Context, Client) call_judge(?API_RULESET_ID, Context, Client)
), ),
?assertMatch( ?assertMatch(
@ -411,7 +411,7 @@ forbidden_w_empty_context(C) ->
Client1 = mk_client(C), Client1 = mk_client(C),
EmptyFragment = mk_ctx_v1_fragment(#{}), EmptyFragment = mk_ctx_v1_fragment(#{}),
?assertMatch( ?assertMatch(
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden), ?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
call_judge(?API_RULESET_ID, ?CONTEXT(#{}), Client1) call_judge(?API_RULESET_ID, ?CONTEXT(#{}), Client1)
), ),
?assertMatch( ?assertMatch(
@ -420,7 +420,7 @@ forbidden_w_empty_context(C) ->
), ),
Client2 = mk_client(C), Client2 = mk_client(C),
?assertMatch( ?assertMatch(
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden), ?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
call_judge(?API_RULESET_ID, ?CONTEXT(#{<<"empty">> => EmptyFragment}), Client2) call_judge(?API_RULESET_ID, ?CONTEXT(#{<<"empty">> => EmptyFragment}), Client2)
), ),
?assertMatch( ?assertMatch(
@ -453,12 +453,11 @@ mk_auth_session_token(ExpiresAt) ->
expiration => format_ts(ExpiresAt, second) expiration => format_ts(ExpiresAt, second)
}}. }}.
mk_op_create_invoice(InvoiceID, ShopID, PartyID) -> mk_op_search_invoices(Shops, PartyID) ->
#{capi => #{ #{anapi => #{
op => #{ op => #{
id => <<"CreateInvoice">>, id => <<"SearchInvoices">>,
invoice => #{id => InvoiceID}, shops => Shops,
shop => #{id => ShopID},
party => #{id => PartyID} party => #{id => PartyID}
} }
}}. }}.

View File

@ -1,118 +0,0 @@
package authz.api
import data.authz.blacklists
assertions := {
"forbidden" : { why | forbidden[why] },
"allowed" : { why | allowed[why] }
}
# Set of assertions which tell why operation under the input context is forbidden.
# When the set is empty operation is not explicitly forbidden.
# Each element must be an object of the following form:
# ```
# {"code": "...", ...}
# ```
forbidden[why] {
input
not input.auth.method
why := {
"code": "auth_required",
"description": "Authorization is required"
}
}
forbidden[why] {
exp := time.parse_rfc3339_ns(input.auth.expiration)
now := time.parse_rfc3339_ns(input.env.now)
now > exp
why := {
"code": "auth_expired",
"description": sprintf("Authorization is expired at: %s", [input.auth.expiration])
}
}
forbidden[why] {
ip := input.requester.ip
blacklist := blacklists["source-ip-range"]
matches := net.cidr_contains_matches(blacklist, ip)
ranges := [ range | matches[_][0] = i; range := blacklist[i] ]
why := {
"code": "ip_range_blacklisted",
"description": sprintf("Requester IP address is blacklisted with ranges: %v", [concat(", ", ranges)])
}
}
# Set of assertions which tell why operation under the input context is allowed.
# When the set is empty operation is not explicitly allowed.
# Each element must be an object of the following form:
# ```
# {"code": "...", ...}
# ```
allowed[why] {
input.auth.method == "SessionToken"
input.user
org_allowed[why]
}
org_allowed[why] {
org := org_by_operation
org.owner == input.user.id
why := {
"code": "user_is_owner"
}
}
org_allowed[why] {
rolename := role_by_operation[_]
org_by_operation.roles[i].id == rolename
scopename := scopename_by_role[i]
why := {
"code": "user_has_role",
"description": sprintf("User has role %s in scope %v", [rolename, scopename])
}
}
scopename_by_role[i] = sprintf("shop:%s", [shop]) {
role := org_by_operation.roles[i]
shop := role.scope.shop.id
shop == input.capi.op.shop.id
}
scopename_by_role[i] = "*" {
role := org_by_operation.roles[i]
not role.scope
}
# Set of roles at least one of which is required to perform the operation in context.
role_by_operation["Manager"]
{ input.capi.op.id == "CreateInvoice" }
{ input.capi.op.id == "GetInvoiceByID" }
{ input.capi.op.id == "GetInvoiceEvents" }
{ input.capi.op.id == "FulfillInvoice" }
{ input.capi.op.id == "RescindInvoice" }
{ input.capi.op.id == "GetPayments" }
{ input.capi.op.id == "GetPaymentByID" }
{ input.capi.op.id == "CancelPayment" }
{ input.capi.op.id == "CapturePayment" }
{ input.capi.op.id == "GetRefunds" }
{ input.capi.op.id == "GetRefundByID" }
{ input.capi.op.id == "CreateRefund" }
{ input.capi.op.id == "CreateInvoiceTemplate" }
{ input.capi.op.id == "GetInvoiceTemplateByID" }
{ input.capi.op.id == "UpdateInvoiceTemplate" }
{ input.capi.op.id == "DeleteInvoiceTemplate" }
role_by_operation["Administrator"]
{ input.orgmgmt.op.id == "ListInvitations" }
{ input.orgmgmt.op.id == "CreateInvitation" }
{ input.orgmgmt.op.id == "GetInvitation" }
{ input.orgmgmt.op.id == "RevokeInvitation" }
# Context of an organisation which is being operated upon.
org_by_operation = org_by_id[id]
{ id = input.capi.op.party.id }
{ id = input.orgmgmt.op.organization.id }
# A mapping of org ids to organizations.
org_by_id := { org.id: org | org := input.user.orgs[_] }

View File

@ -0,0 +1,111 @@
package service.authz.api
import data.service.authz.api.invoice_access_token
import data.service.authz.api.url_shortener
import data.service.authz.api.binapi
import data.service.authz.api.anapi
import data.service.authz.blacklists
import data.service.authz.whitelists
import data.service.authz.roles
import data.service.authz.org
import data.service.authz.judgement
assertions := {
"forbidden" : { why | forbidden[why] },
"allowed" : { why | allowed[why] },
"restrictions": { what.type: what.restrictions[what.type] | restrictions[what] }
}
judgement := judgement.judge(assertions)
# Set of assertions which tell why operation under the input context is forbidden.
# When the set is empty operation is not explicitly forbidden.
# Each element must be an object of the following form:
# ```
# {"code": "auth_expired", "description": "..."}
# ```
forbidden[why] {
input
not input.auth.method
why := {
"code": "auth_required",
"description": "Authorization is required"
}
}
forbidden[why] {
exp := time.parse_rfc3339_ns(input.auth.expiration)
now := time.parse_rfc3339_ns(input.env.now)
now > exp
why := {
"code": "auth_expired",
"description": sprintf("Authorization is expired at: %s", [input.auth.expiration])
}
}
forbidden[why] {
ip := input.requester.ip
blacklist := blacklists.source_ip_range
matches := net.cidr_contains_matches(blacklist, ip)
matches[_]
ranges := [ range | matches[_][0] = i; range := blacklist[i] ]
why := {
"code": "ip_range_blacklisted",
"description": sprintf(
"Requester IP address is blacklisted with ranges: %v",
[concat(", ", ranges)]
)
}
}
forbidden[why] {
input.anapi
anapi.forbidden[why]
}
warnings[why] {
not blacklists.source_ip_range
why := "Blacklist 'source_ip_range' is not defined, blacklisting by IP will NOT WORK."
}
warnings[why] {
not whitelists.bin_lookup_allowed_party_ids
why := "Whitelist 'bin_lookup_allowed_party_ids' is not defined, whitelisting by partyID will NOT WORK."
}
# Set of assertions which tell why operation under the input context is allowed.
# When the set is empty operation is not explicitly allowed.
# Each element must be an object of the following form:
# ```
# {"code": "auth_expired", "description": "..."}
# ```
allowed[why] {
input.shortener
url_shortener.allowed[why]
}
allowed[why] {
input.binapi
binapi.allowed[why]
}
allowed[why] {
input.auth.method == "InvoiceAccessToken"
invoice_access_token.allowed[why]
}
allowed[why] {
input.anapi
anapi.allowed[why]
}
# Restrictions
restrictions[what] {
input.anapi
rstns := anapi.restrictions[_]
what := {
"type": "anapi",
"restrictions": rstns
}
}

View File

@ -0,0 +1,80 @@
package service.authz.api.anapi
import input.anapi.op
import data.service.authz.roles
api_name := "AnalyticsAPI"
# Set of assertions which tell why operation under the input context is forbidden.
# Each element must be an object of the following form:
# ```
# {"code": "auth_expired", "description": "..."}
# ```
forbidden[why] {
input.auth.method != "SessionToken"
why := {
"code": "unknown_auth_method_forbids_operation",
"description": sprintf("Unknown auth method for this operation: %v", [input.auth.method])
}
}
# Restrictions
restrictions[what] {
not user_is_owner
user_has_any_role_for_op
what := {
"anapi": {
"op": {
"shops": [shop | shop := op_shop_in_scope[_]]
}
}
}
}
op_shop_in_scope[shop] {
some i
op.shops[i].id == user_roles_by_operation[_].scope.shop.id
shop := op.shops[i]
}
# Set of assertions which tell why operation under the input context is allowed.
# Each element must be an object of the following form:
# ```
# {"code": "auth_expired", "description": "..."}
# ```
allowed[why] {
user_is_owner
why := {
"code": "org_ownership_allows_operation",
"description": "User is owner of organization that is subject of this operation"
}
}
allowed[why] {
user_role_id := user_roles_by_operation[_].id
why := {
"code": "org_role_allows_operation",
"description": sprintf("User has role that permits this operation: %v", [user_role_id])
}
}
user_is_owner {
organization := org_by_operation
input.user.id == organization.owner.id
}
user_has_any_role_for_op {
user_roles_by_operation[_]
}
user_roles_by_operation[user_role] {
user_role := org_by_operation.roles[_]
op.id == roles.roles[user_role.id].apis[api_name].operations[_]
}
org_by_operation = org {
org := input.user.orgs[_]
org.id == op.party.id
}

View File

@ -0,0 +1,35 @@
package service.authz.judgement
judge(assertions) = jm {
count(assertions.forbidden) > 0
jm := {
"resolution": ["forbidden", assertions.forbidden]
}
}
judge(assertions) = jm {
count(assertions.forbidden) == 0
assertions.restrictions != {}
count(assertions.allowed) > 0
jm := {
"resolution": ["restricted", assertions.allowed],
"restrictions": assertions.restrictions
}
}
judge(assertions) = jm {
count(assertions.forbidden) == 0
assertions.restrictions == {}
count(assertions.allowed) > 0
jm := {
"resolution": ["allowed", assertions.allowed]
}
}
judge(assertions) = jm {
count(assertions.forbidden) == 0
count(assertions.allowed) == 0
jm := {
"resolution": ["forbidden", []]
}
}

View File

@ -0,0 +1,45 @@
{
"roles": {
"Manager" : {
"apis": {
"AnalyticsAPI" : {
"operations": [
"SearchInvoices",
"SearchPayments",
"SearchRefunds",
"SearchChargebacks"
]
}
}
},
"Administrator" : {
"apis": {
"AnalyticsAPI" : {
"operations": [
"SearchInvoices",
"SearchPayments",
"SearchRefunds",
"SearchPayouts",
"SearchReports",
"SearchChargebacks",
"GetReport",
"CreateReport",
"CancelReport",
"DownloadFile",
"GetPaymentsToolDistribution",
"GetPaymentsAmount",
"GetAveragePayment",
"GetPaymentsCount",
"GetPaymentsErrorDistribution",
"GetPaymentsSplitAmount",
"GetPaymentsSplitCount",
"GetRefundsAmount",
"GetCurrentBalances",
"GetPaymentsSubErrorDistribution",
"GetCurrentBalancesGroupByShop"
]
}
}
}
}
}

View File

@ -1,5 +1,5 @@
package trivial.incorrect1 package trivial.incorrect1
assertions := { judgement := {
"fordibben" : { } "resolution" : []
} }

View File

@ -1,8 +1,7 @@
package trivial.incorrect2 package trivial.incorrect2
assertions := { judgement := {
"forbidden" : forbidden, "resolution": ["forbidden", forbidden]
"allowed" : allowed
} }
forbidden[why] = description { forbidden[why] = description {
@ -11,10 +10,3 @@ forbidden[why] = description {
why := "auth_required" why := "auth_required"
description := "Authorization is required" description := "Authorization is required"
} }
allowed[why] = description {
input.auth.method == "SessionToken"
input.user
why := "its_a_user"
description := "Then why not?"
}

View File

@ -1,7 +1,7 @@
package trivial.incorrect3 package trivial.incorrect3
assertions := { judgement := {
"forbidden" : { why | forbidden[why] } "resolution": ["forbidden", forbidden, allowed]
} }
forbidden[why] { forbidden[why] {
@ -9,3 +9,10 @@ forbidden[why] {
not input.auth.method not input.auth.method
why := {} why := {}
} }
allowed[why] = description {
input.auth.method == "SessionToken"
input.user
why := "its_a_user"
description := "Then why not?"
}