用 Goalng 开发 OPA 策略

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

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

概述

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

image.png

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

示例

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

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

  • 左侧是 Policy窗口,即上方图的黄色部分)

  • 右上方的为用户输入input窗口,一般是由用户方提供的,也可以直接省略不写,大部分情况下为 JSONYAML

  • 右中间窗口为 Data ,提供数据源部分。这类数据一般是一个存储层

  • 右下方则为输出,就是我们所需要的结果内容,在 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  # 宠物主人
}

上面的 allow 表示规则变量名,它是完整写法为 allow = true,一般都使用简写方式。

allow 后面的是一个 JSON 对象。这里的input 是一个系统保留字,对于保留字请参考 TODO, 表示用户的输入,也就是窗口右上角小窗口的内容;其中第二行使用了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": "[email protected]"} , 则输出结果为 true

image-20220511191539511.png

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 的结构体,常用的就三个字段 Head,BodyElse 。其中 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, 而 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=%s\tvalue=%#v\n", 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表示无权限。

也可以将上面输入内容 [email protected]修改为[email protected],再次执行,就可以返回true表示有权限。

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

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

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

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

参考文档:

  • https://www.openpolicyagent.org/

  • https://blog.openpolicyagent.org/

  • https://pkg.go.dev/github.com/open-policy-agent/opa/rego

你可能感兴趣的:(用 Goalng 开发 OPA 策略)