用 Goalng 开发 OPA 策略

Open Policy Agent 简称OPA是一个开源的通用策略引擎,可在整个堆栈中实现统一的、上下文感知的策略实施。OPA 已经成为了云原生计算基金会 (CNCF) 领域的毕业项目,已经在 Kubernetes / Istio 等多个知名项目里使用 。

OPA的核心思想就是策略即代码。

它使用Rego语言开发,Rego 的灵感来自 Datalog,它是一种易于理解、已有数十年的历史的查询语言。Rego 扩展了 Datalog 以支持 JSON 等文档模型。对于它的详细介绍请参考官方文档 https://www.openpolicyagent.org/docs/latest/policy-language/#what-is-rego,这里不再介绍,本方主要介绍如何使用Golang 来开发一个opa策略。

概述

OPA 将策略决策与策略执行分离。当您的软件需要做出策略决策时,它会查询 OPA 并提供结构化数据(例如 JSON)作为输入。 OPA 接受任意结构化数据作为输入。

对于它的输入,一般称为 input 可以为任何类型,输出也一样可以为任意类型,即可以输出布尔值truefalse,也可以输出一个 JSON 字符串对象。

示例

我们先从官方提供的一个 playground https://play.openpolicyagent.org/p/qUkvgJRpIU 开始,它是一个官方提供的在线执行平台。

从界面我们可以看出,窗口主要分四块:

  • 左侧是 Policy窗口,即上方图的黄色部分
  • 右上方的为用户提供数据的input 窗口,也可以直接省略不写,大部分情况下为 JSONYAML 数据格式
  • 右中间窗口为 Data ,提供数据源部分,一般是一个对接持久化数据库的存储层接口。有时规则里有一些数据需要从数据库里查询提取,如根据 input.user_id 查询出来用户入职时间,在规则里根据这个入职时间进行一些逻辑判断之类。
  • 右下方则为评估结果输出区域,在 playground 这种方式中会将全部信息都输出给我们,没有办法指定查询指定的字段值,官方提示的示例中输出为布尔值。

下面我们重点只介绍下 Policy,它才是我们本节关注的重点。

Policy

Rego 策略是使用一组相对较小的类型定义的:ModulePackageImportsRuleExprTerm。从本质上讲,策略Rule 组成,这些 Rule 由一个或多个Expr对策略引擎可用的文档进行定义Expr内在值(Term)定义,例如字符串对象变量等。源码在 https://pkg.go.dev/github.com/open-policy-agent/opa/ast

从大体中来说,主要为PackageImportsRules三部分,而对于 Rules 是由多个 Rule 组成。

Rego 策略通常在文本文件中定义,然后在运行时由策略引擎解析和编译。

解析阶段获取策略的文本或字符串表示,并将其转换为由上述类型组成的抽象语法树 (AST)。 AST 的组织如下:

Module
 |
 +--- Package (Reference)
 |
 +--- Imports
 |     |
 |     +--- Import (Term)
 |
 +--- Rules
       |
       +--- Rule
             |
             +--- Head
             |     |
             |     +--- Name (Variable)
             |     |
             |     +--- Key (Term)
             |     |
             |     +--- Value (Term)
             |
             +--- Body
                   |
                   +--- Expression (Term | Terms | Variable Declaration)

在查询时,策略引擎期望策略已经编译。编译阶段采用一个或多个模块并将它们编译成策略引擎支持的格式。

知道了AST 的组织结构,我们再看看官方提供的示例

# 当前所在包声明
package application.authz

# 如果引用了外部包,则需要声明。下面的in关键字后期版本会作为默认关键字,到时候就不再需要导入
# 下面这行是为了介绍包,故意添加上的,官方示例没有这一行
# import future.keywords.in

# 要实现公公宠物主人可以更新宠物的信息, 主人关系是由OPA的输入input(右上方窗口内容)来提供的
# Only owner can update the pet's information
# Ownership information is provided as part of OPA's input

# 声明结果默认值为 false, 如果不写这行的话,当没有规则不满足的时候,不会输出任何内容。可以试着注释掉这行运行看下结果
default allow = false

# 规则内容
allow {
 input.method == "PUT" # 请求类型,常见的有 GET/POST
 some petid # 声明一个值some局部变量
 input.path = ["pets", petid] # 请求路径 /pets/pet113-987
 input.user == input.owner  # 宠物主人
}

一个完整的规则 Rule 是由 HeadBody 两部分组成。

上面的 allow 表示规则变量名,它的完整格式为 allow := true,一般推荐使用简写方式,它是组成部分的 Rule.Head 部分

allow{} 里面的内容是由多个表达式组成, 它是 Body 部分。

allow 里面的内容每一行为一个表达式 Expr 。 如果在 rego 文件里写规则的话,也可以将多个表示式放在一行,中间分 ; 分开。

这里的 input 是一个系统保留字, 表示用户的输入,它表示右上角窗口里内容;

第一行的意思是判断右上角窗口里的 method 字段的值是否为 PUT

其中第二行使用了some 关键字声明了一个变量,它也是一个表达式。

整个规则表示当用户通过PUT 请求路径 /pets/xxxxxxx 时,如果当前的访问用户 input.user 是(==) 宠物的主人 input.owner ,即对象里每个规则结果都true,则将 allow 赋值为 true

这里规则里有两个判等语句,一个是 input.method == "PUT",另一个是 input.user == input.owner。如果在判断的时候遇到第一个为 false 的,则终止后续的语句,否则将一直执行直到结束。

为了方便查询,建议先选中窗口的”Coverage“选择项,红色的表示有不执行的语句,绿色的表示执行过的语句。如果我们现在点击 “Evalute”按钮的话,可以看到输出结果为

{
 "allow": false
}

同时在输出结果的上面还显示了本次执行得出的结果数量和消耗的时间。

这是因为 input.user == input.owner 这个条件不成立,如果我们试着修改访问用户 {"user": "bob@hooli.com"} , 则输出结果为 true

Go 开发

上面的规则如果用Golang 来实现的话,我们应该怎么写呢,在此我们还得要了解一下我们最上面提供的策略组成层级关系才可以。

规则生成

一般我们都是先创建RuleSet,再后再多个Rule 并将其添加到里面。

一个RuleHeadBody和其它字段组成

type Rule struct {
    Location *Location `json:"-"`
    Default  bool      `json:"default,omitempty"`
    Head     *Head     `json:"head"`
    Body     Body      `json:"body"`
    Else     *Rule     `json:"else,omitempty"`

    Module *Module `json:"-"`
}

其中 Head 就是我们规则名,即示例中的 allow字段,而 Body字段是是里面的规则,它是由多个表达式组成

type Head struct {
    Location *Location `json:"-"`
    Name     Var       `json:"name"`
    Args     Args      `json:"args,omitempty"`
    Key      *Term     `json:"key,omitempty"`
    Value    *Term     `json:"value,omitempty"`
    Assign   bool      `json:"assign,omitempty"`
}

type Body []*Expr

// Expr represents a single expression contained inside the body of a rule.
type Expr struct {
    With      []*With     `json:"with,omitempty"`
    Terms     interface{} `json:"terms"`
    Location  *Location   `json:"-"`
    Index     int         `json:"index"`
    Generated bool        `json:"generated,omitempty"`
    Negated   bool        `json:"negated,omitempty"`
}

对于表达式,我们直接调用提供的函数即可创建,不同类型的表达式使用的不同的函数。

我们先创建一个规则集 RuleSet,然后动态添加 Rule 规则

// RuleSet
 rs := ast.NewRuleSet()

 // 添加第一个规则,规则的生成通过从字符串里解析实现
 rs.Add(ast.MustParseRule(`default allow = false`))

这里通过 ast.NewRuleSet()函数创建一个规则集合,然后通过 rs.Add() 函数添加规则。

对于规则的创建一般由两种方式,一种是从字符串里直接解析,另一种是通过提供了函数动态创建,我们这里使用了第一种。有些规则也只能通过解析字符串来创建,例如下面的 some v这类的。

下面我们开始创建第二个规则 allow,我们上面介绍过了 Rule 的结构体,常用的就三个字段 HeadBodyElse 。其中 Else 字段主要用在 if-else 类规则,这里我们没有用到。

第一个表达式

我们先创建一个 rule.Body, 并实现添加第一个规则表达式 input.method == "PUT"

 body := ast.Body{}
 body.Append(
   ast.Equal.Expr(
     ast.RefTerm(ast.VarTerm("input"), ast.StringTerm("method")),
     ast.StringTerm("PUT"),
   ),
 )

一个表达式由三部分组成,分别为 运算符+-*/,=,=== 此类的;还有运算符左侧的元素,一般为变量或字段值;其次就是运算符右侧的元素。

这里实现了判等操作,左侧元素是一个多层级的对象字段,这种情况下需要通过 ast.RefTerm()函数将多个对象连接起来实现。对于只有一个值的来说,直接调用相应类型的函数即可,如字符串类型为 ast.StringTerm(), 布尔类型的为 ast.BooleanTerm(),类似的还有一些,都是一些常用的其它类型的处理函数。

第二个规则表达式

表达式some petid

body.Append(ast.MustParseExpr("some petid")) 

对于这类我们只能通过字符串解析的方式实现,没有办法用函数调用。不过此方法也是最简单的方式,避免了使用函数动态创建表达式出错。

第三个表达式

这个表达式稍微有一点点的复杂,主要是右侧的是一个数组对象。

    body.Append(ast.Equality.Expr(
        ast.RefTerm(
            ast.VarTerm("input"),
            ast.some petid("path"),
        ),
        ast.ArrayTerm(
            ast.StringTerm("pets"),
            ast.VarTerm("petid"),
        ),
    ))

运算符为赋值运算符,直接调用 ast.Equality.Expr 函数创建即可,另外还有一个 ast.Assign.Expr 函数用来创建 := 局部变量。左侧部分还是和上面的一样,对于数组一共有两个元素,分别为字符串和变量。我们只要先创建一个数组,再创建两个数组元素即可。

第四个表达式

    body.Append(
        ast.Equal.Expr(
            ast.RefTerm(ast.VarTerm("input"), ast.StringTerm("user")),
            ast.RefTerm(ast.VarTerm("input"), ast.StringTerm("owner")),
        ),
    )

这个也很简单,基本和第一个表达式一样。

现在第二个规则的四个表达式创建完成,现在我们将其添加到 RuleSet 中即可

// Rule
    r1 := &ast.Rule{
        Head: &ast.Head{
            Name: ast.Var("allow"),
        },
        Body: body,
    }
    rs.Add(r1)

这里我们先使用 &ast.Rule 创建一个规则,其中 Head 是规则名称,也就是 allow, 它是可以指定一些参数的,详细见上面 Head 的数据结构;而 Body 就是我们上面的 body 变量。需要注意的是这里的规则一定要使用 & 取址即 &ast.Rule,千万不要搞错了。

生成Module

Module 的数据结构为

type Module struct {
        Package     *Package       `json:"package"`
        Imports     []*Import      `json:"imports,omitempty"`
        Annotations []*Annotations `json:"annotations,omitempty"`
        Rules       []*Rule        `json:"rules,omitempty"`
        Comments    []*Comment     `json:"comments,omitempty"`
    }

Package 字段表示当前规则所属包

Imports 表示导入了三方的库,当前没有用到

Rules 规则,也就是我们上面创建的 rs

所以最后合在一起的代码

// Module
    mod := ast.Module{
        Package: &ast.Package{
            Path: ast.Ref{
                ast.StringTerm("policy.rego"),
                ast.StringTerm("application"),
                ast.StringTerm("authz"),
            },
        },
        Rules: rs,
    }

在创建 Package.Path时, ast.Ref对象的第一个参数可以为任意值,暂不清楚它的意义。从第二个参数开始后面的元素,按先后顺序每个元素就是一个包名,这里规则所属的包名为 application.authz

执行并验证

最后我们验证下它的生成是否正确

 # format.Ast() 参数是一个地址,一定要小心
    bs, err := format.Ast(&mod)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(string(bs))

这里调用 format.Ast() 函数输出规则最后生成的rego代码,这里一定要注意参数是一个地址值不是普通的值。

输入

上面我们使用 Go 动态创建了规则,规则里面用到的一些数据是通过input 来提供的。

只有在进行评估的时候,才需要提供这些数据。如评估当前登录的用户是否有访问指定资源的权限,这时只有提供了当前登录用户的信息,然后才能根据规则进行评估,这里的用户登录信息就是我们所说的输入,即 input

下面我们再次使用 Go 来实现数据的输入。

// input
    r := rego.New(
        rego.Module("example.rego", string(bs)),
        rego.Query("data.application.authz.allow"),
    )

创建一个 rego 对象,并在创建时并以 function options 编程模式提供一些内容。

rego.Module 提示当前的输入。第一个参数表示当前规则所在的文件名,这里由于是动态创建指定的,所以可以任意填写;第二个参数表示规则内容字符串,就是上面的变量 bs 内容。

rego.Query 表示要查询的内容。对于评估结果的获取,一般以 data为前缀,如这里表示要获取 application.authz包中的 allow 评估结果。

执行评估

    r := rego.New(
        rego.Input(input),
        rego.Module("example.rego", string(bs)),
        rego.Query("data.application.authz.allow"),
    )

  # 评估
    ctx := context.Background()
    q, err := r.PrepareForEval(ctx)
    if err != nil {
        log.Fatal(err)
    }
    resultSet, err1 := q.Eval(ctx)
    if err1 != nil {
        log.Fatal(err)
    }

    fmt.Println("len:", len(resultSet))
    if len(resultSet) > 0 {
        fmt.Printf("text=%stvalue=%#vn", resultSet[0].Expressions[0].Text, resultSet[0].Expressions[0].Value)
    }
    fmt.Println(resultSet.Allowed())
    fmt.Printf("%#v", resultSet)

最后输出结果

len: 1
text=data.application.authz.allow       value=false
false
rego.ResultSet{rego.Result{Expressions:[]*rego.ExpressionValue{(*rego.ExpressionValue)(0xc00021e300)}, Bindings:rego.Vars{}}}%

可以看到最后打印结果这里可以直接调用 resultSet.Allowed()来实现,返回一个布尔值,这里为false表示无权限。

也可以将上面输入内容 alice@hooli.com修改为bob@hooli.com,再次执行,就可以返回 true表示有权限。

在我们的示例中,只有一个allow 规则项,一般情况下会有多个,这个时候就可以查询的时候,不需要指定特定的规则名称,如

rego.Query("data.application.authz")

这样就可以查询出来包中的所有可输出的评估结果。

OPA 有一点要注意,如果没有为规则名称设置默认值的话,当规则评估结果为 false 的话,它的值是不会在输出结果中显示的,因此建议最后在规则前面声明初始化声明一个默认值。

常见问题

对于规则,一定要注意对象与数组写法的区别。

例如对象规则

input.method == "PUT"

正确的写法为

ast.Equal.Expr(
    ast.RefTerm(ast.VarTerm("input"), ast.StringTerm("method")),
    ast.StringTerm("PUT"),
),

method 是对象 input 的一个 字符串 属性

如果写成

ast.Equal.Expr(
    ast.RefTerm(ast.VarTerm("input"), ast.VarTerm("method")),
    ast.StringTerm("PUT"),
),

则实际的结果为

input[method] == "PUT"

这里的 methodast.VarTerm() 函数创建,表示它是一个变量,也就是此变量需要在其它地方定义了才可以。

我们再看下数组,一般都是按索引取值的,如 arr[0]arr[1] 之类,如

input[0] == "PUT"

正确写法为

ast.Equal.Expr(
    ast.RefTerm(ast.VarTerm("input"), ast.IntNumberTerm(0)),
    ast.StringTerm("PUT"),
),

这里用到了 ast.IntNumberTerm() 函数。

扩展阅读

  • 示例中介绍的是一些基本的用法,还有常用关键字没有介绍,参考官网文档
  • 对于赋值有两个操作符,一种是 = ,另一种是 := ,后面的这种用法属于声明一个局部变量,同时在其声明的时候,变量只能声明一次,在遇到对一个变量使用 := 赋值值,编译器会检查这个变量是否已经被声明过,如果声明过,则会编译出错,如 这个例子。
  • 系统针对每种数据类型提供了一些相应的内置函数,同时也可以自定义函数。
  • 同一个规则名,允许声明多次,只要其中任意一个满足条件即可,如这个例子

参考文档: