go 项目中基于 casbin 的 API 接口权限管理

温馨提示:仅为个人笔记以免遗忘,不保证代码完整

一般在 web 项目中的权限管理基本是这几种

  1. 前端菜单的展示
  2. 后端 API 接口的请求权限
  3. 前端 按钮的操作权限
  4. 部分数据的访问权限

根据这个需求,我们可以抽象出,用户、角色、菜单(模块、菜单、接口)、某某资源等几个实体
但是如果系统分为多个子系统,就需要引入域的概念,比如后台用户和网站用户就是不同的域

在 Casbin 中的 RBAC 角色可以是全局或是基于特定于域的。 特定域的角色意味着当用户处于不同的域/租户群体时,用户所表现的角色也不尽相同。如后台用户和网站用户就是不同的域。

Casbin是一个强大的、高效的开源访问控制框架,可以方便的实现对于后端 API 接口的权限控制,
使用 casbin 需要两个文件,模型文件 model.conf 和策略文件 polic.csv

model.conf

# 请求的规则 
# r 是规则的名称,sub 为请求的实体,dom 所在域,obj 为资源的名称, act 为请求的实际操作动作
[request_definition]
r = sub, dom, obj, act

# 策略的规则, 同请求
[policy_definition]
p = sub, dom, obj, act

# 角色的定义
# g 角色的名称,第一个位置为用户,第二个位置为角色,第三个位置为域(在多租户场景下使用)
[role_definition]
g = _, _, _

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

[matchers]
 m = (g(r.sub, p.sub, r.dom) || p.sub == "*") && r.dom == p.dom && (keyMatch2(r.obj, p.obj) || keyMatch(r.obj, p.obj)) && (r.act == p.act || p.act == "*")

polic.csv

对于 API 接口的访问,无非是 http 的几个 GET、PUT、DELETE 等等访问方式,使用 http 方法作为 act(动作)
API 请求接口地址,可以作为 casbin 中的 obj(资源)
这样在用户访问的时候,我们只需要把提前把特定的 obj(资源) 和 act(动作) 分配给角色,就可以实现对每一个 API 接口的访问控制

p, *, admin, /admin/current_user/*, *
p, *, admin, /admin/upload, *
p, *, admin, /admin/sys_dict_data/type/*, GET
p, *, admin, /admin/sys_depts/treelist, GET
p, *, admin, /admin/attachments/*, *
p, *, admin, /admin/tracking_records, GET
p, *, admin, /admin/projects/attrs_data, GET

初始化 casbin 需要加载这两个文件,除了加载的策略文件,我们还需要载入数据库的策略
我的做法是初始加载的 polic.csv 里的规则作为用户通用的可访问的 API 接口,而数据库中的策略是可以在后台根据角色修改的策略,这样就可以很好的避免管理非必要的 API 接口权限

我使用的是 Postgres 数据库 以及 gorm,可以使用官网上已经有推荐的 https://github.com/casbin/gorm-adapter
但是,因为我们项目中有 handler,service,repository,mode 的分层,以及对表名进行了自定义,只能自己动手撸了

gcasbin 的初始化

package gcasbin

import (
	"sync"

	casbin "github.com/casbin/casbin/v2"
	"github.com/pkg/errors"
	"github.com/spf13/viper"
)

var (
	fileEnforcer *casbin.Enforcer // 从文件加载的策略
	dbEnforcer   *casbin.Enforcer // 数据库的权限策略
	once         sync.Once
)

type Config struct {
	Model  string
	Policy string
}

// GetEnforcerDb 读取数据库策略
func GetEnforcerDb() *casbin.Enforcer {
	return dbEnforcer
}

// GetEnforcerFile 读取从文件策略
func GetEnforcerFile() *casbin.Enforcer {
	return fileEnforcer
}

func NewConfig(v *viper.Viper) (*Config, error) {
	var (
		err error
		o   = new(Config)
	)
	if err = v.UnmarshalKey("casbin", o); err != nil {
		return nil, err
	}

	return o, err
}

func NewEnforcer(v *viper.Viper) (*casbin.Enforcer, error) {

	// 获取配置
	var err error
	o, err := NewConfig(v)
	if err != nil {
		return nil, errors.Wrap(err, "casbin 初始化读取配置出错")
	}

	once.Do(func() {
		dbEnforcer, err = casbin.NewEnforcer(o.Model, o.Policy)
		if err == nil {
			fileEnforcer, err = casbin.NewEnforcer(o.Model, o.Policy)
		}
	})

	if err != nil {
		return nil, errors.Wrap(err, "casbin 初始化出错")
	}
	return dbEnforcer, nil
}

adapter 代码:

package casbinAdapter

import (
	"errors"
	"goapp/internal/pmhuang/models"
	"goapp/internal/pmhuang/repositories"

	"github.com/casbin/casbin/v2/model"
	"github.com/casbin/casbin/v2/persist"
)

// Adapter is the interface for Casbin adapters.
type Adapter interface {
	// LoadPolicy loads all policy rules from the storage.
	LoadPolicy(model model.Model) error
	// SavePolicy saves all policy rules to the storage.
	SavePolicy(model model.Model) error

	// AddPolicy adds a policy rule to the storage.
	// This is part of the Auto-Save feature.
	AddPolicy(sec string, ptype string, rule []string) error
	// RemovePolicy removes a policy rule from the storage.
	// This is part of the Auto-Save feature.
	RemovePolicy(sec string, ptype string, rule []string) error
	// RemoveFilteredPolicy removes policy rules that match the filter from the storage.
	// This is part of the Auto-Save feature.
	RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error
}

type CasbinAdapter struct {
	repo repositories.ISysCasbinRuleRepo
}

//初始化adapter操作
func NewCasbinAdapter(repo repositories.ISysCasbinRuleRepo) (a *CasbinAdapter) {
	a = new(CasbinAdapter)
	a.repo = repo
	return
}

// getTableInstance return the dynamic table name
func getTableInstance() *models.SysCasbinRule {
	return &models.SysCasbinRule{}
}

func (a *CasbinAdapter) createTable() (err error) {
	return
}

func (a *CasbinAdapter) dropTable() (err error) {
	return
}

func loadPolicyLine(line *models.SysCasbinRule, model model.Model) {
	var p = []string{line.Ptype,
		line.V0, line.V1, line.V2,
		line.V3, line.V4, line.V5}

	index := len(p) - 1
	for p[index] == "" {
		index--
	}
	index += 1
	p = p[:index]

	persist.LoadPolicyArray(p, model)
}

// LoadPolicy loads policy from database.
func (a *CasbinAdapter) LoadPolicy(model model.Model) error {
	lines, err := a.repo.FetchList()
	if err != nil {
		return err
	}
	for _, line := range lines {
		loadPolicyLine(&line, model)
	}
	return nil
}

func savePolicyLine(ptype string, rule []string) models.SysCasbinRule {
	line := getTableInstance()
	line.Ptype = ptype
	if len(rule) > 0 {
		line.V0 = rule[0]
	}
	if len(rule) > 1 {
		line.V1 = rule[1]
	}
	if len(rule) > 2 {
		line.V2 = rule[2]
	}
	if len(rule) > 3 {
		line.V3 = rule[3]
	}
	if len(rule) > 4 {
		line.V4 = rule[4]
	}
	if len(rule) > 5 {
		line.V5 = rule[5]
	}
	return *line
}

// SavePolicy saves policy to database.
func (a *CasbinAdapter) SavePolicy(model model.Model) error {
	err := a.dropTable()
	if err != nil {
		return err
	}
	err = a.createTable()
	if err != nil {
		return err
	}

	var lines []models.SysCasbinRule
	flushEvery := 1000
	for ptype, ast := range model["p"] {
		for _, rule := range ast.Policy {
			lines = append(lines, savePolicyLine(ptype, rule))
			if len(lines) > flushEvery {
				if err := a.repo.Create(lines); err != nil {
					return err
				}
				lines = nil
			}
		}
	}

	for ptype, ast := range model["g"] {
		for _, rule := range ast.Policy {
			lines = append(lines, savePolicyLine(ptype, rule))
			if len(lines) > flushEvery {
				if err := a.repo.Create(lines); err != nil {
					return err
				}
				lines = nil
			}
		}
	}
	if err := a.repo.Create(lines); err != nil {
		return err
	}

	return nil
}

// AddPolicy adds a policy rule to the storage.
func (a *CasbinAdapter) AddPolicy(sec string, ptype string, rule []string) error {
	line := savePolicyLine(ptype, rule)
	err := a.repo.Create([]models.SysCasbinRule{line})
	if err != nil {
		return err
	}
	return nil
}

// RemovePolicy removes a policy rule from the storage.
func (a *CasbinAdapter) RemovePolicy(sec string, ptype string, rule []string) error {
	line := savePolicyLine(ptype, rule)
	return a.repo.RawDelete(line)
}

// RemoveFilteredPolicy removes policy rules that match the filter from the storage.
func (a *CasbinAdapter) RemoveFilteredPolicy(sec string,
	ptype string,
	fieldIndex int,
	fieldValues ...string) error {
	line := getTableInstance()

	line.Ptype = ptype

	if fieldIndex == -1 {
		return a.repo.RawDelete(*line)
	}

	err := checkQueryField(fieldValues)
	if err != nil {
		return err
	}

	if fieldIndex <= 0 && 0 < fieldIndex+len(fieldValues) {
		line.V0 = fieldValues[0-fieldIndex]
	}
	if fieldIndex <= 1 && 1 < fieldIndex+len(fieldValues) {
		line.V1 = fieldValues[1-fieldIndex]
	}
	if fieldIndex <= 2 && 2 < fieldIndex+len(fieldValues) {
		line.V2 = fieldValues[2-fieldIndex]
	}
	if fieldIndex <= 3 && 3 < fieldIndex+len(fieldValues) {
		line.V3 = fieldValues[3-fieldIndex]
	}
	if fieldIndex <= 4 && 4 < fieldIndex+len(fieldValues) {
		line.V4 = fieldValues[4-fieldIndex]
	}
	if fieldIndex <= 5 && 5 < fieldIndex+len(fieldValues) {
		line.V5 = fieldValues[5-fieldIndex]
	}
	err = a.repo.RawDelete(*line)
	return err
}

// checkQueryfield make sure the fields won't all be empty (string --> "")
func checkQueryField(fieldValues []string) error {
	for _, fieldValue := range fieldValues {
		if fieldValue != "" {
			return nil
		}
	}
	return errors.New("the query field cannot all be empty string (\"\"), please check")
}

model 代码

package models

type SysCasbinRule struct {
	Ptype string `json:"ptype"` //
	V0    string `json:"v0"`    //
	V1    string `json:"v1"`    //
	V2    string `json:"v2"`    //
	V3    string `json:"v3"`    //
	V4    string `json:"v4"`    //
	V5    string `json:"v5"`    //
}

func (SysCasbinRule) TableName() string {
	return "sys_casbin_rules"
}

service 代码

package services

import (
	"errors"
	"fmt"
	"goapp/internal/pkg/gcasbin"
	"goapp/internal/pmhuang/casbinAdapter"
	"goapp/internal/pmhuang/repositories"

	casbin "github.com/casbin/casbin/v2"
	"gorm.io/gorm"
)

type CasbinService struct {
	dom      string
	Enforcer *casbin.Enforcer
}

// GetEnforcerFile 从 polic.csv 文件读取策略
func GetEnforcerFile() *casbin.Enforcer {
	return gcasbin.GetEnforcerFile()
}

// NewCasbinServiceWithDB 从数据库读取策略
func NewCasbinServiceWithDB(domain string, db *gorm.DB) *CasbinService {

	e := gcasbin.GetEnforcerDb()

	// 设置适配器
	repo := repositories.NewSysCasbinRuleRepository(db)
	e.SetAdapter(casbinAdapter.NewCasbinAdapter(repo))

	// 清空策略
	e.ClearPolicy()

	// 从数据库读取策略
	e.LoadPolicy()

	return &CasbinService{
		dom:      domain,
		Enforcer: e,
	}
}

func (a *CasbinService) HasRoleForUser(userId, roleId int64) bool {
	ret, _ := a.Enforcer.HasRoleForUser(fmt.Sprintf("%d", userId), fmt.Sprintf("%d", roleId), a.dom)
	return ret
}

func (s *CasbinService) AddRoleForUser(userId, roleId int64) error {
	if s.HasRoleForUser(userId, roleId) {
		return nil
	}
	_, err := s.Enforcer.AddRoleForUser(fmt.Sprintf("%d", userId), fmt.Sprintf("%d", roleId), s.dom)
	return err
}

func (s *CasbinService) HasPermissionForRole(roleId int64, path, action string) bool {
	return s.Enforcer.HasPolicy(fmt.Sprintf("%d", roleId), s.dom, path, action)
}

func (s *CasbinService) AddPermissionForRole(roleId int64, path, action string) error {
	if roleId <= 0 {
		return errors.New("角色不能为空")
	}

	if path == "" || action == "" {
		return errors.New("权限 path 和 action 不能为空")
	}

	if s.HasPermissionForRole(roleId, path, action) {
		return nil
	}

	_, err := s.Enforcer.AddPolicy(fmt.Sprintf("%d", roleId), s.dom, path, action)
	return err
}

func (a *CasbinService) GetRolesForUser(userId int64) []string {
	ret, _ := a.Enforcer.GetRolesForUser(fmt.Sprintf("%d", userId), a.dom)
	return ret
}

添加用户角色

// AddUserRoleRule 给用户添加角色规则
func (s SysUserService) AddUserRoleRule(roleIds []int64, userId int64) error {
	// models.RBAC_DOMAIN_ADMIN 是 admin
	serv := NewCasbinServiceWithDB(models.RBAC_DOMAIN_ADMIN, s.repo.DB())
	for _, v := range roleIds {
		err := serv.AddRoleForUser(userId, v)
		if err != nil {
			return err
		}
	}
	return nil
}

func (s *CasbinService) AddRoleForUser(userId, roleId int64) error {
	if s.HasRoleForUser(userId, roleId) {
		return nil
	}
	_, err := s.Enforcer.AddRoleForUser(fmt.Sprintf("%d", userId), fmt.Sprintf("%d", roleId), s.dom)
	return err
}

添加角色权限

// AddRoleMenuRule 给角色添加权限
func (s SysRoleService) AddRoleMenuRule(menuids []int64, roleId int64) error {
	var err error

	// models.RBAC_DOMAIN_ADMIN 是 admin
	serv := NewCasbinServiceWithDB(models.RBAC_DOMAIN_ADMIN, s.repo.DB())
	menus := repositories.NewSysMenuRepository(s.repo.DB()).FindByIds(menuids)
	for _, v := range menus {
		if v.MenuType != models.MENU_TYPEE_DIRECTORY {
			err = serv.AddPermissionForRole(roleId, v.ApiPath, v.ApiAction)
			if err != nil {
				return err
			}
		}
	}
	return nil
}

根据文件和数据库的两种策略,判断权限

	//访问路径
	path := c.Request.URL.Path

	// 访问方式
	method := c.Request.Method

	// 文件权限策略
	fileEnforcer := gcasbin.GetEnforcerFile()
	fileHasPerm, _ := fileEnforcer.Enforce(fmt.Sprintf("%d", userid), models.RBAC_DOMAIN_ADMIN, path, method)

	// 数据库的权限策略
	casbinServ := services.NewCasbinServiceWithDB(models.RBAC_DOMAIN_ADMIN, database.GetDB())
	hasPerm, _ := casbinServ.Enforcer.Enforce(fmt.Sprintf("%d", userid), models.RBAC_DOMAIN_ADMIN, path, method)

	if fileHasPerm || hasPerm {
		c.Next()
	} else {
		c.Abort()
		api.RenderError(c, http.StatusUnauthorized, tfunc("errors.invalid_data_scope", "没有权限"))
	}

这样,既可以在代码中控制 API 接口,也可以在业务后台中管理接口权限,可能是更加实际的吧,毕竟总有特别的接口不需要在后台展示