diff --git a/ast/compile.go b/ast/compile.go index e499e273df..62c843be44 100644 --- a/ast/compile.go +++ b/ast/compile.go @@ -3518,8 +3518,12 @@ func getGlobals(pkg *Package, rules []Var, imports []*Import) map[Var]*usedRef { } // Populate globals with imports. - for _, i := range imports { - globals[i.Name()] = &usedRef{ref: i.Path.Value.(Ref)} + for _, imp := range imports { + path := imp.Path.Value.(Ref) + if FutureRootDocument.Equal(path[0]) { + continue // ignore future imports + } + globals[imp.Name()] = &usedRef{ref: path} } return globals diff --git a/ast/internal/tokens/tokens.go b/ast/internal/tokens/tokens.go index baaed4ce62..29bf971d34 100644 --- a/ast/internal/tokens/tokens.go +++ b/ast/internal/tokens/tokens.go @@ -67,6 +67,8 @@ const ( Semicolon Every + Contains + If ) var strings = [...]string{ @@ -115,6 +117,8 @@ var strings = [...]string{ Dot: ".", Semicolon: ";", Every: "every", + Contains: "contains", + If: "if", } var keywords = map[string]Token{ diff --git a/ast/parser.go b/ast/parser.go index 842d518216..33a3749730 100644 --- a/ast/parser.go +++ b/ast/parser.go @@ -543,7 +543,8 @@ func (p *Parser) parseRules() []*Rule { return nil } - if rule.Head = p.parseHead(rule.Default); rule.Head == nil { + usesContains := false + if rule.Head, usesContains = p.parseHead(rule.Default); rule.Head == nil { return nil } @@ -556,13 +557,52 @@ func (p *Parser) parseRules() []*Rule { return []*Rule{&rule} } - if p.s.tok == tokens.LBrace { + hasIf := false + if p.s.tok == tokens.If { + hasIf = true + } + + if hasIf && !usesContains && rule.Head.Key != nil && rule.Head.Value == nil { + p.illegal("invalid for partial set rule %s (use `contains`)", rule.Head.Name) + return nil + } + + switch { + case hasIf: + p.scan() + s := p.save() + if expr := p.parseLiteral(); expr != nil { + // NOTE(sr): set literals are never false or undefined, so parsing this as + // p if { true } + // ^^^^^^^^ set of one element, `true` + // isn't valid. + isSetLiteral := false + if t, ok := expr.Terms.(*Term); ok { + _, isSetLiteral = t.Value.(Set) + } + // expr.Term is []*Term or Every + if !isSetLiteral { + rule.Body.Append(expr) + break + } + } + + // parsing as literal didn't work out, expect '{ BODY }' + p.restore(s) + fallthrough + + case p.s.tok == tokens.LBrace: p.scan() if rule.Body = p.parseBody(tokens.RBrace); rule.Body == nil { return nil } p.scan() - } else { + + case usesContains: + rule.Body = NewBody(NewExpr(BooleanTerm(true).SetLocation(rule.Location)).SetLocation(rule.Location)) + return []*Rule{&rule} + + default: return nil } @@ -667,7 +707,7 @@ func (p *Parser) parseElse(head *Head) *Rule { return &rule } -func (p *Parser) parseHead(defaultRule bool) *Head { +func (p *Parser) parseHead(defaultRule bool) (*Head, bool) { var head Head head.SetLoc(p.s.Loc()) @@ -689,13 +729,13 @@ func (p *Parser) parseHead(defaultRule bool) *Head { if p.s.tok != tokens.RParen { head.Args = p.parseTermList(tokens.RParen, nil) if head.Args == nil { - return nil + return nil, false } } p.scan() if p.s.tok == tokens.LBrack { - return nil + return nil, false } } @@ -714,13 +754,23 @@ func (p *Parser) parseHead(defaultRule bool) *Head { p.scan() } - if p.s.tok == tokens.Unify { + switch p.s.tok { + case tokens.Contains: + p.scan() + head.Key = p.parseTermInfixCall() + if head.Key == nil { + p.illegal("expected rule key term (e.g., %s contains { ... })", head.Name) + } + + return &head, true + case tokens.Unify: p.scan() head.Value = p.parseTermInfixCall() if head.Value == nil { p.illegal("expected rule value term (e.g., %s[%s] = { ... })", head.Name, head.Key) } - } else if p.s.tok == tokens.Assign { + + case tokens.Assign: s := p.save() p.scan() head.Assign = true @@ -744,7 +794,7 @@ func (p *Parser) parseHead(defaultRule bool) *Head { head.Value = BooleanTerm(true).SetLocation(head.Location) } - return &head + return &head, false } func (p *Parser) parseBody(end tokens.Token) Body { @@ -1243,7 +1293,7 @@ func (p *Parser) parseTerm() *Term { term = p.parseNumber() case tokens.String: term = p.parseString() - case tokens.Ident: + case tokens.Ident, tokens.Contains: // NOTE(sr): contains anywhere BUT in rule heads gets no special treatment term = p.parseVar() case tokens.LBrack: term = p.parseArray() @@ -2269,8 +2319,10 @@ func convertYAMLMapKeyTypes(x interface{}, path []string) (interface{}, error) { // futureKeywords is the source of truth for future keywords that will // eventually become standard keywords inside of Rego. var futureKeywords = map[string]tokens.Token{ - "in": tokens.In, - "every": tokens.Every, + "in": tokens.In, + "every": tokens.Every, + "contains": tokens.Contains, + "if": tokens.If, } func (p *Parser) futureImport(imp *Import, allowedFutureKeywords map[string]tokens.Token) { diff --git a/ast/parser_test.go b/ast/parser_test.go index e52bc66b72..3e16e572c3 100644 --- a/ast/parser_test.go +++ b/ast/parser_test.go @@ -1200,7 +1200,7 @@ func TestImport(t *testing.T) { func TestFutureImports(t *testing.T) { assertParseErrorContains(t, "future", "import future", "invalid import, must be `future.keywords`") assertParseErrorContains(t, "future.a", "import future.a", "invalid import, must be `future.keywords`") - assertParseErrorContains(t, "unknown keyword", "import future.keywords.xyz", "unexpected keyword, must be one of [every in]") + assertParseErrorContains(t, "unknown keyword", "import future.keywords.xyz", "unexpected keyword, must be one of [contains every if in]") assertParseErrorContains(t, "all keyword import + alias", "import future.keywords as xyz", "`future` imports cannot be aliased") assertParseErrorContains(t, "keyword import + alias", "import future.keywords.in as xyz", "`future` imports cannot be aliased") @@ -1591,6 +1591,244 @@ func TestRule(t *testing.T) { assertParseError(t, "invalid rule body no newline", `p { a b c }`) } +func TestRuleContains(t *testing.T) { + opts := ParserOptions{FutureKeywords: []string{"contains"}} + + tests := []struct { + note string + rule string + exp *Rule + }{ + { + note: "simple", + rule: `p contains "x" { true }`, + exp: &Rule{ + Head: NewHead(Var("p"), StringTerm("x")), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "no body", + rule: `p contains "x"`, + exp: &Rule{ + Head: NewHead(Var("p"), StringTerm("x")), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "set with var element", + rule: `deny contains msg { msg := "nonono" }`, + exp: &Rule{ + Head: NewHead(Var("deny"), VarTerm("msg")), + Body: MustParseBody(`msg := "nonono"`), + }, + }, + { + note: "set with object elem", + rule: `deny contains {"allow": false, "msg": msg} { msg := "nonono" }`, + exp: &Rule{ + Head: NewHead(Var("deny"), MustParseTerm(`{"allow": false, "msg": msg}`)), + Body: MustParseBody(`msg := "nonono"`), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + assertParseRule(t, tc.note, tc.rule, tc.exp, opts) + }) + } +} + +func TestRuleIf(t *testing.T) { + opts := ParserOptions{FutureKeywords: []string{"contains", "if", "every"}} + + tests := []struct { + note string + rule string + exp *Rule + }{ + { + note: "complete", + rule: `p if { true }`, + exp: &Rule{ + Head: NewHead(Var("p"), nil, BooleanTerm(true)), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "complete, normal body", + rule: `p if { x := 10; x > y }`, + exp: &Rule{ + Head: NewHead(Var("p"), nil, BooleanTerm(true)), + Body: MustParseBody(`x := 10; x > y`), + }, + }, + { + note: "complete+else, normal bodies, assign", + rule: `p := "yes" if { 10 > y } else := "no" { 10 <= y }`, + exp: &Rule{ + Head: &Head{ + Name: Var("p"), + Value: StringTerm("yes"), + Assign: true, + }, + Body: MustParseBody(`10 > y`), + Else: &Rule{ + Head: &Head{ + Name: Var("p"), + Value: StringTerm("no"), + Assign: true, + }, + Body: MustParseBody(`10 <= y`), + }, + }, + }, + { + note: "complete, shorthand", + rule: `p if true`, + exp: &Rule{ + Head: NewHead(Var("p"), nil, BooleanTerm(true)), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "complete+not, shorthand", + rule: `p if not q`, + exp: &Rule{ + Head: NewHead(Var("p"), nil, BooleanTerm(true)), + Body: MustParseBody(`not q`), + }, + }, + { + note: "complete+else, shorthand", + rule: `p if 1 > 2 else = 42 { 2 > 1 }`, + exp: &Rule{ + Head: NewHead(Var("p"), nil, BooleanTerm(true)), + Body: MustParseBody(`1 > 2`), + Else: &Rule{ + Head: &Head{ + Name: Var("p"), + Value: NumberTerm("42"), + }, + Body: MustParseBody(`2 > 1`), + }, + }, + }, + { + note: "complete+call, shorthand", + rule: `p if count(q) > 0`, + exp: &Rule{ + Head: NewHead(Var("p"), nil, BooleanTerm(true)), + Body: MustParseBody(`count(q) > 0`), + }, + }, + { + note: "function, shorthand", + rule: `f(x) = y if y := x + 1`, + exp: &Rule{ + Head: &Head{ + Name: Var("f"), + Args: []*Term{VarTerm("x")}, + Value: VarTerm("y"), + }, + Body: MustParseBody(`y := x + 1`), + }, + }, + { + note: "function+every, shorthand", + rule: `f(xs) if every x in xs { x != 0 }`, + exp: &Rule{ + Head: &Head{ + Name: Var("f"), + Args: []*Term{VarTerm("xs")}, + Value: BooleanTerm(true), + }, + Body: MustParseBodyWithOpts(`every x in xs { x != 0 }`, opts), + }, + }, + { + note: "object", + rule: `p["foo"] = "bar" if { true }`, + exp: &Rule{ + Head: NewHead(Var("p"), StringTerm("foo"), StringTerm("bar")), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "object, shorthand", + rule: `p["foo"] = "bar" if true`, + exp: &Rule{ + Head: NewHead(Var("p"), StringTerm("foo"), StringTerm("bar")), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "object with vars", + rule: `p[x] = y if { + x := "foo" + y := "bar" + }`, + exp: &Rule{ + Head: NewHead(Var("p"), VarTerm("x"), VarTerm("y")), + Body: MustParseBody(`x := "foo"; y := "bar"`), + }, + }, + { + note: "set", + rule: `p contains "foo" if { true }`, + exp: &Rule{ + Head: NewHead(Var("p"), StringTerm("foo")), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "set, shorthand", + rule: `p contains "foo" if true`, + exp: &Rule{ + Head: NewHead(Var("p"), StringTerm("foo")), + Body: NewBody(NewExpr(BooleanTerm(true))), + }, + }, + { + note: "set+var+shorthand", + rule: `p contains x if { x := "foo" }`, + exp: &Rule{ + Head: NewHead(Var("p"), VarTerm("x")), + Body: MustParseBody(`x := "foo"`), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + assertParseRule(t, tc.note, tc.rule, tc.exp, opts) + }) + } + + errors := []struct { + note string + rule string + err string + }{ + { + note: "partial set+if, shorthand", + rule: `p[x] if x := 1`, + err: "rego_parse_error: unexpected if keyword: invalid for partial set rule p (use `contains`)", + }, + { + note: "partial set+if", + rule: `p[x] if { x := 1 }`, + err: "rego_parse_error: unexpected if keyword: invalid for partial set rule p (use `contains`)", + }, + } + for _, tc := range errors { + t.Run(tc.note, func(t *testing.T) { + assertParseErrorContains(t, tc.note, tc.rule, tc.err, opts) + }) + } +} + func TestRuleElseKeyword(t *testing.T) { mod := `package test @@ -4004,7 +4242,7 @@ func assertParseOne(t *testing.T, msg string, input string, correct func(interfa return } if len(stmts) != 1 { - t.Errorf("Error on test \"%s\": parse error on %s: expected exactly one statement, got %d", msg, input, len(stmts)) + t.Errorf("Error on test \"%s\": parse error on %s: expected exactly one statement, got %d: %v", msg, input, len(stmts), stmts) return } correct(stmts[0]) diff --git a/capabilities.json b/capabilities.json index 03c99cffaf..2a57613fb0 100644 --- a/capabilities.json +++ b/capabilities.json @@ -4058,7 +4058,9 @@ } ], "future_keywords": [ + "contains", "every", + "if", "in" ], "wasm_abi_versions": [ diff --git a/docs/content/_index.md b/docs/content/_index.md index c63b3eb9b5..91564c3a7d 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -108,18 +108,30 @@ Rego (pronounced "ray-go") is purpose-built for expressing policies over complex hierarchical data structures. For detailed information on Rego see the [Policy Language](policy-language) documentation. -> 💡 The examples below are interactive! If you edit the input data above +{{< info >}} +💡 The examples below are interactive! If you edit the input data above containing servers, networks, and ports, the output will change below. Similarly, if you edit the queries or rules in the examples below the output will change. As you read through this section, try changing the input, queries, and rules and observe the difference in output. -> -> 💻 They can also be run locally on your machine using the [`opa eval` command, here are setup instructions.](#running-opa) + +💻 They can also be run locally on your machine using the [`opa eval` command, here are setup instructions.](#running-opa) + +Note that the examples in this section try to represent the best practices. +As such, they make use of keywords that are meant to become standard keywords +at some point in time, but have been introduced gradually. These _future +keywords_ can be enabled using + +```live:eg/import:module:read_only +import future.keywords +``` +{{< /info >}} ### References ```live:example/refs:module:hidden package example +import future.keywords ``` When OPA evaluates policies it binds data provided in the query to a global @@ -156,6 +168,7 @@ input.deadbeef ```live:example/exprs:module:hidden package example +import future.keywords ``` To produce policy decisions in Rego you write expressions against input and @@ -214,6 +227,7 @@ input.servers[0].protocols[0] == "telnet" ```live:example/vars:module:hidden package example +import future.keywords ``` You can store values in intermediate variables using the `:=` (assignment) @@ -265,6 +279,7 @@ x != y # y has not been assigned a value ```live:example/iter:module:hidden package example +import future.keywords ``` Like other declarative languages (e.g., SQL), iteration in Rego happens @@ -386,19 +401,17 @@ It introduces new bindings to the evaluation of the rest of the rule body. Using `some`, we can express the rules introduced above in different ways: ```live:example/iter/some1:module:merge_down -import future.keywords.in - -public_network[net.id] { # net.id is in the public_network set if... - some net in input.networks # some network exists and.. - net.public # it is public. +public_network contains net.id if { + some net in input.networks # some network exists and.. + net.public # it is public. } -shell_accessible[server.id] { +shell_accessible contains server.id if { some server in input.servers "telnet" in server.protocols } -shell_accessible[server.id] { +shell_accessible contains server.id if { some server in input.servers "ssh" in server.protocols } @@ -417,9 +430,7 @@ Expanding on the examples above, `every` allows us to succinctly express that a condition holds for all elements of a domain. ```live:example/iter/every2:module:merge_down -import future.keywords.every - -no_telnet_exposed { +no_telnet_exposed if { every server in input.servers { every protocol in server.protocols { "telnet" != protocol @@ -427,17 +438,17 @@ no_telnet_exposed { } } -no_telnet_exposed_alt { # alternative: every + not-in +no_telnet_exposed_alt if { # alternative: every + not-in every server in input.servers { not "telnet" in server.protocols } } -no_telnet_exposed_alt2 { # alternative: not + rule + some +no_telnet_exposed_alt2 if { # alternative: not + rule + some not any_telnet_exposed } -any_telnet_exposed { +any_telnet_exposed if { some server in input.servers "telnet" in server.protocols } @@ -473,24 +484,27 @@ For all the details, see [Every Keyword](policy-language/#every-keyword). Rego lets you encapsulate and re-use logic with rules. Rules are just if-then logic statements. Rules can either be "complete" or "partial". +```live:example/complete:module:hidden +package example.rules +import future.keywords +``` + #### Complete Rules Complete rules are if-then statements that assign a single value to a variable. For example: ```live:example/complete/1:module:openable -package example.rules - -any_public_networks := true { # is true if... - net := input.networks[_] # some network exists and.. - net.public # it is public. +any_public_networks := true if { + some net in input.networks # some network exists and.. + net.public # it is public. } ``` -Every rule consists of a _head_ and a _body_. In Rego we say the rule head is -true _if_ the rule body is true for some set of variable assignments. In the -example above `any_public_networks := true` is the head and `net := -input.networks[_]; net.public` is the body. +Every rule consists of a _head_ and a _body_. In Rego we say the rule head +is true _if_ the rule body is true for some set of variable assignments. In +the example above `any_public_networks := true` is the head and `some net in +input.networks; net.public` is the body. You can query for the value generated by rules just like any other value: @@ -515,11 +529,9 @@ data.example.rules.any_public_networks If you omit the `= ` part of the rule head the value defaults to `true`. You could rewrite the example above as follows without changing the meaning: -```live:example/complete_elided:module:read_only,openable -package example.rules - -any_public_networks { - net := input.networks[_] +```live:example/complete/elided:module:read_only,openable +any_public_networks if { + some net in input.networks net.public } ``` @@ -563,15 +575,18 @@ any_public_networks #### Partial Rules +```live:example/partial_set:module:hidden +package example +import future.keywords +``` + Partial rules are if-then statements that generate a set of values and assign that set to a variable. For example: -```live:example/partial_set:module:openable -package example.rules - -public_network[net.id] { # net.id is in the public_network set if... - net := input.networks[_] # some network exists and... - net.public # it is public. +```live:example/partial_set/1:module:openable +public_network contains net.id if { + some net in input.networks # some network exists and.. + net.public # it is public. } ``` @@ -579,27 +594,44 @@ In the example above `public_network[net.id]` is the rule head and `net := input.networks[_]; net.public` is the rule body. You can query for the entire set of values just like any other value: -```live:example/partial_set/extent:query:merge_down +```live:example/partial_set/1/extent:query:merge_down public_network ``` -```live:example/partial_set/extent:output +```live:example/partial_set/1/extent:output ``` -You can iterate over the set of values by referencing the set elements with a +Iteration over the set of values can be done with the `some ... in ...` expression: + +```live:example/partial_set/1/iteration_some:query:merge_down +some net in public_network +``` +```live:example/partial_set/1/iteration_some:output +``` + +With a literal, or a bound variable, you can check if the value exists in the set +via `... in ...`: + +```live:example/partial_set/1/exists_in:query:merge_down +"net3" in public_network +``` +```live:example/partial_set/1/exists_in:output +``` + +You can also iterate over the set of values by referencing the set elements with a variable: -```live:example/partial_set/iteration:query:merge_down +```live:example/partial_set/1/iteration:query:merge_down some n; public_network[n] ``` -```live:example/partial_set/iteration:output +```live:example/partial_set/1/iteration:output ``` Lastly, you can check if a value exists in the set using the same syntax: -```live:example/partial_set/exists:query:merge_down +```live:example/partial_set/1/exists:query:merge_down public_network["net3"] ``` -```live:example/partial_set/exists:output +```live:example/partial_set/1/exists:output ``` In addition to partially defining sets, You can also partially define key/value diff --git a/docs/content/envoy-primer.md b/docs/content/envoy-primer.md index 5d17d6f8fa..3159e4a209 100644 --- a/docs/content/envoy-primer.md +++ b/docs/content/envoy-primer.md @@ -14,36 +14,37 @@ Let's start with an example policy that restricts access to an endpoint based on ```live:bool_example:module:openable package envoy.authz +import future.keywords import input.attributes.request.http default allow := false -allow { +allow if { is_token_valid action_allowed } -is_token_valid { +is_token_valid if { token.valid now := time.now_ns() / 1000000000 token.payload.nbf <= now now < token.payload.exp } -action_allowed { +action_allowed if { http.method == "GET" token.payload.role == "guest" glob.match("/people/*", ["/"], http.path) } -action_allowed { +action_allowed if { http.method == "GET" token.payload.role == "admin" glob.match("/people/*", ["/"], http.path) } -action_allowed { +action_allowed if { http.method == "POST" token.payload.role == "admin" glob.match("/people", ["/"], http.path) @@ -51,7 +52,7 @@ action_allowed { } -token := {"valid": valid, "payload": payload} { +token := {"valid": valid, "payload": payload} if { [_, encoded] := split(http.headers.authorization, " ") [valid, _, payload] := io.jwt.decode_verify(encoded, {"secret": "secret"}) } @@ -110,12 +111,13 @@ If you want, you can also control the HTTP status sent to the upstream or downst ```live:obj_example:module:openable package envoy.authz +import future.keywords import input.attributes.request.http default allow := false -allow { +allow if { is_token_valid action_allowed } @@ -127,37 +129,35 @@ request_headers_to_remove := ["one-auth-header", "another-auth-header"] response_headers_to_add["x-foo"] := "bar" -status_code := 200 { +status_code := 200 if { allow } else := 401 { not is_token_valid -} else := 403 { - true -} +} else := 403 -body := "Authentication Failed" { status_code == 401 } -body := "Unauthorized Request" { status_code == 403 } +body := "Authentication Failed" if status_code == 401 +body := "Unauthorized Request" if status_code == 403 -is_token_valid { +is_token_valid if { token.valid now := time.now_ns() / 1000000000 token.payload.nbf <= now now < token.payload.exp } -action_allowed { +action_allowed if { http.method == "GET" token.payload.role == "guest" glob.match("/people/*", ["/"], http.path) } -action_allowed { +action_allowed if { http.method == "GET" token.payload.role == "admin" glob.match("/people/*", ["/"], http.path) } -action_allowed { +action_allowed if { http.method == "POST" token.payload.role == "admin" glob.match("/people", ["/"], http.path) @@ -165,7 +165,7 @@ action_allowed { } -token := {"valid": valid, "payload": payload} { +token := {"valid": valid, "payload": payload} if { [_, encoded] := split(http.headers.authorization, " ") [valid, _, payload] := io.jwt.decode_verify(encoded, {"secret": "secret"}) } @@ -434,12 +434,11 @@ access the path `/people`. ```live:parsed_path_example:module:read_only package envoy.authz +import future.keywords default allow := false -allow { - input.parsed_path == ["people"] -} +allow if input.parsed_path == ["people"] ``` The `parsed_query` field in the input is also generated from the `path` field in the HTTP request. This field provides @@ -448,10 +447,11 @@ the HTTP URL query as a map of string array. The below sample policy allows anyo ```live:parsed_query_example:module:read_only package envoy.authz +import future.keywords default allow := false -allow { +allow if { input.parsed_path == ["people"] input.parsed_query.lang == ["en"] input.parsed_query.id == ["1", "2"] @@ -464,10 +464,11 @@ can then be used in a policy as shown below. ```live:parsed_body_example:module:read_only package envoy.authz +import future.keywords default allow := false -allow { +allow if { input.parsed_body.firstname == "Charlie" input.parsed_body.lastname == "Opa" } diff --git a/docs/content/envoy-tutorial-istio.md b/docs/content/envoy-tutorial-istio.md index 5cf2574195..808a5001a7 100644 --- a/docs/content/envoy-tutorial-istio.md +++ b/docs/content/envoy-tutorial-istio.md @@ -45,50 +45,50 @@ The `quick_start.yaml` manifest defines the following resources: ```live:example:module:openable package istio.authz - + + import future.keywords + import input.attributes.request.http as http_request import input.parsed_path - + default allow := false - - allow { - parsed_path[0] == "health" - http_request.method == "GET" + + allow if { + parsed_path[0] == "health" + http_request.method == "GET" } - - allow { - roles_for_user[r] - required_roles[r] + + allow if { + some r in roles_for_user + r in required_roles } - - roles_for_user[r] { - r := user_roles[user_name][_] + + roles_for_user contains r if { + some r in user_roles[user_name] } - - required_roles[r] { - perm := role_perms[r][_] - perm.method == http_request.method - perm.path == http_request.path + + required_roles contains r if { + some perm in role_perms[r] + perm.method == http_request.method + perm.path == http_request.path } - - user_name := parsed { - [_, encoded] := split(http_request.headers.authorization, " ") - [parsed, _] := split(base64url.decode(encoded), ":") + + user_name := parsed if { + [_, encoded] := split(http_request.headers.authorization, " ") + [parsed, _] := split(base64url.decode(encoded), ":") } - + user_roles := { - "alice": ["guest"], - "bob": ["admin"] + "alice": ["guest"], + "bob": ["admin"], } - + role_perms := { - "guest": [ - {"method": "GET", "path": "/productpage"}, - ], - "admin": [ - {"method": "GET", "path": "/productpage"}, - {"method": "GET", "path": "/api/v1/products"}, - ], + "guest": [{"method": "GET", "path": "/productpage"}], + "admin": [ + {"method": "GET", "path": "/productpage"}, + {"method": "GET", "path": "/api/v1/products"}, + ], } ``` diff --git a/docs/content/envoy-tutorial-standalone-envoy.md b/docs/content/envoy-tutorial-standalone-envoy.md index 0cdb714acc..7926fecc7b 100644 --- a/docs/content/envoy-tutorial-standalone-envoy.md +++ b/docs/content/envoy-tutorial-standalone-envoy.md @@ -125,44 +125,46 @@ employee with the same `firstname` as himself. ```live:example:module:openable package envoy.authz +import future.keywords + import input.attributes.request.http as http_request default allow := false -allow { - is_token_valid - action_allowed +allow if { + is_token_valid + action_allowed } -is_token_valid { - token.valid - now := time.now_ns() / 1000000000 - token.payload.nbf <= now - now < token.payload.exp +is_token_valid if { + token.valid + now := time.now_ns() / 1000000000 + token.payload.nbf <= now + now < token.payload.exp } -action_allowed { - http_request.method == "GET" - token.payload.role == "guest" - glob.match("/people", ["/"], http_request.path) +action_allowed if { + http_request.method == "GET" + token.payload.role == "guest" + glob.match("/people", ["/"], http_request.path) } -action_allowed { - http_request.method == "GET" - token.payload.role == "admin" - glob.match("/people", ["/"], http_request.path) +action_allowed if { + http_request.method == "GET" + token.payload.role == "admin" + glob.match("/people", ["/"], http_request.path) } -action_allowed { - http_request.method == "POST" - token.payload.role == "admin" - glob.match("/people", ["/"], http_request.path) - lower(input.parsed_body.firstname) != base64url.decode(token.payload.sub) +action_allowed if { + http_request.method == "POST" + token.payload.role == "admin" + glob.match("/people", ["/"], http_request.path) + lower(input.parsed_body.firstname) != base64url.decode(token.payload.sub) } -token := {"valid": valid, "payload": payload} { - [_, encoded] := split(http_request.headers.authorization, " ") - [valid, _, payload] := io.jwt.decode_verify(encoded, {"secret": "secret"}) +token := {"valid": valid, "payload": payload} if { + [_, encoded] := split(http_request.headers.authorization, " ") + [valid, _, payload] := io.jwt.decode_verify(encoded, {"secret": "secret"}) } ``` diff --git a/docs/content/policy-language.md b/docs/content/policy-language.md index 984c17780c..5db1c6f97d 100644 --- a/docs/content/policy-language.md +++ b/docs/content/policy-language.md @@ -7,6 +7,7 @@ toc: true ```live:eg:module:hidden package example +import future.keywords ``` OPA is purpose built for reasoning about information represented in structured @@ -23,6 +24,17 @@ Rego queries are assertions on data stored in OPA. These queries can be used to define policies that enumerate instances of data that violate the expected state of the system. +{{< info >}} +The examples in this section try to represent the best practices. As such, they +make use of keywords that are meant to become standard keywords at some point in +time, but have been introduced gradually. These _future keywords_ can be enabled +using + +```live:eg/import:module:read_only +import future.keywords +``` +{{< /info >}} + ## Why use Rego? Use Rego for defining policy that is easy to read and write. @@ -77,10 +89,10 @@ rect == {"height": 4, "width": 2} ```live:eg/rect/compare:output ``` -You can define a new concept using a rule. For example, `v` below is true if the equality expression is true. +You can define a new concept using a rule. For example, `v` below is true if the equality expression is true. ```live:eg/undefined:module -v { "hello" == "world" } +v if "hello" == "world" ``` If we evaluate `v`, the result is `undefined` because the body of the rule never @@ -109,13 +121,25 @@ v != true We can define rules in terms of [Variables](#variables) as well: ```live:eg/rules:module -t { x := 42; y := 41; x > y } +t if { x := 42; y := 41; x > y } ``` The formal syntax uses the semicolon character `;` to separate expressions. Rule bodies can separate expressions with newlines and omit the semicolon: ```live:eg/rules/newlines:module:read_only +t2 if { + x := 42 + y := 41 + x > y +} +``` + +Note that the future keyword `if` is optional. We could have written `v` and `t2` like this: + +```live:eg/rules/newlines_no_if:module:read_only +v { "hello" == "world" } + t2 { x := 42 y := 41 @@ -154,7 +178,10 @@ sites := [{"name": "prod"}, {"name": "smoke1"}, {"name": "dev"}] ``` And ```live:eg/references/basic:module -r { sites[_].name == "prod" } +r if { + some site in sites + site.name == "prod" +} ``` The rule `r` above asserts that there exists (at least) one document within `sites` where the `name` attribute equals `"prod"`. @@ -170,7 +197,10 @@ r We can generalize the example above with a rule that defines a set document instead of a boolean document: ```live:eg/references/helper:module -q[name] { name := sites[_].name } +q contains name if { + some site in sites + name := site.name +} ``` The value of `q` is a set of names @@ -184,7 +214,7 @@ q We can re-write the rule `r` from above to make use of `q`. We will call the new rule `p`: ```live:eg/references/helper/composed:module -p { q["prod"] } +p if q["prod"] ``` Querying `p` will have the same result: @@ -370,7 +400,10 @@ sites := [ {"name": "dev"} ] -q[name] { name := sites[_].name } +q contains name if { + some site in sites + name := site.name +} ``` In this case, we evaluate `q` with a variable `x` (which is not bound to a value). As a result, the query returns all of the values for `x` and all of the values for `q[x]`, which are always the same because `q` is a set. @@ -582,7 +615,7 @@ Array Comprehensions build array values out of sub-queries. Array Comprehensions For example, the following rule defines an object where the keys are application names and the values are hostnames of servers where the application is deployed. The hostnames of servers are represented as an array. ```live:eg/data/array_comprehension:module -app_to_hostnames[app_name] := hostnames { +app_to_hostnames[app_name] := hostnames if { app := apps[_] app_name := app.name hostnames := [hostname | name := app.servers[_] @@ -670,9 +703,12 @@ The sample code in this section make use of the data defined in [Examples](#exam The following rule defines a set containing the hostnames of all servers: -```live:eg/data/rules:module -hostnames[name] { name := sites[_].servers[_].hostname } +```live:eg/data/rules:module:read_only +hostnames contains name if { + name := sites[_].servers[_].hostname +} ``` +Note that the (future) keyword `if` is optional here. When we query for the content of `hostnames` we see the same data as we would if we queried using the `sites[_].servers[_].hostname` reference directly: @@ -701,7 +737,7 @@ Third, the `name := sites[_].servers[_].hostname` expression binds the value of Rules that define objects are very similar to rules that define sets. ```live:eg/data/rule_objects:module -apps_by_hostname[hostname] := app { +apps_by_hostname[hostname] := app if { some i server := sites[_].servers[_] hostname := server.hostname @@ -731,12 +767,12 @@ For example, we can write a rule that abstracts over our `servers` and `containers` data as `instances`: ```live:eg/data/incremental_rule:module -instances[instance] { +instances contains instance if { server := sites[_].servers[_] instance := {"address": server.hostname, "name": server.name} } -instances[instance] { +instances contains instance if { container := containers[_] instance := {"address": container.ipaddress, "name": container.name} } @@ -746,7 +782,7 @@ If the head of the rule is same, we can chain multiple rule bodies together to obtain the same result. We don't recommend using this form anymore. ```live:eg/data/incremental_rule_nr:module:read_only -instances[instance] { +instances contains instance if { server := sites[_].servers[_] instance := {"address": server.hostname, "name": server.name} } { @@ -795,10 +831,10 @@ power_users := {"alice", "bob", "fred"} restricted_users := {"bob", "kim"} # Power users get 32GB memory. -max_memory := 32 { power_users[user] } +max_memory := 32 if power_users[user] # Restricted users get 4GB memory. -max_memory := 4 { restricted_users[user] } +max_memory := 4 if restricted_users[user] ``` Error: @@ -825,7 +861,7 @@ Rego supports user-defined functions that can be called with the same semantics For example, the following function will return the result of trimming the spaces from a string and then splitting it by periods. ```live:eg/basic_function:module:merge_down -trim_and_split(s) := x { +trim_and_split(s) := x if { t := trim(s, " ") x := split(t, ".") } @@ -839,7 +875,7 @@ trim_and_split(" foo.bar ") Functions may have an arbitrary number of inputs, but exactly one output. Function arguments may be any kind of term. For example, suppose we have the following function: ```live:eg/function_input:module:read_only -foo([x, {"bar": y}]) := z { +foo([x, {"bar": y}]) := z if { z := {x: y} } ``` @@ -853,21 +889,25 @@ The following calls would produce the logical mappings given: | ``z := foo(["5", {"bar": [1, 2, 3, ["foo", "bar"]]}])`` | ``"5"`` | ``[1, 2, 3, ["foo", "bar"]]`` | -If you need multiple outputs, write your functions so that the output is an array, object or set containing your results. If the output term is omitted, it is equivalent to having the output term be the literal `true`. That is, the function declarations below are equivalent: -```live:eg/function_output_unset:module:read_only -f(x) { - x == "foo" -} +If you need multiple outputs, write your functions so that the output is an array, object or set +containing your results. If the output term is omitted, it is equivalent to having the output term +be the literal `true`. Furthermore, `if` can be used to write shorter definitions. That is, the +function declarations below are equivalent: -f(x) = true { - x == "foo" -} +```live:eg/function_output_unset:module:read_only +f(x) { x == "foo" } +f(x) if { x == "foo" } +f(x) if x == "foo" + +f(x) := true { x == "foo" } +f(x) := true if { x == "foo" } +f(x) := true if x == "foo" ``` The outputs of user functions have some additional limitations, namely that they must resolve to a single value. If you write a function that has multiple possible bindings for an output variable, you will get a conflict error: ```live:eg/function_single_output:module:merge_down -p(x) := y { +p(x) := y if { y := x[_] } ``` @@ -882,10 +922,10 @@ It is possible in Rego to define a function more than once, to achieve a conditi Functions can be defined incrementally. ```live:eg/double_function_define:module -q(1, x) := y { +q(1, x) := y if { y := x } -q(2, x) := y { +q(2, x) := y if { y := x*4 } ``` @@ -905,11 +945,11 @@ q(2, 2) A given function call will execute all functions that match the signature given. If a call matches multiple functions, they must produce the same output, or else a conflict error will occur: ```live:eg/double_function_define_diff_out:module -r(1, x) := y { +r(1, x) := y if { y := x } -r(x, 2) := y { +r(x, 2) := y if { y := x*4 } ``` @@ -922,7 +962,7 @@ r(1, 2) On the other hand, if a call matches no functions, then the result is undefined. ```live:eg/double_function_define_undefined:module -s(x, 2) := y { +s(x, 2) := y if { y := x * 4 } ``` @@ -952,7 +992,7 @@ For safety, a variable appearing in a negated expression must also appear in ano The simplest use of negation involves only scalar values or variables and is equivalent to complementing the operator: ```live:eg/simple_negation:module -t { +t if { greeting := "hello" not greeting == "goodbye" } @@ -971,21 +1011,24 @@ Negation is required to check whether some value *does not* exist in a collectio For example, we can write a rule that defines a document containing names of apps not deployed on the `"prod"` site: ```live:eg/data/negation:module -prod_servers[name] { - site := sites[_] +prod_servers contains name if { + some site in sites site.name == "prod" - name := site.servers[_].name + some server in site.servers + name := server.name } -apps_in_prod[name] { - app := apps[_] - server := app.servers[_] - prod_servers[server] +apps_in_prod contains name if { + some site in sites + some app in apps name := app.name + some server in app.servers + prod_servers[server] } -apps_not_in_prod[name] { - name := apps[_].name +apps_not_in_prod contains name if { + some app in apps + name := app.name not apps_in_prod[name] } ``` @@ -1013,7 +1056,7 @@ The most expressive way to state this in Rego is using the `every` keyword: ```live:eg/data/every_alternative:module:read_only import future.keywords.every -no_bitcoin_miners_using_every { +no_bitcoin_miners_using_every if { every app in apps { app.name != "bitcoin-miner" } @@ -1042,13 +1085,9 @@ quantifier. For example: ```live:eg/data/correct_negation:module -import future.keywords.in +no_bitcoin_miners_using_negation if not any_bitcoin_miners -no_bitcoin_miners_using_negation { - not any_bitcoin_miners -} - -any_bitcoin_miners { +any_bitcoin_miners if { some app in apps app.name == "bitcoin-miner" } @@ -1076,7 +1115,7 @@ A common mistake is to try encoding the policy with a rule named `no_bitcoin_min like so: ```live:eg/data/incorrect_no_bitcoin:module:read_only -no_bitcoin_miners { +no_bitcoin_miners if { app := apps[_] app.name != "bitcoin-miner" # THIS IS NOT CORRECT. } @@ -1087,9 +1126,7 @@ keyword, because the rule is true whenever there is SOME app that is not a bitcoin-miner: ```live:eg/data/incorrect_no_bitcoin_some:module -import future.keywords.in - -no_bitcoin_miners { +no_bitcoin_miners if { some app in apps app.name != "bitcoin-miner" } @@ -1113,7 +1150,7 @@ Alternatively, we can implement the same kind of logic inside a single rule using [Comprehensions](#comprehensions). ```live:eg/data/comprehesion_alternative:module:read_only -no_bitcoin_miners_using_comprehension { +no_bitcoin_miners_using_comprehension if { bitcoin_miners := {app | some app in apps; app.name == "bitcoin-miner"} count(bitcoin_miners) == 0 } @@ -1191,11 +1228,11 @@ Modules use the same syntax to declare dependencies on [Base and Virtual Documen ```live:import_data:module:read_only package opa.examples -import future.keywords.in +import future.keywords # uses 'in' and 'contains' and 'if' import data.servers -http_servers[server] { +http_servers contains server if { some server in servers "http" in server.protocols } @@ -1205,28 +1242,28 @@ Similarly, modules can declare dependencies on query arguments by specifying an ```live:import_input:module:read_only package opa.examples -import future.keywords.in +import future.keywords import input.user import input.method # allow alice to perform any operation. -allow { user == "alice" } +allow if user == "alice" # allow bob to perform read-only operations. -allow { +allow if { user == "bob" method == "GET" } # allows users assigned a "dev" role to perform read-only operations. -allow { +allow if { method == "GET" input.user in data.roles["dev"] } # allows user catherine access on Saturday and Sunday -allow { +allow if { user == "catherine" day := time.weekday(time.now_ns()) day in ["Saturday", "Sunday"] @@ -1237,11 +1274,11 @@ Imports can include an optional `as` keyword to handle namespacing issues: ```live:import_namespacing:module:read_only package opa.examples -import future.keywords.in +import future.keywords import data.servers as my_servers -http_servers[server] { +http_servers contains server if { some server in my_servers "http" in server.protocols } @@ -1264,10 +1301,10 @@ the "west" region that contain "db" in their name. The first element in the tuple is the site index and the second element is the server index. ```live:eg/data/some:module -tuples[[i, j]] { +tuples contains [i, j] if { some i, j sites[i].region == "west" - server := sites[i].servers[j] # note: 'server' is local because it's declared with := + server := sites[i].servers[j] # note: 'server' is local because it's declared with := contains(server.name, "db") } ``` @@ -1319,7 +1356,7 @@ variable names. ```live:eg/data/every0:module:merge_down import future.keywords.every -names_with_dev { +names_with_dev if { some site in sites site.name == "dev" @@ -1353,18 +1390,18 @@ scope of the body evaluation: ```live:eg/every1:module:merge_down import future.keywords.every -array_domain { +array_domain if { every i, x in [1, 2, 3] { x-i == 1 } # array domain } -object_domain { +object_domain if { every k, v in {"foo": "bar", "fox": "baz" } { # object domain startswith(k, "f") startswith(v, "b") } } -set_domain { +set_domain if { every x in {1, 2, 3} { x != 4 } # set domain } ``` @@ -1380,15 +1417,13 @@ import future.keywords.every xs := [2, 2, 4, 8] larger_than_one(x) := x > 1 -rule_every { +rule_every if { every x in xs { larger_than_one(x) } } -not_less_or_equal_one { - not lte_one -} +not_less_or_equal_one if not lte_one -lte_one { +lte_one if { some x in xs not larger_than_one(x) } @@ -1470,17 +1505,17 @@ will see the unmodified value. The exception to this rule is when multiple `with` keywords are in-scope like below: ```live:multiple_with:module:read_only -inner := [x, y] { +inner := [x, y] if { x := input.foo y := input.bar } -middle := [a, b] { +middle := [a, b] if { a := inner with input.foo as 100 b := input } -outer := result { +outer := result if { result := middle with input as {"foo": 200, "bar": 300} } ``` @@ -1498,66 +1533,60 @@ Replacement functions can call the function they're replacing **without causing recursion**. See the following example: -```live:with_builtins:module:read_only -package opa.examples -import future.keywords.in - +```live:eg/with_builtins:module:read_only f(x) := count(x) -mock_count(x) := 0 { "x" in x } -mock_count(x) := count(x) { not "x" in x } +mock_count(x) := 0 if "x" in x +mock_count(x) := count(x) if not "x" in x ``` -```live:with_builtins/1:query:merge_down +```live:eg/with_builtins/1:query:merge_down f([1, 2, 3]) with count as mock_count ``` -```live:with_builtins/1:output +```live:eg/with_builtins/1:output ``` -```live:with_builtins/2:query:merge_down +```live:eg/with_builtins/2:query:merge_down f(["x", "y", "z"]) with count as mock_count ``` -```live:with_builtins/2:output +```live:eg/with_builtins/2:output ``` Each replacement function evaluation will start a new scope: it's valid to use `with as ...` in the body of the replacement function -- for example: -```live:with_builtins_nested:module:read_only -package opa.examples -import future.keywords.in - -f(x) := count(x) { +```live:eg/with_builtins_nested:module:read_only +f(x) := count(x) if { rule_using_concat with concat as "foo,bar" } -mock_count(x) := 0 { "x" in x } -mock_count(x) := count(x) { not "x" in x } +mock_count(x) := 0 if "x" in x +mock_count(x) := count(x) if not "x" in x -rule_using_concat { +rule_using_concat if { concat(",", input.x) == "foo,bar" } ``` -```live:with_builtins_nested/1:query:merge_down +```live:eg/with_builtins_nested/1:query:merge_down f(["x", "y", "z"]) with count as mock_count with input.x as ["baz"] ``` -```live:with_builtins_nested/1:output +```live:eg/with_builtins_nested/1:output ``` Note that function replacement via `with` does not affect the evaluation of the function arguments: if `input.x` is undefined, the replacement of `concat` does not change the result of the evaluation: -```live:with_builtins_nested/2:query:merge_down +```live:eg/with_builtins_nested/2:query:merge_down count(input.x) with count as 3 with input.x as ["x"] ``` -```live:with_builtins_nested/2:output +```live:eg/with_builtins_nested/2:output ``` -```live:with_builtins_nested/3:query:merge_down +```live:eg/with_builtins_nested/3:query:merge_down count(input.x) with count as 3 with input as {} ``` -```live:with_builtins_nested/3:output:expect_undefined +```live:eg/with_builtins_nested/3:output:expect_undefined ``` ## Default Keyword @@ -1571,14 +1600,12 @@ For example: ```live:eg/default:module default allow := false -allow { +allow if { input.user == "bob" input.method == "GET" } -allow { - input.user == "alice" -} +allow if input.user == "alice" ``` When the `allow` document is queried, the return value will be either `true` or `false`. @@ -1620,7 +1647,7 @@ The ``else`` keyword is useful if you are porting policies into Rego from an order-sensitive system like IPTables. ```live:eg/else:module -authorize := "allow" { +authorize := "allow" if { input.user == "superuser" # allow 'superuser' to perform any operation. } else := "deny" { input.path[0] == "admin" # disallow 'admin' operations... @@ -1687,7 +1714,7 @@ The membership operator `in` lets you check if an element is part of a collectio ```live:eg/member1:module:merge_down import future.keywords.in -p := [x, y, z] { +p := [x, y, z] if { x := 3 in [1, 2, 3] # array y := 3 in {1, 2, 3} # set z := 3 in {"foo": 1, "bar": 3} # object @@ -1703,7 +1730,7 @@ taken to be the key (object) or index (array), respectively: ```live:eg/member1c:module:merge_down import future.keywords.in -p := [x, y] { +p := [x, y] if { x := "foo", "bar" in {"foo": "bar"} # key, val with object y := 2, "baz" in ["foo", "bar", "baz"] # key, val with array } @@ -1718,16 +1745,16 @@ arguments -- compare: ```live:eg/member1d:module:merge_down import future.keywords.in -p := x { +p := x if { x := { 0, 2 in [2] } } -q := x { +q := x if { x := { (0, 2 in [2]) } } -w := x { +w := x if { x := g((0, 2 in [2])) } -z := x { +z := x if { x := f(0, 2 in [2]) } @@ -1743,9 +1770,7 @@ member of an array: ```live:eg/member1a:module:merge_down import future.keywords.in -deny { - not "admin" in input.user.roles -} +deny if not "admin" in input.user.roles test_deny { deny with input.user.roles as ["operator", "user"] @@ -1760,7 +1785,7 @@ when called in non-collection arguments: ```live:eg/member1b:module:merge_down import future.keywords.in -q := x { +q := x if { x := 3 in "three" } ``` @@ -1795,11 +1820,11 @@ p[x] { some x, "r" in ["a", "r", "r", "a", "y"] # key variable, value constant } -q[x] = y { +q[x] = y if { some x, y in ["a", "r", "r", "a", "y"] # both variables } -r[y] = x { +r[y] = x if { some x, y in {"foo": "bar", "baz": "quz"} } ``` @@ -1811,10 +1836,10 @@ Any argument to the `some` variant can be a composite, non-ground value: ```live:eg/member4:module:merge_down import future.keywords.in -p[x] = y { +p[x] = y if { some x, {"foo": y} in [{"foo": 100}, {"bar": 200}] } -p[x] = y { +p[x] = y if { some {"bar": x}, {"foo": y} in {{"bar": "b"}: {"foo": "f"}} } ``` @@ -1832,7 +1857,7 @@ The assignment operator (`:=`) is used to assign values to variables. Variables ```live:eg/assignment1:module:read_only x := 100 -p { +p if { x := 1 # declare local variable 'x' and assign value 1 x != 100 # true because 'x' refers to local variable } @@ -1842,12 +1867,12 @@ Assigned variables are not allowed to appear before the assignment in the query. For example, the following policy will not compile: ```live:eg/assignment2:module:merge_down -p { +p if { x != 100 x := 1 # error because x appears earlier in the query. } -q { +q if { x := 1 x := 2 # error because x is assigned twice. } @@ -1860,7 +1885,7 @@ A simple form of destructuring can be used to unpack values from arrays and assi ```live:eg/assignment3:module:read_only address := ["3 Abbey Road", "NW8 9AY", "London", "England"] -in_london { +in_london if { [_, _, city, country] := address city == "London" country == "England" @@ -1875,7 +1900,7 @@ in_london { Comparison checks if two values are equal within a rule. If the left or right hand side contains a variable that has not been assigned a value, the compiler throws an error. ```live:eg/comparison1:module:merge_down -p { +p if { x := 100 x == 100 # true because x refers to the local variable } @@ -1885,7 +1910,7 @@ p { ```live:eg/comparison2:module:merge_down y := 100 -q { +q if { y == 100 # true because y refers to the global variable } ``` @@ -1893,7 +1918,7 @@ q { ``` ```live:eg/comparison3:module:merge_down -r { +r if { z == 100 # compiler error because z has not been assigned a value } ``` @@ -1920,7 +1945,7 @@ sites[i].servers[j].name = apps[k].servers[m] As opposed to when assignment (`:=`) is used, the order of expressions in a rule does not affect the document’s content. ```live:eg/expression_order:module -s { +s if { x > y y = 41 x = 42 @@ -2003,13 +2028,13 @@ logic. If error handling is required, the built-in function call can be negated to test for undefined. For example: ```live:eg/errors:module:merge_down -allow { +allow if { io.jwt.verify_hs256(input.token, "secret") [_, payload, _] := io.jwt.decode(input.token) payload.role == "admin" } -reason["invalid JWT supplied as input"] { +reason contains "invalid JWT supplied as input" if { not io.jwt.decode(input.token) } ``` diff --git a/docs/content/policy-reference.md b/docs/content/policy-reference.md index e84a7efbce..04b81f2772 100644 --- a/docs/content/policy-reference.md +++ b/docs/content/policy-reference.md @@ -103,6 +103,11 @@ some [x, "b", z] in a_set ## Iteration +```live:iteration:module:hidden +package example +import future.keywords +``` + ### Arrays ```live:iteration/arrays:query:read_only @@ -182,13 +187,13 @@ not any_not_match ``` ```live:iteration/forall:module:read_only -any_match { - set[x] +any_match if { + some x in set f(x) } -any_not_match { - set[x] +any_not_match if { + some x in set not f(x) } ``` @@ -197,6 +202,11 @@ any_not_match { In the examples below `...` represents one or more conditions. +```live:rules:module:hidden +package example +import future.keywords +``` + ### Constants ```live:rules/constants:module:read_only @@ -212,7 +222,9 @@ c := a | b p := true { ... } # OR +p if { ... } +# OR p { ... } ``` @@ -220,8 +232,8 @@ p { ... } ```live:rules/cond:module:read_only default a := 1 -a := 5 { ... } -a := 100 { ... } +a := 5 if { ... } +a := 100 if { ... } ``` ### Incremental @@ -231,29 +243,33 @@ a := 100 { ... } a_set[x] { ... } a_set[y] { ... } +# alternatively, with future.keywords +a_set contains x if { ... } +a_set contains y if { ... } + # a_map will contain key->value pairs x->y and w->z -a_map[x] := y { ... } -a_map[w] := z { ... } +a_map[x] := y if { ... } +a_map[w] := z if { ... } ``` ### Ordered (Else) ```live:rules/ordered:module:read_only default a := 1 -a := 5 { ... } +a := 5 if { ... } else := 10 { ... } ``` ### Functions (Boolean) ```live:rules/funcs:module:read_only -f(x, y) { +f(x, y) if { ... } # OR -f(x, y) := true { +f(x, y) := true if { ... } ``` @@ -261,9 +277,9 @@ f(x, y) := true { ### Functions (Conditionals) ```live:rules/condfuncs:module:read_only -f(x) := "A" { x >= 90 } -f(x) := "B" { x >= 80; x < 90 } -f(x) := "C" { x >= 70; x < 80 } +f(x) := "A" if { x >= 90 } +f(x) := "B" if { x >= 80; x < 90 } +f(x) := "C" if { x >= 70; x < 80 } ``` ## Tests @@ -1055,7 +1071,11 @@ package = "package" ref import = "import" ref [ "as" var ] policy = { rule } rule = [ "default" ] rule-head { rule-body } -rule-head = var [ "(" rule-args ")" ] [ "[" term "]" ] [ ( ":=" | "=" ) term ] +rule-head = var ( rule-head-set | rule-head-obj | rule-head-func | rule-head-comp ) +rule-head-comp = [ ( ":=" | "=" ) term ]") [ "if" ] +rule-head-obj = [ "[" term "]" ] [ ( ":=" | "=" ) term ]) [ "if" ] +rule-head-func = [ "(" rule-args ")" ] [ ( ":=" | "=" ) term ]) [ "if" ] +rule-head-set = "contains" term [ "if" ] | "[" term "]" rule-args = term { "," term } rule-body = [ "else" [ ( ":=" | "=" ) term ] ] "{" query "}" query = literal { ( ";" | ( [CR] LF ) ) literal } @@ -1090,6 +1110,8 @@ non-empty-set = "{" term { "," term } "}" empty-set = "set(" ")" ``` +Note that the grammar corresponds to Rego with all future keywords enabled. + The grammar defined above makes use of the following syntax. See [the Wikipedia page on EBNF](https://en.wikipedia.org/wiki/Extended_Backus–Naur_Form) for more details: ``` diff --git a/docs/content/policy-testing.md b/docs/content/policy-testing.md index 349ad872bd..b507d14528 100644 --- a/docs/content/policy-testing.md +++ b/docs/content/policy-testing.md @@ -14,6 +14,17 @@ framework that you can use to write _tests_ for your policies. By writing tests for your policies you can speed up the development process of new rules and reduce the amount of time it takes to modify rules as requirements evolve. +{{< info >}} +The examples in this section try to represent the best practices. As such, they +make use of keywords that are meant to become standard keywords at some point in +time, but have been introduced gradually. These _future keywords_ can be enabled +using + +```live:eg/import:module:read_only +import future.keywords +``` +{{< /info >}} + ## Getting Started Let's use an example to get started. The file below implements a simple @@ -24,13 +35,14 @@ profile. ```live:example:module:read_only,openable package authz +import future.keywords -allow { +allow if { input.path == ["users"] input.method == "POST" } -allow { +allow if { input.path == ["users", input.user_id] input.method == "GET" } @@ -43,19 +55,19 @@ To test this policy, we will create a separate Rego file that contains test case ```live:example/test:module:read_only package authz -test_post_allowed { +test_post_allowed if { allow with input as {"path": ["users"], "method": "POST"} } -test_get_anonymous_denied { +test_get_anonymous_denied if { not allow with input as {"path": ["users"], "method": "GET"} } -test_get_user_allowed { +test_get_user_allowed if { allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "bob"} } -test_get_another_user_denied { +test_get_another_user_denied if { not allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "alice"} } ``` @@ -113,7 +125,7 @@ name is prefixed with `test_`. ```live:example_format:module:read_only package mypackage -test_some_descriptive_name { +test_some_descriptive_name if { # test logic } ``` @@ -145,22 +157,16 @@ by zero condition) the test result is marked as an `ERROR`. Tests prefixed with package example # This test will pass. -test_ok { - true -} +test_ok if true # This test will fail. -test_failure { - 1 == 2 -} +test_failure if 1 == 2 # This test will error. -test_error { - 1 / 0 -} +test_error if 1 / 0 # This test will be skipped. -todo_test_missing_implementation { +todo_test_missing_implementation if { allow with data.roles as ["not", "implemented"] } ``` @@ -249,7 +255,7 @@ Below is a simple policy that depends on the data document. ```live:with_keyword:module:read_only,openable package authz -import future.keywords.in +import future.keywords allow { some x in data.policies @@ -257,9 +263,7 @@ allow { matches_role(input.role) } -matches_role(my_role) { - input.user in data.roles[my_role] -} +matches_role(my_role) if input.user in data.roles[my_role] ``` Below is the Rego file to test the above policy. @@ -268,11 +272,12 @@ Below is the Rego file to test the above policy. ```live:with_keyword/tests:module:read_only package authz +import future.keywords policies := [{"name": "test_policy"}] roles := {"admin": ["alice"]} -test_allow_with_data { +test_allow_with_data if { allow with input as {"user": "alice", "role": "admin"} with data.policies as policies with data.roles as roles @@ -294,22 +299,20 @@ Below is an example to replace a **rule without arguments**. ```live:with_keyword_rules:module:read_only package authz +import future.keywords -allow1 { - allow2 -} +allow1 if allow2 -allow2 { - 2 == 1 -} +allow2 if 2 == 1 ``` **authz_test.rego**: ```live:with_keyword_rules/tests:module:read_only package authz +import future.keywords -test_replace_rule { +test_replace_rule if { allow1 with allow2 as true } ``` @@ -327,10 +330,11 @@ Here is an example to replace a rule's **built-in function** with a user-defined ```live:with_keyword_builtins:module:read_only package authz +import future.keywords import data.jwks.cert -allow { +allow if { [true, _, _] = io.jwt.decode_verify(input.headers["x-token"], {"cert": cert, "iss": "corp.issuer.com"}) } ``` @@ -339,13 +343,12 @@ allow { ```live:with_keyword_builtins/tests:module:read_only package authz +import future.keywords mock_decode_verify("my-jwt", _) := [true, {}, {}] -mock_decode_verify(x, _) := [false, {}, {}] { - x != "my-jwt" -} +mock_decode_verify(x, _) := [false, {}, {}] if x != "my-jwt" -test_allow { +test_allow if { allow with input.headers["x-token"] as "my-jwt" with data.jwks.cert as "mock-cert" @@ -363,7 +366,7 @@ PASS: 1/1 In simple cases, a function can also be replaced with a value, as in ```live:with_keyword_builtins/tests/value:module:read_only -test_allow_value { +test_allow_value if { allow with input.headers["x-token"] as "my-jwt" with data.jwks.cert as "mock-cert" @@ -381,12 +384,13 @@ function by a built-in function. ```live:with_keyword_funcs:module:read_only package authz +import future.keywords -replace_rule { +replace_rule if { replace(input.label) } -replace(label) { +replace(label) if { label == "test_label" } ``` @@ -395,8 +399,9 @@ replace(label) { ```live:with_keyword_funcs/tests:module:read_only package authz +import future.keywords -test_replace_rule { +test_replace_rule if { replace_rule with input.label as "does-not-matter" with replace as true } ``` diff --git a/format/format.go b/format/format.go index 2376c94db3..706dbf74cb 100644 --- a/format/format.go +++ b/format/format.go @@ -12,6 +12,7 @@ import ( "sort" "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/internal/future" ) // Opts lets you control the code formatting via `AstWithOpts()`. @@ -74,6 +75,17 @@ func AstWithOpts(x interface{}, opts Opts) ([]byte, error) { // present. extraFutureKeywordImports := map[string]struct{}{} + // When the future keyword "contains" is imported, all the pretty-printed + // modules will use that format for partial sets. + // NOTE(sr): For ref-head rules, this will be the default behaviour, since + // we need "contains" to disambiguate complete rules from partial sets. + useContainsKW := false + + // Same logic applies as for "contains": if `future.keywords.if` (or all + // future keywords) is imported, we'll render rules that can use `if` with + // `if`. + useIf := false + // Preprocess the AST. Set any required defaults and calculate // values required for printing the formatted output. ast.WalkNodes(x, func(x ast.Node) bool { @@ -92,6 +104,17 @@ func AstWithOpts(x interface{}, opts Opts) ([]byte, error) { case n.IsEvery(): extraFutureKeywordImports["every"] = struct{}{} } + + case *ast.Import: + switch { + case future.IsAllFutureKeywords(n): + useContainsKW = true + useIf = true + case future.IsFutureKeyword(n, "contains"): + useContainsKW = true + case future.IsFutureKeyword(n, "if"): + useIf = true + } } if opts.IgnoreLocations || x.Loc() == nil { @@ -109,15 +132,20 @@ func AstWithOpts(x interface{}, opts Opts) ([]byte, error) { for kw := range extraFutureKeywordImports { x.Imports = ensureFutureKeywordImport(x.Imports, kw) } - w.writeModule(x) + w.writeModule(x, useContainsKW, useIf) case *ast.Package: w.writePackage(x, nil) case *ast.Import: w.writeImports([]*ast.Import{x}, nil) case *ast.Rule: - w.writeRule(x, false, nil) + w.writeRule(x, false /* isElse */, useContainsKW, useIf, nil) case *ast.Head: - w.writeHead(x, false, false, nil) + w.writeHead(x, + false, // isDefault + false, // isExpandedConst + useContainsKW, + useIf, + nil) case ast.Body: w.writeBody(x, nil) case *ast.Expr: @@ -186,7 +214,7 @@ type writer struct { delay bool } -func (w *writer) writeModule(module *ast.Module) { +func (w *writer) writeModule(module *ast.Module, useContainsKW, useIf bool) { var pkg *ast.Package var others []interface{} var comments []*ast.Comment @@ -225,7 +253,7 @@ func (w *writer) writeModule(module *ast.Module) { imports, others = gatherImports(others) comments = w.writeImports(imports, comments) rules, others = gatherRules(others) - comments = w.writeRules(rules, comments) + comments = w.writeRules(rules, useContainsKW, useIf, comments) } for i, c := range comments { @@ -255,16 +283,16 @@ func (w *writer) writeComments(comments []*ast.Comment) { } } -func (w *writer) writeRules(rules []*ast.Rule, comments []*ast.Comment) []*ast.Comment { +func (w *writer) writeRules(rules []*ast.Rule, useContainsKW, useIf bool, comments []*ast.Comment) []*ast.Comment { for _, rule := range rules { comments = w.insertComments(comments, rule.Location) - comments = w.writeRule(rule, false, comments) + comments = w.writeRule(rule, false, useContainsKW, useIf, comments) w.blankLine() } return comments } -func (w *writer) writeRule(rule *ast.Rule, isElse bool, comments []*ast.Comment) []*ast.Comment { +func (w *writer) writeRule(rule *ast.Rule, isElse, useContainsKW, useIf bool, comments []*ast.Comment) []*ast.Comment { if rule == nil { return comments } @@ -283,13 +311,30 @@ func (w *writer) writeRule(rule *ast.Rule, isElse bool, comments []*ast.Comment) // pretend that the rule has no body in this case. isExpandedConst := rule.Body.Equal(ast.NewBody(ast.NewExpr(ast.BooleanTerm(true)))) && rule.Else == nil - comments = w.writeHead(rule.Head, rule.Default, isExpandedConst, comments) + comments = w.writeHead(rule.Head, rule.Default, isExpandedConst, useContainsKW, useIf, comments) + + // this excludes partial sets UNLESS `contains` is used + partialSetException := useContainsKW || rule.Head.Value != nil if (len(rule.Body) == 0 || isExpandedConst) && !isElse { w.endLine() return comments } + if useIf && partialSetException && !isElse { + w.write(" if") + if len(rule.Body) == 1 { + if rule.Body[0].Location.Row == rule.Head.Location.Row { + w.write(" ") + comments = w.writeExpr(rule.Body[0], comments) + w.endLine() + if rule.Else != nil { + comments = w.writeElse(rule, useContainsKW, useIf, comments) + } + return comments + } + } + } w.write(" {") w.endLine() w.up() @@ -312,12 +357,12 @@ func (w *writer) writeRule(rule *ast.Rule, isElse bool, comments []*ast.Comment) w.startLine() w.write("}") if rule.Else != nil { - comments = w.writeElse(rule, comments) + comments = w.writeElse(rule, useContainsKW, useIf, comments) } return comments } -func (w *writer) writeElse(rule *ast.Rule, comments []*ast.Comment) []*ast.Comment { +func (w *writer) writeElse(rule *ast.Rule, useContainsKW, useIf bool, comments []*ast.Comment) []*ast.Comment { // If there was nothing else on the line before the "else" starts // then preserve this style of else block, otherwise it will be // started as an "inline" else eg: @@ -378,10 +423,10 @@ func (w *writer) writeElse(rule *ast.Rule, comments []*ast.Comment) []*ast.Comme rule.Else.Head.Value.Location = rule.Else.Head.Location } - return w.writeRule(rule.Else, true, comments) + return w.writeRule(rule.Else, true, useContainsKW, useIf, comments) } -func (w *writer) writeHead(head *ast.Head, isDefault bool, isExpandedConst bool, comments []*ast.Comment) []*ast.Comment { +func (w *writer) writeHead(head *ast.Head, isDefault, isExpandedConst, useContainsKW, useIf bool, comments []*ast.Comment) []*ast.Comment { w.write(head.Name.String()) if len(head.Args) > 0 { w.write("(") @@ -393,9 +438,14 @@ func (w *writer) writeHead(head *ast.Head, isDefault bool, isExpandedConst bool, w.write(")") } if head.Key != nil { - w.write("[") - comments = w.writeTerm(head.Key, comments) - w.write("]") + if useContainsKW && head.Value == nil { + w.write(" contains ") + comments = w.writeTerm(head.Key, comments) + } else { // no `if` for p[x] notation + w.write("[") + comments = w.writeTerm(head.Key, comments) + w.write("]") + } } if head.Value != nil && (head.Key != nil || ast.Compare(head.Value, ast.BooleanTerm(true)) != 0 || isExpandedConst || isDefault) { if head.Assign { @@ -1255,23 +1305,16 @@ func (w *writer) down() { } func ensureFutureKeywordImport(imps []*ast.Import, kw string) []*ast.Import { - allKeywords := ast.MustParseTerm("future.keywords") - kwPath := keyword(kw) - every := keyword("every") for _, imp := range imps { - if allKeywords.Equal(imp.Path) || - imp.Path.Equal(kwPath) || - (imp.Path.Equal(every) && kw == "in") { // "every" implies "in", so we don't need to add both + if future.IsAllFutureKeywords(imp) || + future.IsFutureKeyword(imp, kw) || + (future.IsFutureKeyword(imp, "every") && kw == "in") { // "every" implies "in", so we don't need to add both return imps } } imp := &ast.Import{ - Path: kwPath, + Path: ast.MustParseTerm("future.keywords." + kw), } imp.Location = defaultLocation(imp) return append(imps, imp) } - -func keyword(kw string) *ast.Term { - return ast.MustParseTerm("future.keywords." + kw) -} diff --git a/format/format_test.go b/format/format_test.go index 5c46a517fa..48f3169855 100644 --- a/format/format_test.go +++ b/format/format_test.go @@ -274,7 +274,7 @@ p { import future.keywords -p { +p if { every k, v in [1, 2] { k != v } }`, }, @@ -290,7 +290,7 @@ p { import future.keywords -p { +p if { every k, v in [1, 2] { k != v } }`, }, diff --git a/format/testfiles/test_contains.rego b/format/testfiles/test_contains.rego new file mode 100644 index 0000000000..ba229ca974 --- /dev/null +++ b/format/testfiles/test_contains.rego @@ -0,0 +1,12 @@ +package test.contains +import future.keywords.contains + +p contains "foo" { true } + +deny contains msg { + msg := "foo" +} +deny[msg] {msg := "bar" } + +# partial objects unchanged +o[k] = v { k := "ok"; v := "nok" } diff --git a/format/testfiles/test_contains.rego.formatted b/format/testfiles/test_contains.rego.formatted new file mode 100644 index 0000000000..7b712fa9db --- /dev/null +++ b/format/testfiles/test_contains.rego.formatted @@ -0,0 +1,19 @@ +package test.contains + +import future.keywords.contains + +p contains "foo" + +deny contains msg { + msg := "foo" +} + +deny contains msg { + msg := "bar" +} + +# partial objects unchanged +o[k] = v { + k := "ok" + v := "nok" +} diff --git a/format/testfiles/test_contains_if.rego b/format/testfiles/test_contains_if.rego new file mode 100644 index 0000000000..8506083c62 --- /dev/null +++ b/format/testfiles/test_contains_if.rego @@ -0,0 +1,17 @@ +package test.if + +import future.keywords + +q[x] = y if { + y := 10 + x := "ten" +} + +q[x] = y { # not using if + y := 11 + x := "eleven" +} + +r[x] { # no if here before + x := "set" +} diff --git a/format/testfiles/test_contains_if.rego.formatted b/format/testfiles/test_contains_if.rego.formatted new file mode 100644 index 0000000000..8170c70ec2 --- /dev/null +++ b/format/testfiles/test_contains_if.rego.formatted @@ -0,0 +1,17 @@ +package test.if + +import future.keywords + +q[x] = y if { + y := 10 + x := "ten" +} + +q[x] = y if { # not using if + y := 11 + x := "eleven" +} + +r contains x if { # no if here before + x := "set" +} diff --git a/format/testfiles/test_if.rego b/format/testfiles/test_if.rego new file mode 100644 index 0000000000..5f7dca32b6 --- /dev/null +++ b/format/testfiles/test_if.rego @@ -0,0 +1,32 @@ +package test.if + +import future.keywords.if + +p if 1 > 0 # shorthand + +p if { 1 > 0 } # longhand one line + +p if { + 1 > 0 # longhand two lines +} + +# same without the comment +p if { + 1 > 0 +} + +p if { # comment one + 1 > 0 # comment two +} + +q[x] = y if { + y := 10 + x := "ten" +} + +q[x] = y { # not using if + y := 11 + x := "eleven" +} + +r[x] { x := "set" } # no if here before diff --git a/format/testfiles/test_if.rego.formatted b/format/testfiles/test_if.rego.formatted new file mode 100644 index 0000000000..b657f105fd --- /dev/null +++ b/format/testfiles/test_if.rego.formatted @@ -0,0 +1,34 @@ +package test.if + +import future.keywords.if + +p if 1 > 0 # shorthand + +p if 1 > 0 # longhand one line + +p if { + 1 > 0 # longhand two lines +} + +# same without the comment +p if { + 1 > 0 +} + +p if { # comment one + 1 > 0 # comment two +} + +q[x] = y if { + y := 10 + x := "ten" +} + +q[x] = y if { # not using if + y := 11 + x := "eleven" +} + +r[x] { # no if here before + x := "set" +} diff --git a/format/testfiles/test_if_else.rego b/format/testfiles/test_if_else.rego new file mode 100644 index 0000000000..95f5763b6b --- /dev/null +++ b/format/testfiles/test_if_else.rego @@ -0,0 +1,13 @@ +package test.if + +import future.keywords.if + +p := 1 if { 1 > 0 } +else := 2 + +q := 1 if { 1 > 0 } else := 2 { 2 > 1 } + +q := 1 if { + 1 > 0 + 2 > 1 +} else := 2 { 2 > 1 } diff --git a/format/testfiles/test_if_else.rego.formatted b/format/testfiles/test_if_else.rego.formatted new file mode 100644 index 0000000000..1ccad31198 --- /dev/null +++ b/format/testfiles/test_if_else.rego.formatted @@ -0,0 +1,22 @@ +package test.if + +import future.keywords.if + +p := 1 if 1 > 0 + +else := 2 { + true +} + +q := 1 if 1 > 0 + +else := 2 { + 2 > 1 +} + +q := 1 if { + 1 > 0 + 2 > 1 +} else := 2 { + 2 > 1 +} diff --git a/format/testfiles/test_in_operator_with_all_keywords_import.rego.formatted b/format/testfiles/test_in_operator_with_all_keywords_import.rego.formatted index 78ee143a58..2841f604c0 100644 --- a/format/testfiles/test_in_operator_with_all_keywords_import.rego.formatted +++ b/format/testfiles/test_in_operator_with_all_keywords_import.rego.formatted @@ -3,7 +3,7 @@ package p import future.keywords import input.foo -r { +r if { 1 in [1] 0, 1 in [1] } diff --git a/internal/future/filter_imports.go b/internal/future/filter_imports.go index b0fdc8c66d..2863aad4e9 100644 --- a/internal/future/filter_imports.go +++ b/internal/future/filter_imports.go @@ -18,3 +18,20 @@ func FilterFutureImports(imps []*ast.Import) []*ast.Import { } return ret } + +// IsAllFutureKeywords returns true if the passed *ast.Import is `future.keywords` +func IsAllFutureKeywords(imp *ast.Import) bool { + path := imp.Path.Value.(ast.Ref) + return len(path) == 2 && + ast.FutureRootDocument.Equal(path[0]) && + path[1].Equal(ast.StringTerm("keywords")) +} + +// IsFutureKeyword returns true if the passed *ast.Import is `future.keywords.{kw}` +func IsFutureKeyword(imp *ast.Import, kw string) bool { + path := imp.Path.Value.(ast.Ref) + return len(path) == 3 && + ast.FutureRootDocument.Equal(path[0]) && + path[1].Equal(ast.StringTerm("keywords")) && + path[2].Equal(ast.StringTerm(kw)) +} diff --git a/test/cases/testdata/containskeyword/test-contains-future-keyword.yaml b/test/cases/testdata/containskeyword/test-contains-future-keyword.yaml new file mode 100644 index 0000000000..9c54f6e72c --- /dev/null +++ b/test/cases/testdata/containskeyword/test-contains-future-keyword.yaml @@ -0,0 +1,67 @@ +cases: +- data: + modules: + - | + package test + p { + contains("fireplace", "repl") + } + note: containskeyword/base case + query: data.test.p = x + want_result: + - x: true +- data: + modules: + - | + package test + import future.keywords.contains + + p { + contains("fireplace", "repl") + } + note: containskeyword/with unused kw import + query: data.test.p = x + want_result: + - x: true +- data: + modules: + - | + package test + import future.keywords.contains + + p contains "x" { + contains("fireplace", "repl") + } + note: containskeyword/with kw and builtin used + query: data.test.p = x + want_result: + - x: [x] +- data: + modules: + - | + package test + import future.keywords.contains + + p contains "x" + note: containskeyword/empty body + query: data.test.p = x + want_result: + - x: [x] +- data: + modules: + - | + package test + import future.keywords.contains + + p contains msg { + msg := "nono" + } + p contains msg { + msg := "nonono" + } + note: containskeyword/ordinary deny rule + query: data.test.p = x + want_result: + - x: + - nono + - nonono