Futureproof policy contract (#3)

In line w/ rbkmoney/bouncer-policies#1.

* Fix stop order in tests for robustness
* Add another testcase for invalid ruleset
* Enforce draft-04 JSON schema validation
This commit is contained in:
Andrew Mayorov 2020-10-21 17:17:29 +03:00 committed by GitHub
parent 9004e3fe50
commit 902f06949a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 84 additions and 54 deletions

View File

@ -8,15 +8,15 @@
%% This must be a path to some document with the subdocument of the following form:
%% ```
%% "assertions": {
%% "allowed": [["code", "description"], ...] // 0 or more, may be unset
%% "forbidden": [["code", "description"], ...] // 0 or more, may be unset
%% "allowed": [{"code": "...", ...}, ...] // 0 or more, may be unset
%% "forbidden": [{"code": "...", ...}, ...] // 0 or more, may be unset
%% }
%% ```
-type ruleset_id() :: iodata().
-type judgement() :: {resolution(), [assertion()]}.
-type resolution() :: allowed | forbidden.
-type assertion() :: {_Code :: binary(), _Description :: binary() | undefined}.
-type assertion() :: {_Code :: binary(), _Details :: #{binary() => _}}.
-export_type([judgement/0]).
-export_type([resolution/0]).
@ -70,30 +70,29 @@ infer_judgement(_Forbidden = [], _Allowed = []) ->
extract_assertions(Assertions) ->
[extract_assertion(E) || E <- Assertions].
extract_assertion([Code, Description]) ->
{Code, Description};
extract_assertion(Code) when is_binary(Code) ->
{Code, undefined}.
extract_assertion(Assertion = #{<<"code">> := Code}) ->
{Code, maps:without([<<"code">>], Assertion)}.
-spec get_judgement_schema() ->
jesse:schema().
get_judgement_schema() ->
% TODO
% Worth declaring in a separate file? Should be helpful w/ CI-like activities.
CodeSchema = [{<<"type">>, <<"string">>}, {<<"minLength">>, 1}],
AssertionsSchema = [
{<<"type">>, <<"array">>},
{<<"items">>, [{<<"oneOf">>, [
CodeSchema,
[
{<<"type">>, <<"array">>},
{<<"items">>, CodeSchema},
{<<"minItems">>, 1},
{<<"maxItems">>, 2}
]
]}]}
{<<"items">>, [
{<<"type">>, <<"object">>},
{<<"required">>, [<<"code">>]},
{<<"properties">>, [
{<<"code">>, [
{<<"type">>, <<"string">>},
{<<"minLength">>, 1}
]}
]}
]}
],
[
{<<"$schema">>, <<"http://json-schema.org/draft-04/schema#">>},
{<<"type">>, <<"object">>},
{<<"properties">>, [
{<<"allowed">>, AssertionsSchema},

View File

@ -13,7 +13,9 @@
-export([end_per_testcase/2]).
-export([missing_ruleset_notfound/1]).
-export([incorrect_ruleset_invalid/1]).
-export([incorrect_ruleset_invalid1/1]).
-export([incorrect_ruleset_invalid2/1]).
-export([incorrect_ruleset_invalid3/1]).
-export([missing_content_invalid_context/1]).
-export([junk_content_invalid_context/1]).
-export([conflicting_context_invalid/1]).
@ -61,7 +63,9 @@ groups() ->
[
{general, [parallel], [
missing_ruleset_notfound,
incorrect_ruleset_invalid,
incorrect_ruleset_invalid1,
incorrect_ruleset_invalid2,
incorrect_ruleset_invalid3,
missing_content_invalid_context,
junk_content_invalid_context,
conflicting_context_invalid,
@ -143,8 +147,8 @@ end_per_group(_Name, C) ->
stop_bouncer(C).
stop_bouncer(C) ->
with_config(stash, C, fun (Pid) -> ?assertEqual(ok, ct_stash:destroy(Pid)) end),
with_config(group_apps, C, fun (Apps) -> genlib_app:stop_unload_applications(Apps) end).
with_config(group_apps, C, fun (Apps) -> genlib_app:stop_unload_applications(Apps) end),
with_config(stash, C, fun (Pid) -> ?assertEqual(ok, ct_stash:destroy(Pid)) end).
-spec init_per_testcase(atom(), config()) ->
config().
@ -164,7 +168,9 @@ end_per_testcase(_Name, _C) ->
-define(JUDGEMENT(Resolution), #bdcs_Judgement{resolution = Resolution}).
-spec missing_ruleset_notfound(config()) -> ok.
-spec incorrect_ruleset_invalid(config()) -> ok.
-spec incorrect_ruleset_invalid1(config()) -> ok.
-spec incorrect_ruleset_invalid2(config()) -> ok.
-spec incorrect_ruleset_invalid3(config()) -> ok.
-spec missing_content_invalid_context(config()) -> ok.
-spec junk_content_invalid_context(config()) -> ok.
-spec conflicting_context_invalid(config()) -> ok.
@ -182,28 +188,43 @@ missing_ruleset_notfound(C) ->
lists:last(flush_beats(Client, C))
).
incorrect_ruleset_invalid(C) ->
Client1 = mk_client(C),
incorrect_ruleset_invalid1(C) ->
Client = mk_client(C),
?assertThrow(
#bdcs_InvalidRuleset{},
call_judge("trivial/incorrect1", ?CONTEXT(#{}), Client1)
call_judge("trivial/incorrect1", ?CONTEXT(#{}), Client)
),
?assertMatch(
{judgement, {failed, {ruleset_invalid, [
{data_invalid, _, no_extra_properties_allowed, _, [<<"fordibben">>]}
]}}},
lists:last(flush_beats(Client1, C))
),
Client2 = mk_client(C),
lists:last(flush_beats(Client, C))
).
incorrect_ruleset_invalid2(C) ->
Client = mk_client(C),
?assertThrow(
#bdcs_InvalidRuleset{},
call_judge("trivial/incorrect2", ?CONTEXT(#{}), Client2)
call_judge("trivial/incorrect2", ?CONTEXT(#{}), Client)
),
?assertMatch(
{judgement, {failed, {ruleset_invalid, [
{data_invalid, _, wrong_type, _, [<<"allowed">>]}
]}}},
lists:last(flush_beats(Client2, C))
lists:last(flush_beats(Client, C))
).
incorrect_ruleset_invalid3(C) ->
Client = mk_client(C),
?assertThrow(
#bdcs_InvalidRuleset{},
call_judge("trivial/incorrect3", ?CONTEXT(#{}), Client)
),
?assertMatch(
{judgement, {failed, {ruleset_invalid, [
{data_invalid, _, missing_required_property, <<"code">>, _}
]}}},
lists:last(flush_beats(Client, C))
).
missing_content_invalid_context(C) ->

View File

@ -9,27 +9,27 @@ 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 a 2-item array of the following form:
# Each element must be an object of the following form:
# ```
# ["code", "description"]
# {"code": "...", ...}
# ```
forbidden[why] {
input
not input.auth.method
why := [
"auth_required",
"Authorization is required"
]
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 := [
"auth_expired",
sprintf("Authorization is expired at: %s", [input.auth.expiration])
]
why := {
"code": "auth_expired",
"description": sprintf("Authorization is expired at: %s", [input.auth.expiration])
}
}
forbidden[why] {
@ -37,17 +37,17 @@ forbidden[why] {
blacklist := blacklists["source-ip-range"]
matches := net.cidr_contains_matches(blacklist, ip)
ranges := [ range | matches[_][0] = i; range := blacklist[i] ]
why := [
"ip_range_blacklisted",
sprintf("Requester IP address is blacklisted with ranges: %v", [concat(", ", ranges)])
]
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 a 2-item array of the following form:
# Each element must be an object of the following form:
# ```
# ["code", "description"]
# {"code": "...", ...}
# ```
allowed[why] {
input.auth.method == "SessionToken"
@ -58,20 +58,19 @@ allowed[why] {
org_allowed[why] {
org := org_by_operation
org.owner == input.user.id
why := [
"user_is_owner",
"User is the organisation owner itself"
]
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 := [
"user_has_role",
sprintf("User has role %s in scope %v", [rolename, scopename])
]
why := {
"code": "user_has_role",
"description": sprintf("User has role %s in scope %v", [rolename, scopename])
}
}
scopename_by_role[i] = sprintf("shop:%s", [shop]) {

View File

@ -0,0 +1,11 @@
package trivial.incorrect3
assertions := {
"forbidden" : { why | forbidden[why] }
}
forbidden[why] {
input
not input.auth.method
why := {}
}