go 项目中基于 casbin 的 API 接口权限管理
20 Nov 2021温馨提示:仅为个人笔记以免遗忘,不保证代码完整
一般在 web 项目中的权限管理基本是这几种
- 前端菜单的展示
- 后端 API 接口的请求权限
- 前端 按钮的操作权限
- 部分数据的访问权限
根据这个需求,我们可以抽象出,用户、角色、菜单(模块、菜单、接口)、某某资源等几个实体
但是如果系统分为多个子系统,就需要引入域的概念,比如后台用户和网站用户就是不同的域
在 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 接口,也可以在业务后台中管理接口权限,可能是更加实际的吧,毕竟总有特别的接口不需要在后台展示