mirror of
https://github.com/valitydev/bouncer.git
synced 2024-11-06 02:15:18 +00:00
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:
parent
2850275de0
commit
530f8e646f
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,3 +13,4 @@ Dockerfile
|
||||
docker-compose.yml
|
||||
rebar3.crashdump
|
||||
/_checkouts/
|
||||
/test/policies/bundle.tar.gz
|
@ -4,6 +4,6 @@
|
||||
|
||||
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.
|
||||
|
@ -106,7 +106,7 @@
|
||||
rules => [
|
||||
{elvis_style, invalid_dynamic_call, #{ignore => [
|
||||
% Uses thrift reflection through `struct_info/1`.
|
||||
bouncer_context_v1
|
||||
bouncer_thrift
|
||||
]}}
|
||||
]
|
||||
},
|
||||
|
@ -2,7 +2,7 @@
|
||||
[{<<"bear">>,{pkg,<<"bear">>,<<"0.8.7">>},2},
|
||||
{<<"bouncer_proto">>,
|
||||
{git,"git@github.com:rbkmoney/bouncer-proto.git",
|
||||
{ref,"aab9db7ecc0ff436acc81f3358ae52239559ce7b"}},
|
||||
{ref,"7e00d96d6d9cd183dff50c07e547c99f51acee7e"}},
|
||||
0},
|
||||
{<<"cache">>,{pkg,<<"cache">>,<<"2.2.0">>},1},
|
||||
{<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.1">>},2},
|
||||
|
@ -15,7 +15,7 @@
|
||||
-type ruleset_id() :: iodata().
|
||||
|
||||
-type judgement() :: {resolution(), [assertion()]}.
|
||||
-type resolution() :: allowed | forbidden.
|
||||
-type resolution() :: allowed | forbidden | {restricted, map()}.
|
||||
-type assertion() :: {_Code :: binary(), _Details :: #{binary() => _}}.
|
||||
|
||||
-export_type([judgement/0]).
|
||||
@ -35,7 +35,7 @@
|
||||
judge(RulesetID, Context) ->
|
||||
case mk_opa_client() of
|
||||
{ok, Client} ->
|
||||
Location = join_path(RulesetID, <<"/assertions">>),
|
||||
Location = join_path(RulesetID, <<"/judgement">>),
|
||||
case request_opa_document(Location, Context, Client) of
|
||||
{ok, Document} ->
|
||||
infer_judgement(Document);
|
||||
@ -53,19 +53,20 @@ judge(RulesetID, Context) ->
|
||||
infer_judgement(Document) ->
|
||||
case jesse:validate_with_schema(get_judgement_schema(), Document) of
|
||||
{ok, _} ->
|
||||
Allowed = maps:get(<<"allowed">>, Document, []),
|
||||
Forbidden = maps:get(<<"forbidden">>, Document, []),
|
||||
{ok, infer_judgement(Forbidden, Allowed)};
|
||||
{ok, parse_judgement(Document)};
|
||||
{error, Reason} ->
|
||||
{error, {ruleset_invalid, Reason}}
|
||||
end.
|
||||
|
||||
infer_judgement(Forbidden = [_ | _], _Allowed) ->
|
||||
{forbidden, extract_assertions(Forbidden)};
|
||||
infer_judgement(_Forbidden = [], Allowed = [_ | _]) ->
|
||||
{allowed, extract_assertions(Allowed)};
|
||||
infer_judgement(_Forbidden = [], _Allowed = []) ->
|
||||
{forbidden, []}.
|
||||
parse_judgement(#{<<"resolution">> := [<<"forbidden">>, Assertions]}) ->
|
||||
{forbidden, extract_assertions(Assertions)};
|
||||
parse_judgement(#{<<"resolution">> := [<<"allowed">>, Assertions]}) ->
|
||||
{allowed, extract_assertions(Assertions)};
|
||||
parse_judgement(#{
|
||||
<<"resolution">> := [<<"restricted">>, Assertions],
|
||||
<<"restrictions">> := Restrictions
|
||||
}) ->
|
||||
{{restricted, Restrictions}, extract_assertions(Assertions)}.
|
||||
|
||||
extract_assertions(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#">>},
|
||||
{<<"type">>, <<"object">>},
|
||||
{<<"properties">>, [
|
||||
{<<"allowed">>, AssertionsSchema},
|
||||
{<<"forbidden">>, AssertionsSchema}
|
||||
{<<"resolution">>, ResolutionSchema},
|
||||
{<<"restrictions">>, [
|
||||
{<<"type">>, <<"object">>}
|
||||
]}
|
||||
]},
|
||||
{<<"additionalProperties">>, false}
|
||||
{<<"additionalProperties">>, false},
|
||||
{<<"required">>, [<<"resolution">>]}
|
||||
].
|
||||
|
||||
%%
|
||||
|
@ -81,19 +81,21 @@ handle_network_error({unknown, Reason} = Error, St) ->
|
||||
thrift_judgement().
|
||||
encode_judgement({Resolution, _Assertions}) ->
|
||||
#bdcs_Judgement{
|
||||
resolution_legacy = encode_resolution_legacy(Resolution),
|
||||
resolution = encode_resolution(Resolution)
|
||||
}.
|
||||
|
||||
encode_resolution_legacy(allowed) ->
|
||||
allowed;
|
||||
encode_resolution_legacy(forbidden) ->
|
||||
forbidden.
|
||||
|
||||
encode_resolution(allowed) ->
|
||||
{allowed, #bdcs_ResolutionAllowed{}};
|
||||
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()) ->
|
||||
{bouncer_context:ctx(), st()}.
|
||||
|
@ -217,7 +217,8 @@ get_beat_metadata({judgement, Event}) ->
|
||||
#{
|
||||
event => completed,
|
||||
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} ->
|
||||
#{
|
||||
@ -228,7 +229,15 @@ get_beat_metadata({judgement, Event}) ->
|
||||
}.
|
||||
|
||||
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}) ->
|
||||
#{code => Code, details => Details}.
|
||||
|
@ -58,39 +58,7 @@ from_thrift_context(Ctx) ->
|
||||
bouncer_context_v1_thrift:struct_info('ContextFragment'),
|
||||
% NOTE
|
||||
% This 3 refers to the first data field in a ContextFragment, after version field.
|
||||
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.
|
||||
bouncer_thrift:from_thrift_struct(StructDef, Ctx, 3, #{}).
|
||||
|
||||
-spec try_upgrade(thrift_ctx_fragment()) ->
|
||||
thrift_ctx_fragment().
|
||||
@ -103,61 +71,16 @@ try_upgrade(#bctx_v1_ContextFragment{vsn = ?BCTX_V1_HEAD} = Ctx) ->
|
||||
{ok, _Content} | {error, _}.
|
||||
encode(thrift, Context) ->
|
||||
Codec = thrift_strict_binary_codec:new(),
|
||||
try to_thrift(Context) of
|
||||
CtxThrift ->
|
||||
case thrift_strict_binary_codec:write(Codec, ?THRIFT_TYPE, CtxThrift) of
|
||||
{ok, Codec1} ->
|
||||
{ok, thrift_strict_binary_codec:close(Codec1)};
|
||||
{error, _} = Error ->
|
||||
Error
|
||||
end
|
||||
catch throw:{?MODULE, Reason} ->
|
||||
{error, Reason}
|
||||
CtxThrift = to_thrift(Context),
|
||||
case thrift_strict_binary_codec:write(Codec, ?THRIFT_TYPE, CtxThrift) of
|
||||
{ok, Codec1} ->
|
||||
{ok, thrift_strict_binary_codec:close(Codec1)};
|
||||
{error, _} = Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
-spec to_thrift(bouncer_context:ctx()) ->
|
||||
thrift_ctx_fragment() | no_return().
|
||||
to_thrift(Context) ->
|
||||
{struct, _, StructDef} = bouncer_context_v1_thrift:struct_info('ContextFragment'),
|
||||
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.
|
||||
bouncer_thrift:to_thrift_struct(StructDef, Context, #bctx_v1_ContextFragment{}).
|
||||
|
112
src/bouncer_thrift.erl
Normal file
112
src/bouncer_thrift.erl
Normal 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.
|
@ -25,7 +25,7 @@
|
||||
|
||||
-define(OPA_HOST, "opa").
|
||||
-define(OPA_ENDPOINT, {?OPA_HOST, 8181}).
|
||||
-define(API_RULESET_ID, "authz/api").
|
||||
-define(API_RULESET_ID, "service/authz/api").
|
||||
|
||||
-spec all() ->
|
||||
[atom()].
|
||||
|
@ -26,7 +26,7 @@
|
||||
|
||||
-define(OPA_HOST, "opa").
|
||||
-define(OPA_ENDPOINT, {?OPA_HOST, 8181}).
|
||||
-define(API_RULESET_ID, "authz/api").
|
||||
-define(API_RULESET_ID, "service/authz/api").
|
||||
|
||||
-spec all() ->
|
||||
[atom()].
|
||||
|
@ -21,7 +21,7 @@
|
||||
-export([conflicting_context_invalid/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_expired/1]).
|
||||
-export([forbidden_blacklisted_ip/1]).
|
||||
@ -45,7 +45,7 @@
|
||||
|
||||
-define(OPA_HOST, "opa").
|
||||
-define(OPA_ENDPOINT, {?OPA_HOST, 8181}).
|
||||
-define(API_RULESET_ID, "authz/api").
|
||||
-define(API_RULESET_ID, "service/authz/api").
|
||||
|
||||
-spec all() ->
|
||||
[atom()].
|
||||
@ -72,7 +72,7 @@ groups() ->
|
||||
distinct_sets_context_valid
|
||||
]},
|
||||
{rules_authz_api, [parallel], [
|
||||
allowed_create_invoice_shop_manager,
|
||||
restricted_search_invoices_shop_manager,
|
||||
forbidden_expired,
|
||||
forbidden_blacklisted_ip,
|
||||
forbidden_w_empty_context
|
||||
@ -167,8 +167,8 @@ end_per_testcase(_Name, _C) ->
|
||||
%%
|
||||
|
||||
-define(CONTEXT(Fragments), #bdcs_Context{fragments = Fragments}).
|
||||
-define(JUDGEMENT(Resolution, ResolutionLegacy),
|
||||
#bdcs_Judgement{resolution = Resolution, resolution_legacy = ResolutionLegacy}).
|
||||
-define(JUDGEMENT(Resolution),
|
||||
#bdcs_Judgement{resolution = Resolution}).
|
||||
|
||||
-spec missing_ruleset_notfound(config()) -> ok.
|
||||
-spec incorrect_ruleset_invalid1(config()) -> ok.
|
||||
@ -199,7 +199,7 @@ incorrect_ruleset_invalid1(C) ->
|
||||
),
|
||||
?assertMatch(
|
||||
{judgement, {failed, {ruleset_invalid, [
|
||||
{data_invalid, _, no_extra_properties_allowed, _, [<<"fordibben">>]}
|
||||
{data_invalid, _, wrong_size, _, [<<"resolution">>]}
|
||||
]}}},
|
||||
lists:last(flush_beats(Client, C))
|
||||
).
|
||||
@ -212,7 +212,7 @@ incorrect_ruleset_invalid2(C) ->
|
||||
),
|
||||
?assertMatch(
|
||||
{judgement, {failed, {ruleset_invalid, [
|
||||
{data_invalid, _, wrong_type, _, [<<"allowed">>]}
|
||||
{data_invalid, _, wrong_type, _, [<<"resolution">>, _]}
|
||||
]}}},
|
||||
lists:last(flush_beats(Client, C))
|
||||
).
|
||||
@ -225,7 +225,7 @@ incorrect_ruleset_invalid3(C) ->
|
||||
),
|
||||
?assertMatch(
|
||||
{judgement, {failed, {ruleset_invalid, [
|
||||
{data_invalid, _, missing_required_property, <<"code">>, _}
|
||||
{data_invalid, _, no_extra_items_allowed, [<<"forbidden">>, [#{}], #{}], _}
|
||||
]}}},
|
||||
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_blacklisted_ip(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),
|
||||
Fragment = lists:foldl(fun maps:merge/2, #{}, [
|
||||
mk_auth_session_token(),
|
||||
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_org(<<"PARTY">>, <<"OWNER">>, mk_ordset([
|
||||
mk_role(<<"Manager">>, <<"SHOP">>)
|
||||
@ -362,11 +362,11 @@ allowed_create_invoice_shop_manager(C) ->
|
||||
]),
|
||||
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
|
||||
?assertMatch(
|
||||
?JUDGEMENT({allowed, #bdcs_ResolutionAllowed{}}, allowed),
|
||||
?JUDGEMENT({restricted, #bdcs_ResolutionRestricted{}}),
|
||||
call_judge(?API_RULESET_ID, Context, Client)
|
||||
),
|
||||
?assertMatch(
|
||||
{judgement, {completed, {allowed, [{<<"user_has_role">>, _}]}}},
|
||||
{judgement, {completed, {{restricted, _}, [{<<"org_role_allows_operation">>, _}]}}},
|
||||
lists:last(flush_beats(Client, C))
|
||||
).
|
||||
|
||||
@ -381,7 +381,7 @@ forbidden_expired(C) ->
|
||||
}),
|
||||
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
|
||||
?assertMatch(
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden),
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
|
||||
call_judge(?API_RULESET_ID, Context, Client)
|
||||
),
|
||||
?assertMatch(
|
||||
@ -399,7 +399,7 @@ forbidden_blacklisted_ip(C) ->
|
||||
]),
|
||||
Context = ?CONTEXT(#{<<"root">> => mk_ctx_v1_fragment(Fragment)}),
|
||||
?assertMatch(
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden),
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
|
||||
call_judge(?API_RULESET_ID, Context, Client)
|
||||
),
|
||||
?assertMatch(
|
||||
@ -411,7 +411,7 @@ forbidden_w_empty_context(C) ->
|
||||
Client1 = mk_client(C),
|
||||
EmptyFragment = mk_ctx_v1_fragment(#{}),
|
||||
?assertMatch(
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden),
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
|
||||
call_judge(?API_RULESET_ID, ?CONTEXT(#{}), Client1)
|
||||
),
|
||||
?assertMatch(
|
||||
@ -420,7 +420,7 @@ forbidden_w_empty_context(C) ->
|
||||
),
|
||||
Client2 = mk_client(C),
|
||||
?assertMatch(
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}, forbidden),
|
||||
?JUDGEMENT({forbidden, #bdcs_ResolutionForbidden{}}),
|
||||
call_judge(?API_RULESET_ID, ?CONTEXT(#{<<"empty">> => EmptyFragment}), Client2)
|
||||
),
|
||||
?assertMatch(
|
||||
@ -453,12 +453,11 @@ mk_auth_session_token(ExpiresAt) ->
|
||||
expiration => format_ts(ExpiresAt, second)
|
||||
}}.
|
||||
|
||||
mk_op_create_invoice(InvoiceID, ShopID, PartyID) ->
|
||||
#{capi => #{
|
||||
mk_op_search_invoices(Shops, PartyID) ->
|
||||
#{anapi => #{
|
||||
op => #{
|
||||
id => <<"CreateInvoice">>,
|
||||
invoice => #{id => InvoiceID},
|
||||
shop => #{id => ShopID},
|
||||
id => <<"SearchInvoices">>,
|
||||
shops => Shops,
|
||||
party => #{id => PartyID}
|
||||
}
|
||||
}}.
|
||||
|
@ -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[_] }
|
111
test/policies/service/authz/api.rego
Normal file
111
test/policies/service/authz/api.rego
Normal 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
|
||||
}
|
||||
}
|
80
test/policies/service/authz/api/anapi.rego
Normal file
80
test/policies/service/authz/api/anapi.rego
Normal 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
|
||||
}
|
35
test/policies/service/authz/judgement.rego
Normal file
35
test/policies/service/authz/judgement.rego
Normal 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", []]
|
||||
}
|
||||
}
|
45
test/policies/service/authz/roles/data.json
Normal file
45
test/policies/service/authz/roles/data.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
package trivial.incorrect1
|
||||
|
||||
assertions := {
|
||||
"fordibben" : { }
|
||||
judgement := {
|
||||
"resolution" : []
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
package trivial.incorrect2
|
||||
|
||||
assertions := {
|
||||
"forbidden" : forbidden,
|
||||
"allowed" : allowed
|
||||
judgement := {
|
||||
"resolution": ["forbidden", forbidden]
|
||||
}
|
||||
|
||||
forbidden[why] = description {
|
||||
@ -11,10 +10,3 @@ forbidden[why] = description {
|
||||
why := "auth_required"
|
||||
description := "Authorization is required"
|
||||
}
|
||||
|
||||
allowed[why] = description {
|
||||
input.auth.method == "SessionToken"
|
||||
input.user
|
||||
why := "its_a_user"
|
||||
description := "Then why not?"
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
package trivial.incorrect3
|
||||
|
||||
assertions := {
|
||||
"forbidden" : { why | forbidden[why] }
|
||||
judgement := {
|
||||
"resolution": ["forbidden", forbidden, allowed]
|
||||
}
|
||||
|
||||
forbidden[why] {
|
||||
@ -9,3 +9,10 @@ forbidden[why] {
|
||||
not input.auth.method
|
||||
why := {}
|
||||
}
|
||||
|
||||
allowed[why] = description {
|
||||
input.auth.method == "SessionToken"
|
||||
input.user
|
||||
why := "its_a_user"
|
||||
description := "Then why not?"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user