GraphQL with Casbin(RBAC with domain, field level) 访问控制

February / 16 / 2020

最近在做一些 GraphQL 相关的技术验证,预期要用到生产环境中,第一件要解决的事情就是访问控制了。这里通过一个小例子来分享我的解决思路。大体思路:使用 casbin RBAC model,在 GraphQL server 的 resolver func ( data loader )中对我们的输入请求进行字段级别的访问控制。

前言

使用到的库

源码 https://github.com/WenyXu/gqlgen_casbin_RBAC_example

动手实验

mkdir gqlgen_casbin_RBAC_example
cd gqlgen_casbin_RBAC_example
go mod init github.com/WenyXu/gqlgen_casbin_RBAC_example

define schema.graphql

type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
type User {
id: ID!
name: String!
}
type Query {
todos: [Todo!]!
}
input NewTodo {
text: String!
userId: String!
}
type Mutation {
createTodo(input: NewTodo!): Todo!
}
go run github.com/99designs/gqlgen init

create todo.go

package gqlgen_casbin_RBAC_example
type Todo struct {
ID string
Text string
Done bool
UserID string
}

updatge gqlgen.yml

models:
Todo:
model: github.com/WenyXu/gqlgen_casbin_RBAC_example.Todo

generate code

go run github.com/99designs/gqlgen -v

Implement the resolvers

resolver.go

package gqlgen_casbin_RBAC_example
import (
"context"
"fmt"
"math/rand"
) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
type Resolver struct{
todos []*Todo
}
func (r *Resolver) Mutation() MutationResolver {
return &mutationResolver{r}
}
func (r *Resolver) Query() QueryResolver {
return &queryResolver{r}
}
//***************** updated start ****************//
func (r *Resolver) Todo() TodoResolver {
return &todoResolver{r}
}
//***************** updated end ****************//
type mutationResolver struct{ *Resolver }
func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) {
//***************** updated ****************//
todo := &Todo{
Text: input.Text,
ID: fmt.Sprintf("T%d", rand.Int()),
UserID: input.UserID,
}
r.todos = append(r.todos, todo)
return todo, nil
//***************** updated end ****************//
}
type queryResolver struct{ *Resolver }
func (r *queryResolver) Todos(ctx context.Context) ([]*Todo, error) {
//***************** updated start ****************//
return r.todos, nil
//***************** updated end ****************//
}
//***************** updated start ****************//
type todoResolver struct{ *Resolver }
func (r *todoResolver) User(ctx context.Context, obj *Todo) (*User, error) {
return &User{ID: obj.UserID, Name: "user " + obj.UserID}, nil
}
//***************** updated end ****************//

注:真实环境中会在 resolver.go 实现 data loader ,接入 ORM or low level raw SQL  我们的表单字段相关访问控制将在 data loader 层中实现

Initialize Casbin enforcer

Casbin 文档:https://casbin.org/docs/en/supported-models 这个例子中,我们将使用 RBAC model 作为我们的模型,本示例仅演示使用该模型去控制我们 graphQL 的输入 fileds 。 casbin.go

package main
import (
"fmt"
"github.com/casbin/casbin/v2"
)
var (
enforcer *casbin.Enforcer
)
func init(){
initEnforcer()
}
func initEnforcer() {
e, err := casbin.NewEnforcer("./rbac_with_domains_model.conf", "./rbac_with_domains_policy.csv")
if err!=nil{
panic(err)
}
enforcer=e
}
func Enforcer() *casbin.Enforcer {
return enforcer
}

rbac_with_domains_model.conf

[request_definition]
r = sub, dom, tab, field, act
[policy_definition]
p = sub, dom, tab, field, 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.tab == p.tab && r.field == p.field && r.act == p.act

注:真实环境中我们的 policy model 会从配置中心读取 (watching or by pub/sub)可 实现热更新 rbac_with_domains_policy.csv

p, admin, domain1, todo, Text, read
p, admin, domain1, todo, Text, write
p, admin, domain1, todo, UserID, read
p, admin, domain1, todo, UserID, write
p, admin, domain2, table1, data2, read
p, admin, domain2, table1, data2, write
p, admin, domain2, table2, data2, read
p, admin, domain2, table2, data2, write
g, user, admin, domain1
g, user2, admin, domain2

注:真实环境中我们的 policy 数据会从配置中心读取(watching or by pub/sub)

update resolver

(吐槽:实现 resolver 所有代码后感叹,es 一个 map 能实现的事... go要用 reflect 去实现) resolver.go

...
func accessControl(input interface{},sub,domain,table,act string) (res map[string]interface{}) {
res = make(map[string]interface{})
rt :=reflect.TypeOf(input)
rv:=reflect.ValueOf(input)
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
value:=rv.Field(i).Interface()
ok,err:=enforcer.Enforce(sub,domain,table,field.Name,act)
if err != nil {
// handle err
}
if ok==true{
res[field.Name]=value
} else {
}
}
return
}
func createStructByReflect(data map[string]interface{}, inStructPtr interface{}) {
rType := reflect.TypeOf(inStructPtr)
rVal := reflect.ValueOf(inStructPtr)
if rType.Kind() == reflect.Ptr {
// 传入的 inStructPtr 是指针,需要 .Elem() 取得指针指向的 value
rType = rType.Elem()
rVal = rVal.Elem()
} else {
panic("inStructPtr must be ptr to struct")
}
// 遍历结构体
for i := 0; i < rType.NumField(); i++ {
t := rType.Field(i)
f := rVal.Field(i)
// 得到 tag 中的字段名
//key := t.Tag.Get("key")
// 这个例子中我们直接使用 struct field name
key:=t.Name
if v, ok := data[key]; ok {
// 检查是否需要类型转换
dataType := reflect.TypeOf(v)
structType := f.Type()
if structType == dataType {
f.Set(reflect.ValueOf(v))
} else {
if dataType.ConvertibleTo(structType) {
// 转换类型
f.Set(reflect.ValueOf(v).Convert(structType))
} else {
panic(t.Name + " type mismatch")
}
}
} else {
log.Print(t.Name + " not found")
}
}
}
func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) {
// 我们在这里做一个 MVP 版本作为演示
// 真实环境中使用 RPC or ctx 去获取 request user info
sub:="user"
domain:="domain1"
table:="todo"
act:="write"
//field filter
res:=accessControl(input,sub,domain,table,act)
//createStructByReflect
t:=&Todo{}
createStructByReflect(res,t)
r.todos = append(r.todos, t)
return t, nil
}
...

sub = user 情况 image.png sub !=user 情况(没有写入权限的情况下),没有权限的字段写入被过滤 image.png