@ddd-ts-kit/openfga 0.16.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.
- package/README.fr.md +358 -0
- package/README.md +258 -13
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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 {
|
|
21
|
-
import {
|
|
37
|
+
import { defineSchema, from, union } from '@ddd-ts-kit/auth/rebac';
|
|
38
|
+
import { authorizationModelOf } from '@ddd-ts-kit/openfga';
|
|
22
39
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
const
|
|
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
|
+

|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.16.
|
|
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.16.
|
|
34
|
-
"@ddd-ts-kit/kernel": "0.16.
|
|
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.16.
|
|
45
|
+
"@ddd-ts-kit/testing": "0.16.1",
|
|
46
46
|
"@openfga/sdk": "0.9.7"
|
|
47
47
|
}
|
|
48
48
|
}
|