Casbin 多租户模型(RABC-dom)正确打开方式

本文使用多租户模型即 RABC-dom 做了一个项目,旨在为你提供好的多租户模型打开方式,抛砖引玉,共同进步。

这里只是介绍多租户模型(casbin 官网叫域内RABC) 的使用方式,对于不了解casbin 的同志们可以先去官网了解一下,之后我会出几个详解casbin 的文章,最近实在是抽不出时间。写这个文章是因为没看到有相关的使用多租户模型的案例,自己做了一个自测有效的正确的打开方式,欢迎大家来讨论。

为什要使用Casbin

不知道大家的项目权限管理都是如何做的,反正我们公司是你写你的我写我的全都耦合在各自的代码里,你一个写法我一个写法,各种表之间的JOIN,看着累,写着累,最重要的是严重影响代码的性能。

我去网上查了一下这个 Casbin 这个权限管理工具,发现很不错,他的思路就是根据你的权限逻辑进行一个规定,再把我们要去限定权限的所有资源都拿出来根据你做的规定排列组合形成一个资源权限组, 在你进行资源访问时,根据你的资源权限组进行匹配,匹配规则也是可以定义的,匹配成功就为权限通过。

可以说是摘出了所有代码中的权限,让权限不再成为程序编写者的工作,不用再呕心沥血的梳理这个方法的权限,也同时减少了代码的耦合,是不是很羡慕很多应用的自定义角色权限之类的功能,没错就是因为将权限和代码解耦了,权限只专注于权限模块。想象一下当你应用有了上百个方法,这个时候上面突然说我要加个角色,这个角色可以干嘛干嘛,吐不吐血。casbin 的应用好处太多了。

公司的应用需要项目角色这个两个维度来限制权限这其实是RABC-dom 的权限限制方式,网上找了半天也没找到实战案例,就自己写了一个,主要参考这个开源项目,这个项目的代码架构太美了,真的美如画,层级和模块的处理以及编程逻辑都是按照最佳实践来的。不吹了,我这个多租户的项目的层级和模块划分也是按照他的架构来的,真的美如画。

不多说了,我的项目代码在这里:代码

项目介绍

这个项目主要是实践 Casbin 的域内 RABC 权限模型,说白了就是角色-项目-资源 这三者的权限控制抽离。所以项目只设计了用户,项目,资源三个模块,为了方便登陆注册也被我简化了。

代码讲解

代码架构

还是想先讲一下架构,层级可以看到数据层 model,传输层 schema,业务层 bll,api 层 api,当然后有路由层 router 。

每个层级都把模块的结构题抽象出来,利用层级细分和模块对象抽象把代码接耦。同时防止了模块间循环导包问题,循环导包这个问题真的吐了,你写个模块依赖一大堆模块,我想用你的模块方法?没门。你们是不是也遇到过,所以遇到一堆模块独立分包的代码架构那么恭喜你。

层级的依赖关系也需要清晰,api 依赖 bll 是最上层,bll 依赖 model 是次上层,model 为最底层 ,schema 呢? 各个模块的出参入参统一使用这个模块,这个模块可以说是各个模块的连接器,让api只依赖bll, bll只依赖model,高度解耦。整个架构清晰爽快,简明规范,让使用者不踩坑,后来易上手。有点激动,跑题了接下来说Casbin。

image-20210828131906049

路由层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

func (a *Router) RegisterAPI(app *gin.Engine) {
g := app.Group("/api")

g.Use(middleware.CasbinMiddleware(a.CasbinEnforcer,
middleware.AllowPathPrefixSkipper("/api/v1/pub"),
))

v1 := g.Group("/v1")
{
pub := v1.Group("/pub")
{
gLogin := pub.Group("login")
{
gLogin.POST("", a.LoginAPI.Login)
gLogin.POST("exit", a.LoginAPI.Logout)
}
}

gUser := v1.Group("user")
{
gUser.GET(":id", a.UserAPI.Get)
gUser.POST("", a.UserAPI.Create)
gUser.PUT(":id", a.UserAPI.Update)
gUser.DELETE(":id", a.UserAPI.Delete)
}

gProject := v1.Group("project")
{
gProject.GET(":id", a.ProjectAPI.Get)
gProject.POST("", a.ProjectAPI.Create)
gProject.PUT(":id", a.ProjectAPI.Update)
gProject.DELETE(":id", a.ProjectAPI.Delete)
}
}
}

可以看到有用户和项目的增删改查没什么可说的有个整体概念吧,我们的预期是A用户属于1项目,B用户属于2 项目,C用户属于1,2项目,那么A 访问1可以2报权限不足,C用户访问2可以1项目1报权限不足,C可以访问两个项目。

这只是打个样,如果有其他的模块资源也一样的比如 m 资源属于1项目,此时B用户无法访问 m,A,C用户可以访问。

接下来看如何实现的

Casbin 解析

casbin 在多个层进行了代码实现,我将通过配置,初始化,适配器,异步更新几个方面进行讲解

Casbin 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[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) \
&& keyMatch2(r.obj, p.obj) == true \
&& regexMatch(r.act, p.act) == true \
|| r.sub == "admin"

路径为/config/model.conf 这就是我们所谓的规则,这里不想解释了以后再出详细的教程。其实就是按照这个规则进行资源重组,通过对请求参数和资源重组参数按照你的匹配规则进行匹配,匹配上了就是有权限。

资源配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 资源置初始化(服务启动时会进行数据检查,如果存在则不再初始化)
- module_code: "user"
module_name: 用户管理
action_path: "/api/v1/user"
action_method: POST
action_code: add
action_name: 新增
role: "管理员,操作员Leader"

- module_code: "user"
module_name: 用户管理
action_path: "/api/v1/user/:id"
action_method: PUT
action_code: edit
action_name: 编辑
role: "管理员,操作员Leader"

- module_code: "user"
module_name: 用户管理
action_path: "/api/v1/user/:id"
action_method: PUT
action_code: delete
action_name: 删除
role: "管理员,操作员Leader"

路径为/config/policy_source.yaml ,这里起其实就是把你的资源,资源所属模块,资源的可用角色定义为一个yaml,在启动的时候进行一个casbin 的加载。如果你有新的模块方法也要把相应的资源写在配置文件里。

Casbin 中间键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// CasbinMiddleware casbin中间件
func CasbinMiddleware(enforcer *casbin.SyncedEnforcer, skippers ...SkipperFunc) gin.HandlerFunc {

return func(c *gin.Context) {
if !config.C.Casbin.Enable {
c.Next()
return
}

if SkipHandler(c, skippers...) {
c.Next()
return
}

pIDs := strings.Split(c.GetHeader("project_ids"), ",")
p := c.Request.URL.Path
m := c.Request.Method

var userID string
var ok bool
if userID, ok = schema.LoginUser["login"]; !ok {
gin_util.ResError(c, errors.ErrNoLogin)
return
}


if len(pIDs) == 0 {
pIDs = []string{
"null",
}
}

var pass bool
for _, pID := range pIDs {
// TODO: 传入项目ID,获取用户ID
if b, err := enforcer.Enforce(userID, pID, p, m); err != nil {
gin_util.ResError(c, errors.WithStack(err))
return
} else if b {
pass = true
break
}
}

if !pass {
gin_util.ResError(c, errors.ErrNoPerm)
return
}

c.Next()
}
}

路径在 /middleware/casbin.go 权限中间键其实很简单,拿到你请求的用户ID,资源所在项目ID,资源的方法和路径,进行casbin 权限查询,多个项目就查询多次。全部通过说明该用户有访问项目的权限。

Casbin 适配器

这其实是Casbin 的灵魂,就是把你的资源按照你的规则定义加入Casbin资源权限组,在之后的权限匹配中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
var _ persist.Adapter = (*CasbinAdapter)(nil)

// CasbinAdapter casbin适配器
type CasbinAdapter struct {
UserModel *model.User
ProjectModel *model.Project
ProjectUserModel *model.ProjectUser
PolicySourceModel *model.PolicySource
}

// LoadPolicy loads all policy rules from the storage.
func (a *CasbinAdapter) LoadPolicy(model casbinModel.Model) error {
ctx := context.Background()
err := a.loadRolePolicy(ctx, model)
if err != nil {
logger.WithContext(ctx).Errorf("Load casbin role policy error: %s", err.Error())
return err
}

err = a.loadUserPolicy(ctx, model)
if err != nil {
logger.WithContext(ctx).Errorf("Load casbin user policy error: %s", err.Error())
return err
}

return nil
}

// 加载角色策略(p,role_id,path,method)
func (a *CasbinAdapter) loadRolePolicy(ctx context.Context, m casbinModel.Model) error {
// 获取所有的项目ID
projectResults, err := a.ProjectModel.Query(ctx, schema.ProjectQueryParam{}, schema.ProjectQueryOptions{})
if err != nil {
return err
}
pjIDs := projectResults.Data.ToIDs()

for _, rl := range schema.RoleListData {
// 获取所有的请求资源
policySources, err := a.PolicySourceModel.Query(ctx, schema.PolicySourceQueryParam{RoleCode: string(rl)})
if err != nil {
return err
}

mcache := make(map[string]struct{})
for _, ps := range policySources.Data {
for _, pjID := range pjIDs {
if ps.ActionPath == "" || ps.ActionMethod == "" {
continue
} else if _, ok := mcache[pjID+ps.ActionPath+ps.ActionMethod]; ok {
continue
}

mcache[pjID+ps.ActionPath+ps.ActionMethod] = struct{}{}
line := fmt.Sprintf("p,%s,%s,%s,%s", rl.ToCode(), pjID, ps.ActionPath, ps.ActionMethod)
persist.LoadPolicyLine(line, m)
}
}
}

return nil
}

// 加载用户策略(g,user_id,role_id)
func (a *CasbinAdapter) loadUserPolicy(ctx context.Context, m casbinModel.Model) error {
// 获取所有的用户信息
userResult, err := a.UserModel.Query(ctx, schema.UserQueryParam{})
if err != nil {
return err
} else if len(userResult.Data) > 0 {
for _, uitem := range userResult.Data {

var pIDs []string
if uitem.Role == schema.RoleAdmin.ToCode() {
projectResults, err := a.ProjectModel.Query(ctx, schema.ProjectQueryParam{}, schema.ProjectQueryOptions{})
if err != nil {
return err
}
pIDs = projectResults.Data.ToIDs()
} else {
var projectUsers schema.ProjectUsers
projectUsers, err = a.ProjectUserModel.GetProjectUserByUser(ctx, uitem.ID)
if err != nil {
return err
}
pIDs = projectUsers.ToProjectIDs()
}

for _, pID := range pIDs {
line := fmt.Sprintf("g,%s,%s,%s", uitem.ID, uitem.Role, pID)
persist.LoadPolicyLine(line, m)

}
}
}

return nil
}

// SavePolicy saves all policy rules to the storage.
func (a *CasbinAdapter) SavePolicy(model casbinModel.Model) error {
return nil
}

// AddPolicy adds a policy rule to the storage.
// This is part of the Auto-Save feature.
func (a *CasbinAdapter) AddPolicy(sec string, ptype string, rule []string) error {
return nil
}

// RemovePolicy removes a policy rule from the storage.
// This is part of the Auto-Save feature.
func (a *CasbinAdapter) RemovePolicy(sec string, ptype string, rule []string) error {
return nil
}

// RemoveFilteredPolicy removes policy rules that match the filter from the storage.
// This is part of the Auto-Save feature.
func (a *CasbinAdapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error {
return nil
}

路径在 /casbin/adapter.go, 这里面有些调试代码请忽略。这个适配器需要实现 casbin 定义好的适配器接口,是根据你的程序自己的资源,用户角色,项目的定义逻辑结合你的 casbin 配置文件来实现的,实现了适配器接口就可以愉快的使用Casbin 了。

Casbin 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

// InitCasbin 初始化casbin
func InitCasbin(adapter persist.Adapter) (*casbin.SyncedEnforcer, func(), error) {
cfg := config.C.Casbin
if cfg.Model == "" {
return new(casbin.SyncedEnforcer), nil, nil
}

e, err := casbin.NewSyncedEnforcer(cfg.Model)
if err != nil {
return nil, nil, err
}
e.EnableLog(cfg.Debug)

err = e.InitWithModelAndAdapter(e.GetModel(), adapter)
if err != nil {
return nil, nil, err
}
e.EnableEnforce(cfg.Enable)

cleanFunc := func() {}
if cfg.AutoLoad {
e.StartAutoLoadPolicy(time.Duration(cfg.AutoLoadInternal) * time.Second)
cleanFunc = func() {
e.StopAutoLoadPolicy()
}
}

return e, cleanFunc, nil
}

路径在 /casbin/casbin.go 初始化起始就是加载你的资源配置文件,通过你的配置文件把初始casbin 权限组通过你定义的适配器加载到casbin 中供之后的中间键配置权限。

Casbin 动态加载

如果我中间添加了一个用户,或是一个项目或是多个项目多个用户呢?此时需要动态加载Casbin ,这里设计了一个异步的加载,考虑到多副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

var chCasbinPolicy chan *chCasbinPolicyItem

type chCasbinPolicyItem struct {
ctx context.Context
e *casbin.SyncedEnforcer
}

func init() {
chCasbinPolicy = make(chan *chCasbinPolicyItem, 1)
go func() {
for item := range chCasbinPolicy {
err := item.e.LoadPolicy()
if err != nil {
logger.WithContext(item.ctx).Errorf("The load casbin policy error: %s", err.Error())
}
}
}()
}

// LoadCasbinPolicy 异步加载casbin权限策略
func LoadCasbinPolicy(ctx context.Context, e *casbin.SyncedEnforcer) {
if len(chCasbinPolicy) > 0 {
logger.WithContext(ctx).Infof("The load casbin policy is already in the wait queue")
return
}

chCasbinPolicy <- &chCasbinPolicyItem{
ctx: ctx,
e: e,
}
}

路径在 /bll/bll_casbin.go 这里有点问题,这个消费者其实是个野程,没有优化,应该有一个结束消费者的方法放在 cleanfunc 里,消费者接受到消息立刻return,一旦程序退出这个消费者自动被干掉,这在我之前的并发编程的文章中说到过之后会给他优化掉。

这个loadcasbinPolicy 会在添加用户,添加项目,更新项目中的用户时调用保证casbin 实效性。

这样如果你有一点casbin的基础你一定就掌握了多租户也叫域内RABC的打开方式。