@ddd-ts-kit/openfga

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

The relation tuple store and the authorizer of @ddd-ts-kit/auth on OpenFGA, with the authorization model generated from the relationship schema.

原始使用文档
文档目录5 节
  1. The model, from the schema
  2. The store and the authorizer
  3. Testing
  4. Limits
  5. See also

English · Français

@ddd-ts-kit/openfga

The relationships of @ddd-ts-kit/auth on OpenFGA, the CNCF implementation of Zanzibar: the RelationTupleStore and the Authorizer ports answered by an OpenFGA server, and its authorization model generated from the relationship schema the project already declares with defineSchema. One source of truth: the schema; the model is derived from it and checked against what the server holds.

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

@openfga/sdk (0.9.7, the official client) is an optional peer, imported by the /sdk entry only. The main entry takes any client that fits OpenFgaApi, the subset of the official client the adapters call.

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

The package does three jobs, each against one store of an OpenFGA server: it decides (OpenFgaAuthorizer), it holds the tuples (OpenFgaRelationTupleStore) and it keeps the model of the server equal to the schema (authorizationModelOf, ensureAuthorizationModel, modelDrift). Handlers never import it: they depend on the ports of @ddd-ts-kit/auth, and the platform binds these adapters to them, exactly as it binds RelationBasedAuthorizer and a tuple store in your own database.

The model, from the schema

The schema is the one RelationBasedAuthorizer evaluates in process: types, the relations a tuple can hold, and the relations derived from them, among which the ones named after a permission. authorizationModelOf turns it into the body of WriteAuthorizationModel (schema 1.1). Here is a simplified schema, in the spirit of the one of the example application:

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);

The generated model decides as this one, written in the modelling language of OpenFGA (for reading only: the package writes the JSON form, and nobody writes this text by hand):

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

A derived relation named after a permission id cannot keep its name: OpenFGA refuses : in a relation name. relationNameOf writes scheduling:appointment.cancel as scheduling_appointment_cancel, in the model as in every Check and ListObjects; no permission id holds _, so two never become one name.

Schema of @ddd-ts-kit/auth/rebacOpenFGA
a relation and the subjects it lists: editor: ['user', 'group#member']a directly assignable relation (this) with directly_related_user_types
a name among the ways: edit: 'owner', anyOf: ['owner', 'editor']a computed userset
anyOf, allOfunion, intersection
butNotdifference
via('parent', 'edit')a tuple to userset, edit from parent
a derived relation named docs:document.editthe relation docs_document_edit

ensureAuthorizationModel(client, schema) writes the generated model when the latest one of the store differs, and answers the id in force and the differences (OpenFGA keeps every model; checks use the latest unless a client pins one). Run it where the application is deployed or starts:

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) only compares: one line per type or type#relation missing, extra or changed, empty when the two decide alike. A pipeline step reads the latest model of the store and fails on any line, so the model is never edited by hand next to the schema:

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;
}

Run against a store that still holds the model of a schema without assistants, it prints:

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)) []

The store and the authorizer

In the composition root, one client bound to one store serves both adapters. Both read the schema: the store to give a subject the userset its relation says (group:eng is written group:eng#member), the authorizer to ask a permission as the derived relation named after it, as RelationBasedAuthorizer does:

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 passes relationTupleStoreContract, as the PostgreSQL store does: writing a tuple twice and deleting one that is not there change nothing (the conflict options of OpenFGA 1.10 and later), writes and deletes go by 100, reads follow every page. A tuple of the kit names its subject alone; the store writes the userset the schema gives it (group:eng#member) and reads it back alone. A read by subject is one read per relation of the schema that accepts the type of the subject, since OpenFGA reads by user within one object type.
  • OpenFgaAuthorizer passes authorizerContract, with the schema and the denial codes of RelationBasedAuthorizer (ANONYMOUS, RESOURCE_REQUIRED, UNMAPPED_PERMISSION, NO_RELATION): Check decides, ListObjects gives the scope of a search ({ kind: 'ids' }). A project moves between the two by binding, nothing else changes.

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

What the authorization middleware and a search handler receive, with the tuples of the example (ada assists the calendar of Dr Grace, which holds appointment 42):

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' });

The limits of ListObjects. The server answers at most OPENFGA_LIST_OBJECTS_MAX_RESULTS objects (1000 by default) and stops at OPENFGA_LIST_OBJECTS_DEADLINE (3 seconds by default), and in both cases returns the objects found so far without an error. A search narrowed by a cut list would lose elements in silence, so OpenFgaAuthorizer refuses an answer as long as listObjectsLimit (default 1000: set it to the server's value) with ReverseExpansionLimitError, as the in-process authorizer does beyond its own limit. A list cut by the deadline cannot be told from a complete one by its answer: give the server a deadline above the slowest listing you expect, and watch its latency. When the lists are that long, narrow them first (a projection of the accessible ids, a coarser parent such as the clinic) rather than raising the limits.

A write is a call to a service. It does not join the transaction of the unit of work: an aggregate can commit without its tuple, or a tuple be written for an aggregate whose transaction rolled back. Write tuples after the commit, never from inside the transaction of the command, and decide what a request sees during the short window when the aggregate exists without its relationship:

// 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 });
  }
}

A handler of phase after-commit runs at most once: a crash between the commit and its run, or a server that does not answer, loses the tuple, and its failure is only reported. A tuple that must not be lost goes through the outbox: publish an integration event with the change, and write the tuple from its consumer, which is delivered at least once and retried until it succeeds. The store is idempotent, so a repeated delivery changes nothing.

Testing

The specs of this package run the two contract suites against an in-memory double of the tuple endpoints, and against a real server when DDD_TS_KIT_OPENFGA_URL is set: the integration job of the repository starts openfga/openfga v1.22.0, pinned by digest. A project holds its own adapters to the same suites, each test on a store of its own (createStore, then ensureAuthorizationModel). The tuples the store contract writes are about documents and groups (document:1#editor@group:eng, held by the server as group:eng#member, and document:1#parent@group:eng): the store it runs on holds a model that describes them.

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),
  },
);

Limits

  • No consistency token is carried: a check uses the default preference of the server (MINIMIZE_LATENCY), so a tuple just written may not be seen at once by every replica.
  • No conditional tuples (OpenFGA conditions): the schema of the kit has no caveats either.
  • The subject of an actor is <type>:<id>; an id holding a character OpenFGA refuses in an object id needs subjectOf.
  • explain of RelationBasedAuthorizer has no equivalent here: use the Expand API of OpenFGA for debugging, where a derived relation named after a permission bears the name relationNameOf gives it (docs_document_edit).

See also

版本与发布记录

@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 和提交记录判断。