Golang 使用 Casbin 进行权限管理

先说需求,设计一个基于角色的权限控制系统,满足以下几个规则:

  • 单独配置某个角色对某个资源的访问权限
  • 一个用户可拥有多个角色
  • 可对角色进行禁用或启用
  • 各个角色之间的权限为并集,且只要有一个角色有权限,该用户就有权限操作
  • 可对一个菜单下的某些重要功能做单独控制,当用户有该菜单的访问权限,没有功能访问权限时,依然能访问该菜单下的非功能接口

Casbin 是一个强大的、高效的开源访问控制框架,其权限管理机制支持多种访问控制模型。

具体支持的模型官网有详细描述,回到本例应该使用的模型是RBAC (基于角色的访问控制)。

Casbin使用配置文件来设置访问控制模式。它有两个配置文件,model.confpolicy.csv。 其中,model.conf存储了访问模型,policy.csv存储了特定的用户权限配置。

模型

Casbin 将访问控制模型抽象为基于PERM(Policy, Effect, Request, Matcher)的一个文件,分别为:

  • 策略
  • 效果
  • 请求
  • 匹配器

基于角色的访问控制需要在此基础上加多一个role_definition进行角色的定义。

请求(request_definition)

该部分用于请求的定义,经典的三元组:访问实体 (Subject),访问资源 (Object) 和访问方法 (Action),也可以根据自己的需求进行增加或删除字段。后端常用的控制请求就是uid, /api/res1, GET

[request_definition]
r = sub, obj, act

策略(policy_definition)

该部分定义控制策略的模板,哪个实体对哪个资源有怎样的权限。注意:这里的sub, obj不一定需要与请求里面的值一致,具体怎样匹配是匹配器定义的。例如这里的一条策略可以是alice, res1, allow

[policy_definition]
p = sub, obj, eft

角色定义(role_definition)

该部分定义了角色系统,用户可以具有角色及其继承关系, 资源也可以具有角色及其继承关系。 这两个 RBAC 系统不会互相干扰。这里我使用了三个角色系统:g是用户和角色的从属关系;g2是资源的从属关系;g3是角色的开关。

[role_definition]
g = _, _
g2 = _, _, _
g3 = _, _

举个例子,g可以是user_1, 1,表示uid是 1 的用户拥有角色 1。g2可以理解为将多个接口组成一个资源组,角色拥有该资源组的权限则有这些接口的访问权限。

/api/order/info/*, resOrder, GET
/api/order/del, resOrder, POST
/api/task/add, resTask, *

可以看到上面有两个资源组,只要在策略配置对资源组的访问权限则可批量控制接口的权限了。 最后一个g3更易理解,2, 1可以表示角色 2 是启用的,反之,2, 0则角色 2 被禁用。

匹配器(matchers)

顾名思义,匹配器就是定义如何匹配规则的。我这里进行了三个部分的检查:

[matchers]
m = g(r.sub, p.sub) && g2(r.obj, p.obj, r.act) && g3(p.sub, "1")

第一部分g(r.sub, p.sub)是检查角色关系的,策略p里定义的实体是角色,请求r里定义是实体是用户,在角色系统g里检查用户角色从属关系。

第二部分g2(r.obj, p.obj, r.act)是检查资源关系的,请求r里的最后两个元素r.objr.act,在这个例子分别代表请求 url 和请求方法,p.obj则是策略里定义的资源组,在角色系统g2里检查资源从属关系。

第三部分g3(p.sub, "1")是检查角色是否启用,在角色系统g3里检查。

效果(policy_effect)

策略效果的定义。它确定如果多项策略规则与请求相符,是否应批准访问请求。例如,一项规则允许,另一项规则则加以拒绝。

现在我们考虑实现最后一个需求,可以把非功能接口都放在同一个资源组中,而每个功能用的接口都独立为一个资源组。例如查看属于非功能,编辑和删除属于功能:

# 非功能性
/api/order/info, order, GET
/api/order/list, order, GET
# 编辑功能
/api/order/edit, editOrder, POST
# 删除功能
/api/order/del, delOrder, POST

这样,当角色有菜单权限时,则赋予order资源权限;当有编辑功能权限时,则赋予editOrder资源权限。此时需要策略效果是,如果有任何匹配的策略规则允许, 最终效果是允许。

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

而且这种策略效果和倒数第二点需求相呼应。

要说不足的地方,可能是每次添加接口,都需要把该接口添加到规则里面。当然你可以用通配符,但非功能性接口可能比较零散,很多接口一开始已经写好了,很难有/api/order/public/*这种匹配所有非功能性。

这里我们也可以换一个思路,先匹配该菜单下的所有接口,再检查功能性接口是否有权限。

# 菜单入口
/api/order/*, order, *
# 编辑功能
/api/order/edit, editOrder, POST
# 删除功能
/api/order/del, delOrder, POST

这时对于编辑功能,它既匹配菜单入口,也匹配编辑功能。当所有匹配的都允许,没有一个拒绝时,最终效果是允许。

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

这意味着至少有一个匹配的策略规则允许,并且没有匹配的否定的策略规则。

但这样做的话倒数第二点需求就无法满足了,当一个角色有权限,另一个无权限,按照这种效果判断是拒绝的。如果继续这种思路,我们需要自定义效果决策。

使用分组的思想,角色内的效果决策我们依然选择上面的决策,但角色之间的效果决策我们却使用第一种效果决策,有点像两种方法的结合。

[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) / some(where (p.eft == allow))

注意这个分隔符/,这是我们自己自定义的,分隔符左边代表角色内的决策,分隔符右边代表角色之间的决策。然后我们需要自己实现这个分组决策器。

package acl

import (
	"github.com/casbin/casbin/v2"
	"github.com/casbin/casbin/v2/effector"
	"log"
	"strings"
)

func Partition(s string, sep string) (string, string, string) {
	parts := strings.SplitN(s, sep, 2)
	if len(parts) == 1 {
		return parts[0], "", ""
	}
	return parts[0], sep, parts[1]
}

type GroupEffector struct {
	defaultEffector effector.DefaultEffector
	enforcer        *casbin.Enforcer
	innerExpr       string // 角色内策略
	interExpr       string // 角色间策略
}

func NewGroupEffector(e *casbin.Enforcer) *GroupEffector {
	obj := &GroupEffector{}
	obj.enforcer = e
	lExpr, sep, rExpr := Partition(e.GetModel()["e"]["e"].Value, "/")
	if sep == "" {
		log.Fatal("invalid effector expression")
		return nil
	}
	obj.innerExpr = strings.TrimSpace(lExpr)
	obj.interExpr = strings.TrimSpace(rExpr)
	return obj
}

func (g *GroupEffector) MergeEffects(expr string, effects []effector.Effect, matches []float64, policyIndex int, policyLength int) (effector.Effect, int, error) {
	if policyIndex < policyLength-1 {
		return effector.Indeterminate, -1, nil
	}
	policy := g.enforcer.GetModel()["p"]["p"].Policy
	// 对策略按角色分组
	groupResult := make(map[string][]int)
	for i := range effects {
		if matches[i] == 0 {
			continue
		}
		group := policy[i][0]
		if idx, ok := groupResult[group]; ok {
			groupResult[group] = append(idx, i)
		} else {
			groupResult[group] = []int{i}
		}
	}
	interEffects := make([]effector.Effect, 0, len(groupResult))
	interMatches := make([]float64, 0, len(groupResult))
	// 组内策略决策
	for _, idx := range groupResult {
		groupEffects := make([]effector.Effect, len(idx))
		groupMatches := make([]float64, len(idx))
		for i, j := range idx {
			groupEffects[i] = effects[j]
			groupMatches[i] = matches[j]
		}
		r, e, err := g.defaultEffector.MergeEffects(g.innerExpr, groupEffects, groupMatches, len(groupEffects)-1, len(groupEffects))
		if err != nil {
			return r, e, err
		}
		interEffects = append(interEffects, r)
		if r != effector.Indeterminate {
			interMatches = append(interMatches, 1)
		} else {
			interMatches = append(interMatches, 0)
		}
	}
	// 组间策略决策
	r, _, err := g.defaultEffector.MergeEffects(g.interExpr, interEffects, interMatches, len(interEffects)-1, len(interEffects))
	return r, -1, err
}

策略

简单来说,上面的模型像是定义了一种模板,这里的策略就像具体的数据了。这些策略可以存储在文件中,比如csv文件,也可以存储在数据库表中。对于后端开发最常用的就是数据库了。

表的结构类似这样:

idptypev0v1v2v3v4v5
1p1orderallow
2p1editOrderdeny
3guser_11
4g2/api/order/*order*
5g2/api/order/editeditOrderPOST
6g311

把模型的数据都写到这张表中,Casbin有接口可以方便地操作这个策略表。

如何使用

具体流程比较简单,在程序启动时设定模型的定义信息,初始化策略存储的适配器,利用 Casbin 的接口创建Enforcer,后续就可以通过Enforcer进行权限管理了。

  1. 创建策略存储适配器

在 Casbin 中,策略存储作为适配器实现,文档里有目前支持的适配器列表。本例使用的是 Gorm Adapter。

if adapter, err = gormadapter.NewAdapterByDB(yourDB); err != nil {
	log.Fatal(err)
	return
}
  1. 创建模型

将上面定义的模型写到字符串中,使用 Casbin 的接口创建模型。

const rbac = `[request_definition]
...
`

if acModel, err = model.NewModelFromString(rbac); err != nil {
	log.Fatal(err)
	return
}
  1. 创建执行器

后面就可以通过调用这个执行器的方法操作策略了,比如添加/删除/更新角色和策略规则之类的。

if Enforcer, err = casbin.NewEnforcer(); err != nil {
	log.Fatal(err)
	return
}

if err = Enforcer.InitWithModelAndAdapter(acModel, adapter); err != nil {
	log.Fatal(err)
	return
}
  1. 初始化设置

对执行器执行一些初始化设置,比如加载策略,设定自定义的效果器等。

Enforcer.AddNamedMatchingFunc("g2", "KeyMatch2", util.KeyMatch2)
Enforcer.AddNamedDomainMatchingFunc("g2", "KeyMatch2", util.KeyMatch2)

if err = Enforcer.BuildRoleLinks(); err != nil {
	log.Fatal(err)
	return
}

if err = Enforcer.LoadPolicy(); err != nil {
	log.Fatal(err)
	return
}

Enforcer.SetEffector(NewGroupEffector(Enforcer))