写点什么

Kratos 微服务框架实现权鉴 - OPA

作者:喵个咪
  • 2023-01-12
    湖南
  • 本文字数:10882 字

    阅读完需:约 36 分钟

Kratos 微服务框架实现权鉴 - OPA

Open Policy Agent,官方简称 OPA,旨在统一不同技术和系统的策略执行。今天,OPA 被科技行业内的巨头们所使用。例如,Netflix 使用 OPA 来控制对其内部 API 资源的访问。Chef 用它来为他们的终端用户产品提供 IAM 功能。此外,许多其他公司,如 Cloudflare、Pinterest 等,都使用 OPA 在他们的平台上执行策略(如 Kubernetes 集群)。


OPA 最初是由 Styra 公司在 2016 年创建并开源的项目,目前该公司的主要产品就是提供可视化策略控制及策略执行的可视化 Dashboard 服务的。


OPA 首次进入 CNCF 并成为 sandbox 级别的项目是在 2018 年,在 2021 年的 2 月份便已经从 CNCF 毕业,这个过程相对来说还是比较快的,由此也可以看出 OPA 是一个比较活跃且应用广泛的项目。


策略(policy)是一套管理软件服务行为的规则。该策略可以描述速率限制、受信任的服务器名称、应用程序应部署到的集群、允许的网络路线或用户可以提款的账户等。


授权是一种特殊的策略,通常规定哪些人或机器可以在哪些资源上运行哪些操作。授权有时会与认证(Authentication)混淆:人或机器如何证明他们是他们所说的人。授权和更一般的策略经常利用认证的结果(用户名、用户属性、组、声明),但做出的决定所基于的信息远远超过用户是谁。从授权归纳到策略,使两者的区别更加清晰,因为有些策略决策与用户无关,例如,策略只是描述了软件系统中必须保持的不变量(例如,所有的二进制文件必须来自一个可信的来源)。


现在,策略通常是它实际管理的软件服务的一个硬编码功能。Open Policy Agent 让您可以将策略从软件服务中解耦出来,这样,负责策略的人员就可以从服务本身中分离出来,对策略进行读、写、分析、版本、发布以及一般的管理。OPA 还为您提供了一个统一的工具集,使您可以将策略与任何您喜欢的软件服务解耦,并使用任何您喜欢的上下文来编写上下文感知策略。简而言之,OPA 可以帮助您使用任何上下文从任何软件系统解耦任何策略。

理解 OPA

宏观上,OPA 可以分为四个核心概念:


  1. 请求输入(Request Input);

  2. 外部数据(Data);

  3. Rego 策略(Policy),在 OPA 当中使用了 DLS 语言 Rego 进行编写;

  4. 响应数据(Response),它不一定是单纯的 True/False,也可以是 JSON 格式的数据返回。


以上四个核心概念,在官方提供的交货期里边具有直观的体现:https://play.openpolicyagent.org,你可以在当中对模型和数据进行测试。


从微观上,一个请求输入由以下一个三元组组成:


  1. 访问实体 (Subject);

  2. 访问资源 (Object);

  3. 访问方法 (Action)。


看到这里,如果你使用过 Casbin,会发现一股熟悉的味道油然而生。没错,跟 Casbin 一样一样的。OPA 的 Rego 就相当于 Casbin 中的模型,Casbin 的模型是用表达式描述的,而 OPA 是使用 DSL 语言 Rego 进行的描述。Casbin 要更加简洁,但同时功能上也比较受到约束,肯定不如使用 DSL 更加的丰富。所以,使用 Casbin,如果需求简单还可以很好的应付,但是如果一旦需求复杂了,可能就会有点应付不来。

学习 Rego

Rego 是 OPA 的专用声明性策略语言。它用于编写易于阅读和编写的策略。从根本上说,Rego 检查和转换结构化文档中的数据,允许 OPA 做出政策决定。Rego 最初受到 Datalog 的启发,Datalog 是一种具有数十年历史的通用查询语言,但扩展了其功能以支持 JSON 等结构化文档模型。

赋值

Rego 的变量一旦赋值便不可再进行改变。


# 标量赋值greeting   := "Hello"max_height := 42pi         := 3.14159allowed    := truelocation   := null
# 复合类型赋值rect := {"width": 2, "height": 4}
# 集合allowed_users := [“papaya”, “potato”]
# 数组s1 := [1, 2, 3]
# 对象ips_by_port := { 80: ["1.1.1.1", "1.1.1.2"], 443: ["2.2.2.1"],}
复制代码

布尔判断

可以这样写:


v if "hello" == "world"
复制代码


也可以这样写:


t2 if {    x := 42    y := 41    x > y}
复制代码


因为关键字if是可选的,所以,还可以这样写:


v { "hello" == "world" }
t2 { x := 42 y := 41 x > y}
复制代码


Rego 使用;来表达逻辑 AND,或者要忽略;则使用多行来表达。



input.servers[0].id == "app"; input.servers[0].protocols[0] == "https"
复制代码



input.servers[0].id == "app"input.servers[0].protocols[0] == "https"
复制代码


是等价的。


逻辑 OR 可以用以下方式,只要其中一个决策块为真,即为真:


allow {    is_admin}allow {    is_endpoint_public}
复制代码

遍历

准确来说应该用迭代来描述,使用someevery关键字实现。


some val in arrsome i, val in arr
复制代码


或者


every k, v in {"foo": "bar", "fox": "baz" } {        startswith(k, "f")        startswith(v, "b")    }
复制代码


另外,还可以使用下划线_(通配符)进行遍历。


proj = input.projects[_]id := proj.id
some i, _ in arrval := arr[_]
复制代码

函数

Rego 当中函数具有以下特点:


  • 默认函数返回值为 true/false

  • 可以指定函数返回值

  • 可以存在同名函数, 但参数数目不能变

  • 相同输入(参数)必须获得相同输出(返回值)


举例来说,如果我们要实现一个方法来判断文件是否是配置文件后缀:


is_config_file(str) {  contains(str, ".yaml")}
is_config_file(str) { contains(str, ".yml")}
is_config_file(str) { contains(str, ".json")}
复制代码


上面的is_config_file就是不指定返回值,


条件满足则返回true,三个实现只要满足一个就为true


那么,我们想要三个方法合并为一个方法,能行吗?能行,使用else关键字实现:


is_config_file2(str) {  contains(str, ".yaml")}
else { contains(str, ".yml")}
else { contains(str, ".json")}
复制代码


指定返回值呢?这样做:


plus_custom(a, b) := c {    c := a + b}out := plus_custom(42, 43)
复制代码

一个完整的规则

有了以上这些语法基础,就可以开始写第一个 Repo 规则了:


package authz
default allow = false
# 放行所有的GET请求allow { input.method = "GET"}
# 允许 admin 用户做任何操作allow { input.user == "admin"}
# 允许 admin 用户组下的用户做任何操作allow { input.group[_] == "admin"}
复制代码


然后输入请求数据进行决策:


{    "user": "user1",    "group": [        "dev",        "admin"    ]}
复制代码


可以得到决策结果:


{    "allow": true}
复制代码

单元测试

Repo 提供了单元测试功能,假如上面的 Repo 的文件名叫做:example.repo,那么单元测试的文件名就应该叫做:example_test.rego,这一点跟 golang 的单元测试是很相似的。


那么,测试的代码有可能是这样:


package authzimport future.keywords
test_get_allowed if { allow with input as {"user": "user1", "method": "GET"}}
复制代码


然后在 repo 所在的文件夹下面运行下面的命令即可运行测试:


opa test . -v
example_test.rego:data.authz.test_get_allowed: PASS (522.5µs)--------------------------------------------------------------------------------PASS: 1/1
复制代码

一个最简单的 OPA 的 Golang 程序

package main
import ( "context" "encoding/json" "fmt" "log" "os"
"github.com/open-policy-agent/opa/rego")
func main() {
ctx := context.Background()
// Construct a Rego object that can be prepared or evaluated. r := rego.New( rego.Query(os.Args[2]), rego.Load([]string{os.Args[1]}, nil))
// Create a prepared query that can be evaluated. query, err := r.PrepareForEval(ctx) if err != nil { log.Fatal(err) }
// Load the input document from stdin. var input interface{} dec := json.NewDecoder(os.Stdin) dec.UseNumber() if err := dec.Decode(&input); err != nil { log.Fatal(err) }
// Execute the prepared query. rs, err := query.Eval(ctx, rego.EvalInput(input)) if err != nil { log.Fatal(err) }
// Do something with the result. fmt.Println(rs)}
复制代码

将 OPA 实施封装

package opa
// nolint:lll//go:generate go-bindata -pkg $GOPACKAGE -o policy.bindata.go -ignore .*_test.rego -ignore Makefile -ignore README\.md policy/...
import ( "context" "encoding/json" "fmt" "os" "strings"
"github.com/go-kratos/kratos/v2/log" "github.com/pkg/errors"
"github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/rego" "github.com/open-policy-agent/opa/storage" "github.com/open-policy-agent/opa/storage/inmem" "github.com/open-policy-agent/opa/topdown"
"github.com/tx7do/kratos-authz/engine")
var _ engine.Engine = (*State)(nil)
type State struct { store storage.Store queries map[string]ast.Body compiler *ast.Compiler modules map[string]*ast.Module preparedEvalProjects rego.PreparedEvalQuery}
const ( authzProjectsQuery = "data.authz.authorized_project[project]" filteredPairsQuery = "data.authz.introspection.authorized_pair[_]" filteredProjectsQuery = "data.authz.introspection.authorized_project")
func New(_ context.Context, opts ...OptFunc) (*State, error) { authzProjectsQueryParsed, err := ast.ParseBody(authzProjectsQuery) if err != nil { return nil, errors.Wrapf(err, "parse query %q", authzProjectsQuery) }
filteredPairsQueryParsed, err := ast.ParseBody(filteredPairsQuery) if err != nil { return nil, errors.Wrapf(err, "parse query %q", filteredPairsQuery) }
filteredProjectsQueryParsed, err := ast.ParseBody(filteredProjectsQuery) if err != nil { return nil, errors.Wrapf(err, "parse query %q", filteredProjectsQuery) }
s := State{ store: inmem.New(), queries: map[string]ast.Body{ authzProjectsQuery: authzProjectsQueryParsed, filteredPairsQuery: filteredPairsQueryParsed, filteredProjectsQuery: filteredProjectsQueryParsed, }, }
for _, opt := range opts { opt(&s) }
if err := s.initModules(); err != nil { return nil, errors.Wrap(err, "init OPA modules") }
return &s, nil}
func (s *State) ProjectsAuthorized(ctx context.Context, subjects engine.Subjects, action engine.Action, resource engine.Resource, projects engine.Projects) (engine.Projects, error) { var subs []*ast.Term for _, sub := range subjects { subs = append(subs, ast.NewTerm(ast.String(sub))) }
var projs []*ast.Term for _, proj := range projects { projs = append(projs, ast.NewTerm(ast.String(proj))) }
input := ast.NewObject( [2]*ast.Term{ast.NewTerm(ast.String("subjects")), ast.ArrayTerm(subs...)}, [2]*ast.Term{ast.NewTerm(ast.String("resource")), ast.NewTerm(ast.String(resource))}, [2]*ast.Term{ast.NewTerm(ast.String("action")), ast.NewTerm(ast.String(action))}, [2]*ast.Term{ast.NewTerm(ast.String("projects")), ast.ArrayTerm(projs...)}, ) resultSet, err := s.preparedEvalProjects.Eval(ctx, rego.EvalParsedInput(input)) if err != nil { return engine.Projects{}, &EvaluationError{e: err} }
return s.projectsFromPreparedEvalQuery(resultSet)}
func (s *State) FilterAuthorizedPairs(ctx context.Context, subjects engine.Subjects, pairs engine.Pairs) (engine.Pairs, error) { opaInput := map[string]interface{}{ "subjects": subjects, "pairs": pairs, }
rs, err := s.evalQuery(ctx, s.queries[filteredPairsQuery], opaInput, s.store) if err != nil { return nil, &EvaluationError{e: err} }
return s.pairsFromResults(rs)}
func (s *State) FilterAuthorizedProjects(ctx context.Context, subjects engine.Subjects) (engine.Projects, error) { opaInput := map[string]interface{}{ "subjects": subjects, }
rs, err := s.evalQuery(ctx, s.queries[filteredProjectsQuery], opaInput, s.store) if err != nil { return nil, &EvaluationError{e: err} }
return s.projectsFromPartialResults(rs)}
func (s *State) IsAuthorized(ctx context.Context, subject engine.Subject, action engine.Action, resource engine.Resource, project engine.Project) (bool, error) { if len(project) > 0 { input := ast.NewObject( [2]*ast.Term{ast.NewTerm(ast.String("subjects")), ast.ArrayTerm(ast.NewTerm(ast.String(subject)))}, [2]*ast.Term{ast.NewTerm(ast.String("resource")), ast.NewTerm(ast.String(resource))}, [2]*ast.Term{ast.NewTerm(ast.String("action")), ast.NewTerm(ast.String(action))}, [2]*ast.Term{ast.NewTerm(ast.String("projects")), ast.ArrayTerm(ast.NewTerm(ast.String(project)))}, ) resultSet, err := s.preparedEvalProjects.Eval(ctx, rego.EvalParsedInput(input)) if err != nil { return false, &EvaluationError{e: err} } return s.allowedFromPreparedEvalQuery(resultSet) } else { opaInput := map[string]interface{}{ "subjects": engine.MakeSubjects(subject), "pairs": engine.MakePairs(engine.Pair{Resource: resource, Action: action}), }
rs, err := s.evalQuery(ctx, s.queries[filteredPairsQuery], opaInput, s.store) if err != nil { return false, &EvaluationError{e: err} }
return s.pairsFromAllowed(rs) }}
func (s *State) SetPolicies(ctx context.Context, policyMap engine.PolicyMap, roleMap engine.RoleMap) error { s.store = inmem.NewFromObject(map[string]interface{}{ "policies": policyMap, "roles": roleMap, })
return s.makeAuthorizedProjectPreparedQuery(ctx)}
func (s *State) initModules() error { if len(s.modules) == 0 { mods := map[string]*ast.Module{} for _, name := range AssetNames() { if !strings.HasSuffix(name, ".rego") { continue } parsed, err := ast.ParseModule(name, string(MustAsset(name))) if err != nil { return errors.Wrapf(err, "parse policy file %q", name) } mods[name] = parsed } s.modules = mods }
compiler, err := s.newCompiler() if err != nil { return errors.Wrap(err, "init compiler") } s.compiler = compiler return nil}
func (s *State) makeAuthorizedProjectPreparedQuery(ctx context.Context) error { compiler, err := s.newCompiler() if err != nil { return err }
r := rego.New( rego.Store(s.store), rego.Compiler(compiler), rego.ParsedQuery(s.queries[authzProjectsQuery]), rego.DisableInlining([]string{ "data.authz.denied_project", }), )
pq, err := r.Partial(ctx) if err != nil { return err }
for i, module := range pq.Support { compiler.Modules[fmt.Sprintf("support%d", i)] = module }
main := &ast.Module{ Package: ast.MustParsePackage("package __partialauthz"), }
for i := range pq.Queries { rule := &ast.Rule{ Module: main, Head: ast.NewHead("authorized_project", ast.VarTerm("project")), Body: pq.Queries[i], } main.Rules = append(main.Rules, rule) }
compiler.Modules["__partialauthz"] = main
compiler.Compile(compiler.Modules)
if compiler.Failed() { return compiler.Errors }
r2 := rego.New( rego.Store(s.store), rego.Compiler(compiler), rego.Query("data.__partialauthz.authorized_project[project]"), )
query, err := r2.PrepareForEval(ctx) if err != nil { return errors.Wrap(err, "prepare query for eval (authorized_project)") }
s.preparedEvalProjects = query
return nil}
func (s *State) newCompiler() (*ast.Compiler, error) { compiler := ast.NewCompiler() compiler.Compile(s.modules) if compiler.Failed() { return nil, errors.Wrap(compiler.Errors, "compile modules") }
return compiler, nil}
func (s *State) DumpData(ctx context.Context) error { return dumpData(ctx, s.store)}
func dumpData(ctx context.Context, store storage.Store) error { txn, err := store.NewTransaction(ctx) if err != nil { return err } data, err := store.Read(ctx, txn, []string{}) if err != nil { return err }
jsonData, err := json.Marshal(data) if err != nil { return err }
log.Info("data: ", string(jsonData)) return store.Commit(ctx, txn)}
func (s *State) evalQuery(ctx context.Context, query ast.Body, input interface{}, store storage.Store) (rego.ResultSet, error) { var tracer *topdown.BufferTracer
rs, err := rego.New( rego.ParsedQuery(query), rego.Input(input), rego.Compiler(s.compiler), rego.Store(store), rego.QueryTracer(tracer), ).Eval(ctx) if err != nil { return nil, err }
if tracer.Enabled() { topdown.PrettyTrace(os.Stderr, *tracer) //nolint: govet // tracer can be nil only if tracer.Enabled() == false }
return rs, nil}
func (s *State) pairsFromResults(rs rego.ResultSet) (engine.Pairs, error) { pairs := make(engine.Pairs, len(rs)) for i, r := range rs { if len(r.Expressions) != 1 { return nil, &UnexpectedResultExpressionError{exps: r.Expressions} } m, ok := r.Expressions[0].Value.(map[string]interface{}) if !ok { return nil, &UnexpectedResultExpressionError{exps: r.Expressions} } res, ok := m["resource"].(string) if !ok { return nil, &UnexpectedResultExpressionError{exps: r.Expressions} } act, ok := m["action"].(string) if !ok { return nil, &UnexpectedResultExpressionError{exps: r.Expressions} } pairs[i] = engine.Pair{Resource: engine.Resource(res), Action: engine.Action(act)} }
return pairs, nil}
func (s *State) pairsFromAllowed(rs rego.ResultSet) (bool, error) { for _, r := range rs { if len(r.Expressions) != 1 { return false, &UnexpectedResultExpressionError{exps: r.Expressions} } m, ok := r.Expressions[0].Value.(map[string]interface{}) if !ok { return false, &UnexpectedResultExpressionError{exps: r.Expressions} } _, ok = m["resource"].(string) if !ok { return false, &UnexpectedResultExpressionError{exps: r.Expressions} } _, ok = m["action"].(string) if !ok { return false, &UnexpectedResultExpressionError{exps: r.Expressions} } return true, nil }
return false, nil}
func (s *State) projectsFromPartialResults(rs rego.ResultSet) (engine.Projects, error) { if len(rs) != 1 { return nil, &UnexpectedResultSetError{set: rs} } r := rs[0] if len(r.Expressions) != 1 { return nil, &UnexpectedResultExpressionError{exps: r.Expressions} } projects, err := s.stringArrayFromResults(r.Expressions) if err != nil { return nil, &UnexpectedResultExpressionError{exps: r.Expressions} } return projects, nil}
func (s *State) stringArrayFromResults(exps []*rego.ExpressionValue) (engine.Projects, error) { rawArray, ok := exps[0].Value.([]interface{}) if !ok { return nil, &UnexpectedResultExpressionError{exps: exps} } vals := make(engine.Projects, len(rawArray)) for i := range rawArray { v, ok := rawArray[i].(string) if !ok { return nil, errors.New("error casting to string") } vals[i] = engine.Project(v) } return vals, nil}
func (s *State) projectsFromPreparedEvalQuery(rs rego.ResultSet) (engine.Projects, error) { projectsFound := make(map[string]bool, len(rs)) result := make(engine.Projects, 0, len(rs)) var ok bool var proj string for i := range rs { proj, ok = rs[i].Bindings["project"].(string) if !ok { return nil, &UnexpectedResultExpressionError{exps: rs[i].Expressions} } if !projectsFound[proj] { result = append(result, engine.Project(proj)) projectsFound[proj] = true } } return result, nil}
func (s *State) allowedFromPreparedEvalQuery(rs rego.ResultSet) (bool, error) { var ok bool for i := range rs { _, ok = rs[i].Bindings["project"].(string) if !ok { return false, &UnexpectedResultExpressionError{exps: rs[i].Expressions} } return true, nil } return false, nil}
复制代码

将 OPA 整合进 Kratos

上面的封装有好几个接口,但是要用到的其实只有一个接口:IsAuthorized,我们将之封装成一个中间件以供 Kratos 调用。


package middleware
import ( "context"
"github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/middleware"
"github.com/tx7do/kratos-authz/engine")
const ( reason string = "FORBIDDEN")
var ( ErrUnauthorized = errors.Forbidden(reason, "unauthorized access") ErrMissingClaims = errors.Forbidden(reason, "missing authz claims") ErrInvalidClaims = errors.Forbidden(reason, "invalid authz claims"))
func Server(authorizer engine.Authorizer, opts ...Option) middleware.Middleware { o := &options{}
for _, opt := range opts { opt(o) }
if authorizer == nil { return nil }
return func(handler middleware.Handler) middleware.Handler { return func(ctx context.Context, req interface{}) (interface{}, error) { var ( allowed bool err error )
claims, ok := engine.AuthClaimsFromContext(ctx) if !ok { return nil, ErrMissingClaims }
if claims.Subject == nil || claims.Action == nil || claims.Resource == nil { return nil, ErrInvalidClaims }
var project engine.Project if claims.Project == nil { project = "" } else { project = *claims.Project }
allowed, err = authorizer.IsAuthorized(ctx, *claims.Subject, *claims.Action, *claims.Resource, project) if err != nil { return nil, err } if !allowed { return nil, ErrUnauthorized }
return handler(ctx, req) } }}
复制代码


中间件的实现很简单,所以不再赘述。


具体的使用方法可以在单元测试里面具体看到,另外我还开源了一个 CMS 也有实际应用。

相关代码

相关代码已经开源,欢迎拉取参考学习:



应用方面的代码,我开源了一个简单的 CMS,完整的应用可在当中找到:


参考资料


发布于: 刚刚阅读数: 5
用户头像

喵个咪

关注

还未添加个人签名 2022-06-01 加入

还未添加个人简介

评论

发布
暂无评论
Kratos微服务框架实现权鉴 - OPA_golang_喵个咪_InfoQ写作社区