在木犀的 PaaS 云平台的设计中,需要有一个细粒度比较小的权限控制系统。不同用户对不同的资源,拥有不同的权限。土办法已经不管用了,我们需要更系统,更规范的权限控制系统。本文讲的就是如何将权限控制库 Casbin 接入 Iris Web 框架。

Iris 中间件机制简介

Iris 这个框架是基于中间件的,和 Nodejs 的 Koa 和 Express 很像。所谓中间件机制,就是一个请求到达之后,会生成一个上下文信息,里面包含了这个请求的一些信息。然后我们依次调用中间件函数,把上下文对象作为参数传入。需要注意是中间件函数的调用是嵌套的,在中间件函数中我们可以调用 ctx.Next() 方法,进入下一个中间件函数。当最后一个中间件函数返回时,之前调用过的中间件会依次返回。这个数据流被形象的称为“洋葱模型”。

一个典型的中间件是这样的:

func middleware(ctx iris.Context) {
   // get info from context
	requestPath := ctx.Path()

   // set info with context
	ctx.Values().Set("info", shareInformation)
	
	// call next middleware
	ctx.Next()
}

我们可以在中间件中读取 ctx 结构,根据上面附带的信息,我们可以做一些针对性的事情。

一个常用的中间件场景就是访问控制。我们可以根据 ctx 上带的用户信息,来查看用户的权限,如果用户没有要访问的资源的权限,我们就拒绝这次访问。比如这样:

func auth(ctx iris.Context) {
   // check if user has permission
   if !c.Check(ctx.Request()) {
		ctx.StatusCode(http.StatusForbidden) // Status Forbiden
		ctx.StopExecution() 
		return
	}
	ctx.Next()
}

在本文的权限控制的场景下,中间件的作用就是在请求验证失败时,提前返回 403 状态码。

Casbin 简介

Casbin 是由北大的一位博士生主导开发的一个基于 Go 语言的权限控制库。支持 ACL,RBAC,ABAC 等常用的访问控制模型。

Casbin 的核心是一套基于 PERM metamodel (Policy, Effect, Request, Matchers) 的 DSL。Casbin 从用这种 DSL 定义的配置文件中读取访问控制模型,作为后续权限验证的基础。

一个典型的配置文件如下:

# Request definition
[request_definition]
r = sub, obj, act

# Policy definition
[policy_definition]
p = sub, obj, act

# Policy effect
[policy_effect]
e = some(where (p.eft == allow))

# Matchers
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act

可以看到这个配置文件主要定义了 Request 和 Policy 的组成结构。Policy effect 和 Matchers 则灵活的多,可以包含一些自定义的表达式。

比如我们要加入一个名叫 root 的超级管理员,就可以这样写:

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act || r.sub == "root"

又比如我们可以用正则匹配来判断权限是否 match:

[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)

Casbin 文档中有一节展示了 Casbin 所支持的权限控制模型的示例配置。我们可以根据那些例子来打造我们自己的权限控制模型。

有了权限控制模型,我们还需要权限规则。权限规则是若干行数据的集合。每行数据就是一条规则。权限规则的具体格式因权限模型中的定义而异,最简单的 ACL 模型的规则是这样的:

p, alice, data1, read
p, bob, data2, write

意思就是 alice 可以读 data1,bob 可以写 data2。

为 Casbin 适配 Iris 中间件

既然实现权限控制的最佳位置是中间件,我们就需要为 Casbin 写一个 Iris 中间件。社区里的 Casbin-iris 插件就是一个不错的例子,我们可以以这个插件为基础进行开发。

首先我们来看看这个中间件是如何使用的,这个插件有 Warpper 和 Middleware 两种用法。差别在于 Warpper 方法会在所有路由被调用。而 Middleware 让我们可以控制哪些路由启用权限控制。我们选择 Middleware 方式做示例。打开 casbin/_examples/middleware/main.go,其中核心的几行代码是这样的:

var Enforcer = casbin.NewEnforcer("casbinmodel.conf", "casbinpolicy.csv")

func newApp() *iris.Application {
	casbinMiddleware := casbinMiddleware.New(Enforcer)

	app := iris.New()
	app.Use(casbinMiddleware.ServeHTTP)

	app.Get("/", hi)

	app.Get("/dataset1/{p:path}", hi) // p, alice, /dataset1/*, GET

	app.Post("/dataset1/resource1", hi)

	app.Get("/dataset2/resource2", hi)
	app.Post("/dataset2/folder1/{p:path}", hi)

	app.Any("/dataset2/resource1", hi)

	return app
}

首先通过 NewEnforcer 方法初始化一个 Casbin Enforcer。NewEnforcer 方法接收两个参数,一个是访问控制模型文件的路径,一个是权限规则文件的路径。

然后调用 casbinMiddleware.New 方法,传入 Casbin Enforcer,进行一些初始化工作。最后调用 app.Use(casbinMiddleware.ServeHTTP),应用中间件。

看起来还挺简单的。但这里存在一个问题,这个中间件是如何拿到鉴权需要的用户信息的呢?这个过程对于开发者是不透明的。我们查看源码,发现里面有这样的代码:

// Username gets the username from the basicauth.
func Username(r *http.Request) string {
	username, _, _ := r.BasicAuth()
	return username
}

原来这个中间件假设请求通过 HTTP Basic Auth 方式进行认证。然后从请求的 headers 中获取认证信息。

但在实际生产中,我们认证用户身份的方式有很多种,最常见的就是通过 session 得知用户的身份,或者通过 token 这样的凭证来确定用户的身份。这个中间件如果要使用到生产中去,需要进行一些改动。

以下就是修改过的中间件,为了测试,我在 Username 函数中直接返回了用户名,后续使用时可以在这个函数里进行用户身份的获取。

package casbin

import (
	"net/http"

	"github.com/kataras/iris/context"

	"github.com/casbin/casbin"
)

func New(e *casbin.Enforcer) *Casbin {
	return &Casbin{enforcer: e}
}

func (c *Casbin) Wrapper() func(w http.ResponseWriter, r *http.Request, router http.HandlerFunc) {
	return func(w http.ResponseWriter, r *http.Request, router http.HandlerFunc) {
		if !c.Check(r) {
			w.WriteHeader(http.StatusForbidden)
			w.Write([]byte("403 Forbidden"))
			return
		}
		router(w, r)
	}
}

func (c *Casbin) ServeHTTP(ctx context.Context) {
	if !c.Check(ctx.Request()) {
		ctx.StatusCode(http.StatusForbidden) // Status Forbiden
		ctx.StopExecution()
		return
	}
	ctx.Next()
}

type Casbin struct {
	enforcer *casbin.Enforcer
}

// Check checks the username, request's method and path and
// returns true if permission grandted otherwise false.
func (c *Casbin) Check(r *http.Request) bool {
	username := Username(r)
	method := r.Method
	path := r.URL.Path
	return c.enforcer.Enforce(username, path, method)
}

// Username gets the username from the basicauth.
func Username(r *http.Request) string {
   // TODO: get username form db using userId in session or token
	return "alice"
}

大家可以用这里的代码、模型规则文件进行测试(将中间件替换为上面的版本)。如果我们 GET /dataset2/resource2 这个路径,就会返回 403。这说明中间件正常工作了。因为 alice 是没有 /dataset2/resource2 这个资源的 GET 权限的。

选择合适的访问控制模型

我选择了 RBAC with domains/tenants 这个模型作为木犀云平台的访问控制模型。PaaS 平台中有服务、应用等多种资源,所以需要按领域模型区分。用户中可以存在超级管理员等角色,所以需要角色。需要注意的是 Casbin 的 RBAC 中的角色其实是一种分组。比如我们可以定义一个叫 admin 的用户,这个用户对所有的资源都有权限规则存在,然后我们可以把其他用户和这个用户分为一组,那这些用户也都有了 admin 用户的权限。

[request_definition]
r = sub, dom, obj, act

[policy_definition]
p = sub, dom, obj, act

[role_definition]
g = _, _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act

这个模型定义中的 g 就是指 group。

示例规则如下:

p, admin, domain1, data1, read
p, admin, domain1, data1, write
p, admin, domain2, data2, read
p, admin, domain2, data2, write
g, alice, admin, domain1
g, bob, admin, domain2

如上所示,alice 和 bob 分别是 domian1 和 domain2 的管理员。

Casbin Policy 持久化

Casbin 的 policy 可以保存在一个 csv 文件中。也可以被持久化到数据库中。

所谓的“持久化到数据库中”的意思,就是在数据库中创建一个表,把行数据都存放到数据库中。

对于一个 Web 应用,我们想要的当然是后者。所以我们还需要将 Casbin 和数据库连接起来。

Casbin 支持 gorm 和 xorm 等等常见的 orm。我们以 gorm 为例,Canbin 的 gorm 适配库是 gorm-adapter

接入 gorm 并不是很复杂的事情,其实就是把 NewEnforcer 中的第二个 policy file 参数换成 gorm-adapter 的一个实例就可以。

// 自动创建一个数据库,叫 casbin
// 如果需要制定数据库名,可以这样 a := gormadapter.NewAdapter("mysql", "mysql_username:mysql_password@tcp(127.0.0.1:3306)/abc", true)
var a = gormadapter.NewAdapter("mysql", "root:muxi@tcp(127.0.0.1:3306)/") 

var Enforcer = casbin.NewEnforcer("casbinmodel.conf", a)

然后我们可以调用 API 对规则进行添加和删除等等操作:

Enforcer.LoadPolicy()
Enforcer.AddPolicy("admin", "app", "/app/1", "GET")
Enforcer.AddGroupingPolicy("alice", "admin", "app")

这里踩了一个小坑,这个 gorm-adapter 的 README 里没有写 AddGroupingPolicy 这个 API。还是翻源码才看到的。

规则文件中的 p 对应 AddPolicy API,g 对应 AddGroupingPolicy API。

Warp it up

TODO: 把文中的示例代码整理到一个仓库中

一点感受:Casbin 的文档主要是 README 中的内容。Iris 的文档则主要要看 Example 这一节。有点 Example Driven Development 的感觉。虽然这么说,不过整体来说,这些资料还是可以覆盖到我们的使用场景的。