@ddd-ts-kit/openfga 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.fr.md +358 -0
  2. package/README.md +258 -13
  3. package/package.json +4 -4
package/README.fr.md ADDED
@@ -0,0 +1,358 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:6f08f173a0b20f1f -->
3
+
4
+ # @ddd-ts-kit/openfga
5
+
6
+ Les relations de [`@ddd-ts-kit/auth`](../auth/README.fr.md) sur [OpenFGA](https://openfga.dev),
7
+ l'implémentation de Zanzibar de la CNCF : les ports `RelationTupleStore` et `Authorizer` servis
8
+ par un serveur OpenFGA, et son modèle d'autorisation généré à partir du schéma de relations que
9
+ le projet déclare déjà avec `defineSchema`. Une seule source de vérité : le schéma ; le modèle en
10
+ est dérivé et comparé à ce que détient le serveur.
11
+
12
+ ```bash
13
+ npm install @ddd-ts-kit/openfga @openfga/sdk
14
+ ```
15
+
16
+ `@openfga/sdk` (0.9.7, le client officiel) est une dépendance *peer* optionnelle, importée par le
17
+ seul point d'entrée `/sdk`. Le point d'entrée principal accepte tout client conforme à
18
+ `OpenFgaApi`, le sous-ensemble du client officiel qu'appellent les adaptateurs.
19
+
20
+ ![Les trois fonctions du package dans une application : les cas d'usage interrogent le port Authorizer, que OpenFgaAuthorizer implémente avec Check et ListObjects ; un adaptateur du contexte écrit les tuples après la validation par le port RelationTupleStore, que OpenFgaRelationTupleStore implémente avec Write et Read ; le schéma de relations est la seule source du modèle, qu'authorizationModelOf génère et que ensureAuthorizationModel ou modelDrift écrivent ou comparent sur le serveur](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/openfga-place-in-an-application.fr.svg)
21
+
22
+ Le *package* remplit trois fonctions, chacun auprès d'un magasin d'un serveur OpenFGA : il décide
23
+ (`OpenFgaAuthorizer`), il tient les tuples (`OpenFgaRelationTupleStore`) et il garde le modèle du
24
+ serveur égal au schéma (`authorizationModelOf`, `ensureAuthorizationModel`, `modelDrift`). Les
25
+ gestionnaires (handlers) ne l'importent jamais : ils dépendent des ports de `@ddd-ts-kit/auth`, et
26
+ la plateforme lie ces adaptateurs à ces ports, exactement comme elle lie `RelationBasedAuthorizer`
27
+ et un magasin de tuples dans votre propre base de données.
28
+
29
+ ## Le modèle, à partir du schéma
30
+
31
+ Le schéma est celui qu'évalue `RelationBasedAuthorizer` dans le processus : des types, les
32
+ relations qu'un tuple peut porter, et les permissions qui en dérivent. `authorizationModelOf` le
33
+ transforme en corps de `WriteAuthorizationModel` (schéma 1.1). Voici un schéma simplifié, inspiré
34
+ de celui de l'application d'exemple :
35
+
36
+ <!-- checked sample -->
37
+ ```ts
38
+ import { defineSchema, from, union } from '@ddd-ts-kit/auth/rebac';
39
+ import { authorizationModelOf } from '@ddd-ts-kit/openfga';
40
+
41
+ // Qui est quoi pour quel élément : un cabinet a des responsables, un agenda son titulaire et ses assistants.
42
+ export const relationshipSchema = defineSchema({
43
+ user: {},
44
+ practice: { relations: { manager: ['user'] }, permissions: { manage: union('manager') } },
45
+ calendar: {
46
+ relations: { holder: ['user'], assistant: ['user'], practice: ['practice'] },
47
+ permissions: { run: union('holder', 'assistant', from('practice', 'manage')) },
48
+ },
49
+ appointment: {
50
+ relations: { patient: ['user'], calendar: ['calendar'] },
51
+ permissions: { change: union('patient', from('calendar', 'run')) },
52
+ },
53
+ });
54
+
55
+ // { schema_version: '1.1', type_definitions: [user, practice, calendar, appointment] }
56
+ export const model = authorizationModelOf(relationshipSchema);
57
+ ```
58
+
59
+ Le modèle généré décide comme celui-ci, écrit dans le langage de modélisation d'OpenFGA (pour la
60
+ lecture seulement : le *package* écrit la forme JSON, et personne n'écrit ce texte à la main) :
61
+
62
+ ```text
63
+ model
64
+ schema 1.1
65
+
66
+ type user
67
+
68
+ type practice
69
+ relations
70
+ define manager: [user]
71
+ define manage: manager
72
+
73
+ type calendar
74
+ relations
75
+ define holder: [user]
76
+ define assistant: [user]
77
+ define practice: [practice]
78
+ define run: holder or assistant or manage from practice
79
+
80
+ type appointment
81
+ relations
82
+ define patient: [user]
83
+ define calendar: [calendar]
84
+ define change: patient or run from calendar
85
+ ```
86
+
87
+ | Schéma de `@ddd-ts-kit/auth/rebac` | OpenFGA |
88
+ |---|---|
89
+ | une relation et les sujets qu'elle énumère : `editor: ['user', 'group#member']` | une relation directement attribuable (`this`) avec `directly_related_user_types` |
90
+ | un nom dans une réécriture : `union('owner', 'editor')` | un *userset* calculé (computed userset) |
91
+ | `union(...)`, `intersection(...)` | `union`, `intersection` |
92
+ | `exclusion(base, subtract)` | `difference` |
93
+ | `from('parent', 'edit')` | un *tuple to userset* |
94
+
95
+ `ensureAuthorizationModel(client, schema)` écrit le modèle généré quand le dernier modèle du
96
+ magasin en diffère, et renvoie l'identifiant en vigueur et les différences (OpenFGA garde chaque
97
+ modèle ; les vérifications utilisent le dernier, sauf si un client en épingle un). Exécutez-le là
98
+ où l'application est déployée ou démarre :
99
+
100
+ <!-- checked sample -->
101
+ ```ts
102
+ import type { Schema } from '@ddd-ts-kit/auth/rebac';
103
+ import { ensureAuthorizationModel } from '@ddd-ts-kit/openfga';
104
+ import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';
105
+
106
+ declare const relationshipSchema: Schema;
107
+ declare const config: { readonly openfga: { readonly url: string; readonly storeId: string } };
108
+
109
+ const client = openFgaClient({ apiUrl: config.openfga.url, storeId: config.openfga.storeId });
110
+
111
+ // La première exécution écrit le modèle ; les suivantes n'écrivent rien tant que le schéma ne change pas.
112
+ const { modelId, changes } = await ensureAuthorizationModel(client, relationshipSchema);
113
+
114
+ console.log(`authorization model ${modelId}`, changes);
115
+ ```
116
+
117
+ `modelDrift(authorizationModelOf(schema), served)` ne fait que comparer : une ligne par type ou
118
+ par `type#relation` manquant, en trop ou modifié, et rien quand les deux décident de la même
119
+ façon. Une étape du *pipeline* lit le dernier modèle du magasin et échoue sur la moindre ligne,
120
+ si bien que le modèle n'est jamais modifié à la main à côté du schéma :
121
+
122
+ <!-- checked sample -->
123
+ ```ts
124
+ import type { Schema } from '@ddd-ts-kit/auth/rebac';
125
+ import { authorizationModelOf, modelDrift, type OpenFgaApi } from '@ddd-ts-kit/openfga';
126
+
127
+ declare const client: OpenFgaApi;
128
+ declare const relationshipSchema: Schema;
129
+
130
+ const { authorization_model: served } = await client.readLatestAuthorizationModel();
131
+ const drift = modelDrift(authorizationModelOf(relationshipSchema), served);
132
+
133
+ if (drift.length > 0) {
134
+ console.error(drift.join('\n'));
135
+ process.exitCode = 1;
136
+ }
137
+ ```
138
+
139
+ Exécuté contre un magasin qui détient encore le modèle d'un schéma sans assistants, il affiche :
140
+
141
+ ```text
142
+ calendar#assistant is in the schema, not in the store: this [user]
143
+ calendar#run differs: the schema says union(holder, assistant, from(practice, manage)) [], the store union(holder, from(practice, manage)) []
144
+ ```
145
+
146
+ ## Le magasin et l'*authorizer*
147
+
148
+ Dans la racine de composition, un seul client lié à un seul magasin sert les deux adaptateurs. La
149
+ correspondance (`mapping`) dit, par type de ressource, quelle relation du modèle représente
150
+ chaque permission du kit, comme pour `RelationBasedAuthorizer` :
151
+
152
+ <!-- checked sample -->
153
+ ```ts
154
+ import { definePermissions } from '@ddd-ts-kit/auth';
155
+ import type { PermissionMapping, Schema } from '@ddd-ts-kit/auth/rebac';
156
+ import { OpenFgaAuthorizer, OpenFgaRelationTupleStore } from '@ddd-ts-kit/openfga';
157
+ import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';
158
+
159
+ declare const relationshipSchema: Schema;
160
+ declare const config: { readonly openfga: { readonly url: string; readonly storeId: string } };
161
+
162
+ export const schedulingPermissions = definePermissions('scheduling', {
163
+ 'appointment.cancel': { description: 'Cancel an appointment' },
164
+ });
165
+
166
+ // Annuler un rendez-vous, c'est détenir la relation `change` sur lui.
167
+ const mapping: PermissionMapping = {
168
+ appointment: { [schedulingPermissions['appointment.cancel'].id]: 'change' },
169
+ };
170
+
171
+ const client = openFgaClient({ apiUrl: config.openfga.url, storeId: config.openfga.storeId });
172
+
173
+ // Les deux mêmes ports que l'authorizer dans le processus et qu'un magasin de tuples dans votre base.
174
+ export const tuples = new OpenFgaRelationTupleStore(client, relationshipSchema);
175
+ export const authorizer = new OpenFgaAuthorizer({ client, mapping, listObjectsLimit: 1000 });
176
+ ```
177
+
178
+ - `OpenFgaRelationTupleStore` passe `relationTupleStoreContract`, comme le magasin PostgreSQL :
179
+ écrire deux fois un tuple et supprimer un tuple absent ne changent rien (les options de conflit
180
+ d'OpenFGA 1.10 et des versions suivantes), les écritures et les suppressions vont par 100, les
181
+ lectures suivent chaque page. Une lecture par sujet fait une lecture par relation du schéma qui
182
+ accepte la sorte du sujet, puisqu'OpenFGA lit par utilisateur au sein d'un seul type d'objet.
183
+ - `OpenFgaAuthorizer` passe `authorizerContract`, avec la correspondance et les codes de refus de
184
+ `RelationBasedAuthorizer` (`ANONYMOUS`, `RESOURCE_REQUIRED`, `UNMAPPED_PERMISSION`,
185
+ `NO_RELATION`) : `Check` décide, `ListObjects` donne le périmètre d'une recherche
186
+ (`{ kind: 'ids' }`). Un projet passe de l'un à l'autre par la liaison, rien d'autre ne change.
187
+
188
+ ![À gauche, check : un acteur anonyme est refusé avec ANONYMOUS, une vérification sans ressource avec RESOURCE_REQUIRED, une permission sans relation associée avec UNMAPPED_PERMISSION, tous avant le moindre appel ; sinon Check sur le serveur renvoie allowed ou NO_RELATION. À droite, listAccessible : un acteur anonyme ou une permission sans relation associée donne le périmètre none ; sinon ListObjects, refusé avec ReverseExpansionLimitError quand la réponse atteint listObjectsLimit, et sinon le périmètre ids](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/openfga-authorizer-decisions.fr.svg)
189
+
190
+ Ce que reçoivent le *middleware* d'autorisation et un gestionnaire de recherche, avec les tuples
191
+ de l'exemple (`ada` assiste l'agenda du Dr Grace, qui contient le rendez-vous 42) :
192
+
193
+ <!-- checked sample -->
194
+ ```ts
195
+ import type { Permission } from '@ddd-ts-kit/auth';
196
+ import type { Actor } from '@ddd-ts-kit/kernel';
197
+ import type { OpenFgaAuthorizer, OpenFgaRelationTupleStore } from '@ddd-ts-kit/openfga';
198
+
199
+ declare const tuples: OpenFgaRelationTupleStore;
200
+ declare const authorizer: OpenFgaAuthorizer;
201
+ declare const cancel: Permission;
202
+
203
+ await tuples.write([
204
+ { object: 'calendar:dr-grace', relation: 'assistant', subject: 'user:ada' },
205
+ { object: 'appointment:42', relation: 'calendar', subject: 'calendar:dr-grace' },
206
+ ]);
207
+
208
+ const ada: Actor = { id: 'ada', type: 'user', roles: [], attributes: {} };
209
+
210
+ // Check user:ada change appointment:42 : { allowed: true }
211
+ export const decision = await authorizer.check({
212
+ actor: ada,
213
+ permission: cancel,
214
+ resource: { type: 'appointment', id: '42' },
215
+ });
216
+
217
+ // ListObjects user:ada change appointment : { kind: 'ids', ids: ['42'] }, les identifiants que la recherche peut lire
218
+ export const scope = await authorizer.listAccessible({ actor: ada, permission: cancel, resourceType: 'appointment' });
219
+ ```
220
+
221
+ **Les limites de `ListObjects`.** Le serveur renvoie au plus `OPENFGA_LIST_OBJECTS_MAX_RESULTS`
222
+ objets (1000 par défaut) et s'arrête à `OPENFGA_LIST_OBJECTS_DEADLINE` (3 secondes par défaut),
223
+ et dans les deux cas renvoie les objets trouvés jusque-là **sans erreur**. Une recherche
224
+ restreinte par une liste coupée perdrait des éléments en silence : `OpenFgaAuthorizer` refuse
225
+ donc une réponse aussi longue que `listObjectsLimit` (1000 par défaut : réglez-le sur la valeur du
226
+ serveur) avec `ReverseExpansionLimitError`, comme le fait l'*authorizer* dans le processus au-delà
227
+ de sa propre limite. Une liste coupée par le délai ne se distingue pas d'une liste complète par
228
+ sa réponse : donnez au serveur un délai supérieur au listage le plus lent que vous attendez, et
229
+ surveillez sa latence. Quand les listes sont aussi longues, restreignez-les d'abord (une
230
+ projection des identifiants accessibles, un parent plus large comme la clinique) plutôt que de
231
+ relever les limites.
232
+
233
+ **Une écriture est un appel à un service.** Elle ne rejoint pas la transaction de l'unité de
234
+ travail : un agrégat peut être validé sans son tuple, ou un tuple être écrit pour un agrégat dont
235
+ la transaction a été annulée. Écrivez les tuples après la validation (commit), jamais depuis la
236
+ transaction de la commande, et décidez de ce que voit une requête pendant la courte fenêtre
237
+ où l'agrégat existe sans sa relation :
238
+
239
+ ```ts
240
+ // À éviter : un gestionnaire de la phase par défaut (before-commit) s'exécute dans la transaction,
241
+ // si bien qu'une annulation après lui laisse sur le serveur un tuple pour un rendez-vous qui n'existe pas.
242
+ @DomainEventHandler(AppointmentBooked, { name: AppointmentBooked.eventName })
243
+ export class RelationshipsOnAppointmentBooked { /* ... */ }
244
+ ```
245
+
246
+ <!-- checked sample -->
247
+ ```ts
248
+ import type { DomainEvent } from '@ddd-ts-kit/kernel';
249
+ import { DomainEventHandler } from '@ddd-ts-kit/kernel/declarations';
250
+
251
+ export class AppointmentBooked implements DomainEvent {
252
+ static readonly eventName = 'scheduling.appointment.booked';
253
+
254
+ readonly name = AppointmentBooked.eventName;
255
+
256
+ constructor(
257
+ readonly aggregateId: string,
258
+ readonly occurredAt: Date,
259
+ readonly booking: { readonly calendarId: string; readonly patientId: string },
260
+ ) {}
261
+ }
262
+
263
+ // Le port du contexte, dans ses propres mots ; son adaptateur écrit les tuples dans OpenFgaRelationTupleStore.
264
+ export abstract class SchedulingRelationships {
265
+ abstract recordAppointment(appointment: { appointmentId: string; calendarId: string; patientId: string }): Promise<void>;
266
+ }
267
+
268
+ // Écrire : après la validation, jamais quand la transaction a été annulée.
269
+ @DomainEventHandler(AppointmentBooked, { name: AppointmentBooked.eventName, phase: 'after-commit' })
270
+ export class RelationshipsOnAppointmentBooked implements DomainEventHandler<AppointmentBooked> {
271
+ constructor(private readonly relationships: SchedulingRelationships) {}
272
+
273
+ async handle(event: AppointmentBooked): Promise<void> {
274
+ await this.relationships.recordAppointment({ appointmentId: event.aggregateId, ...event.booking });
275
+ }
276
+ }
277
+ ```
278
+
279
+ Un gestionnaire de phase `after-commit` s'exécute **au plus une fois** : un arrêt brutal entre la
280
+ validation et son exécution, ou un serveur qui ne répond pas, perd le tuple, et son échec est
281
+ seulement signalé. Un tuple qui ne doit pas être perdu passe par l'outbox : publiez un événement
282
+ d'intégration avec la modification, et écrivez le tuple depuis son consommateur, à qui
283
+ l'événement est livré au moins une fois et qui est relancé jusqu'à ce qu'il réussisse. Le magasin
284
+ est idempotent, si bien qu'une livraison répétée ne change rien.
285
+
286
+ ## Tester
287
+
288
+ Les *specs* de ce *package* exécutent les deux suites de contrat contre un double en mémoire des
289
+ points d'accès des tuples, et contre un vrai serveur quand `DDD_TS_KIT_OPENFGA_URL` est défini :
290
+ le *job* d'intégration du dépôt démarre `openfga/openfga` v1.22.0, épinglé par *digest*. Un projet
291
+ soumet ses propres adaptateurs aux mêmes suites, chaque test sur un magasin à lui (`createStore`,
292
+ puis `ensureAuthorizationModel`). Les tuples qu'écrit le contrat du magasin portent sur des
293
+ documents et des groupes (`document:1#editor@group:eng#member`, `document:1#parent@group:eng`) :
294
+ le magasin sur lequel il s'exécute détient un modèle qui les décrit.
295
+
296
+ <!-- checked sample -->
297
+ ```ts
298
+ import { OpenFgaClient } from '@openfga/sdk';
299
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
300
+ import { defineSchema } from '@ddd-ts-kit/auth/rebac';
301
+ import { relationTupleStoreContract } from '@ddd-ts-kit/auth/testing';
302
+ import { ensureAuthorizationModel, OpenFgaRelationTupleStore, type OpenFgaApi } from '@ddd-ts-kit/openfga';
303
+ import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';
304
+
305
+ const OPENFGA_URL = process.env['OPENFGA_TEST_URL'] ?? '';
306
+
307
+ // Les types et les relations des tuples qu'écrit le contrat.
308
+ const contractSchema = defineSchema({
309
+ user: {},
310
+ group: { relations: { member: ['user', 'group#member'] } },
311
+ document: {
312
+ relations: { editor: ['user', 'group#member'], viewer: ['user', 'group#member'], parent: ['group'] },
313
+ },
314
+ });
315
+
316
+ let stores = 0;
317
+
318
+ // Un magasin à lui par test, qui détient le modèle du schéma : aucun test ne voit les tuples d'un autre.
319
+ async function freshStore(): Promise<OpenFgaApi> {
320
+ const admin = new OpenFgaClient({ apiUrl: OPENFGA_URL });
321
+ const { id: storeId } = await admin.createStore({ name: `contract-${process.pid}-${(stores += 1)}` });
322
+ const client = openFgaClient({ apiUrl: OPENFGA_URL, storeId });
323
+
324
+ await ensureAuthorizationModel(client, contractSchema);
325
+
326
+ return client;
327
+ }
328
+
329
+ // Ignoré sans serveur, comme les specs du package.
330
+ relationTupleStoreContract(
331
+ { describe: describe.runIf(OPENFGA_URL !== ''), it, expect, beforeEach, afterEach },
332
+ {
333
+ name: 'OpenFgaRelationTupleStore on an OpenFGA server',
334
+ create: async () => new OpenFgaRelationTupleStore(await freshStore(), contractSchema),
335
+ },
336
+ );
337
+ ```
338
+
339
+ ## Limites
340
+
341
+ - Aucun jeton de cohérence n'est transmis : une vérification utilise la préférence par défaut du
342
+ serveur (`MINIMIZE_LATENCY`), si bien qu'un tuple tout juste écrit peut ne pas être vu tout de
343
+ suite par chaque réplique.
344
+ - Pas de tuples conditionnels (les conditions d'OpenFGA) : le schéma du kit n'a pas de *caveats*
345
+ non plus.
346
+ - Le sujet d'un acteur est `<type>:<id>` ; un identifiant qui contient un caractère qu'OpenFGA
347
+ refuse dans un identifiant d'objet demande `subjectOf`.
348
+ - `expand` de `RelationBasedAuthorizer` n'a pas d'équivalent ici : utilisez l'API `Expand`
349
+ d'OpenFGA pour le débogage.
350
+
351
+ ## Voir aussi
352
+
353
+ - [Moteurs d'autorisation](../../docs/tools/authorization-engines.fr.md) : le magasin de tuples
354
+ PostgreSQL ou OpenFGA, et quand choisir lequel
355
+ - [Choisir le contrôle d'accès](../../docs/guides/choosing-access-control.fr.md)
356
+ - [Événements du domaine et événements d'intégration](../../docs/concepts/domain-and-integration-events.fr.md) :
357
+ les phases d'un gestionnaire d'événement du domaine, et ce qui atteint un autre processus au
358
+ moins une fois
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/openfga
2
4
 
3
5
  The relationships of [`@ddd-ts-kit/auth`](../auth/README.md) on [OpenFGA](https://openfga.dev),
@@ -14,15 +16,71 @@ npm install @ddd-ts-kit/openfga @openfga/sdk
14
16
  only. The main entry takes any client that fits `OpenFgaApi`, the subset of the official
15
17
  client the adapters call.
16
18
 
19
+ ![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](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/openfga-place-in-an-application.svg)
20
+
21
+ The package does three jobs, each against one store of an OpenFGA server: it decides
22
+ (`OpenFgaAuthorizer`), it holds the tuples (`OpenFgaRelationTupleStore`) and it keeps the model
23
+ of the server equal to the schema (`authorizationModelOf`, `ensureAuthorizationModel`,
24
+ `modelDrift`). Handlers never import it: they depend on the ports of `@ddd-ts-kit/auth`, and the
25
+ platform binds these adapters to them, exactly as it binds `RelationBasedAuthorizer` and a tuple
26
+ store in your own database.
27
+
17
28
  ## The model, from the schema
18
29
 
30
+ The schema is the one `RelationBasedAuthorizer` evaluates in process: types, the relations a
31
+ tuple can hold, and the permissions derived from them. `authorizationModelOf` turns it into the
32
+ body of `WriteAuthorizationModel` (schema 1.1). Here is a simplified schema, in the spirit of
33
+ the one of the example application:
34
+
35
+ <!-- checked sample -->
19
36
  ```ts
20
- import { authorizationModelOf, ensureAuthorizationModel, modelDrift } from '@ddd-ts-kit/openfga';
21
- import { relationshipSchema } from '@/platform/auth/relationship-schema';
37
+ import { defineSchema, from, union } from '@ddd-ts-kit/auth/rebac';
38
+ import { authorizationModelOf } from '@ddd-ts-kit/openfga';
22
39
 
23
- authorizationModelOf(relationshipSchema); // the body of WriteAuthorizationModel, schema 1.1
40
+ // Who is what to which element: a practice has managers, a calendar its holder and assistants.
41
+ export const relationshipSchema = defineSchema({
42
+ user: {},
43
+ practice: { relations: { manager: ['user'] }, permissions: { manage: union('manager') } },
44
+ calendar: {
45
+ relations: { holder: ['user'], assistant: ['user'], practice: ['practice'] },
46
+ permissions: { run: union('holder', 'assistant', from('practice', 'manage')) },
47
+ },
48
+ appointment: {
49
+ relations: { patient: ['user'], calendar: ['calendar'] },
50
+ permissions: { change: union('patient', from('calendar', 'run')) },
51
+ },
52
+ });
24
53
 
25
- const { modelId, changes } = await ensureAuthorizationModel(client, relationshipSchema);
54
+ // { schema_version: '1.1', type_definitions: [user, practice, calendar, appointment] }
55
+ export const model = authorizationModelOf(relationshipSchema);
56
+ ```
57
+
58
+ The generated model decides as this one, written in the modelling language of OpenFGA (for
59
+ reading only: the package writes the JSON form, and nobody writes this text by hand):
60
+
61
+ ```text
62
+ model
63
+ schema 1.1
64
+
65
+ type user
66
+
67
+ type practice
68
+ relations
69
+ define manager: [user]
70
+ define manage: manager
71
+
72
+ type calendar
73
+ relations
74
+ define holder: [user]
75
+ define assistant: [user]
76
+ define practice: [practice]
77
+ define run: holder or assistant or manage from practice
78
+
79
+ type appointment
80
+ relations
81
+ define patient: [user]
82
+ define calendar: [calendar]
83
+ define change: patient or run from calendar
26
84
  ```
27
85
 
28
86
  | Schema of `@ddd-ts-kit/auth/rebac` | OpenFGA |
@@ -35,22 +93,84 @@ const { modelId, changes } = await ensureAuthorizationModel(client, relationship
35
93
 
36
94
  `ensureAuthorizationModel(client, schema)` writes the generated model when the latest one of the
37
95
  store differs, and answers the id in force and the differences (OpenFGA keeps every model; checks
38
- use the latest unless a client pins one). Run it where the application is deployed or starts.
96
+ use the latest unless a client pins one). Run it where the application is deployed or starts:
97
+
98
+ <!-- checked sample -->
99
+ ```ts
100
+ import type { Schema } from '@ddd-ts-kit/auth/rebac';
101
+ import { ensureAuthorizationModel } from '@ddd-ts-kit/openfga';
102
+ import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';
103
+
104
+ declare const relationshipSchema: Schema;
105
+ declare const config: { readonly openfga: { readonly url: string; readonly storeId: string } };
106
+
107
+ const client = openFgaClient({ apiUrl: config.openfga.url, storeId: config.openfga.storeId });
108
+
109
+ // The first run writes the model; the next ones write nothing until the schema changes.
110
+ const { modelId, changes } = await ensureAuthorizationModel(client, relationshipSchema);
111
+
112
+ console.log(`authorization model ${modelId}`, changes);
113
+ ```
114
+
39
115
  `modelDrift(authorizationModelOf(schema), served)` only compares: one line per type or
40
116
  `type#relation` missing, extra or changed, empty when the two decide alike. A pipeline step reads
41
117
  the latest model of the store and fails on any line, so the model is never edited by hand next to
42
- the schema.
118
+ the schema:
119
+
120
+ <!-- checked sample -->
121
+ ```ts
122
+ import type { Schema } from '@ddd-ts-kit/auth/rebac';
123
+ import { authorizationModelOf, modelDrift, type OpenFgaApi } from '@ddd-ts-kit/openfga';
124
+
125
+ declare const client: OpenFgaApi;
126
+ declare const relationshipSchema: Schema;
127
+
128
+ const { authorization_model: served } = await client.readLatestAuthorizationModel();
129
+ const drift = modelDrift(authorizationModelOf(relationshipSchema), served);
130
+
131
+ if (drift.length > 0) {
132
+ console.error(drift.join('\n'));
133
+ process.exitCode = 1;
134
+ }
135
+ ```
136
+
137
+ Run against a store that still holds the model of a schema without assistants, it prints:
138
+
139
+ ```text
140
+ calendar#assistant is in the schema, not in the store: this [user]
141
+ calendar#run differs: the schema says union(holder, assistant, from(practice, manage)) [], the store union(holder, from(practice, manage)) []
142
+ ```
43
143
 
44
144
  ## The store and the authorizer
45
145
 
146
+ In the composition root, one client bound to one store serves both adapters. The mapping says,
147
+ per resource type, which relation of the model each permission of the kit stands for, as for
148
+ `RelationBasedAuthorizer`:
149
+
150
+ <!-- checked sample -->
46
151
  ```ts
152
+ import { definePermissions } from '@ddd-ts-kit/auth';
153
+ import type { PermissionMapping, Schema } from '@ddd-ts-kit/auth/rebac';
47
154
  import { OpenFgaAuthorizer, OpenFgaRelationTupleStore } from '@ddd-ts-kit/openfga';
48
155
  import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';
49
156
 
157
+ declare const relationshipSchema: Schema;
158
+ declare const config: { readonly openfga: { readonly url: string; readonly storeId: string } };
159
+
160
+ export const schedulingPermissions = definePermissions('scheduling', {
161
+ 'appointment.cancel': { description: 'Cancel an appointment' },
162
+ });
163
+
164
+ // To cancel an appointment is to hold the relation `change` on it.
165
+ const mapping: PermissionMapping = {
166
+ appointment: { [schedulingPermissions['appointment.cancel'].id]: 'change' },
167
+ };
168
+
50
169
  const client = openFgaClient({ apiUrl: config.openfga.url, storeId: config.openfga.storeId });
51
170
 
52
- const tuples = new OpenFgaRelationTupleStore(client, relationshipSchema);
53
- const authorizer = new OpenFgaAuthorizer({ client, mapping: permissionMapping() });
171
+ // The same two ports as the in-process authorizer and a tuple store in your database.
172
+ export const tuples = new OpenFgaRelationTupleStore(client, relationshipSchema);
173
+ export const authorizer = new OpenFgaAuthorizer({ client, mapping, listObjectsLimit: 1000 });
54
174
  ```
55
175
 
56
176
  - `OpenFgaRelationTupleStore` passes `relationTupleStoreContract`, as the PostgreSQL store does:
@@ -63,6 +183,39 @@ const authorizer = new OpenFgaAuthorizer({ client, mapping: permissionMapping()
63
183
  `NO_RELATION`): `Check` decides, `ListObjects` gives the scope of a search (`{ kind: 'ids' }`).
64
184
  A project moves between the two by binding, nothing else changes.
65
185
 
186
+ ![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](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/openfga-authorizer-decisions.svg)
187
+
188
+ What the authorization middleware and a search handler receive, with the tuples of the
189
+ example (`ada` assists the calendar of Dr Grace, which holds appointment 42):
190
+
191
+ <!-- checked sample -->
192
+ ```ts
193
+ import type { Permission } from '@ddd-ts-kit/auth';
194
+ import type { Actor } from '@ddd-ts-kit/kernel';
195
+ import type { OpenFgaAuthorizer, OpenFgaRelationTupleStore } from '@ddd-ts-kit/openfga';
196
+
197
+ declare const tuples: OpenFgaRelationTupleStore;
198
+ declare const authorizer: OpenFgaAuthorizer;
199
+ declare const cancel: Permission;
200
+
201
+ await tuples.write([
202
+ { object: 'calendar:dr-grace', relation: 'assistant', subject: 'user:ada' },
203
+ { object: 'appointment:42', relation: 'calendar', subject: 'calendar:dr-grace' },
204
+ ]);
205
+
206
+ const ada: Actor = { id: 'ada', type: 'user', roles: [], attributes: {} };
207
+
208
+ // Check user:ada change appointment:42: { allowed: true }
209
+ export const decision = await authorizer.check({
210
+ actor: ada,
211
+ permission: cancel,
212
+ resource: { type: 'appointment', id: '42' },
213
+ });
214
+
215
+ // ListObjects user:ada change appointment: { kind: 'ids', ids: ['42'] }, the ids the search may read
216
+ export const scope = await authorizer.listAccessible({ actor: ada, permission: cancel, resourceType: 'appointment' });
217
+ ```
218
+
66
219
  **The limits of `ListObjects`.** The server answers at most `OPENFGA_LIST_OBJECTS_MAX_RESULTS`
67
220
  objects (1000 by default) and stops at `OPENFGA_LIST_OBJECTS_DEADLINE` (3 seconds by default),
68
221
  and in both cases returns the objects found so far **without an error**. A search narrowed by a
@@ -76,10 +229,55 @@ rather than raising the limits.
76
229
 
77
230
  **A write is a call to a service.** It does not join the transaction of the unit of work: an
78
231
  aggregate can commit without its tuple, or a tuple be written for an aggregate whose transaction
79
- rolled back. Write tuples after the commit, from a domain event handler of phase `after-commit`
80
- or from the consumer of an integration event (both retried, and the store is idempotent), never
81
- from inside the transaction of the command; and decide what a request sees during the short
82
- window when the aggregate exists without its relationship.
232
+ rolled back. Write tuples after the commit, never from inside the transaction of the command,
233
+ and decide what a request sees during the short window when the aggregate exists without its
234
+ relationship:
235
+
236
+ ```ts
237
+ // Instead of: a handler of the default phase (before-commit) runs inside the transaction,
238
+ // so a rollback after it leaves on the server a tuple for an appointment that does not exist.
239
+ @DomainEventHandler(AppointmentBooked, { name: AppointmentBooked.eventName })
240
+ export class RelationshipsOnAppointmentBooked { /* ... */ }
241
+ ```
242
+
243
+ <!-- checked sample -->
244
+ ```ts
245
+ import type { DomainEvent } from '@ddd-ts-kit/kernel';
246
+ import { DomainEventHandler } from '@ddd-ts-kit/kernel/declarations';
247
+
248
+ export class AppointmentBooked implements DomainEvent {
249
+ static readonly eventName = 'scheduling.appointment.booked';
250
+
251
+ readonly name = AppointmentBooked.eventName;
252
+
253
+ constructor(
254
+ readonly aggregateId: string,
255
+ readonly occurredAt: Date,
256
+ readonly booking: { readonly calendarId: string; readonly patientId: string },
257
+ ) {}
258
+ }
259
+
260
+ // The port of the context, in its own words; its adapter writes the tuples to OpenFgaRelationTupleStore.
261
+ export abstract class SchedulingRelationships {
262
+ abstract recordAppointment(appointment: { appointmentId: string; calendarId: string; patientId: string }): Promise<void>;
263
+ }
264
+
265
+ // Write: after the commit, never when the transaction rolled back.
266
+ @DomainEventHandler(AppointmentBooked, { name: AppointmentBooked.eventName, phase: 'after-commit' })
267
+ export class RelationshipsOnAppointmentBooked implements DomainEventHandler<AppointmentBooked> {
268
+ constructor(private readonly relationships: SchedulingRelationships) {}
269
+
270
+ async handle(event: AppointmentBooked): Promise<void> {
271
+ await this.relationships.recordAppointment({ appointmentId: event.aggregateId, ...event.booking });
272
+ }
273
+ }
274
+ ```
275
+
276
+ A handler of phase `after-commit` runs **at most once**: a crash between the commit and its run,
277
+ or a server that does not answer, loses the tuple, and its failure is only reported. A tuple
278
+ that must not be lost goes through the outbox: publish an integration event with the change, and
279
+ write the tuple from its consumer, which is delivered at least once and retried until it
280
+ succeeds. The store is idempotent, so a repeated delivery changes nothing.
83
281
 
84
282
  ## Testing
85
283
 
@@ -87,7 +285,52 @@ The specs of this package run the two contract suites against an in-memory doubl
87
285
  endpoints, and against a real server when `DDD_TS_KIT_OPENFGA_URL` is set: the integration job
88
286
  of the repository starts `openfga/openfga` v1.22.0, pinned by digest. A project holds its own
89
287
  adapters to the same suites, each test on a store of its own (`createStore`, then
90
- `ensureAuthorizationModel`).
288
+ `ensureAuthorizationModel`). The tuples the store contract writes are about documents and groups
289
+ (`document:1#editor@group:eng#member`, `document:1#parent@group:eng`): the store it runs on holds
290
+ a model that describes them.
291
+
292
+ <!-- checked sample -->
293
+ ```ts
294
+ import { OpenFgaClient } from '@openfga/sdk';
295
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
296
+ import { defineSchema } from '@ddd-ts-kit/auth/rebac';
297
+ import { relationTupleStoreContract } from '@ddd-ts-kit/auth/testing';
298
+ import { ensureAuthorizationModel, OpenFgaRelationTupleStore, type OpenFgaApi } from '@ddd-ts-kit/openfga';
299
+ import { openFgaClient } from '@ddd-ts-kit/openfga/sdk';
300
+
301
+ const OPENFGA_URL = process.env['OPENFGA_TEST_URL'] ?? '';
302
+
303
+ // The types and relations of the tuples the contract writes.
304
+ const contractSchema = defineSchema({
305
+ user: {},
306
+ group: { relations: { member: ['user', 'group#member'] } },
307
+ document: {
308
+ relations: { editor: ['user', 'group#member'], viewer: ['user', 'group#member'], parent: ['group'] },
309
+ },
310
+ });
311
+
312
+ let stores = 0;
313
+
314
+ // A store of its own per test, holding the model of the schema: no test sees the tuples of another.
315
+ async function freshStore(): Promise<OpenFgaApi> {
316
+ const admin = new OpenFgaClient({ apiUrl: OPENFGA_URL });
317
+ const { id: storeId } = await admin.createStore({ name: `contract-${process.pid}-${(stores += 1)}` });
318
+ const client = openFgaClient({ apiUrl: OPENFGA_URL, storeId });
319
+
320
+ await ensureAuthorizationModel(client, contractSchema);
321
+
322
+ return client;
323
+ }
324
+
325
+ // Skipped without a server, as the specs of the package are.
326
+ relationTupleStoreContract(
327
+ { describe: describe.runIf(OPENFGA_URL !== ''), it, expect, beforeEach, afterEach },
328
+ {
329
+ name: 'OpenFgaRelationTupleStore on an OpenFGA server',
330
+ create: async () => new OpenFgaRelationTupleStore(await freshStore(), contractSchema),
331
+ },
332
+ );
333
+ ```
91
334
 
92
335
  ## Limits
93
336
 
@@ -104,3 +347,5 @@ adapters to the same suites, each test on a store of its own (`createStore`, the
104
347
  - [Authorization engines](../../docs/tools/authorization-engines.md): the PostgreSQL tuple store
105
348
  or OpenFGA, and when to choose which
106
349
  - [Choosing access control](../../docs/guides/choosing-access-control.md)
350
+ - [Domain and integration events](../../docs/concepts/domain-and-integration-events.md): the
351
+ phases of a domain event handler, and what reaches another process at least once
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddd-ts-kit/openfga",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "The relation tuple store and the authorizer of @ddd-ts-kit/auth on OpenFGA, with the authorization model generated from the relationship schema.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,8 +30,8 @@
30
30
  "dist"
31
31
  ],
32
32
  "dependencies": {
33
- "@ddd-ts-kit/auth": "0.15.0",
34
- "@ddd-ts-kit/kernel": "0.15.0"
33
+ "@ddd-ts-kit/auth": "0.16.1",
34
+ "@ddd-ts-kit/kernel": "0.16.1"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@openfga/sdk": "^0.9.7"
@@ -42,7 +42,7 @@
42
42
  }
43
43
  },
44
44
  "devDependencies": {
45
- "@ddd-ts-kit/testing": "0.15.0",
45
+ "@ddd-ts-kit/testing": "0.16.1",
46
46
  "@openfga/sdk": "0.9.7"
47
47
  }
48
48
  }