权限系统探索-ReBAC典型实践——OpenFGA与图数据库

500 阅读13分钟

前言

最近在研究权限系统,选择了ReBAC作为我们统一业务的权限模型。那么本文着重于研究ReBAC的核心原理,OpenFGA的具体实践,以及为什么没有使用图数据库作为存储

ReBAC

什么是ReBAC

ReBAC完整的英文是Relational Based Access Control,基于关系的访问控制。

这个概念是由Carrie E. Gates在2006年提出的,但他并没有给出如何实践,2019年,谷歌发表了一篇论文 Zanzibar: Google’s Consistent, Global Authorization System,他详细介绍了如何实践rebac,其中包括授权模型,提供的api接口,授权一致性方案,acl存储,acl索引,但并没有给出具体实现。

Zanzibar指出,谷歌的大量应用基于Zanzibar鉴权,包括但不局限于Calendar, Cloud, Drive, Maps, Photos, YouTube,acl规模在万亿级别,每秒百万级请求量,三年可用性高达99.999%。

在传统的rbac中,只能限制操作权限,并不能限制带数据约束的操作权限,比如,rbac中无法表达用户Bob拥有项目A的'查看'权限,而rebac可以丝滑解决问题。rebac与rbac或abac并不是水火不容的,可以基于rebac构建rbac或abac。

相关介绍可以阅读以下文章:

这几篇文章不仅介绍了ReBAC,还举了一些例子。

为什么都是英文?因为这个概念比较新,相对于国内来说资料太少了,。

通过上述学习,你应该会得到一个概念,ReBAC的表现力非常强,细粒度也可以非常细,这都是基于其图的思想

那ReBAC其原理又是什么样的呢,我们下文将通过ReBAC现在主流的开源框架OpenFGA进行讲解。

OpenFGA

什么是OpenFGA

github地址:github.com/openfga/ope…

官网:openfga.dev/docs/fga

官方介绍如下: QQ_1734409882514.png

一个高性能且灵活的授权/权限引擎,为开发者而生,灵感来源于 Google Zanzibar。

OpenFGA 的设计目标是帮助开发者轻松构建应用程序的权限模型,并将细粒度的授权功能集成到应用程序中。

它支持内存数据存储以便快速开发,同时支持可插拔的数据库模块。目前支持 PostgreSQL 14、MySQL 8 和 SQLite(目前为测试版)。

OpenFGA 提供 HTTP API 和 gRPC API,并拥有适用于 Java、Node.js/JavaScript、GoLang、Python 和 .NET 的 SDK。在社区部分,还可以找到第三方 SDK 和工具。它也可以作为库来使用。

你光看介绍就知道,OpenFGA并没有采用图数据库作为底层存储。那么问题来了,既然是基于图的思想,OpenFGA使用关系型数据库,其图的概念又是如何实现的?

OpenFGA基于图的实现

我直接拉了其Github源码,接下来我们将从其鉴权方法——check方法的源码入手,分析其是如何实现的。

核心代码解析

以下是其核心源码!

源码路径:github.com/openfga/ope…

func (s *Server) Check(ctx context.Context, req *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) {
	start := time.Now()

	tk := req.GetTupleKey()

        ......前面省略
        
	storeID := req.GetStoreId()


        // 将model转为实体类typesys
	typesys, err := s.resolveTypesystem(ctx, storeID, req.GetAuthorizationModelId())
	if err != nil {
		return nil, err
	}

	......后面省略

	return res, nil
}

第一步看到这里就ok了!

前面我有一个文章介绍到ReBAC的策略,ReBAC中图即是策略(Policy as a Graph),那么定义这个图的就是model,这处代码就是将model转为图!

直接进入其最底层(以下代码在Github上找不到,是其依赖包中的实现) 代码如下

// NewAuthorizationModelGraph builds an authorization model in graph form.
// For example, types such as `group`, usersets such as `group#member` and wildcards `group:*` are encoded as nodes.
// By default, the graph is drawn from bottom to top (i.e. terminal types have outgoing edges and no incoming edges).
// Conditions are not encoded in the graph.
func NewAuthorizationModelGraph(model *openfgav1.AuthorizationModel) (*AuthorizationModelGraph, error) {
    res, ids, err := parseModel(model)
    if err != nil {
       return nil, err
    }

    return &AuthorizationModelGraph{res, DrawingDirectionListObjects, ids}, nil
}

func parseModel(model *openfgav1.AuthorizationModel) (*multi.DirectedGraph, NodeLabelsToIDs, error) {
    graphBuilder := &AuthorizationModelGraphBuilder{
       multi.NewDirectedGraph(), map[string]int64{},
    }
    // 这里使用multi下图的库创建一个graphBuilder

    // sort types by name to guarantee stable output
    sortedTypeDefs := make([]*openfgav1.TypeDefinition, len(model.GetTypeDefinitions()))
    copy(sortedTypeDefs, model.GetTypeDefinitions())

    slices.SortFunc(sortedTypeDefs, func(a, b *openfgav1.TypeDefinition) int {
       return cmp.Compare(a.GetType(), b.GetType())
    })

    for _, typeDef := range sortedTypeDefs {
       graphBuilder.GetOrAddNode(typeDef.GetType(), typeDef.GetType(), SpecificType)

       // sort relations by name to guarantee stable output
       sortedRelations := make([]string, 0, len(typeDef.GetRelations()))
       for relationName := range typeDef.GetRelations() {
          sortedRelations = append(sortedRelations, relationName)
       }

       slices.Sort(sortedRelations)

       for _, relation := range sortedRelations {
          uniqueLabel := fmt.Sprintf("%s#%s", typeDef.GetType(), relation)
          parentNode := graphBuilder.GetOrAddNode(uniqueLabel, uniqueLabel, SpecificTypeAndRelation)
          rewrite := typeDef.GetRelations()[relation]
          checkRewrite(graphBuilder, parentNode, model, rewrite, typeDef, relation)
       }
    }

    multigraph, ok := graphBuilder.DirectedMultigraphBuilder.(*multi.DirectedGraph)
    if ok {
       return multigraph, graphBuilder.ids, nil
    }

    return nil, nil, fmt.Errorf("%w: could not cast to directed graph", ErrBuildingGraph)
}

这两个方法就是将我们定义的的model转化成为了,而这里的的图实现使用的是go的multi下图的库。

那么问题来了,对于model建一个图,有什么用?真正的数据是三元组,我们该如何对应起来?

OpenFGA中如何实现图的思想

OpenFGA的核心思想大致如下:

  1. 将model构建为图。
  2. 基于model的图,判断三元组是否有路径可达,是否有环是否自反(自身依赖)。
  3. 逐层重写(重写可以理解为寻找下一个可达的实际数据)
  4. 最后将结果汇总,包含用户的直接是否可达,用户的重写是否可达,通配符是否可达。

如何解决自身依赖(自反)问题

什么叫做自反问题?

我举个例子就明白了。

假如现在有一个模型如下

type user
type group
 relations member:[user,group#member]

它的含义是

  1. 某个人可以是某个组的成员
  2. 某个组的成员可以是另一个组的成员。

一开始时,我们可以添加如下三元组

user -> group:01#member
relation -> member
object -> group:02

这个定义可以表示为用户组01的成员,自动成为用户组02的成员。 然后我们可以在授权时,添加如下三元组

user -> user:alice
relation -> member
object -> group:01

由于有这样的关系,那么用户alice加入用户组01的时候,就会自动加入用户组02。

如果我现在鉴权,调用checkApi,去判断group02的成员是否是group01的成员,结果肯定是true,因为我们刚刚维护了对应的关系。

但是问题来了,如果我去判断group01的成员是否是group01的成员,结果会是什么?

按照正常思考逻辑,group01的成员肯定是group01的成员!这个问题就相当于问了一句废话,小明是小明吗,杯子是杯子吗,现在由于我们并没有维护三元组,它会给我们返回true吗?

没错,OpenFGA代码中提供了自反的判断,即使我们没有维护对应的三元组,它也会返回true! 判断如下。

// IsSelfDefining returns true if the tuple is reflexive/self-defining. E.g. Document:1#viewer@document:1#viewer.
// See https://github.com/openfga/rfcs/blob/main/20240328-queries-with-usersets.md
func IsSelfDefining(tuple *openfgav1.TupleKey) bool {
    userObject, userRelation := SplitObjectRelation(tuple.GetUser())
    return tuple.GetRelation() == userRelation && tuple.GetObject() == userObject
}

上面这个例子只是为了方便理解,任何一个定义都可以自反,比如document:01的parent是不是document:01的parent。

如果还不理解,可以查看官方的讨论链接 github.com/openfga/rfc…

如何判断是否有环

在图的遍历过程中,如果我们不判断是否有环,一旦遇到就会循环执行下去,所以我们需要有一个判断是否有环的方法,遇到环了,鉴权返回false。

OpenFGA的方法如下:

// 判断是否有环
func (c *LocalChecker) hasCycle(req *ResolveCheckRequest) bool {
    key := tuple.TupleKeyToString(req.GetTupleKey())
    
    // 路径没有则初始化
    if req.VisitedPaths == nil {
       req.VisitedPaths = map[string]struct{}{}
    }

    // 判断当前的路径是否走过了,如果key对应的路径存在,说明有环,返回true
    _, cycleDetected := req.VisitedPaths[key]
    if cycleDetected {
       return true
    }
    
    // 如果没有发现循环,就把当前路径的 `key` 添加到 `VisitedPaths` 中,记录下来,以便后续检查用。
    req.VisitedPaths[key] = struct{}{}
    return false
}

如果此方法判断是否有环,则返回true,鉴权的结果会直接返回一个false

cycle := c.hasCycle(req)
if cycle {
    span.SetAttributes(attribute.Bool("cycle_detected", true))
    return &ResolveCheckResponse{
       Allowed: false,
       ResolutionMetadata: ResolveCheckResponseMetadata{
          CycleDetected: true,
       },
    }, nil
}

什么是重写

在 OpenFGA 中,重写(Rewrite) 是权限模型的一种核心机制,用于灵活定义和组合关系(Relation)。通过重写,你可以基于已有的关系创建更复杂的权限逻辑,而无需直接硬编码所有权限规则。它的作用是让权限的表达更加抽象、模块化和易于维护。我们在之前的举例过程中已经有了大量的权限继承,传递,这就是重写的一个定义。

下面我们开始对 OpenFGA 中重写机制进行详细讲解:

重写(Rewrite)的定义

重写是用来定义一个关系如何由其他关系派生或组合的规则。
一个关系可以通过以下方式被重写:

  1. 直接引用(Direct Relation) :直接关联某个用户或用户组。
  2. 条件引用(Indirect Relation) :从其他对象或关系继承或推导。
  3. 逻辑操作(Union、Intersection、Difference) :基于多个关系通过逻辑组合(如或、与、差集)生成新的关系。

重写的基本语法

以下是一个关系的重写定义:

define <relation>: [direct-relations] or <relation> from <other-relation>
关键部分说明:
  • define <relation> :定义当前关系的名称。
  • [direct-relations] :可以直接访问此关系的用户/用户组。
  • or / and / but not:逻辑运算符,用于组合多个关系。
  • from:从另一个关系派生当前关系。
重写的类型
1. 基础重写(Direct Relations)

直接定义某个用户或用户组拥有该关系。


define viewer: [user, group#member]
  • 说明:viewer 权限直接授予某些 usergroup#member
2. 继承重写(Inheritance/Userset Rewrite)

从其他对象的关系中继承权限。

define viewer: [user] or viewer from parent
  • 说明:viewer 权限不仅包含直接授予的 user,还包括从 parent 对象中继承的 viewer
3. 逻辑重写(Logical Combinations)

通过逻辑运算组合多个关系。

Union(并集)

define editor: [user] or viewer
  • 说明:editor 权限包含直接授予的 user 和拥有 viewer 权限的用户。

Intersection(交集)

define contributor: viewer and editor
  • 说明:contributor 权限只授予同时拥有 viewereditor 权限的用户。

Difference(差集)

define restricted_viewer: viewer but not blacklisted
  • 说明:restricted_viewer 权限授予拥有 viewer 权限但不在 blacklisted 列表中的用户。
举例:文档权限模型

以下是一个基于文档的权限模型,展示如何使用重写实现复杂逻辑:

type document
  relations
    define owner: [user]          # 直接定义 owner 权限
    define editor: [user] or owner   # 编辑权限包括 editor 和 owner
    define viewer: [user] or editor or viewer from parent but not blackList# 查看权限包括 viewer 和 editor,但不能是黑名单中的
    define parent: [document]        # 定义父文档
    define blackList [user]
解释
  • owner:只有直接被授予的用户才是文档的所有者。
  • editor:编辑权限不仅包括 editor 用户,还包括文档的 owner
  • viewer:查看权限包括直接的 viewer 用户、editor 用户和 owner
  • viewer from parent:一个文档的 viewer 权限可以从父文档继承。

重写的核心原理

它的核心原理简而概之就是:

  1. 根据model定义查出与我们的user有关联的object
  2. 从数据库中找到object作为user的三元组。
  3. 拿到三元组后去推导下一个三元组。
  4. 根据关系依次递归找到我们的路径,然后汇总,发现是否可达目标。

其代码非常多,所以我这里就不贴了,大家能够理解其原理即可。

感兴趣的也可以从check方法入手查看源码,Github路径:github.com/openfga/ope…

为什么ReBAC不使用图数据库?

这个问题其实就是我一直想问的问题,在我研究OpenFGA的时候,我就会想,为什么它支持关系型数据库或者key-value型数据库,基于图的思想,为什么不使用图数据库?

说实话这个问题我找了很久很久,终于在外网找到了一篇博客能够验证此问题。(我真的是费了很大的功夫,所以此文未经允许,不可转载)。

链接如下:Modelling ReBAC Access Control System with Ontologies and Graph Databases

此人通过研究图数据库去实现ReBAC时,发现了一个问题,图数据库本身不具有推理功能!也就是说如果我需要使用图数据库去寻找一个路径,那么我需要将我可以路过的所有的关系传递进去。比如这样

match (chris:User { id : "Chris"})  
match (chris)-[:VIEWER]-(doc1)-[:PARENT*0..]->(doc)  
return doc1, doc

但是,我们的ReBAC的理念是,我们只传递起点和终点,其路径依靠推理即可出来。但是牛逼的地方在于,这个人通过加入Reasoner推理引擎实现了图数据库的推理能力。

感兴趣的强烈看一下原文,应该能够有所见识。

但是他也得出了结论,翻译如下:

其实,我选择的方式可能不太适合手头的任务。对于具有数千个用户和资源的大型系统,在数据库的每次更改上运行推理器在计算(为每个个体运行规则)和存储角度(存储推断的关系)方面可能非常昂贵。我认为这里的关键因素是本体的复杂性,包括规则的数量和系统的变化率。因此,对于具有相当简单的本体(正如我们探索的 — 三个类和十几个关系)和高更改率(每秒多次更改)的系统,使用推理器的方式根本不合适。但是对于具有非常复杂和低变化率的系统,拥有一个具有大量规则的通用本体并很少运行推理器是完全有意义的,即使计算所有不存在的关系需要很长时间。

对比

OpenFGA:OpenFGA 通过缓存机制来提高查询效率。如果缓存可用,权限检查会非常快速,因此它可以在大规模应用中提供快速响应。它不需要重复执行所有的推理过程,而是依赖缓存来避免不必要的计算。

图数据库 + 推理引擎:虽然图数据库也可以使用缓存来加速查询,但推理引擎的计算通常是 基于规则的推导,并且需要在 多步推理多次迭代 中得出结论。这使得每次推理都会涉及到 复杂的计算和内存开销,从而影响性能。

我相信这也是为什么OpenFGA为什么不使用图数据库的原因:

  1. 图数据库本身不具有推理功能。
  2. 使用推理引擎性能会是一个问题。

OpenFGA的性能怎么样

最后我们来探讨一下OpenFGA的性能。

其在官网介绍其是毫秒级别的

QQ_1735888811101.png 但是没有数据说明不够有说服力,官网和github上面都没有相关的性能介绍

但是我在其官方github上面找到了一篇关于性能的discussion

github.com/orgs/openfg…

结论是:

他们结尾还说到,这个性能测试是开发环境,生产环境只会更好!

后续如果有时间也会单开文章去探讨OpenFGA的性能优化的源码。

当然!基于建模的复杂度,以及三元组的庞大的数据,其性能肯定会有所下降。

结尾

如果读者发现有什么没有讲清楚的地方,欢迎评论区指正。后续我们也会研究其落地方案,主要解决listObejct和搜索的问题,以及性能问题,并且寻找model定义的最佳实践

最后说一句,本人呕心沥血之作,切勿转载。