ast+format: introduce new keywords for rule heads: if and contains

`contains` provides an alternative way to declare partial sets:

    p contains x {
      x := { "foo": "bar"
    }

which is the same as

    p[x] {
      x := { "foo": "bar"
    }

The keyword is enabled by importing `future.keywords.contains`, and
when it _is enabled_, the format will be used for all partial sets in
that file for pretty-printing.

`if` is a new keyword allowing for more readable rule definitions:

The syntax is

    NAME [if] { EXPR [EXPR...] }

and the is a shorthand allows dropping the braces around the expression
if there is only one:

    NAME if EXPR

For example, this allows expressions like

    allow if not deny
    f(xs) if every x in xs { x != "foo" }

The one exception here are partial sets: they cannot use `if` UNLESS
they use `contains`:

    p[x] { x := "foo" }            # valid
    p contains x { x := "bar" }    # valid
    p contains x if { x := "bar" } # valid
    p[x] if { x := "foo" }         # invalid

This is because we want to interpret that differently (as an object
rule defining `p.foo = true`) in the near future.

The formatter works in the same way: if `future.keywords.if` is imported, it
will be used where it can be used.

We don't want to be too eager when it comes to introducing syntactic sugar.

So this will be rewritten, because head and body expression are on the same
line:

    p := 5 if { time.day_of_week() == "Monday" }

    # => p := 5 if time.day_of_week() == "Monday"

but this won't:

    p := 5 if {
       time.day_of_week() == "Monday"
    }

The rationale here is that if the policy author decided that they want this on
an extra line, we won't mess with it.

This also sidesteps the need to check if both the head and the single body
expression have a comment.

This change includes various docs updates. Notable exceptions are the GK docs,
since it will take a while for these keywords to be come available there; and
the frontpage: merging a PR would update the frontpage immediately, and we
don't want to show something there that isn't available in the latest release.

Signed-off-by: Stephan Renatus <stephan.renatus@gmail.com>
This commit is contained in:
Stephan Renatus
2022-06-13 11:33:23 +02:00
committed by Stephan Renatus
parent 9d4fc06f4d
commit 04a3523b22
25 changed files with 1032 additions and 352 deletions
+6 -2
View File
@@ -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
+4
View File
@@ -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{
+64 -12
View File
@@ -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 <VALUE> { ... })", 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] = <VALUE> { ... })", 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) {
+240 -2
View File
@@ -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])
+2
View File
@@ -4058,7 +4058,9 @@
}
],
"future_keywords": [
"contains",
"every",
"if",
"in"
],
"wasm_abi_versions": [
+75 -43
View File
@@ -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 `= <value>` 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
+24 -23
View File
@@ -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"
}
+33 -33
View File
@@ -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"},
],
}
```
+26 -24
View File
@@ -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"})
}
```
+155 -130
View File
@@ -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 <builtin1> 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 documents 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)
}
```
+37 -15
View File
@@ -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_BackusNaur_Form) for more details:
```
+43 -38
View File
@@ -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
}
```
+70 -27
View File
@@ -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)
}
+2 -2
View File
@@ -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 }
}`,
},
+12
View File
@@ -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" }
@@ -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"
}
+17
View File
@@ -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"
}
@@ -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"
}
+32
View File
@@ -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
+34
View File
@@ -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"
}
+13
View File
@@ -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 }
@@ -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
}
@@ -3,7 +3,7 @@ package p
import future.keywords
import input.foo
r {
r if {
1 in [1]
0, 1 in [1]
}
+17
View File
@@ -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))
}
@@ -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