Skip to main content

OPA Policy Authoring

·2069 words·10 mins
Jack Warner
Author
Jack Warner
A little blog by me
Table of Contents

Overview of OPA
#

The Probelm OPA Solves
#

What is OPA
#

Quiz on OPA and its Integrations
#

OPA Features
#

Quiz on OPA Features
#

Future Reading
#

Rego Expressions
#

Single Values
#

Rego Design Principles
#

Syntax: Mirror declarative real-world policies
#

99% of Rego statements are IF statements, like those found in PDF/email policies

allow {
  user == "alice" # allow if user is alice
}

Semantics: Embrace hierarchical data
#

Rego provides first-class support for navigating and constructing deeply nested data

input.token.claims[i].id

Algorithms: Optimize Performance Automatically
#

Policy author is responsible for correctness. OPA is responsible for performance

Rego Overview
#

When writing Rego you do two things:

  1. Write Rules that make policy decisions. A Rule is a conditional assignment.
Assignment IF Conditions
allow is true IF user is alice and action is read
allow := true IF {user == “alice”; action == “read”}
• value assignment
• element assignment to a set
• key assigned to a value
• function call assigned to a result
IF • variable assignment
• Reference, e.g. input.user
• Equality or inequality
• Function call
• iteration
  1. Organize Rules into Policies. A Policy is set of Rules with a hierarchical name.

Values & Variables
#

Rego is a superset of JSON. Rego values are JSON values plust sets.

  • String
  • Number
  • Boolean
  • null
  • Array (a list of values)
  • Object (a dictionary mapping strings to values)
  • Set (an unordered collection of distinct values)

Assignment (:=) assigns a variable to a value

s := "a string"                                                  #string 
n := 17.35                                                       #number
b := true                                                        #boolean
u := null                                                        #null
a := [1, 1, 2, 3]                                                #array
d := {"key": "value", "user": "alice", "path": ["Pets", "dogs"]} #object
e := {1, 2, 3}                                                   #set

Variable are immutable. Do not assign them twice

x := 1
x := 2  # COMPILER ERROR 

Input and Data Variables
#

input is a global variable sotring the JSON object given to OPA

# OPA does this assignment for you
input := {
  "metadata":{
    "name": "netpol1",
    "namespace": "dev"}
  }, 
  "spec": {...}
}

data is a global variable storing the external data given to OPA

# OPA manages this variable for you 
data := {
  "oncall" :{
    "alice": { "level": 2},
    "bob": { "level": 1},
    "charlie": { "level": 1}
  },
  ... # many other sources of data 
}

Bracket Expression
#

# object
obj := {
  "user": "alice",
  path: ["pets", "dogs"]
}

# array 
arr := ["apple", "banana", "carrot", "apple"]

# set 
st := {"apple", "banana", "carrot"}

Bracket [] inspects objects

obj["user"]          # "alice"

key:= "user"
obj[key]            # "alice"

Brackets [] inspect arrays (0-indexed arrays)

arr[2]              # "carrot"

index := 2
arr[index]         # "carrot"

Brackets [] inspect sets

st["banana"]        # "banana"

key := "banana"
st[key]            # "banana"

Bracket applies repeatedly

obj["path"][1]      # "dogs"

Dot Expressions
#

# object
obj := { 
  "user": "alice",
  "path": ["pets", "dogs"], 
  "foo": {"bar": 7} 
}

# array 
arr := ["apple", "apple", "banana", "carrot"]

# set
st := {"apple", "banana", "carrot"}

Dot . is a shorthand for Bracket[]. Rego makes x.y into x[“y”]

obj.user            # "alice"
obj["user"]         # "alice"
obj.path[0]         # "pets"
obj.foo.bar         # 7
obj["foo"].bar      # 7

Dot can be used only when the key is alpha-numeric starting with a letter. In practice, Dot is used only with objects, not arrays or sets.

arr.0              # COMPILER ERROR
arr[0]             # "apple"

Examples to test your knowledge

x := "user"
obj[x]          # Equivalent to obj["user"], which is "alice" 
obj.x           # Equivalent to obj["x"], which is missing 

y := "foo.bar"
obj[y]          # Equivalent to obj["foo.bar"], which is missing
obj.foo.bar     # Equivalent to obj["foo"]["bar"], which is 7

Undefined
#

# object
obj := { 
  "user": "alice",
  "path": ["pets", "dogs"], 
  "foo": {"bar": 7} 
}

# array 
arr := ["apple", "apple", "banana", "carrot"]

# set
st := {"apple", "banana", "carrot"}

When a path is missing, result is Undefined–not an error.

obj.x               # Undefined
obj.foo.x           # Undefined
obj.x.y.z           # Undefined
obj.path[47]        # Undefined
count(obj.path[47]) # Undefined
v := obj.path[47]   # v is Undefined

NOT turns UNDEFINED into true NOT turns false into true NOT turns everything else into undefined

not obj.x         # true
not false         # true
not 42            # undefined
not true          # undefined

Check path existence by writing the path

# check is path exists 
obj.foo.bar 

# check if path does not exists 
not obj.foo.x

Comparing and Constructing Values
#

Equality Expressions
#

Comparison operator (==) on scalars checks if values are equal

"apple" == "apple"   # true
1 == 2               # false

Comparison (==) does recursive, semantic equality checks

[1, [2, 3]] == [1, [2, 3]]                                           # true
{1, 3, 1, 4} == {4, 4, 1, 3, 1}                                      # true
{1, 2, 3} == {2, 3, 4}                                               # false
{"alice": 1, "bob": 2} == {"bob":2, "alice" : 1}                     #true
[{"alice": 1}, "bob": 2}, {3,4}] == [{"alice": 1}, "bob": 2}, {4,3}] #true

Unification operator (=) combines assignment (:=) and comparision (==) Unification assigns any unassigned variables so that the comparison returns true. Use it only when necessary. Prefer := and ==

[1, x] = [1, 2]   # x is assigned to 2
[1, x] = [y, 2]   # assigns x to 2 and y to 1
[1, x] = [2, y]   # undefined

Built-in Expressions
#

50+ builtin functions for comparison and construction: openpolicyagent.org/docs/latest/policy-reference/

  • No mutation of arguments. Return new values instead
  • No optional arguments, though can take objects/arrays/set as arguments.
  • Can generate errors (e.g. division by zero)
Expressions:
Basic: ==, !=, <, <=, >, >=, +, -, *, /, %
Strings: concatenate, lowercase, trim, replace, regexp, glob
Arrays/Sets/Objects: concatenate, slice, intersect, union, difference, remove, filter
Aggregates: count, sum. min, sort
Parsing: base64, url, json, yaml
Tokens: verification, decode, encode
Time: date, time, weekday, add
Network CIDRs: contains, intersects, expand

Basic builtins are infix

x + (y * 3) > 5

Remaining builtns are functions

count(z) > 1
part := substring(w, 0, count(t))

Basic Rego Rules
#

Boolean rules and evaluation
#

Rego Overview
#

When writing Rego you do two things.

  1. Write rules that make policy decisions. A Rule is a conditional assignment.
Assignment IF Conditions
allow is true IF user is alice and action is read
allow := true IF {user == “alice”; action == “read”}
• value assignment
• element assignment to a set
• key assigned to a value
• function call assigned to a result
IF • variable assignment
• Reference, e.g. input.user
• Equality or inequality
• Function call
• iteration

Boolean Rules
#

request:
  id: 123
  method: GET
  path: "/api/v1/products"
  host: "192.168.1.1"
  protocol: "HTTP/1.1"
  token:
    user: alice
    roles:
    - manager
    - engineering

Boolean rules are IF statements that assign a variable to true or false. Neither allow nor deny are keywords; they ar eboolean variables.

# Variable 'allow' is assigned the value 'true' IF ...
allow = true{
  ...
}

By default a rule assigns the value true

# the following 2 rules are equivalent
allow = true {
  ...
}
# vs. 
allow {
  ...
}
# OPA knows what you mean

The IF part (rule body) is a collection of (i) assignments and (ii) expressions. The IF part is an AND. All assignments and expressions must succeed for the IF to succeed.

# alice can read everything   
allow = true {                          # allow if true if ...
  input.request.token.user == "alice"   # user is alice AND 
  input.request.method == "GET"         # method is GET
}  

Rule evaluation
#

request:
  id: 123
  method: GET
  path: "/api/v1/products"
  host: "192.168.1.1"
  protocol: "HTTP/1.1"
  token:
    user: alice
    roles:
    - manager
    - engineering

A successful rule evaluation

allow = true{
  input.request.token.user == "alice"   # true AND
  input.request.method == "GET"         # true
}
# allow evaluates to true

A unsuccessful rule evaluation

allow = true{
  input.request.token.user == "alice"   # true AND
  input.request.method == "PUT"         # false
}
# allow evaluates to undefined. This is because allow is ONLY set to true WHEN the two conditions in the body are met. Since they are not met allow is not defined. 

An unsuccessful rule evaluation with undefined

allow = true{
  input.request.method == "GET"     # true AND
  startswith(input.food.bar, "baz") # undefined 
}
# allow evaluates to undefined 

Multiple Rules
#

request:
  id: 123
  method: GET
  path: "/api/v1/products"
  host: "192.168.1.1"
  protocol: "HTTP/1.1"
  token:
    user: alice
    roles:
    - manager
    - engineering

Multiple rules give logical OR.

is_read{
  input.request.method == "GET"
}
is_read{
  input.request.method == "HEAD"
}

Rule order is irrelevant.

OPA could decide to evaluate ALL rules that are pertinent to the query. OPA could decide to terminate early but will return the same result as if it evaluated all rules.

For priority evaluation, use ELSE keyword. But use it sparingly because it disables optimizations. Prefer instead to make rule bodies mutually exclusive.

Under- and Over- assignment
#

request:
  id: 123
  method: GET
  path: "/api/v1/products"
  host: "192.168.1.1"
  protocol: "HTTP/1.1"
  token:
    user: alice
    roles:
    - manager
    - engineering

If no rules succeed, a scalar variable’s value is undefined

is_read{ ... }
is_read{ ... }
is_read{ ... }

DEFAULT sets a value when no rules succeed

default is_read = false # use single equals here 

Multiple rules yielding different assignments produces an error. Avoid by making rule bodies mutually exclusive

foo = true {true}
foo = false {true} # RUNTIME ERROR 

The not operator needs to be the outermost operator in a rule condition

Rule chaining and non-boolean rules
#

Rule Chaining
#

request:
  id: 123
  method: GET
  path: "/api/v1/products"
  host: "192.168.1.1"
  protocol: "HTTP/1.1"
  token:
    user: alice
    roles:
    - manager
    - engineering

Rules can be used by other rules. Recommend using helpers for readability and modularity.

allow{
  action_is_read
  user_is_authenticated
}

action_is_read { ... }
user_is_authenticated { ... }

Note: Neither allow nor deny are keywords. They are just variables. Recursion is forbidden

allow{
  action_is_read
  user_is_authenticated
}
user_is_authenticated{
  allow   # COMPILER ERROR: recursion is forbidden 
}

Rule Chaining for AND/ORs
#

Policy: allow IF action is a read and user is authenticated or path is the root

# Helpers 
action_is_read{ ... }
user_is_authenticated{ ... }
path_is_root{ ... }

Option 1: ((aciton_is_read AND user_is_authenticated) OR (action_is_read AND path_is_root))

allow{
  action_is_read
  user_is_authenticated
}
allow{
  action_is_read
  path_is_root
}

Option 2: (action_is_read AND (user_is_authenticated OR path_is_root))

allow{
  action_is_read
  safe            # new helper
} 
safe{
  user_is_authenticated
}
safe{
  path_is_root
}

Non-boolean Rules
#

Variables can be assigned any Rego Value

code = 200 {
  allow
}

code = 403 {
  not allow
}

Values can be computed

port_number = result{
  values := split(input.request.host, ":")
  result:= to_number(values[1])
}

Commonly, multiple values are returned via an object

authz = result{
  result :={
    "allowed": allow, 
    "code" : code
  }
}

Rule bodies are optional

# all of these are equivalent
pi = x {
  x := 3.14
}
pi = 3.14 {
  true
}
pi = 3.14

Policy Decisions
#

A POLICY DECISION in Rego is the value of a variable. Caller asks for the value of a variable.

allow { ... }

code = 200 { 
  allow 
}

code = 403 {
  not allow
}

authz = {
  "allowed": allow, 
  "code" : code
}

A POLICY DECISION in Rego is the value of a variable. Caller asks for the value of a variable.

POST /v1/data/<policypath>/allow    <input>
=> { "result": true}

POST /v1/data/<policypath>/code     <input>
=> { "result": 200}

POST /v1/data/<policypath>/authz    <input>
=> { "result": { "allowed": true, "code": 200 } }

A Common Use Case: JWTs
#

request:
  id: 123
  method: GET
  path: "/api/v1/products"
  host: "192.168.1.1"
  protocol: "HTTP/1.1"
  jwt: eyAOWIHDOAWIHD... 

JSON Web Token (JWTs) often contain end-user information

user:alice
roles:
- manager
- engineering

Once decoded, the JWT is another JSON object

claims = payload{
  # verify the token (key can be pulled from environment)
  io.jwt.verify_hs256(input.request.jwt, "B4BD1203109h9ahd9...")

  # decode the token
  [header, payload, signature] := io.jwt.decode(input.request.jwt)
}

Use the JWT contents to make decisions

# allow alice to do everything
allow{
  claims.user == "alice"
}

Unit tests and test coverage
#

package main

import data.policy.role
import future.keywords.if

allow_review := true if { # allow a customer with reputation >=0  to review 
  input.role == "customer"
  input.reputation >= 0
}

allow_delete := true if { # allow a moderator to delete 
  role.is_moderator == true
}

### TEST CASES ## 
# Typical form 
test_NAME if {
  EXPECTATION_CONDITION with input as TESTING_INPUT 
}

test_allow_review if { # PASS
  allow_review == true with input as {"role" : "customer", "reputation": 0}
}

test_disallow_review_non_customer if{ # FAIL this is because allow_reivew can be set to false or undefined value. 
  allow_review == false with input as {"role" : "foo", "reputation": 0}
}
# to fix that we use NOT
test_disallow_review_non_customer if{ # PASS
  not allow_review with input as {"role" : "foo", "reputation": 0}
}

Related

About
·3 words·1 min
Wiz x Cloud Security Championship: Perimeter Leak
·1243 words·6 mins
HomeLab Part 1: Self-Hosted Password Manager with Cloudflare, Raspberry Pi, Nginx Proxy Manager, and VaultWarden
·513 words·3 mins