@ddd-ts-kit/openfga

中文文档
0.106.0发布于 2026/11/01MITTypeScriptES Module构建来源已验证

OpenFGA上的关系元组存储和@ddd-ts-kit/auth的授权器,以及从关系模式生成的授权模型。

中文使用文档
文档目录5 节
  1. 模型,来自模式
  2. 商店和授权者
  3. 测试
  4. 限制
  5. 看到也

英语 · 法语

@ddd-ts-kit / openfga

的关系 @ddd-ts-kit/auth 在 OpenFGA, CNCF在桑给巴尔的实施 RelationTupleStore 和 Authorizer 港口回答 由OpenFGA服务器提供,其授权模型由关系模式生成 项目已声明 defineSchema. 真理的一个来源:图式;模型是 从它派生并根据服务器持有的内容进行检查。

npm install @ddd-ts-kit/openfga @openfga/sdk

@openfga/sdk (0.9.7,官方客户端)是一个可选的对等体,由 /sdk 条目 只有。主条目接受任何合适的客户端 OpenFgaApi,官方的子集 客户机适配器调用。

The three jobs of the package in an application: the use cases ask the Authorizer port, which OpenFgaAuthorizer implements with Check and ListObjects; a context adapter writes tuples after the commit through the RelationTupleStore port, which OpenFgaRelationTupleStore implements with Write and Read; the relationship schema is the one source of the model, which authorizationModelOf generates and ensureAuthorizationModel or modelDrift write or compare on the server

该包执行三个任务,每个任务针对OpenFGA服务器的一个存储:它决定 (OpenFgaAuthorizer),它保存元组(OpenFgaRelationTupleStore),并保留模型 等于架构(authorizationModelOf, ensureAuthorizationModel, modelDrift). 处理程序永远不会导入它:它们依赖于的端口 @ddd-ts-kit/auth,和 平台将这些适配器绑定到它们,就像它绑定的那样 RelationBasedAuthorizer 一个元组 存储在您自己的数据库中。

模型,来自模式

模式是唯一的 RelationBasedAuthorizer 过程中评估:类型、关系a 元组可以包含,以及由它们派生的关系,其中以a命名的关系 许可。 authorizationModelOf 把它变成 的身体 WriteAuthorizationModel (schema 1.1)。这是一个简化的图式,本着 示例应用程序之一:

import { definePermissions } from '@ddd-ts-kit/auth';
import { defineSchema, via } from '@ddd-ts-kit/auth/rebac';
import { authorizationModelOf } from '@ddd-ts-kit/openfga';

export const schedulingPermissions = definePermissions('scheduling', {
  'appointment.cancel': { description: 'Cancel an appointment' },
});

// Who is what to which element: a practice has managers, a calendar its holder and assistants.
export const relationshipSchema = defineSchema({
  user: {},
  practice: { relations: { manager: ['user'] }, derived: { manage: 'manager' } },
  calendar: {
    relations: { holder: ['user'], assistant: ['user'], practice: ['practice'] },
    derived: { run: { anyOf: ['holder', 'assistant', via('practice', 'manage')] } },
  },
  appointment: {
    relations: { patient: ['user'], calendar: ['calendar'] },
    // To cancel an appointment is to hold `change` on it: the derived relation named after the permission grants it.
    derived: { change: { anyOf: ['patient', via('calendar', 'run')] }, [schedulingPermissions['appointment.cancel'].id]: 'change' },
  },
});

// { schema_version: '1.1', type_definitions: [user, practice, calendar, appointment] }
export const model = authorizationModelOf(relationshipSchema);

生成的模型决定如下,用OpenFGA建模语言编写 只读:包写入JSON表单,没有人手动编写此文本):

model
  schema 1.1

type user

type practice
  relations
    define manager: [user]
    define manage: manager

type calendar
  relations
    define holder: [user]
    define assistant: [user]
    define practice: [practice]
    define run: holder or assistant or manage from practice

type appointment
  relations
    define patient: [user]
    define calendar: [calendar]
    define change: patient or run from calendar
    define scheduling_appointment_cancel: change

以权限id命名的派生关系不能保留其名称:OpenFGA拒绝 : 在一个 关系的名字。 relationNameOf 写 scheduling:appointment.cancel 作为 scheduling_appointment_cancel,在模型中也是如此 Check 和 ListObjects;否 权限id持有 _所以两个永远不会成为一个名字。

模式的 @ddd-ts-kit/auth/rebacOpenFGA
关系:关系及其所列的主题: editor: ['user', 'group#member']直接可分配关系(this), directly_related_user_types
诸道中的名字: edit: 'owner', anyOf: ['owner', 'editor']计算用户集
anyOf, allOfunion, intersection
butNotdifference
via('parent', 'edit')一个元组到用户集, edit from parent
一个名为 docs:document.edit的关系 docs_document_edit

ensureAuthorizationModel(client, schema) 对象中最新的对象写入生成的模型 商店不同,并回答有效的id和差异(OpenFGA保留每个模型;检查) 使用最新的,除非客户端固定一个)。在部署或启动应用程序的地方运行它:

import type { Schema } from '@ddd-ts-kit/auth/rebac';
import { ensureAuthorizationModel } from '@ddd-ts-kit/openfga';
import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';

declare const relationshipSchema: Schema;
declare const config: { readonly openfga: { readonly url: string; readonly storeId: string } };

const client = openFgaClient({ apiUrl: config.openfga.url, storeId: config.openfga.storeId });

// The first run writes the model; the next ones write nothing until the schema changes.
const { modelId, changes } = await ensureAuthorizationModel(client, relationshipSchema);

console.log(`authorization model ${modelId}`, changes);

modelDrift(authorizationModelOf(schema), served) 只比较:每个类型或一行 type#relation 思念,多了还是变了,空的时候两个人决定一样。管道步骤如下 店里最新的模型在任何一条线上都失败了,所以模型从来没有手工编辑过 模式:

import type { Schema } from '@ddd-ts-kit/auth/rebac';
import { authorizationModelOf, modelDrift, type OpenFgaApi } from '@ddd-ts-kit/openfga';

declare const client: OpenFgaApi;
declare const relationshipSchema: Schema;

const { authorization_model: served } = await client.readLatestAuthorizationModel();
const drift = modelDrift(authorizationModelOf(relationshipSchema), served);

if (drift.length > 0) {
  console.error(drift.join('\n'));
  process.exitCode = 1;
}

运行在一个store上,它仍然保存着一个没有助手的模式的模型,它打印:

calendar#assistant is in the schema, not in the store: this [user]
calendar#run differs: the schema says union(holder, assistant, from(practice, manage)) [], the store union(holder, from(practice, manage)) []

商店和授权者

在组合根中,一个绑定到一个存储的客户端为两个适配器提供服务。两人都读了 Schema:为用户集提供主题的存储,其关系为(group:eng 写 group:eng#member),授权人请求许可作为以其命名的派生关系; 作为 RelationBasedAuthorizer :

import type { Schema } from '@ddd-ts-kit/auth/rebac';
import { OpenFgaAuthorizer, OpenFgaRelationTupleStore } from '@ddd-ts-kit/openfga';
import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';

declare const relationshipSchema: Schema; // the one of the block above, with scheduling:appointment.cancel derived on an appointment
declare const config: { readonly openfga: { readonly url: string; readonly storeId: string } };

const client = openFgaClient({ apiUrl: config.openfga.url, storeId: config.openfga.storeId });

// The same two ports as the in-process authorizer and a tuple store in your database.
export const tuples = new OpenFgaRelationTupleStore(client, relationshipSchema);
export const authorizer = new OpenFgaAuthorizer({ client, schema: relationshipSchema, listObjectsLimit: 1000 });
  • OpenFgaRelationTupleStore 通过 relationTupleStoreContract,就像PostgreSQL存储一样: 两次写入元组并删除不存在的元组不会改变任何东西(冲突选项) (OpenFGA 1.10及更新版本),写入和删除每100次,读取每一个页面。的元组 这本书只给它的主题命名;存储写入模式给它的用户集 (group:eng#member),然后自己再读一遍。按主题读取是按关系读取一次 模式接受主题的类型,因为OpenFGA在一个对象内按用户读取 类型。
  • OpenFgaAuthorizer 通过 authorizerContract的模式和拒绝代码 RelationBasedAuthorizer (ANONYMOUS, RESOURCE_REQUIRED, UNMAPPED_PERMISSION, NO_RELATION): Check 决定, ListObjects 给出搜索的范围({ kind: 'ids' })。 项目通过绑定在两者之间移动,其他的都不会改变。

Left, check: an anonymous actor is denied ANONYMOUS, a check without a resource RESOURCE_REQUIRED, an unmapped permission UNMAPPED_PERMISSION, all before any call; otherwise Check on the server answers allowed or NO_RELATION. Right, listAccessible: anonymous or unmapped gives the scope none; otherwise ListObjects, refused with ReverseExpansionLimitError when the answer reaches listObjectsLimit, else the scope ids

的元组,授权中间件和搜索处理程序接收到什么 示例(ada 协助格雷斯博士的日程表,其中有预约;

import type { Permission } from '@ddd-ts-kit/auth';
import type { Actor } from '@ddd-ts-kit/kernel';
import type { OpenFgaAuthorizer, OpenFgaRelationTupleStore } from '@ddd-ts-kit/openfga';

declare const tuples: OpenFgaRelationTupleStore;
declare const authorizer: OpenFgaAuthorizer;
declare const cancel: Permission;

await tuples.write([
  { object: 'calendar:dr-grace', relation: 'assistant', subject: 'user:ada' },
  { object: 'appointment:42', relation: 'calendar', subject: 'calendar:dr-grace' },
]);

const ada: Actor = { id: 'ada', type: 'user', roles: [], attributes: {} };

// Check user:ada scheduling_appointment_cancel appointment:42: { allowed: true }
export const decision = await authorizer.check({
  actor: ada,
  permission: cancel,
  resource: { type: 'appointment', id: '42' },
});

// ListObjects user:ada scheduling_appointment_cancel appointment: { kind: 'ids', ids: ['42'] }, the ids the search may read
export const scope = await authorizer.listAccessible({ actor: ada, permission: cancel, resourceType: 'appointment' });

的极限 ListObjects. 服务器最多应答 OPENFGA_LIST_OBJECTS_MAX_RESULTS 对象(默认为1000),并在 OPENFGA_LIST_OBJECTS_DEADLINE (默认3秒), 在这两种情况下都返回到目前为止找到的对象 没有错误. 搜索范围缩小了 删减列表会在沉默中丢失元素,所以 OpenFgaAuthorizer 拒绝回答,只要 listObjectsLimit (默认1000:将其设置为服务器的值) ReverseExpansionLimitError,因为进程内授权器超出了自己的限制。删减清单 截止日期不能从一个完整的答案中分辨出来:给服务器一个截止日期 在您所期望的最慢列表之上,并观察其延迟。当名单那么长, 首先缩小范围(可访问id的投影,如诊所等较粗糙的母体) 而不是提高限额。

写操作是对服务的调用。 它不加入工作单元的事务 聚合可以在没有元组的情况下提交,或者为其事务的聚合编写元组 回滚。在提交之后写元组,不要在命令的事务中写。 并决定当聚合存在时,请求在短窗口内看到什么 关系:

// Instead of: a handler of the default phase (before-commit) runs inside the transaction,
// so a rollback after it leaves on the server a tuple for an appointment that does not exist.
@DomainEventHandler(AppointmentBooked)
export class RelationshipsOnAppointmentBooked { /* ... */ }
import { NamedDomainEvent } from '@ddd-ts-kit/kernel';
import { DomainEventHandler } from '@ddd-ts-kit/kernel/declarations';

export class AppointmentBooked extends NamedDomainEvent('scheduling.appointment.booked') {
  constructor(
    readonly aggregateId: string,
    readonly occurredAt: Date,
    readonly booking: { readonly calendarId: string; readonly patientId: string },
  ) {
    super();
  }
}

// The port of the context, in its own words; its adapter writes the tuples to OpenFgaRelationTupleStore.
export abstract class SchedulingRelationships {
  abstract recordAppointment(appointment: { appointmentId: string; calendarId: string; patientId: string }): Promise<void>;
}

// Write: after the commit, never when the transaction rolled back.
@DomainEventHandler(AppointmentBooked, { phase: 'after-commit' })
export class RelationshipsOnAppointmentBooked implements DomainEventHandler<AppointmentBooked> {
  constructor(private readonly relationships: SchedulingRelationships) {}

  async handle(event: AppointmentBooked): Promise<void> {
    await this.relationships.recordAppointment({ appointmentId: event.aggregateId, ...event.booking });
  }
}

相位处理程序 after-commit 运行 最多一次提交和运行之间的崩溃; 或者服务器没有响应,丢失元组,并且只报告其失败。一个元组 不能通过发件箱丢失:发布带有更改的集成事件,以及 从它的消费者写入元组,它至少被传递一次,然后重试,直到它被传递 成功。商店是幂等的,所以重复送货不会改变什么。

测试

该包的规范针对元组的内存双精度运行两个契约套件 端点,并针对一个真实的服务器时 DDD_TS_KIT_OPENFGA_URL 集成工作是什么 存储库启动 openfga/openfga V1.22.0,由摘要固定。每个项目都有自己的特色 适配器到相同的套件,每个测试在它自己的存储(createStore,然后 ensureAuthorizationModel). 存储契约写入的元组是关于文档和组的 (document:1#editor@group:eng,由服务器持有为 group:eng#member, document:1#parent@group:eng):它运行的存储保存了描述它们的模型。

import { OpenFgaClient } from '@openfga/sdk';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { defineSchema } from '@ddd-ts-kit/auth/rebac';
import { relationTupleStoreContract } from '@ddd-ts-kit/auth/testing';
import { ensureAuthorizationModel, OpenFgaRelationTupleStore, type OpenFgaApi } from '@ddd-ts-kit/openfga';
import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';

const OPENFGA_URL = process.env['OPENFGA_TEST_URL'] ?? '';

// The types and relations of the tuples the contract writes.
const contractSchema = defineSchema({
  user: {},
  group: { relations: { member: ['user', 'group#member'] } },
  document: {
    relations: { editor: ['user', 'group#member'], viewer: ['user', 'group#member'], parent: ['group'] },
  },
});

let stores = 0;

// A store of its own per test, holding the model of the schema: no test sees the tuples of another.
async function freshStore(): Promise<OpenFgaApi> {
  const admin = new OpenFgaClient({ apiUrl: OPENFGA_URL });
  const { id: storeId } = await admin.createStore({ name: `contract-${process.pid}-${(stores += 1)}` });
  const client = openFgaClient({ apiUrl: OPENFGA_URL, storeId });

  await ensureAuthorizationModel(client, contractSchema);

  return client;
}

// Skipped without a server, as the specs of the package are.
relationTupleStoreContract(
  { describe: describe.runIf(OPENFGA_URL !== ''), it, expect, beforeEach, afterEach },
  {
    name: 'OpenFgaRelationTupleStore on an OpenFGA server',
    create: async () => new OpenFgaRelationTupleStore(await freshStore(), contractSchema),
  },
);

限制

  • 不携带一致性令牌:检查使用服务器的默认优先级 (MINIMIZE_LATENCY),因此,刚写入的元组可能不会被每个副本同时看到。
  • 没有条件元组(OpenFGA条件):工具包的模式也没有任何警告。
  • 演员的主题是 <type>:<id>;持有OpenFGA拒绝的字符的id 对象id需求 subjectOf.
  • explain 的 RelationBasedAuthorizer 这里没有对应的词:用the Expand OpenFGA的API 用于调试,其中以权限命名的派生关系具有该名称 relationNameOf 给它(docs_document_edit)。

看到也

版本与发布记录

@ddd-ts-kit/openfga 共发布 55 个版本,首次发布于 2026/10/13;过去 12 个月发布了 55 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.106.0latest2026/11/012 天前
0.105.02026/10/312 天前
0.104.02026/10/312 天前
0.103.02026/10/313 天前
0.102.02026/10/313 天前
0.101.02026/10/303 天前

其他发布通道:reserved → 0.0.0。

关于 @ddd-ts-kit/openfga 的常见问题

@ddd-ts-kit/openfga 是什么?

OpenFGA上的关系元组存储和@ddd-ts-kit/auth的授权器,以及从关系模式生成的授权模型。

如何安装 @ddd-ts-kit/openfga?

在项目目录运行 npm install @ddd-ts-kit/openfga,或使用 pnpm add / yarn add / bun add。

@ddd-ts-kit/openfga 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ddd-ts-kit/openfga 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@ddd-ts-kit/openfga 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ddd-ts-kit/openfga 可以商用吗?

@ddd-ts-kit/openfga 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ddd-ts-kit/openfga 还在维护吗?

最新版本 0.106.0 发布于 2026/11/01(2 天前);至今共发布 55 个版本,其中过去 12 个月发布了 55 个;首次发布于 2026/10/13。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。